Skip to content

feat: support sections and headers in RAC gridlist #8667

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

Open
wants to merge 11 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 5 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/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
export {useGridList} from './useGridList';
export {useGridListItem} from './useGridListItem';
export {useGridListSelectionCheckbox} from './useGridListSelectionCheckbox';
export {useGridListSection} from './useGridListSection';

export type {AriaGridListOptions, AriaGridListProps, GridListAria, GridListProps} from './useGridList';
export type {AriaGridListItemOptions, GridListItemAria} from './useGridListItem';
Expand Down
58 changes: 58 additions & 0 deletions packages/@react-aria/gridlist/src/useGridListSection.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
/*
* Copyright 2020 Adobe. All rights reserved.
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. You may obtain a copy
* of the License at http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software distributed under
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
* OF ANY KIND, either express or implied. See the License for the specific language
* governing permissions and limitations under the License.
*/

import {DOMAttributes} from '@react-types/shared';
import {ReactNode} from 'react';
import {useId} from '@react-aria/utils';

export interface AriaGridListSectionProps {
/** The heading for the section. */
heading?: ReactNode,
/** An accessibility label for the section. Required if `heading` is not present. */
'aria-label'?: string
}

export interface GridListSectionAria {
/** Props for the wrapper list item. */
rowProps: DOMAttributes,

/** Props for the heading element, if any. */
headingProps: DOMAttributes,

/** Props for the grid's row group element. */
rowGroupProps: DOMAttributes
}

/**
* Provides the behavior and accessibility implementation for a section in a grid list.
* See `useGridList` for more details about grid list.
* @param props - Props for the section.
*/
export function useGridListSection(props: AriaGridListSectionProps): GridListSectionAria {
Copy link
Member

Choose a reason for hiding this comment

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

pass state and ref, we always seem to regret not passing them and it's breaking to add them later

Copy link
Member Author

Choose a reason for hiding this comment

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

we could pass it but wouldn't they be unused?

Copy link
Member

Choose a reason for hiding this comment

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

still need to declare them in the signature so that others using the hook know they should supply them. that's the only way it can be non-breaking

let {heading, 'aria-label': ariaLabel} = props;
let headingId = useId();

return {
rowProps: {
Copy link
Member Author

Choose a reason for hiding this comment

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

so ideally rowProps would have aria-rowindex but it's difficult to determine what the correct index is for the header. especially in the case where a gridlist has sections that both contain headers and don't container headers. basically, we rely on knowing the index of the previous section to determine what the index of the header should be. at the same time, we don't actually want to include sections in our calculations because they don't have the rowindex value which is what makes this tricky.

it'd be easy to determine if we calculated it inside GridListHeader but that wouldn't match any of our existing API and would sort of defeat the purpose of having this hook supply this information...

Copy link
Member

Choose a reason for hiding this comment

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

just thinking out loud (and definitely don't implement this yet), but is it possible to:

  1. Get the parent section
  2. Get section's previous key and source the previous section node
  3. Get the last child key of that section node and grab that key's node's index. That index should equal how many rows were in that section
  4. Repeat by going through each section before that and total up the total number of rows

I think the ideal way to calculate this will come from the collection nodes containing more information than they do now, include ways to get the "indexOfSameType" kind of information, something that maybe easier with some of the BaseCollection work in some of my other PRs

Copy link
Member Author

@yihuiliao yihuiliao Aug 14, 2025

Choose a reason for hiding this comment

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

yeah i think we could just do a sum. i can look into it a bit more

role: 'row'
},
headingProps: heading ? {
id: headingId,
role: 'rowheader'
} : {},
rowGroupProps: {
role: 'rowgroup',
'aria-label': ariaLabel,
'aria-labelledby': heading ? headingId : undefined
}
};
}
71 changes: 66 additions & 5 deletions packages/react-aria-components/src/GridList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,18 @@
* OF ANY KIND, either express or implied. See the License for the specific language
* governing permissions and limitations under the License.
*/
import {AriaGridListProps, DraggableItemResult, DragPreviewRenderer, DropIndicatorAria, DroppableCollectionResult, FocusScope, ListKeyboardDelegate, mergeProps, useCollator, useFocusRing, useGridList, useGridListItem, useGridListSelectionCheckbox, useHover, useLocale, useVisuallyHidden} from 'react-aria';
import {AriaGridListProps, DraggableItemResult, DragPreviewRenderer, DropIndicatorAria, DroppableCollectionResult, FocusScope, ListKeyboardDelegate, mergeProps, useCollator, useFocusRing, useGridList, useGridListItem, useGridListSection, useGridListSelectionCheckbox, useHover, useLocale, useVisuallyHidden} from 'react-aria';
import {ButtonContext} from './Button';
import {CheckboxContext} from './RSPContexts';
import {Collection, CollectionBuilder, createLeafComponent} from '@react-aria/collections';
import {CollectionProps, CollectionRendererContext, DefaultCollectionRenderer, ItemRenderProps} from './Collection';
import {ContextValue, DEFAULT_SLOT, Provider, RenderProps, SlotProps, StyleProps, StyleRenderProps, useContextProps, useRenderProps} from './utils';
import {Collection, CollectionBuilder, createBranchComponent, createLeafComponent} from '@react-aria/collections';
import {CollectionProps, CollectionRendererContext, DefaultCollectionRenderer, ItemRenderProps, SectionContext, SectionProps} from './Collection';
import {ContextValue, DEFAULT_SLOT, Provider, RenderProps, SlotProps, StyleProps, StyleRenderProps, useContextProps, useRenderProps, useSlot} from './utils';
import {DragAndDropContext, DropIndicatorContext, DropIndicatorProps, useDndPersistedKeys, useRenderDropIndicator} from './DragAndDrop';
import {DragAndDropHooks} from './useDragAndDrop';
import {DraggableCollectionState, DroppableCollectionState, Collection as ICollection, ListState, Node, SelectionBehavior, useListState} from 'react-stately';
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 @@ -245,7 +246,8 @@ function GridListInner<T extends object>({props, collection, gridListRef: ref}:
values={[
[ListStateContext, state],
[DragAndDropContext, {dragAndDropHooks, dragState, dropState}],
[DropIndicatorContext, {render: GridListDropIndicatorWrapper}]
[DropIndicatorContext, {render: GridListDropIndicatorWrapper}],
[SectionContext, {name: 'GridListSection', render: GridListSectionInner}]
]}>
{isListDroppable && <RootDropIndicator />}
<CollectionRoot
Expand Down Expand Up @@ -561,3 +563,62 @@ export const GridListLoadMoreItem = createLeafComponent('loader', function GridL
</>
);
});

