-
Notifications
You must be signed in to change notification settings - Fork 1.6k
test: add regression for global aria-posinset across sections #10554
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
16f21da
dda41ce
0bddc1d
1af6cf2
07939d1
ce5868b
3fc8128
6b65027
dfbdd99
a0bde03
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -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}) { | ||||||
|
|
@@ -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'] | ||||||
| }); | ||||||
|
|
||||||
| return ( | ||||||
| <div {...itemProps}> | ||||||
| {node.rendered && <span {...headingProps}>{node.rendered}</span>} | ||||||
| <div {...groupProps}> | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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(() => { | ||||||
|
|
@@ -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
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
| let groups = getAllByRole2('group'); | ||||||
| expect(groups).toHaveLength(2); | ||||||
| // Each group is labelled by its section heading, which is present in the DOM. | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
|
||||||
| 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
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
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(); | ||||||
| }); | ||||||
| }); | ||||||
There was a problem hiding this comment.
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?
though not sure what the aria-label there is for? it could just be omitted i think?
There was a problem hiding this comment.
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.
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
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.