aboutsummaryrefslogtreecommitdiffstats
path: root/src/components/templates
diff options
context:
space:
mode:
Diffstat (limited to 'src/components/templates')
-rw-r--r--src/components/templates/layout/layout.module.scss133
-rw-r--r--src/components/templates/layout/layout.stories.tsx2
-rw-r--r--src/components/templates/layout/layout.test.tsx11
-rw-r--r--src/components/templates/layout/layout.tsx412
-rw-r--r--src/components/templates/layout/site-footer/index.ts1
-rw-r--r--src/components/templates/layout/site-footer/site-footer.module.scss42
-rw-r--r--src/components/templates/layout/site-footer/site-footer.test.tsx20
-rw-r--r--src/components/templates/layout/site-footer/site-footer.tsx93
-rw-r--r--src/components/templates/layout/site-header/index.ts2
-rw-r--r--src/components/templates/layout/site-header/site-branding.test.tsx23
-rw-r--r--src/components/templates/layout/site-header/site-branding.tsx91
-rw-r--r--src/components/templates/layout/site-header/site-header.module.scss90
-rw-r--r--src/components/templates/layout/site-header/site-header.test.tsx11
-rw-r--r--src/components/templates/layout/site-header/site-header.tsx25
-rw-r--r--src/components/templates/layout/site-header/site-navbar.test.tsx73
-rw-r--r--src/components/templates/layout/site-header/site-navbar.tsx210
16 files changed, 733 insertions, 506 deletions
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);