From 4d26f0cdf808449afac9c4d1d7d22beb57c098fd Mon Sep 17 00:00:00 2001 From: Richard Dinh <1038306+flacoman91@users.noreply.github.com> Date: Mon, 27 Apr 2026 14:48:02 -0700 Subject: [PATCH 01/14] update secondary nav with open/close update mobile toggle test --- src/components/Breadcrumb/breadcrumb.scss | 25 +-- .../SecondaryNav/secondary-nav.scss | 53 ++++++ .../SecondaryNav/secondary-nav.stories.tsx | 2 + .../SecondaryNav/secondary-nav.test.tsx | 17 +- src/components/SecondaryNav/secondary-nav.tsx | 153 +++++++++++------- 5 files changed, 173 insertions(+), 77 deletions(-) diff --git a/src/components/Breadcrumb/breadcrumb.scss b/src/components/Breadcrumb/breadcrumb.scss index 3454708016..c4fdc94b4e 100644 --- a/src/components/Breadcrumb/breadcrumb.scss +++ b/src/components/Breadcrumb/breadcrumb.scss @@ -1,4 +1,6 @@ .u-layout-grid__breadcrumbs { + padding: 1.875rem; + .m-breadcrumbs { align-items: center; display: flex; @@ -6,32 +8,15 @@ font-size: .875rem; gap: .625rem; min-height: 33px; - padding-bottom: .9375rem; - padding-top: .9375rem; position: relative; } } @media only screen and (width <= 56.25em) { .u-layout-grid__breadcrumbs { - background:var(--gray-5); + background: var(--gray-5); border-bottom: 1px solid var(--gray-40); - margin-left: -1.875rem; - margin-right: -1.875rem; - padding-left: 1.875rem; - padding-right: 1.875rem - } -} - -@media only screen and (width >= 37.5625em)and (width <=56.25em) { - .u-layout-grid__breadcrumbs { - padding-left: .9375rem; - padding-right: .9375rem + margin-bottom: 1.875rem; + padding: .9375rem; } } - -@media only screen and (width >= 37.5625em) { - .u-layout-grid__breadcrumbs { - margin-top: 0 - } -} \ No newline at end of file diff --git a/src/components/SecondaryNav/secondary-nav.scss b/src/components/SecondaryNav/secondary-nav.scss index b641817c24..89f14a9bd2 100644 --- a/src/components/SecondaryNav/secondary-nav.scss +++ b/src/components/SecondaryNav/secondary-nav.scss @@ -3,8 +3,37 @@ // 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 +@use '@cfpb/cfpb-design-system/src/elements/abstracts' as *; .o-secondary-nav { + &__toggle { + display: none; + width: 100%; + padding: 0.75rem 0.9375rem; + font-size: 18px; + font-weight: 500; + color: var(--pacific); + text-align: left; + background: var(--gray-5); + border: 0; + border-bottom: 1px solid var(--gray-40); + cursor: pointer; + + &:focus { + outline: 1px dotted var(--pacific); + outline-offset: -1px; + } + } + + &__toggle-label { + flex: 1 1 auto; + } + + &__toggle-icon { + flex: 0 0 auto; + margin-left: 0.5rem; + } + &__list { list-style: none; margin: 0; @@ -69,4 +98,28 @@ font-weight: 500; } } + + @include respond-to-max($bp-sm-max) { + margin-left: -0.9375rem; + margin-right: -0.9375rem; + + &__toggle { + display: flex; + align-items: center; + justify-content: space-between; + } + + &__list--parents { + display: none; + margin-top: 0.5rem; + + &[data-expanded='true'] { + display: block; + } + } + } + + @include respond-to-range($bp-sm-min, $bp-sm-max) { + margin-right: -1.875rem; + } } diff --git a/src/components/SecondaryNav/secondary-nav.stories.tsx b/src/components/SecondaryNav/secondary-nav.stories.tsx index 26f12f56ec..5e12c812ee 100644 --- a/src/components/SecondaryNav/secondary-nav.stories.tsx +++ b/src/components/SecondaryNav/secondary-nav.stories.tsx @@ -18,12 +18,14 @@ Matches the "Navigate this section" pattern used on [consumerfinance.gov](https: - 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. +- Use \`mobileToggleLabel\` to set the toggle label shown on mobile when the list collapses. `, }, }, }, argTypes: { ariaLabel: { control: 'text' }, + mobileToggleLabel: { control: 'text' }, }, }; diff --git a/src/components/SecondaryNav/secondary-nav.test.tsx b/src/components/SecondaryNav/secondary-nav.test.tsx index 3d3b1b5704..085355d2e5 100644 --- a/src/components/SecondaryNav/secondary-nav.test.tsx +++ b/src/components/SecondaryNav/secondary-nav.test.tsx @@ -1,5 +1,5 @@ import '@testing-library/jest-dom'; -import { render, screen } from '@testing-library/react'; +import { fireEvent, render, screen } from '@testing-library/react'; import { SecondaryNav } from './secondary-nav'; import type { SecondaryNavItem } from './secondary-nav'; @@ -24,6 +24,21 @@ describe('', () => { ).toBeInTheDocument(); }); + it('renders a mobile toggle button with aria-expanded', () => { + render(); + const toggleButton = screen.getByTestId('secondary-nav-toggle'); + expect(toggleButton).toHaveAttribute('aria-expanded', 'false'); + }); + + it('toggles aria-expanded when the button is clicked', () => { + render(); + 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 all items as links; active link has aria-current', () => { render(); const linkA = screen.getByRole('link', { name: 'Link A' }); diff --git a/src/components/SecondaryNav/secondary-nav.tsx b/src/components/SecondaryNav/secondary-nav.tsx index ba9e1903f2..743c5186ef 100644 --- a/src/components/SecondaryNav/secondary-nav.tsx +++ b/src/components/SecondaryNav/secondary-nav.tsx @@ -1,6 +1,7 @@ import classnames from 'classnames'; import type { HTMLAttributes } from 'react'; -import { JSX } from 'react'; +import { JSX, useId, useState } from 'react'; +import { Icon } from '../Icon/icon'; import Link from '../Link/link'; import './secondary-nav.scss'; @@ -30,6 +31,10 @@ export interface SecondaryNavProperties extends HTMLAttributes { * Accessible label for the nav landmark. Defaults to "Page navigation". */ ariaLabel?: string; + /** + * Label for the mobile toggle button. Defaults to "Navigate this section". + */ + mobileToggleLabel?: string; } /** @@ -41,9 +46,21 @@ export interface SecondaryNavProperties extends HTMLAttributes { export const SecondaryNav = ({ items, ariaLabel = 'Page navigation', + mobileToggleLabel = 'Navigate this section', className, ...properties }: SecondaryNavProperties): JSX.Element => { + const [isExpanded, setIsExpanded] = useState(false); + const listId = useId(); + + const onToggle = (): void => { + setIsExpanded((isOpen) => !isOpen); + }; + + const onLinkClick = (): void => { + setIsExpanded(false); + }; + return ( ); From 982c529e8e1482723c2520dae7399981dc5e183a Mon Sep 17 00:00:00 2001 From: Richard Dinh <1038306+flacoman91@users.noreply.github.com> Date: Tue, 28 Apr 2026 07:37:23 -0700 Subject: [PATCH 02/14] fixing lint nag --- src/components/Icon/icon.stories.tsx | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/components/Icon/icon.stories.tsx b/src/components/Icon/icon.stories.tsx index fab54da915..bf13d4829d 100644 --- a/src/components/Icon/icon.stories.tsx +++ b/src/components/Icon/icon.stories.tsx @@ -102,8 +102,9 @@ export const DocumentIcons = (): ReactElement => ( {makeRows(documentIcons)} ); -export const FinancialProductsServicesAndConceptIcons = - (): ReactElement => {makeRows(financialIcons)}; +export const FinancialProductsServicesAndConceptIcons = (): ReactElement => ( + {makeRows(financialIcons)} +); export const ExpenseIcons = (): ReactElement => ( {makeRows(expenseIcons)} @@ -131,9 +132,11 @@ export const IconWithText: Story = { return ( - - - + + + + + {acceptableLevels.map(({ type, text }) => ( From 3e0c078b6e7f6f9c169463e2701a26236b8381cc Mon Sep 17 00:00:00 2001 From: Richard Dinh <1038306+flacoman91@users.noreply.github.com> Date: Tue, 28 Apr 2026 13:14:15 -0700 Subject: [PATCH 03/14] update layout, make divider line match whatever height --- .../Layout/layout-content.stories.tsx | 67 +++++++-- src/components/Layout/layout-main.stories.tsx | 133 +++++++++++------- .../Layout/layout-sidebar.stories.tsx | 20 +-- .../Layout/layout-wrapper.stories.tsx | 19 +-- src/components/Layout/layout.scss | 57 ++++++++ 5 files changed, 217 insertions(+), 79 deletions(-) diff --git a/src/components/Layout/layout-content.stories.tsx b/src/components/Layout/layout-content.stories.tsx index 48f42abed0..16106853a7 100644 --- a/src/components/Layout/layout-content.stories.tsx +++ b/src/components/Layout/layout-content.stories.tsx @@ -52,8 +52,17 @@ export const Content: Story = { flushAllOnSmall: false, }, render: (properties) => ( - + + +

