diff options
Diffstat (limited to 'src/components/templates/layout')
| -rw-r--r-- | src/components/templates/layout/layout.module.scss | 53 | ||||
| -rw-r--r-- | src/components/templates/layout/layout.stories.tsx | 117 | ||||
| -rw-r--r-- | src/components/templates/layout/layout.test.tsx | 35 | ||||
| -rw-r--r-- | src/components/templates/layout/layout.tsx | 242 | 
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; | 
