Skip to content
Open
104 changes: 102 additions & 2 deletions packages/react-aria/test/menu/useMenu.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,15 @@
*/

import {AriaMenuProps, useMenu} from '../../src/menu/useMenu';

import {getChildNodes} from 'react-stately/private/collections/getChildNodes';
import {Item} from 'react-stately/Item';
import {Key} from '@react-types/shared';
import {Key, Node} from '@react-types/shared';
import {pointerMap, render} from '@react-spectrum/test-utils-internal';
import React from 'react';
import {Section} from 'react-stately/Section';
import {TreeState, useTreeState} from 'react-stately/useTreeState';
import {useMenuItem} from '../../src/menu/useMenuItem';
import {useMenuSection} from '../../src/menu/useMenuSection';
import userEvent from '@testing-library/user-event';

function Menu<T extends object>(props: AriaMenuProps<T> & {onSelect: () => void}) {
Expand Down Expand Up @@ -81,6 +83,48 @@ function VirtualizedMenu<T extends object>(props: AriaMenuProps<T>) {
);
}

function VirtualizedMenuSection<T extends {title?: React.ReactNode}>({
node,
state
}: {
node: Node<T>;
state: TreeState<T>;
}) {
let {itemProps, headingProps, groupProps} = useMenuSection({
heading: node.rendered,
'aria-label': node['aria-label']
});
Comment on lines +93 to +96

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think you meant this?

Suggested change
let {itemProps, headingProps, groupProps} = useMenuSection({
heading: node.rendered,
'aria-label': node['aria-label']
});
let {itemProps, headingProps, groupProps} = useMenuSection({
heading: node.props.title,
'aria-label': node['aria-label']
});

though not sure what the aria-label there is for? it could just be omitted i think?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Applied in 6b65027 - the heading now uses node.props.title (matching how Section renders it) and the redundant aria-label is dropped, since with a heading present useMenuSection labels the group via aria-labelledby.

Verified: 4/4 in useMenu.test.tsx, full lint clean.

@gonzoblasco gonzoblasco Sep 3, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The aria-label is the fallback accessible name for sections without a heading. useMenuSection labels the group via aria-labelledby when a heading is present, and falls back to aria-label when the section has no heading (the heading is optional in the ARIA menu pattern). Without it, an unheaded section group would have no accessible name at all.

I kept it and added a test that locks that branch: a Section with aria-label and no title renders a group with aria-label and no aria-labelledby. So the accessible-name contract of sectioned menus is covered on both paths - labelled-by-heading and labelled-by-aria-label.

Verified: 5/5 in useMenu.test.tsx, full lint clean.


return (
<div {...itemProps}>
{node.rendered && <span {...headingProps}>{node.rendered}</span>}
<div {...groupProps}>

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i know that in our RAC implementation, headings + menu items are siblings. as in, items are not grouped in a wrapper separate from the heading. and as @snowystinger said, probably easier to do this as the RAC level

{[...getChildNodes(node, state.collection)].map(item => (
<VirtualizedMenuItem key={item.key} item={item} state={state} />
))}
</div>
</div>
);
}

function VirtualizedMenuWithSections<T extends object>(props: AriaMenuProps<T>) {
let state = useTreeState(props);
let ref = React.useRef(null);
let {menuProps} = useMenu(props, state, ref);

return (
<ul {...menuProps} ref={ref}>
{[...state.collection].map(node =>
node.type === 'section' ? (
<VirtualizedMenuSection key={node.key} node={node} state={state} />
) : (
<VirtualizedMenuItem key={node.key} item={node} state={state} />
)
)}
</ul>
);
}

describe('useMenuTrigger', function () {
let user;
beforeAll(() => {
Expand Down Expand Up @@ -137,4 +181,60 @@ describe('useMenuItem with isVirtualized', function () {
expect(items[1]).toHaveAttribute('aria-setsize', '3');
expect(items[2]).toHaveAttribute('aria-setsize', '3');
});

it('sets global aria-posinset across sections', () => {
let {getAllByRole, getAllByRole: getAllByRole2} = render(
<VirtualizedMenuWithSections aria-label="test menu">
<Section title="Group 1">
<Item key="1">One</Item>
<Item key="2">Two</Item>
</Section>
<Section title="Group 2">
<Item key="3">Three</Item>
<Item key="4">Four</Item>
</Section>
</VirtualizedMenuWithSections>
);

// The sections must actually render as groups, mirroring the real
// sectioned menu structure, so this test locks the real DOM shape.
Comment on lines +199 to +200

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// The sections must actually render as groups, mirroring the real
// sectioned menu structure, so this test locks the real DOM shape.

let groups = getAllByRole2('group');
expect(groups).toHaveLength(2);
// Each group is labelled by its section heading, which is present in the DOM.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// Each group is labelled by its section heading, which is present in the DOM.

for (let group of groups) {
let labelledBy = group.getAttribute('aria-labelledby');
expect(labelledBy).not.toBeNull();
let heading = document.getElementById(labelledBy!);
expect(heading).not.toBeNull();
expect(heading!.textContent).toMatch(/^Group [12]$/);
}

// aria-posinset should be global (1..4) and match aria-setsize, not restart
// per section (which would report 1..2 for both groups).
Comment on lines +212 to +213

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// aria-posinset should be global (1..4) and match aria-setsize, not restart
// per section (which would report 1..2 for both groups).

This comment is the name of the test

let items = getAllByRole('menuitem');
expect(items[0]).toHaveAttribute('aria-posinset', '1');
expect(items[1]).toHaveAttribute('aria-posinset', '2');
expect(items[2]).toHaveAttribute('aria-posinset', '3');
expect(items[3]).toHaveAttribute('aria-posinset', '4');
expect(items[0]).toHaveAttribute('aria-setsize', '4');
expect(items[3]).toHaveAttribute('aria-setsize', '4');
});

it('labels a section group via aria-label when the section has no heading', () => {
let {getAllByRole} = render(
<VirtualizedMenuWithSections aria-label="test menu">
<Section aria-label="Actions">
<Item key="1">One</Item>
<Item key="2">Two</Item>
</Section>
</VirtualizedMenuWithSections>
);

// With no heading, useMenuSection falls back to the aria-label for the
// group's accessible name instead of aria-labelledby.
let groups = getAllByRole('group');
expect(groups).toHaveLength(1);
expect(groups[0]).toHaveAttribute('aria-label', 'Actions');
expect(groups[0].getAttribute('aria-labelledby')).toBeNull();
});
});