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 (
+
+
+
+
+
+
+
+
+
+
+
+ );
+ },
+};
+
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([]);