diff --git a/packages/css/src/components/header/README.md b/packages/css/src/components/header/README.md index 78e1e0f28d..c0c9a345c1 100644 --- a/packages/css/src/components/header/README.md +++ b/packages/css/src/components/header/README.md @@ -11,13 +11,15 @@ Includes the name of the application if it is not the general website. - It includes the logo of the City or the organization, the site title (except for the general website), and a menu with links to commonly used pages. - The Header is important because it conveys our corporate identity and is the first thing people see. Using it consistently helps users recognize and trust the website. -- It is the same on every page of the application. -- The page menu can contain a maximum of 5 items. +- The Header is the same on every page of the application, although full-screen pages may hide it, e.g. a video or a map. +- The inline menu can contain a maximum of 5 items. The last two will often be ‘Search’ and ‘Menu’. -- Labels should be short to ensure the menu fits on one line, even on medium-wide screens. -- An icon can be added to the end of a link to make its function easier to find. +- The 'Menu' button opens a collapsible menu, which has room for more links. +- On narrow windows, links can move from the inline menu to the collapsible one. +- Labels should be short to help the inline menu fit on a single line whenever possible. +- An icon can be added to the end of a link to make its destination easier to guess. ## References -- A Header is a [landmark](https://www.w3.org/TR/wai-aria-practices-1.1/#aria_landmark_roles) and can be use to group navigation elements. +- A Header is a [landmark](https://www.w3.org/TR/wai-aria-practices-1.1/#aria_landmark_roles) and can be used to group navigation elements. - [WCAG 3.2.3](https://wcag.com/designers/3-2-3-consistent-navigation/) Consistent Navigation: Navigation menus that appear on multiple pages are consistent. diff --git a/packages/css/src/components/header/header.scss b/packages/css/src/components/header/header.scss index 7c80af5d0a..828831d6a4 100644 --- a/packages/css/src/components/header/header.scss +++ b/packages/css/src/components/header/header.scss @@ -4,83 +4,213 @@ */ @use "../../common/breakpoint" as *; +@use "../../common/text-rendering" as *; .ams-header { + /* + * The branding section is created twice: once outside the navigation and once hidden inside it. + * This keeps all navigation in one nav element and lets the menu wrap around the branding section. + * Display grid is used to let both branding sections overlap. + */ + display: grid; + padding-block: var(--ams-header-padding-block); + padding-inline: var(--ams-header-padding-inline); +} + +.ams-header__branding { align-items: center; + align-self: start; // To align the branding section to the top of the header when it wraps + column-gap: var(--ams-header-branding-column-gap); display: flex; - flex-wrap: wrap; - padding-block: var(--ams-header-padding-block); - row-gap: 1.5rem; + grid-area: 1 / 1; // To allow this section to overlap with the second branding section +} - @media screen and (min-width: $ams-breakpoint-wide) { - column-gap: var(--ams-header-column-gap); - flex-wrap: nowrap; - } +.ams-header__branding--hidden { + opacity: 0%; + user-select: none; // The hidden branding section should not be selectable } .ams-header__logo-link { - flex: none; outline-offset: var(--ams-header-logo-link-outline-offset); } -.ams-header__links { - display: none; +/* TODO Remove after updating Heading line heights in DES-973. */ +.ams-heading.ams-header__brand-name { + line-height: 1.35; +} + +.ams-header__navigation { + column-gap: var(--ams-header-navigation-column-gap); + display: flex; + flex-wrap: wrap; + grid-area: 1 / 1; // To allow this section to overlap with the branding section + // This section blocks pointer events initially, so the hidden branding section can't be activated. + // The menu and collapsible menu set it back to auto, to make sure they can be activated. + pointer-events: none; + row-gap: var(--ams-header-navigation-row-gap); +} + +@mixin reset-list { + list-style: none; + margin-block: 0; + padding-inline-start: 0; +} + +.ams-header__menu { + align-items: center; + column-gap: var(--ams-header-menu-column-gap); + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + margin-inline-start: auto; + pointer-events: auto; // Set pointer events back to auto to allow the menu to be activated + row-gap: var(--ams-header-menu-row-gap); - @media screen and (min-width: $ams-breakpoint-medium) { - display: block; - flex: 10 0 auto; + @include reset-list; +} + +// Do not show menu items below the wide breakpoint... +.ams-header__menu-item { + @media screen and (not (min-width: $ams-breakpoint-wide)) { + display: none; } +} - @media screen and (min-width: $ams-breakpoint-wide) { - order: 3; +// ...unless they're fixed. +.ams-header__menu-item--fixed { + display: revert; +} + +@mixin header-menu-action { + color: var(--ams-header-menu-item-color); + font-family: var(--ams-header-menu-item-font-family); + font-size: var(--ams-header-menu-item-font-size); + font-weight: var(--ams-header-menu-item-font-weight); + line-height: var(--ams-header-menu-item-line-height); + outline-offset: var(--ams-header-menu-item-outline-offset); + padding-block: var(--ams-header-menu-item-padding-block); + touch-action: manipulation; + white-space: nowrap; + + @include text-rendering; + + &:hover { + color: var(--ams-header-menu-item-hover-color); } } -.ams-header__menu { - flex: 1; - padding-inline-start: var(--ams-page-menu-column-gap); // TODO Don’t use tokens of another component - text-align: end; +.ams-header__menu-link { + display: inline-block; + text-decoration-line: var(--ams-header-menu-link-text-decoration-line); + text-decoration-thickness: var(--ams-header-menu-link-text-decoration-thickness); + text-underline-offset: var(--ams-header-menu-link-text-underline-offset); + + @include header-menu-action; + + &:hover { + text-decoration-line: var(--ams-header-menu-link-hover-text-decoration-line); + } +} +.ams-header__mega-menu-button-item--hide-on-wide-window { @media screen and (min-width: $ams-breakpoint-wide) { - order: 4; - padding-inline-start: 0; + display: none; } } -.ams-header__app-name { - flex: 1 1 100%; +@mixin reset-button { + background: none; + border: 0; + margin-block: 0; // [1] + margin-inline: 0; // [1] + padding-inline: 0; - @media screen and (min-width: $ams-breakpoint-wide) { - min-inline-size: 0; - order: 2; - - .ams-header__app-name-heading { - display: block; - inline-size: 100%; - line-height: 1; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; + // [1] Remove the margin in older Safari. +} + +.ams-header__mega-menu-button { + column-gap: var(--ams-header-menu-item-column-gap); + cursor: pointer; + display: grid; + grid-auto-flow: column; + + @include header-menu-action; + @include reset-button; +} + +.ams-header__mega-menu-button[aria-expanded="true"] { + font-weight: var(--ams-header-mega-menu-button-label-open-font-weight); +} + +.ams-header__mega-menu-button-label, +.ams-header__mega-menu-button-hidden-label { + grid-area: 1 / 1; // To allow the label and the hidden label to overlap +} + +// This hidden label is used to prevent a layout shift when the mega menu is opened +// and the button text becomes bold. +.ams-header__mega-menu-button-hidden-label { + font-weight: var(--ams-header-mega-menu-button-label-open-font-weight); + pointer-events: none; + user-select: none; + visibility: hidden; +} + +.ams-header__menu-icon { + line { + stroke: currentColor; + stroke-width: 3px; + transform-origin: center; + transition: + translate 0.1s ease-in-out, + rotate 0.2s ease-in-out, + opacity 0.1s ease-in-out; + + @media (prefers-reduced-motion) { + transition: none; } } + + .ams-header__menu-icon-top { + translate: 0 -7px; + } + + .ams-header__menu-icon-bottom { + translate: 0 7px; + } } -// Temporary – will move to Mega Menu and/or Icon Button -.ams-header__menu-button { - background-color: transparent; - background-image: url("data:image/svg+xml;utf8,"); - background-position: center right; - background-repeat: no-repeat; - background-size: 1.1875rem 1.1875rem; - border: 0; - color: var(--ams-page-menu-item-color); - font-family: var(--ams-page-menu-item-font-family); - font-size: var(--ams-page-menu-item-font-size); - font-weight: var(--ams-page-menu-item-font-weight); - line-height: var(--ams-page-menu-item-line-height); - margin-block: 0; - margin-inline: 0; - padding-inline: 0 1.875rem; - text-align: center; - touch-action: manipulation; +.ams-header__menu-icon--open { + .ams-header__menu-icon-top { + rotate: 45deg; + translate: 0; + } + .ams-header__menu-icon-middle { + opacity: 0%; + } + .ams-header__menu-icon-bottom { + rotate: -45deg; + translate: 0; + } +} + +.ams-header__mega-menu { + inline-size: 100%; + pointer-events: auto; // Set pointer events back to auto to allow the mega menu to be activated + + // Remove inline padding from Grids that are used in the mega menu. + // The grid inline padding is set on the header element. + & .ams-grid { + padding-inline: 0; + } +} + +.ams-header__mega-menu--closed.ams-header__mega-menu--closed { + display: none; +} + +.ams-header__grid-cell-narrow-window-only { + @media screen and (min-width: $ams-breakpoint-wide) { + display: none; + } } diff --git a/packages/css/src/components/logo/README.md b/packages/css/src/components/logo/README.md index 0db557e7f9..421fa8b41e 100644 --- a/packages/css/src/components/logo/README.md +++ b/packages/css/src/components/logo/README.md @@ -34,7 +34,7 @@ The sub-brands are: - The logo links to the homepage of the website or application. - If the application is a form, application, or tool without a homepage, the logo links to the page where the form, application, or tool is referred to. -The height of the logo is always 40 pixels. +The logo is 40 pixels tall at its minimum, growing to 56 pixels in wider windows. This also applies to sub-brand logos. ## Download diff --git a/packages/css/src/components/logo/logo.scss b/packages/css/src/components/logo/logo.scss index 43247d6eaf..a4ef372767 100644 --- a/packages/css/src/components/logo/logo.scss +++ b/packages/css/src/components/logo/logo.scss @@ -6,6 +6,7 @@ .ams-logo { block-size: var(--ams-logo-block-size); display: block; + min-block-size: var(--ams-logo-min-block-size); } .ams-logo__emblem { diff --git a/packages/react/src/Header/Header.test.tsx b/packages/react/src/Header/Header.test.tsx index 663342a1ec..891f8554ed 100644 --- a/packages/react/src/Header/Header.test.tsx +++ b/packages/react/src/Header/Header.test.tsx @@ -1,5 +1,7 @@ import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' import { createRef } from 'react' +import './matchMedia.mock' // Must be imported before Header import { Header } from './Header' import '@testing-library/jest-dom' @@ -40,7 +42,31 @@ describe('Header', () => { expect(ref.current).toBe(component) }) - it('renders with a logo link', () => { + it('renders a brand section', () => { + const { container } = render(
) + + const component = container.querySelector('.ams-header__branding') + + expect(component).toBeInTheDocument() + }) + + it('renders a logo link', () => { + render(
) + + const component = screen.getByRole('link') + + expect(component).toHaveClass('ams-header__logo-link') + }) + + it('renders a different brand logo', () => { + const { container } = render(
) + + const component = container.querySelector('.ams-logo__text-secondary') + + expect(component).toBeInTheDocument() + }) + + it('renders a custom logo link', () => { render(
) const logoLink = screen.getByRole('link') @@ -48,16 +74,16 @@ describe('Header', () => { expect(logoLink).toHaveAttribute('href', '/home') }) - it('renders with a logo link title', () => { + it('renders a custom logo link title', () => { render(
) const logoLinkTitle = screen.getByRole('link', { name: 'Go to homepage' }) - expect(logoLinkTitle).toHaveTextContent('Go to homepage') + expect(logoLinkTitle).toBeInTheDocument() }) it('renders an application name', () => { - render(
) + render(
) const heading = screen.getByRole('heading', { name: 'Application name', @@ -67,20 +93,103 @@ describe('Header', () => { expect(heading).toBeInTheDocument() }) - it('renders with links', () => { - const { container } = render(
Test content} />) + it('renders a nav section', () => { + render(
Test
) + + const component = screen.getByRole('navigation') + + expect(component).toHaveClass('ams-header__navigation') + }) + + it('renders a nav section with a custom label', () => { + render(
Test
) + + const component = screen.getByRole('navigation', { name: 'Custom Navigation' }) - const menu = container.querySelector('.ams-header__links') + expect(component).toBeInTheDocument() + }) + + it('renders a menu', () => { + render(
Menu Item} />) + + const component = screen.getByRole('list') + + expect(component).toHaveClass('ams-header__menu') + }) - expect(menu).toBeInTheDocument() - expect(menu).toHaveTextContent('Test content') + it('renders menu items', () => { + render( +
+ Menu Item 1 + , + + Menu Item 2 + , + ]} + />, + ) + + const item1 = screen.getByRole('link', { name: 'Menu Item 1' }) + const item2 = screen.getByRole('link', { name: 'Menu Item 2' }) + + expect(item1).toBeInTheDocument() + expect(item2).toBeInTheDocument() }) - it('renders with menu button', () => { - render(
Menu Button} />) + it('renders a menu button', () => { + render(
Test
) - const menu = screen.getByRole('button') + const component = screen.getByRole('button', { name: 'Menu' }) + + expect(component).toHaveClass('ams-header__mega-menu-button') + }) + + it('renders a menu button icon', () => { + const { container } = render(
Test
) + + const component = container.querySelector('.ams-header__menu-icon') + + expect(component).toBeInTheDocument() + }) + + it('renders a custom menu button text', () => { + render(
Test
) + + const component = screen.getByRole('button', { name: 'Custom button text' }) + + expect(component).toBeInTheDocument() + }) + + it('renders the correct class when noMenuButtonOnWideWindow is true', () => { + render(
Test
) + + const component = screen.getByRole('listitem') + + expect(component).toHaveClass('ams-header__mega-menu-button-item--hide-on-wide-window') + }) + + it('opens and closes the mega menu', async () => { + const user = userEvent.setup() + + const { container } = render(
Test
) + + const closedMegaMenu = container.querySelector('.ams-header__mega-menu--closed') + + expect(closedMegaMenu).toBeInTheDocument() + + const menuButton = screen.getByRole('button', { name: 'Menu' }) + + await user.click(menuButton) + + const openMegaMenu = container.querySelector('.ams-header__mega-menu') + + expect(openMegaMenu).toBeInTheDocument() + expect(openMegaMenu).not.toHaveClass('ams-header__mega-menu--closed') + }) - expect(menu).toBeInTheDocument() + it.skip('it closes the mega menu when it is open and the screen width passes the breakpoint', () => { + // TODO: Add this test }) }) diff --git a/packages/react/src/Header/Header.tsx b/packages/react/src/Header/Header.tsx index 907c46b57b..64c3733430 100644 --- a/packages/react/src/Header/Header.tsx +++ b/packages/react/src/Header/Header.tsx @@ -4,61 +4,142 @@ */ import clsx from 'clsx' -import { forwardRef } from 'react' +import { forwardRef, useEffect, useState } from 'react' import type { ForwardedRef, HTMLAttributes, ReactNode } from 'react' import { Heading } from '../Heading' +import { Icon } from '../Icon' import { Logo } from '../Logo' import type { LogoBrand } from '../Logo' +import { HeaderGridCellNarrowWindowOnly } from './HeaderGridCellNarrowWindowOnly' +import { HeaderMenuIcon } from './HeaderMenuIcon' +import { HeaderMenuLink } from './HeaderMenuLink' +import useIsAfterBreakpoint from '../common/useIsAfterBreakpoint' export type HeaderProps = { /** The name of the application. */ - appName?: string - /** The list of menu links. Use a Page Menu here. */ - links?: ReactNode + brandName?: string /** The name of the brand for which to display the logo. */ logoBrand?: LogoBrand /** The url for the link on the logo. */ logoLink?: string /** The accessible text for the link on the logo. */ logoLinkTitle?: string - /** A button to toggle the visibility of a Mega Menu. */ - menu?: ReactNode + /** A slot for the menu items. Use Header.MenuLink here. */ + menuItems?: ReactNode + /** The text for the menu button. */ + menuButtonText?: string + /** The accessible label for the navigation section. */ + navigationLabel?: string + /** Whether the menu button is visible on wide screens. */ + noMenuButtonOnWideWindow?: boolean } & HTMLAttributes -export const Header = forwardRef( +const HeaderRoot = forwardRef( ( { - appName, + brandName, className, - links, + children, logoBrand = 'amsterdam', logoLink = '/', logoLinkTitle = 'Ga naar de homepage', - menu, + menuItems, + menuButtonText = 'Menu', + navigationLabel = 'Hoofdnavigatie', + noMenuButtonOnWideWindow, ...restProps }: HeaderProps, ref: ForwardedRef, ) => { + const [open, setOpen] = useState(false) + + const isWideWindow = useIsAfterBreakpoint('wide') + + useEffect(() => { + // Close the menu when the menu button disappears + if (noMenuButtonOnWideWindow && isWideWindow) { + setOpen(false) + } + }, [isWideWindow]) + return ( - <> -
- +
+
+ {logoLinkTitle} - {links &&
{links}
} - {menu &&
{menu}
} - {appName && ( -
- - {appName} - -
+ {brandName && ( + + {brandName} + )} -
- + + {(children || menuItems) && ( + + )} +
) }, ) -Header.displayName = 'Header' +HeaderRoot.displayName = 'Header' + +export const Header = Object.assign(HeaderRoot, { + GridCellNarrowWindowOnly: HeaderGridCellNarrowWindowOnly, + MenuLink: HeaderMenuLink, +}) diff --git a/packages/react/src/Header/HeaderGridCellNarrowWindowOnly.test.tsx b/packages/react/src/Header/HeaderGridCellNarrowWindowOnly.test.tsx new file mode 100644 index 0000000000..08e552a530 --- /dev/null +++ b/packages/react/src/Header/HeaderGridCellNarrowWindowOnly.test.tsx @@ -0,0 +1,49 @@ +import { render } from '@testing-library/react' +import { createRef } from 'react' +import { HeaderGridCellNarrowWindowOnly } from './HeaderGridCellNarrowWindowOnly' +import '@testing-library/jest-dom' + +describe('Header narrow screen only grid cell', () => { + it('renders', () => { + const { container } = render() + + const component = container.querySelector(':only-child') + + expect(component).toBeInTheDocument() + expect(component).toBeVisible() + }) + + it('renders a Grid.Cell', () => { + const { container } = render() + + const component = container.querySelector(':only-child') + + expect(component).toHaveClass('ams-grid__cell') + }) + + it('renders a design system BEM class name', () => { + const { container } = render() + + const component = container.querySelector(':only-child') + + expect(component).toHaveClass('ams-header__grid-cell-narrow-window-only') + }) + + it('renders an additional class name', () => { + const { container } = render() + + const component = container.querySelector(':only-child') + + expect(component).toHaveClass('ams-header__grid-cell-narrow-window-only extra') + }) + + it('supports ForwardRef in React', () => { + const ref = createRef() + + const { container } = render() + + const component = container.querySelector(':only-child') + + expect(ref.current).toBe(component) + }) +}) diff --git a/packages/react/src/Header/HeaderGridCellNarrowWindowOnly.tsx b/packages/react/src/Header/HeaderGridCellNarrowWindowOnly.tsx new file mode 100644 index 0000000000..148f1f6b11 --- /dev/null +++ b/packages/react/src/Header/HeaderGridCellNarrowWindowOnly.tsx @@ -0,0 +1,13 @@ +import clsx from 'clsx' +import { ForwardedRef, forwardRef } from 'react' +import { Grid, GridCellProps } from '../Grid' + +export const HeaderGridCellNarrowWindowOnly = forwardRef( + ({ children, className, ...restProps }: GridCellProps, ref: ForwardedRef) => ( + + {children} + + ), +) + +HeaderGridCellNarrowWindowOnly.displayName = 'Header.GridCellNarrowWindowOnly' diff --git a/packages/react/src/Header/HeaderMenuIcon.tsx b/packages/react/src/Header/HeaderMenuIcon.tsx new file mode 100644 index 0000000000..3de326c087 --- /dev/null +++ b/packages/react/src/Header/HeaderMenuIcon.tsx @@ -0,0 +1,9 @@ +import type { SVGProps } from 'react' + +export const HeaderMenuIcon = (props: SVGProps) => ( + +) diff --git a/packages/react/src/Header/HeaderMenuLink.test.tsx b/packages/react/src/Header/HeaderMenuLink.test.tsx new file mode 100644 index 0000000000..5bcdceb0d1 --- /dev/null +++ b/packages/react/src/Header/HeaderMenuLink.test.tsx @@ -0,0 +1,61 @@ +import { render, screen } from '@testing-library/react' +import { createRef } from 'react' +import { HeaderMenuLink } from './HeaderMenuLink' +import '@testing-library/jest-dom' + +describe('Header menu link', () => { + it('renders', () => { + render() + + const component = screen.getByRole('listitem') + + expect(component).toBeInTheDocument() + expect(component).toBeVisible() + }) + + it('renders a design system BEM class name', () => { + render() + + const item = screen.getByRole('listitem') + + expect(item).toHaveClass('ams-header__menu-item') + + const link = screen.getByRole('link') + + expect(link).toHaveClass('ams-header__menu-link') + }) + + it('renders an additional class name', () => { + render() + + const component = screen.getByRole('link') + + expect(component).toHaveClass('ams-header__menu-link extra') + }) + + it('renders the href attribute', () => { + render() + + const component = screen.getByRole('link') + + expect(component).toHaveAttribute('href', '/') + }) + + it('renders the ‘fixed’ prop classname', () => { + render() + + const component = screen.getByRole('listitem') + + expect(component).toHaveClass('ams-header__menu-item--fixed') + }) + + it('supports ForwardRef in React', () => { + const ref = createRef() + + render() + + const component = screen.getByRole('link') + + expect(ref.current).toBe(component) + }) +}) diff --git a/packages/react/src/Header/HeaderMenuLink.tsx b/packages/react/src/Header/HeaderMenuLink.tsx new file mode 100644 index 0000000000..487e873219 --- /dev/null +++ b/packages/react/src/Header/HeaderMenuLink.tsx @@ -0,0 +1,18 @@ +import clsx from 'clsx' +import { AnchorHTMLAttributes, ForwardedRef, forwardRef, PropsWithChildren } from 'react' + +export type HeaderMenuLinkProps = { + fixed?: boolean +} & PropsWithChildren> + +export const HeaderMenuLink = forwardRef( + ({ children, className, fixed, ...restProps }: HeaderMenuLinkProps, ref: ForwardedRef) => ( +
  • + + {children} + +
  • + ), +) + +HeaderMenuLink.displayName = 'Header.MenuLink' diff --git a/packages/react/src/Header/index.ts b/packages/react/src/Header/index.ts index acc1f0dbc9..9d50745641 100644 --- a/packages/react/src/Header/index.ts +++ b/packages/react/src/Header/index.ts @@ -1,2 +1,3 @@ export { Header } from './Header' export type { HeaderProps } from './Header' +export type { HeaderMenuLinkProps } from './HeaderMenuLink' diff --git a/packages/react/src/Header/matchMedia.mock.ts b/packages/react/src/Header/matchMedia.mock.ts new file mode 100644 index 0000000000..19da954af9 --- /dev/null +++ b/packages/react/src/Header/matchMedia.mock.ts @@ -0,0 +1,15 @@ +// Sourced from https://jestjs.io/docs/29.4/manual-mocks#mocking-methods-which-are-not-implemented-in-jsdom + +Object.defineProperty(window, 'matchMedia', { + writable: true, + value: jest.fn().mockImplementation((query) => ({ + matches: false, + media: query, + onchange: null, + addListener: jest.fn(), // deprecated + removeListener: jest.fn(), // deprecated + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + dispatchEvent: jest.fn(), + })), +}) diff --git a/packages/react/src/Icon/Icon.tsx b/packages/react/src/Icon/Icon.tsx index 5a8f8fe46d..306cce87d4 100644 --- a/packages/react/src/Icon/Icon.tsx +++ b/packages/react/src/Icon/Icon.tsx @@ -7,7 +7,7 @@ import clsx from 'clsx' import { forwardRef } from 'react' -import type { ForwardedRef, HTMLAttributes } from 'react' +import type { ForwardedRef, HTMLAttributes, ReactNode } from 'react' export type IconProps = { /** Changes the icon colour for readability on a dark background. */ @@ -17,7 +17,7 @@ export type IconProps = { /** Whether the icon container should be made square. */ square?: boolean /** The component rendering the icon’s markup. */ - svg: Function + svg: Function | ReactNode } & HTMLAttributes export const Icon = forwardRef( @@ -39,7 +39,7 @@ export const Icon = forwardRef( )} {...restProps} > - {svg()} + {typeof svg === 'function' ? svg() : svg} ), ) diff --git a/packages/react/src/common/useIsAfterBreakpoint.tsx b/packages/react/src/common/useIsAfterBreakpoint.tsx new file mode 100644 index 0000000000..5cb31fc0d2 --- /dev/null +++ b/packages/react/src/common/useIsAfterBreakpoint.tsx @@ -0,0 +1,36 @@ +import { useLayoutEffect, useState } from 'react' + +// TODO: we should set the breakpoint in JS somewhere and render this and the sass variables from that +const breakpoints = { + medium: '36rem', + wide: '68rem', +} + +type useIsAfterBreakpointProps = 'medium' | 'wide' + +const useIsAfterBreakpoint = (breakpoint: useIsAfterBreakpointProps) => { + const [matches, setMatches] = useState(false) + + useLayoutEffect(() => { + // Check for window object to avoid SSR issues + if (breakpoint && typeof window !== 'undefined') { + const media = window.matchMedia(`(min-width: ${breakpoints[breakpoint]})`) + + if (media.matches !== matches) { + setMatches(media.matches) + } + + const listener = () => setMatches(media.matches) + + window.addEventListener('resize', listener) + + return () => window.removeEventListener('resize', listener) + } + + return undefined + }, [matches, breakpoint]) + + return matches +} + +export default useIsAfterBreakpoint diff --git a/proprietary/tokens/src/components/ams/header.tokens.json b/proprietary/tokens/src/components/ams/header.tokens.json index fe4f70d283..d95b0d98b0 100644 --- a/proprietary/tokens/src/components/ams/header.tokens.json +++ b/proprietary/tokens/src/components/ams/header.tokens.json @@ -1,13 +1,55 @@ { "ams": { "header": { - "column-gap": { - "value": "{ams.space.grid.md}", - "comment": "Must have the same value as `ams.grid.column-gap`." + "padding-block": { "value": "{ams.space.grid.sm}" }, + "padding-inline": { + "value": "{ams.grid.padding-inline}", + "comment": "Must be the Grid inline padding, to make sure Header and Grid line up" + }, + "branding": { + "column-gap": { "value": "{ams.space.md}" }, + "row-gap": { "value": "{ams.space.grid.xs}" } }, - "padding-block": { "value": "{ams.space.md}" }, "logo-link": { "outline-offset": { "value": "{ams.focus.outline-offset}" } + }, + "mega-menu": { + "button": { + "label": { + "open": { + "font-weight": { "value": "{ams.text.font-weight.bold}" } + } + } + } + }, + "menu": { + "column-gap": { "value": "{ams.space.lg}" }, + "row-gap": { "value": "{ams.space.xs}" }, + "item": { + "color": { "value": "{ams.link-appearance.color}" }, + "column-gap": { "value": "{ams.space.xs}" }, + "font-family": { "value": "{ams.text.font-family}" }, + "font-size": { "value": "{ams.text.level.5.font-size}" }, + "font-weight": { "value": "{ams.text.font-weight.normal}" }, + "line-height": { "value": "{ams.text.level.5.line-height}" }, + "outline-offset": { "value": "{ams.focus.outline-offset}" }, + "padding-block": { "value": "{ams.space.xs}" }, + "hover": { + "color": { "value": "{ams.link-appearance.hover.color}" } + } + }, + "link": { + "text-decoration-line": { "value": "{ams.link-appearance.subtle.text-decoration-line}" }, + "text-decoration-thickness": { "value": "{ams.link-appearance.text-decoration-thickness}" }, + "text-underline-offset": { "value": "{ams.link-appearance.text-underline-offset}" }, + "hover": { + "text-decoration-line": { "value": "{ams.link-appearance.subtle.hover.text-decoration-line}" } + } + } + }, + "navigation": { + "column-gap": { "value": "{ams.space.lg}" }, + "row-gap": { "value": "{ams.space.grid.md}" } } } } diff --git a/proprietary/tokens/src/components/ams/logo.tokens.json b/proprietary/tokens/src/components/ams/logo.tokens.json index c4f9ac41e2..5dc3df14cf 100644 --- a/proprietary/tokens/src/components/ams/logo.tokens.json +++ b/proprietary/tokens/src/components/ams/logo.tokens.json @@ -1,8 +1,12 @@ { "ams": { "logo": { - "block-size": { "value": "2.5rem" }, + "block-size": { + "value": "clamp(1rem, calc(0.375rem + 3.125vw), 3.5rem)", + "comment": "This is the same size as Grid space medium" + }, "emblem": { "color": { "value": "{ams.brand.color.red.60}" } }, + "min-block-size": { "value": "2.5rem" }, "title": { "color": { "value": "{ams.brand.color.red.60}" } }, "subsite": { "color": { "value": "{ams.brand.color.neutral.100}" } } } diff --git a/storybook/src/components/Avatar/Avatar.docs.mdx b/storybook/src/components/Avatar/Avatar.docs.mdx index c74868519c..9a799bd542 100644 --- a/storybook/src/components/Avatar/Avatar.docs.mdx +++ b/storybook/src/components/Avatar/Avatar.docs.mdx @@ -26,7 +26,3 @@ Make sure to scale the image down to around 100 pixels to prevent unnecessary da A user icon displays if no label and image are provided. - -### In a Header - - diff --git a/storybook/src/components/Avatar/Avatar.stories.tsx b/storybook/src/components/Avatar/Avatar.stories.tsx index f9b55adb73..b63aa2cd2c 100644 --- a/storybook/src/components/Avatar/Avatar.stories.tsx +++ b/storybook/src/components/Avatar/Avatar.stories.tsx @@ -3,9 +3,7 @@ * Copyright Gemeente Amsterdam */ -import { Header, PageMenu } from '@amsterdam/design-system-react' import { Avatar } from '@amsterdam/design-system-react/src' -import { SearchIcon } from '@amsterdam/design-system-react-icons' import { Meta, StoryObj } from '@storybook/react' const meta = { @@ -36,24 +34,3 @@ export const FallbackIcon: Story = { label: '', }, } - -export const InAHeader: Story = { - args: { - label: 'DS', - }, - render: (args) => ( -
    - Contact - - Zoeken - -
  • - -
  • - - } - /> - ), -} diff --git a/storybook/src/components/Header/Header.docs.mdx b/storybook/src/components/Header/Header.docs.mdx index a6aa627823..5bafc981cd 100644 --- a/storybook/src/components/Header/Header.docs.mdx +++ b/storybook/src/components/Header/Header.docs.mdx @@ -17,33 +17,44 @@ import { StatusBadge } from "../../docs/components/StatusBadge"; ## Examples -### For a sub-brand +### With moving links - +Links can move from the inline menu to the collapsible one on narrow windows. +A `MenuLink` is hidden by default on narrow windows. +Use `GridCellNarrowWindowOnly` to show that same link in the collapsible menu on narrow windows. -### With app name +If you do not want the `MenuLink` to be hidden, use the `fixed` prop. - +In this example, ‘English’ moves to the collapsible menu, while ‘Zoeken’ does not. -### With menu button + - +### Without menu button on wide windows -### With links +If you only have a few links, you may not always need a collapsible menu. +Use `noMenuButtonOnWideWindow` to hide the menu button on wide windows. +On narrow windows, the menu button will still be visible, +so do not forget to add the links to the collapsible menu. -Use a [Page Menu](/docs/components-navigation-page-menu--docs) to add links. -A Page Menu in the Header should not wrap. + - +### Without menu button -### With links and menu button +In some cases, a collapsible menu might not be necessary. +If the Header has no `children`, the menu button will not appear. +Remember to use the `fixed` prop if you want the inline menu links to stay in place. - + -### With app name and menu button +### With custom logo link - +The destination and accessible text of the logo link can be customized, +as well as the logo itself. -### With app name, links and menu button + - +### With custom texts + +The text of the menu button and the accessible navigation description can be customized. + + diff --git a/storybook/src/components/Header/Header.stories.tsx b/storybook/src/components/Header/Header.stories.tsx index 8c08a33001..ac3fc5be6f 100644 --- a/storybook/src/components/Header/Header.stories.tsx +++ b/storybook/src/components/Header/Header.stories.tsx @@ -3,9 +3,8 @@ * Copyright Gemeente Amsterdam */ -import { PageMenu } from '@amsterdam/design-system-react' +import { Grid, Heading, LinkList } from '@amsterdam/design-system-react' import { Header } from '@amsterdam/design-system-react/src' -import { SearchIcon } from '@amsterdam/design-system-react-icons' import { Meta, StoryObj } from '@storybook/react' const meta = { @@ -17,76 +16,181 @@ export default meta type Story = StoryObj -export const Default: Story = {} +const defaultStoryLinks = [ + [ + { label: 'Kaart', href: '#' }, + { label: 'Panoramabeelden', href: '#' }, + { label: 'Tabellen', href: '#' }, + { label: 'Catalogus (Beta)', href: '#' }, + { label: 'Kaarten', href: '#' }, + { label: 'Datacatalogus', href: '#' }, + ], + [ + { label: 'Over de organisatie', href: '#' }, + { label: 'Over het dataplatform', href: '#' }, + ], + [ + { label: 'Help', href: '#' }, + { label: 'Contact', href: '#' }, + ], +] -export const ForSubBrand: Story = { +export const Default: Story = { args: { - logoBrand: 'ggd-amsterdam', - logoLink: 'https://www.ggd.amsterdam.nl/', - logoLinkTitle: 'Naar de homepage van de GGD Amsterdam', - }, -} - -export const WithAppName: Story = { - args: { - appName: 'Aan de Amsterdamse grachten', + brandName: 'Data Amsterdam', + menuItems: [ + + English + , + + Zoeken + , + ], + children: ( + + + + + English + + + + + + Onderdelen + + + {defaultStoryLinks[0].map(({ label, href }) => ( + + {label} + + ))} + + + + + Over ons + + + {defaultStoryLinks[1].map(({ label, href }) => ( + + {label} + + ))} + + + + + Help + + + {defaultStoryLinks[2].map(({ label, href }) => ( + + {label} + + ))} + + + + ), }, } -export const WithLinks: Story = { +export const WithMovingLinks: Story = { args: { - links: ( - - Contact - Mijn Amsterdam - - Zoeken - - + menuItems: [ + + English + , + + Zoeken + , + ], + children: ( + + + + + English + + + + + + Regular collapsible menu link + + + ), }, } -export const WithMenuButton: Story = { +const WithoutMenuButtonOnWideWindowStoryLinks = [ + { label: 'Stad', href: '#' }, + { label: 'Techniek', href: '#' }, + { label: 'Historie', href: '#' }, + { label: 'Duurzaamheid', href: '#' }, +] + +export const WithoutMenuButtonOnWideWindow: Story = { args: { - menu: , + brandName: 'Aan de Amsterdamse grachten', + menuItems: [ + ...WithoutMenuButtonOnWideWindowStoryLinks.map(({ label, href }) => ( + + {label} + + )), + + Zoeken + , + ], + noMenuButtonOnWideWindow: true, + children: ( + + {WithoutMenuButtonOnWideWindowStoryLinks.map(({ label, href }) => ( + + {label} + + ))} + + ), }, } -export const WithLinksAndMenuButton: Story = { +export const WithoutMenuButton: Story = { args: { - links: ( - - Contact - Mijn Amsterdam - - Zoeken - - + brandName: 'Mijn Amsterdam', + menuItems: ( + + Inloggen + ), - menu: , }, } -export const WithAppNameAndMenuButton: Story = { +export const WithCustomLogoLink: Story = { args: { - appName: 'Aan de Amsterdamse grachten', - menu: , + logoBrand: 'ggd-amsterdam', + logoLink: 'https://www.ggd.amsterdam.nl/', + logoLinkTitle: 'Naar de homepage van de GGD Amsterdam', }, } -export const WithAppNameLinksAndMenuButton: Story = { +export const WithCustomTexts: Story = { args: { - appName: 'Aan de Amsterdamse grachten', - links: ( - - Contact - Mijn Amsterdam - - Zoeken - - + menuButtonText: 'Hoofdmenu', + navigationLabel: 'Navigatie', + children: ( + + + + + English + + + + ), - menu: , }, } diff --git a/storybook/src/pages/amsterdam/common/AppHeader.tsx b/storybook/src/pages/amsterdam/common/AppHeader.tsx index 67d990ee09..0bc3c3f5eb 100644 --- a/storybook/src/pages/amsterdam/common/AppHeader.tsx +++ b/storybook/src/pages/amsterdam/common/AppHeader.tsx @@ -1,13 +1,25 @@ -import { Grid, Header, PageMenu } from '@amsterdam/design-system-react' -import { Alignment as PageMenuStory } from '../../../components/PageMenu/PageMenu.stories' +import { Header, LinkList } from '@amsterdam/design-system-react' export const AppHeader = () => ( - - -
    {PageMenuStory.args?.children}} - menu={} - /> - - +
    + English + , + + Mijn Amsterdam + , + + Zoeken + , + ]} + > + + + English + + Mijn Amsterdam + +
    )