Skip to content
Open
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/@react-aria/gridlist/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
"url": "https://github.com/adobe/react-spectrum"
},
"dependencies": {
"@react-aria/collections": "3.0.0-rc.4",
"@react-aria/focus": "^3.21.1",
"@react-aria/grid": "^3.14.4",
"@react-aria/i18n": "^3.12.12",
Expand Down
47 changes: 46 additions & 1 deletion packages/@react-aria/gridlist/src/useGridListItem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
*/

import {chain, getScrollParent, mergeProps, scrollIntoViewport, useSlotId, useSyntheticLinkProps} from '@react-aria/utils';
import {CollectionNode} from '@react-aria/collections';
import {DOMAttributes, FocusableElement, Key, RefObject, Node as RSNode} from '@react-types/shared';
import {focusSafely, getFocusableTreeWalker} from '@react-aria/focus';
import {getRowId, listMap} from './utils';
Expand Down Expand Up @@ -277,6 +278,33 @@ export function useGridListItem<T>(props: AriaGridListItemOptions, state: ListSt
// });
// }

let sumOfNodes = (node: RSNode<unknown>): number => {
if (node.prevKey === null) {
if (node.type === 'section') {
return [...state.collection.getChildren!(node.key)].length;
} else if (node.type === 'item') {
return 1;
}
return 0;
}

let parentNode = node.parentKey ? state.collection.getItem(node.parentKey) : null;
if (parentNode && parentNode.type === 'section') {
return sumOfNodes(parentNode);
}

let prevNode = state.collection.getItem(node.prevKey!);
if (prevNode) {
if (node.type === 'section') {
return sumOfNodes(prevNode) + [...state.collection.getChildren!(node.key)].length;
}

return sumOfNodes(prevNode) + 1;
}

return 0;
};

let rowProps: DOMAttributes = mergeProps(itemProps, linkProps, {
role: 'row',
onKeyDownCapture,
Expand All @@ -294,7 +322,24 @@ export function useGridListItem<T>(props: AriaGridListItemOptions, state: ListSt
let {collection} = state;
let nodes = [...collection];
Copy link
Member

Choose a reason for hiding this comment

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

this means every grid item has to make a copy of the collection (O)n^2, can we do this at the useGrid level and send it (hasSections) via our hooks context or in the state?

Copy link
Member Author

Choose a reason for hiding this comment

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

yeah we could add a new prop to useGridListItem called something isInSection and then determine that value inside of GridListItem by checking whether the parent node's type is a section. how does that sound?

Copy link
Member

@snowystinger snowystinger Sep 8, 2025

Choose a reason for hiding this comment

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

The check here isn't if the node is inside a section, we do that below without needing to make a copy of the collection:

let parentNode = node.parentKey ? state.collection.getItem(node.parentKey) as CollectionNode<T> : null;
let isInSection = parentNode && parentNode.type === 'section';

This line here:

let nodes = [...collection];
if (nodes.some(node => node.type === 'section')) {

just checks if there are any sections in the collection, and it does it for every item, and it copies the collection every time as well. Which, for the worst case (no sections) would actually be 2 complete iterations.

I propose simplifying by doing this check for collectionHasSections in useGridList once, then passing it along on the hook context

listMap.set(state, {id, onAction, linkBehavior, keyboardNavigationBehavior, shouldSelectOnPressUp});

We could also instead add it as a tracked property here https://github.com/adobe/react-spectrum/blob/main/packages/%40react-stately/list/src/ListCollection.ts and update it when the collection builds, then we could just ask the collection if it has sections and we could skip copying and iterating over the entire collection.

Copy link
Member Author

Choose a reason for hiding this comment

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

ohhhh right right, sorry i see what you mean

// TODO: refactor ListCollection to store an absolute index of a node's position?
rowProps['aria-rowindex'] = nodes.find(node => node.type === 'section') ? [...collection.getKeys()].filter((key) => collection.getItem(key)?.type !== 'section').findIndex((key) => key === node.key) + 1 : node.index + 1;
if (nodes.find(node => node.type === 'section')) {
let parentNode = node.parentKey ? state.collection.getItem(node.parentKey) as CollectionNode<T> : null;
let isInSection = parentNode && parentNode.type === 'section';
let lastChildKey = parentNode?.lastChildKey;
if (isInSection && lastChildKey) {
let lastChild = state.collection.getItem(lastChildKey);
let diff = lastChild ? lastChild.index - node.index : 0;
if (parentNode!.prevKey) {
rowProps['aria-rowindex'] = sumOfNodes(parentNode!) - diff;
} else {
rowProps['aria-rowindex'] = [...state.collection.getChildren!(parentNode!.key)].length - diff;
}
} else {
rowProps['aria-rowindex'] = sumOfNodes(node);
}
} else {
rowProps['aria-rowindex'] = node.index + 1;
}
}

let gridCellProps = {
Expand Down
57 changes: 52 additions & 5 deletions packages/@react-aria/gridlist/src/useGridListSection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,18 @@
* governing permissions and limitations under the License.
*/

import {DOMAttributes, RefObject} from '@react-types/shared';
import {CollectionNode} from '@react-aria/collections';
import {DOMAttributes, RefObject, Node as RSNode} from '@react-types/shared';
import type {ListState} from '@react-stately/list';
import {useLabels, useSlotId} from '@react-aria/utils';

export interface AriaGridListSectionProps {
/** An accessibility label for the section. Required if `heading` is not present. */
'aria-label'?: string
'aria-label'?: string,
/** An object representing the section. */
node: RSNode<unknown>,
/** Whether the list row is contained in a virtual scroller. */
isVirtualized?: boolean
}

export interface GridListSectionAria {
Expand All @@ -37,20 +42,62 @@ export interface GridListSectionAria {
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export function useGridListSection<T>(props: AriaGridListSectionProps, state: ListState<T>, ref: RefObject<HTMLElement | null>): GridListSectionAria {
let {'aria-label': ariaLabel} = props;
let {'aria-label': ariaLabel, node, isVirtualized} = props;
let headingId = useSlotId();
let labelProps = useLabels({
'aria-label': ariaLabel,
'aria-labelledby': headingId
});
let rowIndex;

let sumOfNodes = (node: RSNode<unknown>): number => {
if (node.prevKey === null) {
let lastChildKey = (node as CollectionNode<T>).lastChildKey;
Copy link
Member Author

Choose a reason for hiding this comment

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

if we want to use lastChildKey, the node types needs to defined as a CollectionNode<T>. however, createBranchComponent expects the item to be type Node<T> which doesn't have lastChildKey defined and i don't think i should change the type declarations in createBranchComponent. as a result, i end up having to cast the type here as CollectionNode. it should be fine tho since the BaseCollection uses CollectionNodes anyway

if (node.type === 'section' && lastChildKey) {
let lastChild = state.collection.getItem(lastChildKey);
return lastChild ? lastChild.index + 1 : 0;
} else if (node.type === 'item') {
return 1;
}
return 0;
}

let prevNode = state.collection.getItem(node.prevKey!);
if (prevNode) {
if (node.type === 'item') {
return sumOfNodes(prevNode) + 1;
}

let lastChildKey = (node as CollectionNode<T>).lastChildKey;
if (lastChildKey) {
let lastChild = state.collection.getItem(lastChildKey);
return lastChild ? sumOfNodes(prevNode) + lastChild.index + 1 : sumOfNodes(prevNode);
}
}

return 0;
};

if (isVirtualized) {
if (node.prevKey) {
let prevNode = state.collection.getItem(node.prevKey);
if (prevNode) {
rowIndex = sumOfNodes(prevNode) + 1;
}
} else {
rowIndex = 1;
}
}

return {
rowProps: {
role: 'row'
role: 'row',
'aria-rowindex': rowIndex
},
rowHeaderProps: {
id: headingId,
role: 'rowheader'
role: 'rowheader',
'aria-colindex': 1
},
rowGroupProps: {
role: 'rowgroup',
Expand Down
6 changes: 4 additions & 2 deletions packages/react-aria-components/src/GridList.tsx
Copy link
Collaborator

Choose a reason for hiding this comment

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

The Accessibility checker within Storybook is throwing errors for an invalid aria role on the section and header elements used to define the Section and Section Headers. Can we use generic divs with the same rowgroup and row roles instead?

See: https://w3c.github.io/html-aria/#el-header and https://w3c.github.io/html-aria/#el-section regarding the roles permitted on section and header.

Original file line number Diff line number Diff line change
Expand Up @@ -582,11 +582,13 @@ export interface GridListSectionProps<T> extends SectionProps<T> {}
*/
export const GridListSection = /*#__PURE__*/ createBranchComponent(SectionNode, <T extends object>(props: GridListSectionProps<T>, ref: ForwardedRef<HTMLElement>, item: Node<T>) => {
let state = useContext(ListStateContext)!;
let {CollectionBranch} = useContext(CollectionRendererContext);
let {CollectionBranch, isVirtualized} = useContext(CollectionRendererContext);
let headingRef = useRef(null);
ref = useObjectRef<HTMLElement>(ref);
let {rowHeaderProps, rowProps, rowGroupProps} = useGridListSection({
'aria-label': props['aria-label'] ?? undefined
'aria-label': props['aria-label'] ?? undefined,
node: item,
isVirtualized
}, state, ref);
let renderProps = useRenderProps({
defaultClassName: 'react-aria-GridListSection',
Expand Down
24 changes: 12 additions & 12 deletions packages/react-aria-components/stories/GridList.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -160,21 +160,21 @@ export const GridListSectionExample = (args) => (
}}>
<GridListSection>
<GridListHeader>Section 1</GridListHeader>
<MyGridListItem>1,1 <Button>Actions</Button></MyGridListItem>
<MyGridListItem>1,2 <Button>Actions</Button></MyGridListItem>
<MyGridListItem>1,3 <Button>Actions</Button></MyGridListItem>
<MyGridListItem textValue="1,1" >1,1 <Button>Actions</Button></MyGridListItem>
<MyGridListItem textValue="1,2" >1,2 <Button>Actions</Button></MyGridListItem>
<MyGridListItem textValue="1,3" >1,3 <Button>Actions</Button></MyGridListItem>
</GridListSection>
<GridListSection>
<GridListHeader>Section 2</GridListHeader>
<MyGridListItem>2,1 <Button>Actions</Button></MyGridListItem>
<MyGridListItem>2,2 <Button>Actions</Button></MyGridListItem>
<MyGridListItem>2,3 <Button>Actions</Button></MyGridListItem>
<MyGridListItem textValue="2,1">2,1 <Button>Actions</Button></MyGridListItem>
<MyGridListItem textValue="2,2">2,2 <Button>Actions</Button></MyGridListItem>
<MyGridListItem textValue="2,3">2,3 <Button>Actions</Button></MyGridListItem>
</GridListSection>
<GridListSection>
<GridListHeader>Section 3</GridListHeader>
<MyGridListItem>3,1 <Button>Actions</Button></MyGridListItem>
<MyGridListItem>3,2 <Button>Actions</Button></MyGridListItem>
<MyGridListItem>3,3 <Button>Actions</Button></MyGridListItem>
<MyGridListItem textValue="3,1">3,1 <Button>Actions</Button></MyGridListItem>
<MyGridListItem textValue="3,2">3,2 <Button>Actions</Button></MyGridListItem>
<MyGridListItem textValue="3,3">3,3 <Button>Actions</Button></MyGridListItem>
</GridListSection>
</GridList>
);
Expand Down Expand Up @@ -213,7 +213,7 @@ export function VirtualizedGridListSection() {
let sections: {id: string, name: string, children: {id: string, name: string}[]}[] = [];
for (let s = 0; s < 10; s++) {
let items: {id: string, name: string}[] = [];
for (let i = 0; i < 3; i++) {
for (let i = 0; i < 5; i++) {
items.push({id: `item_${s}_${i}`, name: `Section ${s}, Item ${i}`});
}
sections.push({id: `section_${s}`, name: `Section ${s}`, children: items});
Expand All @@ -223,11 +223,11 @@ export function VirtualizedGridListSection() {
<Virtualizer
layout={ListLayout}
layoutOptions={{
headingHeight: 25,
rowHeight: 25
}}>
<GridList
className={styles.menu}
// selectionMode="multiple"
style={{height: 400}}
aria-label="virtualized with grid section"
items={sections}>
Expand All @@ -236,7 +236,7 @@ export function VirtualizedGridListSection() {
<GridListSection>
<GridListHeader>{section.name}</GridListHeader>
<Collection items={section.children} >
{item => <MyGridListItem>{item.name}</MyGridListItem>}
{item => <MyGridListItem textValue={item.name}>{item.name}</MyGridListItem>}
</Collection>
</GridListSection>
)}
Expand Down
79 changes: 79 additions & 0 deletions packages/react-aria-components/test/GridList.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -494,6 +494,85 @@ describe('GridList', () => {
expect(items).toHaveLength(2);
});

it('should calculate the correct aria-rowindex when gridlist is made up of only sections', () => {
let sections = [];
for (let s = 0; s < 5; s++) {
let items = [];
for (let i = 0; i < 2; i++) {
items.push({id: `item_${s}_${i}`, name: `Section ${s}, Item ${i}`});
}
sections.push({id: `section_${s}`, name: `Section ${s}`, children: items});
}

jest.spyOn(window.HTMLElement.prototype, 'clientWidth', 'get').mockImplementation(() => 100);
jest.spyOn(window.HTMLElement.prototype, 'clientHeight', 'get').mockImplementation(() => 300);

let {getAllByRole} = render(
<Virtualizer layout={ListLayout} layoutOptions={{headingHeight: 25, rowHeight: 25}}>
<GridList aria-label="Test" items={sections}>
<Collection items={sections}>
{section => (
<GridListSection>
<GridListHeader>{section.name}</GridListHeader>
<Collection items={section.children} >
{item => <GridListItem>{item.name}</GridListItem>}
</Collection>
</GridListSection>
)}
</Collection>
</GridList>
</Virtualizer>
);

let rows = getAllByRole('row');
expect(rows).toHaveLength(15);
Copy link
Collaborator

Choose a reason for hiding this comment

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

It seems that the aria-rowcount prop on the role="grid" does is not including the count for the number of section header roles.

In the Virtualized GridList Section example, the aria-colcount on the grid is 50, when if we were to include the header rows, the count should be 60.

expect(rows.map(r => r.textContent)).toEqual(['Section 0', 'Section 0, Item 0', 'Section 0, Item 1', 'Section 1', 'Section 1, Item 0', 'Section 1, Item 1', 'Section 2', 'Section 2, Item 0', 'Section 2, Item 1', 'Section 3', 'Section 3, Item 0', 'Section 3, Item 1', 'Section 4', 'Section 4, Item 0', 'Section 4, Item 1']);

for (let i = 0; i < 15; i++) {
expect(rows[i]).toHaveAttribute('aria-rowindex');
expect(rows[i].getAttribute('aria-rowindex')).toEqual(`${i + 1}`);
}
});

it('should calculate the correct aria-rowindex when there is a mix of sections and individual items', () => {
jest.spyOn(window.HTMLElement.prototype, 'clientWidth', 'get').mockImplementation(() => 100);
jest.spyOn(window.HTMLElement.prototype, 'clientHeight', 'get').mockImplementation(() => 300);

let {getAllByRole} = render(
<Virtualizer layout={ListLayout} layoutOptions={{headingHeight: 25, rowHeight: 25}}>
<GridList aria-label="Test">
<GridListItem>Home</GridListItem>
<GridListItem>School</GridListItem>
<GridListSection>
<GridListHeader>Pets</GridListHeader>
<GridListItem>Cat</GridListItem>
<GridListItem>Dog</GridListItem>
</GridListSection>
<GridListSection aria-label="Ice cream flavors">
<GridListItem>Vanilla</GridListItem>
<GridListItem>Chocolate</GridListItem>
</GridListSection>
<GridListItem>City Hall</GridListItem>
<GridListItem>Pharmacy</GridListItem>
<GridListSection>
<GridListHeader>Plants</GridListHeader>
<GridListItem>Sunflower</GridListItem>
<GridListItem>Daffodil</GridListItem>
</GridListSection>
</GridList>
</Virtualizer>
);

let rows = getAllByRole('row');
expect(rows).toHaveLength(12);
expect(rows.map(r => r.textContent)).toEqual(['Home', 'School', 'Pets', 'Cat', 'Dog', 'Vanilla', 'Chocolate', 'City Hall', 'Pharmacy', 'Plants', 'Sunflower', 'Daffodil']);

for (let i = 0; i < 12; i++) {
expect(rows[i]).toHaveAttribute('aria-rowindex');
expect(rows[i].getAttribute('aria-rowindex')).toEqual(`${i + 1}`);
}
});

describe('selectionBehavior="replace"', () => {
// Required for proper touch detection
installPointerEvent();
Expand Down
24 changes: 21 additions & 3 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -5520,6 +5520,23 @@ __metadata:
languageName: unknown
linkType: soft

"@react-aria/collections@npm:3.0.0-rc.4":
version: 3.0.0-rc.4
resolution: "@react-aria/collections@npm:3.0.0-rc.4"
dependencies:
"@react-aria/interactions": "npm:^3.25.4"
"@react-aria/ssr": "npm:^3.9.10"
"@react-aria/utils": "npm:^3.30.0"
"@react-types/shared": "npm:^3.31.0"
"@swc/helpers": "npm:^0.5.0"
use-sync-external-store: "npm:^1.4.0"
peerDependencies:
react: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1
react-dom: ^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1
checksum: 10c0/712c574704bc735b525a8c184780373e1854be515cabf83b22ee59ea9c52befd02495445b5546db44d913bb37273e16a59672298a180b57f72930e697d81c9ad
languageName: node
linkType: hard

"@react-aria/collections@npm:3.0.0-rc.6, @react-aria/collections@workspace:packages/@react-aria/collections":
version: 0.0.0-use.local
resolution: "@react-aria/collections@workspace:packages/@react-aria/collections"
Expand Down Expand Up @@ -5728,6 +5745,7 @@ __metadata:
version: 0.0.0-use.local
resolution: "@react-aria/gridlist@workspace:packages/@react-aria/gridlist"
dependencies:
"@react-aria/collections": "npm:3.0.0-rc.4"
"@react-aria/focus": "npm:^3.21.1"
"@react-aria/grid": "npm:^3.14.4"
"@react-aria/i18n": "npm:^3.12.12"
Expand Down Expand Up @@ -5762,7 +5780,7 @@ __metadata:
languageName: unknown
linkType: soft

"@react-aria/interactions@npm:^3.1.0, @react-aria/interactions@npm:^3.25.1, @react-aria/interactions@npm:^3.25.5, @react-aria/interactions@workspace:packages/@react-aria/interactions":
"@react-aria/interactions@npm:^3.1.0, @react-aria/interactions@npm:^3.25.1, @react-aria/interactions@npm:^3.25.4, @react-aria/interactions@npm:^3.25.5, @react-aria/interactions@workspace:packages/@react-aria/interactions":
version: 0.0.0-use.local
resolution: "@react-aria/interactions@workspace:packages/@react-aria/interactions"
dependencies:
Expand Down Expand Up @@ -6303,7 +6321,7 @@ __metadata:
languageName: unknown
linkType: soft

"@react-aria/utils@npm:^3.29.0, @react-aria/utils@npm:^3.30.1, @react-aria/utils@npm:^3.8.0, @react-aria/utils@workspace:packages/@react-aria/utils":
"@react-aria/utils@npm:^3.29.0, @react-aria/utils@npm:^3.30.0, @react-aria/utils@npm:^3.30.1, @react-aria/utils@npm:^3.8.0, @react-aria/utils@workspace:packages/@react-aria/utils":
version: 0.0.0-use.local
resolution: "@react-aria/utils@workspace:packages/@react-aria/utils"
dependencies:
Expand Down Expand Up @@ -8766,7 +8784,7 @@ __metadata:
languageName: unknown
linkType: soft

"@react-types/shared@npm:^3.1.0, @react-types/shared@npm:^3.30.0, @react-types/shared@npm:^3.32.0, @react-types/shared@workspace:packages/@react-types/shared":
"@react-types/shared@npm:^3.1.0, @react-types/shared@npm:^3.30.0, @react-types/shared@npm:^3.31.0, @react-types/shared@npm:^3.32.0, @react-types/shared@workspace:packages/@react-types/shared":
version: 0.0.0-use.local
resolution: "@react-types/shared@workspace:packages/@react-types/shared"
peerDependencies:
Expand Down