diff options
| author | Armand Philippot <git@armandphilippot.com> | 2023-11-06 18:08:04 +0100 |
|---|---|---|
| committer | Armand Philippot <git@armandphilippot.com> | 2023-11-11 18:15:27 +0100 |
| commit | c9c1c90b30e243563bb4f731da15b3fe657556d2 (patch) | |
| tree | 8263c176b4096e2893b9d9319bfa7edb01fce188 /src/components/organisms/post-preview | |
| parent | 2771de88f40a5f4ed7480bd8614532dda72deeda (diff) | |
refactor(components): replace Summary component with PostPreview
* rename component to PostPreview because Summary is an HTML element
and it could lead to confusion
* replace `title` and `titleLevel` with `heading` and `headingLvl`
because `title` is a native attribute
* rename `intro` prop to `excerpt`
* extract `cover` from `meta` prop
* rewrite meta type
* extract meta logic into a new component
Diffstat (limited to 'src/components/organisms/post-preview')
8 files changed, 849 insertions, 0 deletions
diff --git a/src/components/organisms/post-preview/index.ts b/src/components/organisms/post-preview/index.ts new file mode 100644 index 0000000..8c49301 --- /dev/null +++ b/src/components/organisms/post-preview/index.ts @@ -0,0 +1,2 @@ +export * from './post-preview'; +export type { PostPreviewMetaData } from './post-preview-meta'; diff --git a/src/components/organisms/post-preview/post-preview-meta/index.ts b/src/components/organisms/post-preview/post-preview-meta/index.ts new file mode 100644 index 0000000..0305db3 --- /dev/null +++ b/src/components/organisms/post-preview/post-preview-meta/index.ts @@ -0,0 +1 @@ +export * from './post-preview-meta'; diff --git a/src/components/organisms/post-preview/post-preview-meta/post-preview-meta.test.tsx b/src/components/organisms/post-preview/post-preview-meta/post-preview-meta.test.tsx new file mode 100644 index 0000000..188afcc --- /dev/null +++ b/src/components/organisms/post-preview/post-preview-meta/post-preview-meta.test.tsx @@ -0,0 +1,141 @@ +import { describe, expect, it } from '@jest/globals'; +import { render, screen as rtlScreen } from '../../../../../tests/utils'; +import { getReadingTimeFrom } from '../../../../utils/helpers'; +import { PostPreviewMeta, type PostPreviewMetaData } from './post-preview-meta'; + +describe('PostPreviewMeta', () => { + it('can render a meta for the author', () => { + const meta = { + author: 'Gilberto70', + } satisfies PostPreviewMetaData; + + render(<PostPreviewMeta meta={meta} />); + + expect(rtlScreen.getByRole('term')).toHaveTextContent('Written by:'); + expect(rtlScreen.getByRole('definition')).toHaveTextContent(meta.author); + }); + + it('can render a meta for a single comment', () => { + const meta = { + comments: { + count: 1, + postHeading: 'quae commodi deserunt', + }, + } satisfies PostPreviewMetaData; + + render(<PostPreviewMeta meta={meta} />); + + expect(rtlScreen.getByRole('term')).toHaveTextContent('Comments:'); + expect(rtlScreen.getByRole('definition').textContent).toBe( + `${meta.comments.count} comment about ${meta.comments.postHeading}` + ); + }); + + it('can render a meta for comments with a link', () => { + const meta = { + comments: { + count: 3, + postHeading: 'quae commodi deserunt', + url: '#temporibus', + }, + } satisfies PostPreviewMetaData; + + render(<PostPreviewMeta meta={meta} />); + + expect(rtlScreen.getByRole('term')).toHaveTextContent('Comments:'); + expect(rtlScreen.getByRole('definition').textContent).toBe( + `${meta.comments.count} comments about ${meta.comments.postHeading}` + ); + expect(rtlScreen.getByRole('link')).toHaveAttribute( + 'href', + meta.comments.url + ); + }); + + it('can render a meta for the publication date', () => { + const meta = { + publicationDate: '2002', + } satisfies PostPreviewMetaData; + + render(<PostPreviewMeta meta={meta} />); + + expect(rtlScreen.getByRole('term')).toHaveTextContent('Published on:'); + expect(rtlScreen.getByRole('definition')).toHaveTextContent( + meta.publicationDate + ); + }); + + it('can render a meta for thematics', () => { + const meta = { + thematics: [{ id: 1, name: 'autem', url: '#est' }], + } satisfies PostPreviewMetaData; + + render(<PostPreviewMeta meta={meta} />); + + expect(rtlScreen.getByRole('term')).toHaveTextContent('Thematic:'); + expect(rtlScreen.getByRole('definition')).toHaveTextContent( + meta.thematics[0].name + ); + expect(rtlScreen.getByRole('link')).toHaveAttribute( + 'href', + meta.thematics[0].url + ); + }); + + it('can render a meta for topics', () => { + const meta = { + topics: [{ id: 1, name: 'hic', url: '#ipsa' }], + } satisfies PostPreviewMetaData; + + render(<PostPreviewMeta meta={meta} />); + + expect(rtlScreen.getByRole('term')).toHaveTextContent('Topic:'); + expect(rtlScreen.getByRole('definition')).toHaveTextContent( + meta.topics[0].name + ); + expect(rtlScreen.getByRole('link')).toHaveAttribute( + 'href', + meta.topics[0].url + ); + }); + + it('can render a meta for the update date', () => { + const meta = { + updateDate: '2020', + } satisfies PostPreviewMetaData; + + render(<PostPreviewMeta meta={meta} />); + + expect(rtlScreen.getByRole('term')).toHaveTextContent('Updated on:'); + expect(rtlScreen.getByRole('definition')).toHaveTextContent( + meta.updateDate + ); + }); + + it('does not render a meta for the update date if it is equal to the publication date', () => { + const meta = { + publicationDate: '2020', + updateDate: '2020', + } satisfies PostPreviewMetaData; + + render(<PostPreviewMeta meta={meta} />); + + const terms = rtlScreen.getAllByRole('term'); + + expect(terms.length).toBe(1); + expect(terms[0].textContent).toBe('Published on:'); + }); + + it('can render a meta for the reading time', () => { + const meta = { + wordsCount: 500, + } satisfies PostPreviewMetaData; + + render(<PostPreviewMeta meta={meta} />); + + expect(rtlScreen.getByRole('term')).toHaveTextContent('Reading time:'); + expect(rtlScreen.getByRole('definition')).toHaveTextContent( + `${getReadingTimeFrom(meta.wordsCount).inMinutes()} minutes` + ); + }); +}); diff --git a/src/components/organisms/post-preview/post-preview-meta/post-preview-meta.tsx b/src/components/organisms/post-preview/post-preview-meta/post-preview-meta.tsx new file mode 100644 index 0000000..5a342da --- /dev/null +++ b/src/components/organisms/post-preview/post-preview-meta/post-preview-meta.tsx @@ -0,0 +1,254 @@ +import type { FC, ReactNode } from 'react'; +import { useIntl } from 'react-intl'; +import type { PageLink } from '../../../../types'; +import { getReadingTimeFrom } from '../../../../utils/helpers'; +import { Link, Time, VisuallyHidden } from '../../../atoms'; +import { + CardMeta, + type MetaItemData, + type CardMetaProps, +} from '../../../molecules'; + +const a11y = (chunks: ReactNode) => <VisuallyHidden>{chunks}</VisuallyHidden>; + +export type PostPreviewMetaComment = { + /** + * The number of comments. + */ + count: number; + /** + * The post heading (used to generate an accessible label). + */ + postHeading: string; + /** + * An url to the comments section. + */ + url?: string; +}; + +export type PostPreviewMetaData = { + /** + * The author name. + */ + author?: string; + /** + * The number of comments on the post and eventually an url to read them. + */ + comments?: PostPreviewMetaComment; + /** + * The publication date of the post. + */ + publicationDate?: string; + /** + * The thematics attached to the post. + */ + thematics?: PageLink[]; + /** + * The topics attached to the post. + */ + topics?: PageLink[]; + /** + * The last modification date of the post. + */ + updateDate?: string; + /** + * The number of words in the post. + */ + wordsCount?: number; +}; + +const validMetaKeys = [ + 'author', + 'comments', + 'publicationDate', + 'thematics', + 'topics', + 'updateDate', + 'wordsCount', +] satisfies (keyof PostPreviewMetaData)[]; + +const isValidMetaKey = (key: string): key is keyof PostPreviewMetaData => + (validMetaKeys as string[]).includes(key); + +export type PostPreviewMetaProps = Omit<CardMetaProps, 'items'> & { + /** + * The post meta. + */ + meta: PostPreviewMetaData; +}; + +export const PostPreviewMeta: FC<PostPreviewMetaProps> = ({ + meta, + ...props +}) => { + const intl = useIntl(); + + const getAuthor = (): MetaItemData | undefined => { + if (!meta.author) return undefined; + + return { + id: 'author', + label: intl.formatMessage({ + defaultMessage: 'Written by:', + description: 'PostPreviewMeta: author label', + id: '2U7ixo', + }), + value: meta.author, + }; + }; + + const getCommentsCount = (): MetaItemData | undefined => { + if (!meta.comments) return undefined; + + const commentsLabel = intl.formatMessage<ReactNode>( + { + defaultMessage: + '{commentsCount, plural, =0 {No comments} one {# comment} other {# comments}}<a11y> about {title}</a11y>', + description: 'PostPreviewMeta: comments count', + id: 'NfAn9N', + }, + { + a11y, + commentsCount: meta.comments.count, + title: meta.comments.postHeading, + } + ); + + return { + id: 'comments', + label: intl.formatMessage({ + defaultMessage: 'Comments:', + description: 'PostPreviewMeta: comments label', + id: 'FCpPCm', + }), + value: meta.comments.url ? ( + <Link href={meta.comments.url}>{commentsLabel}</Link> + ) : ( + <>{commentsLabel}</> + ), + }; + }; + + const getPublicationDate = (): MetaItemData | undefined => { + if (!meta.publicationDate) return undefined; + + return { + id: 'publication-date', + label: intl.formatMessage({ + defaultMessage: 'Published on:', + description: 'PostPreviewMeta: publication date label', + id: '+6f4p1', + }), + value: <Time date={meta.publicationDate} />, + }; + }; + + const getThematics = (): MetaItemData | undefined => { + if (!meta.thematics?.length) return undefined; + + return { + id: 'thematics', + label: intl.formatMessage( + { + defaultMessage: + '{thematicsCount, plural, =0 {Thematics:} one {Thematic:} other {Thematics:}}', + description: 'PostPreviewMeta: thematics label', + id: '9MTBCG', + }, + { thematicsCount: meta.thematics.length } + ), + value: meta.thematics.map((thematic) => { + return { + id: `thematic-${thematic.id}`, + value: <Link href={thematic.url}>{thematic.name}</Link>, + }; + }), + }; + }; + + const getTopics = (): MetaItemData | undefined => { + if (!meta.topics?.length) return undefined; + + return { + id: 'topics', + label: intl.formatMessage( + { + defaultMessage: + '{topicsCount, plural, =0 {Topics:} one {Topic:} other {Topics:}}', + description: 'PostPreviewMeta: topics label', + id: 'aBQYbE', + }, + { topicsCount: meta.topics.length } + ), + value: meta.topics.map((topic) => { + return { + id: `topic-${topic.id}`, + value: <Link href={topic.url}>{topic.name}</Link>, + }; + }), + }; + }; + + const getUpdateDate = (): MetaItemData | undefined => { + if (!meta.updateDate || meta.updateDate === meta.publicationDate) + return undefined; + + return { + id: 'update-date', + label: intl.formatMessage({ + defaultMessage: 'Updated on:', + description: 'PostPreviewMeta: update date label', + id: 'ZmRh0V', + }), + value: <Time date={meta.updateDate} />, + }; + }; + + const getReadingTime = (): MetaItemData | undefined => { + if (!meta.wordsCount) return undefined; + + return { + id: 'reading-time', + label: intl.formatMessage({ + defaultMessage: 'Reading time:', + description: 'PostPreviewMeta: reading time label', + id: 'B1lS/v', + }), + value: intl.formatMessage( + { + defaultMessage: + '{minutesCount, plural, =0 {Less than one minute} one {# minute} other {# minutes}}', + description: 'PostPreviewMeta: rounded minutes count', + id: 'y+13Ax', + }, + { minutesCount: getReadingTimeFrom(meta.wordsCount).inMinutes() } + ), + }; + }; + + const items: MetaItemData[] = Object.keys(meta) + .filter(isValidMetaKey) + .map((key): MetaItemData | undefined => { + switch (key) { + case 'author': + return getAuthor(); + case 'comments': + return getCommentsCount(); + case 'publicationDate': + return getPublicationDate(); + case 'thematics': + return getThematics(); + case 'topics': + return getTopics(); + case 'updateDate': + return getUpdateDate(); + case 'wordsCount': + return getReadingTime(); + default: + throw new Error('Unsupported meta key.'); + } + }) + .filter((item): item is MetaItemData => item !== undefined); + + return <CardMeta {...props} items={items} />; +}; diff --git a/src/components/organisms/post-preview/post-preview.module.scss b/src/components/organisms/post-preview/post-preview.module.scss new file mode 100644 index 0000000..2ff2e0e --- /dev/null +++ b/src/components/organisms/post-preview/post-preview.module.scss @@ -0,0 +1,96 @@ +@use "../../../styles/abstracts/placeholders"; + +.wrapper { + &:hover { + .icon { + :global { + animation: pulse 1.5s ease-in-out 0.2s infinite; + } + } + } +} + +.heading { + font-size: var(--font-size-2xl); +} + +.excerpt { + > *:last-child { + margin-bottom: 0; + } + + :global { + a { + @extend %link; + + &[hreflang], + &.download, + &.external { + @extend %link-with-icon; + } + + &[hreflang] { + @extend %link-with-lang; + } + + &[hreflang]:not(.download, .external) { + --is-icon-hidden: ""; + } + + &.download { + @extend %download-link; + } + + &.external { + @extend %external-link; + } + + &.download, + &.external { + &:not([hreflang]) { + --is-lang-hidden: ""; + } + } + + &.external.download { + @extend %external-download-link; + } + } + } +} + +:global([data-theme="light"]) { + :local { + .intro { + :global { + a { + &.download { + @extend %light-download-link; + } + + &.external { + @extend %light-external-link; + } + } + } + } + } +} + +:global([data-theme="dark"]) { + :local { + .intro { + :global { + a { + &.download { + @extend %dark-download-link; + } + + &.external { + @extend %dark-external-link; + } + } + } + } + } +} diff --git a/src/components/organisms/post-preview/post-preview.stories.tsx b/src/components/organisms/post-preview/post-preview.stories.tsx new file mode 100644 index 0000000..c22698f --- /dev/null +++ b/src/components/organisms/post-preview/post-preview.stories.tsx @@ -0,0 +1,157 @@ +import type { ComponentMeta, ComponentStory } from '@storybook/react'; +import NextImage from 'next/image'; +import { PostPreview } from './post-preview'; + +/** + * PostPreview - Storybook Meta + */ +export default { + title: 'Organisms/PostPreview', + component: PostPreview, + argTypes: { + cover: { + description: 'The cover data.', + table: { + category: 'Options', + }, + type: { + name: 'object', + required: false, + value: {}, + }, + }, + excerpt: { + control: { + type: 'text', + }, + description: 'The page excerpt.', + type: { + name: 'string', + required: true, + }, + }, + meta: { + description: 'The page metadata.', + type: { + name: 'object', + required: true, + value: {}, + }, + }, + heading: { + control: { + type: 'text', + }, + description: 'The page title', + type: { + name: 'string', + required: true, + }, + }, + headingLvl: { + control: { + type: 'number', + min: 1, + max: 6, + }, + description: 'The page title level (hn)', + table: { + category: 'Options', + defaultValue: { summary: 2 }, + }, + type: { + name: 'number', + required: false, + }, + }, + url: { + control: { + type: 'text', + }, + description: 'The page url.', + type: { + name: 'string', + required: true, + }, + }, + }, +} as ComponentMeta<typeof PostPreview>; + +const Template: ComponentStory<typeof PostPreview> = (args) => ( + <PostPreview {...args} /> +); + +/** + * PostPreview Stories - Default + */ +export const Default = Template.bind({}); +Default.args = { + excerpt: + 'Et vel amet minus. Inventore magnam et vel ea animi omnis qui. Dicta quos qui consequuntur aspernatur ullam non nam odio et. Incidunt fugit sequi. Neque sit vel tenetur libero sit aut quisquam est et. Nostrum autem et.', + heading: 'The post title', + url: '#post', +}; + +/** + * PostPreview Stories - WithCover + */ +export const WithCover = Template.bind({}); +WithCover.args = { + cover: ( + <NextImage + alt="" + height={480} + src="https://picsum.photos/640/480" + width={640} + /> + ), + excerpt: + 'Et vel amet minus. Inventore magnam et vel ea animi omnis qui. Dicta quos qui consequuntur aspernatur ullam non nam odio et. Incidunt fugit sequi. Neque sit vel tenetur libero sit aut quisquam est et. Nostrum autem et.', + heading: 'The post title', + url: '#post', +}; + +/** + * PostPreview Stories - WithMeta + */ +export const WithMeta = Template.bind({}); +WithMeta.args = { + excerpt: + 'Et vel amet minus. Inventore magnam et vel ea animi omnis qui. Dicta quos qui consequuntur aspernatur ullam non nam odio et. Incidunt fugit sequi. Neque sit vel tenetur libero sit aut quisquam est et. Nostrum autem et.', + heading: 'The post title', + meta: { + publicationDate: '06/11/2023', + thematics: [{ id: 1, name: 'Thematic 1', url: '#thematic' }], + wordsCount: 300, + }, + url: '#post', +}; + +/** + * PostPreview Stories - WithCoverAndMeta + */ +export const WithCoverAndMeta = Template.bind({}); +WithCoverAndMeta.args = { + cover: ( + <NextImage + alt="" + height={480} + src="https://picsum.photos/640/480" + width={640} + /> + ), + excerpt: + 'Et vel amet minus. Inventore magnam et vel ea animi omnis qui. Dicta quos qui consequuntur aspernatur ullam non nam odio et. Incidunt fugit sequi. Neque sit vel tenetur libero sit aut quisquam est et. Nostrum autem et.', + heading: 'The post title', + meta: { + publicationDate: '06/11/2023', + wordsCount: 300, + thematics: [{ id: 1, name: 'Thematic 1', url: '#thematic' }], + comments: { + count: 3, + postHeading: 'The post title', + url: '#comments', + }, + }, + url: '#post', +}; diff --git a/src/components/organisms/post-preview/post-preview.test.tsx b/src/components/organisms/post-preview/post-preview.test.tsx new file mode 100644 index 0000000..78012ed --- /dev/null +++ b/src/components/organisms/post-preview/post-preview.test.tsx @@ -0,0 +1,66 @@ +import { describe, expect, it } from '@jest/globals'; +import NextImage from 'next/image'; +import { render, screen as rtlScreen } from '../../../../tests/utils'; +import { PostPreview } from './post-preview'; +import type { PostPreviewMetaData } from './post-preview-meta'; + +describe('PostPreview', () => { + it('renders an excerpt with a heading and a link', () => { + const excerpt = 'At necessitatibus id soluta adipisci quibusdam.'; + const heading = 'impedit et ea'; + const url = '#quia'; + + render(<PostPreview excerpt={excerpt} heading={heading} url={url} />); + + expect(rtlScreen.getByRole('heading')).toHaveTextContent(heading); + expect(rtlScreen.getByRole('link', { name: heading })).toHaveAttribute( + 'href', + url + ); + expect(rtlScreen.getByText(excerpt)).toBeInTheDocument(); + }); + + it('can render a cover', () => { + const excerpt = 'At necessitatibus id soluta adipisci quibusdam.'; + const heading = 'impedit et ea'; + const url = '#quia'; + const altTxt = 'alias consequatur quod'; + + render( + <PostPreview + cover={ + <NextImage + alt={altTxt} + height={480} + src="https://picsum.photos/640/480" + width={640} + /> + } + excerpt={excerpt} + heading={heading} + url={url} + /> + ); + + expect(rtlScreen.getByRole('img')).toHaveAccessibleName(altTxt); + }); + + it('can render some meta', () => { + const excerpt = 'At necessitatibus id soluta adipisci quibusdam.'; + const heading = 'impedit et ea'; + const url = '#quia'; + const meta = { + author: 'Noah_Gleason48', + publicationDate: '2023', + wordsCount: 250, + } satisfies PostPreviewMetaData; + + render( + <PostPreview excerpt={excerpt} heading={heading} meta={meta} url={url} /> + ); + + expect(rtlScreen.getAllByRole('term')).toHaveLength( + Object.keys(meta).length + ); + }); +}); diff --git a/src/components/organisms/post-preview/post-preview.tsx b/src/components/organisms/post-preview/post-preview.tsx new file mode 100644 index 0000000..df459e2 --- /dev/null +++ b/src/components/organisms/post-preview/post-preview.tsx @@ -0,0 +1,132 @@ +import { + type ForwardRefRenderFunction, + type ReactElement, + forwardRef, + type ReactNode, +} from 'react'; +import { useIntl } from 'react-intl'; +import { + ButtonLink, + type HeadingLevel, + Icon, + Link, + VisuallyHidden, +} from '../../atoms'; +import { + Card, + CardActions, + CardBody, + CardFooter, + CardHeader, + type CardProps, + CardTitle, + CardCover, +} from '../../molecules'; +import { PostPreviewMeta, type PostPreviewMetaData } from './post-preview-meta'; +import styles from './post-preview.module.scss'; + +const a11y = (chunks: ReactNode) => <VisuallyHidden>{chunks}</VisuallyHidden>; + +export type PostPreviewProps = Omit< + CardProps<undefined>, + 'children' | 'cover' | 'linkTo' | 'meta' | 'variant' +> & { + /** + * The post cover. + */ + cover?: ReactElement; + /** + * The post excerpt. + */ + excerpt: string; + /** + * The post title. + */ + heading: string; + /** + * The heading level to use on post title. + */ + headingLvl?: HeadingLevel; + /** + * The post meta. + */ + meta?: PostPreviewMetaData; + /** + * The post url. + */ + url: string; +}; + +const PostPreviewWithRef: ForwardRefRenderFunction< + HTMLDivElement, + PostPreviewProps +> = ( + { className, cover, excerpt, heading, headingLvl, meta, url, ...props }, + ref +) => { + const wrapperClass = `${styles.wrapper} ${className}`; + const intl = useIntl(); + const coverLabel = intl.formatMessage( + { + defaultMessage: '{postTitle} cover', + description: + 'PostPreview: an accessible name for the figure wrapping the cover', + id: 'iG5SHf', + }, + { postTitle: heading } + ); + const readMore = intl.formatMessage<ReactNode>( + { + defaultMessage: 'Read more<a11y> about {postTitle}</a11y>', + description: 'PostPreview: read more link', + id: 'BYdQze', + }, + { + postTitle: heading, + a11y, + } + ); + + return ( + <Card + {...props} + className={wrapperClass} + cover={ + cover ? ( + <CardCover aria-label={coverLabel} hasBorders> + {cover} + </CardCover> + ) : undefined + } + meta={meta ? <PostPreviewMeta meta={meta} /> : undefined} + ref={ref} + > + <CardHeader> + <CardTitle className={styles.heading} level={headingLvl}> + <Link href={url}>{heading}</Link> + </CardTitle> + </CardHeader> + <CardBody + className={styles.excerpt} + dangerouslySetInnerHTML={{ __html: excerpt }} + /> + <CardFooter> + <CardActions> + <ButtonLink to={url}> + {readMore} + <Icon + aria-hidden + className={styles.icon} + // eslint-disable-next-line react/jsx-no-literals + orientation="right" + // eslint-disable-next-line react/jsx-no-literals + shape="arrow" + /> + </ButtonLink> + </CardActions> + </CardFooter> + </Card> + ); +}; + +export const PostPreview = forwardRef(PostPreviewWithRef); |
