diff --git a/.storybook/preview-head.html b/.storybook/preview-head.html index 886f719d04..cb6f59345b 100644 --- a/.storybook/preview-head.html +++ b/.storybook/preview-head.html @@ -5,24 +5,48 @@ + vertical inset for focus plus small horizontal inset so outlines are not clipped at edges. + Full-bleed stories pass `sbNestedCanvasPadding=flush` (via `parameters.sbNestedCanvasPadding` + in preview.js) to use zero inset instead. + SecondaryNav: cfgov negative h-margins are overridden in nested mode. Default root uses + horizontal padding so :focus-visible rings are not clipped; the nav bleeds by the same + amount so the bar still spans the full iframe. The mobile header uses a negative + outline-offset so the dotted focus ring sits slightly inside the tap target. --> + \ No newline at end of file diff --git a/.storybook/preview.js b/.storybook/preview.js index 2cf84c0fd7..75257e3933 100644 --- a/.storybook/preview.js +++ b/.storybook/preview.js @@ -200,7 +200,12 @@ const ResponsivePreviewFrame = ({ previewSrc, viewport }) => { * * @param {{ context: import('storybook/internal/types').StoryContext, nestedCanvasPaddingMode: 'focus' | 'flush', args: Record, globals: Record }} props */ -const AllViewportsPreviews = ({ context, nestedCanvasPaddingMode, args, globals }) => { +const AllViewportsPreviews = ({ + context, + nestedCanvasPaddingMode, + args, + globals, +}) => { const argsParam = buildNestedQueryParam(context.initialArgs, args); const globalsParam = buildNestedQueryParam(context.initialGlobals, globals); const previewSrc = getPreviewSource(context.id, nestedCanvasPaddingMode, { @@ -258,7 +263,9 @@ const renderResponsivePreviews = (Story, context) => { return React.createElement(Story); } - const nestedCanvasPaddingMode = getNestedCanvasPaddingMode(context.parameters); + const nestedCanvasPaddingMode = getNestedCanvasPaddingMode( + context.parameters, + ); return React.createElement(AllViewportsPreviews, { context, diff --git a/src/assets/styles/_shared.scss b/src/assets/styles/_shared.scss index 49f1dd6950..d9c6f7ff2a 100644 --- a/src/assets/styles/_shared.scss +++ b/src/assets/styles/_shared.scss @@ -12,7 +12,9 @@ */ /** - * Source Sans 3 is loaded via @fontsource in app / Storybook preview entrypoints. + * Source Sans 3 is loaded via `@fontsource-variable/source-sans-3` in **`src/index.ts`** (apps) + * and **`.storybook/preview.js`** (Storybook, including nested “All viewports” iframes) before + * `_shared.scss` so `@font-face` is always registered. * * DS `custom-props` sets `--font-stack-branded: initial`, so `var(--font-stack)` falls back to * `system-ui`. Other DS chunks can repeat that `:root` block when code-split; in dev the last diff --git a/src/components/Hero/hero.stories.tsx b/src/components/Hero/hero.stories.tsx index e9c08e09ce..fb166e7e7a 100644 --- a/src/components/Hero/hero.stories.tsx +++ b/src/components/Hero/hero.stories.tsx @@ -10,9 +10,11 @@ const meta: Meta = { docs: { description: { component: ` -Heroes are a primary focal point on landing and sublanding pages. They introduce a collection of pages by combining a brief description of the goals of that section along with a visually impactful graphic. To introduce lower-level pages, use the [text introduction](https://cfpb.github.io/design-system/patterns/text-introductions) instead. - -This component supports illustration, photograph (overlay), and knockout variants only — not the DS jumbo or 50/50 patterns. +Heroes are a primary focal point on landing and sublanding pages. They +introduce a collection of pages by combining a brief description of the goals +of that section along with a visually impactful graphic. To introduce +lower-level pages, use the +[text introduction](https://cfpb.github.io/design-system/patterns/text-introductions) instead. Source: https://cfpb.github.io/design-system/patterns/heroes `, @@ -26,8 +28,9 @@ export default meta; type Story = StoryObj; export const WithIllustration: Story = { + name: 'With illustration', args: { - heading: '41 chars max for a one-line heading', + heading: '41 characters max for a one-line heading', image: 'https://cfpb.github.io/design-system/images/uploads/hero_illustration_example_keys.png', subheading: @@ -37,6 +40,7 @@ export const WithIllustration: Story = { }; export const WithPhotograph: Story = { + name: 'With photograph', args: { ...WithIllustration.args, imageIsPhoto: true, @@ -49,7 +53,7 @@ export const WithKnockoutText: Story = { name: 'With knockout text', args: { ...WithIllustration.args, - heading: 'Max of 41 chars for a one-line heading', + heading: '41 characters max for a one-line heading', subheading: 'This text has a recommended count of 165-186 characters (three lines at 1230px) following a one-line heading and 108-124 characters (two lines at 1230px) following a two-line heading.', backgroundColor: '#207676', diff --git a/src/components/Hero/hero.tsx b/src/components/Hero/hero.tsx index d7453795f7..90d3b3a7f7 100644 --- a/src/components/Hero/hero.tsx +++ b/src/components/Hero/hero.tsx @@ -23,6 +23,13 @@ interface HeroProperties extends HTMLAttributes { /** * https://cfpb.github.io/design-system/patterns/heroes + * + * The Jumbo and 50/50 hero variants, included in the CFPB Design System, + * and used on the CF.gov homepage are not included by this component. + * + * The implementation of the hero on https://www.consumerfinance.gov/es/ + * uses a combination of Jumbo and 50/50 hero variants including some tweaks in + * wagtail. */ export default function Hero({ backgroundColor, diff --git a/src/components/SecondaryNav/secondary-nav.scss b/src/components/SecondaryNav/secondary-nav.scss index b641817c24..03feedd4b8 100644 --- a/src/components/SecondaryNav/secondary-nav.scss +++ b/src/components/SecondaryNav/secondary-nav.scss @@ -1,72 +1,278 @@ -// Secondary navigation (left panel / "Navigate this section" pattern) -// Matches consumerfinance.gov compliance section sidebar -// Active = black 5px left border; hover = green 5px left border -// @see https://www.consumerfinance.gov/compliance/supervisory-highlights/ -// @see https://www.consumerfinance.gov/static/css/main.a624b7218b13.css +// 1:1 with consumerfinance.gov cfgov/unprocessed/css/organisms/secondary-nav.scss +// Units: px values in cfgov source are converted via math.div(..., $base-font-size-px) +// to em (padding, parent link size) or rem (header label). Compiled cf.gov CSS shows e.g. +// .875rem crumbs elsewhere; secondary-nav uses 1rem label, 1.125em parent links, 0.625em/0.9375em header padding. +// @see https://github.com/cfpb/consumerfinance.gov/blob/main/cfgov/unprocessed/css/organisms/secondary-nav.scss +@use 'sass:math'; +@use '@cfpb/cfpb-design-system/src/elements/abstracts' as *; +@use '@cfpb/cfpb-design-system/src/utilities' as *; .o-secondary-nav { + // + // Header + // + &__header { + display: flex; + justify-content: space-between; + border: 0; + cursor: pointer; + padding: (math.div(10px, $base-font-size-px) + em) + (math.div(15px, $base-font-size-px) + em); + + &:focus { + outline: 1px dotted var(--black); + outline-offset: 1px; + } + + .o-secondary-nav__cue-close, + .o-secondary-nav__cue-open { + display: none; + } + + &[aria-expanded='false'] .o-secondary-nav__cue-open { + display: block; + } + + &[aria-expanded='true'] .o-secondary-nav__cue-close { + display: block; + } + } + + // Using the button element with .o-secondary-nav__header requires setting + // an explicit width. + button.o-secondary-nav__header { + background-color: transparent; + width: 100%; + text-align: left; + } + + &__cues { + min-width: 60px; + text-align: right; + color: var(--pacific); + font-size: math.div($btn-font-size, $base-font-size-px) + em; + line-height: math.div($base-line-height-px, $btn-font-size); + } + + &__label { + // Grow to available width. + flex-grow: 1; + + font-size: math.div(16px, $base-font-size-px) + rem; + font-weight: 600; + letter-spacing: 1px; + color: var(--pacific); + + line-height: math.div(22px, $size-v); + margin-bottom: 0; + } + + &__content { + padding: math.div(15px, $base-font-size-px) + em; + padding-top: 0; + + // The divider between __header and __content. + &::before { + content: ''; + display: block; + border-top: 1px solid var(--gray-40); + padding-top: math.div(15px, $base-font-size-px) + em; + } + + &::after { + padding-bottom: math.div(15px, $base-font-size-px) + em; + width: 100%; + } + } + &__list { + padding-left: 0; list-style: none; - margin: 0; - padding: 0; - &--children { - padding-left: 0.9375rem; + > li { + margin-left: 0; } } - &__item { - margin: 0; - padding: 0; + &__list--children { + margin-left: math.div(math.div($grid-gutter-width, 2), $base-font-size-px) + + em; + + // Desktop and above. + @include respond-to-min($bp-med-min) { + // Add 5px for the border to half the gutter + margin-left: math.div( + math.div($grid-gutter-width, 2) + 5px, + $base-font-size-px + ) + + em; + } } &__link { - display: block; - padding: 0.5rem 0 0.5rem 0.9375rem; - color: var(--pacific); - text-decoration: none; - border: solid transparent; - border-width: 0 0 0 5px; + display: inline-block; + + // Break the menu word when it is too wide to fit in the sidebar area. + // These two values usurp the deprecated `word-break: break-word;`. overflow-wrap: anywhere; word-break: normal; - &:hover, - &:focus { - border-left-color: var(--green); - color: var(--black); - text-decoration-color: var(--green); + border-style: solid; + border-left-width: 5px; + border-top-width: 0; + border-bottom-width: 0; + border-right-width: 0; + border-color: transparent; + + &:hover { + border-color: var(--green); } &:focus { - outline: 1px dotted var(--pacific); + display: block; outline-offset: -1px; } - &:visited { - color: var(--pacific); - text-decoration-color: transparent; + @include u-link-colors( + var(--pacific), + var(--pacific), + var(--black), + var(--black), + var(--black), + transparent, + transparent, + var(--green), + var(--green), + var(--green) + ); + + // Tablet and below. + @include respond-to-max($bp-sm-max) { + display: block; + + padding: math.div(math.div($grid-gutter-width, 2), $base-font-size-px) + + em; + } + + // Desktop and above. + @include respond-to-min($bp-med-min) { + padding-top: math.div(10px, $base-font-size-px) + em; + padding-bottom: math.div(10px, $base-font-size-px) + em; + padding-left: math.div( + math.div($grid-gutter-width, 2), + $base-font-size-px + ) + + em; } &--current { - border-left-color: var(--black); - color: var(--black); - cursor: text; - text-decoration: none; - text-decoration-color: var(--black); - - &:hover, - &:focus, - &:visited { - border-left-color: var(--black); - color: var(--black); - text-decoration: none; - text-decoration-color: var(--black); - } + border-color: var(--black); + + @include u-link-colors( + var(--black), + var(--black), + var(--black), + var(--black), + var(--black), + var(--black), + var(--black), + var(--black), + var(--black), + var(--black) + ); } &--parent { - font-size: 18px; - font-weight: 500; + margin-bottom: inherit; + + @include heading-4($has-margin-bottom: false, $is-responsive: false); + } + } + + // Tablet and below. + @include respond-to-max($bp-sm-max) { + background: var(--gray-5); + border-bottom: 1px solid var(--gray-40); + margin-left: -0.9375rem; + margin-right: -0.9375rem; + + // Add drop-shadow. + box-shadow: 0 5px 5px rgb(0, 0, 0, 20%); + + + // cfgov initializes FlyoutMenu + MaxHeightTransition in SecondaryNav.js. + // Collapse content from header state when that JS is not running. + &__header[aria-expanded='false'] ~ &__content { + display: none; + } + } + + // Desktop and above. + @include respond-to-min($bp-med-min) { + .o-secondary-nav { + background: none; + + &__header { + display: none; + } + + &__content { + // These two !important values override basic expandable styling, + // because these do not function like expandables on med+ screens. + display: block !important; + max-height: 100% !important; + padding: 0; + + &::before { + display: none; + } + } + } + } + + // Don't print the secondary navigation. + @media print { + display: none; + } +} + +// Right-to-left (RTL) layout. +html[lang='ar'] { + .o-secondary-nav { + button.o-secondary-nav__header { + text-align: right; + } + + &__cues { + text-align: left; + } + + &__list--parents { + padding-right: 0; + } + + &__link { + border-left-width: 0; + border-right-width: 5px; + } + + // Desktop and above. + @include respond-to-min($bp-med-min) { + &__link { + padding-right: math.div( + math.div($grid-gutter-width, 2), + $base-font-size-px + ) + + em; + } + + &__list--parents { + padding-right: math.div( + math.div($grid-gutter-width, 2), + $base-font-size-px + ) + + em; + } } } } diff --git a/src/components/SecondaryNav/secondary-nav.stories.tsx b/src/components/SecondaryNav/secondary-nav.stories.tsx index 26f12f56ec..ebeac6fadc 100644 --- a/src/components/SecondaryNav/secondary-nav.stories.tsx +++ b/src/components/SecondaryNav/secondary-nav.stories.tsx @@ -10,20 +10,16 @@ const meta: Meta = { docs: { description: { component: ` -Secondary navigation for in-page or section navigation, typically shown in a left sidebar. -Matches the "Navigate this section" pattern used on [consumerfinance.gov](https://www.consumerfinance.gov/compliance/supervisory-highlights/). +The secondary navigation component provides a left-hand navigation menu that can be used for in-page or section navigation. -### Usage - -- Pass \`items\` with \`href\`, \`label\`, and optional \`isActive\` for the current page. -- Items can have optional \`children\` for sub-menu items. Parent items with children can omit \`href\` when active (section header). -- Use \`ariaLabel\` to describe the nav for screen readers. +Source: https://cfpb.github.io/design-system/development/main-content-and-sidebars `, }, }, }, argTypes: { ariaLabel: { control: 'text' }, + mobileToggleLabel: { control: 'text' }, }, }; @@ -31,74 +27,94 @@ export default meta; type Story = StoryObj; -const defaultItems: SecondaryNavItem[] = [ - { href: '#section-1', label: 'Section 1' }, - { href: '#section-2', label: 'Section 2', isActive: true }, - { href: '#section-3', label: 'Section 3' }, - { href: '#section-4', label: 'Section 4' }, - { href: '#section-5', label: 'Section 5' }, - { href: '#section-6', label: 'Section 6' }, - { href: '#section-7', label: 'Section 7' }, +/** 1. Flat links only; none marked current. */ +const basicNoChildren: SecondaryNavItem[] = [ + { href: '#', label: 'Section A' }, + { href: '#', label: 'Section B' }, + { href: '#', label: 'Section C' }, +]; + +/** 2. Flat list; one top-level item is the current page. */ +const basicNoChildrenWithCurrent: SecondaryNavItem[] = [ + { href: '#', label: 'Section A' }, + { href: '#', label: 'Section B', isActive: true }, + { href: '#', label: 'Section C' }, +]; + +/** 3. Nested items; no \`isActive\` on parents or children. */ +const withChildrenNoActive: SecondaryNavItem[] = [ + { + label: 'Section 1', + href: '#', + children: [ + { href: '#', label: 'Item A' }, + { href: '#', label: 'Item B' }, + ], + }, + { href: '#', label: 'Section 2' }, + { href: '#', label: 'Section 3' }, +]; + +/** 4. Current page is the parent “index”; children are links but none are active. */ +const withChildrenActiveParent: SecondaryNavItem[] = [ + { + label: 'Section 1', + href: '#', + isActive: true, + children: [ + { href: '#', label: 'Item A' }, + { href: '#', label: 'Item B' }, + ], + }, + { href: '#', label: 'Section 2' }, ]; -const itemsWithSubMenu: SecondaryNavItem[] = [ +/** 5. Typical subpage: one child is the current page. */ +const withChildrenActiveChild: SecondaryNavItem[] = [ { label: 'Section 1', - href: '/section-1', + href: '#', children: [ - { href: '/section-1/item-a', label: 'Item A', isActive: true }, - { href: '/section-1/item-b', label: 'Item B' }, - { href: '/section-1/item-c', label: 'Item C' }, + { href: '#', label: 'Item A', isActive: true }, + { href: '#', label: 'Item B' }, + { href: '#', label: 'Item C' }, ], }, - { href: '/section-2', label: 'Section 2' }, - { href: '/section-3', label: 'Section 3' }, - { href: '/section-4', label: 'Section 4' }, - { href: '/section-5', label: 'Section 5' }, - { href: '/section-6', label: 'Section 6' }, - { href: '/section-7', label: 'Section 7' }, + { href: '#', label: 'Section 2' }, + { href: '#', label: 'Section 3' }, ]; -export const Default: Story = { +export const BasicMenuNoChildren: Story = { + name: 'Basic', args: { - items: defaultItems, - ariaLabel: 'Page navigation', + items: basicNoChildren, }, - render: (args) => , }; -export const WithShortList: Story = { +export const BasicMenuNoChildrenOneActive: Story = { + name: 'One active item', args: { - items: [ - { href: '#overview', label: 'Overview' }, - { href: '#rules', label: 'Rules', isActive: true }, - { href: '#resources', label: 'Resources' }, - ], - ariaLabel: 'On this page', + items: basicNoChildrenWithCurrent, }, - render: (args) => , }; -export const WithSubMenu: Story = { +export const MenuWithChildrenNoActive: Story = { + name: 'With children', args: { - items: itemsWithSubMenu, - ariaLabel: 'Section', + items: withChildrenNoActive, }, - render: (args) => , }; -export const NoActiveItem: Story = { +export const MenuWithChildrenActiveParent: Story = { + name: 'With children, active parent', args: { - items: defaultItems.map(({ isActive: _isActive, ...item }) => item), - ariaLabel: 'Page navigation', + items: withChildrenActiveParent, }, - render: (args) => , }; -export const EmptyList: Story = { +export const MenuWithChildrenActiveChild: Story = { + name: 'With children, active child', args: { - items: [], - ariaLabel: 'Page navigation', + items: withChildrenActiveChild, }, - render: (args) => , }; diff --git a/src/components/SecondaryNav/secondary-nav.test.tsx b/src/components/SecondaryNav/secondary-nav.test.tsx index 3d3b1b5704..62c71c5906 100644 --- a/src/components/SecondaryNav/secondary-nav.test.tsx +++ b/src/components/SecondaryNav/secondary-nav.test.tsx @@ -1,7 +1,7 @@ import '@testing-library/jest-dom'; -import { render, screen } from '@testing-library/react'; -import { SecondaryNav } from './secondary-nav'; +import { fireEvent, render, screen } from '@testing-library/react'; import type { SecondaryNavItem } from './secondary-nav'; +import { SecondaryNav } from './secondary-nav'; describe('', () => { const defaultItems: SecondaryNavItem[] = [ @@ -12,7 +12,7 @@ describe('', () => { it('renders a nav with the default aria-label', () => { render(); - const nav = screen.getByRole('navigation', { name: 'Page navigation' }); + const nav = screen.getByRole('navigation', { name: 'Section' }); expect(nav).toBeInTheDocument(); expect(nav).toHaveClass('o-secondary-nav'); }); @@ -24,38 +24,43 @@ describe('', () => { ).toBeInTheDocument(); }); - it('renders all items as links; active link has aria-current', () => { + it('renders a mobile toggle button with aria-expanded', () => { render(); - const linkA = screen.getByRole('link', { name: 'Link A' }); - const linkB = screen.getByRole('link', { name: 'Link B' }); - const linkC = screen.getByRole('link', { name: 'Link C' }); - expect(linkA).toHaveAttribute('href', '/a'); - expect(linkB).toHaveAttribute('href', '/b'); - expect(linkB).toHaveAttribute('aria-current', 'page'); - expect(linkC).toHaveAttribute('href', '/c'); + const toggleButton = screen.getByTestId('secondary-nav-toggle'); + expect(toggleButton).toHaveClass('o-secondary-nav__header'); + expect(toggleButton).toHaveAttribute('aria-expanded', 'false'); }); - it('sets data-nav-is-active on the li for the active item', () => { + it('toggles aria-expanded when the button is clicked', () => { render(); - const listItems = screen.getAllByRole('listitem'); - expect(listItems).toHaveLength(3); - expect(listItems[0]).not.toHaveAttribute('data-nav-is-active'); - expect(listItems[1]).toHaveAttribute('data-nav-is-active', 'true'); - expect(listItems[2]).not.toHaveAttribute('data-nav-is-active'); + const toggleButton = screen.getByTestId('secondary-nav-toggle'); + fireEvent.click(toggleButton); + expect(toggleButton).toHaveAttribute('aria-expanded', 'true'); + fireEvent.click(toggleButton); + expect(toggleButton).toHaveAttribute('aria-expanded', 'false'); }); - it('renders no list when items is empty', () => { - render(); - expect(screen.queryByRole('list')).toBeNull(); + it('renders anchors; active item has no href and aria-current', () => { + render(); + const linkA = screen.getByRole('link', { name: 'Link A' }); + const linkC = screen.getByRole('link', { name: 'Link C' }); + expect(linkA).toHaveAttribute('href', '/a'); + expect(linkC).toHaveAttribute('href', '/c'); + + const current = screen.getByText('Link B'); + expect(current.tagName).toBe('A'); + expect(current).not.toHaveAttribute('href'); + expect(current).toHaveAttribute('aria-current', 'page'); }); it('applies custom className', () => { render(); - const nav = screen.getByRole('navigation', { name: 'Page navigation' }); + const nav = screen.getByRole('navigation', { name: 'Section' }); expect(nav).toHaveClass('o-secondary-nav'); expect(nav).toHaveClass('custom-nav'); }); + it('renders child items when parent has children', () => { const itemsWithChildren: SecondaryNavItem[] = [ { @@ -69,17 +74,12 @@ describe('', () => { ]; render(); expect(screen.getByText('Parent')).toBeInTheDocument(); - expect(screen.getByRole('link', { name: 'Child A' })).toHaveAttribute( - 'href', - '/child-a', - ); - expect(screen.getByRole('link', { name: 'Child B' })).toHaveAttribute( - 'href', - '/child-b', - ); - expect(screen.getByRole('link', { name: 'Child A' })).toHaveAttribute( - 'aria-current', - 'page', - ); + const childB = screen.getByRole('link', { name: 'Child B' }); + expect(childB).toHaveAttribute('href', '/child-b'); + + const childA = screen.getByText('Child A'); + expect(childA.tagName).toBe('A'); + expect(childA).not.toHaveAttribute('href'); + expect(childA).toHaveAttribute('aria-current', 'page'); }); }); diff --git a/src/components/SecondaryNav/secondary-nav.tsx b/src/components/SecondaryNav/secondary-nav.tsx index ba9e1903f2..b0ccb2bf51 100644 --- a/src/components/SecondaryNav/secondary-nav.tsx +++ b/src/components/SecondaryNav/secondary-nav.tsx @@ -1,7 +1,7 @@ import classnames from 'classnames'; import type { HTMLAttributes } from 'react'; -import { JSX } from 'react'; -import Link from '../Link/link'; +import { JSX, useEffect, useState } from 'react'; +import { Icon } from '../Icon/icon'; import './secondary-nav.scss'; export interface SecondaryNavChildItem { @@ -27,86 +27,165 @@ export interface SecondaryNavProperties extends HTMLAttributes { */ items: SecondaryNavItem[]; /** - * Accessible label for the nav landmark. Defaults to "Page navigation". + * Accessible label for the nav landmark. Matches cfgov gettext('Section'). */ ariaLabel?: string; + /** + * Label for the mobile header. Matches cfgov _('Navigate this section'). + */ + mobileToggleLabel?: string; } /** - * Secondary navigation (e.g. left panel "Navigate this section") for in-page or section navigation. - * Matches the pattern used on consumerfinance.gov compliance and other CFPB pages. + * Markup and classes match cfgov `secondary-nav.html` / `SecondaryNav.js` on + * consumerfinance.gov (FlyoutMenu + MaxHeightTransition are not initialized here; + * mobile expand/collapse follows `aria-expanded` on `.o-secondary-nav__header`). * - * @see https://www.consumerfinance.gov/compliance/supervisory-highlights/ + * Typography and spacing live in `secondary-nav.scss` (cfgov organism): DS math from + * `$base-font-size-px` produces **em** (e.g. header padding, 1.125em parent links) and + * **rem** (header label), not px in this file. + * + * @see https://github.com/cfpb/consumerfinance.gov/blob/main/cfgov/v1/jinja2/v1/includes/organisms/secondary-nav.html + * @see https://github.com/cfpb/consumerfinance.gov/blob/main/cfgov/unprocessed/css/organisms/secondary-nav.scss */ export const SecondaryNav = ({ items, - ariaLabel = 'Page navigation', + ariaLabel = 'Section', + mobileToggleLabel = 'Navigate this section', className, ...properties }: SecondaryNavProperties): JSX.Element => { + const [isExpanded, setIsExpanded] = useState(false); + + // Align with cfgov small-screen layout: when the viewport crosses into the + // mobile breakpoint, hide the flyout so the collapsed header + chevron show. + // (matches max-width in secondary-nav.scss / $bp-sm-max → 56.25em.) + useEffect(() => { + if (!globalThis.window?.matchMedia) { + return; + } + + const mediaQuery = globalThis.window.matchMedia('(max-width: 56.25em)'); + + const collapseForMobileLayout = (): void => { + if (mediaQuery.matches) { + setIsExpanded(false); + } + }; + + collapseForMobileLayout(); + mediaQuery.addEventListener('change', collapseForMobileLayout); + + return () => { + mediaQuery.removeEventListener('change', collapseForMobileLayout); + }; + }, []); + + const onToggle = (): void => { + setIsExpanded((isOpen) => !isOpen); + }; + + const onLinkClick = (): void => { + setIsExpanded(false); + }; + return ( );