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 (
- <>
-
-
+
+