diff options
| author | Armand Philippot <git@armandphilippot.com> | 2023-11-13 17:45:59 +0100 |
|---|---|---|
| committer | Armand Philippot <git@armandphilippot.com> | 2023-11-13 17:45:59 +0100 |
| commit | 56878f647ea0f1066fa3e222d7aa0d83057f496d (patch) | |
| tree | 26f673a062741414bfa7db5d37990936ce115f49 /src/components/organisms | |
| parent | 599b70cd2390d08ce26ee44174b3f39c6587110c (diff) | |
refactor(components): rewrite PostsList component
* remove NoResults component and move logic to Search page
* add a usePostsList hook
* remove Pagination from PostsList (it is only used if javascript is
disabled and not on every posts list)
* replace `byYear` prop with `sortByYear`
* replace `loadMore` prop with `onLoadMore`
* remove `showLoadMoreBtn` (we can use `loadMore` prop instead to
determine if we need to display the button)
* replace `titleLevel` prop with `headingLvl`
* add `firstNewResult` prop to handle focus on the new results when
loading more article (we should not focus a useless span but the item
directly)
Diffstat (limited to 'src/components/organisms')
15 files changed, 396 insertions, 609 deletions
diff --git a/src/components/organisms/index.ts b/src/components/organisms/index.ts index 04a985d..d0d1f99 100644 --- a/src/components/organisms/index.ts +++ b/src/components/organisms/index.ts @@ -1,9 +1,9 @@ export * from './comment'; export * from './comments-list'; export * from './forms'; -export * from './layout'; export * from './nav'; export * from './navbar'; +export * from './posts-list'; export * from './post-preview'; export * from './project-overview'; export * from './widgets'; diff --git a/src/components/organisms/layout/no-results.stories.tsx b/src/components/organisms/layout/no-results.stories.tsx deleted file mode 100644 index cfcee83..0000000 --- a/src/components/organisms/layout/no-results.stories.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import type { ComponentMeta, ComponentStory } from '@storybook/react'; -import { NoResults as NoResultsComponent } from './no-results'; - -export default { - title: 'Organisms/Layout', - component: NoResultsComponent, - argTypes: {}, -} as ComponentMeta<typeof NoResultsComponent>; - -const Template: ComponentStory<typeof NoResultsComponent> = (args) => ( - <NoResultsComponent {...args} /> -); - -export const NoResults = Template.bind({}); -NoResults.args = {}; diff --git a/src/components/organisms/layout/no-results.test.tsx b/src/components/organisms/layout/no-results.test.tsx deleted file mode 100644 index fdd86f7..0000000 --- a/src/components/organisms/layout/no-results.test.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import { describe, expect, it } from '@jest/globals'; -import { render, screen as rtlScreen } from '../../../../tests/utils'; -import { NoResults } from './no-results'; - -describe('NoResults', () => { - it('renders a text with a form', () => { - render(<NoResults />); - - expect(rtlScreen.getByText(/No results/i)).toBeInTheDocument(); - expect(rtlScreen.getByRole('searchbox')).toBeInTheDocument(); - }); -}); diff --git a/src/components/organisms/layout/no-results.tsx b/src/components/organisms/layout/no-results.tsx deleted file mode 100644 index f760616..0000000 --- a/src/components/organisms/layout/no-results.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import { useRouter } from 'next/router'; -import { type FC, useCallback } from 'react'; -import { useIntl } from 'react-intl'; -import { ROUTES } from '../../../utils/constants'; -import { SearchForm, type SearchFormSubmit } from '../forms'; - -/** - * NoResults component - * - * Renders a no results text with a search form. - */ -export const NoResults: FC = () => { - const intl = useIntl(); - const router = useRouter(); - const searchSubmitHandler: SearchFormSubmit = useCallback( - ({ query }) => { - if (!query) - return { - messages: { - error: intl.formatMessage({ - defaultMessage: 'Query must be longer than one character.', - description: 'NoResults: invalid query message', - id: 'VkfO7t', - }), - }, - validator: (value) => value.query.length > 1, - }; - - router.push({ pathname: ROUTES.SEARCH, query: { s: query } }); - - return undefined; - }, - [intl, router] - ); - - return ( - <> - <p> - {intl.formatMessage({ - defaultMessage: 'No results found.', - description: 'NoResults: no results', - id: '5O2vpy', - })} - </p> - <p> - {intl.formatMessage({ - defaultMessage: 'Would you like to try a new search?', - description: 'NoResults: try a new search message', - id: 'DVBwfu', - })} - </p> - <SearchForm isLabelHidden onSubmit={searchSubmitHandler} /> - </> - ); -}; diff --git a/src/components/organisms/layout/posts-list.fixture.tsx b/src/components/organisms/layout/posts-list.fixture.tsx deleted file mode 100644 index e1f7a95..0000000 --- a/src/components/organisms/layout/posts-list.fixture.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import NextImage from 'next/image'; -import type { PostData } from './posts-list'; - -export const introPost1 = - 'Esse et voluptas sapiente modi impedit unde et. Ducimus nulla ea impedit sit placeat nihil assumenda. Rem est fugiat amet quo hic. Corrupti fuga quod animi autem dolorem ullam corrupti vel aut.'; - -export const introPost2 = - 'Illum quae asperiores quod aut necessitatibus itaque excepturi voluptas. Incidunt exercitationem ullam saepe alias consequatur sed. Quam veniam quaerat voluptatum earum quia quisquam fugiat sed perspiciatis. Et velit saepe est recusandae facilis eos eum ipsum.'; - -export const introPost3 = - 'Sunt aperiam ut rem impedit dolor id sit. Reprehenderit ipsum iusto fugiat. Quaerat laboriosam magnam facilis. Totam sint aliquam voluptatem in quis laborum sunt eum. Enim aut debitis officiis porro iure quia nihil voluptas ipsum. Praesentium quis necessitatibus cumque quia qui velit quos dolorem.'; - -export const cover = { - alt: 'cover', - height: 480, - src: 'http://picsum.photos/640/480', - width: 640, -}; - -export const posts: PostData[] = [ - { - cover: <NextImage {...cover} />, - excerpt: introPost1, - id: 'post-1', - meta: { - publicationDate: '2022-02-26', - wordsCount: introPost1.split(' ').length, - thematics: [ - { id: 1, name: 'Cat 1', url: '#' }, - { id: 2, name: 'Cat 2', url: '#' }, - ], - comments: { count: 1, postHeading: 'Ratione velit fuga' }, - }, - heading: 'Ratione velit fuga', - url: '#', - }, - { - excerpt: introPost2, - id: 'post-2', - meta: { - publicationDate: '2022-02-20', - wordsCount: introPost2.split(' ').length, - thematics: [{ id: 2, name: 'Cat 2', url: '#' }], - comments: { count: 0, postHeading: 'Debitis laudantium laudantium' }, - }, - heading: 'Debitis laudantium laudantium', - url: '#', - }, - { - cover: <NextImage {...cover} />, - excerpt: introPost3, - id: 'post-3', - meta: { - publicationDate: '2021-12-20', - wordsCount: introPost3.split(' ').length, - thematics: [{ id: 1, name: 'Cat 1', url: '#' }], - comments: { count: 3, postHeading: 'Quaerat ut corporis' }, - }, - heading: 'Quaerat ut corporis', - url: '#', - }, -]; diff --git a/src/components/organisms/layout/posts-list.module.scss b/src/components/organisms/layout/posts-list.module.scss deleted file mode 100644 index cc5acda..0000000 --- a/src/components/organisms/layout/posts-list.module.scss +++ /dev/null @@ -1,66 +0,0 @@ -@use "../../../styles/abstracts/functions" as fun; -@use "../../../styles/abstracts/mixins" as mix; -@use "../../../styles/abstracts/placeholders"; - -.section { - &:not(:last-of-type) { - margin-bottom: var(--spacing-md); - } - - @include mix.media("screen") { - @include mix.dimensions("md") { - display: grid; - grid-template-columns: fun.convert-px(150) minmax(0, 1fr); - align-items: first baseline; - margin-left: fun.convert-px(-150); - } - } -} - -.list { - .item { - border-bottom: fun.convert-px(1) solid var(--color-border); - } -} - -.year { - margin-bottom: var(--spacing-md); - padding-bottom: fun.convert-px(3); - background: linear-gradient( - to top, - var(--color-primary-dark) 0.3rem, - transparent 0.3rem - ) - 0 0 / 3rem 100% no-repeat; - font-size: var(--font-size-2xl); - font-weight: 500; - text-shadow: fun.convert-px(1) fun.convert-px(1) 0 var(--color-shadow-light); - - @include mix.media("screen") { - @include mix.dimensions("md") { - grid-column: 1; - justify-self: end; - padding-right: var(--spacing-lg); - position: sticky; - top: var(--spacing-xs); - } - - @include mix.dimensions("lg") { - padding-right: var(--spacing-xl); - } - } -} - -.btn { - display: flex; - margin: auto; -} - -.progress { - margin-block: var(--spacing-md); -} - -.pagination { - margin-inline: auto; - margin-block-end: var(--spacing-lg); -} diff --git a/src/components/organisms/layout/posts-list.test.tsx b/src/components/organisms/layout/posts-list.test.tsx deleted file mode 100644 index fabf31f..0000000 --- a/src/components/organisms/layout/posts-list.test.tsx +++ /dev/null @@ -1,31 +0,0 @@ -import { describe, expect, it } from '@jest/globals'; -import { render, screen as rtlScreen } from '../../../../tests/utils'; -import { PostsList } from './posts-list'; -import { posts } from './posts-list.fixture'; - -describe('PostsList', () => { - it('renders the correct number of posts', () => { - render(<PostsList posts={posts} total={posts.length} />); - expect(rtlScreen.getAllByRole('article')).toHaveLength(posts.length); - }); - - it('renders the number of loaded posts', () => { - render(<PostsList posts={posts} total={posts.length} />); - const info = `${posts.length} loaded articles out of a total of ${posts.length}`; - expect(rtlScreen.getByText(info)).toBeInTheDocument(); - }); - - it('renders a load more button', () => { - render( - <PostsList posts={posts} total={posts.length} showLoadMoreBtn={true} /> - ); - expect( - rtlScreen.getByRole('button', { name: /Load more/i }) - ).toBeInTheDocument(); - }); - - it('renders a search form if no results', () => { - render(<PostsList posts={[]} total={0} showLoadMoreBtn={true} />); - expect(rtlScreen.getByRole('searchbox')).toBeInTheDocument(); - }); -}); diff --git a/src/components/organisms/layout/posts-list.tsx b/src/components/organisms/layout/posts-list.tsx deleted file mode 100644 index 40306a6..0000000 --- a/src/components/organisms/layout/posts-list.tsx +++ /dev/null @@ -1,327 +0,0 @@ -/* eslint-disable max-statements */ -import { type FC, Fragment, useRef, useCallback, useId } from 'react'; -import { useIntl } from 'react-intl'; -import { useIsMounted, useSettings } from '../../../utils/hooks'; -import { - Button, - Heading, - type HeadingLevel, - ProgressBar, - Spinner, - List, - ListItem, -} from '../../atoms'; -import { - Pagination, - type PaginationProps, - type RenderPaginationItemAriaLabel, - type RenderPaginationLink, -} from '../nav'; -import { - PostPreview, - type PostPreviewMetaData, - type PostPreviewProps, -} from '../post-preview'; -import { NoResults } from './no-results'; -import styles from './posts-list.module.scss'; - -export type PostData = Pick< - PostPreviewProps, - 'cover' | 'excerpt' | 'heading' | 'url' -> & { - /** - * The post id. - */ - id: string | number; - /** - * The post meta. - */ - meta: PostPreviewMetaData & - Required<Pick<PostPreviewMetaData, 'publicationDate'>>; -}; - -export type YearCollection = Record<string, PostData[]>; - -export type PostsListProps = Pick<PaginationProps, 'siblings'> & { - /** - * The pagination base url. - */ - baseUrl?: string; - /** - * True to display the posts by year. Default: false. - */ - byYear?: boolean; - /** - * Determine if the data is loading. - */ - isLoading?: boolean; - /** - * Load more button handler. - */ - loadMore?: () => void; - /** - * The current page number. Default: 1. - */ - pageNumber?: number; - /** - * The posts data. - */ - posts: PostData[]; - /** - * Determine if the load more button should be visible. - */ - showLoadMoreBtn?: boolean; - /** - * The posts heading level (hn). - */ - titleLevel?: HeadingLevel; - /** - * The total posts number. - */ - total: number; -}; - -/** - * Create a collection of posts sorted by year. - * - * @param {PostData[]} data - A collection of posts. - * @returns {YearCollection} The posts sorted by year. - */ -const sortPostsByYear = (data: PostData[]): YearCollection => { - const yearCollection: Partial<YearCollection> = {}; - - data.forEach((post) => { - const postYear = new Date(post.meta.publicationDate) - .getFullYear() - .toString(); - yearCollection[postYear] = [...(yearCollection[postYear] ?? []), post]; - }); - - return yearCollection as YearCollection; -}; - -/** - * PostsList component - * - * Render a list of post summaries. - */ -export const PostsList: FC<PostsListProps> = ({ - baseUrl = '', - byYear = false, - isLoading = false, - loadMore, - pageNumber = 1, - posts, - showLoadMoreBtn = false, - siblings, - titleLevel, - total, -}) => { - const intl = useIntl(); - const listRef = useRef<HTMLOListElement>(null); - const lastPostRef = useRef<HTMLSpanElement>(null); - const isMounted = useIsMounted(listRef); - const { blog } = useSettings(); - const lastPostId = posts.length ? posts[posts.length - 1].id : 0; - const progressBarId = useId(); - - /** - * Retrieve the list of posts. - * - * @param {PostData[]} allPosts - A collection fo posts. - * @param {HeadingLevel} [headingLevel] - The posts heading level (hn). - * @returns {JSX.Element} The list of posts. - */ - const getList = ( - allPosts: PostData[], - headingLevel: HeadingLevel = 2 - ): JSX.Element => ( - <List - aria-busy={isLoading} - aria-describedby={progressBarId} - className={styles.list} - hideMarker - isOrdered - ref={listRef} - spacing="md" - > - {allPosts.map(({ id, ...post }) => ( - <Fragment key={id}> - <ListItem className={styles.item}> - <PostPreview {...post} headingLvl={headingLevel} /> - </ListItem> - {id === lastPostId && ( - <ListItem> - <span ref={lastPostRef} tabIndex={-1} /> - </ListItem> - )} - </Fragment> - ))} - </List> - ); - - /** - * Retrieve the list of posts. - * - * @returns {JSX.Element | JSX.Element[]} The posts list. - */ - const getPosts = (): JSX.Element | JSX.Element[] => { - const firstLevel = titleLevel ?? 2; - if (!byYear) return getList(posts, firstLevel); - - const postsPerYear = sortPostsByYear(posts); - const years = Object.keys(postsPerYear).reverse(); - const nextLevel = (firstLevel + 1) as HeadingLevel; - - return years.map((year) => ( - <section key={year} className={styles.section}> - <Heading level={firstLevel} className={styles.year}> - {year} - </Heading> - {getList(postsPerYear[year], nextLevel)} - </section> - )); - }; - - const loadedPostsCount = - pageNumber === 1 - ? posts.length - : pageNumber * blog.postsPerPage + posts.length; - const progressInfo = intl.formatMessage( - { - defaultMessage: - '{articlesCount, plural, =0 {# loaded articles} one {# loaded article} other {# loaded articles}} out of a total of {total}', - description: 'PostsList: loaded articles progress', - id: '9MeLN3', - }, - { - articlesCount: loadedPostsCount, - total, - } - ); - - const loadMoreBody = intl.formatMessage({ - defaultMessage: 'Load more articles?', - description: 'PostsList: load more button', - id: 'uaqd5F', - }); - - const loadingMoreArticles = intl.formatMessage({ - defaultMessage: 'Loading more articles...', - description: 'PostsList: loading more articles message', - id: 'xYemkP', - }); - - /** - * Load more posts handler. - */ - const loadMorePosts = useCallback(() => { - if (lastPostRef.current) { - lastPostRef.current.focus(); - } - - if (loadMore) loadMore(); - }, [loadMore]); - - const getProgressBar = () => ( - <> - <ProgressBar - aria-label={progressInfo} - className={styles.progress} - current={loadedPostsCount} - id={progressBarId} - isCentered - isLoading={isLoading} - label={progressInfo} - max={total} - /> - {showLoadMoreBtn ? ( - <Button - className={styles.btn} - isDisabled={isLoading} - // eslint-disable-next-line react/jsx-no-literals -- Kind allowed. - kind="tertiary" - onClick={loadMorePosts} - > - {loadMoreBody} - </Button> - ) : null} - </> - ); - - const paginationAriaLabel = intl.formatMessage({ - defaultMessage: 'Pagination', - description: 'PostsList: pagination accessible name', - id: 'k1aA+G', - }); - - const renderItemAriaLabel: RenderPaginationItemAriaLabel = useCallback( - ({ kind, pageNumber: page, isCurrentPage }) => { - switch (kind) { - case 'backward': - return intl.formatMessage({ - defaultMessage: 'Go to previous page', - description: 'PostsList: pagination backward link label', - id: 'PHO94k', - }); - case 'forward': - return intl.formatMessage({ - defaultMessage: 'Go to next page', - description: 'PostsList: pagination forward link label', - id: 'HaKhih', - }); - case 'number': - default: - return isCurrentPage - ? intl.formatMessage( - { - defaultMessage: 'Current page, page {number}', - description: 'PostsList: pagination current page label', - id: 'nwDGkZ', - }, - { number: page } - ) - : intl.formatMessage( - { - defaultMessage: 'Go to page {number}', - description: 'PostsList: pagination page link label', - id: 'AmHSC4', - }, - { number: page } - ); - } - }, - [intl] - ); - - const renderLink: RenderPaginationLink = useCallback( - (page) => `${baseUrl}${page}`, - [baseUrl] - ); - - const getPagination = () => { - if (total < blog.postsPerPage) return null; - - return ( - <Pagination - aria-label={paginationAriaLabel} - className={styles.pagination} - current={pageNumber} - renderItemAriaLabel={renderItemAriaLabel} - renderLink={renderLink} - siblings={siblings} - total={Math.round(total / blog.postsPerPage)} - /> - ); - }; - - if (posts.length === 0) return <NoResults />; - - return ( - <> - {getPosts()} - {isLoading ? <Spinner>{loadingMoreArticles}</Spinner> : null} - {isMounted ? getProgressBar() : getPagination()} - </> - ); -}; diff --git a/src/components/organisms/nav/pagination/pagination.module.scss b/src/components/organisms/nav/pagination/pagination.module.scss index 13970d3..eeb9ca6 100644 --- a/src/components/organisms/nav/pagination/pagination.module.scss +++ b/src/components/organisms/nav/pagination/pagination.module.scss @@ -4,6 +4,10 @@ gap: var(--spacing-sm); align-items: center; width: fit-content; + + &--centered { + margin-inline: auto; + } } .list { diff --git a/src/components/organisms/nav/pagination/pagination.tsx b/src/components/organisms/nav/pagination/pagination.tsx index 8e95122..006663b 100644 --- a/src/components/organisms/nav/pagination/pagination.tsx +++ b/src/components/organisms/nav/pagination/pagination.tsx @@ -32,6 +32,12 @@ export type PaginationProps = Omit<NavProps, 'children'> & { */ current: number; /** + * Should the pagination be centered? + * + * @default false + */ + isCentered?: boolean; + /** * Function used to provide an accessible label to pagination items. */ renderItemAriaLabel: RenderPaginationItemAriaLabel; @@ -83,6 +89,7 @@ const PaginationWithRef: ForwardRefRenderFunction< { className = '', current, + isCentered = false, renderItemAriaLabel, renderLink, siblings = 1, @@ -91,7 +98,11 @@ const PaginationWithRef: ForwardRefRenderFunction< }, ref ) => { - const paginationClass = `${styles.wrapper} ${className}`; + const paginationClass = [ + styles.wrapper, + styles[isCentered ? 'wrapper--centered' : ''], + className, + ].join(' '); const displayRange = current === 1 || current === total ? siblings + 1 : siblings; const hasPreviousPage = current > 1; diff --git a/src/components/organisms/layout/index.ts b/src/components/organisms/posts-list/index.ts index 03fba32..a5faa8e 100644 --- a/src/components/organisms/layout/index.ts +++ b/src/components/organisms/posts-list/index.ts @@ -1,2 +1 @@ -export * from './no-results'; export * from './posts-list'; diff --git a/src/components/organisms/posts-list/posts-list.module.scss b/src/components/organisms/posts-list/posts-list.module.scss new file mode 100644 index 0000000..fc0ef44 --- /dev/null +++ b/src/components/organisms/posts-list/posts-list.module.scss @@ -0,0 +1,40 @@ +@use "../../../styles/abstracts/functions" as fun; +@use "../../../styles/abstracts/mixins" as mix; +@use "../../../styles/abstracts/placeholders"; + +.section { + max-width: 100%; + margin-block-end: var(--spacing-md); + + @include mix.media("screen") { + @include mix.dimensions("md") { + display: grid; + grid-template-columns: var(--col1, auto) minmax(0, 1fr); + align-items: first baseline; + gap: var(--gap, var(--spacing-lg)); + } + } +} + +:where(.section) .year { + @extend %h2; + + margin-bottom: var(--spacing-md); + + @include mix.media("screen") { + @include mix.dimensions("md") { + grid-column: 1; + position: sticky; + top: var(--spacing-xs); + justify-self: end; + } + } +} + +.progress { + margin-block: var(--spacing-md); +} + +.btn { + margin-inline: auto; +} diff --git a/src/components/organisms/layout/posts-list.stories.tsx b/src/components/organisms/posts-list/posts-list.stories.tsx index b5af1d3..0a00afe 100644 --- a/src/components/organisms/layout/posts-list.stories.tsx +++ b/src/components/organisms/posts-list/posts-list.stories.tsx @@ -1,21 +1,13 @@ import type { ComponentMeta, ComponentStory } from '@storybook/react'; import { PostsList } from './posts-list'; -import { posts } from './posts-list.fixture'; /** * PostsList - Storybook Meta */ export default { - title: 'Organisms/Layout/PostsList', + title: 'Organisms/PostsList', component: PostsList, - args: { - byYear: false, - isLoading: false, - pageNumber: 1, - showLoadMoreBtn: false, - siblings: 1, - titleLevel: 2, - }, + args: {}, argTypes: { baseUrl: { control: { @@ -160,32 +152,30 @@ const Template: ComponentStory<typeof PostsList> = (args) => ( */ export const Default = Template.bind({}); Default.args = { - posts, - total: posts.length, -}; - -/** - * PostsList Stories - By years - */ -export const ByYears = Template.bind({}); -ByYears.args = { - posts, - byYear: true, - total: posts.length, -}; -ByYears.decorators = [ - (Story) => ( - <div style={{ marginLeft: 150 }}> - <Story /> - </div> - ), -]; - -/** - * PostsList Stories - No results - */ -export const NoResults = Template.bind({}); -NoResults.args = { - posts: [], - total: posts.length, + posts: [ + { + excerpt: + 'Omnis voluptatem et sit sit porro possimus quo rerum. Natus et sint cupiditate magnam omnis a consequuntur reprehenderit. Ex omnis voluptatem itaque id laboriosam qui dolorum facilis architecto. Impedit aliquid et qui quae dolorum accusamus rerum.', + heading: 'Post 1', + id: 'post1', + meta: { publicationDate: '2023-11-06' }, + url: '#post1', + }, + { + excerpt: + 'Nobis omnis excepturi deserunt laudantium unde totam quam. Voluptates maiores minima voluptatem nihil ea voluptatem similique. Praesentium ratione necessitatibus et et dolore voluptas illum dignissimos ipsum. Eius tempore ex.', + heading: 'Post 2', + id: 'post2', + meta: { publicationDate: '2023-11-05' }, + url: '#post2', + }, + { + excerpt: + 'Doloremque est dolorum explicabo. Laudantium quos delectus odit esse fugit officiis. Fugit provident vero harum atque. Eos nam qui sit ut minus voluptas. Reprehenderit rerum ut nostrum. Eos dolores mollitia quia ea voluptatem rerum vel.', + heading: 'Post 3', + id: 'post3', + meta: { publicationDate: '2023-11-04' }, + url: '#post3', + }, + ], }; diff --git a/src/components/organisms/posts-list/posts-list.test.tsx b/src/components/organisms/posts-list/posts-list.test.tsx new file mode 100644 index 0000000..8d91162 --- /dev/null +++ b/src/components/organisms/posts-list/posts-list.test.tsx @@ -0,0 +1,75 @@ +import { describe, expect, it, jest } from '@jest/globals'; +import { userEvent } from '@testing-library/user-event'; +import { render, screen as rtlScreen } from '../../../../tests/utils'; +import { type PostData, PostsList } from './posts-list'; + +const posts = [ + { + excerpt: + 'Omnis voluptatem et sit sit porro possimus quo rerum. Natus et sint cupiditate magnam omnis a consequuntur reprehenderit. Ex omnis voluptatem itaque id laboriosam qui dolorum facilis architecto. Impedit aliquid et qui quae dolorum accusamus rerum.', + heading: 'Post 1', + id: 'post1', + meta: { publicationDate: '2023-11-06' }, + url: '#post1', + }, + { + excerpt: + 'Nobis omnis excepturi deserunt laudantium unde totam quam. Voluptates maiores minima voluptatem nihil ea voluptatem similique. Praesentium ratione necessitatibus et et dolore voluptas illum dignissimos ipsum. Eius tempore ex.', + heading: 'Post 2', + id: 'post2', + meta: { publicationDate: '2023-02-05' }, + url: '#post2', + }, + { + excerpt: + 'Doloremque est dolorum explicabo. Laudantium quos delectus odit esse fugit officiis. Fugit provident vero harum atque. Eos nam qui sit ut minus voluptas. Reprehenderit rerum ut nostrum. Eos dolores mollitia quia ea voluptatem rerum vel.', + heading: 'Post 3', + id: 'post3', + meta: { publicationDate: '2022-10-04' }, + url: '#post3', + }, +] satisfies PostData[]; + +describe('PostsList', () => { + it('renders a list of posts', () => { + render(<PostsList posts={posts} />); + + expect(rtlScreen.getAllByRole('article')).toHaveLength(posts.length); + }); + + it('can render a list of posts divided by year in sections', () => { + const yearHeadingLvl = 2; + const yearCount = new Set( + posts.map((post) => post.meta.publicationDate.split('-')[0]) + ).size; + + render(<PostsList headingLvl={yearHeadingLvl} posts={posts} sortByYear />); + + expect( + rtlScreen.getAllByRole('heading', { level: yearHeadingLvl }) + ).toHaveLength(yearCount); + expect( + rtlScreen.getAllByRole('heading', { level: yearHeadingLvl + 1 }) + ).toHaveLength(posts.length); + }); + + it('can render a load more button', async () => { + const loadMore = jest.fn(); + const user = userEvent.setup(); + + render(<PostsList onLoadMore={loadMore} posts={posts} />); + + // eslint-disable-next-line @typescript-eslint/no-magic-numbers + expect.assertions(3); + + expect(loadMore).not.toHaveBeenCalled(); + + const loadMoreBtn = rtlScreen.getByRole('button', { name: /Load more/ }); + + expect(loadMoreBtn).toBeInTheDocument(); + + await user.click(loadMoreBtn); + + expect(loadMore).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/components/organisms/posts-list/posts-list.tsx b/src/components/organisms/posts-list/posts-list.tsx new file mode 100644 index 0000000..783bc4e --- /dev/null +++ b/src/components/organisms/posts-list/posts-list.tsx @@ -0,0 +1,236 @@ +import { + type ForwardRefRenderFunction, + type HTMLAttributes, + forwardRef, + useCallback, + useRef, + type RefCallback, +} from 'react'; +import { useIntl } from 'react-intl'; +import { mergeRefs } from '../../../utils/helpers'; +import { + Heading, + type HeadingLevel, + List, + ListItem, + Button, + ProgressBar, +} from '../../atoms'; +import { + PostPreview, + type PostPreviewMetaData, + type PostPreviewProps, +} from '../post-preview'; +import styles from './posts-list.module.scss'; + +const MAX_HEADING_LVL = 6; + +export type PostData = Pick< + PostPreviewProps, + 'cover' | 'excerpt' | 'heading' | 'url' +> & { + /** + * The post id. + */ + id: string | number; + /** + * The post meta. + */ + meta: PostPreviewMetaData & + Required<Pick<PostPreviewMetaData, 'publicationDate'>>; +}; + +const getPostsByYear = (posts: PostData[]) => { + const yearCollection = new Map<string, PostData[]>(); + + for (const post of posts) { + const currentPostYear = new Date(post.meta.publicationDate) + .getFullYear() + .toString(); + + const yearPosts = yearCollection.get(currentPostYear) ?? []; + + yearCollection.set(currentPostYear, [...yearPosts, post]); + } + + return yearCollection; +}; + +type GetPostsListOptions = { + headingLvl: HeadingLevel; + isOrdered?: boolean; +}; + +export type PostsListProps = Omit< + HTMLAttributes<HTMLDivElement>, + 'children' +> & { + /** + * The first new result index. It will be use to make the load more button + * accessible for keyboard users. + */ + firstNewResult?: number; + /** + * The heading level to use on posts titles. + * + * @default 2 + */ + headingLvl?: HeadingLevel; + /** + * Should we indicate that new posts are loading? + * + * @default false + */ + isLoading?: boolean; + /** + * A callback function to handle loading more posts. + */ + onLoadMore?: () => void; + /** + * The posts. + */ + posts: PostData[]; + /** + * Should we use a different section by year? + */ + sortByYear?: boolean; + /** + * The total posts number. + */ + total?: number; +}; + +const PostsListWithRef: ForwardRefRenderFunction< + HTMLDivElement, + PostsListProps +> = ( + { + firstNewResult, + headingLvl = 2, + isLoading = false, + onLoadMore, + posts, + sortByYear = false, + total, + ...props + }, + ref +) => { + const wrapperRef = useRef<HTMLDivElement | null>(null); + const firstNewResultRef: RefCallback<HTMLLIElement> = useCallback((el) => { + el?.focus(); + }, []); + const intl = useIntl(); + const progressInfo = intl.formatMessage( + { + defaultMessage: + '{articlesCount, plural, =0 {# loaded articles} one {# loaded article} other {# loaded articles}} out of a total of {total}', + description: 'PostsList: loaded articles progress', + id: '9MeLN3', + }, + { + articlesCount: posts.length, + total, + } + ); + const loadMoreBtn = intl.formatMessage({ + defaultMessage: 'Load more posts?', + description: 'PostsList: load more button', + id: 'hGvQpI', + }); + + const getPostsList = useCallback( + ( + data: PostData[], + { headingLvl: lvl, isOrdered }: GetPostsListOptions, + indexAcc = 0 + ) => ( + <List + hideMarker + isOrdered={isOrdered} + // eslint-disable-next-line react/jsx-no-literals + spacing="md" + > + {data.map(({ id, ...post }, index) => { + const isFirstNewResult = firstNewResult === indexAcc + index; + + return ( + <ListItem + key={id} + ref={isFirstNewResult ? firstNewResultRef : undefined} + tabIndex={isFirstNewResult ? -1 : undefined} + > + <PostPreview {...post} headingLvl={lvl} /> + </ListItem> + ); + })} + </List> + ), + [firstNewResult, firstNewResultRef] + ); + + const getSortedPostsList = useCallback( + (data: PostData[]) => { + const postsByYear = Array.from(getPostsByYear(data)); + const postsLvl = + headingLvl < MAX_HEADING_LVL + ? ((headingLvl + 1) as HeadingLevel) + : headingLvl; + let indexAcc = 0; + + return postsByYear.map(([year, sortedPosts], index) => { + indexAcc += + index > 0 ? postsByYear[index - 1][1].length : sortedPosts.length; + + return ( + <section className={styles.section} key={year}> + <Heading className={styles.year} level={headingLvl}> + {year} + </Heading> + {getPostsList( + sortedPosts, + { + headingLvl: postsLvl, + isOrdered: true, + }, + indexAcc + )} + </section> + ); + }); + }, + [getPostsList, headingLvl] + ); + + return ( + <div {...props} ref={mergeRefs([wrapperRef, ref])}> + {sortByYear + ? getSortedPostsList(posts) + : getPostsList(posts, { headingLvl })} + {total ? ( + <ProgressBar + aria-label={progressInfo} + className={styles.progress} + current={posts.length} + isCentered + isLoading={isLoading} + label={progressInfo} + max={total} + /> + ) : null} + {onLoadMore ? ( + <Button + className={styles.btn} + isLoading={isLoading} + // eslint-disable-next-line react/jsx-no-literals + kind="tertiary" + onClick={onLoadMore} + > + {loadMoreBtn} + </Button> + ) : null} + </div> + ); +}; + +export const PostsList = forwardRef(PostsListWithRef); |
