Skip to content
Open
Show file tree
Hide file tree
Changes from all 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' || node.type === 'header') && this.keyMap.get(node.key) == null) {
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
4 changes: 3 additions & 1 deletion packages/@react-aria/gridlist/src/useGridList.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,9 @@ export function useGridList<T>(props: AriaGridListOptions<T>, state: ListState<T
});

let id = useId(props.id);
listMap.set(state, {id, onAction, linkBehavior, keyboardNavigationBehavior, shouldSelectOnPressUp});
let {collection} = state;
let nodes = [...collection];
listMap.set(state, {id, onAction, linkBehavior, keyboardNavigationBehavior, shouldSelectOnPressUp, hasSection: nodes.some(node => node.type === 'section')});

let descriptionProps = useHighlightSelectionDescription({
selectionManager: state.selectionManager,
Expand Down
60 changes: 55 additions & 5 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 @@ -67,7 +68,7 @@ export function useGridListItem<T>(props: AriaGridListItemOptions, state: ListSt

// let stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-aria/gridlist');
let {direction} = useLocale();
let {onAction, linkBehavior, keyboardNavigationBehavior, shouldSelectOnPressUp} = listMap.get(state)!;
let {onAction, linkBehavior, keyboardNavigationBehavior, shouldSelectOnPressUp, hasSection} = listMap.get(state)!;
let descriptionId = useSlotId();

// We need to track the key of the item at the time it was last focused so that we force
Expand Down Expand Up @@ -277,6 +278,27 @@ 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 prevKey is null, then this is the first node in the collection so get number of row(s)
if (node.prevKey === null) {
return getNumberOfRows(node, state);
}

// If the node is an item inside of a section, get number of rows in the current section
let parentNode = node.parentKey ? state.collection.getItem(node.parentKey) as CollectionNode<T> : null;
if (parentNode && parentNode.type === 'section') {
return sumOfNodes(parentNode);
}

// Otherwise, if the node is a section or item outside of a section, recursively call to get the current sum + get the number of row(s)
let prevNode = state.collection.getItem(node.prevKey!) as CollectionNode<T>;
if (prevNode) {
return sumOfNodes(prevNode) + getNumberOfRows(node, state);
}

return 0;
};

let rowProps: DOMAttributes = mergeProps(itemProps, linkProps, {
role: 'row',
onKeyDownCapture,
Expand All @@ -291,10 +313,26 @@ export function useGridListItem<T>(props: AriaGridListItemOptions, state: ListSt
});

if (isVirtualized) {
let {collection} = state;
let nodes = [...collection];
// 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 (hasSection) {
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 delta = lastChild ? lastChild.index - node.index : 0;
if (parentNode && parentNode.prevKey) {
rowProps['aria-rowindex'] = sumOfNodes(parentNode) - delta;
} else {
// If the item is within a section but the section is the first node in the collection
rowProps['aria-rowindex'] = node.index + 1;
}
} else {
rowProps['aria-rowindex'] = sumOfNodes(node as CollectionNode<T>);
}
} else {
rowProps['aria-rowindex'] = node.index + 1;
}
}

let gridCellProps = {
Expand Down Expand Up @@ -324,3 +362,15 @@ function last(walker: TreeWalker) {
} while (last);
return next;
}

export function getNumberOfRows<T>(node: RSNode<unknown>, state: ListState<T> | TreeState<T>) {
if (node.type === 'section') {
// Use the index of the last child to determine the number of nodes in the section
let currentNode = node as CollectionNode<T>;
let lastChild = currentNode.lastChildKey ? state.collection.getItem(currentNode.lastChildKey) : null;
return lastChild ? lastChild.index + 1 : 0;
} else if (node.type === 'item') {
return 1;
}
return 0;
}
45 changes: 40 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,19 @@
* 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 {getNumberOfRows} from './useGridListItem';
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 +43,49 @@ 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: CollectionNode<unknown>): number => {
// If prevKey is null, then this is the first node in the collection
if (node.prevKey === null) {
return getNumberOfRows(node, state);
}

// Otherwise, if the node is a section or item outside of a section, recursively call to get the current sum + get the number of row(s)
let prevNode = state.collection.getItem(node.prevKey!) as CollectionNode<T>;
if (prevNode) {
return sumOfNodes(prevNode) + getNumberOfRows(node, state);
}

return 0;
};

if (isVirtualized) {
if (node.prevKey) {
let prevNode = state.collection.getItem(node.prevKey);
if (prevNode) {
rowIndex = sumOfNodes(prevNode as CollectionNode<T>) + 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
3 changes: 2 additions & 1 deletion packages/@react-aria/gridlist/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ interface ListMapShared {
onAction?: (key: Key) => void,
linkBehavior?: 'action' | 'selection' | 'override',
keyboardNavigationBehavior: 'arrow' | 'tab',
shouldSelectOnPressUp?: boolean
shouldSelectOnPressUp?: boolean,
hasSection?: boolean
}

// Used to share:
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