Layout.Content

+

+ Lorem ipsum, dolor sit amet consectetur adipisicing elit. Quaerat + alias eum ut officiis optio similique explicabo cupiditate + architecto voluptatem nostrum recusandae, eaque consectetur iure, + veritatis eos, mollitia possimus error earum? +

+

Layout.Sidebar

@@ -62,17 +71,55 @@ export const Content: Story = {
  • Item 2
  • Item 3
  • +

    Layout.Sidebar

    +
      +
    • Item 1
    • +
    • Item 2
    • +
    • Item 3
    • +
    +

    Layout.Sidebar

    +
      +
    • Item 1
    • +
    • Item 2
    • +
    • Item 3
    • +

    Layout.Sidebar

    +
      +
    • Item 1
    • +
    • Item 2
    • +
    • Item 3
    • +
    +

    Layout.Sidebar

    +
      +
    • Item 1
    • +
    • Item 2
    • +
    • Item 3
    • +
    +

    Layout.Sidebar

    +
      +
    • Item 1
    • +
    • Item 2
    • +
    • Item 3
    • +
    +

    Layout.Sidebar

    +
      +
    • Item 1
    • +
    • Item 2
    • +
    • Item 3
    • +
    +

    Layout.Sidebar

    +
      +
    • Item 1
    • +
    • Item 2
    • +
    • Item 3
    • +
    +

    Layout.Sidebar

    +
      +
    • Item 1
    • +
    • Item 2
    • +
    • Item 3
    • +
    - -

    Layout.Content

    -

    - Lorem ipsum, dolor sit amet consectetur adipisicing elit. Quaerat - alias eum ut officiis optio similique explicabo cupiditate - architecto voluptatem nostrum recusandae, eaque consectetur iure, - veritatis eos, mollitia possimus error earum? -

    -
    ), diff --git a/src/components/Layout/layout-main.stories.tsx b/src/components/Layout/layout-main.stories.tsx index b570597409..3418fa6632 100644 --- a/src/components/Layout/layout-main.stories.tsx +++ b/src/components/Layout/layout-main.stories.tsx @@ -1,5 +1,7 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; +import type { ReactElement } from 'react'; import { Layout } from '~/src/index'; +import type { LayoutMainProperties } from './layout-main'; const meta: Meta = { title: 'Components (Draft)/Layout/Main', @@ -40,6 +42,8 @@ import Layout from './Layout
        < /Layout.Sidebar >
      < /Layout.Wrapper >
    < /Layout.Main >
    + +**Note:** For \`layout="1-3"\` (sidebar on the left), put \`Layout.Sidebar\` **before** \`Layout.Content\` inside \`Layout.Wrapper\`. For \`layout="2-1"\`, put **main first**, then sidebar—matching the [CFPB markup](https://cfpb.github.io/design-system/development/main-content-and-sidebars). `, }, }, @@ -50,64 +54,93 @@ export default meta; type Story = StoryObj; +const exampleContent = ( + +

    Content

    +

    + Lorem ipsum, dolor sit amet consectetur adipisicing elit. Quaerat alias + eum ut officiis optio similique explicabo cupiditate architecto + voluptatem nostrum recusandae, eaque consectetur iure, veritatis eos, + mollitia possimus error earum? +

    +
    +); + +const exampleSidebar = ( + +
    +

    Sidebar

    +
      +
    • Item 1
    • +
    • Item 2
    • +
    • Item 3
    • +
    +

    Sidebar

    +
      +
    • Item 1
    • +
    • Item 2
    • +
    • Item 3
    • +
    +

    Sidebar

    +
      +
    • Item 1
    • +
    • Item 2
    • +
    • Item 3
    • +
    +

    Sidebar

    +
      +
    • Item 1
    • +
    • Item 2
    • +
    • Item 3
    • +
    +

    Sidebar

    +
      +
    • Item 1
    • +
    • Item 2
    • +
    • Item 3
    • +
    +

    Sidebar

    +
      +
    • Item 1
    • +
    • Item 2
    • +
    • Item 3
    • +
    +

    Sidebar

    +
      +
    • Item 1
    • +
    • Item 2
    • +
    • Item 3
    • +
    +
    +
    +); + +function renderMainLayout( + properties: LayoutMainProperties, +): ReactElement { + const layout = properties.layout ?? '2-1'; + const columnChildren = + layout === '1-3' + ? [exampleSidebar, exampleContent] + : [exampleContent, exampleSidebar]; + + return ( + + {columnChildren} + + ); +} + export const Layout_2_1: Story = { args: { layout: '2-1', }, - render: (properties) => ( - - - -

    Content

    -

    - Lorem ipsum, dolor sit amet consectetur adipisicing elit. Quaerat - alias eum ut officiis optio similique explicabo cupiditate - architecto voluptatem nostrum recusandae, eaque consectetur iure, - veritatis eos, mollitia possimus error earum? -

    -
    - -
    -

    Sidebar

    -
      -
    • Item 1
    • -
    • Item 2
    • -
    • Item 3
    • -
    -
    -
    -
    -
    - ), + render: (properties) => renderMainLayout(properties), }; export const Layout_1_3: Story = { args: { layout: '1-3', }, - render: (properties) => ( - - - -
    -

    Sidebar

    -
      -
    • Item 1
    • -
    • Item 2
    • -
    • Item 3
    • -
    -
    -
    - -

    Content

    -

    - Lorem ipsum, dolor sit amet consectetur adipisicing elit. Quaerat - alias eum ut officiis optio similique explicabo cupiditate - architecto voluptatem nostrum recusandae, eaque consectetur iure, - veritatis eos, mollitia possimus error earum? -

    -
    -
    -
    - ), + render: (properties) => renderMainLayout(properties), }; diff --git a/src/components/Layout/layout-sidebar.stories.tsx b/src/components/Layout/layout-sidebar.stories.tsx index c17026ab43..7271a3e9b1 100644 --- a/src/components/Layout/layout-sidebar.stories.tsx +++ b/src/components/Layout/layout-sidebar.stories.tsx @@ -53,8 +53,17 @@ export const Sidebar: Story = { flushAllOnSmall: false, }, render: (properties) => ( - + + +

    Layout.Content

    +

    + Lorem ipsum, dolor sit amet consectetur adipisicing elit. Quaerat + alias eum ut officiis optio similique explicabo cupiditate + architecto voluptatem nostrum recusandae, eaque consectetur iure, + veritatis eos, mollitia possimus error earum? +

    +

    Layout.Sidebar

    @@ -65,15 +74,6 @@ export const Sidebar: Story = {
    - -

    Layout.Content

    -

    - Lorem ipsum, dolor sit amet consectetur adipisicing elit. Quaerat - alias eum ut officiis optio similique explicabo cupiditate - architecto voluptatem nostrum recusandae, eaque consectetur iure, - veritatis eos, mollitia possimus error earum? -

    -
    ), diff --git a/src/components/Layout/layout-wrapper.stories.tsx b/src/components/Layout/layout-wrapper.stories.tsx index bfbd58278d..a371aca496 100644 --- a/src/components/Layout/layout-wrapper.stories.tsx +++ b/src/components/Layout/layout-wrapper.stories.tsx @@ -42,7 +42,17 @@ type Story = StoryObj; export const Wrapper: Story = { args: { + // Order matches default Layout.Main layout "2-1" (main first, then sidebar). children: [ + +

    Layout.Content

    +

    + Lorem ipsum, dolor sit amet consectetur adipisicing elit. Quaerat + alias eum ut officiis optio similique explicabo cupiditate architecto + voluptatem nostrum recusandae, eaque consectetur iure, veritatis eos, + mollitia possimus error earum? +

    +
    ,

    Layout.Sidebar

    @@ -53,15 +63,6 @@ export const Wrapper: Story = {
    , - -

    Layout.Content

    -

    - Lorem ipsum, dolor sit amet consectetur adipisicing elit. Quaerat - alias eum ut officiis optio similique explicabo cupiditate architecto - voluptatem nostrum recusandae, eaque consectetur iure, veritatis eos, - mollitia possimus error earum? -

    -
    , ], }, render: ({ children }) => ( diff --git a/src/components/Layout/layout.scss b/src/components/Layout/layout.scss index 2599629e9c..5e268314b0 100644 --- a/src/components/Layout/layout.scss +++ b/src/components/Layout/layout.scss @@ -5,3 +5,60 @@ margin-right: 0 !important; } } + +// At the two-column breakpoint, use flex so main and sidebar share the row height of the +// taller column. The vertical divider is drawn from `.content__main::after` with `bottom: 0`, +// so it only reaches the bottom of `.content__main`; stretching that box matches the divider +// to the full sidebar/main height (CFPB DS uses inline-block, which does not equalize height). +@media only screen and (width >= 56.3125em) { + .content--1-3 .wrapper, + .content--2-1 .wrapper { + align-items: stretch; + display: flex; + } + + .content--1-3 .wrapper > .content__main, + .content--1-3 .wrapper > .content__sidebar, + .content--2-1 .wrapper > .content__main, + .content--2-1 .wrapper > .content__sidebar { + display: block; + margin-right: 0 !important; + } + + .content--1-3 .wrapper > .content__sidebar { + flex: 0 0 25%; + max-width: 25%; + } + + .content--1-3 .wrapper > .content__main { + flex: 0 0 75%; + max-width: 75%; + } + + .content--2-1 .wrapper > .content__main { + flex: 0 0 66.6667%; + max-width: 66.6667%; + } + + .content--2-1 .wrapper > .content__sidebar { + flex: 0 0 33.3333%; + max-width: 33.3333%; + } + + // CFPB DS 5.3.2 defines the column divider via `.content__main::after` for `content--1-3` + // but only emits `right: -1.875em` for `content--2-1` (no `content`, border, or positioning). + // Mirror the 1-3 rule so the vertical rule appears between main and a right-hand sidebar. + .content--2-1 .content__main { + position: relative; + } + + .content--2-1 .content__main::after { + border-right: 1px solid var(--content-main-border); + bottom: 0; + content: ''; + position: absolute; + right: -1.875em; + top: 2.8125em; + width: 0; + } +} From c1191720c923adde6050036b759e69192ea5bc78 Mon Sep 17 00:00:00 2001 From: Richard Dinh <1038306+flacoman91@users.noreply.github.com> Date: Tue, 28 Apr 2026 13:32:56 -0700 Subject: [PATCH 04/14] update unit tests --- src/components/Layout/layout.test.tsx | 169 ++++++++++++++++++++++++++ 1 file changed, 169 insertions(+) create mode 100644 src/components/Layout/layout.test.tsx diff --git a/src/components/Layout/layout.test.tsx b/src/components/Layout/layout.test.tsx new file mode 100644 index 0000000000..71154cb31d --- /dev/null +++ b/src/components/Layout/layout.test.tsx @@ -0,0 +1,169 @@ +import '@testing-library/jest-dom'; +import { render, screen } from '@testing-library/react'; +import Layout from './layout'; + +describe('Layout.Main', () => { + it('renders main landmark with default 2-1 layout classes', () => { + render( + + child + , + ); + + const main = screen.getByRole('main'); + expect(main).toHaveClass('content', 'content--2-1'); + expect(main).toHaveAttribute('id', 'main'); + expect(screen.getByText('child')).toBeInTheDocument(); + }); + + it('applies 1-3 layout class when layout is 1-3', () => { + render( + + child + , + ); + + expect(screen.getByRole('main')).toHaveClass('content--1-3'); + }); + + it('accepts custom id and extra classes', () => { + render( + + child + , + ); + + const main = screen.getByRole('main'); + expect(main).toHaveAttribute('id', 'page-main'); + expect(main).toHaveClass('extra-class'); + }); +}); + +describe('Layout.Wrapper', () => { + it('renders wrapper class and passes through div attributes', () => { + render( + + inner + , + ); + + const wrap = screen.getByTestId('wrap'); + expect(wrap).toHaveClass('wrapper'); + expect(wrap).toHaveAttribute('aria-label', 'Page'); + expect(wrap).toHaveTextContent('inner'); + }); +}); + +describe('Layout.Content', () => { + it('renders content__main and optional flush modifiers', () => { + const { rerender } = render( + + body + , + ); + + let node = screen.getByTestId('content'); + expect(node).toHaveClass('content__main'); + expect(node).not.toHaveClass('content--flush-bottom'); + + rerender( + + body + , + ); + + node = screen.getByTestId('content'); + expect(node).toHaveClass( + 'content__main', + 'content--flush-bottom', + 'content--flush-top-on-small', + 'content--flush-all-on-small', + ); + }); +}); + +describe('Layout.Sidebar', () => { + it('renders aside with sidebar classes and optional flush modifiers', () => { + const { rerender } = render( + nav, + ); + + let aside = screen.getByTestId('side'); + expect(aside.tagName).toBe('ASIDE'); + expect(aside).toHaveClass('sidebar', 'content__sidebar', 'o-sidebar-content'); + expect(aside).not.toHaveClass('content--flush-bottom'); + + rerender( + + nav + , + ); + + aside = screen.getByTestId('side'); + expect(aside).toHaveClass( + 'content--flush-bottom', + 'content--flush-top-on-small', + 'content--flush-all-on-small', + ); + }); +}); + +describe('Layout composition (CFPB DOM order)', () => { + it('2-1: main column precedes sidebar in document order', () => { + render( + + + + Main + + + Side + + + , + ); + + const mainCol = screen.getByTestId('layout-main-col'); + const sidebar = screen.getByTestId('layout-sidebar-col'); + expect( + Boolean( + mainCol.compareDocumentPosition(sidebar) & + Node.DOCUMENT_POSITION_FOLLOWING, + ), + ).toBe(true); + }); + + it('1-3: sidebar precedes main column in document order', () => { + render( + + + + Side + + + Main + + + , + ); + + const mainCol = screen.getByTestId('layout-main-col'); + const sidebar = screen.getByTestId('layout-sidebar-col'); + expect( + Boolean( + sidebar.compareDocumentPosition(mainCol) & + Node.DOCUMENT_POSITION_FOLLOWING, + ), + ).toBe(true); + }); +}); From cca564249c1696ee2168ea5be16ec1c6f50004d3 Mon Sep 17 00:00:00 2001 From: Richard Dinh <1038306+flacoman91@users.noreply.github.com> Date: Wed, 29 Apr 2026 12:15:38 -0700 Subject: [PATCH 05/14] add padding and shadow to open secondary nav menu --- src/components/SecondaryNav/secondary-nav.scss | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/components/SecondaryNav/secondary-nav.scss b/src/components/SecondaryNav/secondary-nav.scss index 89f14a9bd2..61ff2a3682 100644 --- a/src/components/SecondaryNav/secondary-nav.scss +++ b/src/components/SecondaryNav/secondary-nav.scss @@ -102,6 +102,7 @@ @include respond-to-max($bp-sm-max) { margin-left: -0.9375rem; margin-right: -0.9375rem; + background: var(--gray-5); &__toggle { display: flex; @@ -111,7 +112,10 @@ &__list--parents { display: none; - margin-top: 0.5rem; + margin-top: 0; + padding: 1.875rem 0.9375rem; + background: var(--gray-5); + box-shadow: 0 0.375rem 0.625rem -0.375rem rgb(0, 0, 0, 35%); &[data-expanded='true'] { display: block; @@ -120,6 +124,12 @@ } @include respond-to-range($bp-sm-min, $bp-sm-max) { + margin-left: -1.875rem; margin-right: -1.875rem; + + &__list--parents { + padding-left: 1.875rem; + padding-right: 1.875rem; + } } } From b240a54c875012e75c77fe83b5f94433e94f94a6 Mon Sep 17 00:00:00 2001 From: Richard Dinh <1038306+flacoman91@users.noreply.github.com> Date: Wed, 29 Apr 2026 12:56:44 -0700 Subject: [PATCH 06/14] fix hamburge button --- src/components/Header/responsive-menu.scss | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/components/Header/responsive-menu.scss b/src/components/Header/responsive-menu.scss index f6a18e2f70..588c0cf388 100644 --- a/src/components/Header/responsive-menu.scss +++ b/src/components/Header/responsive-menu.scss @@ -23,6 +23,11 @@ $max-width: $breakpoint - 0.0625em; align-items: stretch; padding: 0; min-height: 60px; + + &.wrapper.wrapper--match-content { + padding-left: 0 !important; + padding-right: 0 !important; + } } @media (min-width: $breakpoint) { From 938d990f8a99e23b0cf6bb75a7dcc973e15dc927 Mon Sep 17 00:00:00 2001 From: Richard Dinh <1038306+flacoman91@users.noreply.github.com> Date: Wed, 29 Apr 2026 14:25:53 -0700 Subject: [PATCH 07/14] font size adjust --- .../SecondaryNav/secondary-nav.scss | 21 +++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/src/components/SecondaryNav/secondary-nav.scss b/src/components/SecondaryNav/secondary-nav.scss index 61ff2a3682..eff9197a7e 100644 --- a/src/components/SecondaryNav/secondary-nav.scss +++ b/src/components/SecondaryNav/secondary-nav.scss @@ -10,8 +10,9 @@ display: none; width: 100%; padding: 0.75rem 0.9375rem; - font-size: 18px; - font-weight: 500; + + // Base for child ems from heading-4; avoids UA/button font-size shrinking the label. + font-size: $base-font-size-px; color: var(--pacific); text-align: left; background: var(--gray-5); @@ -27,11 +28,27 @@ &__toggle-label { flex: 1 1 auto; + + // Same as DS .h4 / heading-4, but not responsive: global .h4 uses 1em on small + // viewports (inherits ~16px); the toggle should stay 18px with font-size-adjust. + @include heading-4( + $has-margin-bottom: false, + $has-margin-top: false, + $is-responsive: false + ); } &__toggle-icon { flex: 0 0 auto; margin-left: 0.5rem; + display: flex; + align-items: center; + justify-content: center; + line-height: 0; + + .cf-icon-svg { + display: block; + } } &__list { From 073f6e63c5d2a3617d160dbd3426dfc0c5ab7a2f Mon Sep 17 00:00:00 2001 From: Richard Dinh <1038306+flacoman91@users.noreply.github.com> Date: Wed, 29 Apr 2026 15:54:38 -0700 Subject: [PATCH 08/14] update to match cf.gov --- src/components/Breadcrumb/breadcrumb.scss | 36 +- .../SecondaryNav/secondary-nav.scss | 313 +++++++++++++----- .../SecondaryNav/secondary-nav.stories.tsx | 89 ++--- .../SecondaryNav/secondary-nav.test.tsx | 66 ++-- src/components/SecondaryNav/secondary-nav.tsx | 200 ++++++----- 5 files changed, 469 insertions(+), 235 deletions(-) diff --git a/src/components/Breadcrumb/breadcrumb.scss b/src/components/Breadcrumb/breadcrumb.scss index c4fdc94b4e..14b9b7250a 100644 --- a/src/components/Breadcrumb/breadcrumb.scss +++ b/src/components/Breadcrumb/breadcrumb.scss @@ -1,22 +1,46 @@ +// Matches u-layout-grid__breadcrumbs + m-breadcrumbs on consumerfinance.gov +// Live main.css (verified): .m-breadcrumbs uses font-size: .875rem (→ 14px at 16px root), +// min-height: 33px (px), gap/padding in rem. No separate .m-breadcrumbs__crumb block—crumbs inherit. +// @see https://www.consumerfinance.gov/static/css/main.css +@use '@cfpb/cfpb-design-system/src/elements/abstracts' as *; + .u-layout-grid__breadcrumbs { - padding: 1.875rem; + grid-area: c-breadcrumbs; .m-breadcrumbs { align-items: center; display: flex; flex-wrap: wrap; - font-size: .875rem; - gap: .625rem; + font-size: 0.875rem; + gap: 0.625rem; min-height: 33px; + padding-bottom: 0.9375rem; + padding-top: 0.9375rem; position: relative; } + + @include respond-to-min($bp-med-min) { + .m-breadcrumbs { + padding-bottom: 0; + padding-top: 1.875rem; + } + } } -@media only screen and (width <= 56.25em) { +@include respond-to-max($bp-sm-max) { .u-layout-grid__breadcrumbs { background: var(--gray-5); border-bottom: 1px solid var(--gray-40); - margin-bottom: 1.875rem; - padding: .9375rem; + margin-left: -1.875rem; + margin-right: -1.875rem; + padding-left: 1.875rem; + padding-right: 1.875rem; + } +} + +@include respond-to-range($bp-sm-min, $bp-sm-max) { + .u-layout-grid__breadcrumbs { + padding-left: 0.9375rem; + padding-right: 0.9375rem; } } diff --git a/src/components/SecondaryNav/secondary-nav.scss b/src/components/SecondaryNav/secondary-nav.scss index eff9197a7e..55bdc8a7a8 100644 --- a/src/components/SecondaryNav/secondary-nav.scss +++ b/src/components/SecondaryNav/secondary-nav.scss @@ -1,152 +1,285 @@ -// 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 { - &__toggle { - display: none; - width: 100%; - padding: 0.75rem 0.9375rem; - - // Base for child ems from heading-4; avoids UA/button font-size shrinking the label. - font-size: $base-font-size-px; - color: var(--pacific); - text-align: left; - background: var(--gray-5); + // + // Header + // + &__header { + display: flex; + justify-content: space-between; border: 0; - border-bottom: 1px solid var(--gray-40); cursor: pointer; + padding: (math.div(10px, $base-font-size-px) + em) + (math.div(15px, $base-font-size-px) + em); &:focus { - outline: 1px dotted var(--pacific); - outline-offset: -1px; + 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; } } - &__toggle-label { - flex: 1 1 auto; + // 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; + } - // Same as DS .h4 / heading-4, but not responsive: global .h4 uses 1em on small - // viewports (inherits ~16px); the toggle should stay 18px with font-size-adjust. - @include heading-4( - $has-margin-bottom: false, - $has-margin-top: false, - $is-responsive: false - ); + &__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); } - &__toggle-icon { - flex: 0 0 auto; - margin-left: 0.5rem; - display: flex; - align-items: center; - justify-content: center; - line-height: 0; + &__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; + } - .cf-icon-svg { + &__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) { - margin-left: -0.9375rem; - margin-right: -0.9375rem; background: var(--gray-5); + border-bottom: 1px solid var(--gray-40); + + // Add drop-shadow. + box-shadow: 0 5px 5px rgb(0, 0, 0, 20%); - &__toggle { - display: flex; - align-items: center; - justify-content: space-between; + &--no-children { + display: none; } &__list--parents { + li[data-nav-is-active='False'] { + display: none; + } + } + + // 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; - margin-top: 0; - padding: 1.875rem 0.9375rem; - background: var(--gray-5); - box-shadow: 0 0.375rem 0.625rem -0.375rem rgb(0, 0, 0, 35%); + } + } - &[data-expanded='true'] { - display: block; + // 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; + } } } } - @include respond-to-range($bp-sm-min, $bp-sm-max) { - margin-left: -1.875rem; - margin-right: -1.875rem; + // 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-left: 1.875rem; - padding-right: 1.875rem; + 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 5e12c812ee..59478a0cae 100644 --- a/src/components/SecondaryNav/secondary-nav.stories.tsx +++ b/src/components/SecondaryNav/secondary-nav.stories.tsx @@ -11,14 +11,16 @@ const meta: Meta = { 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/). +Markup and classes match [cfgov secondary-nav.html](https://github.com/cfpb/consumerfinance.gov/blob/main/cfgov/v1/jinja2/v1/includes/organisms/secondary-nav.html) (e.g. [Auto loans](https://www.consumerfinance.gov/data-research/consumer-credit-trends/auto-loans/)). ### 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. -- Use \`mobileToggleLabel\` to set the toggle label shown on mobile when the list collapses. +- Items can have optional \`children\` for sub-menu items. +- **Cfgov:** if *no* item has \`children\`, the nav gets \`o-secondary-nav--no-children\` and is **hidden on small viewports**. Examples below all include a parent-with-children branch so the mobile header works. +- Put the **current section** (with \`children\`) **first**, like production: on small screens only \`li[data-nav-is-active="True"]\` stays visible. +- Default \`ariaLabel\` is \`Section\` (cfgov gettext). Override if needed. +- \`mobileToggleLabel\` sets the collapsible header text (default **Navigate this section**). `, }, }, @@ -33,9 +35,21 @@ export default meta; type Story = StoryObj; -const defaultItems: SecondaryNavItem[] = [ - { href: '#section-1', label: 'Section 1' }, - { href: '#section-2', label: 'Section 2', isActive: true }, +/** + * Canonical cfgov-shaped data (matches the working consumerfinance pattern): + * first item = index with subpages; one child \`isActive\`; following items are peers. + */ +const sectionNavItems: SecondaryNavItem[] = [ + { + label: 'Section 1', + href: '#section-1', + 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: '#section-2', label: 'Section 2' }, { href: '#section-3', label: 'Section 3' }, { href: '#section-4', label: 'Section 4' }, { href: '#section-5', label: 'Section 5' }, @@ -43,64 +57,63 @@ const defaultItems: SecondaryNavItem[] = [ { href: '#section-7', label: 'Section 7' }, ]; -const itemsWithSubMenu: SecondaryNavItem[] = [ - { - label: 'Section 1', - href: '/section-1', - 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: '/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' }, -]; +const stripActive = (items: SecondaryNavItem[]): SecondaryNavItem[] => + items.map((item) => ({ + ...item, + isActive: undefined, + children: item.children?.map((child) => ({ + ...child, + isActive: undefined, + })), + })); export const Default: Story = { args: { - items: defaultItems, - ariaLabel: 'Page navigation', + items: sectionNavItems, }, - render: (args) => , }; export const WithShortList: Story = { args: { items: [ - { href: '#overview', label: 'Overview' }, - { href: '#rules', label: 'Rules', isActive: true }, - { href: '#resources', label: 'Resources' }, + sectionNavItems[0], + { href: '#more', label: 'More' }, ], ariaLabel: 'On this page', }, - render: (args) => , }; export const WithSubMenu: Story = { args: { - items: itemsWithSubMenu, + items: sectionNavItems, ariaLabel: 'Section', }, - render: (args) => , }; export const NoActiveItem: Story = { args: { - items: defaultItems.map(({ isActive: _isActive, ...item }) => item), - ariaLabel: 'Page navigation', + items: stripActive(sectionNavItems), + }, + parameters: { + docs: { + description: { + story: + 'No `isActive` anywhere: every `li` is `data-nav-is-active="False"`, so cfgov hides all section rows on small viewports—you still get the header, but the expanded panel has no visible links.', + }, + }, }, - render: (args) => , }; export const EmptyList: Story = { args: { items: [], - ariaLabel: 'Page navigation', }, - render: (args) => , + parameters: { + docs: { + description: { + story: + 'With no items, or with `o-secondary-nav--no-children` (no nested sections), cfgov hides the whole nav on small viewports.', + }, + }, + }, }; diff --git a/src/components/SecondaryNav/secondary-nav.test.tsx b/src/components/SecondaryNav/secondary-nav.test.tsx index 085355d2e5..a50b671a66 100644 --- a/src/components/SecondaryNav/secondary-nav.test.tsx +++ b/src/components/SecondaryNav/secondary-nav.test.tsx @@ -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'); }); @@ -27,6 +27,7 @@ describe('', () => { it('renders a mobile toggle button with aria-expanded', () => { render(); const toggleButton = screen.getByTestId('secondary-nav-toggle'); + expect(toggleButton).toHaveClass('o-secondary-nav__header'); expect(toggleButton).toHaveAttribute('aria-expanded', 'false'); }); @@ -39,24 +40,26 @@ describe('', () => { expect(toggleButton).toHaveAttribute('aria-expanded', 'false'); }); - it('renders all items as links; active link has aria-current', () => { + it('renders anchors; active item has no href and aria-current', () => { 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 current = screen.getByText('Link B'); + expect(current.tagName).toBe('A'); + expect(current).not.toHaveAttribute('href'); + expect(current).toHaveAttribute('aria-current', 'page'); }); - it('sets data-nav-is-active on the li for the active item', () => { + it('sets cfgov data-nav-is-active True/False on each li', () => { 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'); + expect(listItems[0]).toHaveAttribute('data-nav-is-active', 'False'); + expect(listItems[1]).toHaveAttribute('data-nav-is-active', 'True'); + expect(listItems[2]).toHaveAttribute('data-nav-is-active', 'False'); }); it('renders no list when items is empty', () => { @@ -66,11 +69,35 @@ describe('', () => { 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('adds o-secondary-nav--no-children when no item has children', () => { + render(); + expect(screen.getByRole('navigation', { name: 'Section' })).toHaveClass( + 'o-secondary-nav--no-children', + ); + }); + + it('omits o-secondary-nav--no-children when any item has children', () => { + const itemsWithChildren: SecondaryNavItem[] = [ + { + label: 'Parent', + isActive: true, + children: [ + { href: '/child-a', label: 'Child A', isActive: true }, + { href: '/child-b', label: 'Child B' }, + ], + }, + ]; + render(); + expect(screen.getByRole('navigation', { name: 'Section' })).not.toHaveClass( + 'o-secondary-nav--no-children', + ); + }); + it('renders child items when parent has children', () => { const itemsWithChildren: SecondaryNavItem[] = [ { @@ -84,17 +111,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 743c5186ef..f194b01d9f 100644 --- a/src/components/SecondaryNav/secondary-nav.tsx +++ b/src/components/SecondaryNav/secondary-nav.tsx @@ -1,8 +1,7 @@ import classnames from 'classnames'; import type { HTMLAttributes } from 'react'; -import { JSX, useId, useState } from 'react'; +import { JSX, useEffect, useState } from 'react'; import { Icon } from '../Icon/icon'; -import Link from '../Link/link'; import './secondary-nav.scss'; export interface SecondaryNavChildItem { @@ -28,30 +27,60 @@ 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 toggle button. Defaults to "Navigate this section". + * 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); - const listId = useId(); + const hasAnyChildren = items.some((item) => Boolean(item.children?.length)); + + // 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); @@ -63,7 +92,11 @@ export const SecondaryNav = ({ return ( From 48430be98b4fc91b11d46e743e57e4652db7056e Mon Sep 17 00:00:00 2001 From: Richard Dinh <1038306+flacoman91@users.noreply.github.com> Date: Wed, 29 Apr 2026 23:24:52 -0700 Subject: [PATCH 09/14] update src. --- src/components/Breadcrumb/breadcrumb.scss | 27 +++---------------- .../SecondaryNav/secondary-nav.scss | 7 +++++ 2 files changed, 10 insertions(+), 24 deletions(-) diff --git a/src/components/Breadcrumb/breadcrumb.scss b/src/components/Breadcrumb/breadcrumb.scss index 14b9b7250a..73cdced29d 100644 --- a/src/components/Breadcrumb/breadcrumb.scss +++ b/src/components/Breadcrumb/breadcrumb.scss @@ -1,11 +1,8 @@ -// Matches u-layout-grid__breadcrumbs + m-breadcrumbs on consumerfinance.gov -// Live main.css (verified): .m-breadcrumbs uses font-size: .875rem (→ 14px at 16px root), -// min-height: 33px (px), gap/padding in rem. No separate .m-breadcrumbs__crumb block—crumbs inherit. -// @see https://www.consumerfinance.gov/static/css/main.css @use '@cfpb/cfpb-design-system/src/elements/abstracts' as *; .u-layout-grid__breadcrumbs { grid-area: c-breadcrumbs; + padding: 1.875rem; .m-breadcrumbs { align-items: center; @@ -14,33 +11,15 @@ font-size: 0.875rem; gap: 0.625rem; min-height: 33px; - padding-bottom: 0.9375rem; - padding-top: 0.9375rem; position: relative; } - - @include respond-to-min($bp-med-min) { - .m-breadcrumbs { - padding-bottom: 0; - padding-top: 1.875rem; - } - } } @include respond-to-max($bp-sm-max) { .u-layout-grid__breadcrumbs { background: var(--gray-5); border-bottom: 1px solid var(--gray-40); - margin-left: -1.875rem; - margin-right: -1.875rem; - padding-left: 1.875rem; - padding-right: 1.875rem; - } -} - -@include respond-to-range($bp-sm-min, $bp-sm-max) { - .u-layout-grid__breadcrumbs { - padding-left: 0.9375rem; - padding-right: 0.9375rem; + margin-bottom: 1.875rem; + padding: 0.9375rem; } } diff --git a/src/components/SecondaryNav/secondary-nav.scss b/src/components/SecondaryNav/secondary-nav.scss index 55bdc8a7a8..0f1002037f 100644 --- a/src/components/SecondaryNav/secondary-nav.scss +++ b/src/components/SecondaryNav/secondary-nav.scss @@ -193,6 +193,8 @@ @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%); @@ -214,6 +216,11 @@ } } + @include respond-to-range($bp-sm-min, $bp-sm-max) { + margin-left: -1.875rem; + margin-right: -1.875rem; + } + // Desktop and above. @include respond-to-min($bp-med-min) { .o-secondary-nav { From a834f92dbeb87b3314ec4463a7ea3f22d0a118ec Mon Sep 17 00:00:00 2001 From: Richard Dinh <1038306+flacoman91@users.noreply.github.com> Date: Thu, 30 Apr 2026 11:37:54 -0700 Subject: [PATCH 10/14] fix mobile header when menu open --- src/components/Header/responsive-menu.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/Header/responsive-menu.scss b/src/components/Header/responsive-menu.scss index 588c0cf388..db8789e5ea 100644 --- a/src/components/Header/responsive-menu.scss +++ b/src/components/Header/responsive-menu.scss @@ -97,7 +97,7 @@ $max-width: $breakpoint - 0.0625em; .nav-items { @media (max-width: $max-width) { position: absolute; - top: 54px; + top: 60px; left: -$menu-width - 15; width: $menu-width; background-color: var(--white); From 89839df719648730539e7ec20804728eceef752a Mon Sep 17 00:00:00 2001 From: Richard Dinh <1038306+flacoman91@users.noreply.github.com> Date: Thu, 30 Apr 2026 15:08:47 -0700 Subject: [PATCH 11/14] remove unnecessary examples --- design-system-react.iml | 9 +++++++++ .../SecondaryNav/secondary-nav.stories.tsx | 17 ----------------- 2 files changed, 9 insertions(+), 17 deletions(-) create mode 100644 design-system-react.iml diff --git a/design-system-react.iml b/design-system-react.iml new file mode 100644 index 0000000000..8021953ed9 --- /dev/null +++ b/design-system-react.iml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/src/components/SecondaryNav/secondary-nav.stories.tsx b/src/components/SecondaryNav/secondary-nav.stories.tsx index 59478a0cae..816aa1ac41 100644 --- a/src/components/SecondaryNav/secondary-nav.stories.tsx +++ b/src/components/SecondaryNav/secondary-nav.stories.tsx @@ -73,23 +73,6 @@ export const Default: Story = { }, }; -export const WithShortList: Story = { - args: { - items: [ - sectionNavItems[0], - { href: '#more', label: 'More' }, - ], - ariaLabel: 'On this page', - }, -}; - -export const WithSubMenu: Story = { - args: { - items: sectionNavItems, - ariaLabel: 'Section', - }, -}; - export const NoActiveItem: Story = { args: { items: stripActive(sectionNavItems), From b790e91e5924f8d5b80cbb5eccb01119ad454c88 Mon Sep 17 00:00:00 2001 From: Richard Dinh <1038306+flacoman91@users.noreply.github.com> Date: Thu, 30 Apr 2026 15:17:46 -0700 Subject: [PATCH 12/14] display hidden options at mobile --- .../SecondaryNav/secondary-nav.scss | 5 ---- .../SecondaryNav/secondary-nav.stories.tsx | 27 ------------------- src/components/SecondaryNav/secondary-nav.tsx | 4 +-- 3 files changed, 1 insertion(+), 35 deletions(-) diff --git a/src/components/SecondaryNav/secondary-nav.scss b/src/components/SecondaryNav/secondary-nav.scss index 0f1002037f..40825adae0 100644 --- a/src/components/SecondaryNav/secondary-nav.scss +++ b/src/components/SecondaryNav/secondary-nav.scss @@ -203,11 +203,6 @@ display: none; } - &__list--parents { - li[data-nav-is-active='False'] { - display: none; - } - } // cfgov initializes FlyoutMenu + MaxHeightTransition in SecondaryNav.js. // Collapse content from header state when that JS is not running. diff --git a/src/components/SecondaryNav/secondary-nav.stories.tsx b/src/components/SecondaryNav/secondary-nav.stories.tsx index 816aa1ac41..1e74f6661a 100644 --- a/src/components/SecondaryNav/secondary-nav.stories.tsx +++ b/src/components/SecondaryNav/secondary-nav.stories.tsx @@ -73,30 +73,3 @@ export const Default: Story = { }, }; -export const NoActiveItem: Story = { - args: { - items: stripActive(sectionNavItems), - }, - parameters: { - docs: { - description: { - story: - 'No `isActive` anywhere: every `li` is `data-nav-is-active="False"`, so cfgov hides all section rows on small viewports—you still get the header, but the expanded panel has no visible links.', - }, - }, - }, -}; - -export const EmptyList: Story = { - args: { - items: [], - }, - parameters: { - docs: { - description: { - story: - 'With no items, or with `o-secondary-nav--no-children` (no nested sections), cfgov hides the whole nav on small viewports.', - }, - }, - }, -}; diff --git a/src/components/SecondaryNav/secondary-nav.tsx b/src/components/SecondaryNav/secondary-nav.tsx index f194b01d9f..42cb07cbe3 100644 --- a/src/components/SecondaryNav/secondary-nav.tsx +++ b/src/components/SecondaryNav/secondary-nav.tsx @@ -126,12 +126,10 @@ export const SecondaryNav = ({ const hasActiveChild = hasChildren && item.children!.some((c) => c.isActive); const parentIsActive = Boolean(item.isActive && !hasActiveChild); - const sectionHasActive = hasActiveChild || parentIsActive; - + return (
  • {item.href && !parentIsActive ? ( Date: Thu, 30 Apr 2026 15:37:54 -0700 Subject: [PATCH 13/14] update examples --- docs/local-cfpb-ds.md | 47 +++++++ .../SecondaryNav/secondary-nav.scss | 4 - .../SecondaryNav/secondary-nav.stories.tsx | 117 +++++++++++++----- .../SecondaryNav/secondary-nav.test.tsx | 39 +----- src/components/SecondaryNav/secondary-nav.tsx | 4 +- 5 files changed, 136 insertions(+), 75 deletions(-) create mode 100644 docs/local-cfpb-ds.md diff --git a/docs/local-cfpb-ds.md b/docs/local-cfpb-ds.md new file mode 100644 index 0000000000..5f62aff597 --- /dev/null +++ b/docs/local-cfpb-ds.md @@ -0,0 +1,47 @@ +# Pointing design-system-react at a local `@cfpb/cfpb-design-system` + +Use this when you have the [cfpb/design-system](https://github.com/cfpb/design-system) repo cloned next to this repo and want Storybook/tests to use your branch (e.g. layout fixes) before a release. + +## Layout + +Assume sibling directories: + +```text +projects/ + design-system/ # monorepo root; package lives in packages/cfpb-design-system/ + design-system-react/ # this repo +``` + +If your paths differ, adjust the `portal:` URL below. + +## Yarn (Berry) + +In **design-system-react** `package.json`, temporarily set the devDependency to the **portal** protocol (live symlink to source): + +```json +"@cfpb/cfpb-design-system": "portal:../design-system/packages/cfpb-design-system" +``` + +Then from **design-system-react**: + +```bash +yarn install +yarn storybook +# optional +yarn test +``` + +`portal:` keeps the dependency wired to your clone so SCSS/JS changes in `design-system` show up after save (no publish step). + +## After you’re done + +1. Remove the `portal:` line and restore the published version (e.g. `"5.3.2"`). +2. Run `yarn install` again. + +## Optional: trim duplicate Layout CSS here + +`src/components/Layout/layout.scss` in this repo duplicates some rules that belong in the DS once your PR ships. After you adopt a released `@cfpb/cfpb-design-system` that includes the layout fix, consider removing the overlapping blocks from `layout.scss` so overrides stay minimal. + +## Alternative: `yarn link` + +From `design-system/packages/cfpb-design-system` you can `yarn link`, then in design-system-react `yarn link @cfpb/cfpb-design-system`. Portal is usually simpler in a Yarn workspaces/monorepo workflow. diff --git a/src/components/SecondaryNav/secondary-nav.scss b/src/components/SecondaryNav/secondary-nav.scss index 40825adae0..260a63f68a 100644 --- a/src/components/SecondaryNav/secondary-nav.scss +++ b/src/components/SecondaryNav/secondary-nav.scss @@ -199,10 +199,6 @@ // Add drop-shadow. box-shadow: 0 5px 5px rgb(0, 0, 0, 20%); - &--no-children { - display: none; - } - // cfgov initializes FlyoutMenu + MaxHeightTransition in SecondaryNav.js. // Collapse content from header state when that JS is not running. diff --git a/src/components/SecondaryNav/secondary-nav.stories.tsx b/src/components/SecondaryNav/secondary-nav.stories.tsx index 1e74f6661a..d09b363831 100644 --- a/src/components/SecondaryNav/secondary-nav.stories.tsx +++ b/src/components/SecondaryNav/secondary-nav.stories.tsx @@ -11,16 +11,13 @@ const meta: Meta = { description: { component: ` Secondary navigation for in-page or section navigation, typically shown in a left sidebar. -Markup and classes match [cfgov secondary-nav.html](https://github.com/cfpb/consumerfinance.gov/blob/main/cfgov/v1/jinja2/v1/includes/organisms/secondary-nav.html) (e.g. [Auto loans](https://www.consumerfinance.gov/data-research/consumer-credit-trends/auto-loans/)). +Markup and classes match [cfgov secondary-nav.html](https://github.com/cfpb/consumerfinance.gov/blob/main/cfgov/v1/jinja2/v1/includes/organisms/secondary-nav.html). ### Usage -- Pass \`items\` with \`href\`, \`label\`, and optional \`isActive\` for the current page. -- Items can have optional \`children\` for sub-menu items. -- **Cfgov:** if *no* item has \`children\`, the nav gets \`o-secondary-nav--no-children\` and is **hidden on small viewports**. Examples below all include a parent-with-children branch so the mobile header works. -- Put the **current section** (with \`children\`) **first**, like production: on small screens only \`li[data-nav-is-active="True"]\` stays visible. -- Default \`ariaLabel\` is \`Section\` (cfgov gettext). Override if needed. -- \`mobileToggleLabel\` sets the collapsible header text (default **Navigate this section**). +- **Flat list (no \`children\`):** use \`isActive\` on the current top-level item. +- **With \`children\`:** only children should use \`isActive\` for a subpage, unless the “current” page is the parent index—in that case set \`isActive\` on the parent and leave children inactive. +- Default \`ariaLabel\` is \`Section\`. \`mobileToggleLabel\` defaults to **Navigate this section**. `, }, }, @@ -35,41 +32,101 @@ export default meta; type Story = StoryObj; -/** - * Canonical cfgov-shaped data (matches the working consumerfinance pattern): - * first item = index with subpages; one child \`isActive\`; following items are peers. - */ -const sectionNavItems: SecondaryNavItem[] = [ +/** 1. Flat links only; none marked current. */ +const basicNoChildren: SecondaryNavItem[] = [ + { href: '#topic-a', label: 'Section A' }, + { href: '#topic-b', label: 'Section B' }, + { href: '#topic-c', label: 'Section C' }, +]; + +/** 2. Flat list; one top-level item is the current page. */ +const basicNoChildrenWithCurrent: SecondaryNavItem[] = [ + { href: '#topic-a', label: 'Section A' }, + { href: '#topic-b', label: 'Section B', isActive: true }, + { href: '#topic-c', label: 'Section C' }, +]; + +/** 3. Nested items; no \`isActive\` on parents or children. */ +const withChildrenNoActive: SecondaryNavItem[] = [ { label: 'Section 1', href: '#section-1', 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: '#section-1-a', label: 'Item A' }, + { href: '#section-1-b', label: 'Item B' }, ], }, { 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' }, ]; -const stripActive = (items: SecondaryNavItem[]): SecondaryNavItem[] => - items.map((item) => ({ - ...item, - isActive: undefined, - children: item.children?.map((child) => ({ - ...child, - isActive: undefined, - })), - })); +/** 4. Current page is the parent “index”; children are links but none are active. */ +const withChildrenActiveParent: SecondaryNavItem[] = [ + { + label: 'Section 1', + href: '#section-1', + isActive: true, + children: [ + { href: '#section-1-a', label: 'Item A' }, + { href: '#section-1-b', label: 'Item B' }, + ], + }, + { href: '#section-2', label: 'Section 2' }, +]; -export const Default: Story = { +/** 5. Typical subpage: one child is the current page. */ +const withChildrenActiveChild: SecondaryNavItem[] = [ + { + label: 'Section 1', + href: '#section-1', + children: [ + { href: '#section-1-a', label: 'Item A', isActive: true }, + { href: '#section-1-b', label: 'Item B' }, + { href: '#section-1-c', label: 'Item C' }, + ], + }, + { href: '#section-2', label: 'Section 2' }, + { href: '#section-3', label: 'Section 3' }, +]; + +export const BasicMenuNoChildren: Story = { + name: 'Basic secondary nav', + args: { + items: basicNoChildren, + }, +}; + +export const BasicMenuNoChildrenOneActive: Story = { + name: 'Basic secondary nav, one active', args: { - items: sectionNavItems, + items: basicNoChildrenWithCurrent, }, }; +export const MenuWithChildrenNoActive: Story = { + name: 'Secondary nav with children, no active items', + args: { + items: withChildrenNoActive, + }, +}; + +export const MenuWithChildrenActiveParent: Story = { + name: 'Secondary nav with children, active parent', + args: { + items: withChildrenActiveParent, + }, +}; + +export const MenuWithChildrenActiveChild: Story = { + name: 'Secondary nav with children, active child', + args: { + items: withChildrenActiveChild, + }, +}; + +/** Same data as story 5; kept as a short default entry in the sidebar. */ +export const Default: Story = { + args: { + items: withChildrenActiveChild, + }, +}; diff --git a/src/components/SecondaryNav/secondary-nav.test.tsx b/src/components/SecondaryNav/secondary-nav.test.tsx index a50b671a66..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 { fireEvent, render, screen } from '@testing-library/react'; -import { SecondaryNav } from './secondary-nav'; import type { SecondaryNavItem } from './secondary-nav'; +import { SecondaryNav } from './secondary-nav'; describe('', () => { const defaultItems: SecondaryNavItem[] = [ @@ -53,20 +53,6 @@ describe('', () => { expect(current).toHaveAttribute('aria-current', 'page'); }); - it('sets cfgov data-nav-is-active True/False on each li', () => { - render(); - const listItems = screen.getAllByRole('listitem'); - expect(listItems).toHaveLength(3); - expect(listItems[0]).toHaveAttribute('data-nav-is-active', 'False'); - expect(listItems[1]).toHaveAttribute('data-nav-is-active', 'True'); - expect(listItems[2]).toHaveAttribute('data-nav-is-active', 'False'); - }); - - it('renders no list when items is empty', () => { - render(); - expect(screen.queryByRole('list')).toBeNull(); - }); - it('applies custom className', () => { render(); const nav = screen.getByRole('navigation', { name: 'Section' }); @@ -74,29 +60,6 @@ describe('', () => { expect(nav).toHaveClass('custom-nav'); }); - it('adds o-secondary-nav--no-children when no item has children', () => { - render(); - expect(screen.getByRole('navigation', { name: 'Section' })).toHaveClass( - 'o-secondary-nav--no-children', - ); - }); - - it('omits o-secondary-nav--no-children when any item has children', () => { - const itemsWithChildren: SecondaryNavItem[] = [ - { - label: 'Parent', - isActive: true, - children: [ - { href: '/child-a', label: 'Child A', isActive: true }, - { href: '/child-b', label: 'Child B' }, - ], - }, - ]; - render(); - expect(screen.getByRole('navigation', { name: 'Section' })).not.toHaveClass( - 'o-secondary-nav--no-children', - ); - }); it('renders child items when parent has children', () => { const itemsWithChildren: SecondaryNavItem[] = [ diff --git a/src/components/SecondaryNav/secondary-nav.tsx b/src/components/SecondaryNav/secondary-nav.tsx index 42cb07cbe3..b0ccb2bf51 100644 --- a/src/components/SecondaryNav/secondary-nav.tsx +++ b/src/components/SecondaryNav/secondary-nav.tsx @@ -56,8 +56,7 @@ export const SecondaryNav = ({ ...properties }: SecondaryNavProperties): JSX.Element => { const [isExpanded, setIsExpanded] = useState(false); - const hasAnyChildren = items.some((item) => Boolean(item.children?.length)); - + // 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.) @@ -94,7 +93,6 @@ export const SecondaryNav = ({
  • Text elementIcon with backgroundIcon without background
    Text elementIcon with backgroundIcon without background