diff --git a/.changeset/dropdown-menu-group-block-radio-title.md b/.changeset/dropdown-menu-group-block-radio-title.md new file mode 100644 index 0000000000000..fb5a3bc881d8d --- /dev/null +++ b/.changeset/dropdown-menu-group-block-radio-title.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/cli': patch +--- + +[docs] The "DropdownMenuGroup — Selectable rows" block titles its radio set with the set's own `title` instead of wrapping it in a `DropdownMenuGroup`, so the menu exposes one group named "Model" rather than two nested groups with the same name. + +@vjeux diff --git a/.changeset/dropdown-menu-radio-group-title.md b/.changeset/dropdown-menu-radio-group-title.md new file mode 100644 index 0000000000000..b7e1a1b028891 --- /dev/null +++ b/.changeset/dropdown-menu-radio-group-title.md @@ -0,0 +1,7 @@ +--- +'@astryxdesign/core': patch +--- + +[feat] `DropdownMenuRadioGroup` (also exported as `ContextMenuRadioGroup`) takes a `title`: a visible heading above its radios, drawn like a `DropdownMenuGroup` heading, that names the group. With a title, `label` is optional, and the title names the group so the name always holds the visible heading: a caller's `aria-labelledby` is read after it, and `label` or a caller's `aria-label` is ignored, with a development warning when it differs from the title's text. A `title` that renders no text counts as no title. Give a menu section that holds one radio set this title instead of wrapping the set in a `DropdownMenuGroup`: the menu then exposes one group named by its heading rather than two nested groups with the same name. Without `title` nothing changes. + +@vjeux diff --git a/apps/storybook/stories/DropdownMenu.stories.tsx b/apps/storybook/stories/DropdownMenu.stories.tsx index 93639a6f58308..75fb06b0d414d 100644 --- a/apps/storybook/stories/DropdownMenu.stories.tsx +++ b/apps/storybook/stories/DropdownMenu.stories.tsx @@ -869,6 +869,40 @@ export const LabRadioGroup: Story = { }, }; +/** + * A section of a menu that holds one radio set gives the set a `title` + * instead of wrapping it in a DropdownMenuGroup: the heading shows as any + * group heading does, and the menu exposes the set as one group named by + * that heading. + */ +export const RadioGroupWithTitle: Story = { + name: 'Radio group with a title', + render: function RadioGroupWithTitleStory() { + const [hidden, setHidden] = useState('hide'); + const [showDrafts, setShowDrafts] = useState(true); + return ( +
+ + + + + + + + + {hidden} +
+ ); + }, +}; + export const LabSelectableSizes: Story = { render: function LabSelectableSizesStory() { const [sm, setSm] = useState('a'); diff --git a/apps/storybook/stories/RadioGroupA11y.stories.tsx b/apps/storybook/stories/RadioGroupA11y.stories.tsx index 9668c4b7315a9..d0b2a07ea1b86 100644 --- a/apps/storybook/stories/RadioGroupA11y.stories.tsx +++ b/apps/storybook/stories/RadioGroupA11y.stories.tsx @@ -98,6 +98,8 @@ export const SegmentedOptionGroupDisabledWithMessage = storyFor( export const MenuGroupSelected = storyFor('menu-group-selected'); export const MenuGroupNoneSelected = storyFor('menu-group-none-selected'); +export const MenuGroupTitled = storyFor('menu-group-titled'); +export const MenuGroupTitledLabelled = storyFor('menu-group-titled-labelled'); export const MenuOptionUnselectedDescribed = storyFor( 'menu-option-unselected-described', ); diff --git a/packages/cli/assets/templates/blocks/components/DropdownMenuGroup/DropdownMenuGroupWithSelectable.doc.mjs b/packages/cli/assets/templates/blocks/components/DropdownMenuGroup/DropdownMenuGroupWithSelectable.doc.mjs index ed8e9ff06a5f2..06a905f409aa0 100644 --- a/packages/cli/assets/templates/blocks/components/DropdownMenuGroup/DropdownMenuGroupWithSelectable.doc.mjs +++ b/packages/cli/assets/templates/blocks/components/DropdownMenuGroup/DropdownMenuGroupWithSelectable.doc.mjs @@ -7,7 +7,7 @@ export const doc = { name: 'DropdownMenuGroup — Selectable rows', displayName: 'DropdownMenuGroup — Selectable rows', description: - 'Titled groups around a radio group and checkbox rows — the rows the data API cannot express, which is why the heading lives in compound mode.', + 'A radio set titled by its own title and a titled group of checkbox rows — the rows the data API cannot express, which is why the headings live in compound mode. A section holding one radio set titles the set itself rather than a group around it, so it is one group with one name.', isReady: true, aspectRatio: 16 / 9, componentsUsed: [ diff --git a/packages/cli/assets/templates/blocks/components/DropdownMenuGroup/DropdownMenuGroupWithSelectable.tsx b/packages/cli/assets/templates/blocks/components/DropdownMenuGroup/DropdownMenuGroupWithSelectable.tsx index 12d35ca5e4cd3..7a85af2309af7 100644 --- a/packages/cli/assets/templates/blocks/components/DropdownMenuGroup/DropdownMenuGroupWithSelectable.tsx +++ b/packages/cli/assets/templates/blocks/components/DropdownMenuGroup/DropdownMenuGroupWithSelectable.tsx @@ -19,17 +19,15 @@ export default function DropdownMenuGroupWithSelectable() { return ( - - - - - - - + + + + + - * + * * * * @@ -94,6 +160,7 @@ export function DropdownMenuRadioGroup({ value, onChange, label, + title, hasCloseOnSelect = true, children, ref, @@ -106,14 +173,94 @@ export function DropdownMenuRadioGroup({ () => ({value, onChange, hasCloseOnSelect}), [value, onChange, hasCloseOnSelect], ); + const headingId = useId(); + const headingRef = useRef(null); + // A title counts only while it renders text: `true`, an empty fragment or a + // component that returns null leaves the group named as without one. + // Whether the heading has text is read from the DOM after each commit of the + // group, before paint, and again whenever the heading's content changes + // without the group rendering (a title component's own state, a context or + // a store); until the first read a given title is taken to name the group, + // and the naming warnings wait for it. + const isTitleGiven = isRenderable(title); + const [measuredTitleText, setMeasuredTitleText] = useState( + null, + ); + useIsomorphicLayoutEffect(() => { + const text = headingText(headingRef.current); + if (text !== measuredTitleText) { + setMeasuredTitleText(text); + } + }); + // Content changes come from the shared observer (one for every observed + // element, `architecture:react-component-runtime` INV7), not one per group. + useIsomorphicLayoutEffect(() => { + const heading = headingRef.current; + if (heading == null) { + return; + } + return observeContent(heading, () => { + setMeasuredTitleText(headingText(heading)); + }); + }, [isTitleGiven]); + const titleHasText = measuredTitleText !== ''; + const hasTitle = isTitleGiven && titleHasText; + const isNamingSettled = !isTitleGiven || measuredTitleText != null; + const callerLabelledBy = rest['aria-labelledby']; + // Without a title, `label` wins over a caller's own `aria-label`. A visible + // title always names the group, so the name holds the text a speech-input + // user reads (the radio-group pattern's label-in-name rule): a caller's + // `aria-labelledby` is read after it, and `label` or `aria-label` is ignored. + const ariaLabel = label != null && label !== '' ? label : rest['aria-label']; + const hasAriaLabel = ariaLabel != null && ariaLabel !== ''; + const isUnnamed = !hasAriaLabel && callerLabelledBy == null; + useDevWarning( + 'DropdownMenuRadioGroup', + 'Give the group a label or a title: an unnamed radio group is an accessibility defect.', + !isTitleGiven && isUnnamed, + ); + useDevWarning( + 'DropdownMenuRadioGroup', + 'The title renders no text, so it does not name the group: give it text, or add a label. An unnamed radio group is an accessibility defect.', + isTitleGiven && isNamingSettled && !titleHasText && isUnnamed, + ); + // Compared with the text the heading renders, so `title={Sort by}` + // beside `label="Sort by"` does not warn. + useDevWarning( + 'DropdownMenuRadioGroup', + 'The title names the group, so label and aria-label are ignored beside it.', + isNamingSettled && + hasTitle && + hasAriaLabel && + normalizeName(ariaLabel) !== measuredTitleText, + ); return (
+ {isTitleGiven && ( +
+ {title} +
+ )} {children} diff --git a/packages/core/src/DropdownMenu/DropdownMenuSelectable.test.tsx b/packages/core/src/DropdownMenu/DropdownMenuSelectable.test.tsx index 75b10caf3ca93..0e6551153bc5c 100644 --- a/packages/core/src/DropdownMenu/DropdownMenuSelectable.test.tsx +++ b/packages/core/src/DropdownMenu/DropdownMenuSelectable.test.tsx @@ -9,9 +9,9 @@ * outcomes live in their reusable contracts; Menu retains navigation. */ -import {useState} from 'react'; +import {createElement, Fragment, useState} from 'react'; import {describe, it, expect, vi, beforeEach} from 'vitest'; -import {render, screen, fireEvent, waitFor} from '@testing-library/react'; +import {act, render, screen, fireEvent, waitFor} from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import {DropdownMenu} from './DropdownMenu'; import {DropdownMenuCheckboxItem} from './DropdownMenuCheckboxItem'; @@ -633,3 +633,475 @@ describe('selectable item refs', () => { ); }); }); + +describe('DropdownMenuRadioGroup title', () => { + const h = {hidden: true} as const; + + async function openMenu(group: React.ReactNode) { + const user = userEvent.setup(); + render( + + {}} + /> + {group} + , + ); + await user.click(screen.getByRole('button', {name: /Filter/})); + // A pointer open focuses the menu itself; let that land first. + const menu = screen.getByRole('menu', h); + await waitFor(() => expect(menu).toHaveFocus()); + return user; + } + + it('names the group by a visible heading, so a titled radio set is one group with one name', async () => { + await openMenu( + {}} + title="Hidden files"> + + + , + ); + const groups = screen.getAllByRole('group', h); + expect(groups).toHaveLength(1); + const [group] = groups; + expect(group).toHaveAccessibleName('Hidden files'); + expect(group).not.toHaveAttribute('aria-label'); + const heading = screen.getByText('Hidden files'); + expect(group).toHaveAttribute('aria-labelledby', heading.id); + // The heading is the menu's section heading: same theme target as a + // DropdownMenuGroup's, and not a row. + expect(heading).toHaveClass('astryx-dropdown-menu-section-heading'); + expect(heading).not.toHaveAttribute('role'); + expect(group.firstElementChild).toBe(heading); + expect( + screen + .getAllByRole('menuitemradio', h) + .map(item => item.getAttribute('aria-checked')), + ).toEqual(['true', 'false']); + }); + + it('skips the heading in arrow-key order and typeahead', async () => { + const user = await openMenu( + {}} + title="Hidden files"> + + + , + ); + const drafts = screen.getByRole('menuitemcheckbox', { + name: 'Show drafts', + ...h, + }); + act(() => drafts.focus()); + await user.keyboard('{ArrowDown}'); + expect( + screen.getByRole('menuitemradio', {name: 'Hide hidden files', ...h}), + ).toHaveFocus(); + // Typing the heading's first letter lands on a row starting with it. + act(() => drafts.focus()); + await user.keyboard('h'); + expect( + screen.getByRole('menuitemradio', {name: 'Hide hidden files', ...h}), + ).toHaveFocus(); + }); + + it.each([ + ['a label', {label: 'Hidden files visibility'}], + ['an aria-label', {'aria-label': 'Hidden files visibility'}], + ])( + 'with %s too, the visible title still names the group, and the other name warns in development', + async (_prop, nameProps) => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + await openMenu( + {}} + title="Hidden files" + {...nameProps}> + + , + ); + const group = screen.getByRole('group', h); + // The name holds the heading a speech-input user reads. + expect(group).toHaveAccessibleName('Hidden files'); + expect(group).toHaveAttribute( + 'aria-labelledby', + screen.getByText('Hidden files').id, + ); + expect(group).not.toHaveAttribute('aria-label'); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('The title names the group'), + ); + } finally { + warn.mockRestore(); + } + }, + ); + + it('a label that repeats the title does not warn', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + await openMenu( + {}} + label="Hidden files" + title="Hidden files"> + + , + ); + expect(screen.getByRole('group', h)).toHaveAccessibleName('Hidden files'); + expect(warn).not.toHaveBeenCalled(); + } finally { + warn.mockRestore(); + } + }); + + it('without a title nothing changes: the label names the group and no heading renders', async () => { + await openMenu( + {}} + label="Sort by"> + + , + ); + const group = screen.getByRole('group', h); + expect(group).toHaveAttribute('aria-label', 'Sort by'); + expect(group).not.toHaveAttribute('aria-labelledby'); + expect( + group.querySelector('.astryx-dropdown-menu-section-heading'), + ).toBeNull(); + }); + + it("names the group by a caller's aria-label when label is omitted and there is no title, and does not warn", async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + await openMenu( + {}} + aria-label="Hidden files visibility"> + + , + ); + const group = screen.getByRole('group', h); + expect(group).toHaveAccessibleName('Hidden files visibility'); + expect(group).not.toHaveAttribute('aria-labelledby'); + expect(warn).not.toHaveBeenCalled(); + } finally { + warn.mockRestore(); + } + }); + + it("reads a caller's aria-labelledby after the title, which still names the group first", async () => { + const user = userEvent.setup(); + render( + <> + Visibility + + {}} + aria-labelledby="radio-set-name" + title="Hidden files"> + + + + , + ); + await user.click(screen.getByRole('button', {name: /Filter/})); + const group = await screen.findByRole('group', h); + expect(group).toHaveAttribute( + 'aria-labelledby', + `${screen.getByText('Hidden files').id} radio-set-name`, + ); + expect(group).toHaveAccessibleName('Hidden files Visibility'); + }); + + function NoTitle() { + return null; + } + it.each([ + ['true', true, 'Give the group a label or a title'], + [ + 'an empty fragment', + createElement(Fragment), + 'The title renders no text, so it does not name the group', + ], + [ + 'a component that returns null', + createElement(NoTitle), + 'The title renders no text, so it does not name the group', + ], + ])( + 'a title that renders no text, %s, counts as no title: the label names the group, no heading shows, and an unnamed group warns', + async (_case, title, unnamedWarning) => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const {unmount} = render( + + {}} + label="Hidden files" + title={title}> + + + , + ); + await userEvent + .setup() + .click(screen.getByRole('button', {name: /Filter/})); + const group = await screen.findByRole('group', h); + expect(group).toHaveAccessibleName('Hidden files'); + expect(group).not.toHaveAttribute('aria-labelledby'); + const heading = group.querySelector( + '.astryx-dropdown-menu-section-heading', + ); + // The heading, when one mounts to be read, shows nothing. + if (heading != null) { + expect(getComputedStyle(heading).display).toBe('none'); + } + // Beside a title that counts, a different label would warn. + expect(warn).not.toHaveBeenCalledWith( + expect.stringContaining('The title names the group'), + ); + unmount(); + + await openMenu( + {}} + title={title}> + + , + ); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining(unnamedWarning), + ); + } finally { + warn.mockRestore(); + } + }, + ); + + it.each([ + ['the same text', 'Hidden files', false], + ['the same text with other spacing', ' Hidden files ', false], + ['other text', 'Hidden files visibility', true], + ])( + "compares a label with the title's rendered text: a node title with %s warns only when they differ", + async (_case, label, warns) => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + await openMenu( + {}} + label={label} + title={ + + Hidden files + + }> + + , + ); + expect(screen.getByRole('group', h)).toHaveAccessibleName( + 'Hidden files', + ); + const warned = warn.mock.calls.some(call => + String(call[0]).includes('The title names the group'), + ); + expect(warned).toBe(warns); + } finally { + warn.mockRestore(); + } + }, + ); + + it('reads the title again on each render, so a title that starts rendering text names the group', async () => { + function LateTitle({text}: {text: string | null}) { + return text; + } + function Menu({text}: {text: string | null}) { + return ( + + {}} + label="Hidden files" + title={}> + + + + ); + } + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const {rerender} = render(); + await userEvent + .setup() + .click(screen.getByRole('button', {name: /Filter/})); + const group = await screen.findByRole('group', h); + expect(group).toHaveAccessibleName('Hidden files'); + + rerender(); + await waitFor(() => + expect(group).toHaveAttribute( + 'aria-labelledby', + screen.getByText('Hidden files').id, + ), + ); + expect( + getComputedStyle(screen.getByText('Hidden files')).display, + ).not.toBe('none'); + expect(group).not.toHaveAttribute('aria-label'); + } finally { + warn.mockRestore(); + } + }); + + it('re-reads a title that changes its own text, through its own state, while the menu shows', async () => { + let setTitleText!: (text: string | null) => void; + function SelfUpdatingTitle() { + const [text, setText] = useState(null); + setTitleText = setText; + return text; + } + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + await openMenu( + {}} + label="Hidden files" + title={}> + + , + ); + const group = screen.getByRole('group', h); + expect(group).toHaveAccessibleName('Hidden files'); + + // Text arrives without the group rendering: the title names the group. + await act(async () => setTitleText('Sort by')); + await waitFor(() => expect(group).toHaveAccessibleName('Sort by')); + expect(getComputedStyle(screen.getByText('Sort by')).display).not.toBe( + 'none', + ); + expect(group).not.toHaveAttribute('aria-label'); + + // Text goes away: the label names the group again, and the heading + // shows nothing. + await act(async () => setTitleText(null)); + await waitFor(() => expect(group).toHaveAccessibleName('Hidden files')); + const heading = group.querySelector( + '.astryx-dropdown-menu-section-heading', + ); + expect(heading).not.toBeNull(); + expect(getComputedStyle(heading!).display).toBe('none'); + } finally { + warn.mockRestore(); + } + }); + + it('titled groups read their titles through one shared observer, not one each', async () => { + const Original = globalThis.MutationObserver; + const observedBy = new Map(); + class Recording extends Original { + observe(target: Node, options?: MutationObserverInit) { + observedBy.set(target, this); + super.observe(target, options); + } + } + globalThis.MutationObserver = Recording; + try { + await openMenu( + <> + {}} + title="Sort by"> + + + {}} + title="Group by"> + + + , + ); + const headings = Array.from( + document.querySelectorAll('.astryx-dropdown-menu-section-heading'), + ); + expect(headings).toHaveLength(2); + const [first, second] = headings.map(heading => observedBy.get(heading)); + expect(first).toBeDefined(); + expect(second).toBe(first); + } finally { + globalThis.MutationObserver = Original; + } + }); + + it('a focusable node in the title is not reachable by the arrow keys, as in a DropdownMenuGroup heading', async () => { + await openMenu( + {}} + title={ + <> + Hidden files + + }> + + + , + ); + const menu = screen.getByRole('menu', h); + const info = screen.getByRole('button', {name: 'Info', ...h}); + const hide = screen.getByRole('menuitemradio', { + name: 'Hide hidden files', + ...h, + }); + const show = screen.getByRole('menuitemradio', { + name: 'Show hidden files', + ...h, + }); + + hide.focus(); + fireEvent.keyDown(menu, {key: 'ArrowDown'}); + expect(show).toHaveFocus(); + fireEvent.keyDown(menu, {key: 'ArrowDown'}); + expect(info).not.toHaveFocus(); + fireEvent.keyDown(menu, {key: 'ArrowUp'}); + expect(info).not.toHaveFocus(); + }); + + it('warns in development when the group has neither a label nor a title', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + await openMenu( + {}}> + + , + ); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining( + 'DropdownMenuRadioGroup: Give the group a label or a title', + ), + ); + } finally { + warn.mockRestore(); + } + }); +}); diff --git a/packages/core/src/DropdownMenu/__tests__/RadioGroupTitle.a11y.chromium.spec.ts b/packages/core/src/DropdownMenu/__tests__/RadioGroupTitle.a11y.chromium.spec.ts new file mode 100644 index 0000000000000..3cb119abe5f8a --- /dev/null +++ b/packages/core/src/DropdownMenu/__tests__/RadioGroupTitle.a11y.chromium.spec.ts @@ -0,0 +1,69 @@ +// Copyright (c) Meta Platforms, Inc. and affiliates. + +/** + * @file RadioGroupTitle.a11y.chromium.spec.ts + * @input The built Storybook "Radio group with a title" story and real + * Chromium's accessibility tree. + * @output Evidence that a DropdownMenuRadioGroup with a `title` is one group + * with one name: the heading shows as a group heading, the radios sit in a + * single group named by it, and the heading is not a row. + * @position Browser proof behind the jsdom title suite in + * DropdownMenuSelectable.test.tsx: what a screen reader is handed. + */ + +import {expect, test} from '@playwright/test'; +import { + DEFAULT_STORYBOOK_DIR, + serveStorybook, + type StaticServer, +} from '@astryxdesign/a11y-spec/storybook'; + +let storybook: StaticServer; +const storyId = 'core-dropdownmenu--radio-group-with-title'; + +test.beforeAll(async () => { + storybook = await serveStorybook( + process.env.ASTRYX_STORYBOOK_DIR ?? DEFAULT_STORYBOOK_DIR, + ); +}); +test.afterAll(async () => { + await storybook?.close(); +}); + +test('a titled radio set is one group named by its visible heading', async ({ + page, +}) => { + await page.goto( + `${storybook.origin}/iframe.html?id=${storyId}&viewMode=story`, + ); + await expect(page.getByTestId('radio-title-fixture')).toBeVisible(); + await page.getByRole('button', {name: 'Filter'}).click(); + const menu = page.getByRole('menu', {name: 'Filter'}); + await expect(menu).toBeVisible(); + + // The menu exposes exactly one group, named once, holding both radios. + await expect(menu).toMatchAriaSnapshot(` + - menu "Filter": + - menuitemcheckbox "Show drafts" [checked] + - separator + - group "Hidden files": + - text: Hidden files + - menuitemradio "Hide hidden files" [checked] + - menuitemradio "Show hidden files" + `); + const groups = menu.getByRole('group'); + await expect(groups).toHaveCount(1); + await expect(groups).toHaveAccessibleName('Hidden files'); + await expect(menu.getByText('Hidden files', {exact: true})).toBeVisible(); + + // The heading is not a stop: arrows go from the checkbox row to the first + // radio, and a pick works as in any radio group. + await menu.getByRole('menuitemcheckbox', {name: 'Show drafts'}).focus(); + await page.keyboard.press('ArrowDown'); + await expect( + menu.getByRole('menuitemradio', {name: 'Hide hidden files'}), + ).toBeFocused(); + await page.keyboard.press('ArrowDown'); + await page.keyboard.press('Enter'); + await expect(page.getByTestId('hidden')).toHaveText('show'); +}); diff --git a/packages/core/src/RadioList/__tests__/RadioGroup.a11y.renders.tsx b/packages/core/src/RadioList/__tests__/RadioGroup.a11y.renders.tsx index 0b97774fcd176..89ebd3397aa13 100644 --- a/packages/core/src/RadioList/__tests__/RadioGroup.a11y.renders.tsx +++ b/packages/core/src/RadioList/__tests__/RadioGroup.a11y.renders.tsx @@ -122,10 +122,15 @@ function MenuRadioFixture({ initial = 'newest', described = false, disabledOption, + isTitled = false, + titledLabel, }: { initial?: string; described?: boolean; disabledOption?: 'oldest'; + isTitled?: boolean; + /** A `label` beside the title, which the title outranks. */ + titledLabel?: string; }) { const [value, setValue] = useState(initial || undefined); return ( @@ -133,7 +138,9 @@ function MenuRadioFixture({ , 'menu-group-none-selected': () => , + 'menu-group-titled': () => , + 'menu-group-titled-labelled': () => ( + + ), 'menu-option-unselected-described': () => , 'menu-option-selected': () => , 'menu-option-disabled': () => , diff --git a/packages/core/src/RadioList/__tests__/RadioGroup.a11y.states.ts b/packages/core/src/RadioList/__tests__/RadioGroup.a11y.states.ts index ae2d60df80007..b530623478d41 100644 --- a/packages/core/src/RadioList/__tests__/RadioGroup.a11y.states.ts +++ b/packages/core/src/RadioList/__tests__/RadioGroup.a11y.states.ts @@ -423,6 +423,41 @@ export const RADIO_GROUP_BINDING_STATES = [ storyId: story('menu-group-none-selected'), opensMenu: true, }, + { + id: 'menu-group-titled', + binding: 'DropdownMenuRadioGroup', + summary: 'a menu-radio group named by its visible title', + facts: groupFacts({ + role: 'group', + disabled: null, + tabReachable: false, + spaceSelection: false, + arrowSelection: false, + movement: 'none', + }), + subjectName: 'Sort by', + relations: MENU_RELATIONS, + storyId: story('menu-group-titled'), + opensMenu: true, + }, + { + id: 'menu-group-titled-labelled', + binding: 'DropdownMenuRadioGroup', + summary: + 'a titled menu-radio group also given a label, still named by its visible title', + facts: groupFacts({ + role: 'group', + disabled: null, + tabReachable: false, + spaceSelection: false, + arrowSelection: false, + movement: 'none', + }), + subjectName: 'Sort by', + relations: MENU_RELATIONS, + storyId: story('menu-group-titled-labelled'), + opensMenu: true, + }, { id: 'menu-option-unselected-described', binding: 'DropdownMenuRadioItem', diff --git a/packages/core/src/RadioList/__tests__/RadioGroup.a11y.test.tsx b/packages/core/src/RadioList/__tests__/RadioGroup.a11y.test.tsx index 54b179dea6a3d..f3ce81b06776d 100644 --- a/packages/core/src/RadioList/__tests__/RadioGroup.a11y.test.tsx +++ b/packages/core/src/RadioList/__tests__/RadioGroup.a11y.test.tsx @@ -199,9 +199,11 @@ describe('the radio-group binding inventory', () => { it('locks which bindings expose a visible label for the bound subject', () => { const wrong = RADIO_GROUP_BINDING_STATES.filter(state => { const expected = - state.binding !== 'SegmentedControl' && - state.binding !== 'DropdownMenuRadioGroup' && - state.id !== 'segmented-option-selected-hidden-label'; + state.id === 'menu-group-titled' || + state.id === 'menu-group-titled-labelled' || + (state.binding !== 'SegmentedControl' && + state.binding !== 'DropdownMenuRadioGroup' && + state.id !== 'segmented-option-selected-hidden-label'); return state.facts.visibleLabel !== expected; }).map(state => state.id); expect(wrong).toEqual([]);