From 16f21da34ed557bba0a3b70b6e9033add6fd2bba Mon Sep 17 00:00:00 2001 From: Gonzalo Blasco Date: Wed, 2 Sep 2026 11:27:53 -0300 Subject: [PATCH 1/7] test: add regression for global aria-posinset across sections Locks in that aria-posinset is global (1..4) across sections in a virtualized menu and matches aria-setsize, instead of restarting per group. Passes against current main; guards the behavior from the discussion in #9556. --- .../react-aria/test/menu/useMenu.test.tsx | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/packages/react-aria/test/menu/useMenu.test.tsx b/packages/react-aria/test/menu/useMenu.test.tsx index 9b19bbec8dc..f1abdc5300f 100644 --- a/packages/react-aria/test/menu/useMenu.test.tsx +++ b/packages/react-aria/test/menu/useMenu.test.tsx @@ -13,6 +13,8 @@ import {AriaMenuProps, useMenu} from '../../src/menu/useMenu'; import {Item} from 'react-stately/Item'; +import {Section} from 'react-stately/Section'; +import {getChildNodes} from 'react-stately/private/collections/getChildNodes'; import {Key} from '@react-types/shared'; import {pointerMap, render} from '@react-spectrum/test-utils-internal'; import React from 'react'; @@ -81,6 +83,33 @@ function VirtualizedMenu(props: AriaMenuProps) { ); } +function VirtualizedMenuWithSections(props: AriaMenuProps) { + let state = useTreeState(props); + let ref = React.useRef(null); + let {menuProps} = useMenu(props, state, ref); + + // Flatten sections into their items so useMenuItem is invoked for each item, + // mirroring how the real listbox iterates a sectioned collection. + let nodes: {key: Key; rendered: React.ReactNode; index?: number}[] = []; + for (let node of state.collection) { + if (node.type === 'section') { + for (let child of getChildNodes(node, state.collection)) { + nodes.push(child); + } + } else if (node.type === 'item') { + nodes.push(node); + } + } + + return ( +
    + {nodes.map(item => ( + + ))} +
