From d4045fbcbfa8208ec31539744417f315f1f6fad8 Mon Sep 17 00:00:00 2001 From: Armand Philippot Date: Tue, 21 Nov 2023 19:01:18 +0100 Subject: refactor(components): split Layout component in smaller components The previous component was too long and hardly readable. So I splitted it in different part and added tests. --- .../forms/search-form/search-form.module.scss | 6 +- .../organisms/forms/search-form/search-form.tsx | 119 +++--- src/components/templates/layout/layout.module.scss | 133 ------- src/components/templates/layout/layout.stories.tsx | 2 +- src/components/templates/layout/layout.test.tsx | 11 +- src/components/templates/layout/layout.tsx | 412 ++------------------- .../templates/layout/site-footer/index.ts | 1 + .../layout/site-footer/site-footer.module.scss | 42 +++ .../layout/site-footer/site-footer.test.tsx | 20 + .../templates/layout/site-footer/site-footer.tsx | 93 +++++ .../templates/layout/site-header/index.ts | 2 + .../layout/site-header/site-branding.test.tsx | 23 ++ .../templates/layout/site-header/site-branding.tsx | 91 +++++ .../layout/site-header/site-header.module.scss | 90 +++++ .../layout/site-header/site-header.test.tsx | 11 + .../templates/layout/site-header/site-header.tsx | 25 ++ .../layout/site-header/site-navbar.test.tsx | 73 ++++ .../templates/layout/site-header/site-navbar.tsx | 210 +++++++++++ src/i18n/en.json | 126 +++---- src/i18n/fr.json | 136 +++---- src/styles/base/_base.scss | 53 +++ src/styles/base/_spacings.scss | 1 - src/styles/base/_typography.scss | 53 --- src/styles/globals.scss | 1 - src/utils/constants.ts | 1 + 25 files changed, 978 insertions(+), 757 deletions(-) create mode 100644 src/components/templates/layout/site-footer/index.ts create mode 100644 src/components/templates/layout/site-footer/site-footer.module.scss create mode 100644 src/components/templates/layout/site-footer/site-footer.test.tsx create mode 100644 src/components/templates/layout/site-footer/site-footer.tsx create mode 100644 src/components/templates/layout/site-header/index.ts create mode 100644 src/components/templates/layout/site-header/site-branding.test.tsx create mode 100644 src/components/templates/layout/site-header/site-branding.tsx create mode 100644 src/components/templates/layout/site-header/site-header.module.scss create mode 100644 src/components/templates/layout/site-header/site-header.test.tsx create mode 100644 src/components/templates/layout/site-header/site-header.tsx create mode 100644 src/components/templates/layout/site-header/site-navbar.test.tsx create mode 100644 src/components/templates/layout/site-header/site-navbar.tsx delete mode 100644 src/styles/base/_typography.scss diff --git a/src/components/organisms/forms/search-form/search-form.module.scss b/src/components/organisms/forms/search-form/search-form.module.scss index db247a2..3edaef6 100644 --- a/src/components/organisms/forms/search-form/search-form.module.scss +++ b/src/components/organisms/forms/search-form/search-form.module.scss @@ -37,7 +37,7 @@ } } -.wrapper { +.form { display: flex; &--no-label { @@ -76,3 +76,7 @@ } } } + +.notice { + margin-top: var(--spacing-sm); +} diff --git a/src/components/organisms/forms/search-form/search-form.tsx b/src/components/organisms/forms/search-form/search-form.tsx index 3d0efa2..a803d8c 100644 --- a/src/components/organisms/forms/search-form/search-form.tsx +++ b/src/components/organisms/forms/search-form/search-form.tsx @@ -4,17 +4,11 @@ import { useId, useImperativeHandle, useRef, + type HTMLAttributes, } from 'react'; import { useIntl } from 'react-intl'; import { type FormSubmitHandler, useForm } from '../../../../utils/hooks'; -import { - Button, - Form, - type FormProps, - Icon, - Input, - Label, -} from '../../../atoms'; +import { Button, Form, Icon, Input, Label, Notice } from '../../../atoms'; import { LabelledField } from '../../../molecules'; import styles from './search-form.module.scss'; @@ -22,7 +16,10 @@ export type SearchFormData = { query: string }; export type SearchFormSubmit = FormSubmitHandler; -export type SearchFormProps = Omit & { +export type SearchFormProps = Omit< + HTMLAttributes, + 'children' | 'onSubmit' +> & { /** * Should the label be visually hidden? * @@ -45,19 +42,18 @@ export type SearchFormRef = { const SearchFormWithRef: ForwardRefRenderFunction< SearchFormRef, SearchFormProps -> = ({ className = '', isLabelHidden = false, onSubmit, ...props }, ref) => { +> = ({ isLabelHidden = false, onSubmit, ...props }, ref) => { const intl = useIntl(); - const { values, submit, submitStatus, update } = useForm({ - initialValues: { query: '' }, - submitHandler: onSubmit, - }); + const { messages, submit, submitStatus, update, values } = + useForm({ + initialValues: { query: '' }, + submitHandler: onSubmit, + }); const id = useId(); const inputRef = useRef(null); - const formClass = [ - styles.wrapper, - styles[isLabelHidden ? 'wrapper--no-label' : 'wrapper--has-label'], - className, - ].join(' '); + const formClass = `${styles.form} ${ + styles[isLabelHidden ? 'form--no-label' : 'form--has-label'] + }`; const labels = { button: intl.formatMessage({ defaultMessage: 'Search', @@ -84,48 +80,55 @@ const SearchFormWithRef: ForwardRefRenderFunction< ); return ( -
- + + + } + label={ + + } + /> + - + + + {messages?.error && submitStatus === 'FAILED' ? ( + + {messages.error} + + ) : null} + ); }; diff --git a/src/components/templates/layout/layout.module.scss b/src/components/templates/layout/layout.module.scss index 69c4ef0..cf2a10f 100644 --- a/src/components/templates/layout/layout.module.scss +++ b/src/components/templates/layout/layout.module.scss @@ -2,132 +2,10 @@ @use "../../../styles/abstracts/mixins" as mix; @use "../../../styles/abstracts/placeholders"; -%typing-animation { - --typing-animation: none; - - width: fit-content; - position: relative; - overflow: hidden; - - &::after { - content: "|"; - display: block; - width: 100%; - height: 100%; - position: absolute; - top: 0; - right: 0; - background: var(--color-bg); - color: var(--color-primary-darker); - font-weight: 400; - text-align: left; - visibility: hidden; - transform: translateX(100%); - transform-origin: right; - animation: var(--typing-animation); - - :global { - animation: var(--typing-animation); - } - } -} - -.header { - display: grid; - grid-template-columns: - minmax(0, 1fr) min(calc(100vw - calc(var(--spacing-md) * 2)), 100ch) - minmax(0, 1fr); - align-items: center; - padding: var(--spacing-md) 0 var(--spacing-lg); - border-bottom: fun.convert-px(3) solid var(--color-border-light); - - &__body { - grid-column: 2; - display: flex; - flex-flow: row wrap; - align-items: center; - justify-content: space-between; - gap: var(--spacing-md); - } -} - -.brand { - &__logo { - --logo-size: #{clamp( - fun.convert-px(95), - calc(120px - 5vw), - fun.convert-px(120) - )}; - - animation: flip-logo 9s ease-in 0s 1; - } - - &__title { - font-size: var(--font-size-2xl); - - @extend %typing-animation; - } - - &__baseline { - color: var(--color-fg-light); - font-size: var(--font-size-lg); - font-weight: 600; - - @extend %typing-animation; - } -} - -.search, -.settings { - @include mix.media("screen") { - @include mix.dimensions("sm") { - min-width: 30ch; - } - } -} - .main { flex: 1; } -.footer { - display: flex; - flex-flow: column wrap; - gap: var(--spacing-xs); - place-items: center; - place-content: center; - padding: var(--spacing-md) 0 calc(var(--toolbar-size) + var(--spacing-md)); - border-top: fun.convert-px(3) solid var(--color-border-light); - - @include mix.media("screen") { - @include mix.dimensions("sm") { - --toolbar-size: 0px; - - flex-flow: row wrap; - font-size: var(--font-size-sm); - } - } -} - -.back-to-top { - position: fixed; - bottom: calc(var(--toolbar-size, 0px) + var(--spacing-md)); - right: var(--spacing-md); - transition: all 0.4s ease-in 0s; - - &--hidden { - opacity: 0; - transform: translateY(calc(var(--button-height) + var(--spacing-md))); - visibility: hidden; - } - - &--visible { - opacity: 1; - transform: translateY(0); - visibility: visible; - } -} - .noscript { padding: var(--spacing-xs) var(--spacing-sm); position: fixed; @@ -153,14 +31,3 @@ } } } - -@keyframes flip-logo { - 0%, - 90% { - transform: rotateY(180deg); - } - - 100% { - transform: rotateY(0deg); - } -} diff --git a/src/components/templates/layout/layout.stories.tsx b/src/components/templates/layout/layout.stories.tsx index 67ad008..6d55f34 100644 --- a/src/components/templates/layout/layout.stories.tsx +++ b/src/components/templates/layout/layout.stories.tsx @@ -1,4 +1,4 @@ -import { ComponentMeta, ComponentStory } from '@storybook/react'; +import type { ComponentMeta, ComponentStory } from '@storybook/react'; import { Layout as LayoutComponent } from './layout'; /** diff --git a/src/components/templates/layout/layout.test.tsx b/src/components/templates/layout/layout.test.tsx index d3abe1d..43b94f4 100644 --- a/src/components/templates/layout/layout.test.tsx +++ b/src/components/templates/layout/layout.test.tsx @@ -1,6 +1,6 @@ import { describe, expect, it } from '@jest/globals'; import { render, screen as rtlScreen } from '../../../../tests/utils'; -import { Layout } from './layout'; +import { Layout, getLayout } 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.'; @@ -33,3 +33,12 @@ describe('Layout', () => { expect(rtlScreen.getByText(body)).toBeInTheDocument(); }); }); + +describe('getLayout', () => { + it('wraps the given contents in a layout component', () => { + const PageCont 44 --- a/src/components/templates/layout/layout.tsx +++ b/src/components/templates/layout/layout.tsx @@ -1,67 +1,24 @@ -/* eslint-disable max-statements */ -import NextImage from 'next/image'; -import { useRouter } from 'next/router'; import Script from 'next/script'; -import { - type FC, - type ReactElement, - type ReactNode, - useRef, - type CSSProperties, - type FormEvent, - useCallback, -} from 'react'; +import type { FC, ReactElement, ReactNode } from 'react'; import { useIntl } from 'react-intl'; import type { Person, SearchAction, WebSite, WithContext } from 'schema-dts'; import type { NextPageWithLayoutOptions } from '../../../types'; import { CONFIG } from '../../../utils/config'; import { ROUTES } from '../../../utils/constants'; -import { useOnRouteChange, useScrollPosition } from '../../../utils/hooks'; -import { - ButtonLink, - Footer, - Header, - Heading, - Icon, - Logo, - Main, -} from '../../atoms'; -import { - BackToTop, - Branding, - Colophon, - type ColophonLink, - Copyright, - FlippingLogo, -} from '../../molecules'; -import { - type MainNavItem, - Navbar, - MainNav, - SearchForm, - SettingsForm, - type SearchFormSubmit, - NavbarItem, - type SearchFormRef, - type NavbarItemActivationHandler, -} from '../../organisms'; +import { ButtonLink, Main } from '../../atoms'; import styles from './layout.module.scss'; +import { SiteFooter } from './site-footer'; +import { SiteHeader, type SiteHeaderProps } from './site-header'; export type QueryAction = SearchAction & { 'query-input': string; }; -export type LayoutProps = { +export type LayoutProps = Pick & { /** * The layout main content. */ children: ReactNode; - /** - * Is it homepage? - * - * @default false - */ - isHome?: boolean; }; /** @@ -70,187 +27,22 @@ export type LayoutProps = { * Render the base layout used by all pages. */ export const Layout: FC = ({ children, isHome }) => { - const router = useRouter(); - const intl = useIntl(); const { baseline, copyright, locales, name, url } = CONFIG; - - 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 copyrightTitle = intl.formatMessage({ - defaultMessage: 'CC BY SA', - description: 'Layout: copyright title', - id: 'yB1SPF', - }); - - 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 photoAltText = intl.formatMessage( - { - defaultMessage: '{website} picture', - description: 'Layout: photo alternative text', - id: '8jjY1X', - }, - { website: name } - ); - const logoTitle = intl.formatMessage( - { - defaultMessage: '{website} logo', - description: 'Layout: logo title', - id: '52H2HA', - }, - { website: name } - ); - const backToTop = intl.formatMessage({ - defaultMessage: 'Back to top', - description: 'Layout: an accessible name for the back to top button', - id: 'Kjj1Zk', - }); - - const mainNav: MainNavItem[] = [ - { - id: 'home', - label: homeLabel, - href: '/', - logo: , - }, - { - id: 'blog', - label: blogLabel, - href: ROUTES.BLOG, - logo: , - }, - { - id: 'projects', - label: projectsLabel, - href: ROUTES.PROJECTS, - logo: , - }, - { - id: 'cv', - label: cvLabel, - href: ROUTES.CV, - logo: , - }, - { - id: 'contact', - label: contactLabel, - href: ROUTES.CONTACT, - logo: , - }, - ]; - - const labels = { - mainNavItem: intl.formatMessage({ - defaultMessage: 'Open menu', - description: 'Layout: main nav button label in navbar', - id: 'Fgt/RZ', - }), - mainNavModal: intl.formatMessage({ - defaultMessage: 'Main navigation', - description: 'Layout: main nav accessible name', - id: 'dfTljv', - }), - searchItem: intl.formatMessage({ - defaultMessage: 'Open search', - id: 'XRwEoA', - description: 'Layout: search button label in navbar', - }), - searchModal: intl.formatMessage({ - defaultMessage: 'Search', - description: 'Layout: search modal title in navbar', - id: 'Mq+O6q', - }), - settingsItem: intl.formatMessage({ - defaultMessage: 'Open settings', - id: 'mDKiaN', - description: 'Layout: settings button label in navbar', - }), - settingsForm: intl.formatMessage({ - defaultMessage: 'Settings form', - id: 'h3J0a+', - description: 'Layout: an accessible name for the settings form in navbar', + const intl = useIntl(); + const messages = { + 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', }), - settingsModal: intl.formatMessage({ - defaultMessage: 'Settings', - description: 'Layout: settings modal title in navbar', - id: 'o3WSz5', + skipToContent: intl.formatMessage({ + defaultMessage: 'Skip to content', + description: 'Layout: Skip to content link', + id: 'K4rYdT', }), }; - const settingsSubmitHandler = useCallback((e: FormEvent) => { - e.preventDefault(); - }, []); - - const searchFormRef = useRef(null); - const giveFocusToSearchInput: NavbarItemActivationHandler = useCallback( - (isActive) => { - if (isActive) searchFormRef.current?.focus(); - }, - [] - ); - const searchSubmitHandler: SearchFormSubmit = useCallback( - ({ query }) => { - if (!query) - return { - messages: { - error: intl.formatMessage({ - defaultMessage: 'Query must be longer than one character.', - description: 'Layout: invalid query message', - id: 'C2YcUJ', - }), - }, - validator: (value) => value.query.length > 1, - }; - - router.push({ pathname: ROUTES.SEARCH, query: { s: query } }); - - return undefined; - }, - [intl, router] - ); - - const legalNoticeLabel = intl.formatMessage({ - defaultMessage: 'Legal notice', - description: 'Layout: Legal notice label', - id: 'nwbzKm', - }); - - const footerNav: ColophonLink[] = [ - { id: 'legal-notice', label: legalNoticeLabel, href: ROUTES.LEGAL_NOTICE }, - ]; - const searchActionSchema: QueryAction = { '@type': 'SearchAction', target: { @@ -260,7 +52,14 @@ export const Layout: FC = ({ children, isHome }) => { query: 'required', 'query-input': 'required name=search_term_string', }; - + const brandingSchema: Person = { + '@type': 'Person', + name, + url, + jobTitle: baseline, + image: '/armand-philippot.jpg', + subjectOf: { '@id': `${url}` }, + }; const schemaJsonLd: WithContext = { '@context': 'https://schema.org', '@id': `${url}`, @@ -268,51 +67,16 @@ export const Layout: FC = ({ children, isHome }) => { name, description: baseline, url, - author: { '@id': `${url}/#branding` }, + author: brandingSchema, copyrightYear: Number(copyright.startYear), - creator: { '@id': `${url}/#branding` }, - editor: { '@id': `${url}/#branding` }, + creator: brandingSchema, + editor: brandingSchema, inLanguage: locales.defaultLocale, potentialAction: searchActionSchema, }; - const brandingSchema: WithContext = { - '@context': 'https://schema.org', - '@type': 'Person', - '@id': `${url}/#branding`, - name, - url, - jobTitle: baseline, - image: '/armand-philippot.jpg', - subjectOf: { '@id': `${url}` }, - }; - - const scrollPos = useScrollPosition(); - const backToTopBreakpoint = 300; - const backToTopClassName = [ - styles['back-to-top'], - styles[ - scrollPos.y > backToTopBreakpoint - ? 'back-to-top--visible' - : 'back-to-top--hidden' - ], - ].join(' '); - - const topRef = useRef(null); - const giveFocusToTopRef = () => { - if (topRef.current) topRef.current.focus(); - }; - - useOnRouteChange(giveFocusToTopRef); - - const brandingTitleStyles = { - '--typing-animation': - 'blink 0.7s ease-in-out 0s 2, typing 4.3s linear 0s 1', - } as CSSProperties; - const brandingBaselineStyles = { - '--typing-animation': - 'hide-text 4.25s linear 0s 1, blink 0.8s ease-in-out 4.25s 2, typing 3.8s linear 4.25s 1', - } as CSSProperties; + const topId = 'top'; + const mainId = 'main'; return ( <> @@ -322,119 +86,25 @@ export const Layout: FC = ({ children, isHome }) => { id="schema-layout" type="application/ld+json" /> -