aboutsummaryrefslogtreecommitdiffstats
path: root/src/components/templates/layout/site-header
diff options
context:
space:
mode:
authorArmand Philippot <git@armandphilippot.com>2023-11-21 19:01:18 +0100
committerArmand Philippot <git@armandphilippot.com>2023-11-22 12:52:35 +0100
commitd4045fbcbfa8208ec31539744417f315f1f6fad8 (patch)
tree54746d3e28cc6e4a2d7d1e54a4b2e3e1e74a6896 /src/components/templates/layout/site-header
parentc6212f927daf3c928f479afa052e4772216a2d8a (diff)
refactor(components): split Layout component in smaller components
The previous component was too long and hardly readable. So I splitted it in different part and added tests.
Diffstat (limited to 'src/components/templates/layout/site-header')
-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
8 files changed, 525 insertions, 0 deletions
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);