From cfc84234090bf49f64c21bf6d386eee7a4ebb86b Mon Sep 17 00:00:00 2001 From: Marco Ciampini Date: Mon, 9 Mar 2026 17:32:41 +0100 Subject: [PATCH 1/5] CollapsibleCard: include card title in trigger's accessible label MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The toggle button in CollapsibleCard.Header used a hardcoded "Expand or collapse card" label, making multiple cards on the same page indistinguishable for screen reader users. Introduce a React Context (TitleTextContext) so Card.Title can register its rendered text content with a parent CollapsibleCard.Header. The header composes a descriptive label — e.g. "Expand or collapse Settings" — and passes it to the IconButton as both aria-label and tooltip. When no Card.Title is present, the header falls back to reading the text content of its content wrapper. Made-with: Cursor --- packages/ui/src/card/context.ts | 8 ++ packages/ui/src/card/index.ts | 1 + packages/ui/src/card/title.tsx | 33 ++++++- packages/ui/src/collapsible-card/header.tsx | 47 ++++++++- .../collapsible-card/stories/index.story.tsx | 43 ++++++++ .../src/collapsible-card/test/index.test.tsx | 99 ++++++++++++++++--- 6 files changed, 207 insertions(+), 24 deletions(-) create mode 100644 packages/ui/src/card/context.ts diff --git a/packages/ui/src/card/context.ts b/packages/ui/src/card/context.ts new file mode 100644 index 00000000000000..bfd7248ed4134e --- /dev/null +++ b/packages/ui/src/card/context.ts @@ -0,0 +1,8 @@ +import { createContext } from '@wordpress/element'; + +export const TitleTextContext = createContext< + | { + setTitleText: ( text: string | undefined ) => void; + } + | undefined +>( undefined ); diff --git a/packages/ui/src/card/index.ts b/packages/ui/src/card/index.ts index c23fa3474fb191..a72d41b2845030 100644 --- a/packages/ui/src/card/index.ts +++ b/packages/ui/src/card/index.ts @@ -5,3 +5,4 @@ import { FullBleed } from './full-bleed'; import { Title } from './title'; export { Root, Header, Content, FullBleed, Title }; +export { TitleTextContext } from './context'; diff --git a/packages/ui/src/card/title.tsx b/packages/ui/src/card/title.tsx index fb6d14bd1642e7..436ac109f23e64 100644 --- a/packages/ui/src/card/title.tsx +++ b/packages/ui/src/card/title.tsx @@ -1,5 +1,12 @@ import { mergeProps, useRender } from '@base-ui/react'; -import { forwardRef } from '@wordpress/element'; +import { useMergeRefs } from '@wordpress/compose'; +import { + forwardRef, + useContext, + useLayoutEffect, + useRef, +} from '@wordpress/element'; +import { TitleTextContext } from './context'; import styles from './style.module.css'; import type { TitleProps } from './types'; @@ -8,13 +15,31 @@ import type { TitleProps } from './types'; * prop to swap in a semantic heading element when appropriate. */ export const Title = forwardRef< HTMLDivElement, TitleProps >( - function CardTitle( { render, ...props }, ref ) { + function CardTitle( { render, ...restProps }, forwardedRef ) { + const titleTextContext = useContext( TitleTextContext ); + const internalRef = useRef< HTMLDivElement >( null ); + const mergedRef = useMergeRefs( [ internalRef, forwardedRef ] ); + + // Sync the rendered text content to the parent context (used by + // CollapsibleCard to build the trigger's accessible label). Runs on + // every render so the label stays current if children change + // dynamically. The cost is a single `textContent` read plus a + // bail-out `setState` when the value hasn't changed. + useLayoutEffect( () => { + const text = internalRef.current?.textContent?.trim() || undefined; + titleTextContext?.setTitleText( text ); + return () => titleTextContext?.setTitleText( undefined ); + } ); + const element = useRender( { defaultTagName: 'div', render, - ref, + ref: mergedRef, // TODO: use `Text` component instead, when ready - props: mergeProps< 'div' >( { className: styles.title }, props ), + props: mergeProps< 'div' >( + { className: styles.title }, + restProps + ), } ); return element; diff --git a/packages/ui/src/collapsible-card/header.tsx b/packages/ui/src/collapsible-card/header.tsx index a017bd29d8505f..df79f28802a466 100644 --- a/packages/ui/src/collapsible-card/header.tsx +++ b/packages/ui/src/collapsible-card/header.tsx @@ -1,10 +1,18 @@ import { Collapsible } from '@base-ui/react/collapsible'; import clsx from 'clsx'; import type { MouseEvent } from 'react'; -import { forwardRef, useCallback, useRef } from '@wordpress/element'; -import { __ } from '@wordpress/i18n'; +import { + forwardRef, + useCallback, + useLayoutEffect, + useMemo, + useRef, + useState, +} from '@wordpress/element'; +import { __, sprintf } from '@wordpress/i18n'; import { chevronDown, chevronUp } from '@wordpress/icons'; import * as Card from '../card'; +import { TitleTextContext } from '../card'; import { IconButton } from '../icon-button'; import styles from './style.module.css'; import type { HeaderProps } from './types'; @@ -24,6 +32,30 @@ export const Header = forwardRef< HTMLDivElement, HeaderProps >( ref ) { const triggerRef = useRef< HTMLButtonElement >( null ); + const headerContentRef = useRef< HTMLDivElement >( null ); + const [ titleText, setTitleText ] = useState< string >(); + const [ headerText, setHeaderText ] = useState< string >(); + const titleTextContextValue = useMemo( () => ( { setTitleText } ), [] ); + + // Fallback: read the header content's text when no Card.Title is + // present. When a Card.Title is used, its own effect keeps the label + // in sync (including dynamic updates); this only covers the uncommon + // case of a header without a Card.Title. + useLayoutEffect( () => { + if ( titleText === undefined ) { + const text = headerContentRef.current?.textContent?.trim(); + setHeaderText( text || undefined ); + } + }, [ titleText ] ); + + const identifierText = titleText ?? headerText; + const triggerLabel = identifierText + ? sprintf( + /* translators: %s: title of the card being expanded or collapsed */ + __( 'Expand or collapse %s' ), + identifierText + ) + : __( 'Expand or collapse' ); const handleHeaderClick = useCallback( ( event: MouseEvent< HTMLDivElement > ) => { @@ -48,14 +80,21 @@ export const Header = forwardRef< HTMLDivElement, HeaderProps >( onClick={ handleHeaderClick } { ...restProps } > -
{ children }
+ +
+ { children } +
+
( ( +
+ + + General settings + + + Configure your general preferences here. + + + + + Privacy + + + Manage your privacy options. + + + + + Notifications + + + Choose which notifications you receive. + + +
+ ), +}; + /** * 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 6c2004807c4229..106991141128dc 100644 --- a/packages/ui/src/collapsible-card/test/index.test.tsx +++ b/packages/ui/src/collapsible-card/test/index.test.tsx @@ -87,23 +87,19 @@ describe( 'CollapsibleCard', () => { ); + const trigger = screen.getByRole( 'button', { + name: 'Expand or collapse Title', + } ); + expect( screen.queryByText( 'Toggle content' ) ).not.toBeInTheDocument(); - await user.click( - screen.getByRole( 'button', { - name: 'Expand or collapse card', - } ) - ); + await user.click( trigger ); expect( screen.getByText( 'Toggle content' ) ).toBeVisible(); - await user.click( - screen.getByRole( 'button', { - name: 'Expand or collapse card', - } ) - ); + await user.click( trigger ); expect( screen.queryByText( 'Toggle content' ) @@ -152,7 +148,7 @@ describe( 'CollapsibleCard', () => { await user.click( screen.getByRole( 'button', { - name: 'Expand or collapse card', + name: 'Expand or collapse Title', } ) ); @@ -179,7 +175,7 @@ describe( 'CollapsibleCard', () => { await user.click( screen.getByRole( 'button', { - name: 'Expand or collapse card', + name: 'Expand or collapse Title', } ) ); @@ -187,19 +183,90 @@ describe( 'CollapsibleCard', () => { } ); } ); - describe( 'trigger', () => { - it( 'renders a toggle button', () => { + describe( 'trigger accessible label', () => { + it( 'includes the Card.Title text in the trigger label', () => { render( - Title + Settings ); expect( screen.getByRole( 'button', { - name: 'Expand or collapse card', + name: 'Expand or collapse Settings', + } ) + ).toBeVisible(); + } ); + + it( 'uses a static label that does not change when toggled', async () => { + const user = userEvent.setup(); + + render( + + + Settings + + +

Content

+
+
+ ); + + const trigger = screen.getByRole( 'button', { + name: 'Expand or collapse Settings', + } ); + expect( trigger ).toHaveAttribute( 'aria-expanded', 'false' ); + + await user.click( trigger ); + + expect( trigger ).toHaveAttribute( 'aria-expanded', 'true' ); + expect( trigger ).toHaveAccessibleName( + 'Expand or collapse Settings' + ); + } ); + + it( 'falls back to header content when no Card.Title is used', () => { + render( + + + Plain header text + + + ); + + expect( + screen.getByRole( 'button', { + name: 'Expand or collapse Plain header text', + } ) + ).toBeVisible(); + } ); + + it( 'produces unique labels for multiple cards', () => { + render( + <> + + + General + + + + + Privacy + + + + ); + + expect( + screen.getByRole( 'button', { + name: 'Expand or collapse General', + } ) + ).toBeVisible(); + expect( + screen.getByRole( 'button', { + name: 'Expand or collapse Privacy', } ) ).toBeVisible(); } ); From 6cf86e7eca204cf1d899edce5a1055d937083bc8 Mon Sep 17 00:00:00 2001 From: Marco Ciampini Date: Mon, 9 Mar 2026 17:35:45 +0100 Subject: [PATCH 2/5] Add CHANGELOG entry for CollapsibleCard trigger label enhancement Made-with: Cursor --- packages/ui/CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/ui/CHANGELOG.md b/packages/ui/CHANGELOG.md index 956cf975f4588d..22b569a0383cd7 100644 --- a/packages/ui/CHANGELOG.md +++ b/packages/ui/CHANGELOG.md @@ -12,6 +12,7 @@ ### Enhancements +- `CollapsibleCard`: Include the card title in the toggle button's accessible label so multiple collapsible cards on the same page are distinguishable by screen reader users ([#76329](https://github.com/WordPress/gutenberg/pull/76329)). - `Notice`: Improve narrow layout by letting description and actions span the icon column when a title is present ([#76202](https://github.com/WordPress/gutenberg/pull/76202)). ## 0.8.0 (2026-03-04) From 6c1c8771b8f9688486a4c6fb57c29ae6d03a0aef Mon Sep 17 00:00:00 2001 From: Marco Ciampini Date: Mon, 9 Mar 2026 17:42:13 +0100 Subject: [PATCH 3/5] Address PR feedback: fix transient clear, keep context private, improve fallback - Split Card.Title's useLayoutEffect into two: a per-render sync (no cleanup) and a separate unmount-only cleanup, avoiding a transient clear of the title text between effect runs. - Remove TitleTextContext from the card barrel export to keep it private; import directly from card/context in collapsible-card. - Add children to the fallback effect's dependency array so the header content label re-syncs when children change. Made-with: Cursor --- packages/ui/src/card/index.ts | 1 - packages/ui/src/card/title.tsx | 8 +++++++- packages/ui/src/collapsible-card/header.tsx | 9 ++++----- 3 files changed, 11 insertions(+), 7 deletions(-) diff --git a/packages/ui/src/card/index.ts b/packages/ui/src/card/index.ts index a72d41b2845030..c23fa3474fb191 100644 --- a/packages/ui/src/card/index.ts +++ b/packages/ui/src/card/index.ts @@ -5,4 +5,3 @@ import { FullBleed } from './full-bleed'; import { Title } from './title'; export { Root, Header, Content, FullBleed, Title }; -export { TitleTextContext } from './context'; diff --git a/packages/ui/src/card/title.tsx b/packages/ui/src/card/title.tsx index 436ac109f23e64..0a4639787f4997 100644 --- a/packages/ui/src/card/title.tsx +++ b/packages/ui/src/card/title.tsx @@ -28,9 +28,15 @@ export const Title = forwardRef< HTMLDivElement, TitleProps >( useLayoutEffect( () => { const text = internalRef.current?.textContent?.trim() || undefined; titleTextContext?.setTitleText( text ); - return () => titleTextContext?.setTitleText( undefined ); } ); + // Clear the registered text on unmount so the parent can fall back + // to the header content text. Kept separate from the per-render + // sync above to avoid transiently clearing the value between runs. + useLayoutEffect( () => { + return () => titleTextContext?.setTitleText( undefined ); + }, [ titleTextContext ] ); + const element = useRender( { defaultTagName: 'div', render, diff --git a/packages/ui/src/collapsible-card/header.tsx b/packages/ui/src/collapsible-card/header.tsx index df79f28802a466..65b207e9c039c4 100644 --- a/packages/ui/src/collapsible-card/header.tsx +++ b/packages/ui/src/collapsible-card/header.tsx @@ -12,7 +12,7 @@ import { import { __, sprintf } from '@wordpress/i18n'; import { chevronDown, chevronUp } from '@wordpress/icons'; import * as Card from '../card'; -import { TitleTextContext } from '../card'; +import { TitleTextContext } from '../card/context'; import { IconButton } from '../icon-button'; import styles from './style.module.css'; import type { HeaderProps } from './types'; @@ -38,15 +38,14 @@ export const Header = forwardRef< HTMLDivElement, HeaderProps >( const titleTextContextValue = useMemo( () => ( { setTitleText } ), [] ); // Fallback: read the header content's text when no Card.Title is - // present. When a Card.Title is used, its own effect keeps the label - // in sync (including dynamic updates); this only covers the uncommon - // case of a header without a Card.Title. + // present. `children` is listed as a dependency so the label + // re-syncs when the header content changes. useLayoutEffect( () => { if ( titleText === undefined ) { const text = headerContentRef.current?.textContent?.trim(); setHeaderText( text || undefined ); } - }, [ titleText ] ); + }, [ titleText, children ] ); const identifierText = titleText ?? headerText; const triggerLabel = identifierText From 3fbb4cedfb05837ef915959f6a4a0b7b2c8ce156 Mon Sep 17 00:00:00 2001 From: Marco Ciampini Date: Mon, 9 Mar 2026 17:58:22 +0100 Subject: [PATCH 4/5] Extract custom hooks and skip unnecessary DOM reads when context is absent Made-with: Cursor --- packages/ui/src/card/title.tsx | 51 +++++++++++------ packages/ui/src/collapsible-card/header.tsx | 61 +++++++++++++-------- 2 files changed, 71 insertions(+), 41 deletions(-) diff --git a/packages/ui/src/card/title.tsx b/packages/ui/src/card/title.tsx index 0a4639787f4997..be2b73f68bdb93 100644 --- a/packages/ui/src/card/title.tsx +++ b/packages/ui/src/card/title.tsx @@ -10,32 +10,49 @@ import { TitleTextContext } from './context'; import styles from './style.module.css'; import type { TitleProps } from './types'; +/** + * Syncs the rendered text content of the given element to the nearest + * TitleTextContext (used by CollapsibleCard to build the trigger's + * accessible label). No-ops when rendered outside a CollapsibleCard. + * + * Runs on every render so the label stays current if children change + * dynamically. The cost is a single `textContent` read plus a bail-out + * `setState` when the value hasn't changed — skipped entirely when + * there is no context provider (i.e. the common non-collapsible case). + */ +function useSyncTitleText( ref: React.RefObject< HTMLElement | null > ) { + const titleTextContext = useContext( TitleTextContext ); + + useLayoutEffect( () => { + if ( ! titleTextContext ) { + return; + } + + const text = ref.current?.textContent?.trim() || undefined; + titleTextContext.setTitleText( text ); + } ); + + // Unmount-only cleanup — kept separate from the per-render sync + // above to avoid transiently clearing the value between runs. + useLayoutEffect( () => { + if ( ! titleTextContext ) { + return; + } + + return () => titleTextContext.setTitleText( undefined ); + }, [ titleTextContext ] ); +} + /** * The title for a card. Renders as a `
` by default — use the `render` * prop to swap in a semantic heading element when appropriate. */ export const Title = forwardRef< HTMLDivElement, TitleProps >( function CardTitle( { render, ...restProps }, forwardedRef ) { - const titleTextContext = useContext( TitleTextContext ); const internalRef = useRef< HTMLDivElement >( null ); const mergedRef = useMergeRefs( [ internalRef, forwardedRef ] ); - // Sync the rendered text content to the parent context (used by - // CollapsibleCard to build the trigger's accessible label). Runs on - // every render so the label stays current if children change - // dynamically. The cost is a single `textContent` read plus a - // bail-out `setState` when the value hasn't changed. - useLayoutEffect( () => { - const text = internalRef.current?.textContent?.trim() || undefined; - titleTextContext?.setTitleText( text ); - } ); - - // Clear the registered text on unmount so the parent can fall back - // to the header content text. Kept separate from the per-render - // sync above to avoid transiently clearing the value between runs. - useLayoutEffect( () => { - return () => titleTextContext?.setTitleText( undefined ); - }, [ titleTextContext ] ); + useSyncTitleText( internalRef ); const element = useRender( { defaultTagName: 'div', diff --git a/packages/ui/src/collapsible-card/header.tsx b/packages/ui/src/collapsible-card/header.tsx index 65b207e9c039c4..1ea9bdd0af6f84 100644 --- a/packages/ui/src/collapsible-card/header.tsx +++ b/packages/ui/src/collapsible-card/header.tsx @@ -1,6 +1,6 @@ import { Collapsible } from '@base-ui/react/collapsible'; import clsx from 'clsx'; -import type { MouseEvent } from 'react'; +import type { MouseEvent, ReactNode } from 'react'; import { forwardRef, useCallback, @@ -17,6 +17,40 @@ import { IconButton } from '../icon-button'; import styles from './style.module.css'; import type { HeaderProps } from './types'; +/** + * Tracks the title text from a Card.Title child (via TitleTextContext) and + * falls back to the full header content text when no Card.Title is present. + * Returns the context provider value, a ref for the header content wrapper, + * and the composed trigger label. + */ +function useTriggerLabel( children: ReactNode ) { + const headerContentRef = useRef< HTMLDivElement >( null ); + const [ titleText, setTitleText ] = useState< string >(); + const [ headerText, setHeaderText ] = useState< string >(); + const titleTextContextValue = useMemo( () => ( { setTitleText } ), [] ); + + // Fallback: read the header content's text when no Card.Title is + // present. `children` is listed as a dependency so the label + // re-syncs when the header content changes. + useLayoutEffect( () => { + if ( titleText === undefined ) { + const text = headerContentRef.current?.textContent?.trim(); + setHeaderText( text || undefined ); + } + }, [ titleText, children ] ); + + const identifierText = titleText ?? headerText; + const triggerLabel = identifierText + ? sprintf( + /* translators: %s: title of the card being expanded or collapsed */ + __( 'Expand or collapse %s' ), + identifierText + ) + : __( 'Expand or collapse' ); + + return { titleTextContextValue, headerContentRef, triggerLabel }; +} + /** * The header of a collapsible card. Always visible, and acts as the * toggle trigger — clicking anywhere on it expands or collapses the @@ -32,29 +66,8 @@ export const Header = forwardRef< HTMLDivElement, HeaderProps >( ref ) { const triggerRef = useRef< HTMLButtonElement >( null ); - const headerContentRef = useRef< HTMLDivElement >( null ); - const [ titleText, setTitleText ] = useState< string >(); - const [ headerText, setHeaderText ] = useState< string >(); - const titleTextContextValue = useMemo( () => ( { setTitleText } ), [] ); - - // Fallback: read the header content's text when no Card.Title is - // present. `children` is listed as a dependency so the label - // re-syncs when the header content changes. - useLayoutEffect( () => { - if ( titleText === undefined ) { - const text = headerContentRef.current?.textContent?.trim(); - setHeaderText( text || undefined ); - } - }, [ titleText, children ] ); - - const identifierText = titleText ?? headerText; - const triggerLabel = identifierText - ? sprintf( - /* translators: %s: title of the card being expanded or collapsed */ - __( 'Expand or collapse %s' ), - identifierText - ) - : __( 'Expand or collapse' ); + const { titleTextContextValue, headerContentRef, triggerLabel } = + useTriggerLabel( children ); const handleHeaderClick = useCallback( ( event: MouseEvent< HTMLDivElement > ) => { From 0628aefb6a7bd3e3afcb4fb01434b0b9fb2fcf3b Mon Sep 17 00:00:00 2001 From: Marco Ciampini Date: Mon, 9 Mar 2026 18:10:06 +0100 Subject: [PATCH 5/5] Widen internalRef type to HTMLElement to match render prop flexibility Made-with: Cursor --- packages/ui/src/card/title.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ui/src/card/title.tsx b/packages/ui/src/card/title.tsx index be2b73f68bdb93..ca4b5a384678d5 100644 --- a/packages/ui/src/card/title.tsx +++ b/packages/ui/src/card/title.tsx @@ -49,7 +49,7 @@ function useSyncTitleText( ref: React.RefObject< HTMLElement | null > ) { */ export const Title = forwardRef< HTMLDivElement, TitleProps >( function CardTitle( { render, ...restProps }, forwardedRef ) { - const internalRef = useRef< HTMLDivElement >( null ); + const internalRef = useRef< HTMLElement >( null ); const mergedRef = useMergeRefs( [ internalRef, forwardedRef ] ); useSyncTitleText( internalRef );