diff options
| author | Armand Philippot <git@armandphilippot.com> | 2023-11-21 19:01:18 +0100 |
|---|---|---|
| committer | Armand Philippot <git@armandphilippot.com> | 2023-11-22 12:52:35 +0100 |
| commit | d4045fbcbfa8208ec31539744417f315f1f6fad8 (patch) | |
| tree | 54746d3e28cc6e4a2d7d1e54a4b2e3e1e74a6896 /src/components/templates/layout/site-header | |
| parent | c6212f927daf3c928f479afa052e4772216a2d8a (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')
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); |
