From fafd8631b0203d24fe6a0ce4b0aec2a80abb0b4d Mon Sep 17 00:00:00 2001 From: Marco Ciampini Date: Fri, 27 Mar 2026 17:59:04 +0100 Subject: [PATCH 1/4] CollapsibleCard: Add HeaderDescription subcomponent Add a `CollapsibleCard.HeaderDescription` subcomponent that provides an `aria-describedby` relationship between supplementary header content (e.g. status badges, summaries) and the collapsible trigger button. The component uses React context to communicate its auto-generated ID to the parent `Header`, which sets `aria-describedby` on the trigger. Content is rendered with `aria-hidden="true"` so assistive technologies consume it only through the description relationship, avoiding double announcements. Also refactors the DataForm card layout to use `HeaderDescription` instead of the previous manual `aria-describedby` + `useId()` approach. Made-with: Cursor --- packages/dataviews/CHANGELOG.md | 1 + .../dataform-layouts/card/index.tsx | 15 +-- packages/ui/CHANGELOG.md | 1 + packages/ui/src/collapsible-card/context.ts | 7 ++ .../collapsible-card/header-description.tsx | 43 +++++++++ packages/ui/src/collapsible-card/header.tsx | 69 ++++++++------ packages/ui/src/collapsible-card/index.ts | 3 +- .../collapsible-card/stories/index.story.tsx | 44 +++++++++ .../src/collapsible-card/test/index.test.tsx | 93 +++++++++++++++++++ packages/ui/src/collapsible-card/types.ts | 12 +++ 10 files changed, 249 insertions(+), 39 deletions(-) create mode 100644 packages/ui/src/collapsible-card/context.ts create mode 100644 packages/ui/src/collapsible-card/header-description.tsx diff --git a/packages/dataviews/CHANGELOG.md b/packages/dataviews/CHANGELOG.md index fcdad5093a7a26..4246b56ca54634 100644 --- a/packages/dataviews/CHANGELOG.md +++ b/packages/dataviews/CHANGELOG.md @@ -11,6 +11,7 @@ - DataForm: Add `compact` configuration option to the `datetime` control. [#76905](https://github.com/WordPress/gutenberg/pull/76905) - DataViews: Field's description can accept ReactElements. [#76829](https://github.com/WordPress/gutenberg/pull/76829) +- DataForm: Use `CollapsibleCard.HeaderDescription` for card layout header descriptions instead of manual `aria-describedby`. ## 13.1.0 (2026-03-18) diff --git a/packages/dataviews/src/components/dataform-layouts/card/index.tsx b/packages/dataviews/src/components/dataform-layouts/card/index.tsx index 29a153042023e6..bd18e5bab84637 100644 --- a/packages/dataviews/src/components/dataform-layouts/card/index.tsx +++ b/packages/dataviews/src/components/dataform-layouts/card/index.tsx @@ -5,7 +5,6 @@ import { useCallback, useContext, useEffect, - useId, useMemo, useRef, useState, @@ -87,7 +86,6 @@ function isSummaryFieldVisible< Item >( function HeaderContent< Item >( { data, fields, - descriptionId, label, layout, isOpen, @@ -96,7 +94,6 @@ function HeaderContent< Item >( { }: { data: Item; fields: NormalizedField< Item >[]; - descriptionId: string; label: string | undefined; layout: NormalizedCardLayout; isOpen: boolean; @@ -120,11 +117,7 @@ function HeaderContent< Item >( { > { label } { ( hasBadge || hasSummary ) && ( - + ) } ); @@ -208,7 +201,6 @@ export default function FormCardField< Item >( { const { fields } = useContext( DataFormContext ); const layout = field.layout as NormalizedCardLayout; const contentRef = useRef< HTMLDivElement >( null ); - const descriptionId = useId(); const form: NormalizedForm = useMemo( () => ( { @@ -284,7 +276,6 @@ export default function FormCardField< Item >( { ( { open={ isOpen } onOpenChange={ handleOpenChange } > - + { headerContent } void; +} >( { + setDescriptionId: () => {}, +} ); diff --git a/packages/ui/src/collapsible-card/header-description.tsx b/packages/ui/src/collapsible-card/header-description.tsx new file mode 100644 index 00000000000000..08c24560bee84b --- /dev/null +++ b/packages/ui/src/collapsible-card/header-description.tsx @@ -0,0 +1,43 @@ +import { forwardRef, useContext, useEffect, useId } from '@wordpress/element'; +import { HeaderDescriptionIdContext } from './context'; +import type { HeaderDescriptionProps } from './types'; + +/** + * Secondary content placed in the collapsible card header that describes + * the trigger button via `aria-describedby`. Use it for supplementary + * information such as status badges or summary values. + * + * The content is visually rendered but marked `aria-hidden` so that + * assistive technologies consume it only through the `aria-describedby` + * relationship on the trigger, avoiding double announcements. + * + * Avoid interactive elements (buttons, links, inputs) inside this + * component — the entire header is the toggle trigger. + */ +export const HeaderDescription = forwardRef< + HTMLDivElement, + HeaderDescriptionProps +>( function CollapsibleCardHeaderDescription( + { children, className, ...restProps }, + ref +) { + const descriptionId = useId(); + const { setDescriptionId } = useContext( HeaderDescriptionIdContext ); + + useEffect( () => { + setDescriptionId( descriptionId ); + return () => setDescriptionId( undefined ); + }, [ descriptionId, setDescriptionId ] ); + + return ( + + ); +} ); diff --git a/packages/ui/src/collapsible-card/header.tsx b/packages/ui/src/collapsible-card/header.tsx index fdb7d88a8705e1..230f05b2d9f654 100644 --- a/packages/ui/src/collapsible-card/header.tsx +++ b/packages/ui/src/collapsible-card/header.tsx @@ -1,11 +1,12 @@ import clsx from 'clsx'; -import { forwardRef } from '@wordpress/element'; +import { forwardRef, useMemo, useState } from '@wordpress/element'; import { chevronDown } from '@wordpress/icons'; import * as Card from '../card'; import * as Collapsible from '../collapsible'; import { Icon } from '../icon'; import styles from './style.module.css'; import focusStyles from '../utils/css/focus.module.css'; +import { HeaderDescriptionIdContext } from './context'; import type { HeaderProps } from './types'; /** @@ -22,38 +23,54 @@ export const Header = forwardRef< HTMLDivElement, HeaderProps >( { children, className, render, ...restProps }, ref ) { + const [ descriptionId, setDescriptionId ] = useState< string >(); + + const contextValue = useMemo( + () => ( { setDescriptionId } ), + [ setDescriptionId ] + ); + return ( - - } - nativeButton={ false } - > -
{ children }
-
+ + } + nativeButton={ false } + aria-describedby={ descriptionId } > +
+ { children } +
- +
+ +
-
-
+ + ); } ); diff --git a/packages/ui/src/collapsible-card/index.ts b/packages/ui/src/collapsible-card/index.ts index 3387b70e220616..d8d00f0202a394 100644 --- a/packages/ui/src/collapsible-card/index.ts +++ b/packages/ui/src/collapsible-card/index.ts @@ -1,5 +1,6 @@ import { Root } from './root'; import { Header } from './header'; +import { HeaderDescription } from './header-description'; import { Content } from './content'; -export { Root, Header, Content }; +export { Root, Header, HeaderDescription, Content }; diff --git a/packages/ui/src/collapsible-card/stories/index.story.tsx b/packages/ui/src/collapsible-card/stories/index.story.tsx index d7148a5105d1f0..105a2f195138a8 100644 --- a/packages/ui/src/collapsible-card/stories/index.story.tsx +++ b/packages/ui/src/collapsible-card/stories/index.story.tsx @@ -29,6 +29,7 @@ const meta: Meta< typeof CollapsibleCard.Root > = { component: CollapsibleCard.Root, subcomponents: { 'CollapsibleCard.Header': CollapsibleCard.Header, + 'CollapsibleCard.HeaderDescription': CollapsibleCard.HeaderDescription, 'CollapsibleCard.Content': CollapsibleCard.Content, }, }; @@ -156,6 +157,49 @@ export const Stacked: Story = { ), }; +/** + * A collapsible card with a `HeaderDescription` that provides supplementary + * information (e.g. status, summary) as an `aria-describedby` relationship. + */ +export const WithHeaderDescription: Story = { + // `defaultOpen` (uncontrolled) and `open` (controlled) should not be + // used together — disable the `open` control to avoid confusion. + argTypes: { open: { control: false } }, + args: { + defaultOpen: true, + }, + render: ( { open, defaultOpen, onOpenChange, disabled, ...restArgs } ) => ( + + + Settings + + + 3 items configured + + + + + + The header description provides supplementary context to the + trigger button. Assistive technologies will announce the + description alongside the button label. + + + + ), +}; + /** * Visual comparison: a `CollapsibleCard` (open) next to a regular `Card` * to verify identical spacing and layout. diff --git a/packages/ui/src/collapsible-card/test/index.test.tsx b/packages/ui/src/collapsible-card/test/index.test.tsx index 1d23797d399303..ec7c5123f54a58 100644 --- a/packages/ui/src/collapsible-card/test/index.test.tsx +++ b/packages/ui/src/collapsible-card/test/index.test.tsx @@ -178,4 +178,97 @@ describe( 'CollapsibleCard', () => { ).toBeVisible(); } ); } ); + + describe( 'HeaderDescription', () => { + it( 'sets aria-describedby on the trigger pointing to the description', () => { + render( + + + Settings + + 3 errors + + + +

Content

+
+
+ ); + + const trigger = screen.getByRole( 'button', { + name: 'Settings', + } ); + const descriptionElement = screen.getByTestId( 'desc' ); + + expect( descriptionElement ).toHaveAttribute( 'id' ); + expect( trigger ).toHaveAttribute( + 'aria-describedby', + descriptionElement.id + ); + } ); + + it( 'marks the description content as aria-hidden', () => { + render( + + + Settings + + Status: OK + + + + ); + + const descriptionWrapper = screen.getByTestId( 'desc' ); + expect( descriptionWrapper ).toHaveAttribute( + 'aria-hidden', + 'true' + ); + } ); + + it( 'does not set aria-describedby when HeaderDescription is absent', () => { + render( + + + Title + + + ); + + const trigger = screen.getByRole( 'button', { name: 'Title' } ); + expect( trigger ).not.toHaveAttribute( 'aria-describedby' ); + } ); + + it( 'forwards ref on HeaderDescription', () => { + const descRef = createRef< HTMLDivElement >(); + + render( + + + Title + + Description + + + + ); + + expect( descRef.current ).toBeInstanceOf( HTMLDivElement ); + } ); + + it( 'renders description content visually', () => { + render( + + + Title + + Badge content + + + + ); + + expect( screen.getByText( 'Badge content' ) ).toBeInTheDocument(); + } ); + } ); } ); diff --git a/packages/ui/src/collapsible-card/types.ts b/packages/ui/src/collapsible-card/types.ts index f76986ef1a49b5..35763ccc7910dd 100644 --- a/packages/ui/src/collapsible-card/types.ts +++ b/packages/ui/src/collapsible-card/types.ts @@ -37,6 +37,18 @@ export interface HeaderProps extends ComponentProps< 'div' > { children?: ReactNode; } +export interface HeaderDescriptionProps extends ComponentProps< 'div' > { + /** + * Secondary content that describes the header trigger via + * `aria-describedby`. Rendered visually but marked `aria-hidden` + * so assistive technologies consume it only through the description + * relationship, avoiding double announcements. + * + * Avoid interactive elements — the entire header is the toggle trigger. + */ + children?: ReactNode; +} + export interface ContentProps extends ComponentProps< 'div' > { /** * The content to be rendered inside the collapsible content area. From f2685aacaed46f4459965a4b4a001d0d46074455 Mon Sep 17 00:00:00 2001 From: Marco Ciampini Date: Fri, 27 Mar 2026 19:07:52 +0100 Subject: [PATCH 2/4] CHANGELOG --- packages/dataviews/CHANGELOG.md | 2 +- packages/ui/CHANGELOG.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/dataviews/CHANGELOG.md b/packages/dataviews/CHANGELOG.md index 4246b56ca54634..4d0ec716f604ed 100644 --- a/packages/dataviews/CHANGELOG.md +++ b/packages/dataviews/CHANGELOG.md @@ -11,7 +11,7 @@ - DataForm: Add `compact` configuration option to the `datetime` control. [#76905](https://github.com/WordPress/gutenberg/pull/76905) - DataViews: Field's description can accept ReactElements. [#76829](https://github.com/WordPress/gutenberg/pull/76829) -- DataForm: Use `CollapsibleCard.HeaderDescription` for card layout header descriptions instead of manual `aria-describedby`. +- DataForm: Use `CollapsibleCard.HeaderDescription` for card layout header descriptions instead of manual `aria-describedby`. [#76867](https://github.com/WordPress/gutenberg/pull/76867) ## 13.1.0 (2026-03-18) diff --git a/packages/ui/CHANGELOG.md b/packages/ui/CHANGELOG.md index 2045921b72654e..7abef86fdb9086 100644 --- a/packages/ui/CHANGELOG.md +++ b/packages/ui/CHANGELOG.md @@ -7,7 +7,7 @@ - Add `AlertDialog` primitive ([#76847](https://github.com/WordPress/gutenberg/pull/76847)). - Add `InputControl` component ([#76653](https://github.com/WordPress/gutenberg/pull/76653)). - `Dialog`: Expose `initialFocus` and `finalFocus` props on `Dialog.Popup` for custom focus management ([#76860](https://github.com/WordPress/gutenberg/pull/76860)). -- `CollapsibleCard`: Add `HeaderDescription` subcomponent for supplementary header content with `aria-describedby` relationship. +- `CollapsibleCard`: Add `HeaderDescription` subcomponent for supplementary header content with `aria-describedby` relationship ([#76867](https://github.com/WordPress/gutenberg/pull/76867)). ### Bug Fixes From 692b33b2ec6239583fda9e658a0ddc3a94a0fe76 Mon Sep 17 00:00:00 2001 From: Marco Ciampini Date: Wed, 1 Apr 2026 11:43:15 +0200 Subject: [PATCH 3/4] Better test assertions --- packages/ui/src/collapsible-card/test/index.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ui/src/collapsible-card/test/index.test.tsx b/packages/ui/src/collapsible-card/test/index.test.tsx index ec7c5123f54a58..5ce044713dd492 100644 --- a/packages/ui/src/collapsible-card/test/index.test.tsx +++ b/packages/ui/src/collapsible-card/test/index.test.tsx @@ -268,7 +268,7 @@ describe( 'CollapsibleCard', () => { ); - expect( screen.getByText( 'Badge content' ) ).toBeInTheDocument(); + expect( screen.getByText( 'Badge content' ) ).toBeVisible(); } ); } ); } ); From 3c55c810225093370c926e73fd7b3793fb384319 Mon Sep 17 00:00:00 2001 From: Marco Ciampini Date: Wed, 1 Apr 2026 11:46:49 +0200 Subject: [PATCH 4/4] Stack header children horizontally in HeaderDescription example --- .../collapsible-card/stories/index.story.tsx | 25 +++++++++++-------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/packages/ui/src/collapsible-card/stories/index.story.tsx b/packages/ui/src/collapsible-card/stories/index.story.tsx index 105a2f195138a8..b587604c8e63a8 100644 --- a/packages/ui/src/collapsible-card/stories/index.story.tsx +++ b/packages/ui/src/collapsible-card/stories/index.story.tsx @@ -1,6 +1,7 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; import * as Card from '../../card'; import * as CollapsibleCard from '../index'; +import { Stack } from '../../stack'; /** * Temporary text component for story examples. This will be replaced by an @@ -177,17 +178,19 @@ export const WithHeaderDescription: Story = { { ...restArgs } > - Settings - - - 3 items configured - - + + Settings + + + 3 items configured + + +