diff --git a/.gitignore b/.gitignore index c58d920362..922b05a1dd 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ node_modules .DS_Store +*.iml dist dist-storybook *.iml @@ -17,4 +18,4 @@ coverage !.yarn/plugins !.yarn/releases !.yarn/sdks -!.yarn/versions \ No newline at end of file +!.yarn/versions 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/Breadcrumb/breadcrumb.scss b/src/components/Breadcrumb/breadcrumb.scss index 3454708016..73cdced29d 100644 --- a/src/components/Breadcrumb/breadcrumb.scss +++ b/src/components/Breadcrumb/breadcrumb.scss @@ -1,37 +1,25 @@ +@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; display: flex; flex-wrap: wrap; - font-size: .875rem; - gap: .625rem; + font-size: 0.875rem; + gap: 0.625rem; min-height: 33px; - padding-bottom: .9375rem; - padding-top: .9375rem; position: relative; } } -@media only screen and (width <= 56.25em) { +@include respond-to-max($bp-sm-max) { .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 + margin-bottom: 1.875rem; + padding: 0.9375rem; } } - -@media only screen and (width >= 37.5625em)and (width <=56.25em) { - .u-layout-grid__breadcrumbs { - padding-left: .9375rem; - padding-right: .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/Header/responsive-menu.scss b/src/components/Header/responsive-menu.scss index f6a18e2f70..57a3fa22a3 100644 --- a/src/components/Header/responsive-menu.scss +++ b/src/components/Header/responsive-menu.scss @@ -1,9 +1,10 @@ @use '/src/assets/styles/variables.scss' as *; +@use '@cfpb/cfpb-design-system/src/elements/abstracts' as *; -$breakpoint: 56.3125em; +$breakpoint: $bp-med-min; $menu-width: 100%; $animation-duration: 0.3s; -$max-width: $breakpoint - 0.0625em; +$max-width: $bp-sm-max; .o-header-scope { .o-header__content { @@ -23,6 +24,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) { @@ -32,24 +38,26 @@ $max-width: $breakpoint - 0.0625em; } .o-header__logo { + display: inline-block; min-width: 237px; + .o-header__logo-img { + display: block; + } + @media (max-width: $max-width) { border-left: 1px solid var(--gray-40); padding-left: 15px; + .o-header__logo-img { height: 40px; + margin-top: 10px; + margin-bottom: 10px; + vertical-align: middle; } } } - .o-header__logo-img { - height: 34px; - margin-top: 10px; - margin-bottom: 10px; - vertical-align: middle; - } - @media (min-width: $breakpoint) { .o-header__logo-img { height: 50px; @@ -92,7 +100,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); @@ -109,13 +117,11 @@ $max-width: $breakpoint - 0.0625em; width: 100%; &.open { - display: flex; left: 0; } @media (min-width: $breakpoint) { position: static; - display: flex; flex-direction: row; justify-content: flex-end; width: auto; @@ -155,19 +161,23 @@ $max-width: $breakpoint - 0.0625em; padding: 0; } - &:hover { - position: relative; + @media (max-width: $max-width) { + &:hover::before, + &.nav-item.active::before { + position: absolute; + top: 0; + left: -15px; + height: 100%; + width: 5px; + content: ''; + display: block; + } + } + &:hover { @media (max-width: $max-width) { &::before { - position: absolute; - top: 0; - left: -15px; - height: 100%; - width: 5px; background-color: var(--green); - content: ''; - display: block; } color: var(--black); } @@ -182,14 +192,7 @@ $max-width: $breakpoint - 0.0625em; @media (max-width: $max-width) { &::before { - position: absolute; - top: 0; - left: -15px; - height: 100%; - width: 5px; background-color: var(--black); - content: ' '; - display: block; } } diff --git a/src/components/Headings/heading.test.tsx b/src/components/Headings/heading.test.tsx index 603c31520f..e99038896f 100644 --- a/src/components/Headings/heading.test.tsx +++ b/src/components/Headings/heading.test.tsx @@ -37,6 +37,22 @@ describe('', () => { } }); + it('Renders React elements with text', () => { + render( + + , + ); + + const current = screen.getByTestId('heading-with-element'); + + expect(current).toBeInTheDocument(); + expect(current.tagName.toLowerCase()).toBe('h2'); + expect(screen.getByTestId('heading-icon')).toBeInTheDocument(); + expect(current).toHaveTextContent('Information'); + }); + it('Renders Display heading', () => { const headingType = `display`; const testid = `h${headingType}`; diff --git a/src/components/Headings/heading.tsx b/src/components/Headings/heading.tsx index cb95d96cbb..761d462cbe 100644 --- a/src/components/Headings/heading.tsx +++ b/src/components/Headings/heading.tsx @@ -1,6 +1,6 @@ import classnames from 'classnames'; import { JSX } from 'react'; -import type { HTMLProps } from 'react'; +import type { HTMLProps, ReactNode } from 'react'; export type HeadingType = | '1' @@ -12,9 +12,13 @@ export type HeadingType = | 'eyebrow' | 'slug'; -interface HeadingProperties extends HTMLProps { +interface HeadingProperties extends Omit< + HTMLProps, + 'children' +> { /** Heading type (1-5, display, eyebrow, slug) */ type?: HeadingType; + children?: ReactNode; } export const Heading = ({ diff --git a/src/components/Headings/headings.stories.tsx b/src/components/Headings/headings.stories.tsx index 23be2bc455..ea3f317e77 100644 --- a/src/components/Headings/headings.stories.tsx +++ b/src/components/Headings/headings.stories.tsx @@ -1,5 +1,5 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; -import { Heading } from '~/src/index'; +import { Heading, Icon } from '~/src/index'; /** * A successful type hierarchy establishes the order of importance of elements on a page. Consistent scaling, weights, and capitalization are used to create distinction between headings and provide users with familiar focus points when scanning text. @@ -84,3 +84,14 @@ export const Slug: Story = { children: 'Slug', }, }; + +export const WithIcon: Story = { + args: { + type: '2', + }, + render: (arguments_) => ( + + Information + + ), +}; 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 }) => ( 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; + } +} 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); + }); +}); diff --git a/src/components/SecondaryNav/secondary-nav.scss b/src/components/SecondaryNav/secondary-nav.scss index b641817c24..260a63f68a 100644 --- a/src/components/SecondaryNav/secondary-nav.scss +++ b/src/components/SecondaryNav/secondary-nav.scss @@ -1,72 +1,283 @@ -// 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; + } + } + + @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 { + 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..c962e1a2b7 100644 --- a/src/components/SecondaryNav/secondary-nav.stories.tsx +++ b/src/components/SecondaryNav/secondary-nav.stories.tsx @@ -11,19 +11,19 @@ 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/). ### 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. +- **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**. `, }, }, }, argTypes: { ariaLabel: { control: 'text' }, + mobileToggleLabel: { control: 'text' }, }, }; @@ -31,74 +31,94 @@ export default meta; type Story = StoryObj; -const defaultItems: SecondaryNavItem[] = [ - { href: '#section-1', label: 'Section 1' }, - { href: '#section-2', label: 'Section 2', isActive: true }, +/** 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-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 itemsWithSubMenu: SecondaryNavItem[] = [ +/** 4. Current page is the parent “index”; children are links but none are active. */ +const withChildrenActiveParent: SecondaryNavItem[] = [ { label: 'Section 1', - href: '/section-1', + href: '#section-1', + isActive: true, 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' }, + { 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', 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 ( );
    Text elementIcon with backgroundIcon without background
    Text elementIcon with backgroundIcon without background