aboutsummaryrefslogtreecommitdiffstats
path: root/src/components/templates
diff options
context:
space:
mode:
authorArmand Philippot <git@armandphilippot.com>2023-11-20 12:27:46 +0100
committerArmand Philippot <git@armandphilippot.com>2023-11-20 19:32:09 +0100
commit70b4f633a6fbedb58c8b9134ac64ede854d489de (patch)
treec757bb12ad9a588e23b25cdb8b46710ac14dbcb1 /src/components/templates
parent9a481f066e1427d53a06cf7aeec525a745abf03f (diff)
refactor(components): replace PageLayout template with Page
* split pages in smaller components (it is both easier to maintain and more readable, we avoid the use of fragments in pages directory) * extract breadcrumbs from article tag (the navigation is not related to the page contents) * remove useReadingTime hook * remove layout options except `isHome`
Diffstat (limited to 'src/components/templates')
-rw-r--r--src/components/templates/layout/layout.module.scss13
-rw-r--r--src/components/templates/layout/layout.test.tsx14
-rw-r--r--src/components/templates/layout/layout.tsx25
-rw-r--r--src/components/templates/page/index.ts7
-rw-r--r--src/components/templates/page/page-body.test.tsx14
-rw-r--r--src/components/templates/page/page-body.tsx23
-rw-r--r--src/components/templates/page/page-comments.stories.tsx170
-rw-r--r--src/components/templates/page/page-comments.test.tsx103
-rw-r--r--src/components/templates/page/page-comments.tsx178
-rw-r--r--src/components/templates/page/page-footer.stories.tsx41
-rw-r--r--src/components/templates/page/page-footer.test.tsx53
-rw-r--r--src/components/templates/page/page-footer.tsx54
-rw-r--r--src/components/templates/page/page-header.stories.tsx76
-rw-r--r--src/components/templates/page/page-header.test.tsx149
-rw-r--r--src/components/templates/page/page-header.tsx172
-rw-r--r--src/components/templates/page/page-layout.module.scss95
-rw-r--r--src/components/templates/page/page-layout.stories.tsx521
-rw-r--r--src/components/templates/page/page-layout.test.tsx113
-rw-r--r--src/components/templates/page/page-layout.tsx287
-rw-r--r--src/components/templates/page/page-sidebar.test.tsx14
-rw-r--r--src/components/templates/page/page-sidebar.tsx20
-rw-r--r--src/components/templates/page/page.module.scss212
-rw-r--r--src/components/templates/page/page.stories.tsx456
-rw-r--r--src/components/templates/page/page.test.tsx49
-rw-r--r--src/components/templates/page/page.tsx56
25 files changed, 1856 insertions, 1059 deletions
diff --git a/src/components/templates/layout/layout.module.scss b/src/components/templates/layout/layout.module.scss
index 03276bf..69c4ef0 100644
--- a/src/components/templates/layout/layout.module.scss
+++ b/src/components/templates/layout/layout.module.scss
@@ -90,19 +90,6 @@
flex: 1;
}
-.article {
- &--grid {
- @extend %grid;
-
- grid-auto-flow: column dense;
- align-items: baseline;
- }
-
- &--padding {
- padding-bottom: var(--spacing-lg);
- }
-}
-
.footer {
display: flex;
flex-flow: column wrap;
diff --git a/src/components/templates/layout/layout.test.tsx b/src/components/templates/layout/layout.test.tsx
index 6a257f0..d3abe1d 100644
--- a/src/components/templates/layout/layout.test.tsx
+++ b/src/components/templates/layout/layout.test.tsx
@@ -1,5 +1,5 @@
import { describe, expect, it } from '@jest/globals';
-import { render, screen } from '../../../../tests/utils';
+import { render, screen as rtlScreen } from '../../../../tests/utils';
import { Layout } from './layout';
const body =
@@ -8,28 +8,28 @@ const body =
describe('Layout', () => {
it('renders the website header', () => {
render(<Layout>{body}</Layout>);
- expect(screen.getByRole('banner')).toBeInTheDocument();
+ expect(rtlScreen.getByRole('banner')).toBeInTheDocument();
});
it('renders the website main content', () => {
render(<Layout>{body}</Layout>);
- expect(screen.getByRole('main')).toBeInTheDocument();
+ expect(rtlScreen.getByRole('main')).toBeInTheDocument();
});
it('renders the website footer', () => {
render(<Layout>{body}</Layout>);
- expect(screen.getByRole('contentinfo')).toBeInTheDocument();
+ expect(rtlScreen.getByRole('contentinfo')).toBeInTheDocument();
});
it('renders a skip to content link', () => {
render(<Layout>{body}</Layout>);
expect(
- screen.getByRole('link', { name: 'Skip to content' })
+ rtlScreen.getByRole('link', { name: 'Skip to content' })
).toBeInTheDocument();
});
- it('renders an article', () => {
+ it('renders its body', () => {
render(<Layout>{body}</Layout>);
- expect(screen.getByRole('article')).toHaveTextContent(body);
+ expect(rtlScreen.getByText(body)).toBeInTheDocument();
});
});
diff --git a/src/components/templates/layout/layout.tsx b/src/components/templates/layout/layout.tsx
index 055b1a1..953b0db 100644
--- a/src/components/templates/layout/layout.tsx
+++ b/src/components/templates/layout/layout.tsx
@@ -65,14 +65,6 @@ export type LayoutProps = {
* @default false
*/
isHome?: boolean;
- /**
- * Determine if article has a comments section.
- */
- withExtraPadding?: boolean;
- /**
- * Determine if article should use grid. Default: false.
- */
- useGrid?: boolean;
};
/**
@@ -80,17 +72,10 @@ export type LayoutProps = {
*
* Render the base layout used by all pages.
*/
-export const Layout: FC<LayoutProps> = ({
- children,
- withExtraPadding = false,
- isHome,
- useGrid = false,
-}) => {
+export const Layout: FC<LayoutProps> = ({ children, isHome }) => {
const router = useRouter();
const intl = useIntl();
const { baseline, copyright, locales, name, url } = CONFIG;
- const articleGridClass = useGrid ? 'article--grid' : '';
- const articleCommentsClass = withExtraPadding ? 'article--padding' : '';
const skipToContent = intl.formatMessage({
defaultMessage: 'Skip to content',
@@ -455,11 +440,7 @@ export const Layout: FC<LayoutProps> = ({
</div>
</Header>
<Main id="main" className={styles.main}>
- <article
- className={`${styles[articleGridClass]} ${styles[articleCommentsClass]}`}
- >
- {children}
- </article>
+ {children}
</Main>
<Footer className={styles.footer}>
<Colophon
@@ -495,5 +476,5 @@ export const Layout: FC<LayoutProps> = ({
*/
export const getLayout = (
page: ReactElement,
- props: NextPageWithLayoutOptions
+ props?: NextPageWithLayoutOptions
) => <Layout {...props}>{page}</Layout>;
diff --git a/src/components/templates/page/index.ts b/src/components/templates/page/index.ts
index cdd5a64..3b26326 100644
--- a/src/components/templates/page/index.ts
+++ b/src/components/templates/page/index.ts
@@ -1 +1,6 @@
-export * from './page-layout';
+export * from './page';
+export * from './page-body';
+export * from './page-comments';
+export * from './page-footer';
+export * from './page-header';
+export * from './page-sidebar';
diff --git a/src/components/templates/page/page-body.test.tsx b/src/components/templates/page/page-body.test.tsx
new file mode 100644
index 0000000..28a47d7
--- /dev/null
+++ b/src/components/templates/page/page-body.test.tsx
@@ -0,0 +1,14 @@
+import { describe, expect, it } from '@jest/globals';
+import { render, screen as rtlScreen } from '@testing-library/react';
+import { PageBody } from './page-body';
+
+describe('PageBody', () => {
+ it('renders its contents', () => {
+ const body =
+ 'Consectetur deleniti laboriosam vel velit optio voluptate qui. Possimus voluptatem eos enim labore debitis iure eveniet aspernatur quibusdam. Accusamus dolore quos explicabo recusandae in illo ipsam incidunt.';
+
+ render(<PageBody>{body}</PageBody>);
+
+ expect(rtlScreen.getByText(body)).toBeInTheDocument();
+ });
+});
diff --git a/src/components/templates/page/page-body.tsx b/src/components/templates/page/page-body.tsx
new file mode 100644
index 0000000..df69731
--- /dev/null
+++ b/src/components/templates/page/page-body.tsx
@@ -0,0 +1,23 @@
+import {
+ type ForwardRefRenderFunction,
+ type HTMLAttributes,
+ forwardRef,
+} from 'react';
+import styles from './page.module.scss';
+
+export type PageBodyProps = HTMLAttributes<HTMLDivElement>;
+
+const PageBodyWithRef: ForwardRefRenderFunction<
+ HTMLDivElement,
+ PageBodyProps
+> = ({ children, className = '', ...props }, ref) => {
+ const bodyClass = `${styles.body} ${className}`;
+
+ return (
+ <div {...props} className={bodyClass} ref={ref}>
+ {children}
+ </div>
+ );
+};
+
+export const PageBody = forwardRef(PageBodyWithRef);
diff --git a/src/components/templates/page/page-comments.stories.tsx b/src/components/templates/page/page-comments.stories.tsx
new file mode 100644
index 0000000..362f0a4
--- /dev/null
+++ b/src/components/templates/page/page-comments.stories.tsx
@@ -0,0 +1,170 @@
+import type { ComponentMeta, ComponentStory } from '@storybook/react';
+import type { CommentData } from '../../organisms/comments-list';
+import { Page } from './page';
+import { PageComments } from './page-comments';
+
+/**
+ * PageComments - Storybook Meta
+ */
+export default {
+ title: 'Templates/Page/Comments Section',
+ component: PageComments,
+ argTypes: {
+ comments: {
+ control: {
+ type: null,
+ },
+ description: 'An array of comments.',
+ type: {
+ name: 'object',
+ required: true,
+ value: {},
+ },
+ },
+ pageId: {
+ control: {
+ type: 'number',
+ },
+ description: 'Define the page id in the database.',
+ type: {
+ name: 'number',
+ required: true,
+ },
+ },
+ },
+} as ComponentMeta<typeof PageComments>;
+
+const Template: ComponentStory<typeof PageComments> = (args) => (
+ <Page>
+ <PageComments {...args} />
+ </Page>
+);
+
+const comments = [
+ {
+ author: {
+ name: 'Milan0',
+ avatar: {
+ alt: 'Milan0 avatar',
+ src: 'https://cloudflare-ipfs.com/ipfs/Qmd3W5DuhgHirLHGVixi6V76LhCkZUz6pnFt5AJBiyvHye/avatar/976.jpg',
+ },
+ },
+ content: 'Fugit veniam quas qui dolor explicabo.',
+ id: 1,
+ isApproved: true,
+ publicationDate: '2023-01-23',
+ replies: [
+ {
+ author: { name: 'Haskell42' },
+ content: 'Error quas accusamus nesciunt enim quae a.',
+ id: 25,
+ isApproved: true,
+ publicationDate: '2023-02-04',
+ },
+ {
+ author: { name: 'Hanna49', website: 'https://www.armandphilippot.com' },
+ content: 'Ut ducimus neque aliquam soluta sed totam commodi cum sit.',
+ id: 30,
+ isApproved: true,
+ publicationDate: '2023-03-10',
+ },
+ ],
+ },
+ {
+ author: {
+ name: 'Corrine9',
+ avatar: {
+ alt: 'Corrine9 avatar',
+ src: 'https://cloudflare-ipfs.com/ipfs/Qmd3W5DuhgHirLHGVixi6V76LhCkZUz6pnFt5AJBiyvHye/avatar/539.jpg',
+ },
+ },
+ content:
+ 'Dolore hic iure voluptatum quam error minima. Quas ut aperiam sit commodi cumque consequatur. Voluptas debitis veritatis officiis in voluptas ea et laborum animi. Voluptatem qui enim neque. Et sunt quo neque assumenda iure. Non vel ut consectetur.',
+ id: 2,
+ isApproved: true,
+ publicationDate: '2023-04-20',
+ },
+ {
+ author: { name: 'Presley12' },
+ content:
+ 'Nulla eaque similique recusandae enim aut eligendi iure consequatur. Et aut qui. Voluptatem a voluptatem consequatur aliquid distinctio ex culpa. Adipisci animi amet reprehenderit autem quia commodi voluptatum commodi.',
+ id: 3,
+ isApproved: true,
+ publicationDate: '2023-05-01',
+ replies: [
+ {
+ author: {
+ name: 'Ana_Haley33',
+ avatar: {
+ alt: 'Ana_Haley33 avatar',
+ src: 'https://cloudflare-ipfs.com/ipfs/Qmd3W5DuhgHirLHGVixi6V76LhCkZUz6pnFt5AJBiyvHye/avatar/881.jpg',
+ },
+ },
+ content: 'Ab ea et fugit autem.',
+ id: 17,
+ isApproved: true,
+ publicationDate: '2023-05-01',
+ },
+ {
+ author: { name: 'Santos.Harris17' },
+ content:
+ 'Illo dolores voluptatem similique voluptas quasi hic aspernatur ab nisi.',
+ id: 18,
+ isApproved: false,
+ publicationDate: '2023-05-02',
+ },
+ ],
+ },
+ {
+ author: { name: 'Julius.Borer' },
+ content: 'Ea fugit totam et voluptatum quidem laborum explicabo fuga quod.',
+ id: 4,
+ isApproved: true,
+ publicationDate: '2023-06-15',
+ },
+ {
+ author: { name: 'Geo87' },
+ content:
+ 'Enim consequatur deleniti aliquid adipisci. Et mollitia saepe vel rerum totam praesentium assumenda repellat fuga. Ipsum ut architecto consequatur. Ut laborum suscipit sed corporis quas aliquid. Et et omnis quo. Dolore quia ipsum ut corporis eum et corporis qui.',
+ id: 5,
+ isApproved: false,
+ publicationDate: '2023-06-16',
+ },
+ {
+ author: { name: 'Kurt.Keeling' },
+ content: 'Eligendi repellat officiis amet.',
+ id: 6,
+ isApproved: true,
+ publicationDate: '2023-06-17',
+ },
+] satisfies CommentData[];
+
+/**
+ * PageComments Stories - Without comments
+ */
+export const WithoutComments = Template.bind({});
+WithoutComments.args = {
+ comments: [],
+ pageId: 1,
+};
+
+/**
+ * PageComments Stories - With comments
+ */
+export const WithComments = Template.bind({});
+WithComments.args = {
+ comments,
+ depth: 2,
+ pageId: 1,
+};
+
+/**
+ * PageComments Stories - With comments closed
+ */
+export const WithCommentsClosed = Template.bind({});
+WithCommentsClosed.args = {
+ areCommentsClosed: true,
+ comments,
+ depth: 2,
+ pageId: 1,
+};
diff --git a/src/components/templates/page/page-comments.test.tsx b/src/components/templates/page/page-comments.test.tsx
new file mode 100644
index 0000000..32e61d7
--- /dev/null
+++ b/src/components/templates/page/page-comments.test.tsx
@@ -0,0 +1,103 @@
+import { describe, expect, it } from '@jest/globals';
+import { render, screen as rtlScreen } from '../../../../tests/utils';
+import type { CommentData } from '../../organisms/comments-list';
+import { PageComments } from './page-comments';
+
+const comments = [
+ {
+ author: {
+ name: 'Milan0',
+ avatar: {
+ alt: 'Milan0 avatar',
+ src: 'https://cloudflare-ipfs.com/ipfs/Qmd3W5DuhgHirLHGVixi6V76LhCkZUz6pnFt5AJBiyvHye/avatar/976.jpg',
+ },
+ },
+ content: 'Fugit veniam quas qui dolor explicabo.',
+ id: 1,
+ isApproved: true,
+ publicationDate: '2023-01-23',
+ replies: [],
+ },
+ {
+ author: {
+ name: 'Corrine9',
+ avatar: {
+ alt: 'Corrine9 avatar',
+ src: 'https://cloudflare-ipfs.com/ipfs/Qmd3W5DuhgHirLHGVixi6V76LhCkZUz6pnFt5AJBiyvHye/avatar/539.jpg',
+ },
+ },
+ content:
+ 'Dolore hic iure voluptatum quam error minima. Quas ut aperiam sit commodi cumque consequatur. Voluptas debitis veritatis officiis in voluptas ea et laborum animi. Voluptatem qui enim neque. Et sunt quo neque assumenda iure. Non vel ut consectetur.',
+ id: 2,
+ isApproved: true,
+ publicationDate: '2023-04-20',
+ },
+ {
+ author: { name: 'Presley12' },
+ content:
+ 'Nulla eaque similique recusandae enim aut eligendi iure consequatur. Et aut qui. Voluptatem a voluptatem consequatur aliquid distinctio ex culpa. Adipisci animi amet reprehenderit autem quia commodi voluptatum commodi.',
+ id: 3,
+ isApproved: true,
+ publicationDate: '2023-05-01',
+ replies: [],
+ },
+ {
+ author: { name: 'Julius.Borer' },
+ content: 'Ea fugit totam et voluptatum quidem laborum explicabo fuga quod.',
+ id: 4,
+ isApproved: true,
+ publicationDate: '2023-06-15',
+ },
+ {
+ author: { name: 'Geo87' },
+ content:
+ 'Enim consequatur deleniti aliquid adipisci. Et mollitia saepe vel rerum totam praesentium assumenda repellat fuga. Ipsum ut architecto consequatur. Ut laborum suscipit sed corporis quas aliquid. Et et omnis quo. Dolore quia ipsum ut corporis eum et corporis qui.',
+ id: 5,
+ isApproved: false,
+ publicationDate: '2023-06-16',
+ },
+ {
+ author: { name: 'Kurt.Keeling' },
+ content: 'Eligendi repellat officiis amet.',
+ id: 6,
+ isApproved: true,
+ publicationDate: '2023-06-17',
+ },
+] satisfies CommentData[];
+
+describe('PageComments', () => {
+ it('renders a list of comments with a form', () => {
+ render(<PageComments comments={comments} pageId={1} />);
+
+ const headings = rtlScreen.getAllByRole('heading', { level: 2 });
+
+ expect(headings).toHaveLength(2);
+ expect(headings[0]).toHaveTextContent(`${comments.length} comments`);
+ expect(headings[1]).toHaveTextContent('Leave a comment');
+ expect(rtlScreen.getAllByRole('listitem')).toHaveLength(comments.length);
+ expect(rtlScreen.getByRole('form')).toHaveAccessibleName('Comment form');
+ });
+
+ it('can disable the comment form when comments are closed', () => {
+ render(<PageComments areCommentsClosed comments={comments} pageId={1} />);
+
+ expect(rtlScreen.getAllByRole('heading', { level: 2 })).toHaveLength(1);
+ expect(rtlScreen.queryByRole('form')).not.toBeInTheDocument();
+ });
+
+ it('can render a link to the comment form when there are no comments', () => {
+ render(<PageComments comments={[]} pageId={1} />);
+
+ expect(
+ rtlScreen.getAllByRole('heading', { level: 2 })[0]
+ ).toHaveTextContent('No comments');
+ expect(rtlScreen.queryByRole('listitem')).not.toBeInTheDocument();
+
+ const formSection = rtlScreen.getByRole('form').parentElement;
+
+ expect(formSection?.id).not.toBeUndefined();
+ expect(
+ rtlScreen.getByRole('link', { name: 'Be the first!' })
+ ).toHaveAttribute('href', `#${formSection?.id}`);
+ });
+});
diff --git a/src/components/templates/page/page-comments.tsx b/src/components/templates/page/page-comments.tsx
new file mode 100644
index 0000000..bc715e8
--- /dev/null
+++ b/src/components/templates/page/page-comments.tsx
@@ -0,0 +1,178 @@
+import {
+ type ForwardRefRenderFunction,
+ type HTMLAttributes,
+ forwardRef,
+ type ReactNode,
+ useCallback,
+} from 'react';
+import { useIntl } from 'react-intl';
+import { sendComment } from '../../../services/graphql';
+import type { SendCommentInput } from '../../../types';
+import { Heading, Link } from '../../atoms';
+import { Card, CardBody } from '../../molecules';
+import {
+ type CommentData,
+ CommentsList,
+ type CommentsListProps,
+} from '../../organisms/comments-list';
+import { CommentForm, type CommentFormSubmit } from '../../organisms/forms';
+import styles from './page.module.scss';
+
+const link = (chunks: ReactNode) => (
+ // eslint-disable-next-line react/jsx-no-literals
+ <Link href="#comment-form-section">{chunks}</Link>
+);
+
+export type PageCommentsProps = Omit<
+ HTMLAttributes<HTMLDivElement>,
+ 'children' | 'onSubmit'
+> &
+ Pick<CommentsListProps, 'depth'> & {
+ /**
+ * Should the comments form be removed from the page?
+ *
+ * @default false
+ */
+ areCommentsClosed?: boolean;
+ /**
+ * The page comments.
+ */
+ comments: CommentData[];
+ /**
+ * The database page id.
+ */
+ pageId: number;
+ };
+
+const PageCommentsWithRef: ForwardRefRenderFunction<
+ HTMLDivElement,
+ PageCommentsProps
+> = (
+ {
+ areCommentsClosed = false,
+ className = '',
+ comments,
+ depth,
+ pageId,
+ ...props
+ },
+ ref
+) => {
+ const wrapperClass = `${styles.comments} ${className}`;
+ const commentsCount =
+ comments.length +
+ comments.reduce(
+ (accumulator, currentValue) =>
+ accumulator + (currentValue.replies?.length ?? 0),
+ 0
+ );
+ const intl = useIntl();
+ const commentsListTitle = intl.formatMessage(
+ {
+ defaultMessage:
+ '{commentsCount, plural, =0 {No comments} one {# comment} other {# comments}}',
+ description: 'PageComments: the section title of the comments list',
+ id: 'H4pKJP',
+ },
+ { commentsCount }
+ );
+ const commentFormSectionTitle = intl.formatMessage({
+ defaultMessage: 'Leave a comment',
+ description: 'PageComments: the section title of the comment form',
+ id: 'Y7XdNp',
+ });
+ const commentFormTitle = intl.formatMessage({
+ defaultMessage: 'Comment form',
+ description: 'PageComments: an accessible name for the comment form',
+ id: 'o+wCJz',
+ });
+ const noCommentsYet = intl.formatMessage<ReactNode>(
+ {
+ defaultMessage: 'No comments yet. <link>Be the first!</link>',
+ id: 'w+BpPg',
+ description: 'PageComments: no comments text',
+ },
+ {
+ link,
+ }
+ );
+
+ const saveComment: CommentFormSubmit = useCallback(
+ async (data) => {
+ const commentData: SendCommentInput = {
+ author: data.author,
+ authorEmail: data.email,
+ authorUrl: data.website ?? '',
+ clientMutationId: 'comment',
+ commentOn: pageId,
+ content: data.comment,
+ parent: data.parentId,
+ };
+ const { comment, success } = await sendComment(commentData);
+ const successPrefix = intl.formatMessage({
+ defaultMessage: 'Thanks, your comment was successfully sent.',
+ description: 'PageComments: comment form success message',
+ id: 'ZcFroC',
+ });
+ const successMessage = comment?.approved
+ ? intl.formatMessage({
+ defaultMessage: 'It has been approved.',
+ id: 'UgJwSU',
+ description: 'PageComments: comment approved.',
+ })
+ : intl.formatMessage({
+ defaultMessage: 'It is now awaiting moderation.',
+ id: '/EfcyW',
+ description: 'PageComments: comment awaiting moderation',
+ });
+
+ return {
+ messages: {
+ success: `${successPrefix} ${successMessage}`,
+ },
+ validator: () => success,
+ };
+ },
+ [intl, pageId]
+ );
+
+ return (
+ <div {...props} className={wrapperClass} ref={ref}>
+ <section className={styles.section}>
+ <Heading className={styles.heading} level={2}>
+ {commentsListTitle}
+ </Heading>
+ {comments.length ? (
+ <CommentsList
+ areRepliesForbidden={areCommentsClosed}
+ comments={comments}
+ depth={depth}
+ onSubmit={saveComment}
+ />
+ ) : (
+ <Card variant={2}>
+ <CardBody>{noCommentsYet}</CardBody>
+ </Card>
+ )}
+ </section>
+ {areCommentsClosed ? null : (
+ <section
+ className={styles.section}
+ // eslint-disable-next-line react/jsx-no-literals
+ id="comment-form-section"
+ >
+ <Heading className={styles.heading} level={2}>
+ {commentFormSectionTitle}
+ </Heading>
+ <CommentForm
+ aria-label={commentFormTitle}
+ className={styles.form}
+ onSubmit={saveComment}
+ />
+ </section>
+ )}
+ </div>
+ );
+};
+
+export const PageComments = forwardRef(PageCommentsWithRef);
diff --git a/src/components/templates/page/page-footer.stories.tsx b/src/components/templates/page/page-footer.stories.tsx
new file mode 100644
index 0000000..aee8979
--- /dev/null
+++ b/src/components/templates/page/page-footer.stories.tsx
@@ -0,0 +1,41 @@
+import type { ComponentMeta, ComponentStory } from '@storybook/react';
+import { Page } from './page';
+import { PageFooter } from './page-footer';
+
+/**
+ * PageFooter - Storybook Meta
+ */
+export default {
+ title: 'Templates/Page/Footer',
+ component: PageFooter,
+ argTypes: {
+ readMoreAbout: {
+ control: {
+ type: null,
+ },
+ description: 'An array of page links.',
+ type: {
+ name: 'object',
+ required: true,
+ value: {},
+ },
+ },
+ },
+} as ComponentMeta<typeof PageFooter>;
+
+const Template: ComponentStory<typeof PageFooter> = (args) => (
+ <Page>
+ <PageFooter {...args} />
+ </Page>
+);
+
+/**
+ * PageFooter Stories - Footer
+ */
+export const Footer = Template.bind({});
+Footer.args = {
+ readMoreAbout: [
+ { id: 1, name: 'Topic 1', url: '#topic1' },
+ { id: 2, name: 'Topic 2', url: '#topic2' },
+ ],
+};
diff --git a/src/components/templates/page/page-footer.test.tsx b/src/components/templates/page/page-footer.test.tsx
new file mode 100644
index 0000000..4af0e82
--- /dev/null
+++ b/src/components/templates/page/page-footer.test.tsx
@@ -0,0 +1,53 @@
+import { describe, expect, it } from '@jest/globals';
+import { render, screen as rtlScreen } from '../../../../tests/utils';
+import type { PageLink } from '../../../types';
+import { PageFooter } from './page-footer';
+
+describe('PageFooter', () => {
+ it('renders a list of links', () => {
+ const links = [
+ { id: 1, name: 'Topic 1', url: '/topic1' },
+ { id: 2, name: 'Topic 2', url: '/topic2' },
+ { id: 3, name: 'Topic 3', url: '/topic3' },
+ ] satisfies PageLink[];
+
+ render(<PageFooter readMoreAbout={links} />);
+
+ expect(rtlScreen.getByRole('term')).toHaveTextContent(
+ 'Read more posts about:'
+ );
+ expect(rtlScreen.getAllByRole('link')).toHaveLength(links.length);
+ });
+
+ it('can renders a list of links with logo', () => {
+ const links = [
+ {
+ id: 1,
+ logo: {
+ alt: 'a logo',
+ height: 480,
+ width: 640,
+ src: 'https://picsum.photos/640/480',
+ },
+ name: 'Topic 1',
+ url: '/topic1',
+ },
+ { id: 2, name: 'Topic 2', url: '/topic2' },
+ { id: 3, name: 'Topic 3', url: '/topic3' },
+ ] satisfies PageLink[];
+
+ render(<PageFooter readMoreAbout={links} />);
+
+ expect(rtlScreen.getByRole('term')).toHaveTextContent(
+ 'Read more posts about:'
+ );
+ expect(rtlScreen.getAllByRole('link')).toHaveLength(links.length);
+ expect(rtlScreen.getByRole('img')).toHaveAccessibleName(links[0].logo?.alt);
+ });
+
+ it('does not render a list when the prop length is 0', () => {
+ const { container } = render(<PageFooter readMoreAbout={[]} />);
+
+ expect(container.firstChild).toBeEmptyDOMElement();
+ });
+});
diff --git a/src/components/templates/page/page-footer.tsx b/src/components/templates/page/page-footer.tsx
new file mode 100644
index 0000000..3bfece4
--- /dev/null
+++ b/src/components/templates/page/page-footer.tsx
@@ -0,0 +1,54 @@
+import NextImage from 'next/image';
+import { type ForwardRefRenderFunction, forwardRef } from 'react';
+import { useIntl } from 'react-intl';
+import type { PageLink } from '../../../types';
+import { Footer, type FooterProps, ButtonLink } from '../../atoms';
+import { MetaList, MetaItem } from '../../molecules';
+import styles from './page.module.scss';
+
+export type PageFooterProps = Omit<FooterProps, 'children'> & {
+ readMoreAbout: PageLink[];
+};
+
+const PageFooterWithRef: ForwardRefRenderFunction<
+ HTMLElement,
+ PageFooterProps
+> = ({ className = '', readMoreAbout, ...props }, ref) => {
+ const footerClass = `${styles.footer} ${className}`;
+ const intl = useIntl();
+ const metaLabel = intl.formatMessage({
+ defaultMessage: 'Read more posts about:',
+ description: 'PageFooter: the topics list label',
+ id: 'I6vhfk',
+ });
+
+ return (
+ <Footer {...props} className={footerClass} ref={ref}>
+ {readMoreAbout.length ? (
+ <MetaList>
+ <MetaItem
+ hasInlinedValues
+ label={metaLabel}
+ value={readMoreAbout.map((item) => {
+ return {
+ id: `${item.id}`,
+ value: (
+ <ButtonLink className={styles.btn} to={item.url}>
+ <>
+ {item.logo ? (
+ <NextImage {...item.logo} className={styles.logo} />
+ ) : null}
+ {item.name}
+ </>
+ </ButtonLink>
+ ),
+ };
+ })}
+ />
+ </MetaList>
+ ) : null}
+ </Footer>
+ );
+};
+
+export const PageFooter = forwardRef(PageFooterWithRef);
diff --git a/src/components/templates/page/page-header.stories.tsx b/src/components/templates/page/page-header.stories.tsx
new file mode 100644
index 0000000..3af9b47
--- /dev/null
+++ b/src/components/templates/page/page-header.stories.tsx
@@ -0,0 +1,76 @@
+import type { ComponentMeta, ComponentStory } from '@storybook/react';
+import { Page } from './page';
+import { PageHeader } from './page-header';
+
+/**
+ * PageHeader - Storybook Meta
+ */
+export default {
+ title: 'Templates/Page/Header',
+ component: PageHeader,
+ argTypes: {
+ meta: {
+ control: {
+ type: null,
+ },
+ description: 'Define the page meta.',
+ type: {
+ name: 'object',
+ required: true,
+ value: {},
+ },
+ },
+ },
+} as ComponentMeta<typeof PageHeader>;
+
+const Template: ComponentStory<typeof PageHeader> = (args) => (
+ <Page>
+ <PageHeader {...args} />
+ </Page>
+);
+
+/**
+ * PageHeader Stories - TitleOnly
+ */
+export const TitleOnly = Template.bind({});
+TitleOnly.args = {
+ heading: 'The page title',
+};
+
+/**
+ * PageHeader Stories - TitleAndIntro
+ */
+export const TitleAndIntro = Template.bind({});
+TitleAndIntro.args = {
+ heading: 'The page title',
+ intro:
+ 'Eos similique impedit dolor illo. Rerum voluptates corporis quod et molestiae eum. Ut tenetur repellat hic eum. Doloremque et illum sequi aspernatur.',
+};
+
+/**
+ * PageHeader Stories - TitleAndMeta
+ */
+export const TitleAndMeta = Template.bind({});
+TitleAndMeta.args = {
+ heading: 'The page title',
+ meta: {
+ author: 'Robin_Schroeder77',
+ publicationDate: '2023-11-15',
+ updateDate: '2023-11-16',
+ },
+};
+
+/**
+ * PageHeader Stories - All
+ */
+export const All = Template.bind({});
+All.args = {
+ heading: 'The page title',
+ intro:
+ 'Eos similique impedit dolor illo. Rerum voluptates corporis quod et molestiae eum. Ut tenetur repellat hic eum. Doloremque et illum sequi aspernatur.',
+ meta: {
+ author: 'Robin_Schroeder77',
+ publicationDate: '2023-11-15',
+ updateDate: '2023-11-16',
+ },
+};
diff --git a/src/components/templates/page/page-header.test.tsx b/src/components/templates/page/page-header.test.tsx
new file mode 100644
index 0000000..9878553
--- /dev/null
+++ b/src/components/templates/page/page-header.test.tsx
@@ -0,0 +1,149 @@
+import { describe, expect, it } from '@jest/globals';
+import { render, screen as rtlScreen } from '../../../../tests/utils';
+import { PageHeader, type PageHeaderMetaData } from './page-header';
+
+describe('PageHeader', () => {
+ it('renders the page title', () => {
+ const title = 'nostrum et impedit';
+
+ render(<PageHeader heading={title} />);
+
+ expect(rtlScreen.getByRole('heading', { level: 1 })).toHaveTextContent(
+ title
+ );
+ });
+
+ it('can render an introduction', () => {
+ const title = 'nostrum et impedit';
+ const intro =
+ 'Non reiciendis error eveniet deserunt vel quis debitis incidunt voluptas. Distinctio dolorem reiciendis molestias et velit. Aut distinctio autem dolore ratione neque laudantium sed. Asperiores quo qui omnis maiores.';
+
+ render(<PageHeader heading={title} intro={intro} />);
+
+ expect(rtlScreen.getByText(intro)).toBeInTheDocument();
+ });
+
+ it('can render a meta for the author', () => {
+ const title = 'nostrum et impedit';
+ const meta = {
+ author: 'Edward_Hansen72',
+ } satisfies Partial<PageHeaderMetaData>;
+
+ render(<PageHeader heading={title} meta={meta} />);
+
+ expect(rtlScreen.getAllByRole('term')).toHaveLength(
+ Object.keys(meta).length
+ );
+ expect(rtlScreen.getByRole('term')).toHaveTextContent('Written by:');
+ expect(rtlScreen.getByRole('definition')).toHaveTextContent(meta.author);
+ });
+
+ it('can render a meta for the publication date', () => {
+ const title = 'nostrum et impedit';
+ const meta = {
+ publicationDate: '2023-11-19',
+ } satisfies Partial<PageHeaderMetaData>;
+
+ render(<PageHeader heading={title} meta={meta} />);
+
+ expect(rtlScreen.getAllByRole('term')).toHaveLength(
+ Object.keys(meta).length
+ );
+ expect(rtlScreen.getByRole('term')).toHaveTextContent('Published on:');
+ });
+
+ it('can render a meta for the thematics', () => {
+ const title = 'nostrum et impedit';
+ const meta = {
+ thematics: [
+ { id: 1, name: 'Thematic 1', url: '#thematic1' },
+ { id: 2, name: 'Thematic 2', url: '#thematic2' },
+ ],
+ } satisfies Partial<PageHeaderMetaData>;
+
+ render(<PageHeader heading={title} meta={meta} />);
+
+ expect(rtlScreen.getAllByRole('term')).toHaveLength(
+ Object.keys(meta).length
+ );
+ expect(rtlScreen.getByRole('term')).toHaveTextContent('Thematics:');
+ expect(rtlScreen.getAllByRole('definition')).toHaveLength(
+ meta.thematics.length
+ );
+ });
+
+ it('can render a meta for the posts total', () => {
+ const title = 'nostrum et impedit';
+ const meta = {
+ total: 40,
+ } satisfies Partial<PageHeaderMetaData>;
+
+ render(<PageHeader heading={title} meta={meta} />);
+
+ expect(rtlScreen.getAllByRole('term')).toHaveLength(
+ Object.keys(meta).length
+ );
+ expect(rtlScreen.getByRole('term')).toHaveTextContent('Total:');
+ expect(rtlScreen.getByRole('definition')).toHaveTextContent(
+ new RegExp(`${meta.total}`)
+ );
+ });
+
+ it('can render a meta for the update date', () => {
+ const title = 'nostrum et impedit';
+ const meta = {
+ updateDate: '2023-11-20',
+ } satisfies Partial<PageHeaderMetaData>;
+
+ render(<PageHeader heading={title} meta={meta} />);
+
+ expect(rtlScreen.getAllByRole('term')).toHaveLength(
+ Object.keys(meta).length
+ );
+ expect(rtlScreen.getByRole('term')).toHaveTextContent('Updated on:');
+ });
+
+ it('can render a meta for the website', () => {
+ const title = 'nostrum et impedit';
+ const meta = {
+ website: 'https://example.test',
+ } satisfies Partial<PageHeaderMetaData>;
+
+ render(<PageHeader heading={title} meta={meta} />);
+
+ expect(rtlScreen.getAllByRole('term')).toHaveLength(
+ Object.keys(meta).length
+ );
+ expect(rtlScreen.getByRole('term')).toHaveTextContent('Website:');
+ expect(rtlScreen.getByRole('definition')).toHaveTextContent(meta.website);
+ });
+
+ it('can render a meta for the reading time', () => {
+ const title = 'nostrum et impedit';
+ const meta = {
+ wordsCount: 640,
+ } satisfies Partial<PageHeaderMetaData>;
+
+ render(<PageHeader heading={title} meta={meta} />);
+
+ expect(rtlScreen.getAllByRole('term')).toHaveLength(
+ Object.keys(meta).length
+ );
+ expect(rtlScreen.getByRole('term')).toHaveTextContent('Reading time:');
+ });
+
+ it('does not render an undefined meta', () => {
+ const title = 'nostrum et impedit';
+ const meta = {
+ author: undefined,
+ publicationDate: '2023-11-20',
+ } satisfies Partial<PageHeaderMetaData>;
+
+ render(<PageHeader heading={title} meta={meta} />);
+
+ expect(rtlScreen.getAllByRole('term')).toHaveLength(
+ // Author is invalid
+ Object.keys(meta).length - 1
+ );
+ });
+});
diff --git a/src/components/templates/page/page-header.tsx b/src/components/templates/page/page-header.tsx
new file mode 100644
index 0000000..6effc9e
--- /dev/null
+++ b/src/components/templates/page/page-header.tsx
@@ -0,0 +1,172 @@
+import {
+ type ForwardRefRenderFunction,
+ forwardRef,
+ type ReactNode,
+} from 'react';
+import { useIntl } from 'react-intl';
+import type { PageLink } from '../../../types';
+import { getReadingTimeFrom } from '../../../utils/helpers';
+import { Header, Heading, type HeaderProps, Link, Time } from '../../atoms';
+import { MetaList, MetaItem } from '../../molecules';
+import styles from './page.module.scss';
+
+export type PageHeaderMetaData = {
+ author: string;
+ publicationDate: string;
+ thematics: PageLink[];
+ total: number;
+ updateDate: string;
+ website: string;
+ wordsCount: number;
+};
+
+export type PageHeaderProps = Omit<HeaderProps, 'children'> & {
+ /**
+ * The page main title.
+ */
+ heading: ReactNode;
+ /**
+ * The page introduction.
+ */
+ intro?: ReactNode;
+ /**
+ * The page meta.
+ */
+ meta?: Partial<PageHeaderMetaData>;
+};
+
+const PageHeaderWithRef: ForwardRefRenderFunction<
+ HTMLElement,
+ PageHeaderProps
+> = ({ className = '', heading, intro, meta, ...props }, ref) => {
+ const headerClass = `${styles.header} ${className}`;
+ const intl = useIntl();
+
+ return (
+ <Header {...props} className={headerClass} ref={ref}>
+ <div className={styles.header__body}>
+ <Heading className={styles.heading} level={1}>
+ {heading}
+ </Heading>
+ {meta ? (
+ <MetaList className={styles.meta}>
+ {meta.author ? (
+ <MetaItem
+ isInline
+ label={intl.formatMessage({
+ defaultMessage: 'Written by:',
+ description: 'PageHeader: author meta label',
+ id: '/unaGZ',
+ })}
+ value={meta.author}
+ />
+ ) : null}
+ {meta.publicationDate ? (
+ <MetaItem
+ isInline
+ label={intl.formatMessage({
+ defaultMessage: 'Published on:',
+ description: 'PageHeader: publication date label',
+ id: 'pUBhKy',
+ })}
+ value={<Time date={meta.publicationDate} />}
+ />
+ ) : null}
+ {meta.updateDate && meta.updateDate !== meta.publicationDate ? (
+ <MetaItem
+ isInline
+ label={intl.formatMessage({
+ defaultMessage: 'Updated on:',
+ description: 'PageHeader: update date label',
+ id: 'sR5hah',
+ })}
+ value={<Time date={meta.updateDate} />}
+ />
+ ) : null}
+ {meta.wordsCount ? (
+ <MetaItem
+ isInline
+ label={intl.formatMessage({
+ defaultMessage: 'Reading time:',
+ description: 'PageHeader: reading time label',
+ id: 'jJm8wd',
+ })}
+ value={intl.formatMessage(
+ {
+ defaultMessage:
+ '{minutesCount, plural, =0 {Less than one minute} one {# minute} other {# minutes}}',
+ description: 'PageHeader: rounded minutes count',
+ id: 'NNDqRg',
+ },
+ {
+ minutesCount: getReadingTimeFrom(
+ meta.wordsCount
+ ).inMinutes(),
+ }
+ )}
+ />
+ ) : null}
+ {meta.thematics ? (
+ <MetaItem
+ isInline
+ label={intl.formatMessage(
+ {
+ defaultMessage:
+ '{thematicsCount, plural, =0 {Thematics:} one {Thematic:} other {Thematics:}}',
+ description: 'PageHeader: thematics label',
+ id: 'ODwkBI',
+ },
+ { thematicsCount: meta.thematics.length }
+ )}
+ value={meta.thematics.map((thematic) => {
+ return {
+ id: `thematic-${thematic.id}`,
+ value: <Link href={thematic.url}>{thematic.name}</Link>,
+ };
+ })}
+ />
+ ) : null}
+ {meta.total ? (
+ <MetaItem
+ isInline
+ label={intl.formatMessage({
+ defaultMessage: 'Total:',
+ description: 'PageHeader: total meta label',
+ id: 'a6DzIj',
+ })}
+ value={intl.formatMessage(
+ {
+ defaultMessage:
+ '{postsCount, plural, =0 {No posts} one {# post} other {# posts}}',
+ description: 'PageHeader: total meta value',
+ id: 'bAXtMT',
+ },
+ { postsCount: meta.total }
+ )}
+ />
+ ) : null}
+ {meta.website ? (
+ <MetaItem
+ isInline
+ label={intl.formatMessage({
+ defaultMessage: 'Website:',
+ description: 'PageHeader: website meta label',
+ id: '9jh0r2',
+ })}
+ value={meta.website}
+ />
+ ) : null}
+ </MetaList>
+ ) : null}
+ {typeof intro === 'string' ? (
+ // eslint-disable-next-line react/no-danger -- Intro can contain tags.
+ <div dangerouslySetInnerHTML={{ __html: intro }} />
+ ) : (
+ intro
+ )}
+ </div>
+ </Header>
+ );
+};
+
+export const PageHeader = forwardRef(PageHeaderWithRef);
diff --git a/src/components/templates/page/page-layout.module.scss b/src/components/templates/page/page-layout.module.scss
deleted file mode 100644
index 75b996c..0000000
--- a/src/components/templates/page/page-layout.module.scss
+++ /dev/null
@@ -1,95 +0,0 @@
-@use "../../../styles/abstracts/functions" as fun;
-@use "../../../styles/abstracts/mixins" as mix;
-@use "../../../styles/abstracts/placeholders";
-
-.breadcrumb {
- @extend %grid;
-
- grid-column: 1 / -1;
- width: 100%;
- padding: var(--spacing-md) 0;
-
- > * {
- grid-column: 2;
- font-size: var(--font-size-sm);
- }
-}
-
-.header {
- grid-column: 1 / -1;
- margin-bottom: var(--spacing-md);
-}
-
-.body {
- grid-column: 2;
-
- > * + * {
- margin-top: var(--spacing-sm);
- margin-bottom: var(--spacing-sm);
- }
-}
-
-.sidebar {
- grid-column: 2;
-
- &--first {
- margin-bottom: var(--spacing-xs);
-
- @include mix.media("screen") {
- @include mix.dimensions("lg") {
- grid-column: 1;
- align-self: stretch;
- margin: 0 var(--spacing-xs) var(--spacing-md);
- }
- }
- }
-
- &--last {
- margin: var(--spacing-lg) 0 0;
-
- @include mix.media("screen") {
- @include mix.dimensions("md") {
- grid-column: 3;
- align-self: stretch;
- margin: 0 var(--spacing-xs) var(--spacing-md);
- }
- }
- }
-}
-
-.footer {
- grid-column: 2;
- margin: var(--spacing-sm) 0 var(--spacing-2xs);
-}
-
-.comments {
- @extend %grid;
-
- grid-column: 1 / -1;
- margin: var(--spacing-lg) 0 0;
- padding: 0 0 var(--spacing-lg);
- background: var(--color-bg-secondary);
- border-top: fun.convert-px(3) solid var(--color-border-light);
-
- &__section {
- grid-column: 2;
- }
-
- &__title {
- width: fit-content;
- margin: var(--spacing-md) auto;
- }
-
- &__no-comments {
- text-align: center;
- }
-
- &__form {
- max-width: 40ch;
- margin: auto;
- }
-}
-
-.notice {
- margin-top: var(--spacing-sm);
-}
diff --git a/src/components/templates/page/page-layout.stories.tsx b/src/components/templates/page/page-layout.stories.tsx
deleted file mode 100644
index 6dcbeea..0000000
--- a/src/components/templates/page/page-layout.stories.tsx
+++ /dev/null
@@ -1,521 +0,0 @@
-import type { ComponentMeta, ComponentStory } from '@storybook/react';
-import { ButtonLink, Heading, Link } from '../../atoms';
-import { MetaItem, MetaList } from '../../molecules';
-import { LinksWidget, PostsList, SharingWidget } from '../../organisms';
-import { LayoutBase } from '../layout/layout.stories';
-import { PageLayout as PageLayoutComponent } from './page-layout';
-
-/**
- * PageLayout - Storybook Meta
- */
-export default {
- title: 'Templates/Page',
- component: PageLayoutComponent,
- args: {
- allowComments: false,
- breadcrumbSchema: [],
- },
- argTypes: {
- allowComments: {
- control: {
- type: 'boolean',
- },
- description: 'Determine if the comment form is displayed.',
- table: {
- category: 'Options',
- defaultValue: { summary: false },
- },
- type: {
- name: 'boolean',
- required: false,
- },
- },
- bodyAttributes: {
- description: 'Set additional HTML attributes to the main content body.',
- table: {
- category: 'Options',
- },
- type: {
- name: 'object',
- required: false,
- value: {},
- },
- },
- bodyClassName: {
- control: {
- type: 'text',
- },
- description: 'Set additional classnames to the main content body.',
- table: {
- category: 'Styles',
- },
- type: {
- name: 'string',
- required: false,
- },
- },
- breadcrumb: {
- description: 'The breadcrumb items.',
- type: {
- name: 'object',
- required: true,
- value: {},
- },
- },
- breadcrumbSchema: {
- control: {
- type: null,
- },
- description: 'The JSON schema for breadcrumb items.',
- type: {
- name: 'object',
- required: true,
- value: {},
- },
- },
- children: {
- control: {
- type: 'text',
- },
- description: 'The page content.',
- type: {
- name: 'string',
- required: true,
- },
- },
- comments: {
- description: 'The page comments.',
- table: {
- category: 'Options',
- },
- type: {
- name: 'object',
- required: false,
- value: {},
- },
- },
- footerMeta: {
- description: 'The metadata to display in the page footer.',
- table: {
- category: 'Options',
- },
- type: {
- name: 'object',
- required: false,
- value: {},
- },
- },
- headerMeta: {
- description: 'The metadata to display in the page header.',
- table: {
- category: 'Options',
- },
- type: {
- name: 'object',
- required: false,
- value: {},
- },
- },
- id: {
- control: {
- type: 'number',
- },
- description: 'The page id.',
- type: {
- name: 'number',
- required: false,
- },
- },
- intro: {
- control: {
- type: 'text',
- },
- description: 'The page introduction.',
- table: {
- category: 'Options',
- },
- type: {
- name: 'string',
- required: false,
- },
- },
- title: {
- control: {
- type: 'text',
- },
- description: 'The page title.',
- type: {
- name: 'string',
- required: true,
- },
- },
- widgets: {
- control: {
- type: null,
- },
- description: 'An array of widgets to put inside the last sidebar.',
- type: {
- name: 'object',
- required: false,
- value: {},
- },
- },
- withToC: {
- control: {
- type: 'boolean',
- },
- description: 'Determine if the Table of Contents should be in the page.',
- table: {
- category: 'Options',
- defaultValue: { summary: false },
- },
- type: {
- name: 'boolean',
- required: false,
- },
- },
- },
- decorators: [
- (Story, context) => (
- <LayoutBase
- useGrid={true}
- withExtraPadding={!context.args.allowComments && !context.args.comments}
- {...LayoutBase.args}
- >
- <Story />
- </LayoutBase>
- ),
- ],
- parameters: {
- layout: 'fullscreen',
- },
-} as ComponentMeta<typeof PageLayoutComponent>;
-
-const Template: ComponentStory<typeof PageLayoutComponent> = (args) => (
- <PageLayoutComponent {...args} />
-);
-
-const pageTitle = 'Incidunt ad earum';
-const pageIntro =
- 'Recusandae mollitia enim quo omnis rerum enim corporis ratione quidem. Pariatur omnis quas est ut ut numquam totam. Sunt sapiente nostrum aut sunt provident perspiciatis magni illum. Quidem nihil velit quasi fugit minima sint.';
-const pageBreadcrumb = [
- { id: 'home', url: '#', name: 'Home' },
- { id: 'page', url: '#', name: pageTitle },
-];
-
-/**
- * Page Layout Stories - Single Page
- */
-export const SinglePage = Template.bind({});
-SinglePage.args = {
- breadcrumb: pageBreadcrumb,
- title: pageTitle,
- intro: pageIntro,
- children: (
- <>
- <Heading level={2}>Impedit commodi rerum</Heading>
- <p>
- Omnis vel earum cupiditate delectus reprehenderit perferendis distinctio
- omnis. Laudantium rem tempore eligendi porro officia est dolorum
- assumenda. Corrupti tempore quia ab. Quidem est inventore. Autem
- nesciunt sed rerum praesentium.
- </p>
- <p>
- Illo nostrum inventore tenetur quo repellendus autem nisi nostrum
- dolore. Et velit assumenda. Veniam harum officia et. Blanditiis et et
- qui cum. Rerum illum quo doloribus neque non velit. Unde iusto et eaque
- a ut.
- </p>
- <Heading level={2}>Et omnis ducimus</Heading>
- <p>
- Dolor quidem quas perferendis in nam molestiae. Accusamus quidem
- accusantium quaerat est praesentium accusamus ab dolorem. Beatae illum
- totam et corrupti assumenda corporis aut illo animi.
- </p>
- <p>
- Ad rem soluta. Est tenetur consequatur sequi voluptates autem. Molestiae
- in neque dignissimos. Dolorum numquam quos quam voluptas atque facilis
- et. Accusantium fuga architecto excepturi consequatur libero est.
- </p>
- </>
- ),
- widgets: [
- <SharingWidget
- key="sidebar2-widget1"
- data={{ excerpt: pageIntro, title: pageTitle, url: '#' }}
- heading={<Heading level={3}>Share</Heading>}
- media={[
- 'diaspora',
- 'email',
- 'facebook',
- 'journal-du-hacker',
- 'linkedin',
- 'twitter',
- ]}
- />,
- ],
- withToC: true,
-};
-
-const postBreadcrumb = [
- { id: 'home', url: '#', name: 'Home' },
- { id: 'blog', url: '#', name: 'Blog' },
- { id: 'post', url: '#', name: pageTitle },
-];
-
-/**
- * Page Layout Stories - Post
- */
-export const Post = Template.bind({});
-Post.args = {
- breadcrumb: postBreadcrumb,
- title: pageTitle,
- intro: pageIntro,
- headerMeta: (
- <MetaList>
- <MetaItem isInline label="Published on:" value="2020-03-14" />
- <MetaItem
- isInline
- label="Thematic:"
- value={[
- {
- id: 'cat-1',
- value: (
- <Link key="cat1" href="#">
- Cat 1
- </Link>
- ),
- },
- {
- id: 'cat-2',
- value: (
- <Link key="cat2" href="#">
- Cat 2
- </Link>
- ),
- },
- ]}
- />
- </MetaList>
- ),
- footerMeta: (
- <MetaList>
- <MetaItem
- label="Read more about:"
- value={<ButtonLink to="#">Topic 1</ButtonLink>}
- />
- </MetaList>
- ),
- children: (
- <>
- <Heading level={2}>Impedit commodi rerum</Heading>
- <p>
- Omnis vel earum cupiditate delectus reprehenderit perferendis distinctio
- omnis. Laudantium rem tempore eligendi porro officia est dolorum
- assumenda. Corrupti tempore quia ab. Quidem est inventore. Autem
- nesciunt sed rerum praesentium.
- </p>
- <p>
- Illo nostrum inventore tenetur quo repellendus autem nisi nostrum
- dolore. Et velit assumenda. Veniam harum officia et. Blanditiis et et
- qui cum. Rerum illum quo doloribus neque non velit. Unde iusto et eaque
- a ut.
- </p>
- <Heading level={2}>Et omnis ducimus</Heading>
- <p>
- Dolor quidem quas perferendis in nam molestiae. Accusamus quidem
- accusantium quaerat est praesentium accusamus ab dolorem. Beatae illum
- totam et corrupti assumenda corporis aut illo animi.
- </p>
- <p>
- Ad rem soluta. Est tenetur consequatur sequi voluptates autem. Molestiae
- in neque dignissimos. Dolorum numquam quos quam voluptas atque facilis
- et. Accusantium fuga architecto excepturi consequatur libero est.
- </p>
- </>
- ),
- widgets: [
- <SharingWidget
- key="sidebar2-widget1"
- data={{ excerpt: pageIntro, title: pageTitle, url: '#' }}
- heading={<Heading level={3}>Share</Heading>}
- media={[
- 'diaspora',
- 'email',
- 'facebook',
- 'journal-du-hacker',
- 'linkedin',
- 'twitter',
- ]}
- />,
- ],
- withToC: true,
- comments: [
- {
- author: {
- name: 'Milan0',
- avatar: {
- alt: 'Milan0 avatar',
- src: 'https://cloudflare-ipfs.com/ipfs/Qmd3W5DuhgHirLHGVixi6V76LhCkZUz6pnFt5AJBiyvHye/avatar/976.jpg',
- },
- },
- content: 'Fugit veniam quas qui dolor explicabo.',
- id: 1,
- isApproved: true,
- publicationDate: '2023-01-23',
- replies: [
- {
- author: { name: 'Haskell42' },
- content: 'Error quas accusamus nesciunt enim quae a.',
- id: 25,
- isApproved: true,
- publicationDate: '2023-02-04',
- },
- {
- author: {
- name: 'Hanna49',
- website: 'https://www.armandphilippot.com',
- },
- content: 'Ut ducimus neque aliquam soluta sed totam commodi cum sit.',
- id: 30,
- isApproved: true,
- publicationDate: '2023-03-10',
- },
- ],
- },
- {
- author: {
- name: 'Corrine9',
- avatar: {
- alt: 'Corrine9 avatar',
- src: 'https://cloudflare-ipfs.com/ipfs/Qmd3W5DuhgHirLHGVixi6V76LhCkZUz6pnFt5AJBiyvHye/avatar/539.jpg',
- },
- },
- content:
- 'Dolore hic iure voluptatum quam error minima. Quas ut aperiam sit commodi cumque consequatur. Voluptas debitis veritatis officiis in voluptas ea et laborum animi. Voluptatem qui enim neque. Et sunt quo neque assumenda iure. Non vel ut consectetur.',
- id: 2,
- isApproved: true,
- publicationDate: '2023-04-20',
- },
- {
- author: { name: 'Presley12' },
- content:
- 'Nulla eaque similique recusandae enim aut eligendi iure consequatur. Et aut qui. Voluptatem a voluptatem consequatur aliquid distinctio ex culpa. Adipisci animi amet reprehenderit autem quia commodi voluptatum commodi.',
- id: 3,
- isApproved: true,
- publicationDate: '2023-05-01',
- replies: [
- {
- author: {
- name: 'Ana_Haley33',
- avatar: {
- alt: 'Ana_Haley33 avatar',
- src: 'https://cloudflare-ipfs.com/ipfs/Qmd3W5DuhgHirLHGVixi6V76LhCkZUz6pnFt5AJBiyvHye/avatar/881.jpg',
- },
- },
- content: 'Ab ea et fugit autem.',
- id: 17,
- isApproved: true,
- publicationDate: '2023-05-01',
- },
- {
- author: { name: 'Santos.Harris17' },
- content:
- 'Illo dolores voluptatem similique voluptas quasi hic aspernatur ab nisi.',
- id: 18,
- isApproved: false,
- publicationDate: '2023-05-02',
- },
- ],
- },
- {
- author: { name: 'Julius.Borer' },
- content:
- 'Ea fugit totam et voluptatum quidem laborum explicabo fuga quod.',
- id: 4,
- isApproved: true,
- publicationDate: '2023-06-15',
- },
- {
- author: { name: 'Geo87' },
- content:
- 'Enim consequatur deleniti aliquid adipisci. Et mollitia saepe vel rerum totam praesentium assumenda repellat fuga. Ipsum ut architecto consequatur. Ut laborum suscipit sed corporis quas aliquid. Et et omnis quo. Dolore quia ipsum ut corporis eum et corporis qui.',
- id: 5,
- isApproved: false,
- publicationDate: '2023-06-16',
- },
- {
- author: { name: 'Kurt.Keeling' },
- content: 'Eligendi repellat officiis amet.',
- id: 6,
- isApproved: true,
- publicationDate: '2023-06-17',
- },
- ],
- allowComments: true,
-};
-
-const postsListBreadcrumb = [
- { id: 'home', url: '#', name: 'Home' },
- { id: 'blog', url: '#', name: 'Blog' },
-];
-
-const blogCategories = [
- { id: 'cat1', label: 'Cat 1', url: '#' },
- { id: 'cat2', label: 'Cat 2', url: '#' },
- { id: 'cat3', label: 'Cat 3', url: '#' },
- { id: 'cat4', label: '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
- */
-export const Blog = Template.bind({});
-Blog.args = {
- breadcrumb: postsListBreadcrumb,
- title: 'Blog',
- headerMeta: (
- <MetaList>
- <MetaItem isInline label="Total:" value={`${posts.length}`} />
- </MetaList>
- ),
- children: <PostsList posts={posts} sortByYear />,
- widgets: [
- <LinksWidget
- heading={
- <Heading isFake level={3}>
- Categories
- </Heading>
- }
- items={blogCategories}
- key="sidebar-widget1"
- />,
- ],
-};
diff --git a/src/components/templates/page/page-layout.test.tsx b/src/components/templates/page/page-layout.test.tsx
deleted file mode 100644
index c7d7a65..0000000
--- a/src/components/templates/page/page-layout.test.tsx
+++ /dev/null
@@ -1,113 +0,0 @@
-import { describe, expect, it } from '@jest/globals';
-import type { BreadcrumbList } from 'schema-dts';
-import { render, screen as rtlScreen } from '../../../../tests/utils';
-import { PageLayout } from './page-layout';
-
-const title = 'Incidunt ad earum';
-const breadcrumb = [
- { id: 'home', url: '#', name: 'Home' },
- { id: 'page', url: '#', name: title },
-];
-const breadcrumbSchema: BreadcrumbList['itemListElement'][] = [];
-const children =
- 'Reprehenderit aut quis aperiam magnam quia id. Vero enim animi placeat quia. Laborum sit odio minima. Dolores et debitis eaque iste quidem. Omnis aliquam illum porro ea non. Quaerat totam iste quos ex facilis officia accusantium.';
-
-describe('PageLayout', () => {
- it('renders the page title', () => {
- render(
- <PageLayout
- breadcrumb={breadcrumb}
- breadcrumbSchema={breadcrumbSchema}
- title={title}
- >
- {children}
- </PageLayout>
- );
- expect(
- rtlScreen.getByRole('heading', { level: 1, name: title })
- ).toBeInTheDocument();
- });
-
- it('renders the page content', () => {
- render(
- <PageLayout
- breadcrumb={breadcrumb}
- breadcrumbSchema={breadcrumbSchema}
- title={title}
- >
- {children}
- </PageLayout>
- );
- expect(rtlScreen.getByText(children)).toBeInTheDocument();
- });
-
- it('renders the breadcrumb', () => {
- render(
- <PageLayout
- breadcrumb={breadcrumb}
- breadcrumbSchema={breadcrumbSchema}
- title={title}
- >
- {children}
- </PageLayout>
- );
- expect(
- rtlScreen.getByRole('navigation', { name: 'Breadcrumb' })
- ).toBeInTheDocument();
- });
-
- it('renders the table of contents', () => {
- render(
- <PageLayout
- breadcrumb={breadcrumb}
- breadcrumbSchema={breadcrumbSchema}
- title={title}
- withToC={true}
- >
- {children}
- </PageLayout>
- );
- expect(rtlScreen.getByText(/Table of Contents/i)).toBeInTheDocument();
- });
-
- it('renders the comment form', () => {
- render(
- <PageLayout
- breadcrumb={breadcrumb}
- breadcrumbSchema={breadcrumbSchema}
- title={title}
- allowComments={true}
- >
- {children}
- </PageLayout>
- );
- expect(
- rtlScreen.getByRole('form', { name: /Comment form/i })
- ).toBeInTheDocument();
- });
-
- it('renders the comments list', () => {
- render(
- <PageLayout
- breadcrumb={breadcrumb}
- breadcrumbSchema={breadcrumbSchema}
- title={title}
- allowComments={true}
- comments={[
- {
- author: { name: 'Burley40' },
- content: 'Veritatis praesentium non autem ut.',
- id: 1,
- isApproved: true,
- publicationDate: '2023-11-02',
- },
- ]}
- >
- {children}
- </PageLayout>
- );
- expect(
- rtlScreen.getByRole('heading', { level: 2, name: /Comments/i })
- ).toBeInTheDocument();
- });
-});
diff --git a/src/components/templates/page/page-layout.tsx b/src/components/templates/page/page-layout.tsx
deleted file mode 100644
index 75d308e..0000000
--- a/src/components/templates/page/page-layout.tsx
+++ /dev/null
@@ -1,287 +0,0 @@
-/* eslint-disable max-statements */
-import Script from 'next/script';
-import {
- type FC,
- type HTMLAttributes,
- type ReactNode,
- useCallback,
-} from 'react';
-import { useIntl } from 'react-intl';
-import type { BreadcrumbList } from 'schema-dts';
-import { sendComment } from '../../../services/graphql';
-import type { SendCommentInput } from '../../../types';
-import { useHeadingsTree } from '../../../utils/hooks';
-import { Heading, Sidebar } from '../../atoms';
-import { PageFooter, PageHeader, type PageHeaderProps } from '../../molecules';
-import {
- CommentForm,
- CommentsList,
- type CommentsListProps,
- TocWidget,
- Breadcrumbs,
- type BreadcrumbsItem,
- type CommentFormSubmit,
-} from '../../organisms';
-import styles from './page-layout.module.scss';
-
-export type PageLayoutProps = {
- /**
- * True if the page accepts new comments. Default: false.
- */
- allowComments?: boolean;
- /**
- * Set attributes to the page body.
- */
- bodyAttributes?: HTMLAttributes<HTMLDivElement>;
- /**
- * Set additional classnames to the body wrapper.
- */
- bodyClassName?: string;
- /**
- * The breadcrumb items.
- */
- breadcrumb: BreadcrumbsItem[];
- /**
- * The breadcrumb JSON schema.
- */
- breadcrumbSchema: BreadcrumbList['itemListElement'][];
- /**
- * The main content of the page.
- */
- children: ReactNode;
- /**
- * The page comments
- */
- comments?: CommentsListProps['comments'];
- /**
- * The footer metadata.
- */
- footerMeta?: ReactNode;
- /**
- * The header metadata.
- */
- headerMeta?: ReactNode;
- /**
- * The page id.
- */
- id?: number;
- /**
- * The page introduction.
- */
- intro?: PageHeaderProps['intro'];
- /**
- * The page title.
- */
- title: PageHeaderProps['title'];
- /**
- * An array of widgets to put in the last sidebar.
- */
- widgets?: ReactNode[];
- /**
- * Show the table of contents. Default: false.
- */
- withToC?: boolean;
-};
-
-/**
- * PageLayout component
- *
- * Render the pages layout.
- */
-export const PageLayout: FC<PageLayoutProps> = ({
- children,
- allowComments = false,
- bodyAttributes,
- bodyClassName = '',
- breadcrumb,
- breadcrumbSchema,
- comments,
- footerMeta,
- headerMeta,
- id,
- intro,
- title,
- widgets,
- withToC = false,
-}) => {
- const intl = useIntl();
- const breadcrumbsLabel = intl.formatMessage({
- defaultMessage: 'Breadcrumb',
- description: 'PageLayout: an accessible name for the breadcrumb nav.',
- id: 'm6a3BD',
- });
- const commentsTitle = intl.formatMessage({
- defaultMessage: 'Comments',
- description: 'PageLayout: comments title',
- id: '+dJU3e',
- });
- const commentFormSectionTitle = intl.formatMessage({
- defaultMessage: 'Leave a comment',
- description: 'PageLayout: comment form title',
- id: 'kzIYoQ',
- });
- const commentFormTitle = intl.formatMessage({
- defaultMessage: 'Comment form',
- description: 'PageLayout: comment form accessible name',
- id: 'l+Jcf6',
- });
- const tocTitle = intl.formatMessage({
- defaultMessage: 'Table of Contents',
- description: 'PageLayout: table of contents title',
- id: 'eys2uX',
- });
-
- const { ref: bodyRef, tree: headingsTree } = useHeadingsTree<HTMLDivElement>({
- fromLevel: 2,
- });
-
- const saveComment: CommentFormSubmit = useCallback(
- async (data) => {
- if (!id) throw new Error('Page id missing. Cannot save comment.');
-
- const { author, comment: commentBody, email, parentId, website } = data;
- const commentData: SendCommentInput = {
- author,
- authorEmail: email,
- authorUrl: website ?? '',
- clientMutationId: 'comment',
- commentOn: id,
- content: commentBody,
- parent: parentId,
- };
- const { comment, success } = await sendComment(commentData);
-
- if (success) {
- const successPrefix = intl.formatMessage({
- defaultMessage: 'Thanks, your comment was successfully sent.',
- description: 'PageLayout: comment form success message',
- id: 'B290Ph',
- });
- const successMessage = comment?.approved
- ? intl.formatMessage({
- defaultMessage: 'It has been approved.',
- id: 'g3+Ahv',
- description: 'PageLayout: comment approved.',
- })
- : intl.formatMessage({
- defaultMessage: 'It is now awaiting moderation.',
- id: 'Vmj5cw',
- description: 'PageLayout: comment awaiting moderation',
- });
- return {
- messages: {
- success: `${successPrefix} ${successMessage}`,
- },
- validator: () => success,
- };
- }
-
- return {
- messages: {
- error: intl.formatMessage({
- defaultMessage: 'An error occurred:',
- description: 'PageLayout: comment form error message',
- id: 'fkcTGp',
- }),
- },
- validator: () => success,
- };
- },
- [id, intl]
- );
-
- return (
- <>
- <Script
- dangerouslySetInnerHTML={{ __html: JSON.stringify(breadcrumbSchema) }}
- id="schema-breadcrumb"
- type="application/ld+json"
- />
- <Breadcrumbs
- aria-label={breadcrumbsLabel}
- className={styles.breadcrumb}
- items={breadcrumb}
- />
- <PageHeader
- className={styles.header}
- intro={intro}
- meta={headerMeta}
- title={title}
- />
- {withToC ? (
- <Sidebar
- aria-label={intl.formatMessage({
- defaultMessage: 'Table of contents sidebar',
- id: 'Q+1GbT',
- description: 'PageLayout: accessible name for ToC sidebar',
- })}
- className={`${styles.sidebar} ${styles['sidebar--first']}`}
- >
- <TocWidget
- heading={<Heading level={3}>{tocTitle}</Heading>}
- tree={headingsTree}
- />
- </Sidebar>
- ) : null}
- {typeof children === 'string' ? (
- <div
- {...bodyAttributes}
- className={`${styles.body} ${bodyClassName}`}
- dangerouslySetInnerHTML={{ __html: children }}
- ref={bodyRef}
- />
- ) : (
- <div ref={bodyRef} className={`${styles.body} ${bodyClassName}`}>
- {children}
- </div>
- )}
- {footerMeta ? (
- <PageFooter className={styles.footer}>{footerMeta}</PageFooter>
- ) : null}
- <Sidebar
- aria-label={intl.formatMessage({
- defaultMessage: 'Sidebar',
- id: 'c556Qo',
- description: 'PageLayout: accessible name for the sidebar',
- })}
- className={`${styles.sidebar} ${styles['sidebar--last']}`}
- >
- {widgets}
- </Sidebar>
- {allowComments ? (
- <div className={styles.comments} id="comments">
- <section className={styles.comments__section}>
- <Heading className={styles.comments__title} level={2}>
- {commentsTitle}
- </Heading>
- {comments?.length ? (
- <CommentsList
- comments={comments}
- depth={2}
- onSubmit={saveComment}
- />
- ) : (
- <p className={styles['comments__no-comments']}>
- {intl.formatMessage({
- defaultMessage: 'No comments.',
- id: 'sBwfCy',
- description: 'PageLayout: no comments text',
- })}
- </p>
- )}
- </section>
- <section className={styles.comments__section}>
- <Heading className={styles.comments__title} level={2}>
- {commentFormSectionTitle}
- </Heading>
- <CommentForm
- aria-label={commentFormTitle}
- className={styles.comments__form}
- onSubmit={saveComment}
- />
- </section>
- </div>
- ) : null}
- </>
- );
-};
diff --git a/src/components/templates/page/page-sidebar.test.tsx b/src/components/templates/page/page-sidebar.test.tsx
new file mode 100644
index 0000000..93bb57f
--- /dev/null
+++ b/src/components/templates/page/page-sidebar.test.tsx
@@ -0,0 +1,14 @@
+import { describe, expect, it } from '@jest/globals';
+import { render, screen as rtlScreen } from '@testing-library/react';
+import { PageSidebar } from './page-sidebar';
+
+describe('PageSidebar', () => {
+ it('renders its contents', () => {
+ const body =
+ 'Repellendus dignissimos quos dolores sunt pariatur rem optio qui aut. Dolore optio est quam tenetur minus. Dolorem voluptas id maiores rerum velit omnis esse impedit. Unde reiciendis nisi nostrum et. Quia accusamus asperiores. Commodi est provident sequi eaque ipsa ut necessitatibus.';
+
+ render(<PageSidebar>{body}</PageSidebar>);
+
+ expect(rtlScreen.getByText(body)).toBeInTheDocument();
+ });
+});
diff --git a/src/components/templates/page/page-sidebar.tsx b/src/components/templates/page/page-sidebar.tsx
new file mode 100644
index 0000000..1b5ae97
--- /dev/null
+++ b/src/components/templates/page/page-sidebar.tsx
@@ -0,0 +1,20 @@
+import { type ForwardRefRenderFunction, forwardRef } from 'react';
+import { Aside, type AsideProps } from '../../atoms';
+import styles from './page.module.scss';
+
+export type PageSidebarProps = AsideProps;
+
+const PageSidebarWithRef: ForwardRefRenderFunction<
+ HTMLElement,
+ PageSidebarProps
+> = ({ children, className = '', ...props }, ref) => {
+ const sidebarClass = `${styles.sidebar} ${className}`;
+
+ return (
+ <Aside {...props} className={sidebarClass} ref={ref}>
+ <div className={styles.sidebar__body}>{children}</div>
+ </Aside>
+ );
+};
+
+export const PageSidebar = forwardRef(PageSidebarWithRef);
diff --git a/src/components/templates/page/page.module.scss b/src/components/templates/page/page.module.scss
new file mode 100644
index 0000000..b521438
--- /dev/null
+++ b/src/components/templates/page/page.module.scss
@@ -0,0 +1,212 @@
+@use "../../../styles/abstracts/functions" as fun;
+@use "../../../styles/abstracts/mixins" as mix;
+@use "../../../styles/abstracts/variables" as var;
+
+%grid {
+ display: grid;
+ align-items: center;
+ grid-template-columns: var(--left-col) var(--main-col) var(--right-col);
+ column-gap: var(--col-gap);
+}
+
+.wrapper {
+ container: page / inline-size;
+}
+
+.breadcrumbs,
+.page {
+ --border-size: #{fun.convert-px(3)};
+ --col-gap: clamp(var(--spacing-md), 4vw, var(--spacing-2xl));
+ --left-col: 0;
+ --right-col: 0;
+ --main-col: minmax(0, 80ch);
+
+ @extend %grid;
+
+ grid-auto-flow: column dense;
+ align-items: baseline;
+ margin-top: var(--spacing-sm);
+}
+
+.breadcrumbs {
+ width: 100%;
+ padding: var(--spacing-xs) 0;
+
+ & > * {
+ grid-column: 2;
+ font-size: var(--font-size-sm);
+ }
+}
+
+.header {
+ display: contents;
+
+ &::before,
+ &::after {
+ align-self: stretch;
+ content: "";
+ background: var(--color-bg-secondary);
+ border: var(--border-size) solid var(--color-border-light);
+ }
+
+ &::before {
+ grid-column: 1;
+ border-left: none;
+ }
+
+ &::after {
+ grid-column: 3;
+ border-right: none;
+ }
+
+ &__body {
+ grid-column: 2;
+ display: flex;
+ flex-flow: column wrap;
+ row-gap: var(--spacing-sm);
+ }
+}
+
+.body {
+ grid-column: 2;
+ margin-top: var(--spacing-sm);
+ padding-bottom: var(--spacing-md);
+}
+
+.body > * + * {
+ margin-top: var(--spacing-sm);
+ margin-bottom: var(--spacing-sm);
+}
+
+.footer {
+ grid-column: 2;
+ padding: var(--spacing-sm) 0 var(--spacing-2xs);
+}
+
+.sidebar {
+ grid-column: 2;
+ margin-top: var(--spacing-md);
+
+ &__body {
+ position: sticky;
+ top: var(--spacing-xs);
+
+ h1,
+ h2,
+ h3,
+ h4,
+ h5,
+ h6 {
+ background: transparent;
+ font-size: var(--font-size-xl);
+ }
+
+ > * + * {
+ margin-top: var(--spacing-sm);
+ }
+ }
+}
+
+:where(.footer) {
+ .btn {
+ margin-inline-end: var(--spacing-2xs);
+ }
+
+ .logo {
+ max-height: fun.convert-px(30);
+ width: auto;
+ }
+}
+
+:where(.header) {
+ .heading {
+ display: flex;
+ flex-flow: row wrap;
+ align-items: center;
+ position: relative;
+
+ &::before,
+ &::after {
+ content: "";
+ width: 100%;
+ height: var(--border-size);
+ background: radial-gradient(
+ ellipse at center,
+ var(--color-primary-light),
+ var(--color-primary-dark)
+ );
+ }
+ }
+
+ .meta {
+ font-size: var(--font-size-sm);
+ }
+
+ .intro {
+ > *:last-child {
+ margin-bottom: 0;
+ }
+ }
+}
+
+:where(.body, .footer) + .sidebar {
+ margin-bottom: var(--spacing-lg);
+}
+
+.comments {
+ @extend %grid;
+
+ grid-column: 1 / -1;
+ margin-top: var(--spacing-lg);
+ padding: 0 0 var(--spacing-lg);
+ background: var(--color-bg-secondary);
+ border-top: var(--border-size) solid var(--color-border-light);
+}
+
+:where(.comments) {
+ .section {
+ grid-column: 2;
+ }
+
+ .heading {
+ width: fit-content;
+ margin: var(--spacing-md) auto;
+ }
+
+ .form {
+ max-width: 40ch;
+ margin-inline: auto;
+ }
+}
+
+@container page (width > #{var.get-breakpoint("md")}) {
+ .breadcrumbs,
+ .page {
+ --right-col: minmax(25ch, 1fr);
+ }
+
+ :where(.page--body-last) .body {
+ padding-bottom: var(--spacing-lg);
+ }
+
+ .body + .sidebar,
+ .footer + .sidebar {
+ grid-column: 3;
+ grid-row: 2 / span 2;
+ align-self: stretch;
+ padding: 0 var(--spacing-xs) var(--spacing-md);
+ }
+}
+
+@container page (width > #{var.get-breakpoint("lg")}) {
+ .breadcrumbs,
+ .page {
+ --left-col: minmax(25ch, 1fr);
+ }
+
+ .header + .sidebar {
+ grid-column: 1;
+ align-self: stretch;
+ padding: 0 var(--spacing-xs) var(--spacing-md);
+ }
+}
diff --git a/src/components/templates/page/page.stories.tsx b/src/components/templates/page/page.stories.tsx
new file mode 100644
index 0000000..6b1058e
--- /dev/null
+++ b/src/components/templates/page/page.stories.tsx
@@ -0,0 +1,456 @@
+import type { ComponentMeta, ComponentStory } from '@storybook/react';
+import { Heading } from '../../atoms';
+import type { CommentData } from '../../organisms/comments-list';
+import { SharingWidget, TocWidget } from '../../organisms/widgets';
+import { Page } from './page';
+import { PageBody } from './page-body';
+import { PageComments } from './page-comments';
+import { PageFooter } from './page-footer';
+import { PageHeader } from './page-header';
+import { PageSidebar } from './page-sidebar';
+
+/**
+ * Page - Storybook Meta
+ */
+export default {
+ title: 'Templates/Page',
+ component: Page,
+ argTypes: {},
+ parameters: {
+ layout: 'fullscreen',
+ },
+} as ComponentMeta<typeof Page>;
+
+const Template: ComponentStory<typeof Page> = (args) => <Page {...args} />;
+
+const comments = [
+ {
+ author: {
+ name: 'Milan0',
+ avatar: {
+ alt: 'Milan0 avatar',
+ src: 'https://cloudflare-ipfs.com/ipfs/Qmd3W5DuhgHirLHGVixi6V76LhCkZUz6pnFt5AJBiyvHye/avatar/976.jpg',
+ },
+ },
+ content: 'Fugit veniam quas qui dolor explicabo.',
+ id: 1,
+ isApproved: true,
+ publicationDate: '2023-01-23',
+ replies: [
+ {
+ author: { name: 'Haskell42' },
+ content: 'Error quas accusamus nesciunt enim quae a.',
+ id: 25,
+ isApproved: true,
+ publicationDate: '2023-02-04',
+ },
+ {
+ author: { name: 'Hanna49', website: 'https://www.armandphilippot.com' },
+ content: 'Ut ducimus neque aliquam soluta sed totam commodi cum sit.',
+ id: 30,
+ isApproved: true,
+ publicationDate: '2023-03-10',
+ },
+ ],
+ },
+ {
+ author: {
+ name: 'Corrine9',
+ avatar: {
+ alt: 'Corrine9 avatar',
+ src: 'https://cloudflare-ipfs.com/ipfs/Qmd3W5DuhgHirLHGVixi6V76LhCkZUz6pnFt5AJBiyvHye/avatar/539.jpg',
+ },
+ },
+ content:
+ 'Dolore hic iure voluptatum quam error minima. Quas ut aperiam sit commodi cumque consequatur. Voluptas debitis veritatis officiis in voluptas ea et laborum animi. Voluptatem qui enim neque. Et sunt quo neque assumenda iure. Non vel ut consectetur.',
+ id: 2,
+ isApproved: true,
+ publicationDate: '2023-04-20',
+ },
+ {
+ author: { name: 'Presley12' },
+ content:
+ 'Nulla eaque similique recusandae enim aut eligendi iure consequatur. Et aut qui. Voluptatem a voluptatem consequatur aliquid distinctio ex culpa. Adipisci animi amet reprehenderit autem quia commodi voluptatum commodi.',
+ id: 3,
+ isApproved: true,
+ publicationDate: '2023-05-01',
+ replies: [
+ {
+ author: {
+ name: 'Ana_Haley33',
+ avatar: {
+ alt: 'Ana_Haley33 avatar',
+ src: 'https://cloudflare-ipfs.com/ipfs/Qmd3W5DuhgHirLHGVixi6V76LhCkZUz6pnFt5AJBiyvHye/avatar/881.jpg',
+ },
+ },
+ content: 'Ab ea et fugit autem.',
+ id: 17,
+ isApproved: true,
+ publicationDate: '2023-05-01',
+ },
+ {
+ author: { name: 'Santos.Harris17' },
+ content:
+ 'Illo dolores voluptatem similique voluptas quasi hic aspernatur ab nisi.',
+ id: 18,
+ isApproved: false,
+ publicationDate: '2023-05-02',
+ },
+ ],
+ },
+ {
+ author: { name: 'Julius.Borer' },
+ content: 'Ea fugit totam et voluptatum quidem laborum explicabo fuga quod.',
+ id: 4,
+ isApproved: true,
+ publicationDate: '2023-06-15',
+ },
+ {
+ author: { name: 'Geo87' },
+ content:
+ 'Enim consequatur deleniti aliquid adipisci. Et mollitia saepe vel rerum totam praesentium assumenda repellat fuga. Ipsum ut architecto consequatur. Ut laborum suscipit sed corporis quas aliquid. Et et omnis quo. Dolore quia ipsum ut corporis eum et corporis qui.',
+ id: 5,
+ isApproved: false,
+ publicationDate: '2023-06-16',
+ },
+ {
+ author: { name: 'Kurt.Keeling' },
+ content: 'Eligendi repellat officiis amet.',
+ id: 6,
+ isApproved: true,
+ publicationDate: '2023-06-17',
+ },
+] satisfies CommentData[];
+
+/**
+ * Page Stories - HeaderBody
+ */
+export const HeaderBody = Template.bind({});
+HeaderBody.args = {
+ children: (
+ <>
+ <PageHeader
+ heading="The page title"
+ intro="Dolores deleniti et nemo fuga minus dicta enim dolores. Animi magnam dignissimos quaerat repellat autem alias. Recusandae pariatur autem et omnis eveniet. Magni fuga consequatur ut omnis debitis est error. Quos quae odit."
+ />
+ <PageBody>
+ <Heading level={2}>Sint debitis blanditiis</Heading>
+ <p>
+ Exercitationem dolorum sed incidunt commodi sapiente fuga. Qui qui
+ minima nulla ullam alias magnam et. Reiciendis ea voluptatem ab nisi
+ est aut repudiandae eum magnam. Iusto ex ut velit voluptatem sequi
+ facere voluptas.
+ </p>
+ <p>
+ Vel ut ullam veritatis aut quaerat a eveniet. Voluptatem molestias
+ atque rerum quam eos doloremque dolor dolor non. Rerum laudantium
+ provident eos voluptas minus sit mollitia ex neque. Ea est ut est. Id
+ quaerat repudiandae sint autem architecto adipisci est.
+ </p>
+ <Heading level={2}>Non nisi similique</Heading>
+ <p>
+ Non ut id. Dolorem in ex voluptas quis quod ut facere. Laboriosam non
+ necessitatibus mollitia voluptatibus dolorem non ducimus. Et non illo
+ aut quasi accusantium fugiat laudantium veritatis. Odit dicta vel et
+ et rem ipsa nihil. Possimus architecto voluptatibus labore repellat
+ sint aperiam reprehenderit est ratione.
+ </p>
+ <p>
+ Nemo quod est ex ut et quasi. Sed minima voluptatem dolore. Non dolore
+ placeat eos qui praesentium sunt dolores. Consequatur atque quibusdam
+ tempore aut. Quas officiis adipisci consequatur nisi. Quasi veniam qui
+ mollitia sapiente eius ratione necessitatibus nobis molestiae.
+ </p>
+ </PageBody>
+ </>
+ ),
+};
+
+/**
+ * Page Stories - BreadcrumbsHeaderBody
+ */
+export const BreadcrumbsHeaderBody = Template.bind({});
+BreadcrumbsHeaderBody.args = {
+ breadcrumbs: [
+ { id: 'home', name: 'Home', url: '#home' },
+ { id: 'blog', name: 'Blog', url: '#blog' },
+ ],
+ children: (
+ <>
+ <PageHeader
+ heading="The page title"
+ intro="Dolores deleniti et nemo fuga minus dicta enim dolores. Animi magnam dignissimos quaerat repellat autem alias. Recusandae pariatur autem et omnis eveniet. Magni fuga consequatur ut omnis debitis est error. Quos quae odit."
+ />
+ <PageBody>
+ <Heading level={2}>Sint debitis blanditiis</Heading>
+ <p>
+ Exercitationem dolorum sed incidunt commodi sapiente fuga. Qui qui
+ minima nulla ullam alias magnam et. Reiciendis ea voluptatem ab nisi
+ est aut repudiandae eum magnam. Iusto ex ut velit voluptatem sequi
+ facere voluptas.
+ </p>
+ <p>
+ Vel ut ullam veritatis aut quaerat a eveniet. Voluptatem molestias
+ atque rerum quam eos doloremque dolor dolor non. Rerum laudantium
+ provident eos voluptas minus sit mollitia ex neque. Ea est ut est. Id
+ quaerat repudiandae sint autem architecto adipisci est.
+ </p>
+ <Heading level={2}>Non nisi similique</Heading>
+ <p>
+ Non ut id. Dolorem in ex voluptas quis quod ut facere. Laboriosam non
+ necessitatibus mollitia voluptatibus dolorem non ducimus. Et non illo
+ aut quasi accusantium fugiat laudantium veritatis. Odit dicta vel et
+ et rem ipsa nihil. Possimus architecto voluptatibus labore repellat
+ sint aperiam reprehenderit est ratione.
+ </p>
+ <p>
+ Nemo quod est ex ut et quasi. Sed minima voluptatem dolore. Non dolore
+ placeat eos qui praesentium sunt dolores. Consequatur atque quibusdam
+ tempore aut. Quas officiis adipisci consequatur nisi. Quasi veniam qui
+ mollitia sapiente eius ratione necessitatibus nobis molestiae.
+ </p>
+ </PageBody>
+ </>
+ ),
+};
+
+/**
+ * Page Stories - HeaderBodyToc
+ */
+export const HeaderBodyToc = Template.bind({});
+HeaderBodyToc.args = {
+ children: (
+ <>
+ <PageHeader
+ heading="The page title"
+ intro="Dolores deleniti et nemo fuga minus dicta enim dolores. Animi magnam dignissimos quaerat repellat autem alias. Recusandae pariatur autem et omnis eveniet. Magni fuga consequatur ut omnis debitis est error. Quos quae odit."
+ />
+ <PageSidebar>
+ <TocWidget
+ heading={<Heading level={2}>Table of Contents</Heading>}
+ tree={[
+ {
+ children: [],
+ depth: 2,
+ id: 'sint-debitis',
+ label: 'Sint debitis blanditiis',
+ },
+ {
+ children: [],
+ depth: 2,
+ id: 'non-nisi',
+ label: 'Non nisi similique',
+ },
+ ]}
+ />
+ </PageSidebar>
+ <PageBody>
+ <Heading id="sint-debitis" level={2}>
+ Sint debitis blanditiis
+ </Heading>
+ <p>
+ Exercitationem dolorum sed incidunt commodi sapiente fuga. Qui qui
+ minima nulla ullam alias magnam et. Reiciendis ea voluptatem ab nisi
+ est aut repudiandae eum magnam. Iusto ex ut velit voluptatem sequi
+ facere voluptas.
+ </p>
+ <p>
+ Vel ut ullam veritatis aut quaerat a eveniet. Voluptatem molestias
+ atque rerum quam eos doloremque dolor dolor non. Rerum laudantium
+ provident eos voluptas minus sit mollitia ex neque. Ea est ut est. Id
+ quaerat repudiandae sint autem architecto adipisci est.
+ </p>
+ <Heading id="non-nisi" level={2}>
+ Non nisi similique
+ </Heading>
+ <p>
+ Non ut id. Dolorem in ex voluptas quis quod ut facere. Laboriosam non
+ necessitatibus mollitia voluptatibus dolorem non ducimus. Et non illo
+ aut quasi accusantium fugiat laudantium veritatis. Odit dicta vel et
+ et rem ipsa nihil. Possimus architecto voluptatibus labore repellat
+ sint aperiam reprehenderit est ratione.
+ </p>
+ <p>
+ Nemo quod est ex ut et quasi. Sed minima voluptatem dolore. Non dolore
+ placeat eos qui praesentium sunt dolores. Consequatur atque quibusdam
+ tempore aut. Quas officiis adipisci consequatur nisi. Quasi veniam qui
+ mollitia sapiente eius ratione necessitatibus nobis molestiae.
+ </p>
+ </PageBody>
+ </>
+ ),
+};
+
+/**
+ * Page Stories - HeaderBodyTocSidebar
+ */
+export const HeaderBodyTocSidebar = Template.bind({});
+HeaderBodyTocSidebar.args = {
+ children: (
+ <>
+ <PageHeader
+ heading="The page title"
+ intro="Dolores deleniti et nemo fuga minus dicta enim dolores. Animi magnam dignissimos quaerat repellat autem alias. Recusandae pariatur autem et omnis eveniet. Magni fuga consequatur ut omnis debitis est error. Quos quae odit."
+ />
+ <PageSidebar>
+ <TocWidget
+ heading={<Heading level={2}>Table of Contents</Heading>}
+ tree={[
+ {
+ children: [],
+ depth: 2,
+ id: 'sint-debitis',
+ label: 'Sint debitis blanditiis',
+ },
+ {
+ children: [],
+ depth: 2,
+ id: 'non-nisi',
+ label: 'Non nisi similique',
+ },
+ ]}
+ />
+ </PageSidebar>
+ <PageBody>
+ <Heading id="sint-debitis" level={2}>
+ Sint debitis blanditiis
+ </Heading>
+ <p>
+ Exercitationem dolorum sed incidunt commodi sapiente fuga. Qui qui
+ minima nulla ullam alias magnam et. Reiciendis ea voluptatem ab nisi
+ est aut repudiandae eum magnam. Iusto ex ut velit voluptatem sequi
+ facere voluptas.
+ </p>
+ <p>
+ Vel ut ullam veritatis aut quaerat a eveniet. Voluptatem molestias
+ atque rerum quam eos doloremque dolor dolor non. Rerum laudantium
+ provident eos voluptas minus sit mollitia ex neque. Ea est ut est. Id
+ quaerat repudiandae sint autem architecto adipisci est.
+ </p>
+ <Heading id="non-nisi" level={2}>
+ Non nisi similique
+ </Heading>
+ <p>
+ Non ut id. Dolorem in ex voluptas quis quod ut facere. Laboriosam non
+ necessitatibus mollitia voluptatibus dolorem non ducimus. Et non illo
+ aut quasi accusantium fugiat laudantium veritatis. Odit dicta vel et
+ et rem ipsa nihil. Possimus architecto voluptatibus labore repellat
+ sint aperiam reprehenderit est ratione.
+ </p>
+ <p>
+ Nemo quod est ex ut et quasi. Sed minima voluptatem dolore. Non dolore
+ placeat eos qui praesentium sunt dolores. Consequatur atque quibusdam
+ tempore aut. Quas officiis adipisci consequatur nisi. Quasi veniam qui
+ mollitia sapiente eius ratione necessitatibus nobis molestiae.
+ </p>
+ </PageBody>
+ <PageSidebar>
+ <SharingWidget
+ data={{
+ excerpt:
+ 'Dolores deleniti et nemo fuga minus dicta enim dolores. Animi magnam dignissimos quaerat repellat autem alias. Recusandae pariatur autem et omnis eveniet. Magni fuga consequatur ut omnis debitis est error. Quos quae odit.',
+ title: 'The page title',
+ url: '#page',
+ }}
+ heading={<Heading level={2}>Share</Heading>}
+ media={['diaspora', 'email', 'facebook']}
+ />
+ </PageSidebar>
+ </>
+ ),
+};
+
+/**
+ * Page Stories - HeaderBodyFooter
+ */
+export const HeaderBodyFooter = Template.bind({});
+HeaderBodyFooter.args = {
+ children: (
+ <>
+ <PageHeader
+ heading="The page title"
+ intro="Dolores deleniti et nemo fuga minus dicta enim dolores. Animi magnam dignissimos quaerat repellat autem alias. Recusandae pariatur autem et omnis eveniet. Magni fuga consequatur ut omnis debitis est error. Quos quae odit."
+ />
+ <PageBody>
+ <Heading level={2}>Sint debitis blanditiis</Heading>
+ <p>
+ Exercitationem dolorum sed incidunt commodi sapiente fuga. Qui qui
+ minima nulla ullam alias magnam et. Reiciendis ea voluptatem ab nisi
+ est aut repudiandae eum magnam. Iusto ex ut velit voluptatem sequi
+ facere voluptas.
+ </p>
+ <p>
+ Vel ut ullam veritatis aut quaerat a eveniet. Voluptatem molestias
+ atque rerum quam eos doloremque dolor dolor non. Rerum laudantium
+ provident eos voluptas minus sit mollitia ex neque. Ea est ut est. Id
+ quaerat repudiandae sint autem architecto adipisci est.
+ </p>
+ <Heading level={2}>Non nisi similique</Heading>
+ <p>
+ Non ut id. Dolorem in ex voluptas quis quod ut facere. Laboriosam non
+ necessitatibus mollitia voluptatibus dolorem non ducimus. Et non illo
+ aut quasi accusantium fugiat laudantium veritatis. Odit dicta vel et
+ et rem ipsa nihil. Possimus architecto voluptatibus labore repellat
+ sint aperiam reprehenderit est ratione.
+ </p>
+ <p>
+ Nemo quod est ex ut et quasi. Sed minima voluptatem dolore. Non dolore
+ placeat eos qui praesentium sunt dolores. Consequatur atque quibusdam
+ tempore aut. Quas officiis adipisci consequatur nisi. Quasi veniam qui
+ mollitia sapiente eius ratione necessitatibus nobis molestiae.
+ </p>
+ </PageBody>
+ <PageFooter
+ readMoreAbout={[
+ { id: 1, name: 'Topic 1', url: '#topic1' },
+ { id: 2, name: 'Topic 2', url: '#topic2' },
+ ]}
+ />
+ </>
+ ),
+};
+
+/**
+ * Page Stories - HeaderBodyComments
+ */
+export const HeaderBodyComments = Template.bind({});
+HeaderBodyComments.args = {
+ children: (
+ <>
+ <PageHeader
+ heading="The page title"
+ intro="Dolores deleniti et nemo fuga minus dicta enim dolores. Animi magnam dignissimos quaerat repellat autem alias. Recusandae pariatur autem et omnis eveniet. Magni fuga consequatur ut omnis debitis est error. Quos quae odit."
+ />
+ <PageBody>
+ <Heading level={2}>Sint debitis blanditiis</Heading>
+ <p>
+ Exercitationem dolorum sed incidunt commodi sapiente fuga. Qui qui
+ minima nulla ullam alias magnam et. Reiciendis ea voluptatem ab nisi
+ est aut repudiandae eum magnam. Iusto ex ut velit voluptatem sequi
+ facere voluptas.
+ </p>
+ <p>
+ Vel ut ullam veritatis aut quaerat a eveniet. Voluptatem molestias
+ atque rerum quam eos doloremque dolor dolor non. Rerum laudantium
+ provident eos voluptas minus sit mollitia ex neque. Ea est ut est. Id
+ quaerat repudiandae sint autem architecto adipisci est.
+ </p>
+ <Heading level={2}>Non nisi similique</Heading>
+ <p>
+ Non ut id. Dolorem in ex voluptas quis quod ut facere. Laboriosam non
+ necessitatibus mollitia voluptatibus dolorem non ducimus. Et non illo
+ aut quasi accusantium fugiat laudantium veritatis. Odit dicta vel et
+ et rem ipsa nihil. Possimus architecto voluptatibus labore repellat
+ sint aperiam reprehenderit est ratione.
+ </p>
+ <p>
+ Nemo quod est ex ut et quasi. Sed minima voluptatem dolore. Non dolore
+ placeat eos qui praesentium sunt dolores. Consequatur atque quibusdam
+ tempore aut. Quas officiis adipisci consequatur nisi. Quasi veniam qui
+ mollitia sapiente eius ratione necessitatibus nobis molestiae.
+ </p>
+ </PageBody>
+ <PageComments comments={comments} pageId={1} />
+ </>
+ ),
+};
diff --git a/src/components/templates/page/page.test.tsx b/src/components/templates/page/page.test.tsx
new file mode 100644
index 0000000..21c5a86
--- /dev/null
+++ b/src/components/templates/page/page.test.tsx
@@ -0,0 +1,49 @@
+import { describe, expect, it } from '@jest/globals';
+import { render, screen as rtlScreen } from '../../../../tests/utils';
+import type { BreadcrumbsItem } from '../../organisms';
+import { Page } from './page';
+import { PageBody } from './page-body';
+
+describe('Page', () => {
+ it('renders its children', () => {
+ const body =
+ 'Consequatur deleniti eligendi quidem sint et nobis ut qui. Dolores modi eos. Cupiditate aliquid sunt consequatur voluptatem laudantium.';
+
+ render(
+ <Page>
+ <PageBody>{body}</PageBody>
+ </Page>
+ );
+
+ expect(rtlScreen.getByText(body)).toBeInTheDocument();
+ });
+
+ it('can render the breadcrumbs', () => {
+ const body =
+ 'Consequatur deleniti eligendi quidem sint et nobis ut qui. Dolores modi eos. Cupiditate aliquid sunt consequatur voluptatem laudantium.';
+ const breadcrumbs = [
+ { id: 'home', name: 'Home', url: '#home' },
+ { id: 'blog', name: 'Blog', url: '#blog' },
+ ] satisfies BreadcrumbsItem[];
+
+ render(
+ <Page breadcrumbs={breadcrumbs}>
+ <PageBody>{body}</PageBody>
+ </Page>
+ );
+
+ expect(rtlScreen.getByRole('navigation')).toHaveAccessibleName(
+ 'Breadcrumbs'
+ );
+ expect(rtlScreen.getAllByRole('listitem')).toHaveLength(breadcrumbs.length);
+ });
+
+ it('can have a class modifier based on a prop', () => {
+ const body =
+ 'Consequatur deleniti eligendi quidem sint et nobis ut qui. Dolores modi eos. Cupiditate aliquid sunt consequatur voluptatem laudantium.';
+
+ render(<Page isBodyLastChild>{body}</Page>);
+
+ expect(rtlScreen.getByText(body)).toHaveClass('page--body-last');
+ });
+});
diff --git a/src/components/templates/page/page.tsx b/src/components/templates/page/page.tsx
new file mode 100644
index 0000000..f5f3ea5
--- /dev/null
+++ b/src/components/templates/page/page.tsx
@@ -0,0 +1,56 @@
+import {
+ type ForwardRefRenderFunction,
+ forwardRef,
+ type HTMLAttributes,
+} from 'react';
+import { useIntl } from 'react-intl';
+import { Article } from '../../atoms';
+import { Breadcrumbs, type BreadcrumbsItem } from '../../organisms/nav';
+import styles from './page.module.scss';
+
+export type PageProps = HTMLAttributes<HTMLDivElement> & {
+ /**
+ * The breadcrumbs items.
+ */
+ breadcrumbs?: BreadcrumbsItem[];
+ /**
+ * Add an extra padding to the body when there are no footer/comments.
+ *
+ * Note: this should be refactored when `:has()` pseudo-class will have a
+ * better support.
+ *
+ * @default false
+ */
+ isBodyLastChild?: boolean;
+};
+
+const PageWithRef: ForwardRefRenderFunction<HTMLDivElement, PageProps> = (
+ { breadcrumbs, children, className = '', isBodyLastChild = false, ...props },
+ ref
+) => {
+ const wrapperClass = `${styles.wrapper} ${className}`;
+ const pageClass = `${styles.page} ${
+ styles[isBodyLastChild ? 'page--body-last' : '']
+ }`;
+ const intl = useIntl();
+ const breadcrumbsLabel = intl.formatMessage({
+ defaultMessage: 'Breadcrumbs',
+ description: 'Page: an accessible name for the breadcrumb nav.',
+ id: '/TTRRX',
+ });
+
+ return (
+ <div {...props} className={wrapperClass} ref={ref}>
+ {breadcrumbs ? (
+ <Breadcrumbs
+ aria-label={breadcrumbsLabel}
+ className={styles.breadcrumbs}
+ items={breadcrumbs}
+ />
+ ) : null}
+ <Article className={pageClass}>{children}</Article>
+ </div>
+ );
+};
+
+export const Page = forwardRef(PageWithRef);