diff options
| author | Armand Philippot <git@armandphilippot.com> | 2022-05-24 19:35:12 +0200 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2022-05-24 19:35:12 +0200 |
| commit | c85ab5ad43ccf52881ee224672c41ec30021cf48 (patch) | |
| tree | 8058808d9bfca19383f120c46b34d99ff2f89f63 /src/components/templates/layout | |
| parent | 52404177c07a2aab7fc894362fb3060dff2431a0 (diff) | |
| parent | 11b9de44a4b2f305a6a484187805e429b2767118 (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.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; |
