Skip to content
Open
Show file tree
Hide file tree
Changes from 8 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
2 changes: 1 addition & 1 deletion packages/@react-aria/collections/src/BaseCollection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -254,7 +254,7 @@ export class BaseCollection<T> implements ICollection<Node<T>> {
throw new Error('Cannot add a node to a frozen collection');
}

if (node.type === 'item' && this.keyMap.get(node.key) == null) {
if (node.type === 'item' && this.keyMap.get(node.key) == null || node.type === 'header' && this.keyMap.get(node.key) == null) {
Copy link
Member Author

Choose a reason for hiding this comment

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

updated so that we get the correct aria-rowcount, assuming that we want to count 'headers' as part of the rows

this.itemCount++;
}

Expand Down
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
51 changes: 49 additions & 2 deletions 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,35 @@ export function useGridListItem<T>(props: AriaGridListItemOptions, state: ListSt
// });
// }

let sumOfNodes = (node: CollectionNode<T>): number => {
Copy link
Member

Choose a reason for hiding this comment

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

can you add a code comment to this function? It seems to be summing from a few different places, so I'm not entirely following the usage
it's also recursive, is that to handle nesting of sections inside sections?

Copy link
Member Author

Choose a reason for hiding this comment

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

yep sure, i can add some comments to clarify how it works

it's not recursive so that it can handle things like nesting of sections inside sections (tbh, im not even sure if that's supported/would work) but it just allows you to jump around to nodes more easily so that we don't have to go through each individual item, header, and section node.

for useGridListItem, if we start inside of a section, we jump up to the parent node (aka the section the item is contained in), and then go through each section node or individual item node that are outside of sections.

it's the same for useGridListSection, except that the node won't ever be inside a section because it is the section node itself. and then again, similar logic, we go through each section node or individual item nodes that are outside of sections.

it might be more helpful to draw a diagram to explain how it works so i'll see if i can draw one up...

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

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

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

return sumOfNodes(prevNode) + 1;
}

return 0;
};

let rowProps: DOMAttributes = mergeProps(itemProps, linkProps, {
role: 'row',
onKeyDownCapture,
Expand All @@ -293,8 +323,25 @@ export function useGridListItem<T>(props: AriaGridListItemOptions, state: ListSt
if (isVirtualized) {
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;
// TODO: refactor BaseCollection to store an absolute index of a node's position?
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'] = lastChild ? lastChild.index - diff + 1 : 0;
}
} else {
rowProps['aria-rowindex'] = sumOfNodes(node as CollectionNode<T>);
}
} 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
33 changes: 18 additions & 15 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 @@ -21,7 +21,6 @@ import {DraggableCollectionState, DroppableCollectionState, Collection as IColle
import {FieldInputContext, SelectableCollectionContext} from './context';
import {filterDOMProps, inertValue, LoadMoreSentinelProps, useLoadMoreSentinel, useObjectRef} from '@react-aria/utils';
import {forwardRefType, GlobalDOMAttributes, HoverEvents, Key, LinkDOMProps, PressEvents, RefObject} from '@react-types/shared';
import {HeaderContext} from './Header';
import {ListStateContext} from './ListBox';
import React, {createContext, ForwardedRef, forwardRef, HTMLAttributes, JSX, ReactNode, useContext, useEffect, useMemo, useRef} from 'react';
import {TextContext} from './Text';
Expand Down Expand Up @@ -580,13 +579,15 @@ export interface GridListSectionProps<T> extends SectionProps<T> {}
/**
* A GridListSection represents a section within a GridList.
*/
export const GridListSection = /*#__PURE__*/ createBranchComponent(SectionNode, <T extends object>(props: GridListSectionProps<T>, ref: ForwardedRef<HTMLElement>, item: Node<T>) => {
export const GridListSection = /*#__PURE__*/ createBranchComponent(SectionNode, <T extends object>(props: GridListSectionProps<T>, ref: ForwardedRef<HTMLDivElement>, item: Node<T>) => {
let state = useContext(ListStateContext)!;
let {CollectionBranch} = useContext(CollectionRendererContext);
let {CollectionBranch, isVirtualized} = useContext(CollectionRendererContext);
let headingRef = useRef(null);
ref = useObjectRef<HTMLElement>(ref);
ref = useObjectRef<HTMLDivElement>(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 All @@ -599,33 +600,35 @@ export const GridListSection = /*#__PURE__*/ createBranchComponent(SectionNode,
delete DOMProps.id;

return (
<section
<div
Copy link
Member Author

@yihuiliao yihuiliao Sep 4, 2025

Choose a reason for hiding this comment

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

see Michael's comment for context regarding this change, but as a result, i needed to update the ref types. but that caused issues with the HeaderContext since it expects an HTMLElement. so i had to create a new context for GridListHeader rather than reuse the HeaderContext

{...mergeProps(DOMProps, renderProps, rowGroupProps)}
ref={ref}>
<Provider
values={[
[HeaderContext, {...rowProps, ref: headingRef}],
[GridListHeaderContext, {...rowHeaderProps}]
[GridListHeaderContext, {...rowProps, ref: headingRef}],
[GridListHeaderInnerContext, {...rowHeaderProps}]
]}>
<CollectionBranch
collection={state.collection}
parent={item} />
</Provider>
</section>
</div>
);
});

const GridListHeaderContext = createContext<HTMLAttributes<HTMLElement> | null>(null);

export const GridListHeader = /*#__PURE__*/ createLeafComponent(HeaderNode, function Header(props: HTMLAttributes<HTMLElement>, ref: ForwardedRef<HTMLElement>) {
[props, ref] = useContextProps(props, ref, HeaderContext);
let rowHeaderProps = useContext(GridListHeaderContext);
export const GridListHeaderContext = createContext<ContextValue<HTMLAttributes<HTMLDivElement>, HTMLDivElement>>({});
const GridListHeaderInnerContext = createContext<HTMLAttributes<HTMLElement> | null>(null);

export const GridListHeader = /*#__PURE__*/ createLeafComponent(HeaderNode, function Header(props: HTMLAttributes<HTMLDivElement>, ref: ForwardedRef<HTMLDivElement>) {
[props, ref] = useContextProps(props, ref, GridListHeaderContext);
let rowHeaderProps = useContext(GridListHeaderInnerContext);

return (
<header className="react-aria-GridListHeader" ref={ref} {...props}>
<div className="react-aria-GridListHeader" ref={ref} {...props}>
<div {...rowHeaderProps} style={{display: 'contents'}}>
{props.children}
</div>
</header>
</div>
);
});
2 changes: 1 addition & 1 deletion packages/react-aria-components/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ export {DropZone, DropZoneContext} from './DropZone';
export {FieldError, FieldErrorContext} from './FieldError';
export {FileTrigger} from './FileTrigger';
export {Form, FormContext} from './Form';
export {GridListLoadMoreItem, GridList, GridListItem, GridListContext, GridListHeader, GridListSection} from './GridList';
export {GridListLoadMoreItem, GridList, GridListItem, GridListContext, GridListHeader, GridListHeaderContext, GridListSection} from './GridList';
export {Group, GroupContext} from './Group';
export {Header, HeaderContext} from './Header';
export {Heading} from './Heading';
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
Loading