export interface GridListSectionProps<T> extends SectionProps<T> {}

function GridListSectionInner<T extends object>(props: GridListSectionProps<T>, ref: ForwardedRef<HTMLElement>, section: Node<T>, className = 'react-aria-GridListSection') {
let state = useContext(ListStateContext)!;
let {dragAndDropHooks, dropState} = useContext(DragAndDropContext)!;
let {CollectionBranch} = useContext(CollectionRendererContext);
let [headingRef, heading] = useSlot();
let {headingProps, rowProps, rowGroupProps} = useGridListSection({
heading,
'aria-label': props['aria-label'] ?? undefined
});
let renderProps = useRenderProps({
defaultClassName: className,
className: props.className,
style: props.style,
values: {}
});

return (
<section
{...filterDOMProps(props as any)}
{...rowGroupProps}
{...renderProps}
ref={ref}>
<Provider
values={[
[HeaderContext, {...headingProps, ref: headingRef}],
[GridListHeaderContext, {...rowProps}]
]}>
<CollectionBranch
collection={state.collection}
parent={section}
renderDropIndicator={useRenderDropIndicator(dragAndDropHooks, dropState)} />
</Provider>
</section>
);
}

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

export const GridListHeader = /*#__PURE__*/ createLeafComponent('header', function Header(props: HTMLAttributes<HTMLElement>, ref: ForwardedRef<HTMLElement>) {
Copy link
Member Author

Choose a reason for hiding this comment

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

with ListBox, you can just use a normal Header, but with GridList, in order to follow correct aria-pattern, the header must be inside a div with a role=row, hence why i've created a new component called GridListHeader. are we okay with that?

from WAI-ARIA:

Each cell is either a DOM descendant of or owned by a row element and has one of the following roles:

  • columnheader if the cell contains a title or header information for the column.
  • rowheader if the cell contains title or header information for the row.
  • gridcell if the cell does not contain column or row header information.

Copy link
Member

Choose a reason for hiding this comment

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

I think this is ok, but the row itself should get aria-attributes like aria-rowindex when virtualized if so.

[props, ref] = useContextProps(props, ref, HeaderContext);
let rowProps = useContext(GridListHeaderContext);

return (
<div {...rowProps} >
<header className="react-aria-Header" {...props} ref={ref}>
Copy link
Member

Choose a reason for hiding this comment

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

Having two elements will make this harder to style. Maybe we can use display: 'contents' on the inner one so we have the correct ARIA structure, but you only need to style the outer one:

<header {...rowProps} ref={ref}>
  <div {...rowHeaderProps} style={{display: 'contents'}}>
    {children}
  </div>
</header>

This would match the structure of GridListItem too.

Copy link
Member

Choose a reason for hiding this comment

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

linking related discussion #8667 (comment)

I think I like the display contents better

Copy link
Member

Choose a reason for hiding this comment

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

+1, I did the same for the load more elements too (i.e. TableLoadMoreItem)

Copy link
Contributor

@nwidynski nwidynski Aug 10, 2025

Choose a reason for hiding this comment

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

@snowystinger Regarding #8667 (comment)

I think we could provide a custom CollectionNode to wrap headers children with the row div.

Otherwise we could expose an internal context in Header to be set by the GridList? I think its worth to try and maintain a universal API if somehow possible.

Copy link
Member

Choose a reason for hiding this comment

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

I think we could provide a custom #8523 to wrap headers children with the row div.

Definitely an interesting idea. Though I imagine that would have the same issue with styling, so we'd probably still use 'display: contents', or possibly I haven't thought through your other PR enough yet?

Copy link
Contributor

Choose a reason for hiding this comment

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

Yeah, styling issue persists. This just enables re-use of Header.

Copy link
Member Author

@yihuiliao yihuiliao Aug 12, 2025

Choose a reason for hiding this comment

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

We've decided as a team to go ahead and use GridListHeader since it's the most straightforward in terms of implementation. While the re-use of Header would be nice, with the exception of ListBox, we do have a history in RAC of having collection specific components ListBoxSection vs. GridListSection, or ListBoxItem vs GridListItem

Copy link
Contributor

Choose a reason for hiding this comment

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

fine with that 👍

Copy link
Member

Choose a reason for hiding this comment

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

Just so we have the history, it was noted that we'd need a custom renderer as well in order to reuse the Header in this way.

{props.children}
</header>
</div>
);
});


