diff options
25 files changed, 978 insertions, 757 deletions
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<SearchFormData>; -export type SearchFormProps = Omit<FormProps, 'children' | 'onSubmit'> & { +export type SearchFormProps = Omit< + HTMLAttributes<HTMLDivElement>, + '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<SearchFormData>({ - initialValues: { query: '' }, - submitHandler: onSubmit, - }); + const { messages, submit, submitStatus, update, values } = + useForm<SearchFormData>({ + initialValues: { query: '' }, + submitHandler: onSubmit, + }); const id = useId(); const inputRef = useRef<HTMLInputElement>(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 ( - <Form {...props} className={formClass} onSubmit={submit}> - <LabelledField - className={styles.field} - field={ - <Input - className={styles.input} - id={id} + <div {...props}> + <Form className={formClass} onSubmit={submit}> + <LabelledField + className={styles.field} + field={ + <Input + className={styles.input} + id={id} + // eslint-disable-next-line react/jsx-no-literals + name="query" + onChange={update} + ref={inputRef} + // eslint-disable-next-line react/jsx-no-literals + type="search" + value={values.query} + /> + } + label={ + <Label htmlFor={id} isHidden={isLabelHidden}> + {labels.field} + </Label> + } + /> + <Button + aria-label={labels.button} + className={styles.btn} + isLoading={submitStatus === 'PENDING'} + // eslint-disable-next-line react/jsx-no-literals + kind="neutral" + // eslint-disable-next-line react/jsx-no-literals + shape="initial" + type="submit" + > + <Icon + aria-hidden + className={styles.icon} // eslint-disable-next-line react/jsx-no-literals - name="query" - onChange={update} - ref={inputRef} + shape="magnifying-glass" // eslint-disable-next-line react/jsx-no-literals - type="search" - value={values.query} + size="lg" /> - } - label={ - <Label htmlFor={id} isHidden={isLabelHidden}> - {labels.field} - </Label> - } - /> - <Button - aria-label={labels.button} - className={styles.btn} - isLoading={submitStatus === 'PENDING'} - // eslint-disable-next-line react/jsx-no-literals - kind="neutral" - // eslint-disable-next-line react/jsx-no-literals - shape="initial" - type="submit" - > - <Icon - aria-hidden - className={styles.icon} - // eslint-disable-next-line react/jsx-no-literals - shape="magnifying-glass" - // eslint-disable-next-line react/jsx-no-literals - size="lg" - /> - </Button> - </Form> + </Button> + </Form> + {messages?.error && submitStatus === 'FAILED' ? ( + <Notice className={styles.notice} kind="error"> + {messages.error} + </Notice> + ) : null} + </div> ); }; 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 PageContents = <div>{body}</div>; + const Page = getLayout(PageContents); + + expect(Page.props).toStrictEqual({ children: PageContents }); + }); +}); diff --git a/src/components/templates/layout/layout.tsx b/src/components/templates/layout/layout.tsx index ce7f1fa..4dfe5f3 100644 --- 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<SiteHeaderProps, 'isHome'> & { /** * 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<LayoutProps> = ({ 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: <Icon aria-hidden={true} shape="home" />, - }, - { - id: 'blog', - label: blogLabel, - href: ROUTES.BLOG, - logo: <Icon aria-hidden={true} shape="posts-stack" />, - }, - { - id: 'projects', - label: projectsLabel, - href: ROUTES.PROJECTS, - logo: <Icon aria-hidden={true} shape="computer" />, - }, - { - id: 'cv', - label: cvLabel, - href: ROUTES.CV, - logo: <Icon aria-hidden={true} shape="career" />, - }, - { - id: 'contact', - label: contactLabel, - href: ROUTES.CONTACT, - logo: <Icon aria-hidden={true} shape="envelop" />, - }, - ]; - - 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<SearchFormRef>(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<LayoutProps> = ({ 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<WebSite> = { '@context': 'https://schema.org', '@id': `${url}`, @@ -268,51 +67,16 @@ export const Layout: FC<LayoutProps> = ({ 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<Person> = { - '@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<HTMLSpanElement>(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<LayoutProps> = ({ children, isHome }) => { id="schema-layout" type="application/ld+json" /> - <Script - dangerouslySetInnerHTML={{ __html: JSON.stringify(brandingSchema) }} - // eslint-disable-next-line react/jsx-no-literals -- Id allowed - id="schema-branding" - type="application/ld+json" - /> + <span id={topId} /> <noscript> <div className={styles['noscript-spacing']} /> </noscript> - <span ref={topRef} tabIndex={-1} /> - <ButtonLink className="screen-reader-text" to="#main"> - {skipToContent} + <ButtonLink + // eslint-disable-next-line react/jsx-no-literals + className="screen-reader-text" + // eslint-disable-next-line react/jsx-no-literals + to={`#${mainId}`} + > + {messages.skipToContent} </ButtonLink> - <Header className={styles.header}> - <div className={styles.header__body}> - <Branding - baseline={ - <div - className={styles.brand__baseline} - style={brandingBaselineStyles} - > - {baseline} - </div> - } - logo={ - <FlippingLogo - back={<Logo heading={logoTitle} />} - className={styles.brand__logo} - front={ - <NextImage - alt={photoAltText} - height={120} - src="/armand-philippot.jpg" - width={120} - /> - } - /> - } - name={ - <Heading - className={styles.brand__title} - isFake={!isHome} - level={1} - style={brandingTitleStyles} - > - {name} - </Heading> - } - url="/" - /> - <Navbar> - <NavbarItem - icon="hamburger" - id="main-nav" - label={labels.mainNavItem} - modalVisibleFrom="md" - > - <MainNav aria-label={labels.mainNavModal} items={mainNav} /> - </NavbarItem> - <NavbarItem - activationHandlerDelay={350} - icon="magnifying-glass" - id="search" - label={labels.searchItem} - modalHeading={labels.searchModal} - onActivation={giveFocusToSearchInput} - > - <SearchForm - className={styles.search} - isLabelHidden - onSubmit={searchSubmitHandler} - ref={searchFormRef} - /> - </NavbarItem> - <NavbarItem - icon="cog" - id="settings" - label={labels.settingsItem} - modalHeading={labels.settingsModal} - showIconOnModal - > - <SettingsForm - aria-label={labels.settingsForm} - className={styles.settings} - onSubmit={settingsSubmitHandler} - /> - </NavbarItem> - </Navbar> - </div> - </Header> - <Main id="main" className={styles.main}> + <SiteHeader className={styles.header} isHome={isHome} /> + <Main className={styles.main} id={mainId}> {children} </Main> - <Footer className={styles.footer}> - <Colophon - copyright={ - <Copyright - from={copyright.startYear} - owner={name} - to={copyright.endYear} - /> - } - license={<Icon heading={copyrightTitle} shape="cc-by-sa" size="lg" />} - links={footerNav} - /> - <BackToTop - anchor="#top" - className={backToTopClassName} - label={backToTop} - /> - </Footer> + <SiteFooter topId={topId} /> <noscript> - <div className={styles.noscript}>{noScript}</div> + <div className={styles.noscript}>{messages.noScript}</div> </noscript> </> ); diff --git a/src/components/templates/layout/site-footer/index.ts b/src/components/templates/layout/site-footer/index.ts new file mode 100644 index 0000000..cef0a6f --- /dev/null +++ b/src/components/templates/layout/site-footer/index.ts @@ -0,0 +1 @@ +export * from './site-footer'; diff --git a/src/components/templates/layout/site-footer/site-footer.module.scss b/src/components/templates/layout/site-footer/site-footer.module.scss new file mode 100644 index 0000000..935c163 --- /dev/null +++ b/src/components/templates/layout/site-footer/site-footer.module.scss @@ -0,0 +1,42 @@ +@use "../../../../styles/abstracts/functions" as fun; +@use "../../../../styles/abstracts/mixins" as mix; + +.footer { + --navbar-size: #{fun.convert-px(80)}; + + display: flex; + flex-flow: column wrap; + gap: var(--spacing-xs); + place-items: center; + place-content: center; + padding: var(--spacing-md) 0 calc(var(--navbar-size) + var(--spacing-md)); + border-top: fun.convert-px(3) solid var(--color-border-light); + + @include mix.media("screen") { + @include mix.dimensions("sm") { + --navbar-size: 0px; + + flex-flow: row wrap; + font-size: var(--font-size-sm); + } + } +} + +.back-to-top { + position: fixed; + bottom: calc(var(--navbar-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; + } +} diff --git a/src/components/templates/layout/site-footer/site-footer.test.tsx b/src/components/templates/layout/site-footer/site-footer.test.tsx new file mode 100644 index 0000000..fa60b8f --- /dev/null +++ b/src/components/templates/layout/site-footer/site-footer.test.tsx @@ -0,0 +1,20 @@ +import { describe, expect, it } from '@jest/globals'; +import { render, screen as rtlScreen } from '../../../../../tests/utils'; +import { CONFIG } from '../../../../utils/config'; +import { ROUTES } from '../../../../utils/constants'; +import { SiteFooter } from './site-footer'; + +describe('SiteFooter', () => { + it('renders the website colophon', () => { + render(<SiteFooter />); + + expect(rtlScreen.getByRole('contentinfo')).toBeInTheDocument(); + expect(rtlScreen.getByText(CONFIG.copyright.startYear)).toBeInTheDocument(); + expect(rtlScreen.getByText(CONFIG.copyright.endYear)).toBeInTheDocument(); + expect(rtlScreen.getByText(new RegExp(CONFIG.name))).toBeInTheDocument(); + expect(rtlScreen.getByTitle('CC BY SA')).toBeInTheDocument(); + expect( + rtlScreen.getByRole('link', { name: 'Legal notice' }) + ).toHaveAttribute('href', ROUTES.LEGAL_NOTICE); + }); +}); diff --git a/src/components/templates/layout/site-footer/site-footer.tsx b/src/components/templates/layout/site-footer/site-footer.tsx new file mode 100644 index 0000000..b852b32 --- /dev/null +++ b/src/components/templates/layout/site-footer/site-footer.tsx @@ -0,0 +1,93 @@ +import { type ForwardRefRenderFunction, forwardRef } from 'react'; +import { useIntl } from 'react-intl'; +import { CONFIG } from '../../../../utils/config'; +import { ROUTES } from '../../../../utils/constants'; +import { useScrollPosition } from '../../../../utils/hooks'; +import { Footer, type FooterProps, Icon } from '../../../atoms'; +import { + BackToTop, + Colophon, + type ColophonLink, + Copyright, +} from '../../../molecules'; +import styles from './site-footer.module.scss'; + +export type SiteFooterProps = Omit<FooterProps, 'children'> & { + /** + * An id that will be use as anchor for the back to top button. + */ + topId?: string; +}; + +const SiteFooterWithRef: ForwardRefRenderFunction< + HTMLElement, + SiteFooterProps +> = ({ className = '', topId, ...props }, ref) => { + const footerClass = `${styles.footer} ${className}`; + const intl = useIntl(); + const licenseName = intl.formatMessage({ + defaultMessage: 'CC BY SA', + description: 'SiteFooter: the license name', + id: 'iTLvLX', + }); + const backToTop = intl.formatMessage({ + defaultMessage: 'Back to top', + description: 'SiteFooter: an accessible name for the back to top button', + id: 'OHvb01', + }); + const footerNav: ColophonLink[] = [ + { + id: 'legal-notice', + label: intl.formatMessage({ + defaultMessage: 'Legal notice', + description: 'SiteFooter: Legal notice link label', + id: 'lsmD4c', + }), + href: ROUTES.LEGAL_NOTICE, + }, + ]; + const scrollPos = useScrollPosition(); + const backToTopVisibilityBreakpoint = 300; + const backToTopClassName = [ + styles['back-to-top'], + styles[ + scrollPos.y > backToTopVisibilityBreakpoint + ? 'back-to-top--visible' + : 'back-to-top--hidden' + ], + ].join(' '); + const backToTopAnchor = topId ? `#${topId}` : undefined; + + return ( + <Footer {...props} className={footerClass} ref={ref}> + <Colophon + copyright={ + <Copyright + from={CONFIG.copyright.startYear} + owner={CONFIG.name} + to={CONFIG.copyright.endYear} + /> + } + license={ + <Icon + heading={licenseName} + // eslint-disable-next-line react/jsx-no-literals + shape="cc-by-sa" + // eslint-disable-next-line react/jsx-no-literals + size="lg" + /> + } + links={footerNav} + /> + {backToTopAnchor ? ( + <BackToTop + anchor={backToTopAnchor} + className={backToTopClassName} + label={backToTop} + /> + ) : null} + </Footer> + ); +}; + +export const SiteFooter = forwardRef(SiteFooterWithRef); diff --git a/src/components/templates/layout/site-header/index.ts b/src/components/templates/layout/site-header/index.ts new file mode 100644 index 0000000..7172be8 --- /dev/null +++ b/src/components/templates/layout/site-header/index.ts @@ -0,0 +1,2 @@ +export * from './site-header'; +export * from './site-navbar'; diff --git a/src/components/templates/layout/site-header/site-branding.test.tsx b/src/components/templates/layout/site-header/site-branding.test.tsx new file mode 100644 index 0000000..db454e3 --- /dev/null +++ b/src/components/templates/layout/site-header/site-branding.test.tsx @@ -0,0 +1,23 @@ +import { describe, expect, it } from '@jest/globals'; +import { render, screen as rtlScreen } from '../../../../../tests/utils'; +import { SiteBranding } from './site-branding'; +import { CONFIG } from 'src/utils/config'; +import { ROUTES } from 'src/utils/constants'; + +describe('SiteBranding', () => { + it('renders the website logo, name and baseline', () => { + render(<SiteBranding />); + + expect( + rtlScreen.getByRole('img', { name: `${CONFIG.name} picture` }) + ).toBeInTheDocument(); + expect( + rtlScreen.getByRole('img', { name: `${CONFIG.name} logo` }) + ).toBeInTheDocument(); + expect(rtlScreen.getByRole('link', { name: CONFIG.name })).toHaveAttribute( + 'href', + ROUTES.HOME + ); + expect(rtlScreen.getByText(CONFIG.baseline)).toBeInTheDocument(); + }); +}); diff --git a/src/components/templates/layout/site-header/site-branding.tsx b/src/components/templates/layout/site-header/site-branding.tsx new file mode 100644 index 0000000..f5a845d --- /dev/null +++ b/src/components/templates/layout/site-header/site-branding.tsx @@ -0,0 +1,91 @@ +import NextImage from 'next/image'; +import { + type CSSProperties, + forwardRef, + type ForwardRefRenderFunction, +} from 'react'; +import { useIntl } from 'react-intl'; +import { CONFIG } from '../../../../utils/config'; +import { ROUTES } from '../../../../utils/constants'; +import { Heading, Logo } from '../../../atoms'; +import { Branding, FlippingLogo, type BrandingProps } from '../../../molecules'; +import styles from './site-header.module.scss'; + +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; + +export type SiteBrandingProps = Omit< + BrandingProps, + 'baseline' | 'logo' | 'name' | 'url' +> & { + isHome?: boolean; +}; + +const SiteBrandingWithRef: ForwardRefRenderFunction< + HTMLDivElement, + SiteBrandingProps +> = ({ isHome = false, ...props }, ref) => { + const intl = useIntl(); + const photoAltText = intl.formatMessage( + { + defaultMessage: '{website} picture', + description: 'SiteBranding: photo alternative text', + id: 'dDwm38', + }, + { website: CONFIG.name } + ); + const logoTitle = intl.formatMessage( + { + defaultMessage: '{website} logo', + description: 'SiteBranding: logo title', + id: 'Vrw5/h', + }, + { website: CONFIG.name } + ); + + return ( + <Branding + {...props} + baseline={ + <div className={styles.baseline} style={brandingBaselineStyles}> + {CONFIG.baseline} + </div> + } + logo={ + <FlippingLogo + back={<Logo heading={logoTitle} />} + className={styles.logo} + front={ + <NextImage + alt={photoAltText} + height={120} + // eslint-disable-next-line react/jsx-no-literals + src="/armand-philippot.jpg" + width={120} + /> + } + /> + } + name={ + <Heading + className={styles.title} + isFake={!isHome} + level={1} + style={brandingTitleStyles} + > + {CONFIG.name} + </Heading> + } + ref={ref} + url={ROUTES.HOME} + /> + ); +}; + +export const SiteBranding = forwardRef(SiteBrandingWithRef); diff --git a/src/components/templates/layout/site-header/site-header.module.scss b/src/components/templates/layout/site-header/site-header.module.scss new file mode 100644 index 0000000..a48c054 --- /dev/null +++ b/src/components/templates/layout/site-header/site-header.module.scss @@ -0,0 +1,90 @@ +@use "../../../../styles/abstracts/functions" as fun; +@use "../../../../styles/abstracts/mixins" as mix; + +%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: flex; + flex-flow: row wrap; + gap: var(--spacing-md) var(--spacing-xl); + align-items: center; + padding: clamp(var(--spacing-md), 3vh, var(--spacing-xl)) 0; + border-bottom: fun.convert-px(3) solid var(--color-border-light); +} + +.branding, +.navbar { + margin-inline: auto; +} + +.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; + } + } +} + +@keyframes flip-logo { + 0%, + 90% { + transform: rotateY(180deg); + } + + 100% { + transform: rotateY(0deg); + } +} diff --git a/src/components/templates/layout/site-header/site-header.test.tsx b/src/components/templates/layout/site-header/site-header.test.tsx new file mode 100644 index 0000000..55ce072 --- /dev/null +++ b/src/components/templates/layout/site-header/site-header.test.tsx @@ -0,0 +1,11 @@ +import { describe, expect, it } from '@jest/globals'; +import { render, screen as rtlScreen } from '../../../../../tests/utils'; +import { SiteHeader } from './site-header'; + +describe('SiteHeader', () => { + it('renders the website header', () => { + render(<SiteHeader />); + + expect(rtlScreen.getByRole('banner')).toBeInTheDocument(); + }); +}); diff --git a/src/components/templates/layout/site-header/site-header.tsx b/src/components/templates/layout/site-header/site-header.tsx new file mode 100644 index 0000000..3e06350 --- /dev/null +++ b/src/components/templates/layout/site-header/site-header.tsx @@ -0,0 +1,25 @@ +import { type ForwardRefRenderFunction, forwardRef } from 'react'; +import { Header, type HeaderProps } from '../../../atoms'; +import { SiteBranding } from './site-branding'; +import styles from './site-header.module.scss'; +import { SiteNavbar } from './site-navbar'; + +export type SiteHeaderProps = Omit<HeaderProps, 'children'> & { + isHome?: boolean; +}; + +const SiteHeaderWithRef: ForwardRefRenderFunction< + HTMLElement, + SiteHeaderProps +> = ({ className = '', isHome = false, ...props }, ref) => { + const headerClass = `${styles.header} ${className}`; + + return ( + <Header {...props} className={headerClass} ref={ref}> + <SiteBranding className={styles.branding} isHome={isHome} /> + <SiteNavbar className={styles.navbar} /> + </Header> + ); +}; + +export const SiteHeader = forwardRef(SiteHeaderWithRef); diff --git a/src/components/templates/layout/site-header/site-navbar.test.tsx b/src/components/templates/layout/site-header/site-navbar.test.tsx new file mode 100644 index 0000000..cf40927 --- /dev/null +++ b/src/components/templates/layout/site-header/site-navbar.test.tsx @@ -0,0 +1,73 @@ +import { describe, expect, it } from '@jest/globals'; +import { userEvent } from '@testing-library/user-event'; +import mockRouter from 'next-router-mock'; +import { MemoryRouterProvider } from 'next-router-mock/MemoryRouterProvider'; +import { + render, + screen as rtlScreen, + waitFor, +} from '../../../../../tests/utils'; +import { SiteNavbar } from './site-navbar'; +import { ROUTES } from 'src/utils/constants'; + +describe('SiteNavbar', () => { + it('renders the main nav, a search form and a settings form', () => { + render(<SiteNavbar />); + + expect( + rtlScreen.getByRole('checkbox', { name: 'Open menu' }) + ).toBeInTheDocument(); + expect( + rtlScreen.getByRole('checkbox', { name: 'Open search' }) + ).toBeInTheDocument(); + expect( + rtlScreen.getByRole('checkbox', { name: 'Open settings' }) + ).toBeInTheDocument(); + }); + + it('can give focus to the search input on activation', async () => { + const user = userEvent.setup(); + + render(<SiteNavbar />); + + /* It seems we cannot use it with waitFor... the assertions count is + * inaccurate. */ + // expect.assertions(1); + + await user.click(rtlScreen.getByRole('checkbox', { name: 'Open search' })); + + await waitFor(() => { + expect(rtlScreen.getByRole('searchbox')).toHaveFocus(); + }); + }); + + it('can submit the search form', async () => { + const user = userEvent.setup(); + const keywords = 'keywords'; + + render( + <MemoryRouterProvider> + <SiteNavbar /> + </MemoryRouterProvider> + ); + + await user.click(rtlScreen.getByRole('checkbox', { name: 'Open search' })); + await user.type(rtlScreen.getByRole('searchbox'), keywords); + await user.click(rtlScreen.getByRole('button', { name: 'Search' })); + + expect(mockRouter.asPath).toBe(`${ROUTES.SEARCH}?s=${keywords}`); + }); + + it('does not submit the search form without keywords', async () => { + const user = userEvent.setup(); + + render(<SiteNavbar />); + + await user.click(rtlScreen.getByRole('checkbox', { name: 'Open search' })); + await user.click(rtlScreen.getByRole('button', { name: 'Search' })); + + expect( + rtlScreen.getByText(/Query must be longer than one character./) + ).toBeInTheDocument(); + }); +}); diff --git a/src/components/templates/layout/site-header/site-navbar.tsx b/src/components/templates/layout/site-header/site-navbar.tsx new file mode 100644 index 0000000..96aeb4f --- /dev/null +++ b/src/components/templates/layout/site-header/site-navbar.tsx @@ -0,0 +1,210 @@ +import { useRouter } from 'next/router'; +import { + type FormEvent, + useCallback, + type ForwardRefRenderFunction, + forwardRef, + useRef, +} from 'react'; +import { useIntl } from 'react-intl'; +import { ROUTES } from '../../../../utils/constants'; +import { Icon } from '../../../atoms'; +import { + MainNav, + type MainNavItem, + Navbar, + SearchForm, + type SearchFormSubmit, + SettingsForm, + type NavbarProps, + NavbarItem, + type SearchFormRef, + type NavbarItemActivationHandler, +} from '../../../organisms'; +import styles from './site-header.module.scss'; + +export type SiteNavbarProps = Omit<NavbarProps, 'children'>; + +const SiteNavbarWithRef: ForwardRefRenderFunction< + HTMLUListElement, + SiteNavbarProps +> = (props, ref) => { + const router = useRouter(); + const intl = useIntl(); + const labels = { + mainNavItem: intl.formatMessage({ + defaultMessage: 'Open menu', + description: 'SiteNavbar: main nav button label in navbar', + id: '2By3AZ', + }), + mainNavModal: intl.formatMessage({ + defaultMessage: 'Main navigation', + description: 'SiteNavbar: main nav accessible name', + id: 'QQAcaS', + }), + searchItem: intl.formatMessage({ + defaultMessage: 'Open search', + id: 'Z/rsgm', + description: 'SiteNavbar: search button label in navbar', + }), + searchModal: intl.formatMessage({ + defaultMessage: 'Search', + description: 'SiteNavbar: search modal title in navbar', + id: '5eq0+c', + }), + settingsItem: intl.formatMessage({ + defaultMessage: 'Open settings', + id: 'l50cYa', + description: 'SiteNavbar: settings button label in navbar', + }), + settingsForm: intl.formatMessage({ + defaultMessage: 'Settings form', + id: 'zhjPcZ', + description: + 'SiteNavbar: an accessible name for the settings form in navbar', + }), + settingsModal: intl.formatMessage({ + defaultMessage: 'Settings', + description: 'SiteNavbar: settings modal title in navbar', + id: 'uKef8u', + }), + }; + const mainNav: MainNavItem[] = [ + { + id: 'home', + label: intl.formatMessage({ + defaultMessage: 'Home', + description: 'SiteNavbar: main nav - home link', + id: 'PnrHgZ', + }), + href: '/', + // eslint-disable-next-line react/jsx-no-literals + logo: <Icon aria-hidden={true} shape="home" />, + }, + { + id: 'blog', + label: intl.formatMessage({ + defaultMessage: 'Blog', + description: 'SiteNavbar: main nav - blog link', + id: '5C+1PP', + }), + href: ROUTES.BLOG, + // eslint-disable-next-line react/jsx-no-literals + logo: <Icon aria-hidden={true} shape="posts-stack" />, + }, + { + id: 'projects', + label: intl.formatMessage({ + defaultMessage: 'Projects', + description: 'SiteNavbar: main nav - projects link', + id: 'JXLaT8', + }), + href: ROUTES.PROJECTS, + // eslint-disable-next-line react/jsx-no-literals + logo: <Icon aria-hidden={true} shape="computer" />, + }, + { + id: 'cv', + label: intl.formatMessage({ + defaultMessage: 'CV', + description: 'SiteNavbar: main nav - cv link', + id: 'MJLr6U', + }), + href: ROUTES.CV, + // eslint-disable-next-line react/jsx-no-literals + logo: <Icon aria-hidden={true} shape="career" />, + }, + { + id: 'contact', + label: intl.formatMessage({ + defaultMessage: 'Contact', + description: 'SiteNavbar: main nav - contact link', + id: 'XGmQXV', + }), + href: ROUTES.CONTACT, + // eslint-disable-next-line react/jsx-no-literals + logo: <Icon aria-hidden={true} shape="envelop" />, + }, + ]; + const settingsSubmitHandler = useCallback((e: FormEvent) => { + e.preventDefault(); + }, []); + + const searchFormRef = useRef<SearchFormRef>(null); + const giveFocusToSearchInput: NavbarItemActivationHandler = useCallback( + (isActive) => { + if (isActive) searchFormRef.current?.focus(); + }, + [] + ); + const searchSubmitHandler: SearchFormSubmit = useCallback( + async ({ query }) => { + if (!query) + return { + messages: { + error: intl.formatMessage({ + defaultMessage: 'Query must be longer than one character.', + description: 'SiteNavbar: invalid query message', + id: 'nRzO0T', + }), + }, + validator: (value) => value.query.length > 1, + }; + + await router.push({ pathname: ROUTES.SEARCH, query: { s: query } }); + + return undefined; + }, + [intl, router] + ); + + return ( + <Navbar {...props} ref={ref}> + <NavbarItem + // eslint-disable-next-line react/jsx-no-literals + icon="hamburger" + // eslint-disable-next-line react/jsx-no-literals + id="main-nav" + label={labels.mainNavItem} + // eslint-disable-next-line react/jsx-no-literals + modalVisibleFrom="md" + > + <MainNav aria-label={labels.mainNavModal} items={mainNav} /> + </NavbarItem> + <NavbarItem + activationHandlerDelay={300} + // eslint-disable-next-line react/jsx-no-literals + icon="magnifying-glass" + // eslint-disable-next-line react/jsx-no-literals + id="search" + label={labels.searchItem} + modalHeading={labels.searchModal} + onActivation={giveFocusToSearchInput} + > + <SearchForm + className={styles.search} + isLabelHidden + onSubmit={searchSubmitHandler} + ref={searchFormRef} + /> + </NavbarItem> + <NavbarItem + // eslint-disable-next-line react/jsx-no-literals + icon="cog" + // eslint-disable-next-line react/jsx-no-literals + id="settings" + label={labels.settingsItem} + modalHeading={labels.settingsModal} + showIconOnModal + > + <SettingsForm + aria-label={labels.settingsForm} + className={styles.settings} + onSubmit={settingsSubmitHandler} + /> + </NavbarItem> + </Navbar> + ); +}; + +export const SiteNavbar = forwardRef(SiteNavbarWithRef); diff --git a/src/i18n/en.json b/src/i18n/en.json index 7b59fdb..aac327d 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -71,6 +71,10 @@ "defaultMessage": "Projects", "description": "Breadcrumb: projects label" }, + "2By3AZ": { + "defaultMessage": "Open menu", + "description": "SiteNavbar: main nav button label in navbar" + }, "2D9tB5": { "defaultMessage": "Topics", "description": "BlogPage: topics list widget title" @@ -107,14 +111,18 @@ "defaultMessage": "Loading the requested article...", "description": "ArticlePage: loading article message" }, - "52H2HA": { - "defaultMessage": "{website} logo", - "description": "Layout: logo title" + "5C+1PP": { + "defaultMessage": "Blog", + "description": "SiteNavbar: main nav - blog link" }, "5eD6y2": { "defaultMessage": "Full", "description": "AckeeToggle: full option name" }, + "5eq0+c": { + "defaultMessage": "Search", + "description": "SiteNavbar: search modal title in navbar" + }, "6GySNl": { "defaultMessage": "Copy", "description": "usePrism: copy button text (not clicked)" @@ -143,10 +151,6 @@ "defaultMessage": "Full includes all information from partial as well as information about referrer, operating system, device, browser, screen size and language.", "description": "AckeeToggle: tooltip message" }, - "8jjY1X": { - "defaultMessage": "{website} picture", - "description": "Layout: photo alternative text" - }, "8q5PXx": { "defaultMessage": "{date} at {time}", "description": "Time: readable date and time" @@ -175,10 +179,6 @@ "defaultMessage": "Technologies:", "description": "Meta: technologies label" }, - "AE4kCD": { - "defaultMessage": "Contact", - "description": "Layout: main nav - contact link" - }, "AN9iy7": { "defaultMessage": "Contact", "description": "ContactPage: page title" @@ -215,18 +215,10 @@ "defaultMessage": "Failed to load.", "description": "BlogPage: failed to load text" }, - "C2YcUJ": { - "defaultMessage": "Query must be longer than one character.", - "description": "Layout: invalid query message" - }, "C6oK7h": { "defaultMessage": "Query must be longer than one character.", "description": "404Page: invalid query message" }, - "D8vB38": { - "defaultMessage": "Blog", - "description": "Layout: main nav - blog link" - }, "Dq6+WH": { "defaultMessage": "Thematics", "description": "SearchPage: thematics list widget title" @@ -247,10 +239,6 @@ "defaultMessage": "{title} cover", "description": "ProjectsPage: figure (cover) accessible name" }, - "Fgt/RZ": { - "defaultMessage": "Open menu", - "description": "Layout: main nav button label in navbar" - }, "GVpTIl": { "defaultMessage": "Topics", "description": "Error404Page: topics list widget title" @@ -291,6 +279,10 @@ "defaultMessage": "Current page, page {number}", "description": "BlogPage: current page label" }, + "JXLaT8": { + "defaultMessage": "Projects", + "description": "SiteNavbar: main nav - projects link" + }, "JbT+fA": { "defaultMessage": "Updated on:", "description": "ProjectOverview: update date label" @@ -311,10 +303,6 @@ "defaultMessage": "Other thematics", "description": "ThematicPage: other thematics list widget title" }, - "Kjj1Zk": { - "defaultMessage": "Back to top", - "description": "Layout: an accessible name for the back to top button" - }, "KnWeKh": { "defaultMessage": "Page not found", "description": "Error404Page: page title" @@ -323,9 +311,9 @@ "defaultMessage": "All posts in {thematicName}", "description": "ThematicPage: posts list heading" }, - "Mq+O6q": { - "defaultMessage": "Search", - "description": "Layout: search modal title in navbar" + "MJLr6U": { + "defaultMessage": "CV", + "description": "SiteNavbar: main nav - cv link" }, "N44SOc": { "defaultMessage": "Projects", @@ -379,6 +367,10 @@ "defaultMessage": "{websiteName} | Front-end developer: WordPress/React", "description": "HomePage: SEO - Page title" }, + "PnrHgZ": { + "defaultMessage": "Home", + "description": "SiteNavbar: main nav - home link" + }, "Q3oEQn": { "defaultMessage": "LinkedIn profile", "description": "ContactPage: LinkedIn profile link" @@ -387,6 +379,10 @@ "defaultMessage": "Dark Theme 🌙", "description": "usePrism: toggle dark theme button text" }, + "QQAcaS": { + "defaultMessage": "Main navigation", + "description": "SiteNavbar: main nav accessible name" + }, "Qa9twM": { "defaultMessage": "Reply", "description": "CommentsList: reply button" @@ -403,10 +399,6 @@ "defaultMessage": "License:", "description": "ProjectOverview: license label" }, - "R895yC": { - "defaultMessage": "CV", - "description": "Layout: main nav - cv link" - }, "RwI3B9": { "defaultMessage": "Loading the repository popularity...", "description": "ProjectsPage: loading repository popularity" @@ -435,6 +427,10 @@ "defaultMessage": "Query must be longer than one character.", "description": "NoResults: invalid query message" }, + "Vrw5/h": { + "defaultMessage": "{website} logo", + "description": "SiteBranding: logo title" + }, "WDwNDl": { "defaultMessage": "Search", "description": "SearchPage: SEO - Page title" @@ -451,14 +447,14 @@ "defaultMessage": "Search for:", "description": "SearchForm: field accessible label" }, + "XGmQXV": { + "defaultMessage": "Contact", + "description": "SiteNavbar: main nav - contact link" + }, "XKy7rx": { "defaultMessage": "You can also try a search:", "description": "Error404Page: try a search message" }, - "XRwEoA": { - "defaultMessage": "Open search", - "description": "Layout: search button label in navbar" - }, "Y7XdNp": { "defaultMessage": "Leave a comment", "description": "PageComments: the section title of the comment form" @@ -471,6 +467,10 @@ "defaultMessage": "Light theme", "description": "ThemeToggle: light theme label" }, + "Z/rsgm": { + "defaultMessage": "Open search", + "description": "SiteNavbar: search button label in navbar" + }, "ZB/Aw2": { "defaultMessage": "Partial includes only page url, views and duration.", "description": "AckeeToggle: tooltip message" @@ -507,10 +507,6 @@ "defaultMessage": "Contact form", "description": "Contact: form accessible name" }, - "bojYF5": { - "defaultMessage": "Home", - "description": "Layout: main nav - home link" - }, "c0Oecl": { "defaultMessage": "Created on:", "description": "ProjectOverview: creation date label" @@ -523,9 +519,9 @@ "defaultMessage": "Popularity:", "description": "ProjectOverview: popularity label" }, - "dfTljv": { - "defaultMessage": "Main navigation", - "description": "Layout: main nav accessible name" + "dDwm38": { + "defaultMessage": "{website} picture", + "description": "SiteBranding: photo alternative text" }, "eys2uX": { "defaultMessage": "Table of Contents", @@ -547,10 +543,6 @@ "defaultMessage": "Code blocks:", "description": "PrismThemeToggle: theme label" }, - "h3J0a+": { - "defaultMessage": "Settings form", - "description": "Layout: an accessible name for the settings form in navbar" - }, "hGvQpI": { "defaultMessage": "Load more posts?", "description": "PostsList: load more button" @@ -583,6 +575,10 @@ "defaultMessage": "Reading time:", "description": "PageHeader: reading time label" }, + "l50cYa": { + "defaultMessage": "Open settings", + "description": "SiteNavbar: settings button label in navbar" + }, "lKhTGM": { "defaultMessage": "Use Ctrl+c to copy", "description": "usePrism: copy button error text" @@ -591,14 +587,14 @@ "defaultMessage": "Legal notice", "description": "SiteFooter: Legal notice link label" }, - "mDKiaN": { - "defaultMessage": "Open settings", - "description": "Layout: settings button label in navbar" - }, "nGss/j": { "defaultMessage": "Ackee tracking (analytics)", "description": "AckeeToggle: tooltip title" }, + "nRzO0T": { + "defaultMessage": "Query must be longer than one character.", + "description": "SiteNavbar: invalid query message" + }, "ndAawq": { "defaultMessage": "Leave a reply to comment {id}", "description": "ReplyCommentForm: an accessible name for the reply form" @@ -611,18 +607,10 @@ "defaultMessage": "Copied!", "description": "usePrism: copy button text (clicked)" }, - "nwbzKm": { - "defaultMessage": "Legal notice", - "description": "Layout: Legal notice label" - }, "o+wCJz": { "defaultMessage": "Comment form", "description": "PageComments: an accessible name for the comment form" }, - "o3WSz5": { - "defaultMessage": "Settings", - "description": "Layout: settings modal title in navbar" - }, "ofQPC+": { "defaultMessage": "Share on LinkedIn", "description": "SharingWidget: LinkedIn sharing link" @@ -651,10 +639,6 @@ "defaultMessage": "Discover search results for {query} on {websiteName}.", "description": "SearchPage: SEO - Meta description" }, - "qnwsWV": { - "defaultMessage": "Projects", - "description": "Layout: main nav - projects link" - }, "s8/tyz": { "defaultMessage": "Object:", "description": "ContactForm: object label" @@ -691,6 +675,10 @@ "defaultMessage": "Website:", "description": "CommentForm: website label" }, + "uKef8u": { + "defaultMessage": "Settings", + "description": "SiteNavbar: settings modal title in navbar" + }, "uZj4QI": { "defaultMessage": "Cancel reply", "description": "CommentsList: cancel reply button" @@ -727,10 +715,6 @@ "defaultMessage": "{minutesCount, plural, =0 {Less than one minute} one {# minute} other {# minutes}}", "description": "PostPreviewMeta: rounded minutes count" }, - "yB1SPF": { - "defaultMessage": "CC BY SA", - "description": "Layout: copyright title" - }, "yN5P+m": { "defaultMessage": "Message:", "description": "ContactForm: message label" @@ -742,5 +726,9 @@ "zbzlb1": { "defaultMessage": "Page {number}", "description": "BlogPage: page number" + }, + "zhjPcZ": { + "defaultMessage": "Settings form", + "description": "SiteNavbar: an accessible name for the settings form in navbar" } } diff --git a/src/i18n/fr.json b/src/i18n/fr.json index 1ca2efd..17514a3 100644 --- a/src/i18n/fr.json +++ b/src/i18n/fr.json @@ -71,6 +71,10 @@ "defaultMessage": "Projets", "description": "Breadcrumb: projects label" }, + "2By3AZ": { + "defaultMessage": "Ouvrir le menu", + "description": "SiteNavbar: main nav button label in navbar" + }, "2D9tB5": { "defaultMessage": "Sujets", "description": "BlogPage: topics list widget title" @@ -107,14 +111,18 @@ "defaultMessage": "Chargement de l’article demandé…", "description": "ArticlePage: loading article message" }, - "52H2HA": { - "defaultMessage": "Logo du site d’{website}", - "description": "Layout: logo title" + "5C+1PP": { + "defaultMessage": "Blog", + "description": "SiteNavbar: main nav - blog link" }, "5eD6y2": { "defaultMessage": "Complet", "description": "AckeeToggle: full option name" }, + "5eq0+c": { + "defaultMessage": "Recherche", + "description": "SiteNavbar: search modal title in navbar" + }, "6GySNl": { "defaultMessage": "Copier", "description": "usePrism: copy button text (not clicked)" @@ -143,10 +151,6 @@ "defaultMessage": "Complet inclut toutes les informations de Partiel ainsi que des informations à propos du site référent, du système d’exploitation, de l’appareil, du navigateur, de la taille d’écran et de la langue.", "description": "AckeeToggle: tooltip message" }, - "8jjY1X": { - "defaultMessage": "Photo d’{website}", - "description": "Layout: photo alternative text" - }, "8q5PXx": { "defaultMessage": "{date} à {time}", "description": "Time: readable date and time" @@ -175,10 +179,6 @@ "defaultMessage": "Technologies :", "description": "Meta: technologies label" }, - "AE4kCD": { - "defaultMessage": "Contact", - "description": "Layout: main nav - contact link" - }, "AN9iy7": { "defaultMessage": "Contact", "description": "ContactPage: page title" @@ -215,18 +215,10 @@ "defaultMessage": "Échec du chargement.", "description": "BlogPage: failed to load text" }, - "C2YcUJ": { - "defaultMessage": "Les mots-clés doivent être plus longs qu'un caractère.", - "description": "Layout: invalid query message" - }, "C6oK7h": { "defaultMessage": "Les mots-clés doivent être plus longs qu'un caractère.", "description": "404Page: invalid query message" }, - "D8vB38": { - "defaultMessage": "Blog", - "description": "Layout: main nav - blog link" - }, "Dq6+WH": { "defaultMessage": "Thématiques", "description": "SearchPage: thematics list widget title" @@ -247,10 +239,6 @@ "defaultMessage": "Illustration de {title}", "description": "ProjectsPage: figure (cover) accessible name" }, - "Fgt/RZ": { - "defaultMessage": "Ouvrir le menu", - "description": "Layout: main nav button label in navbar" - }, "GVpTIl": { "defaultMessage": "Sujets", "description": "Error404Page: topics list widget title" @@ -291,6 +279,10 @@ "defaultMessage": "Page actuelle, page {number}", "description": "BlogPage: current page label" }, + "JXLaT8": { + "defaultMessage": "Projets", + "description": "SiteNavbar: main nav - projects link" + }, "JbT+fA": { "defaultMessage": "Mis à jour le :", "description": "ProjectOverview: update date label" @@ -311,10 +303,6 @@ "defaultMessage": "Autres thématiques", "description": "ThematicPage: other thematics list widget title" }, - "Kjj1Zk": { - "defaultMessage": "Retour en haut de page", - "description": "Layout: an accessible name for the back to top button" - }, "KnWeKh": { "defaultMessage": "Page non trouvée", "description": "Error404Page: page title" @@ -323,9 +311,9 @@ "defaultMessage": "Tous les articles dans {thematicName}", "description": "ThematicPage: posts list heading" }, - "Mq+O6q": { - "defaultMessage": "Recherche", - "description": "Layout: search modal title in navbar" + "MJLr6U": { + "defaultMessage": "CV", + "description": "SiteNavbar: main nav - cv link" }, "N44SOc": { "defaultMessage": "Projets", @@ -355,6 +343,10 @@ "defaultMessage": "{thematicsCount, plural, =0 {Thématiques :} one {Thématique :} other {Thématiques :}}", "description": "PageHeader: thematics label" }, + "OHvb01": { + "defaultMessage": "Retour en haut de page", + "description": "SiteFooter: an accessible name for the back to top button" + }, "OL0Yzx": { "defaultMessage": "Publier", "description": "CommentForm: submit button" @@ -375,6 +367,10 @@ "defaultMessage": "{websiteName} | Intégrateur web - Développeur WordPress / React", "description": "HomePage: SEO - Page title" }, + "PnrHgZ": { + "defaultMessage": "Accueil", + "description": "SiteNavbar: main nav - home link" + }, "Q3oEQn": { "defaultMessage": "Profil LinkedIn", "description": "ContactPage: LinkedIn profile link" @@ -383,6 +379,10 @@ "defaultMessage": "Thème sombre 🌙", "description": "usePrism: toggle dark theme button text" }, + "QQAcaS": { + "defaultMessage": "Navigation principale", + "description": "SiteNavbar: main nav accessible name" + }, "Qa9twM": { "defaultMessage": "Répondre", "description": "CommentsList: reply button" @@ -399,10 +399,6 @@ "defaultMessage": "Licence :", "description": "ProjectOverview: license label" }, - "R895yC": { - "defaultMessage": "CV", - "description": "Layout: main nav - cv link" - }, "RwI3B9": { "defaultMessage": "Chargement de la popularité du dépôt…", "description": "ProjectsPage: loading repository popularity" @@ -431,6 +427,10 @@ "defaultMessage": "Les mots-clés doivent être plus longs qu'un caractère.", "description": "NoResults: invalid query message" }, + "Vrw5/h": { + "defaultMessage": "Logo d’{website}", + "description": "SiteBranding: logo title" + }, "WDwNDl": { "defaultMessage": "Recherche", "description": "SearchPage: SEO - Page title" @@ -447,14 +447,14 @@ "defaultMessage": "Rechercher :", "description": "SearchForm: field accessible label" }, + "XGmQXV": { + "defaultMessage": "Contact", + "description": "SiteNavbar: main nav - contact link" + }, "XKy7rx": { "defaultMessage": "Vous pouvez également tenter une recherche :", "description": "Error404Page: try a search message" }, - "XRwEoA": { - "defaultMessage": "Ouvrir la recherche", - "description": "Layout: search button label in navbar" - }, "Y7XdNp": { "defaultMessage": "Laisser un commentaire", "description": "PageComments: the section title of the comment form" @@ -467,6 +467,10 @@ "defaultMessage": "Thème clair", "description": "ThemeToggle: light theme label" }, + "Z/rsgm": { + "defaultMessage": "Ouvrir le formulaire de recherche", + "description": "SiteNavbar: search button label in navbar" + }, "ZB/Aw2": { "defaultMessage": "Partiel inclut seulement l’url de la page, le nombre de visites et la durée.", "description": "AckeeToggle: tooltip message" @@ -503,10 +507,6 @@ "defaultMessage": "Formulaire de contact", "description": "Contact: form accessible name" }, - "bojYF5": { - "defaultMessage": "Accueil", - "description": "Layout: main nav - home link" - }, "c0Oecl": { "defaultMessage": "Créé le :", "description": "ProjectOverview: creation date label" @@ -519,9 +519,9 @@ "defaultMessage": "Popularité :", "description": "ProjectOverview: popularity label" }, - "dfTljv": { - "defaultMessage": "Navigation principale", - "description": "Layout: main nav accessible name" + "dDwm38": { + "defaultMessage": "Photo d’{website}", + "description": "SiteBranding: photo alternative text" }, "eys2uX": { "defaultMessage": "Table des matières", @@ -543,10 +543,6 @@ "defaultMessage": "Blocs de code :", "description": "PrismThemeToggle: theme label" }, - "h3J0a+": { - "defaultMessage": "Formulaire des réglages", - "description": "Layout: an accessible name for the settings form in navbar" - }, "hGvQpI": { "defaultMessage": "Charger plus d’articles ?", "description": "PostsList: load more button" @@ -563,6 +559,10 @@ "defaultMessage": "Illustration de {postTitle}", "description": "PostPreview: an accessible name for the figure wrapping the cover" }, + "iTLvLX": { + "defaultMessage": "CC BY SA", + "description": "SiteFooter: the license name" + }, "j5k9Fe": { "defaultMessage": "Accueil", "description": "Breadcrumb: home label" @@ -575,18 +575,26 @@ "defaultMessage": "Temps de lecture :", "description": "PageHeader: reading time label" }, + "l50cYa": { + "defaultMessage": "Ouvrir les réglages", + "description": "SiteNavbar: settings button label in navbar" + }, "lKhTGM": { "defaultMessage": "Utilisez Ctrl+c pour copier", "description": "usePrism: copy button error text" }, - "mDKiaN": { - "defaultMessage": "Ouvrir les réglages", - "description": "Layout: settings button label in navbar" + "lsmD4c": { + "defaultMessage": "Mentions légales", + "description": "SiteFooter: Legal notice link label" }, "nGss/j": { "defaultMessage": "Suivi Ackee (analytique)", "description": "AckeeToggle: tooltip title" }, + "nRzO0T": { + "defaultMessage": "Les mots-clés doivent être plus longs qu'un caractère.", + "description": "SiteNavbar: invalid query message" + }, "ndAawq": { "defaultMessage": "Répondre au commentaire {id}", "description": "ReplyCommentForm: an accessible name for the reply form" @@ -599,18 +607,10 @@ "defaultMessage": "Copié !", "description": "usePrism: copy button text (clicked)" }, - "nwbzKm": { - "defaultMessage": "Mentions légales", - "description": "Layout: Legal notice label" - }, "o+wCJz": { "defaultMessage": "Formulaire des commentaires", "description": "PageComments: an accessible name for the comment form" }, - "o3WSz5": { - "defaultMessage": "Réglages", - "description": "Layout: settings modal title in navbar" - }, "ofQPC+": { "defaultMessage": "Partager sur LinkedIn", "description": "SharingWidget: LinkedIn sharing link" @@ -639,10 +639,6 @@ "defaultMessage": "Découvrez les résultats de recherche pour {query} sur {websiteName}.", "description": "SearchPage: SEO - Meta description" }, - "qnwsWV": { - "defaultMessage": "Projets", - "description": "Layout: main nav - projects link" - }, "s8/tyz": { "defaultMessage": "Sujet :", "description": "ContactForm: object label" @@ -679,6 +675,10 @@ "defaultMessage": "Site web :", "description": "CommentForm: website label" }, + "uKef8u": { + "defaultMessage": "Réglages", + "description": "SiteNavbar: settings modal title in navbar" + }, "uZj4QI": { "defaultMessage": "Annuler la réponse", "description": "CommentsList: cancel reply button" @@ -715,10 +715,6 @@ "defaultMessage": "{minutesCount, plural, =0 {Moins d’une minute} one {# minute} other {# minutes}}", "description": "PostPreviewMeta: rounded minutes count" }, - "yB1SPF": { - "defaultMessage": "CC BY SA", - "description": "Layout: copyright title" - }, "yN5P+m": { "defaultMessage": "Message :", "description": "ContactForm: message label" @@ -730,5 +726,9 @@ "zbzlb1": { "defaultMessage": "Page {number}", "description": "BlogPage: page number" + }, + "zhjPcZ": { + "defaultMessage": "Formulaire des réglages", + "description": "SiteNavbar: an accessible name for the settings form in navbar" } } diff --git a/src/styles/base/_base.scss b/src/styles/base/_base.scss index 91989bd..1b52515 100644 --- a/src/styles/base/_base.scss +++ b/src/styles/base/_base.scss @@ -38,6 +38,59 @@ p + iframe { margin-top: 0; } +p { + font-size: var(--font-size-md); + margin: 0 0 var(--spacing-sm); +} + +small { + font-size: var(--font-size-sm); +} + +button, +input, +optgroup, +select, +textarea { + line-height: var(--line-height); +} + +code, +kbd, +pre, +var, +samp { + font-family: var(--font-family-mono); +} + +pre { + display: block; + max-width: 100%; + overflow: auto; + white-space: pre; + word-spacing: normal; + word-break: normal; + word-wrap: normal; +} + +:not(pre) > code, +kbd, +var, +samp { + background: var(--color-bg-code); + border: fun.convert-px(1) solid var(--color-border); + border-radius: fun.convert-px(3); + color: var(--color-primary-darker); + font-style: normal; + padding: fun.convert-px(2) fun.convert-px(5) fun.convert-px(1) + fun.convert-px(5); +} + +kbd { + box-shadow: fun.convert-px(1) fun.convert-px(1) 0 fun.convert-px(1) + var(--color-shadow); +} + * { scrollbar-color: var(--color-primary) var(--color-bg-tertiary); scrollbar-width: auto; diff --git a/src/styles/base/_spacings.scss b/src/styles/base/_spacings.scss index 3cff009..13a1fbe 100644 --- a/src/styles/base/_spacings.scss +++ b/src/styles/base/_spacings.scss @@ -24,5 +24,4 @@ --spacing-xl: clamp(#{var.spacing("lg")}, 1ex + 4vw, #{var.spacing("xl")}); --spacing-2xl: clamp(#{var.spacing("xl")}, 1ex + 5vw, #{var.spacing("2xl")}); --spacing-3xl: clamp(#{var.spacing("2xl")}, 1ex + 6vw, #{var.spacing("3xl")}); - --toolbar-size: #{fun.convert-px(80)}; } diff --git a/src/styles/base/_typography.scss b/src/styles/base/_typography.scss deleted file mode 100644 index 170f246..0000000 --- a/src/styles/base/_typography.scss +++ /dev/null @@ -1,53 +0,0 @@ -@use "../abstracts/functions" as fun; - -p { - font-size: var(--font-size-md); - margin: 0 0 var(--spacing-sm); -} - -small { - font-size: var(--font-size-sm); -} - -button, -input, -optgroup, -select, -textarea { - line-height: var(--line-height); -} - -code, -kbd, -pre, -var { - font-family: var(--font-family-mono); -} - -:not(pre) > code, -kbd, -var, -samp { - background: var(--color-bg-code); - border: fun.convert-px(1) solid var(--color-border); - border-radius: fun.convert-px(3); - color: var(--color-primary-darker); - font-style: normal; - padding: fun.convert-px(2) fun.convert-px(5) fun.convert-px(1) - fun.convert-px(5); -} - -kbd { - box-shadow: fun.convert-px(1) fun.convert-px(1) 0 fun.convert-px(1) - var(--color-shadow); -} - -pre { - display: block; - max-width: 100%; - overflow: auto; - white-space: pre; - word-spacing: normal; - word-break: normal; - word-wrap: normal; -} diff --git a/src/styles/globals.scss b/src/styles/globals.scss index 8cf7296..32f325f 100644 --- a/src/styles/globals.scss +++ b/src/styles/globals.scss @@ -19,7 +19,6 @@ @use "base/helpers"; @use "base/icons"; @use "base/spacings"; -@use "base/typography"; /** * 3.0. Themes diff --git a/src/utils/constants.ts b/src/utils/constants.ts index 7129624..26cbeaa 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -15,6 +15,7 @@ export const ROUTES = { BLOG: '/blog', CONTACT: '/contact', CV: '/cv', + HOME: '/', LEGAL_NOTICE: '/mentions-legales', NOT_FOUND: '/404', PROJECTS: '/projets', |