+ ); +} + describe('useMenuTrigger', function () { let user; beforeAll(() => { @@ -137,4 +166,29 @@ 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} = render( + +
+ One + Two +
+
+ Three + Four +
+
+ ); + + // aria-posinset should be global (1..4) and match aria-setsize, not restart + // per section (which would report 1..2 for both groups). + 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'); + }); }); From 0bddc1d2613d2197390fc12aab48c617240f3042 Mon Sep 17 00:00:00 2001 From: Yihui Liao <44729383+yihuiliao@users.noreply.github.com> Date: Wed, 2 Sep 2026 15:35:29 -0700 Subject: [PATCH 2/7] fix lint --- packages/react-aria/test/menu/useMenu.test.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/react-aria/test/menu/useMenu.test.tsx b/packages/react-aria/test/menu/useMenu.test.tsx index f1abdc5300f..b75353a92a4 100644 --- a/packages/react-aria/test/menu/useMenu.test.tsx +++ b/packages/react-aria/test/menu/useMenu.test.tsx @@ -11,13 +11,12 @@ */ import {AriaMenuProps, useMenu} from '../../src/menu/useMenu'; - -import {Item} from 'react-stately/Item'; -import {Section} from 'react-stately/Section'; import {getChildNodes} from 'react-stately/private/collections/getChildNodes'; +import {Item} from 'react-stately/Item'; import {Key} 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 userEvent from '@testing-library/user-event'; From 1af6cf2c9ded96a9eea6e218ccf993e8e6044125 Mon Sep 17 00:00:00 2001 From: gonzoblasco <15630819+gonzoblasco@users.noreply.github.com> Date: Wed, 2 Sep 2026 20:06:38 -0300 Subject: [PATCH 3/7] test: render real section structure in posinset regression The previous helper flattened sections into plain items, so the DOM never contained the sectioned menu structure the test claims to cover. Render each section as a labelled group (role=presentation wrapper + role=group with aria-labelledby heading), mirroring the real sectioned menu, and assert the groups are present before checking the global posinset values. --- .../react-aria/test/menu/useMenu.test.tsx | 51 ++++++++++++------- 1 file changed, 34 insertions(+), 17 deletions(-) diff --git a/packages/react-aria/test/menu/useMenu.test.tsx b/packages/react-aria/test/menu/useMenu.test.tsx index b75353a92a4..bd99187f913 100644 --- a/packages/react-aria/test/menu/useMenu.test.tsx +++ b/packages/react-aria/test/menu/useMenu.test.tsx @@ -87,24 +87,28 @@ function VirtualizedMenuWithSections(props: AriaMenuProps) let ref = React.useRef(null); let {menuProps} = useMenu(props, state, ref); - // Flatten sections into their items so useMenuItem is invoked for each item, - // mirroring how the real listbox iterates a sectioned collection. - let nodes: {key: Key; rendered: React.ReactNode; index?: number}[] = []; - for (let node of state.collection) { - if (node.type === 'section') { - for (let child of getChildNodes(node, state.collection)) { - nodes.push(child); - } - } else if (node.type === 'item') { - nodes.push(node); - } - } - return (
    - {nodes.map(item => ( - - ))} + {[...state.collection].map(node => { + if (node.type === 'section') { + let headingId = `heading-${node.key}`; + return ( +
    + {node.rendered && ( + + {node.rendered} + + )} +
    + {[...getChildNodes(node, state.collection)].map(item => ( + + ))} +
    +
    + ); + } + return ; + })}
); } @@ -167,7 +171,7 @@ describe('useMenuItem with isVirtualized', function () { }); it('sets global aria-posinset across sections', () => { - let {getAllByRole} = render( + let {getAllByRole, getAllByRole: getAllByRole2} = render(
One @@ -180,6 +184,19 @@ describe('useMenuItem with isVirtualized', function () { ); + // 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. + 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). let items = getAllByRole('menuitem'); From ce5868becfd0915fae0b40697a3e13ac6422e64a Mon Sep 17 00:00:00 2001 From: gonzoblasco <15630819+gonzoblasco@users.noreply.github.com> Date: Wed, 2 Sep 2026 20:17:19 -0300 Subject: [PATCH 4/7] test: use useMenuSection hook in posinset regression Render each section through the real useMenuSection hook instead of hand-rolling the group structure, so any future logic added to the hook (per-section count restart, references) is exercised by this test. --- .../react-aria/test/menu/useMenu.test.tsx | 54 +++++++++++-------- 1 file changed, 33 insertions(+), 21 deletions(-) diff --git a/packages/react-aria/test/menu/useMenu.test.tsx b/packages/react-aria/test/menu/useMenu.test.tsx index bd99187f913..28f0b5e23c9 100644 --- a/packages/react-aria/test/menu/useMenu.test.tsx +++ b/packages/react-aria/test/menu/useMenu.test.tsx @@ -13,12 +13,13 @@ 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(props: AriaMenuProps & {onSelect: () => void}) { @@ -82,6 +83,30 @@ function VirtualizedMenu(props: AriaMenuProps) { ); } +function VirtualizedMenuSection({ + node, + state +}: { + node: Node; + state: TreeState; +}) { + let {itemProps, headingProps, groupProps} = useMenuSection({ + heading: node.rendered, + 'aria-label': node['aria-label'] + }); + + return ( +
+ {node.rendered && {node.rendered}} +
+ {[...getChildNodes(node, state.collection)].map(item => ( + + ))} +
+
+ ); +} + function VirtualizedMenuWithSections(props: AriaMenuProps) { let state = useTreeState(props); let ref = React.useRef(null); @@ -89,26 +114,13 @@ function VirtualizedMenuWithSections(props: AriaMenuProps) return (
    - {[...state.collection].map(node => { - if (node.type === 'section') { - let headingId = `heading-${node.key}`; - return ( -
    - {node.rendered && ( - - {node.rendered} - - )} -
    - {[...getChildNodes(node, state.collection)].map(item => ( - - ))} -
    -
    - ); - } - return ; - })} + {[...state.collection].map(node => + node.type === 'section' ? ( + + ) : ( + + ) + )}
); } From 6b650273661587adb2ff8388d9d6066236dbc6d8 Mon Sep 17 00:00:00 2001 From: gonzoblasco <15630819+gonzoblasco@users.noreply.github.com> Date: Wed, 2 Sep 2026 21:04:47 -0300 Subject: [PATCH 5/7] test: use section title prop for heading in posinset regression Use node.props.title for the section heading (matching how Section renders it) and drop the redundant aria-label - with a heading present, useMenuSection labels the group via aria-labelledby. --- packages/react-aria/test/menu/useMenu.test.tsx | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/packages/react-aria/test/menu/useMenu.test.tsx b/packages/react-aria/test/menu/useMenu.test.tsx index 28f0b5e23c9..4df50ed0814 100644 --- a/packages/react-aria/test/menu/useMenu.test.tsx +++ b/packages/react-aria/test/menu/useMenu.test.tsx @@ -83,7 +83,7 @@ function VirtualizedMenu(props: AriaMenuProps) { ); } -function VirtualizedMenuSection({ +function VirtualizedMenuSection({ node, state }: { @@ -91,13 +91,12 @@ function VirtualizedMenuSection({ state: TreeState; }) { let {itemProps, headingProps, groupProps} = useMenuSection({ - heading: node.rendered, - 'aria-label': node['aria-label'] + heading: node.props.title }); return (
- {node.rendered && {node.rendered}} + {node.props.title && {node.props.title}}
{[...getChildNodes(node, state.collection)].map(item => ( From dfbdd99af5855330811e0f0d9f6e655fc9332bca Mon Sep 17 00:00:00 2001 From: gonzoblasco <15630819+gonzoblasco@users.noreply.github.com> Date: Wed, 2 Sep 2026 21:06:50 -0300 Subject: [PATCH 6/7] test: cover aria-label fallback for unheaded sections useMenuSection labels a section group via aria-labelledby when a heading is present, and falls back to aria-label when the section has no heading. The new test locks that fallback branch so the accessible name contract of sectioned menus stays covered. --- .../react-aria/test/menu/useMenu.test.tsx | 21 ++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/packages/react-aria/test/menu/useMenu.test.tsx b/packages/react-aria/test/menu/useMenu.test.tsx index 4df50ed0814..5a972edc1a5 100644 --- a/packages/react-aria/test/menu/useMenu.test.tsx +++ b/packages/react-aria/test/menu/useMenu.test.tsx @@ -91,7 +91,8 @@ function VirtualizedMenuSection({ state: TreeState; }) { let {itemProps, headingProps, groupProps} = useMenuSection({ - heading: node.props.title + heading: node.props.title, + 'aria-label': node['aria-label'] }); return ( @@ -218,4 +219,22 @@ describe('useMenuItem with isVirtualized', function () { 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( + +
+ One + Two +
+
+ ); + + // 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(); + }); }); From a0bde03729eb15f3f14d4311889733171a94f40d Mon Sep 17 00:00:00 2001 From: gonzoblasco <15630819+gonzoblasco@users.noreply.github.com> Date: Wed, 2 Sep 2026 21:30:36 -0300 Subject: [PATCH 7/7] test: render heading span via node.rendered (fiel a MenuSection real) snowystinger senalo en 3919829010 que el span del heading debe seguir renderizando node.rendered, no node.props.title - matching how the real MenuSection component in @adobe/react-spectrum does it. The hook already uses node.rendered for the heading prop, this restores the same source for the visible span. Also explored migrating the test to react-aria-components level with a real Virtualizer (per snowystinger's suggestion in 5096476622) - found that the Virtualizer path exposes different posinset behavior (resets per section, presumably a separate issue from the original #10487 hook contract this PR locks in). Keeping the test at the hook level where the original contract was verified. --- packages/react-aria/test/menu/useMenu.test.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/react-aria/test/menu/useMenu.test.tsx b/packages/react-aria/test/menu/useMenu.test.tsx index 5a972edc1a5..52e53cfd359 100644 --- a/packages/react-aria/test/menu/useMenu.test.tsx +++ b/packages/react-aria/test/menu/useMenu.test.tsx @@ -91,13 +91,13 @@ function VirtualizedMenuSection({ state: TreeState; }) { let {itemProps, headingProps, groupProps} = useMenuSection({ - heading: node.props.title, + heading: node.rendered, 'aria-label': node['aria-label'] }); return (
- {node.props.title && {node.props.title}} + {node.rendered && {node.rendered}}
{[...getChildNodes(node, state.collection)].map(item => (