diff options
Diffstat (limited to 'src/components/organisms/widgets')
16 files changed, 1010 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..78c0d26 --- /dev/null +++ b/src/components/organisms/widgets/image-widget.module.scss @@ -0,0 +1,43 @@ +@use "@styles/abstracts/functions" as fun; + +.img { + max-height: fun.convert-px(350); + margin: 0; +} + +.txt { + padding: var(--spacing-sm); +} + +.widget { + &--left { + .img { + margin-right: auto; + } + + .txt { + text-align: left; + } + } + + &--center { + .img { + margin-left: auto; + margin-right: auto; + } + + .txt { + text-align: center; + } + } + + &--right { + .img { + 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..1c2397b --- /dev/null +++ b/src/components/organisms/widgets/image-widget.stories.tsx @@ -0,0 +1,113 @@ +import { ComponentMeta, ComponentStory } from '@storybook/react'; +import { IntlProvider } from 'react-intl'; +import ImageWidgetComponent from './image-widget'; + +export default { + title: 'Organisms/Widgets', + component: ImageWidgetComponent, + 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, + }, + }, + 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, + }, + }, + img: { + description: 'An image object.', + type: { + name: 'object', + required: true, + value: {}, + }, + }, + level: { + control: { + type: 'number', + }, + 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 ImageWidgetComponent>; + +const Template: ComponentStory<typeof ImageWidgetComponent> = (args) => ( + <IntlProvider locale="en"> + <ImageWidgetComponent {...args} /> + </IntlProvider> +); + +const img = { + alt: 'Et perferendis quaerat', + height: 480, + src: 'http://placeimg.com/640/480/nature', + width: 640, +}; + +export const ImageWidget = Template.bind({}); +ImageWidget.args = { + expanded: true, + img, + 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..8c24bd9 --- /dev/null +++ b/src/components/organisms/widgets/image-widget.test.tsx @@ -0,0 +1,54 @@ +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} img={img} title={title} level={titleLevel} /> + ); + expect(screen.getByRole('img', { name: img.alt })).toBeInTheDocument(); + }); + + it('renders a link', () => { + render( + <ImageWidget + expanded={true} + img={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} + img={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..928d5ea --- /dev/null +++ b/src/components/organisms/widgets/image-widget.tsx @@ -0,0 +1,75 @@ +import ResponsiveImage from '@components/molecules/images/responsive-image'; +import Widget, { type WidgetProps } from '@components/molecules/layout/widget'; +import { VFC } from 'react'; +import styles from './image-widget.module.scss'; + +export type Alignment = 'left' | 'center' | 'right'; + +export type Image = { + /** + * An alternative text for the image. + */ + alt: string; + /** + * The image height. + */ + height: number; + /** + * The image source. + */ + src: string; + /** + * The image width. + */ + width: number; +}; + +export type ImageWidgetProps = Pick< + WidgetProps, + 'expanded' | 'level' | 'title' +> & { + /** + * The content alignment. + */ + alignment?: Alignment; + /** + * Add a caption to the image. + */ + description?: string; + /** + * An object describing the image. + */ + img: Image; + /** + * Add a link to the image. + */ + url?: string; +}; + +/** + * ImageWidget component + * + * Renders a widget that print an image and an optional text. + */ +const ImageWidget: VFC<ImageWidgetProps> = ({ + alignment = 'left', + description, + img, + url, + ...props +}) => { + const alignmentClass = `widget--${alignment}`; + + return ( + <Widget className={styles[alignmentClass]} {...props}> + <ResponsiveImage + target={url} + caption={description} + className={styles.img} + {...img} + /> + </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..cbad83e --- /dev/null +++ b/src/components/organisms/widgets/links-list-widget.module.scss @@ -0,0 +1,71 @@ +@use "@styles/abstracts/functions" as fun; +@use "@styles/abstracts/placeholders"; + +.widget { + .list { + &__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) { + .list__link { + 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..528f6f7 --- /dev/null +++ b/src/components/organisms/widgets/links-list-widget.stories.tsx @@ -0,0 +1,92 @@ +import { ComponentMeta, ComponentStory } from '@storybook/react'; +import { IntlProvider } from 'react-intl'; +import LinksListWidget from './links-list-widget'; + +export default { + title: 'Organisms/Widgets', + component: LinksListWidget, + args: { + kind: 'unordered', + }, + argTypes: { + 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', + }, + 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) => ( + <IntlProvider locale="en"> + <LinksListWidget {...args} /> + </IntlProvider> +); + +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: '#' }, +]; + +export const LinksList = Template.bind({}); +LinksList.args = { + items, + 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..155354e --- /dev/null +++ b/src/components/organisms/widgets/links-list-widget.tsx @@ -0,0 +1,81 @@ +import Link from '@components/atoms/links/link'; +import List, { ListProps, type ListItem } from '@components/atoms/lists/list'; +import Widget, { type WidgetProps } from '@components/molecules/layout/widget'; +import { slugify } from '@utils/helpers/slugify'; +import { VFC } 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, 'kind'> & { + /** + * An array of name/url couple. + */ + items: LinksListItems[]; + }; + +/** + * LinksListWidget component + * + * Render a list of links inside a widget. + */ +const LinksListWidget: VFC<LinksListWidgetProps> = ({ + 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} + {...props} + > + <List + items={getListItems(items)} + kind={kind} + withMargin={false} + className={`${styles.list} ${styles[listKindClass]}`} + 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..be20b84 --- /dev/null +++ b/src/components/organisms/widgets/sharing.stories.tsx @@ -0,0 +1,78 @@ +import { ComponentMeta, ComponentStory } from '@storybook/react'; +import { IntlProvider } from 'react-intl'; +import SharingWidget from './sharing'; + +export default { + title: 'Organisms/Widgets', + component: SharingWidget, + argTypes: { + data: { + description: 'The page data.', + type: { + name: 'object', + required: true, + value: {}, + }, + }, + expanded: { + control: { + type: null, + }, + description: 'Default widget state (expanded or collapsed).', + type: { + name: 'boolean', + required: true, + }, + }, + level: { + control: { + type: 'number', + }, + description: 'The heading level.', + type: { + name: 'number', + required: true, + }, + }, + media: { + control: { + type: null, + }, + description: 'An array of active and ordered sharing medium.', + type: { + name: 'string', + required: true, + }, + }, + title: { + control: { + type: 'text', + }, + description: 'The widget title.', + type: { + name: 'string', + required: true, + }, + }, + }, +} as ComponentMeta<typeof SharingWidget>; + +const Template: ComponentStory<typeof SharingWidget> = (args) => ( + <IntlProvider locale="en"> + <SharingWidget {...args} /> + </IntlProvider> +); + +export const Sharing = Template.bind({}); +Sharing.args = { + expanded: true, + 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', + }, + level: 2, + media: ['diaspora', 'facebook', 'linkedin', 'twitter', 'email'], + title: 'Sharing', +}; diff --git a/src/components/organisms/widgets/sharing.test.tsx b/src/components/organisms/widgets/sharing.test.tsx new file mode 100644 index 0000000..265dbe1 --- /dev/null +++ b/src/components/organisms/widgets/sharing.test.tsx @@ -0,0 +1,31 @@ +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']} + expanded={true} + title="Sharing" + level={2} + /> + ); + 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..ccd3a21 --- /dev/null +++ b/src/components/organisms/widgets/sharing.tsx @@ -0,0 +1,190 @@ +import SharingLink, { + type SharingMedium, +} from '@components/atoms/links/sharing-link'; +import Widget, { type WidgetProps } from '@components/molecules/layout/widget'; +import { VFC } 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 = WidgetProps & { + /** + * The page data to share. + */ + data: SharingData; + /** + * A list of active and ordered sharing medium. + */ + media: SharingMedium[]; +}; + +/** + * Sharing widget component + * + * Render a list of sharing links inside a widget. + */ +const Sharing: VFC<SharingProps> = ({ data, media, ...props }) => { + const intl = useIntl(); + + /** + * 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 {...props}> + <ul className={styles.list}>{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..2b84012 --- /dev/null +++ b/src/components/organisms/widgets/social-media.stories.tsx @@ -0,0 +1,56 @@ +import { ComponentMeta, ComponentStory } from '@storybook/react'; +import { IntlProvider } from 'react-intl'; +import SocialMediaWidget, { Media } from './social-media'; + +export default { + title: 'Organisms/Widgets', + component: SocialMediaWidget, + argTypes: { + level: { + control: { + type: 'number', + }, + 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) => ( + <IntlProvider locale="en"> + <SocialMediaWidget {...args} /> + </IntlProvider> +); + +const media: Media[] = [ + { name: 'Github', url: '#' }, + { name: 'LinkedIn', url: '#' }, +]; + +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; |