/**
* A GridListSection represents a section within a GridList.
*/
export const GridListSection = /*#__PURE__*/ createBranchComponent('section', GridListSectionInner);
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} from './GridList';
export {GridListLoadMoreItem, GridList, GridListItem, GridListContext, GridListHeader, GridListSection} from './GridList';
export {Group, GroupContext} from './Group';
export {Header, HeaderContext} from './Header';
export {Heading} from './Heading';
Expand Down
101 changes: 101 additions & 0 deletions packages/react-aria-components/stories/GridList.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,12 @@ import {
DropIndicator,
GridLayout,
GridList,
GridListHeader,
GridListItem,
GridListItemProps,
GridListLoadMoreItem,
GridListProps,
GridListSection,
Heading,
ListLayout,
Modal,
Expand Down Expand Up @@ -145,6 +147,105 @@ const MyCheckbox = ({children, ...props}: CheckboxProps) => {
);
};


export const GridListSectionExample = (args) => (
<GridList
{...args}
className={styles.menu}
aria-label="test gridlist"
style={{
width: 400,
height: 400
}}>
<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>
</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>
</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>
</GridListSection>
</GridList>
);

GridListSectionExample.story = {
args: {
layout: 'stack',
escapeKeyBehavior: 'clearSelection',
shouldSelectOnPressUp: false
},
argTypes: {
layout: {
control: 'radio',
options: ['stack', 'grid']
},
keyboardNavigationBehavior: {
control: 'radio',
options: ['arrow', 'tab']
},
selectionMode: {
control: 'radio',
options: ['none', 'single', 'multiple']
},
selectionBehavior: {
control: 'radio',
options: ['toggle', 'replace']
},
escapeKeyBehavior: {
control: 'radio',
options: ['clearSelection', 'none']
}
}
};

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 < 100; i++) {
items.push({id: `item_${s}_${i}`, name: `Section ${s}, Item ${i}`});
}
sections.push({id: `section_${s}`, name: `Section ${s}`, children: items});
}

return (
<Virtualizer
layout={ListLayout}
layoutOptions={{
rowHeight: 25
}}>
<GridList
className={styles.menu}
// selectionMode="multiple"
style={{height: 400}}
aria-label="virtualized with grid section"
items={sections}>
<Collection items={sections}>
{section => (
<GridListSection>
<GridListHeader>{section.name}</GridListHeader>
<Collection items={section.children} >
{item => <MyGridListItem>{item.name}</MyGridListItem>}
</Collection>
</GridListSection>
)}
</Collection>
</GridList>
</Virtualizer>
);
}


