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 | |
| 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')
32 files changed, 859 insertions, 731 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); diff --git a/src/components/templates/page/page-layout.stories.tsx b/src/components/templates/page/page-layout.stories.tsx index 4086fcd..20740db 100644 --- a/src/components/templates/page/page-layout.stories.tsx +++ b/src/components/templates/page/page-layout.stories.tsx @@ -1,7 +1,6 @@ import type { ComponentMeta, ComponentStory } from '@storybook/react'; import { ButtonLink, Heading, Link } from '../../atoms'; import { LinksListWidget, PostsList, Sharing } from '../../organisms'; -import { posts } from '../../organisms/layout/posts-list.fixture'; import { LayoutBase } from '../layout/layout.stories'; import { PageLayout as PageLayoutComponent } from './page-layout'; @@ -465,6 +464,33 @@ const blogCategories = [ { name: 'Cat 4', url: '#' }, ]; +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-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', + }, +]; + /** * Page Layout Stories - Posts list */ @@ -473,7 +499,7 @@ Blog.args = { breadcrumb: postsListBreadcrumb, title: 'Blog', headerMeta: [{ id: 'total', label: 'Total:', value: `${posts.length}` }], - children: <PostsList posts={posts} byYear={true} total={posts.length} />, + children: <PostsList posts={posts} sortByYear />, widgets: [ <LinksListWidget heading={ diff --git a/src/i18n/en.json b/src/i18n/en.json index 094bf56..2b18178 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -115,10 +115,6 @@ "defaultMessage": "{website} logo", "description": "Layout: logo title" }, - "5O2vpy": { - "defaultMessage": "No results found.", - "description": "NoResults: no results" - }, "5eD6y2": { "defaultMessage": "Full", "description": "AckeeToggle: full option name" @@ -191,9 +187,9 @@ "defaultMessage": "Contact", "description": "ContactPage: page title" }, - "AmHSC4": { - "defaultMessage": "Go to page {number}", - "description": "PostsList: pagination page link label" + "AXe1Iz": { + "defaultMessage": "Pagination", + "description": "BlogPage: pagination accessible name" }, "B1lS/v": { "defaultMessage": "Reading time:", @@ -235,10 +231,6 @@ "defaultMessage": "Blog", "description": "Layout: main nav - blog link" }, - "DVBwfu": { - "defaultMessage": "Would you like to try a new search?", - "description": "NoResults: try a new search message" - }, "Dq6+WH": { "defaultMessage": "Thematics", "description": "SearchPage: thematics list widget title" @@ -275,10 +267,6 @@ "defaultMessage": "Reading time:", "description": "ArticlePage: reading time label" }, - "HaKhih": { - "defaultMessage": "Go to next page", - "description": "PostsList: pagination forward link label" - }, "HohQPh": { "defaultMessage": "Thematics", "description": "Error404Page: thematics list widget title" @@ -291,10 +279,18 @@ "defaultMessage": "Published on:", "description": "ProjectsPage: publication date label" }, + "IVczxR": { + "defaultMessage": "Go to page {number}", + "description": "BlogPage: page number label" + }, "IY5ew6": { "defaultMessage": "Submitting...", "description": "CommentForm: spinner message on submit" }, + "JL6G22": { + "defaultMessage": "Current page, page {number}", + "description": "BlogPage: current page label" + }, "JbT+fA": { "defaultMessage": "Updated on:", "description": "ProjectOverview: update date label" @@ -379,10 +375,6 @@ "defaultMessage": "{starsCount, plural, =0 {No stars} one {# star} other {# stars}}", "description": "ProjectOverview: stars count" }, - "PHO94k": { - "defaultMessage": "Go to previous page", - "description": "PostsList: pagination backward link label" - }, "PXp2hv": { "defaultMessage": "{websiteName} | Front-end developer: WordPress/React", "description": "HomePage: SEO - Page title" @@ -487,6 +479,10 @@ "defaultMessage": "Share on LinkedIn", "description": "Sharing: LinkedIn sharing link" }, + "YV//MH": { + "defaultMessage": "No results found.", + "description": "SearchPage: no results" + }, "Ygea7s": { "defaultMessage": "Light theme", "description": "ThemeToggle: light theme label" @@ -555,6 +551,10 @@ "defaultMessage": "Failed to load.", "description": "SearchPage: failed to load text" }, + "faO6BQ": { + "defaultMessage": "Go to previous page, page {number}", + "description": "BlogPage: previous page label" + }, "fkcTGp": { "defaultMessage": "An error occurred:", "description": "PageLayout: comment form error message" @@ -571,6 +571,10 @@ "defaultMessage": "Settings form", "description": "Layout: an accessible name for the settings form in navbar" }, + "hGvQpI": { + "defaultMessage": "Load more posts?", + "description": "PostsList: load more button" + }, "hHVgW3": { "defaultMessage": "Light Theme 🌞", "description": "usePrism: toggle light theme button text" @@ -599,10 +603,6 @@ "defaultMessage": "Linux", "description": "HomePage: link to Linux thematic" }, - "k1aA+G": { - "defaultMessage": "Pagination", - "description": "PostsList: pagination accessible name" - }, "kNBXyK": { "defaultMessage": "Total:", "description": "Page: total label" @@ -647,10 +647,6 @@ "defaultMessage": "Copied!", "description": "usePrism: copy button text (clicked)" }, - "nwDGkZ": { - "defaultMessage": "Current page, page {number}", - "description": "PostsList: pagination current page label" - }, "nwbzKm": { "defaultMessage": "Legal notice", "description": "Layout: Legal notice label" @@ -671,6 +667,10 @@ "defaultMessage": "Dark theme", "description": "PrismThemeToggle: dark theme label" }, + "oq3BzP": { + "defaultMessage": "Go to next page, page {number}", + "description": "BlogPage: next page label" + }, "pT5nHk": { "defaultMessage": "Published on:", "description": "HomePage: publication date label" @@ -743,10 +743,6 @@ "defaultMessage": "Cancel reply", "description": "CommentsList: cancel reply button" }, - "uaqd5F": { - "defaultMessage": "Load more articles?", - "description": "PostsList: load more button" - }, "va65iw": { "defaultMessage": "On", "description": "MotionToggle: activate reduce motion label" @@ -759,6 +755,10 @@ "defaultMessage": "Share on Journal du Hacker", "description": "Sharing: Journal du Hacker sharing link" }, + "vtDLzG": { + "defaultMessage": "Would you like to try a new search?", + "description": "SearchPage: try a new search message" + }, "w4B5PA": { "defaultMessage": "Email:", "description": "ContactForm: email label" @@ -771,10 +771,6 @@ "defaultMessage": "Updated on:", "description": "ProjectsPage: update date label" }, - "xYemkP": { - "defaultMessage": "Loading more articles...", - "description": "PostsList: loading more articles message" - }, "xaqaYQ": { "defaultMessage": "Sending mail...", "description": "ContactForm: spinner message on submit" diff --git a/src/i18n/fr.json b/src/i18n/fr.json index 5d0fd21..1dd1688 100644 --- a/src/i18n/fr.json +++ b/src/i18n/fr.json @@ -115,10 +115,6 @@ "defaultMessage": "Logo du site d’{website}", "description": "Layout: logo title" }, - "5O2vpy": { - "defaultMessage": "Aucun résultat.", - "description": "NoResults: no results" - }, "5eD6y2": { "defaultMessage": "Complet", "description": "AckeeToggle: full option name" @@ -191,9 +187,9 @@ "defaultMessage": "Contact", "description": "ContactPage: page title" }, - "AmHSC4": { - "defaultMessage": "Aller à la page {number}", - "description": "PostsList: pagination page link label" + "AXe1Iz": { + "defaultMessage": "Pagination", + "description": "BlogPage: pagination accessible name" }, "B1lS/v": { "defaultMessage": "Temps de lecture :", @@ -235,10 +231,6 @@ "defaultMessage": "Blog", "description": "Layout: main nav - blog link" }, - "DVBwfu": { - "defaultMessage": "Souhaitez-vous effectuer une nouvelle recherche ?", - "description": "NoResults: try a new search message" - }, "Dq6+WH": { "defaultMessage": "Thématiques", "description": "SearchPage: thematics list widget title" @@ -275,10 +267,6 @@ "defaultMessage": "Temps de lecture :", "description": "ArticlePage: reading time label" }, - "HaKhih": { - "defaultMessage": "Aller à la page suivante", - "description": "PostsList: pagination forward link label" - }, "HohQPh": { "defaultMessage": "Thématiques", "description": "Error404Page: thematics list widget title" @@ -291,10 +279,18 @@ "defaultMessage": "Publié le :", "description": "ProjectsPage: publication date label" }, + "IVczxR": { + "defaultMessage": "Aller à la page {number}", + "description": "BlogPage: page number label" + }, "IY5ew6": { "defaultMessage": "En cours d’envoi…", "description": "CommentForm: spinner message on submit" }, + "JL6G22": { + "defaultMessage": "Page actuelle, page {number}", + "description": "BlogPage: current page label" + }, "JbT+fA": { "defaultMessage": "Mis à jour le :", "description": "ProjectOverview: update date label" @@ -379,10 +375,6 @@ "defaultMessage": "{starsCount, plural, =0 {0 étoile} one {# étoile} other {# étoiles}}", "description": "ProjectOverview: stars count" }, - "PHO94k": { - "defaultMessage": "Aller à la page précédente", - "description": "PostsList: pagination backward link label" - }, "PXp2hv": { "defaultMessage": "{websiteName} | Intégrateur web - Développeur WordPress / React", "description": "HomePage: SEO - Page title" @@ -487,6 +479,10 @@ "defaultMessage": "Partager sur LinkedIn", "description": "Sharing: LinkedIn sharing link" }, + "YV//MH": { + "defaultMessage": "Aucun résultat.", + "description": "SearchPage: no results" + }, "Ygea7s": { "defaultMessage": "Thème clair", "description": "ThemeToggle: light theme label" @@ -555,6 +551,10 @@ "defaultMessage": "Échec du chargement.", "description": "SearchPage: failed to load text" }, + "faO6BQ": { + "defaultMessage": "Aller à la page précédente, page {number}", + "description": "BlogPage: previous page label" + }, "fkcTGp": { "defaultMessage": "Une erreur est survenue :", "description": "PageLayout: comment form error message" @@ -571,6 +571,10 @@ "defaultMessage": "Formulaire des réglages", "description": "Layout: an accessible name for the settings form in navbar" }, + "hGvQpI": { + "defaultMessage": "Charger plus d’articles ?", + "description": "PostsList: load more button" + }, "hHVgW3": { "defaultMessage": "Thème clair 🌞", "description": "usePrism: toggle light theme button text" @@ -599,10 +603,6 @@ "defaultMessage": "Linux", "description": "HomePage: link to Linux thematic" }, - "k1aA+G": { - "defaultMessage": "Pagination", - "description": "PostsList: pagination accessible name" - }, "kNBXyK": { "defaultMessage": "Total :", "description": "Page: total label" @@ -647,10 +647,6 @@ "defaultMessage": "Copié !", "description": "usePrism: copy button text (clicked)" }, - "nwDGkZ": { - "defaultMessage": "Page actuelle, page {number}", - "description": "PostsList: pagination current page label" - }, "nwbzKm": { "defaultMessage": "Mentions légales", "description": "Layout: Legal notice label" @@ -671,6 +667,10 @@ "defaultMessage": "Thème sombre", "description": "PrismThemeToggle: dark theme label" }, + "oq3BzP": { + "defaultMessage": "Aller à la page suivante, page {number}", + "description": "BlogPage: next page label" + }, "pT5nHk": { "defaultMessage": "Publié le :", "description": "HomePage: publication date label" @@ -743,10 +743,6 @@ "defaultMessage": "Annuler la réponse", "description": "CommentsList: cancel reply button" }, - "uaqd5F": { - "defaultMessage": "Charger plus d’articles ?", - "description": "PostsList: load more button" - }, "va65iw": { "defaultMessage": "Marche", "description": "MotionToggle: activate reduce motion label" @@ -759,6 +755,10 @@ "defaultMessage": "Partager sur le Journal du Hacker", "description": "Sharing: Journal du Hacker sharing link" }, + "vtDLzG": { + "defaultMessage": "Souhaitez-vous essayer une nouvelle recherche ?", + "description": "SearchPage: try a new search message" + }, "w4B5PA": { "defaultMessage": "E-mail :", "description": "ContactForm: email label" @@ -767,9 +767,9 @@ "defaultMessage": "Libre", "description": "HomePage: link to free thematic" }, - "xYemkP": { - "defaultMessage": "Chargement des articles précédents…", - "description": "PostsList: loading more articles message" + "wQrvgw": { + "defaultMessage": "Mis à jour le :", + "description": "ProjectsPage: update date label" }, "xaqaYQ": { "defaultMessage": "Mail en cours d’envoi…", diff --git a/src/pages/blog/index.tsx b/src/pages/blog/index.tsx index d74124e..678b75a 100644 --- a/src/pages/blog/index.tsx +++ b/src/pages/blog/index.tsx @@ -3,6 +3,7 @@ import type { GetStaticProps } from 'next'; import Head from 'next/head'; import { useRouter } from 'next/router'; import Script from 'next/script'; +import { useCallback, useRef } from 'react'; import { useIntl } from 'react-intl'; import { getLayout, @@ -12,6 +13,9 @@ import { Notice, PageLayout, PostsList, + Pagination, + type RenderPaginationLink, + type RenderPaginationItemAriaLabel, } from '../../components'; import { getArticles, @@ -21,6 +25,7 @@ import { getTotalThematics, getTotalTopics, } from '../../services/graphql'; +import styles from '../../styles/pages/blog.module.scss'; import type { EdgesResponse, NextPageWithLayout, @@ -34,12 +39,16 @@ import { getBlogSchema, getLinksListItems, getPageLinkFromRawData, - getPostsList, getSchemaJson, getWebPageSchema, } from '../../utils/helpers'; import { loadTranslation, type Messages } from '../../utils/helpers/server'; -import { useBreadcrumb, usePagination, useSettings } from '../../utils/hooks'; +import { + useBreadcrumb, + useIsMounted, + usePostsList, + useSettings, +} from '../../utils/hooks'; type BlogPageProps = { articles: EdgesResponse<RawArticle>; @@ -68,7 +77,8 @@ const BlogPage: NextPageWithLayout<BlogPageProps> = ({ title, url: ROUTES.BLOG, }); - + const postsListRef = useRef<HTMLDivElement>(null); + const isMounted = useIsMounted(postsListRef); const { blog, website } = useSettings(); const { asPath } = useRouter(); const page = { @@ -105,14 +115,15 @@ const BlogPage: NextPageWithLayout<BlogPageProps> = ({ const schemaJsonLd = getSchemaJson([webpageSchema, blogSchema]); const { - data, error, + firstNewResultIndex, isLoading, isLoadingMore, isRefreshing, hasNextPage, loadMore, - } = usePagination<RawArticle>({ + posts, + } = usePostsList({ fallback: [articles], fetcher: getArticles, perPage: blog.postsPerPage, @@ -129,7 +140,54 @@ const BlogPage: NextPageWithLayout<BlogPageProps> = ({ description: 'BlogPage: topics list widget title', id: '2D9tB5', }); - const postsListBaseUrl = `${ROUTES.BLOG}/page/`; + const renderPaginationLink: RenderPaginationLink = useCallback( + (pageNum) => `${ROUTES.BLOG}/page/${pageNum}`, + [] + ); + const renderPaginationLabel: RenderPaginationItemAriaLabel = useCallback( + ({ kind, pageNumber: number, isCurrentPage }) => { + switch (kind) { + case 'backward': + return intl.formatMessage( + { + defaultMessage: 'Go to previous page, page {number}', + description: 'BlogPage: previous page label', + id: 'faO6BQ', + }, + { number } + ); + case 'forward': + return intl.formatMessage( + { + defaultMessage: 'Go to next page, page {number}', + description: 'BlogPage: next page label', + id: 'oq3BzP', + }, + { number } + ); + case 'number': + default: + return isCurrentPage + ? intl.formatMessage( + { + defaultMessage: 'Current page, page {number}', + description: 'BlogPage: current page label', + id: 'JL6G22', + }, + { number } + ) + : intl.formatMessage( + { + defaultMessage: 'Go to page {number}', + description: 'BlogPage: page number label', + id: 'IVczxR', + }, + { number } + ); + } + }, + [intl] + ); const headerMeta: MetaItemData[] = totalArticles ? [ @@ -153,6 +211,12 @@ const BlogPage: NextPageWithLayout<BlogPageProps> = ({ ] : []; + const paginationAriaLabel = intl.formatMessage({ + defaultMessage: 'Pagination', + description: 'BlogPage: pagination accessible name', + id: 'AXe1Iz', + }); + return ( <> <Head> @@ -206,17 +270,28 @@ const BlogPage: NextPageWithLayout<BlogPageProps> = ({ />, ]} > - {data ? ( + {posts ? ( <PostsList - baseUrl={postsListBaseUrl} - byYear={true} + className={styles.list} + firstNewResult={firstNewResultIndex} isLoading={isLoading || isLoadingMore || isRefreshing} - loadMore={loadMore} - posts={getPostsList(data)} - showLoadMoreBtn={hasNextPage} - total={totalArticles} + onLoadMore={hasNextPage && isMounted ? loadMore : undefined} + posts={posts} + ref={postsListRef} + sortByYear + total={isMounted ? totalArticles : undefined} /> ) : null} + {isMounted ? null : ( + <Pagination + aria-label={paginationAriaLabel} + current={1} + isCentered + renderItemAriaLabel={renderPaginationLabel} + renderLink={renderPaginationLink} + total={totalArticles} + /> + )} {error ? ( <Notice // eslint-disable-next-line react/jsx-no-literals -- Kind allowed diff --git a/src/pages/blog/page/[number].tsx b/src/pages/blog/page/[number].tsx index 1c723f1..842c2b8 100644 --- a/src/pages/blog/page/[number].tsx +++ b/src/pages/blog/page/[number].tsx @@ -4,6 +4,7 @@ import type { GetStaticPaths, GetStaticProps } from 'next'; import Head from 'next/head'; import { useRouter } from 'next/router'; import Script from 'next/script'; +import { useCallback } from 'react'; import { useIntl } from 'react-intl'; import { getLayout, @@ -12,6 +13,9 @@ import { type MetaItemData, PageLayout, PostsList, + Pagination, + type RenderPaginationLink, + type RenderPaginationItemAriaLabel, } from '../../../components'; import { getArticles, @@ -131,7 +135,54 @@ const BlogPage: NextPageWithLayout<BlogPageProps> = ({ description: 'BlogPage: topics list widget title', id: '2D9tB5', }); - const postsListBaseUrl = `${ROUTES.BLOG}/page/`; + const renderPaginationLink: RenderPaginationLink = useCallback( + (pageNum) => `${ROUTES.BLOG}/page/${pageNum}`, + [] + ); + const renderPaginationLabel: RenderPaginationItemAriaLabel = useCallback( + ({ kind, pageNumber: number, isCurrentPage }) => { + switch (kind) { + case 'backward': + return intl.formatMessage( + { + defaultMessage: 'Go to previous page, page {number}', + description: 'BlogPage: previous page label', + id: 'faO6BQ', + }, + { number } + ); + case 'forward': + return intl.formatMessage( + { + defaultMessage: 'Go to next page, page {number}', + description: 'BlogPage: next page label', + id: 'oq3BzP', + }, + { number } + ); + case 'number': + default: + return isCurrentPage + ? intl.formatMessage( + { + defaultMessage: 'Current page, page {number}', + description: 'BlogPage: current page label', + id: 'JL6G22', + }, + { number } + ) + : intl.formatMessage( + { + defaultMessage: 'Go to page {number}', + description: 'BlogPage: page number label', + id: 'IVczxR', + }, + { number } + ); + } + }, + [intl] + ); const headerMeta: MetaItemData[] = totalArticles ? [ @@ -155,6 +206,12 @@ const BlogPage: NextPageWithLayout<BlogPageProps> = ({ ] : []; + const paginationAriaLabel = intl.formatMessage({ + defaultMessage: 'Pagination', + description: 'BlogPage: pagination accessible name', + id: 'AXe1Iz', + }); + return ( <> <Head> @@ -208,11 +265,13 @@ const BlogPage: NextPageWithLayout<BlogPageProps> = ({ />, ]} > - <PostsList - baseUrl={postsListBaseUrl} - byYear={true} - pageNumber={pageNumber} - posts={getPostsList([articles])} + <PostsList posts={getPostsList([articles])} sortByYear /> + <Pagination + aria-label={paginationAriaLabel} + current={pageNumber} + isCentered + renderItemAriaLabel={renderPaginationLabel} + renderLink={renderPaginationLink} total={totalArticles} /> </PageLayout> diff --git a/src/pages/recherche/index.tsx b/src/pages/recherche/index.tsx index a0e5057..effd087 100644 --- a/src/pages/recherche/index.tsx +++ b/src/pages/recherche/index.tsx @@ -3,6 +3,7 @@ import type { GetStaticProps } from 'next'; import Head from 'next/head'; import { useRouter } from 'next/router'; import Script from 'next/script'; +import { useCallback } from 'react'; import { useIntl } from 'react-intl'; import { getLayout, @@ -13,6 +14,8 @@ import { PageLayout, PostsList, Spinner, + SearchForm, + type SearchFormSubmit, } from '../../components'; import { getArticles, @@ -22,9 +25,9 @@ import { getTotalThematics, getTotalTopics, } from '../../services/graphql'; +import styles from '../../styles/pages/blog.module.scss'; import type { NextPageWithLayout, - RawArticle, RawThematicPreview, RawTopicPreview, } from '../../types'; @@ -33,7 +36,6 @@ import { getBlogSchema, getLinksListItems, getPageLinkFromRawData, - getPostsList, getSchemaJson, getWebPageSchema, } from '../../utils/helpers'; @@ -41,7 +43,7 @@ import { loadTranslation, type Messages } from '../../utils/helpers/server'; import { useBreadcrumb, useDataFromAPI, - usePagination, + usePostsList, useSettings, } from '../../utils/hooks'; @@ -59,7 +61,7 @@ const SearchPage: NextPageWithLayout<SearchPageProps> = ({ topicsList, }) => { const intl = useIntl(); - const { asPath, query } = useRouter(); + const { asPath, query, push: routerPush } = useRouter(); const title = query.s ? intl.formatMessage( { @@ -116,14 +118,15 @@ const SearchPage: NextPageWithLayout<SearchPageProps> = ({ const schemaJsonLd = getSchemaJson([webpageSchema, blogSchema]); const { - data, error, + firstNewResultIndex, isLoading, isLoadingMore, isRefreshing, hasNextPage, loadMore, - } = usePagination<RawArticle>({ + posts, + } = usePostsList({ fallback: [], fetcher: getArticles, perPage: blog.postsPerPage, @@ -167,13 +170,33 @@ const SearchPage: NextPageWithLayout<SearchPageProps> = ({ description: 'SearchPage: topics list widget title', id: 'N804XO', }); - const postsListBaseUrl = `${ROUTES.SEARCH}/page/`; const loadingResults = intl.formatMessage({ defaultMessage: 'Loading the search results...', description: 'SearchPage: loading search results message', id: 'EeCqAE', }); + const searchSubmitHandler: SearchFormSubmit = useCallback( + ({ query: searchQuery }) => { + if (!searchQuery) + 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, + }; + + routerPush({ pathname: ROUTES.SEARCH, query: { s: searchQuery } }); + + return undefined; + }, + [intl, routerPush] + ); + return ( <> <Head> @@ -227,18 +250,34 @@ const SearchPage: NextPageWithLayout<SearchPageProps> = ({ />, ]} > - {data && data.length > 0 ? ( + {posts ? null : <Spinner>{loadingResults}</Spinner>} + {posts?.length ? ( <PostsList - baseUrl={postsListBaseUrl} - byYear={true} + className={styles.list} + firstNewResult={firstNewResultIndex} isLoading={isLoading || isLoadingMore || isRefreshing} - loadMore={loadMore} - posts={getPostsList(data)} - showLoadMoreBtn={hasNextPage} - total={totalArticles ?? 0} + onLoadMore={hasNextPage ? loadMore : undefined} + posts={posts} + sortByYear /> ) : ( - <Spinner>{loadingResults}</Spinner> + <> + <p> + {intl.formatMessage({ + defaultMessage: 'No results found.', + description: 'SearchPage: no results', + id: 'YV//MH', + })} + </p> + <p> + {intl.formatMessage({ + defaultMessage: 'Would you like to try a new search?', + description: 'SearchPage: try a new search message', + id: 'vtDLzG', + })} + </p> + <SearchForm isLabelHidden onSubmit={searchSubmitHandler} /> + </> )} {error ? ( <Notice diff --git a/src/pages/sujet/[slug].tsx b/src/pages/sujet/[slug].tsx index 9094703..66c3d02 100644 --- a/src/pages/sujet/[slug].tsx +++ b/src/pages/sujet/[slug].tsx @@ -21,7 +21,7 @@ import { getTopicsPreview, getTotalTopics, } from '../../services/graphql'; -import styles from '../../styles/pages/topic.module.scss'; +import styles from '../../styles/pages/blog.module.scss'; import type { NextPageWithLayout, PageLink, Topic } from '../../types'; import { ROUTES } from '../../utils/constants'; import { @@ -156,7 +156,6 @@ const TopicPage: NextPageWithLayout<TopicPageProps> = ({ </> ); const pageUrl = `${website.url}${asPath}`; - const postsListBaseUrl = `${ROUTES.TOPICS}/page/`; return ( <> @@ -225,11 +224,10 @@ const TopicPage: NextPageWithLayout<TopicPageProps> = ({ )} </Heading> <PostsList - baseUrl={postsListBaseUrl} - byYear={true} + className={styles.list} posts={getPostsWithUrl(articles)} - titleLevel={3} - total={articles.length} + headingLvl={3} + sortByYear /> </> ) : null} diff --git a/src/pages/thematique/[slug].tsx b/src/pages/thematique/[slug].tsx index bb97f47..61d105e 100644 --- a/src/pages/thematique/[slug].tsx +++ b/src/pages/thematique/[slug].tsx @@ -20,6 +20,7 @@ import { getThematicsPreview, getTotalThematics, } from '../../services/graphql'; +import styles from '../../styles/pages/blog.module.scss'; import type { NextPageWithLayout, PageLink, Thematic } from '../../types'; import { ROUTES } from '../../utils/constants'; import { @@ -128,7 +129,6 @@ const ThematicPage: NextPageWithLayout<ThematicPageProps> = ({ id: '/42Z0z', }); const pageUrl = `${website.url}${asPath}`; - const postsListBaseUrl = `${ROUTES.THEMATICS.INDEX}/page/`; return ( <> @@ -197,11 +197,10 @@ const ThematicPage: NextPageWithLayout<ThematicPageProps> = ({ )} </Heading> <PostsList - baseUrl={postsListBaseUrl} - byYear={true} + className={styles.list} posts={getPostsWithUrl(articles)} - titleLevel={3} - total={articles.length} + headingLvl={3} + sortByYear /> </> ) : null} diff --git a/src/styles/pages/blog.module.scss b/src/styles/pages/blog.module.scss new file mode 100644 index 0000000..0fbde7c --- /dev/null +++ b/src/styles/pages/blog.module.scss @@ -0,0 +1,18 @@ +@use "../abstracts/functions" as fun; +@use "../abstracts/mixins" as mix; + +.list { + @include mix.media("screen") { + @include mix.dimensions("md") { + --col1: #{fun.convert-px(100)}; + --gap: var(--spacing-lg); + + margin-left: calc((var(--col1) + var(--gap)) * -1); + } + } +} + +.logo { + max-width: fun.convert-px(50); + margin: 0 var(--spacing-xs) 0 0; +} diff --git a/src/styles/pages/topic.module.scss b/src/styles/pages/topic.module.scss deleted file mode 100644 index bec4ba2..0000000 --- a/src/styles/pages/topic.module.scss +++ /dev/null @@ -1,6 +0,0 @@ -@use "../abstracts/functions" as fun; - -.logo { - max-width: fun.convert-px(50); - margin: 0 var(--spacing-xs) 0 0; -} diff --git a/src/utils/helpers/index.ts b/src/utils/helpers/index.ts index f340a49..79077de 100644 --- a/src/utils/helpers/index.ts +++ b/src/utils/helpers/index.ts @@ -2,6 +2,7 @@ export * from './author'; export * from './images'; export * from './pages'; export * from './reading-time'; +export * from './refs'; export * from './rss'; export * from './schema-org'; export * from './strings'; diff --git a/src/utils/helpers/refs.test.tsx b/src/utils/helpers/refs.test.tsx new file mode 100644 index 0000000..93e5f89 --- /dev/null +++ b/src/utils/helpers/refs.test.tsx @@ -0,0 +1,28 @@ +import { describe, it, jest } from '@jest/globals'; +import { render } from '@testing-library/react'; +import { forwardRef, useImperativeHandle } from 'react'; +import { mergeRefs } from './refs'; + +const refValue = 'minus architecto qui'; +const TestComponentWithForwardedRef = forwardRef((_, ref) => { + useImperativeHandle(ref, () => refValue); + return null; +}); +TestComponentWithForwardedRef.displayName = 'TestComponentWithForwardedRef'; + +describe('merge-refs', () => { + it('can merge a ref function with a ref object', () => { + const refFn = jest.fn(); + const refObj = { current: null }; + + const TestComponent = () => ( + <TestComponentWithForwardedRef ref={mergeRefs([refFn, refObj])} /> + ); + + render(<TestComponent />); + + expect(refFn).toHaveBeenCalledTimes(1); + expect(refFn).toHaveBeenLastCalledWith(refValue); + expect(refObj.current).toBe(refValue); + }); +}); diff --git a/src/utils/helpers/refs.ts b/src/utils/helpers/refs.ts new file mode 100644 index 0000000..74a695a --- /dev/null +++ b/src/utils/helpers/refs.ts @@ -0,0 +1,16 @@ +import type { LegacyRef, MutableRefObject, RefCallback } from 'react'; +import type { Nullable } from '../../types'; + +export const mergeRefs = + <T = unknown>( + refs: (MutableRefObject<T> | LegacyRef<T> | undefined | null)[] + ): RefCallback<T> => + (value) => { + refs.forEach((ref) => { + if (typeof ref === 'function') { + ref(value); + } else if (ref !== null) { + (ref as MutableRefObject<Nullable<T>>).current = value; + } + }); + }; diff --git a/src/utils/hooks/index.ts b/src/utils/hooks/index.ts index 9cc2b0f..68fb7ce 100644 --- a/src/utils/hooks/index.ts +++ b/src/utils/hooks/index.ts @@ -14,6 +14,7 @@ export * from './use-match-media'; export * from './use-mutation-observer'; export * from './use-on-click-outside'; export * from './use-pagination'; +export * from './use-posts-list'; export * from './use-prism'; export * from './use-prism-theme'; export * from './use-reading-time'; diff --git a/src/utils/hooks/use-posts-list/index.ts b/src/utils/hooks/use-posts-list/index.ts new file mode 100644 index 0000000..664c142 --- /dev/null +++ b/src/utils/hooks/use-posts-list/index.ts @@ -0,0 +1 @@ +export * from './use-posts-list'; diff --git a/src/utils/hooks/use-posts-list/use-posts-list.test.ts b/src/utils/hooks/use-posts-list/use-posts-list.test.ts new file mode 100644 index 0000000..1d11111 --- /dev/null +++ b/src/utils/hooks/use-posts-list/use-posts-list.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from '@jest/globals'; +import { act, renderHook } from '@testing-library/react'; +import { getArticles } from '../../../services/graphql'; +import { usePostsList } from './use-posts-list'; + +describe('usePostsList', () => { + it('can return the first new result index when loading more posts', async () => { + const perPage = 5; + const { result } = renderHook(() => + usePostsList({ fetcher: getArticles, perPage }) + ); + + expect.assertions(2); + + expect(result.current.firstNewResultIndex).toBeUndefined(); + + await act(async () => { + await result.current.loadMore(); + }); + + // Assuming there is more than one page. + expect(result.current.firstNewResultIndex).toBe(perPage + 1); + }); +}); diff --git a/src/utils/hooks/use-posts-list/use-posts-list.ts b/src/utils/hooks/use-posts-list/use-posts-list.ts new file mode 100644 index 0000000..661727f --- /dev/null +++ b/src/utils/hooks/use-posts-list/use-posts-list.ts @@ -0,0 +1,66 @@ +import { useCallback, useState } from 'react'; +import type { PostData } from '../../../components'; +import type { Maybe, RawArticle } from '../../../types'; +import { getPostsList } from '../../helpers'; +import { + type UsePaginationConfig, + usePagination, + type UsePaginationReturn, +} from '../use-pagination'; + +export type usePostsListReturn = Omit< + UsePaginationReturn<RawArticle>, + 'data' +> & { + /** + * The index of the first new result when loading more posts. + */ + firstNewResultIndex: Maybe<number>; + /** + * The posts list. + */ + posts: Maybe<PostData[]>; +}; + +export const usePostsList = ( + config: UsePaginationConfig<RawArticle> +): usePostsListReturn => { + const { + data, + error, + hasNextPage, + isEmpty, + isError, + isLoading, + isLoadingMore, + isRefreshing, + isValidating, + loadMore, + size, + } = usePagination(config); + const [firstNewResultIndex, setFirstNewResultIndex] = + useState<Maybe<number>>(undefined); + + const posts = data ? getPostsList(data) : undefined; + + const handleLoadMore = useCallback(async () => { + setFirstNewResultIndex(size * config.perPage + 1); + + await loadMore(); + }, [config.perPage, loadMore, size]); + + return { + error, + firstNewResultIndex, + hasNextPage, + isEmpty, + isError, + isLoading, + isLoadingMore, + isRefreshing, + isValidating, + loadMore: handleLoadMore, + posts, + size, + }; +}; |
