aboutsummaryrefslogtreecommitdiffstats
path: root/src/components/templates/layout
diff options
context:
space:
mode:
authorArmand Philippot <git@armandphilippot.com>2022-05-24 19:35:12 +0200
committerGitHub <noreply@github.com>2022-05-24 19:35:12 +0200
commitc85ab5ad43ccf52881ee224672c41ec30021cf48 (patch)
tree8058808d9bfca19383f120c46b34d99ff2f89f63 /src/components/templates/layout
parent52404177c07a2aab7fc894362fb3060dff2431a0 (diff)
parent11b9de44a4b2f305a6a484187805e429b2767118 (diff)
refactor: use storybook and atomic design (#16)
BREAKING CHANGE: rewrite most of the Typescript types, so the content format (the meta in particular) needs to be updated.
Diffstat (limited to 'src/components/templates/layout')
-rw-r--r--src/components/templates/layout/layout.module.scss53
-rw-r--r--src/components/templates/layout/layout.stories.tsx117
-rw-r--r--src/components/templates/layout/layout.test.tsx35
-rw-r--r--src/components/templates/layout/layout.tsx242
4 files changed, 447 insertions, 0 deletions
diff --git a/src/components/templates/layout/layout.module.scss b/src/components/templates/layout/layout.module.scss
new file mode 100644
index 0000000..1080732
--- /dev/null
+++ b/src/components/templates/layout/layout.module.scss
@@ -0,0 +1,53 @@
+@use "@styles/abstracts/functions" as fun;
+@use "@styles/abstracts/mixins" as mix;
+@use "@styles/abstracts/placeholders";
+
+.header {
+ border-bottom: fun.convert-px(3) solid var(--color-border-light);
+}
+
+.main {
+ flex: 1;
+}
+
+.article {
+ &--grid {
+ @extend %grid;
+
+ grid-auto-flow: column dense;
+ align-items: baseline;
+ }
+
+ &--padding {
+ padding-bottom: var(--spacing-lg);
+ }
+}
+
+.footer {
+ border-top: fun.convert-px(3) solid var(--color-border-light);
+}
+
+.back-to-top {
+ &--hidden {
+ opacity: 0;
+ transform: translateY(calc(var(--button-size) + var(--spacing-md)));
+ visibility: hidden;
+ }
+
+ &--visible {
+ opacity: 1;
+ transform: translateY(0);
+ visibility: visible;
+ }
+}
+
+.noscript-spacing {
+ width: 100%;
+ height: fun.convert-px(75);
+
+ @include mix.media("screen") {
+ @include mix.dimensions("xs") {
+ height: fun.convert-px(50);
+ }
+ }
+}
diff --git a/src/components/templates/layout/layout.stories.tsx b/src/components/templates/layout/layout.stories.tsx
new file mode 100644
index 0000000..4666b07
--- /dev/null
+++ b/src/components/templates/layout/layout.stories.tsx
@@ -0,0 +1,117 @@
+import { ComponentMeta, ComponentStory } from '@storybook/react';
+import LayoutComponent from './layout';
+
+/**
+ * Layout - Storybook Meta
+ */
+export default {
+ title: 'Templates/LayoutBase',
+ component: LayoutComponent,
+ args: {
+ breadcrumbSchema: [],
+ isHome: false,
+ },
+ argTypes: {
+ children: {
+ control: {
+ type: 'text',
+ },
+ description: 'The article content.',
+ type: {
+ name: 'string',
+ required: true,
+ },
+ },
+ breadcrumbSchema: {
+ control: {
+ type: 'null',
+ },
+ description: 'The JSON schema for breadcrumb items.',
+ type: {
+ name: 'object',
+ required: true,
+ value: {},
+ },
+ },
+ isHome: {
+ control: {
+ type: 'boolean',
+ },
+ description: 'Determine if it is the homepage.',
+ table: {
+ category: 'Options',
+ defaultValue: { summary: false },
+ },
+ type: {
+ name: 'boolean',
+ required: false,
+ },
+ },
+ className: {
+ control: {
+ type: 'text',
+ },
+ description: 'Set additional classnames to the article element.',
+ table: {
+ category: 'Styles',
+ },
+ type: {
+ name: 'string',
+ required: false,
+ },
+ },
+ useGrid: {
+ control: {
+ type: 'boolean',
+ },
+ description: 'Use the grid layout.',
+ table: {
+ category: 'Options',
+ },
+ type: {
+ name: 'boolean',
+ required: false,
+ },
+ },
+ withExtraPadding: {
+ control: {
+ type: 'boolean',
+ },
+ description: 'Set additional padding at the bottom of the page.',
+ table: {
+ category: 'Options',
+ },
+ type: {
+ name: 'boolean',
+ required: false,
+ },
+ },
+ },
+ decorators: [
+ (Story) => (
+ <div
+ id="__next"
+ style={{
+ flex: 1,
+ display: 'flex',
+ flexFlow: 'column nowrap',
+ minHeight: '100vh',
+ }}
+ >
+ <Story />
+ </div>
+ ),
+ ],
+ parameters: {
+ layout: 'fullscreen',
+ },
+} as ComponentMeta<typeof LayoutComponent>;
+
+const Template: ComponentStory<typeof LayoutComponent> = (args) => (
+ <LayoutComponent {...args} />
+);
+
+/**
+ * Layout Stories - Default
+ */
+export const LayoutBase = Template.bind({});
diff --git a/src/components/templates/layout/layout.test.tsx b/src/components/templates/layout/layout.test.tsx
new file mode 100644
index 0000000..78547d4
--- /dev/null
+++ b/src/components/templates/layout/layout.test.tsx
@@ -0,0 +1,35 @@
+import { render, screen } from '@test-utils';
+import { BreadcrumbList } from 'schema-dts';
+import Layout from './layout';
+
+const body =
+ 'Sit dolorem eveniet. Sit sit odio nemo vitae corrupti modi sint est rerum. Pariatur quidem maiores distinctio. Quia et illum aspernatur est cum.';
+
+describe('Layout', () => {
+ it('renders the website header', () => {
+ render(<Layout>{body}</Layout>);
+ expect(screen.getByRole('banner')).toBeInTheDocument();
+ });
+
+ it('renders the website main content', () => {
+ render(<Layout>{body}</Layout>);
+ expect(screen.getByRole('main')).toBeInTheDocument();
+ });
+
+ it('renders the website footer', () => {
+ render(<Layout>{body}</Layout>);
+ expect(screen.getByRole('contentinfo')).toBeInTheDocument();
+ });
+
+ it('renders a skip to content link', () => {
+ render(<Layout>{body}</Layout>);
+ expect(
+ screen.getByRole('link', { name: 'Skip to content' })
+ ).toBeInTheDocument();
+ });
+
+ it('renders an article', () => {
+ render(<Layout>{body}</Layout>);
+ expect(screen.getByRole('article')).toHaveTextContent(body);
+ });
+});
diff --git a/src/components/templates/layout/layout.tsx b/src/components/templates/layout/layout.tsx
new file mode 100644
index 0000000..8f0d4e7
--- /dev/null
+++ b/src/components/templates/layout/layout.tsx
@@ -0,0 +1,242 @@
+import ButtonLink from '@components/atoms/buttons/button-link';
+import Career from '@components/atoms/icons/career';
+import CCBySA from '@components/atoms/icons/cc-by-sa';
+import ComputerScreen from '@components/atoms/icons/computer-screen';
+import Envelop from '@components/atoms/icons/envelop';
+import Home from '@components/atoms/icons/home';
+import PostsStack from '@components/atoms/icons/posts-stack';
+import Main from '@components/atoms/layout/main';
+import NoScript from '@components/atoms/layout/no-script';
+import Footer, { type FooterProps } from '@components/organisms/layout/footer';
+import Header, { type HeaderProps } from '@components/organisms/layout/header';
+import { type NextPageWithLayoutOptions } from '@ts/types/app';
+import useScrollPosition from '@utils/hooks/use-scroll-position';
+import useSettings from '@utils/hooks/use-settings';
+import Script from 'next/script';
+import { FC, ReactElement, ReactNode, useState } from 'react';
+import { useIntl } from 'react-intl';
+import { Person, SearchAction, WebSite, WithContext } from 'schema-dts';
+import styles from './layout.module.scss';
+
+export type QueryAction = SearchAction & {
+ 'query-input': string;
+};
+
+export type LayoutProps = Pick<HeaderProps, 'isHome'> & {
+ /**
+ * The layout main content.
+ */
+ children: ReactNode;
+ /**
+ * Determine if article has a comments section.
+ */
+ withExtraPadding?: boolean;
+ /**
+ * Determine if article should use grid. Default: false.
+ */
+ useGrid?: boolean;
+};
+
+/**
+ * Layout component
+ *
+ * Render the base layout used by all pages.
+ */
+const Layout: FC<LayoutProps> = ({
+ children,
+ withExtraPadding = false,
+ isHome,
+ useGrid = false,
+}) => {
+ const intl = useIntl();
+ const { website } = useSettings();
+ const { baseline, copyright, locales, name, picture, url } = website;
+ const articleGridClass = useGrid ? 'article--grid' : '';
+ const articleCommentsClass = withExtraPadding ? 'article--padding' : '';
+
+ const skipToContent = intl.formatMessage({
+ defaultMessage: 'Skip to content',
+ description: 'Layout: Skip to content link',
+ id: 'K4rYdT',
+ });
+ const noScript = intl.formatMessage({
+ defaultMessage:
+ 'Warning: If you want to benefit from all features (search for example), please activate Javascript.',
+ description: 'Layout: noscript message',
+ id: '7jVUT6',
+ });
+
+ const copyrightData = {
+ dates: {
+ start: copyright.start,
+ end: copyright.end,
+ },
+ owner: name,
+ icon: <CCBySA />,
+ };
+
+ const homeLabel = intl.formatMessage({
+ defaultMessage: 'Home',
+ description: 'Layout: main nav - home link',
+ id: 'bojYF5',
+ });
+ const blogLabel = intl.formatMessage({
+ defaultMessage: 'Blog',
+ description: 'Layout: main nav - blog link',
+ id: 'D8vB38',
+ });
+ const projectsLabel = intl.formatMessage({
+ defaultMessage: 'Projects',
+ description: 'Layout: main nav - projects link',
+ id: 'qnwsWV',
+ });
+ const cvLabel = intl.formatMessage({
+ defaultMessage: 'CV',
+ description: 'Layout: main nav - cv link',
+ id: 'R895yC',
+ });
+ const contactLabel = intl.formatMessage({
+ defaultMessage: 'Contact',
+ description: 'Layout: main nav - contact link',
+ id: 'AE4kCD',
+ });
+
+ const mainNav: HeaderProps['nav'] = [
+ { id: 'home', label: homeLabel, href: '/', logo: <Home /> },
+ { id: 'blog', label: blogLabel, href: '/blog', logo: <PostsStack /> },
+ {
+ id: 'projects',
+ label: projectsLabel,
+ href: '/projets',
+ logo: <ComputerScreen />,
+ },
+ { id: 'cv', label: cvLabel, href: '/cv', logo: <Career /> },
+ { id: 'contact', label: contactLabel, href: '/contact', logo: <Envelop /> },
+ ];
+
+ const legalNoticeLabel = intl.formatMessage({
+ defaultMessage: 'Legal notice',
+ description: 'Layout: Legal notice label',
+ id: 'nwbzKm',
+ });
+
+ const footerNav: FooterProps['navItems'] = [
+ { id: 'legal-notice', label: legalNoticeLabel, href: '/mentions-legales' },
+ ];
+
+ const searchActionSchema: QueryAction = {
+ '@type': 'SearchAction',
+ target: {
+ '@type': 'EntryPoint',
+ urlTemplate: `${url}/recherche?s={search_term_string}`,
+ },
+ query: 'required',
+ 'query-input': 'required name=search_term_string',
+ };
+
+ const schemaJsonLd: WithContext<WebSite> = {
+ '@context': 'https://schema.org',
+ '@id': `${url}`,
+ '@type': 'WebSite',
+ name: name,
+ description: baseline,
+ url: url,
+ author: { '@id': `${url}/#branding` },
+ copyrightYear: Number(copyright.start),
+ creator: { '@id': `${url}/#branding` },
+ editor: { '@id': `${url}/#branding` },
+ inLanguage: locales.default,
+ potentialAction: searchActionSchema,
+ };
+
+ const brandingSchema: WithContext<Person> = {
+ '@context': 'https://schema.org',
+ '@type': 'Person',
+ '@id': `${url}/#branding`,
+ name: name,
+ url: url,
+ jobTitle: baseline,
+ image: picture.src,
+ subjectOf: { '@id': `${url}` },
+ };
+
+ const [backToTopClassName, setBackToTopClassName] = useState<string>(
+ styles['back-to-top--hidden']
+ );
+ const updateBackToTopClassName = () => {
+ setBackToTopClassName(
+ window.scrollY > 300
+ ? styles['back-to-top--visible']
+ : styles['back-to-top--hidden']
+ );
+ };
+
+ useScrollPosition(updateBackToTopClassName);
+
+ return (
+ <>
+ <Script
+ id="schema-layout"
+ type="application/ld+json"
+ dangerouslySetInnerHTML={{ __html: JSON.stringify(schemaJsonLd) }}
+ />
+ <Script
+ id="schema-branding"
+ type="application/ld+json"
+ dangerouslySetInnerHTML={{ __html: JSON.stringify(brandingSchema) }}
+ />
+ <noscript>
+ <div className={styles['noscript-spacing']}></div>
+ </noscript>
+ <span tabIndex={-1}></span>
+ <ButtonLink target="#main" className="screen-reader-text">
+ {skipToContent}
+ </ButtonLink>
+ <Header
+ ackeeStorageKey="ackee-tracking"
+ baseline={baseline}
+ className={styles.header}
+ isHome={isHome}
+ motionStorageKey="reduced-motion"
+ nav={mainNav}
+ photo={picture}
+ searchPage="/recherche"
+ title={name}
+ withLink={true}
+ />
+ <Main id="main" className={styles.main}>
+ <article
+ className={`${styles[articleGridClass]} ${styles[articleCommentsClass]}`}
+ >
+ {children}
+ </article>
+ </Main>
+ <Footer
+ copyright={copyrightData}
+ navItems={footerNav}
+ topId="top"
+ backToTopClassName={backToTopClassName}
+ className={styles.footer}
+ />
+ <noscript>
+ <NoScript message={noScript} position="top" />
+ </noscript>
+ </>
+ );
+};
+
+/**
+ * Get the global layout.
+ *
+ * @param {ReactElement} page - A page.
+ * @param {boolean} [isHome] - Determine if it is the homepage.
+ * @returns A page wrapped with the global layout.
+ */
+export const getLayout = (
+ page: ReactElement,
+ props: NextPageWithLayoutOptions
+) => {
+ return <Layout {...props}>{page}</Layout>;
+};
+
+export default Layout;