const VirtualizedGridListRender = (args: GridListProps<any> & {isLoading: boolean}) => {
let items: {id: number, name: string}[] = [];
for (let i = 0; i < 10000; i++) {
Expand Down
81 changes: 81 additions & 0 deletions packages/react-aria-components/test/GridList.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,9 @@ import {
DropIndicator,
GridList,
GridListContext,
GridListHeader,
GridListItem,
GridListSection,
Label,
ListLayout,
Modal,
Expand All @@ -45,6 +47,23 @@ let TestGridList = ({listBoxProps, itemProps}) => (
</GridList>
);

let TestGridListSections = ({listBoxProps, itemProps}) => (
<GridList aria-label="Test" {...listBoxProps}>
<GridListSection>
<GridListHeader>Favorite Animal</GridListHeader>
<GridListItem {...itemProps} id="cat" textValue="Cat"><Checkbox slot="selection" /> Cat</GridListItem>
<GridListItem {...itemProps} id="dog" textValue="Dog"><Checkbox slot="selection" /> Dog</GridListItem>
<GridListItem {...itemProps} id="kangaroo" textValue="Kangaroo"><Checkbox slot="selection" /> Kangaroo</GridListItem>
</GridListSection>
<GridListSection aria-label="Favorite Ice Cream">
<GridListItem {...itemProps} id="cat" textValue="Vanilla"><Checkbox slot="selection" />Vanilla</GridListItem>
<GridListItem {...itemProps} id="dog" textValue="Chocolate"><Checkbox slot="selection" />Chocolate</GridListItem>
<GridListItem {...itemProps} id="kangaroo" textValue="Strawberry"><Checkbox slot="selection" />Strawberry</GridListItem>
</GridListSection>
</GridList>
);


let DraggableGridList = (props) => {
let {dragAndDropHooks} = useDragAndDrop({
getItems: (keys) => [...keys].map((key) => ({'text/plain': key})),
Expand Down Expand Up @@ -413,6 +432,68 @@ describe('GridList', () => {
expect(items[2]).toHaveAttribute('aria-selected', 'true');
});

it('should support sections', () => {
let {getAllByRole} = render(<TestGridListSections />);

let groups = getAllByRole('rowgroup');
expect(groups).toHaveLength(2);

expect(groups[0]).toHaveClass('react-aria-GridListSection');
expect(groups[1]).toHaveClass('react-aria-GridListSection');

expect(groups[0]).toHaveAttribute('aria-labelledby');
expect(document.getElementById(groups[0].getAttribute('aria-labelledby'))).toHaveTextContent('Favorite Animal');
expect(groups[1].getAttribute('aria-label')).toEqual('Favorite Ice Cream');
});

it('should update collection when moving item to a different section', () => {
let {getAllByRole, rerender} = render(
<GridList aria-label="Test">
<GridListSection id="veggies">
<GridListHeader>Veggies</GridListHeader>
<GridListItem key="lettuce" id="lettuce">Lettuce</GridListItem>
<GridListItem key="tomato" id="tomato">Tomato</GridListItem>
<GridListItem key="onion" id="onion">Onion</GridListItem>
</GridListSection>
<GridListSection id="meats">
<GridListHeader>Meats</GridListHeader>
<GridListItem key="ham" id="ham">Ham</GridListItem>
<GridListItem key="tuna" id="tuna">Tuna</GridListItem>
<GridListItem key="tofu" id="tofu">Tofu</GridListItem>
</GridListSection>
</GridList>
);

let sections = getAllByRole('rowgroup');
let items = within(sections[0]).getAllByRole('gridcell');
expect(items).toHaveLength(3);
items = within(sections[1]).getAllByRole('gridcell');
expect(items).toHaveLength(3);

rerender(
<GridList aria-label="Test">
<GridListSection id="veggies">
<GridListHeader>Veggies</GridListHeader>
<GridListItem key="lettuce" id="lettuce">Lettuce</GridListItem>
<GridListItem key="tomato" id="tomato">Tomato</GridListItem>
<GridListItem key="onion" id="onion">Onion</GridListItem>
<GridListItem key="ham" id="ham">Ham</GridListItem>
</GridListSection>
<GridListSection id="meats">
<GridListHeader>Meats</GridListHeader>
<GridListItem key="tuna" id="tuna">Tuna</GridListItem>
<GridListItem key="tofu" id="tofu">Tofu</GridListItem>
</GridListSection>
</GridList>
);

sections = getAllByRole('rowgroup');
items = within(sections[0]).getAllByRole('gridcell');
expect(items).toHaveLength(4);
items = within(sections[1]).getAllByRole('gridcell');
expect(items).toHaveLength(2);
});

describe('selectionBehavior="replace"', () => {
// Required for proper touch detection
installPointerEvent();
Expand Down
Loading