diff options
| author | Armand Philippot <git@armandphilippot.com> | 2022-05-24 19:35:12 +0200 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2022-05-24 19:35:12 +0200 |
| commit | c85ab5ad43ccf52881ee224672c41ec30021cf48 (patch) | |
| tree | 8058808d9bfca19383f120c46b34d99ff2f89f63 /src/components/organisms/widgets | |
| parent | 52404177c07a2aab7fc894362fb3060dff2431a0 (diff) | |
| parent | 11b9de44a4b2f305a6a484187805e429b2767118 (diff) | |
refactor: use storybook and atomic design (#16)
BREAKING CHANGE: rewrite most of the Typescript types, so the content format (the meta in particular) needs to be updated.
Diffstat (limited to 'src/components/organisms/widgets')
20 files changed, 1278 insertions, 0 deletions
diff --git a/src/components/organisms/widgets/image-widget.module.scss b/src/components/organisms/widgets/image-widget.module.scss new file mode 100644 index 0000000..0d69441 --- /dev/null +++ b/src/components/organisms/widgets/image-widget.module.scss @@ -0,0 +1,47 @@ +@use "@styles/abstracts/functions" as fun; + +.figure { + --scale-up: 1.02; + --scale-down: 0.98; + + margin: 0; + padding: fun.convert-px(5); + border: fun.convert-px(1) solid var(--color-border); +} + +.txt { + padding: var(--spacing-sm); +} + +.widget { + &--left { + .figure { + margin-right: auto; + } + + .txt { + text-align: left; + } + } + + &--center { + .figure { + margin-left: auto; + margin-right: auto; + } + + .txt { + text-align: center; + } + } + + &--right { + .figure { + margin-left: auto; + } + + .txt { + text-align: right; + } + } +} diff --git a/src/components/organisms/widgets/image-widget.stories.tsx b/src/components/organisms/widgets/image-widget.stories.tsx new file mode 100644 index 0000000..2271c03 --- /dev/null +++ b/src/components/organisms/widgets/image-widget.stories.tsx @@ -0,0 +1,181 @@ +import { ComponentMeta, ComponentStory } from '@storybook/react'; +import ImageWidget from './image-widget'; + +/** + * ImageWidget - Storybook Meta + */ +export default { + title: 'Organisms/Widgets/Image', + component: ImageWidget, + args: { + alignment: 'left', + }, + argTypes: { + alignment: { + control: { + type: 'select', + }, + description: 'The content alignment.', + options: ['left', 'center', 'right'], + table: { + category: 'Options', + defaultValue: { summary: 'left' }, + }, + type: { + name: 'string', + required: false, + }, + }, + className: { + control: { + type: 'text', + }, + description: 'Set additional classnames to the widget wrapper.', + table: { + category: 'Styles', + }, + type: { + name: 'string', + required: false, + }, + }, + description: { + control: { + type: 'text', + }, + description: 'Add a caption image.', + table: { + category: 'Options', + }, + type: { + name: 'string', + required: false, + }, + }, + expanded: { + control: { + type: 'boolean', + }, + description: 'The state of the widget.', + type: { + name: 'boolean', + required: true, + }, + }, + image: { + description: 'An image object.', + type: { + name: 'object', + required: true, + value: {}, + }, + }, + imageClassName: { + control: { + type: 'text', + }, + description: 'Set additional classnames to the image wrapper.', + table: { + category: 'Styles', + }, + type: { + name: 'string', + required: false, + }, + }, + level: { + control: { + type: 'number', + min: 1, + max: 6, + }, + description: 'The widget title level (hn).', + type: { + name: 'number', + required: true, + }, + }, + title: { + control: { + type: 'text', + }, + description: 'The widget title.', + type: { + name: 'string', + required: true, + }, + }, + url: { + control: { + type: 'text', + }, + description: 'Add a link to the image.', + table: { + category: 'Options', + }, + type: { + name: 'string', + required: false, + }, + }, + }, +} as ComponentMeta<typeof ImageWidget>; + +const Template: ComponentStory<typeof ImageWidget> = (args) => ( + <ImageWidget {...args} /> +); + +const image = { + alt: 'Et perferendis quaerat', + height: 480, + src: 'http://placeimg.com/640/480/nature', + width: 640, +}; + +/** + * ImageWidget Stories - Align left + */ +export const AlignLeft = Template.bind({}); +AlignLeft.args = { + alignment: 'left', + expanded: true, + image, + level: 2, + title: 'Quo et totam', +}; + +/** + * ImageWidget Stories - Align center + */ +export const AlignCenter = Template.bind({}); +AlignCenter.args = { + alignment: 'center', + expanded: true, + image, + level: 2, + title: 'Quo et totam', +}; + +/** + * ImageWidget Stories - Align right + */ +export const AlignRight = Template.bind({}); +AlignRight.args = { + alignment: 'right', + expanded: true, + image, + level: 2, + title: 'Quo et totam', +}; + +/** + * ImageWidget Stories - With description + */ +export const WithDescription = Template.bind({}); +WithDescription.args = { + description: 'Sint enim harum', + expanded: true, + image, + level: 2, + title: 'Quo et totam', +}; diff --git a/src/components/organisms/widgets/image-widget.test.tsx b/src/components/organisms/widgets/image-widget.test.tsx new file mode 100644 index 0000000..c6b1a3a --- /dev/null +++ b/src/components/organisms/widgets/image-widget.test.tsx @@ -0,0 +1,59 @@ +import { render, screen } from '@test-utils'; +import ImageWidget from './image-widget'; + +const description = 'Ut vitae sit'; + +const img = { + alt: 'Et perferendis quaerat', + height: 480, + src: 'http://placeimg.com/640/480/nature', + width: 640, +}; + +const title = 'Fugiat cumque et'; +const titleLevel = 2; + +const url = '/another-page'; + +describe('ImageWidget', () => { + it('renders an image', () => { + render( + <ImageWidget + expanded={true} + image={img} + title={title} + level={titleLevel} + /> + ); + expect(screen.getByRole('img', { name: img.alt })).toBeInTheDocument(); + }); + + it('renders a link', () => { + render( + <ImageWidget + expanded={true} + image={img} + title={title} + level={titleLevel} + url={url} + /> + ); + expect(screen.getByRole('link', { name: img.alt })).toHaveAttribute( + 'href', + url + ); + }); + + it('renders a description', () => { + render( + <ImageWidget + expanded={true} + image={img} + description={description} + title={title} + level={titleLevel} + /> + ); + expect(screen.getByText(description)).toBeInTheDocument(); + }); +}); diff --git a/src/components/organisms/widgets/image-widget.tsx b/src/components/organisms/widgets/image-widget.tsx new file mode 100644 index 0000000..873337b --- /dev/null +++ b/src/components/organisms/widgets/image-widget.tsx @@ -0,0 +1,69 @@ +import ResponsiveImage, { + type ResponsiveImageProps, +} from '@components/molecules/images/responsive-image'; +import Widget, { type WidgetProps } from '@components/molecules/layout/widget'; +import { FC } from 'react'; +import styles from './image-widget.module.scss'; + +export type Alignment = 'left' | 'center' | 'right'; + +export type Image = Pick< + ResponsiveImageProps, + 'alt' | 'height' | 'src' | 'width' +>; + +export type ImageWidgetProps = Pick< + WidgetProps, + 'className' | 'expanded' | 'level' | 'title' +> & { + /** + * The content alignment. + */ + alignment?: Alignment; + /** + * Add a caption to the image. + */ + description?: ResponsiveImageProps['caption']; + /** + * An object describing the image. + */ + image: Image; + /** + * Set additional classnames to the image wrapper. + */ + imageClassName?: string; + /** + * Add a link to the image. + */ + url?: ResponsiveImageProps['target']; +}; + +/** + * ImageWidget component + * + * Renders a widget that print an image and an optional text. + */ +const ImageWidget: FC<ImageWidgetProps> = ({ + alignment = 'left', + className = '', + description, + image, + imageClassName = '', + url, + ...props +}) => { + const alignmentClass = `widget--${alignment}`; + + return ( + <Widget className={`${styles[alignmentClass]} ${className}`} {...props}> + <ResponsiveImage + target={url} + caption={description} + className={`${styles.figure} ${imageClassName}`} + {...image} + /> + </Widget> + ); +}; + +export default ImageWidget; diff --git a/src/components/organisms/widgets/links-list-widget.module.scss b/src/components/organisms/widgets/links-list-widget.module.scss new file mode 100644 index 0000000..4444df4 --- /dev/null +++ b/src/components/organisms/widgets/links-list-widget.module.scss @@ -0,0 +1,75 @@ +@use "@styles/abstracts/functions" as fun; +@use "@styles/abstracts/placeholders"; + +.widget { + .list { + .list { + > *:first-child { + border-top: fun.convert-px(1) solid var(--color-primary); + } + } + + &__link { + display: block; + padding: var(--spacing-2xs) var(--spacing-xs); + background: none; + text-decoration: underline solid transparent 0; + + &:hover, + &:focus { + background: var(--color-bg-secondary); + font-weight: 600; + } + + &:focus { + color: var(--color-primary); + text-decoration-color: var(--color-primary-light); + text-decoration-thickness: 0.25ex; + } + + &:active { + background: var(--color-bg-tertiary); + text-decoration-color: transparent; + text-decoration-thickness: 0; + } + } + + &--ordered { + @extend %reset-ordered-list; + + counter-reset: link; + + .list__link { + counter-increment: link; + + &::before { + padding-right: var(--spacing-2xs); + content: counters(link, ".") ". "; + color: var(--color-secondary); + } + } + } + + &--unordered { + @extend %reset-list; + } + + &__item { + &:not(:last-child) { + border-bottom: fun.convert-px(1) solid var(--color-primary); + } + + > .list { + .list__link { + padding-left: var(--spacing-md); + } + + .list__item > .list { + .list__link { + padding-left: var(--spacing-xl); + } + } + } + } + } +} diff --git a/src/components/organisms/widgets/links-list-widget.stories.tsx b/src/components/organisms/widgets/links-list-widget.stories.tsx new file mode 100644 index 0000000..cdfa96a --- /dev/null +++ b/src/components/organisms/widgets/links-list-widget.stories.tsx @@ -0,0 +1,122 @@ +import { ComponentMeta, ComponentStory } from '@storybook/react'; +import LinksListWidget from './links-list-widget'; + +/** + * LinksListWidget - Storybook Meta + */ +export default { + title: 'Organisms/Widgets/LinksList', + component: LinksListWidget, + args: { + kind: 'unordered', + }, + argTypes: { + className: { + control: { + type: 'text', + }, + description: 'Set additional classnames to the list wrapper.', + table: { + category: 'Styles', + }, + type: { + name: 'string', + required: false, + }, + }, + items: { + description: 'The widget data.', + type: { + name: 'object', + required: true, + value: {}, + }, + }, + kind: { + control: { + type: 'select', + }, + description: 'The list kind: either ordered or unordered.', + options: ['ordered', 'unordered'], + table: { + category: 'Options', + defaultValue: { summary: 'unordered' }, + }, + type: { + name: 'string', + required: false, + }, + }, + level: { + control: { + type: 'number', + min: 1, + max: 6, + }, + description: 'The heading level.', + type: { + name: 'number', + required: true, + }, + }, + title: { + control: { + type: 'text', + }, + description: 'The widget title.', + type: { + name: 'string', + required: true, + }, + }, + }, +} as ComponentMeta<typeof LinksListWidget>; + +const Template: ComponentStory<typeof LinksListWidget> = (args) => ( + <LinksListWidget {...args} /> +); + +const items = [ + { name: 'Level 1: Item 1', url: '#' }, + { + name: 'Level 1: Item 2', + url: '#', + child: [ + { name: 'Level 2: Item 1', url: '#' }, + { name: 'Level 2: Item 2', url: '#' }, + { + name: 'Level 2: Item 3', + url: '#', + child: [ + { name: 'Level 3: Item 1', url: '#' }, + { name: 'Level 3: Item 2', url: '#' }, + ], + }, + { name: 'Level 2: Item 4', url: '#' }, + ], + }, + { name: 'Level 1: Item 3', url: '#' }, + { name: 'Level 1: Item 4', url: '#' }, +]; + +/** + * Links List Widget Stories - Unordered + */ +export const Unordered = Template.bind({}); +Unordered.args = { + items, + kind: 'unordered', + level: 2, + title: 'A list of links', +}; + +/** + * Links List Widget Stories - Ordered + */ +export const Ordered = Template.bind({}); +Ordered.args = { + items, + kind: 'ordered', + level: 2, + title: 'A list of links', +}; diff --git a/src/components/organisms/widgets/links-list-widget.test.tsx b/src/components/organisms/widgets/links-list-widget.test.tsx new file mode 100644 index 0000000..a8d6a35 --- /dev/null +++ b/src/components/organisms/widgets/links-list-widget.test.tsx @@ -0,0 +1,32 @@ +import { render, screen } from '@test-utils'; +import LinksListWidget from './links-list-widget'; + +const title = 'Voluptatem minus autem'; + +const items = [ + { name: 'Item 1', url: '/item-1' }, + { name: 'Item 2', url: '/item-2' }, + { name: 'Item 3', url: '/item-3' }, +]; + +describe('LinksListWidget', () => { + it('renders a widget title', () => { + render(<LinksListWidget items={items} title={title} level={2} />); + expect( + screen.getByRole('heading', { level: 2, name: new RegExp(title, 'i') }) + ).toBeInTheDocument(); + }); + + it('renders the correct number of items', () => { + render(<LinksListWidget items={items} title={title} level={2} />); + expect(screen.getAllByRole('listitem')).toHaveLength(items.length); + }); + + it('renders some links', () => { + render(<LinksListWidget items={items} title={title} level={2} />); + expect(screen.getByRole('link', { name: items[0].name })).toHaveAttribute( + 'href', + items[0].url + ); + }); +}); diff --git a/src/components/organisms/widgets/links-list-widget.tsx b/src/components/organisms/widgets/links-list-widget.tsx new file mode 100644 index 0000000..a9c677b --- /dev/null +++ b/src/components/organisms/widgets/links-list-widget.tsx @@ -0,0 +1,85 @@ +import Link from '@components/atoms/links/link'; +import List, { + type ListProps, + type ListItem, +} from '@components/atoms/lists/list'; +import Widget, { type WidgetProps } from '@components/molecules/layout/widget'; +import { slugify } from '@utils/helpers/strings'; +import { FC } from 'react'; +import styles from './links-list-widget.module.scss'; + +export type LinksListItems = { + /** + * An array of name/url couple child of this list item. + */ + child?: LinksListItems[]; + /** + * The item name. + */ + name: string; + /** + * The item url. + */ + url: string; +}; + +export type LinksListWidgetProps = Pick<WidgetProps, 'level' | 'title'> & + Pick<ListProps, 'className' | 'kind'> & { + /** + * An array of name/url couple. + */ + items: LinksListItems[]; + }; + +/** + * LinksListWidget component + * + * Render a list of links inside a widget. + */ +const LinksListWidget: FC<LinksListWidgetProps> = ({ + className = '', + items, + kind = 'unordered', + ...props +}) => { + const listKindClass = `list--${kind}`; + + /** + * Format the widget data to be used as List items. + * + * @param {LinksListItems[]} data - The widget data. + * @returns {ListItem[]} The list items data. + */ + const getListItems = (data: LinksListItems[]): ListItem[] => { + return data.map((item) => { + return { + id: slugify(item.name), + child: item.child && getListItems(item.child), + value: ( + <Link href={item.url} className={styles.list__link}> + {item.name} + </Link> + ), + }; + }); + }; + + return ( + <Widget + expanded={true} + withBorders={true} + className={styles.widget} + withScroll={true} + {...props} + > + <List + items={getListItems(items)} + kind={kind} + className={`${styles.list} ${styles[listKindClass]} ${className}`} + itemsClassName={styles.list__item} + /> + </Widget> + ); +}; + +export default LinksListWidget; diff --git a/src/components/organisms/widgets/sharing.module.scss b/src/components/organisms/widgets/sharing.module.scss new file mode 100644 index 0000000..e06d4e3 --- /dev/null +++ b/src/components/organisms/widgets/sharing.module.scss @@ -0,0 +1,10 @@ +@use "@styles/abstracts/mixins" as mix; + +.list { + display: flex; + flex-flow: row wrap; + gap: var(--spacing-xs); + margin: 0; + padding: 0 var(--spacing-2xs); + list-style-type: none; +} diff --git a/src/components/organisms/widgets/sharing.stories.tsx b/src/components/organisms/widgets/sharing.stories.tsx new file mode 100644 index 0000000..59b86d3 --- /dev/null +++ b/src/components/organisms/widgets/sharing.stories.tsx @@ -0,0 +1,91 @@ +import { ComponentMeta, ComponentStory } from '@storybook/react'; +import SharingWidget from './sharing'; + +/** + * Sharing - Storybook Meta + */ +export default { + title: 'Organisms/Widgets', + component: SharingWidget, + argTypes: { + className: { + control: { + type: 'text', + }, + description: 'Set additional classnames to the sharing links list.', + table: { + category: 'Styles', + }, + type: { + name: 'string', + required: false, + }, + }, + data: { + description: 'The page data.', + type: { + name: 'object', + required: true, + value: {}, + }, + }, + expanded: { + control: { + type: null, + }, + description: 'Default widget state (expanded or collapsed).', + table: { + category: 'Options', + defaultValue: { summary: true }, + }, + type: { + name: 'boolean', + required: false, + }, + }, + level: { + control: { + type: 'number', + min: 1, + max: 6, + }, + description: 'The heading level.', + table: { + category: 'Options', + defaultValue: { summary: 2 }, + }, + type: { + name: 'number', + required: false, + }, + }, + media: { + control: { + type: null, + }, + description: 'An array of active and ordered sharing medium.', + type: { + name: 'string', + required: true, + }, + }, + }, +} as ComponentMeta<typeof SharingWidget>; + +const Template: ComponentStory<typeof SharingWidget> = (args) => ( + <SharingWidget {...args} /> +); + +/** + * Widgets Stories - Sharing + */ +export const Sharing = Template.bind({}); +Sharing.args = { + data: { + excerpt: + 'Alias similique eius ducimus laudantium aspernatur. Est rem ut eum temporibus sit reprehenderit aut non molestias. Vel dolorem expedita labore quo inventore aliquid nihil nam. Possimus nobis enim quas corporis eos.', + title: 'Accusantium totam nostrum', + url: 'https://www.example.test', + }, + media: ['diaspora', 'facebook', 'linkedin', 'twitter', 'email'], +}; diff --git a/src/components/organisms/widgets/sharing.test.tsx b/src/components/organisms/widgets/sharing.test.tsx new file mode 100644 index 0000000..48da49e --- /dev/null +++ b/src/components/organisms/widgets/sharing.test.tsx @@ -0,0 +1,23 @@ +import { render, screen } from '@test-utils'; +import Sharing, { type SharingData } from './sharing'; + +const postData: SharingData = { + excerpt: 'A post excerpt', + title: 'A post title', + url: 'https://sharing-website.test', +}; + +describe('Sharing', () => { + it('renders a sharing widget', () => { + render(<Sharing data={postData} media={['facebook', 'twitter']} />); + expect( + screen.getByRole('link', { name: 'Share on facebook' }) + ).toBeInTheDocument(); + expect( + screen.getByRole('link', { name: 'Share on twitter' }) + ).toBeInTheDocument(); + expect( + screen.queryByRole('link', { name: 'Share on linkedin' }) + ).not.toBeInTheDocument(); + }); +}); diff --git a/src/components/organisms/widgets/sharing.tsx b/src/components/organisms/widgets/sharing.tsx new file mode 100644 index 0000000..c63f5db --- /dev/null +++ b/src/components/organisms/widgets/sharing.tsx @@ -0,0 +1,214 @@ +import SharingLink, { + type SharingMedium, +} from '@components/atoms/links/sharing-link'; +import Widget, { type WidgetProps } from '@components/molecules/layout/widget'; +import { FC } from 'react'; +import { useIntl } from 'react-intl'; +import styles from './sharing.module.scss'; + +export type SharingData = { + /** + * The content excerpt. + */ + excerpt: string; + /** + * The content title. + */ + title: string; + /** + * The content url. + */ + url: string; +}; + +export type SharingProps = { + /** + * Set additional classnames to the sharing links list. + */ + className?: string; + /** + * The page data to share. + */ + data: SharingData; + /** + * The widget default state. + */ + expanded?: WidgetProps['expanded']; + /** + * The HTML heading level. + */ + level?: WidgetProps['level']; + /** + * A list of active and ordered sharing medium. + */ + media: SharingMedium[]; +}; + +/** + * Sharing widget component + * + * Render a list of sharing links inside a widget. + */ +const Sharing: FC<SharingProps> = ({ + className = '', + data, + media, + expanded = true, + level = 2, + ...props +}) => { + const intl = useIntl(); + const widgetTitle = intl.formatMessage({ + defaultMessage: 'Share', + id: 'q3U6uI', + description: 'Sharing: widget title', + }); + + /** + * Build the Diaspora sharing url with provided data. + * + * @param {string} title - The content title. + * @param {string} url - The content url. + * @returns {string} The Diaspora url. + */ + const buildDiasporaUrl = (title: string, url: string): string => { + const titleParam = `title=${encodeURI(title)}`; + const urlParam = `url=${encodeURI(url)}`; + return `https://share.diasporafoundation.org/?${titleParam}&${urlParam}`; + }; + + /** + * Build the mailto url from provided data. + * + * @param {string} excerpt - The content excerpt. + * @param {string} title - The content title. + * @param {string} url - The content url. + * @returns {string} The mailto url with params. + */ + const buildEmailUrl = ( + excerpt: string, + title: string, + url: string + ): string => { + const intro = intl.formatMessage({ + defaultMessage: 'Introduction:', + description: 'Sharing: email content prefix', + id: 'yfgMcl', + }); + const readMore = intl.formatMessage({ + defaultMessage: 'Read more here:', + description: 'Sharing: content link prefix', + id: 'UsQske', + }); + const body = `${intro}\n\n"${excerpt}"\n\n${readMore} ${url}`; + const bodyParam = excerpt ? `body=${encodeURI(body)}` : ''; + + const subject = intl.formatMessage( + { + defaultMessage: 'You should read {title}', + description: 'Sharing: subject text', + id: 'azgQuH', + }, + { title } + ); + const subjectParam = `subject=${encodeURI(subject)}`; + + return `mailto:?${bodyParam}&${subjectParam}`; + }; + + /** + * Build the Facebook sharing url with provided data. + * + * @param {string} url - The content url. + * @returns {string} The Facebook url. + */ + const buildFacebookUrl = (url: string): string => { + const urlParam = `u=${encodeURI(url)}`; + return `https://www.facebook.com/sharer/sharer.php?${urlParam}`; + }; + + /** + * Build the Journal du Hacker sharing url with provided data. + * + * @param {string} title - The content title. + * @param {string} url - The content url. + * @returns {string} The Journal du Hacker url. + */ + const buildJdHUrl = (title: string, url: string): string => { + const titleParam = `title=${encodeURI(title)}`; + const urlParam = `url=${encodeURI(url)}`; + return `https://www.journalduhacker.net/stories/new?${titleParam}&${urlParam}`; + }; + + /** + * Build the LinkedIn sharing url with provided data. + * + * @param {string} url - The content url. + * @returns {string} The LinkedIn url. + */ + const buildLinkedInUrl = (url: string): string => { + const urlParam = `url=${encodeURI(url)}`; + return `https://www.linkedin.com/sharing/share-offsite/?${urlParam}`; + }; + + /** + * Build the Twitter sharing url with provided data. + * + * @param {string} title - The content title. + * @param {string} url - The content url. + * @returns {string} The Twitter url. + */ + const buildTwitterUrl = (title: string, url: string): string => { + const titleParam = `text=${encodeURI(title)}`; + const urlParam = `url=${encodeURI(url)}`; + return `https://twitter.com/intent/tweet?${titleParam}&${urlParam}`; + }; + + /** + * Retrieve the sharing url by medium id. + * + * @param {SharingMedium} medium - A sharing medium id. + * @returns {string} The sharing url. + */ + const getUrl = (medium: SharingMedium): string => { + const { excerpt, title, url } = data; + + switch (medium) { + case 'diaspora': + return buildDiasporaUrl(title, url); + case 'email': + return buildEmailUrl(excerpt, title, url); + case 'facebook': + return buildFacebookUrl(url); + case 'journal-du-hacker': + return buildJdHUrl(title, url); + case 'linkedin': + return buildLinkedInUrl(url); + case 'twitter': + return buildTwitterUrl(title, url); + default: + return '#'; + } + }; + + /** + * Get the sharing list items. + * + * @returns {JSX.Element[]} The sharing links wrapped with li element. + */ + const getItems = (): JSX.Element[] => { + return media.map((medium) => ( + <li key={medium}> + <SharingLink medium={medium} url={getUrl(medium)} /> + </li> + )); + }; + + return ( + <Widget expanded={expanded} level={level} title={widgetTitle} {...props}> + <ul className={`${styles.list} ${className}`}>{getItems()}</ul> + </Widget> + ); +}; + +export default Sharing; diff --git a/src/components/organisms/widgets/social-media.module.scss b/src/components/organisms/widgets/social-media.module.scss new file mode 100644 index 0000000..01b6c0e --- /dev/null +++ b/src/components/organisms/widgets/social-media.module.scss @@ -0,0 +1,10 @@ +@use "@styles/abstracts/placeholders"; + +.list { + @extend %reset-list; + + display: flex; + flex-flow: row wrap; + gap: var(--spacing-xs); + padding: 0 var(--spacing-2xs); +} diff --git a/src/components/organisms/widgets/social-media.stories.tsx b/src/components/organisms/widgets/social-media.stories.tsx new file mode 100644 index 0000000..6c9de4d --- /dev/null +++ b/src/components/organisms/widgets/social-media.stories.tsx @@ -0,0 +1,61 @@ +import { ComponentMeta, ComponentStory } from '@storybook/react'; +import SocialMediaWidget, { Media } from './social-media'; + +/** + * SocialMedia - Storybook Meta + */ +export default { + title: 'Organisms/Widgets', + component: SocialMediaWidget, + argTypes: { + level: { + control: { + type: 'number', + min: 1, + max: 6, + }, + description: 'The heading level.', + type: { + name: 'number', + required: true, + }, + }, + media: { + description: 'The links data.', + type: { + name: 'object', + required: true, + value: {}, + }, + }, + title: { + control: { + type: 'text', + }, + description: 'The widget title.', + type: { + name: 'string', + required: true, + }, + }, + }, +} as ComponentMeta<typeof SocialMediaWidget>; + +const Template: ComponentStory<typeof SocialMediaWidget> = (args) => ( + <SocialMediaWidget {...args} /> +); + +const media: Media[] = [ + { name: 'Github', url: '#' }, + { name: 'LinkedIn', url: '#' }, +]; + +/** + * Widgets Stories - Social media + */ +export const SocialMedia = Template.bind({}); +SocialMedia.args = { + media, + title: 'Follow me', + level: 2, +}; diff --git a/src/components/organisms/widgets/social-media.test.tsx b/src/components/organisms/widgets/social-media.test.tsx new file mode 100644 index 0000000..e40db30 --- /dev/null +++ b/src/components/organisms/widgets/social-media.test.tsx @@ -0,0 +1,33 @@ +import { render, screen } from '@test-utils'; +import SocialMedia, { Media } from './social-media'; + +const media: Media[] = [ + { name: 'Github', url: '#' }, + { name: 'LinkedIn', url: '#' }, +]; +const title = 'Dolores ut ut'; +const titleLevel = 2; + +/** + * Next.js mock images with next/image component. So for now, I need to mock + * the svg files manually. + */ +jest.mock('@assets/images/social-media/github.svg', () => 'svg-file'); +jest.mock('@assets/images/social-media/linkedin.svg', () => 'svg-file'); + +describe('SocialMedia', () => { + it('renders the widget title', () => { + render(<SocialMedia media={media} title={title} level={titleLevel} />); + expect( + screen.getByRole('heading', { + level: titleLevel, + name: new RegExp(title, 'i'), + }) + ).toBeInTheDocument(); + }); + + it('renders the correct number of items', () => { + render(<SocialMedia media={media} title={title} level={titleLevel} />); + expect(screen.getAllByRole('listitem')).toHaveLength(media.length); + }); +}); diff --git a/src/components/organisms/widgets/social-media.tsx b/src/components/organisms/widgets/social-media.tsx new file mode 100644 index 0000000..58b2f73 --- /dev/null +++ b/src/components/organisms/widgets/social-media.tsx @@ -0,0 +1,41 @@ +import SocialLink, { + type SocialLinkProps, +} from '@components/atoms/links/social-link'; +import Widget, { type WidgetProps } from '@components/molecules/layout/widget'; +import { FC } from 'react'; +import styles from './social-media.module.scss'; + +export type Media = SocialLinkProps; + +export type SocialMediaProps = Pick<WidgetProps, 'level' | 'title'> & { + media: Media[]; +}; + +/** + * Social Media widget component + * + * Render a social media list with links. + */ +const SocialMedia: FC<SocialMediaProps> = ({ media, ...props }) => { + /** + * Retrieve the social media items. + * + * @param {SocialMedia[]} links - An array of social media name and url. + * @returns {JSX.Element[]} The social links. + */ + const getItems = (links: Media[]): JSX.Element[] => { + return links.map((link, index) => ( + <li key={`media-${index}`}> + <SocialLink name={link.name} url={link.url} /> + </li> + )); + }; + + return ( + <Widget expanded={true} {...props}> + <ul className={styles.list}>{getItems(media)}</ul> + </Widget> + ); +}; + +export default SocialMedia; diff --git a/src/components/organisms/widgets/table-of-contents.module.scss b/src/components/organisms/widgets/table-of-contents.module.scss new file mode 100644 index 0000000..36217ed --- /dev/null +++ b/src/components/organisms/widgets/table-of-contents.module.scss @@ -0,0 +1,4 @@ +.list { + font-size: var(--font-size-sm); + font-weight: 500; +} diff --git a/src/components/organisms/widgets/table-of-contents.stories.tsx b/src/components/organisms/widgets/table-of-contents.stories.tsx new file mode 100644 index 0000000..9490ee3 --- /dev/null +++ b/src/components/organisms/widgets/table-of-contents.stories.tsx @@ -0,0 +1,54 @@ +import { ComponentMeta, ComponentStory } from '@storybook/react'; +import ToCWidget from './table-of-contents'; + +/** + * TableOfContents - Storybook Meta + */ +export default { + title: 'Organisms/Widgets', + component: ToCWidget, + argTypes: { + wrapper: { + control: { + type: null, + }, + description: + 'A reference to the HTML element that contains the headings.', + type: { + name: 'string', + required: true, + }, + }, + }, +} as ComponentMeta<typeof ToCWidget>; + +const Template: ComponentStory<typeof ToCWidget> = (args) => ( + <ToCWidget {...args} /> +); + +export const GetWrapper = () => { + const wrapper = document.createElement('div'); + const firstTitle = document.createElement('h2'); + const firstParagraph = document.createElement('p'); + const secondTitle = document.createElement('h2'); + const secondParagraph = document.createElement('p'); + + firstTitle.textContent = 'dignissimos odit odit'; + firstParagraph.textContent = + 'Sint error saepe in. Vel doloribus facere deleniti minima magni. Consequatur veniam quia rerum praesentium eaque culpa culpa quas optio.'; + secondTitle.textContent = 'aliquam exercitationem ut'; + secondParagraph.textContent = + 'Doloribus sunt ut pariatur et praesentium rerum quam deserunt. Quod omnis quia qui quis debitis recusandae. Voluptate et impedit quam quidem quis id explicabo similique enim. Velit illum amet quos veniam consequatur amet nam sunt et. Et odit atque totam culpa officia saepe sed eaque consequatur.'; + + wrapper.append(...[firstTitle, firstParagraph, secondTitle, secondParagraph]); + + return wrapper; +}; + +/** + * Widgets Stories - Table of Contents + */ +export const TableOfContents = Template.bind({}); +TableOfContents.args = { + wrapper: GetWrapper(), +}; diff --git a/src/components/organisms/widgets/table-of-contents.test.tsx b/src/components/organisms/widgets/table-of-contents.test.tsx new file mode 100644 index 0000000..2064f39 --- /dev/null +++ b/src/components/organisms/widgets/table-of-contents.test.tsx @@ -0,0 +1,12 @@ +import { render, screen } from '@test-utils'; +import TableOfContents from './table-of-contents'; + +describe('TableOfContents', () => { + it('renders the ToC title', () => { + const divEl = document.createElement('div'); + render(<TableOfContents wrapper={divEl} />); + expect( + screen.getByRole('heading', { level: 2, name: /Table of Contents/i }) + ).toBeInTheDocument(); + }); +}); diff --git a/src/components/organisms/widgets/table-of-contents.tsx b/src/components/organisms/widgets/table-of-contents.tsx new file mode 100644 index 0000000..800ff58 --- /dev/null +++ b/src/components/organisms/widgets/table-of-contents.tsx @@ -0,0 +1,55 @@ +import useHeadingsTree, { type Heading } from '@utils/hooks/use-headings-tree'; +import { FC } from 'react'; +import { useIntl } from 'react-intl'; +import LinksListWidget, { type LinksListItems } from './links-list-widget'; +import styles from './table-of-contents.module.scss'; + +type TableOfContentsProps = { + /** + * A reference to the HTML element that contains the headings. + */ + wrapper: HTMLElement; +}; + +/** + * Table of Contents widget component + * + * Render a table of contents. + */ +const TableOfContents: FC<TableOfContentsProps> = ({ wrapper }) => { + const intl = useIntl(); + const headingsTree = useHeadingsTree(wrapper); + const title = intl.formatMessage({ + defaultMessage: 'Table of Contents', + description: 'TableOfContents: the widget title', + id: 'WKG9wj', + }); + + /** + * Convert an headings tree to list items. + * + * @param {Heading[]} tree - The headings tree. + * @returns {LinksListItems[]} The list items. + */ + const getItems = (tree: Heading[]): LinksListItems[] => { + return tree.map((heading) => { + return { + name: heading.title, + url: `#${heading.id}`, + child: getItems(heading.children), + }; + }); + }; + + return ( + <LinksListWidget + kind="ordered" + title={title} + level={2} + items={getItems(headingsTree)} + className={styles.list} + /> + ); +}; + +export default TableOfContents; |
