diff options
Diffstat (limited to 'src/components/templates')
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); |
