From 13f30a4f948fadf2e020de31c09904379aa515bb Mon Sep 17 00:00:00 2001 From: Yihui Liao <44729383+yihuiliao@users.noreply.github.com> Date: Tue, 22 Jul 2025 16:52:16 -0700 Subject: [PATCH 01/11] initialize gridlist section and headers --- packages/@react-aria/gridlist/src/index.ts | 1 + .../gridlist/src/useGridListSection.ts | 61 +++++++++ .../selection/src/useSelectableCollection.ts | 2 +- .../react-aria-components/src/GridList.tsx | 64 ++++++++- packages/react-aria-components/src/index.ts | 2 +- .../stories/GridList.stories.tsx | 123 ++++++++++++++++++ packages/react-aria/src/index.ts | 2 +- 7 files changed, 247 insertions(+), 8 deletions(-) create mode 100644 packages/@react-aria/gridlist/src/useGridListSection.ts diff --git a/packages/@react-aria/gridlist/src/index.ts b/packages/@react-aria/gridlist/src/index.ts index 740ca23b200..0e52ce29d6a 100644 --- a/packages/@react-aria/gridlist/src/index.ts +++ b/packages/@react-aria/gridlist/src/index.ts @@ -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'; diff --git a/packages/@react-aria/gridlist/src/useGridListSection.ts b/packages/@react-aria/gridlist/src/useGridListSection.ts new file mode 100644 index 00000000000..a67d581963d --- /dev/null +++ b/packages/@react-aria/gridlist/src/useGridListSection.ts @@ -0,0 +1,61 @@ +/* + * 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'; +import {Node} from 'react-stately'; +import type {ListState} from '@react-stately/list'; + + +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 { + let {heading, 'aria-label': ariaLabel} = props; + let headingId = useId(); + + return { + rowProps: { + role: 'row' + }, + headingProps: heading ? { + id: headingId, + role: 'rowheader' + } : {}, + rowGroupProps: { + role: 'rowgroup', + 'aria-label': ariaLabel, + 'aria-labelledby': heading ? headingId : undefined + } + }; +} \ No newline at end of file diff --git a/packages/@react-aria/selection/src/useSelectableCollection.ts b/packages/@react-aria/selection/src/useSelectableCollection.ts index 825888ffea6..bf1bf8f937d 100644 --- a/packages/@react-aria/selection/src/useSelectableCollection.ts +++ b/packages/@react-aria/selection/src/useSelectableCollection.ts @@ -125,7 +125,7 @@ export function useSelectableCollection(options: AriaSelectableCollectionOptions } = options; let {direction} = useLocale(); let router = useRouter(); - + let onKeyDown = (e: KeyboardEvent) => { // Prevent option + tab from doing anything since it doesn't move focus to the cells, only buttons/checkboxes if (e.altKey && e.key === 'Tab') { diff --git a/packages/react-aria-components/src/GridList.tsx b/packages/react-aria-components/src/GridList.tsx index 9cb34f49795..8f00a47ca35 100644 --- a/packages/react-aria-components/src/GridList.tsx +++ b/packages/react-aria-components/src/GridList.tsx @@ -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, createLeafComponent, createBranchComponent} 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'; @@ -245,7 +246,8 @@ function GridListInner({props, collection, gridListRef: ref}: values={[ [ListStateContext, state], [DragAndDropContext, {dragAndDropHooks, dragState, dropState}], - [DropIndicatorContext, {render: GridListDropIndicatorWrapper}] + [DropIndicatorContext, {render: GridListDropIndicatorWrapper}], + [SectionContext, {name: 'GridListSection', render: GridListSectionInner}] ]}> {isListDroppable && } ); }); + +export interface GridListSectionProps extends SectionProps {} + +function GridListSectionInner(props: GridListSectionProps, ref: ForwardedRef, section: Node, 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 ( +
+ + + +
+ ); +} + +export const GridListHeader = /*#__PURE__*/ createLeafComponent('header', function Header(props: HTMLAttributes, ref: ForwardedRef) { + [props, ref] = useContextProps(props, ref, HeaderContext); + return ( +
+
+ {props.children} +
+
+ ); +}); + + +/** + * A GridListSection represents a section within a GridList. + */ +export const GridListSection = /*#__PURE__*/ createBranchComponent('section', GridListSectionInner); diff --git a/packages/react-aria-components/src/index.ts b/packages/react-aria-components/src/index.ts index e0384baca75..00f432242b6 100644 --- a/packages/react-aria-components/src/index.ts +++ b/packages/react-aria-components/src/index.ts @@ -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'; diff --git a/packages/react-aria-components/stories/GridList.stories.tsx b/packages/react-aria-components/stories/GridList.stories.tsx index a58143c296e..bd4d47a8998 100644 --- a/packages/react-aria-components/stories/GridList.stories.tsx +++ b/packages/react-aria-components/stories/GridList.stories.tsx @@ -25,7 +25,10 @@ import { GridListItemProps, GridListLoadMoreItem, GridListProps, + GridListHeader, + GridListSection, Heading, + Header, ListLayout, Modal, ModalOverlay, @@ -145,6 +148,126 @@ const MyCheckbox = ({children, ...props}: CheckboxProps) => { ); }; + +export const GridListSectionExample = (args) => ( + + + Section 1 + 1,1 + 1,2 + 1,3 + + + Section 2 + 2,1 + 2,2 + 2,3 + + + Section 3 + 3,1 + 3,2 + 3,3 + + +); + +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++) { + const l = (s * 5) + i + 10; + items.push({id: `item_${s}_${i}`, name: `Section ${s}, Item ${i}`}); + } + sections.push({id: `section_${s}`, name: `Section ${s}`, children: items}); + } + + // let list = useListData({ + // initialItems: sections + // }); + + // let {dragAndDropHooks} = useDragAndDrop({ + // getItems: (keys) => { + // return [...keys].map(key => ({'text/plain': list.getItem(key)?.name ?? ''})); + // }, + // onReorder(e) { + // if (e.target.dropPosition === 'before') { + // list.moveBefore(e.target.key, e.keys); + // } else if (e.target.dropPosition === 'after') { + // list.moveAfter(e.target.key, e.keys); + // } + // }, + // renderDropIndicator(target) { + // return ({width: '100%', height: '100%', background: isDropTarget ? 'blue' : 'transparent'})} />; + // } + // }); + + return ( + + + {section => ( + + {section.name} + + {item => {item.name}} + + + )} + + + ); +} + + const VirtualizedGridListRender = (args: GridListProps & {isLoading: boolean}) => { let items: {id: number, name: string}[] = []; for (let i = 0; i < 10000; i++) { diff --git a/packages/react-aria/src/index.ts b/packages/react-aria/src/index.ts index bc63fd9515a..92c549151ad 100644 --- a/packages/react-aria/src/index.ts +++ b/packages/react-aria/src/index.ts @@ -24,7 +24,7 @@ export {FocusRing, FocusScope, useFocusManager, useFocusRing} from '@react-aria/ export {I18nProvider, useCollator, useDateFormatter, useFilter, useLocale, useLocalizedStringFormatter, useMessageFormatter, useNumberFormatter} from '@react-aria/i18n'; export {useFocus, useFocusVisible, useFocusWithin, useHover, useInteractOutside, useKeyboard, useMove, usePress, useLongPress, useFocusable, Pressable, Focusable} from '@react-aria/interactions'; export {useField, useLabel} from '@react-aria/label'; -export {useGridList, useGridListItem, useGridListSelectionCheckbox} from '@react-aria/gridlist'; +export {useGridList, useGridListItem, useGridListSection, useGridListSelectionCheckbox} from '@react-aria/gridlist'; export {useLandmark} from '@react-aria/landmark'; export {useLink} from '@react-aria/link'; export {useListBox, useListBoxSection, useOption} from '@react-aria/listbox'; From 42939bfb5872b5059c8b3654a1354faf0cc67d7c Mon Sep 17 00:00:00 2001 From: Yihui Liao <44729383+yihuiliao@users.noreply.github.com> Date: Mon, 28 Jul 2025 16:42:55 -0700 Subject: [PATCH 02/11] add test for gridlist sections --- .../test/GridList.test.js | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/packages/react-aria-components/test/GridList.test.js b/packages/react-aria-components/test/GridList.test.js index 3dc1f0bf7f7..6979f761d08 100644 --- a/packages/react-aria-components/test/GridList.test.js +++ b/packages/react-aria-components/test/GridList.test.js @@ -20,7 +20,9 @@ import { DropIndicator, GridList, GridListContext, + GridListHeader, GridListItem, + GridListSection, Label, ListLayout, Modal, @@ -45,6 +47,23 @@ let TestGridList = ({listBoxProps, itemProps}) => ( ); +let TestGridListSections = ({listBoxProps, itemProps}) => ( + + + Favorite Animal + Cat + Dog + Kangaroo + + + Vanilla + Chocolate + Strawberry + + +); + + let DraggableGridList = (props) => { let {dragAndDropHooks} = useDragAndDrop({ getItems: (keys) => [...keys].map((key) => ({'text/plain': key})), @@ -413,6 +432,20 @@ describe('GridList', () => { expect(items[2]).toHaveAttribute('aria-selected', 'true'); }); + it('should support sections', () => { + let {getAllByRole} = render(); + + 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'); + }) + describe('selectionBehavior="replace"', () => { // Required for proper touch detection installPointerEvent(); From 1cec0c1e24bfd0e8989cf808490886492ec190d1 Mon Sep 17 00:00:00 2001 From: Yihui Liao <44729383+yihuiliao@users.noreply.github.com> Date: Fri, 1 Aug 2025 16:21:49 -0700 Subject: [PATCH 03/11] add tests, cleanup --- .../react-aria-components/src/GridList.tsx | 23 ++++++--- .../stories/GridList.stories.tsx | 27 ++-------- .../test/GridList.test.js | 50 ++++++++++++++++++- 3 files changed, 69 insertions(+), 31 deletions(-) diff --git a/packages/react-aria-components/src/GridList.tsx b/packages/react-aria-components/src/GridList.tsx index 8f00a47ca35..d9ebc10f3c6 100644 --- a/packages/react-aria-components/src/GridList.tsx +++ b/packages/react-aria-components/src/GridList.tsx @@ -588,21 +588,30 @@ function GridListSectionInner(props: GridListSectionProps, {...rowGroupProps} {...renderProps} ref={ref}> - + - + collection={state.collection} + parent={section} + renderDropIndicator={useRenderDropIndicator(dragAndDropHooks, dropState)} + /> + ); } +const GridListHeaderContext = createContext | null>(null); + export const GridListHeader = /*#__PURE__*/ createLeafComponent('header', function Header(props: HTMLAttributes, ref: ForwardedRef) { [props, ref] = useContextProps(props, ref, HeaderContext); + let rowProps = useContext(GridListHeaderContext); + return ( -
+
{props.children}
diff --git a/packages/react-aria-components/stories/GridList.stories.tsx b/packages/react-aria-components/stories/GridList.stories.tsx index bd4d47a8998..080d8eb64ca 100644 --- a/packages/react-aria-components/stories/GridList.stories.tsx +++ b/packages/react-aria-components/stories/GridList.stories.tsx @@ -212,35 +212,15 @@ GridListSectionExample.story = { export function VirtualizedGridListSection() { let sections: {id: string, name: string, children: {id: string, name: string}[]}[] = []; - for (let s = 0; s < 10; s++) { + for (let s = 0; s < 2; s++) { let items: {id: string, name: string}[] = []; - for (let i = 0; i < 100; i++) { + for (let i = 0; i < 5; i++) { const l = (s * 5) + i + 10; items.push({id: `item_${s}_${i}`, name: `Section ${s}, Item ${i}`}); } sections.push({id: `section_${s}`, name: `Section ${s}`, children: items}); } - // let list = useListData({ - // initialItems: sections - // }); - - // let {dragAndDropHooks} = useDragAndDrop({ - // getItems: (keys) => { - // return [...keys].map(key => ({'text/plain': list.getItem(key)?.name ?? ''})); - // }, - // onReorder(e) { - // if (e.target.dropPosition === 'before') { - // list.moveBefore(e.target.key, e.keys); - // } else if (e.target.dropPosition === 'after') { - // list.moveAfter(e.target.key, e.keys); - // } - // }, - // renderDropIndicator(target) { - // return ({width: '100%', height: '100%', background: isDropTarget ? 'blue' : 'transparent'})} />; - // } - // }); - return ( + {section => ( {section.name} @@ -262,6 +242,7 @@ export function VirtualizedGridListSection() { )} + ); diff --git a/packages/react-aria-components/test/GridList.test.js b/packages/react-aria-components/test/GridList.test.js index 6979f761d08..dc01ea3f93b 100644 --- a/packages/react-aria-components/test/GridList.test.js +++ b/packages/react-aria-components/test/GridList.test.js @@ -444,7 +444,55 @@ describe('GridList', () => { 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( + + + Veggies + Lettuce + Tomato + Onion + + + Meats + Ham + Tuna + Tofu + + + ); + + 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( + + + Veggies + Lettuce + Tomato + Onion + Ham + + + Meats + Tuna + Tofu + + + ); + + 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 From 72c57d3fd979cea52fb284f0f1cab5e0b0dac6fd Mon Sep 17 00:00:00 2001 From: Yihui Liao <44729383+yihuiliao@users.noreply.github.com> Date: Fri, 1 Aug 2025 16:50:41 -0700 Subject: [PATCH 04/11] fix lint --- .../@react-aria/gridlist/src/useGridListSection.ts | 5 +---- packages/react-aria-components/src/GridList.tsx | 12 +++++------- .../stories/GridList.stories.tsx | 13 +++++-------- 3 files changed, 11 insertions(+), 19 deletions(-) diff --git a/packages/@react-aria/gridlist/src/useGridListSection.ts b/packages/@react-aria/gridlist/src/useGridListSection.ts index a67d581963d..fcaae5afa83 100644 --- a/packages/@react-aria/gridlist/src/useGridListSection.ts +++ b/packages/@react-aria/gridlist/src/useGridListSection.ts @@ -13,9 +13,6 @@ import {DOMAttributes} from '@react-types/shared'; import {ReactNode} from 'react'; import {useId} from '@react-aria/utils'; -import {Node} from 'react-stately'; -import type {ListState} from '@react-stately/list'; - export interface AriaGridListSectionProps { /** The heading for the section. */ @@ -58,4 +55,4 @@ export function useGridListSection(props: AriaGridListSectionProps): GridListSec 'aria-labelledby': heading ? headingId : undefined } }; -} \ No newline at end of file +} diff --git a/packages/react-aria-components/src/GridList.tsx b/packages/react-aria-components/src/GridList.tsx index d9ebc10f3c6..daa6dbc18c6 100644 --- a/packages/react-aria-components/src/GridList.tsx +++ b/packages/react-aria-components/src/GridList.tsx @@ -12,7 +12,7 @@ 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, createBranchComponent} from '@react-aria/collections'; +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'; @@ -592,13 +592,11 @@ function GridListSectionInner(props: GridListSectionProps, values={[ [HeaderContext, {...headingProps, ref: headingRef}], [GridListHeaderContext, {...rowProps}] - ]} - > + ]}> + collection={state.collection} + parent={section} + renderDropIndicator={useRenderDropIndicator(dragAndDropHooks, dropState)} /> ); diff --git a/packages/react-aria-components/stories/GridList.stories.tsx b/packages/react-aria-components/stories/GridList.stories.tsx index 080d8eb64ca..0eb599f1e94 100644 --- a/packages/react-aria-components/stories/GridList.stories.tsx +++ b/packages/react-aria-components/stories/GridList.stories.tsx @@ -21,14 +21,13 @@ import { DropIndicator, GridLayout, GridList, + GridListHeader, GridListItem, GridListItemProps, GridListLoadMoreItem, GridListProps, - GridListHeader, GridListSection, Heading, - Header, ListLayout, Modal, ModalOverlay, @@ -153,11 +152,10 @@ export const GridListSectionExample = (args) => ( Section 1 @@ -212,10 +210,9 @@ GridListSectionExample.story = { export function VirtualizedGridListSection() { let sections: {id: string, name: string, children: {id: string, name: string}[]}[] = []; - for (let s = 0; s < 2; s++) { + for (let s = 0; s < 10; s++) { let items: {id: string, name: string}[] = []; - for (let i = 0; i < 5; i++) { - const l = (s * 5) + i + 10; + 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}); @@ -237,7 +234,7 @@ export function VirtualizedGridListSection() { {section => ( {section.name} - + {item => {item.name}} From 56c400e2cb8065f86ea9ad4bfd681f867f959d5a Mon Sep 17 00:00:00 2001 From: Yihui Liao <44729383+yihuiliao@users.noreply.github.com> Date: Fri, 1 Aug 2025 16:52:02 -0700 Subject: [PATCH 05/11] remove spacing --- packages/@react-aria/selection/src/useSelectableCollection.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/@react-aria/selection/src/useSelectableCollection.ts b/packages/@react-aria/selection/src/useSelectableCollection.ts index bf1bf8f937d..825888ffea6 100644 --- a/packages/@react-aria/selection/src/useSelectableCollection.ts +++ b/packages/@react-aria/selection/src/useSelectableCollection.ts @@ -125,7 +125,7 @@ export function useSelectableCollection(options: AriaSelectableCollectionOptions } = options; let {direction} = useLocale(); let router = useRouter(); - + let onKeyDown = (e: KeyboardEvent) => { // Prevent option + tab from doing anything since it doesn't move focus to the cells, only buttons/checkboxes if (e.altKey && e.key === 'Tab') { From 519e0c6d01fabf53af8c221da3767dd4cf4c8993 Mon Sep 17 00:00:00 2001 From: Yihui Liao <44729383+yihuiliao@users.noreply.github.com> Date: Tue, 5 Aug 2025 10:25:12 -0700 Subject: [PATCH 06/11] merge props, useLabels --- packages/@react-aria/gridlist/src/useGridListSection.ts | 9 ++++++--- packages/react-aria-components/src/GridList.tsx | 7 ++++--- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/packages/@react-aria/gridlist/src/useGridListSection.ts b/packages/@react-aria/gridlist/src/useGridListSection.ts index fcaae5afa83..e9bbc167759 100644 --- a/packages/@react-aria/gridlist/src/useGridListSection.ts +++ b/packages/@react-aria/gridlist/src/useGridListSection.ts @@ -12,7 +12,7 @@ import {DOMAttributes} from '@react-types/shared'; import {ReactNode} from 'react'; -import {useId} from '@react-aria/utils'; +import {useId, useLabels} from '@react-aria/utils'; export interface AriaGridListSectionProps { /** The heading for the section. */ @@ -40,6 +40,10 @@ export interface GridListSectionAria { export function useGridListSection(props: AriaGridListSectionProps): GridListSectionAria { let {heading, 'aria-label': ariaLabel} = props; let headingId = useId(); + let labelProps = useLabels({ + 'aria-label': ariaLabel, + 'aria-labelledby': headingId + }); return { rowProps: { @@ -51,8 +55,7 @@ export function useGridListSection(props: AriaGridListSectionProps): GridListSec } : {}, rowGroupProps: { role: 'rowgroup', - 'aria-label': ariaLabel, - 'aria-labelledby': heading ? headingId : undefined + ...labelProps } }; } diff --git a/packages/react-aria-components/src/GridList.tsx b/packages/react-aria-components/src/GridList.tsx index daa6dbc18c6..ce1543f6a11 100644 --- a/packages/react-aria-components/src/GridList.tsx +++ b/packages/react-aria-components/src/GridList.tsx @@ -582,11 +582,12 @@ function GridListSectionInner(props: GridListSectionProps, values: {} }); + let DOMProps = filterDOMProps(props as any, {global: true}); + delete DOMProps.id; + return (
Date: Fri, 8 Aug 2025 16:03:52 -0700 Subject: [PATCH 07/11] follow-up --- .../gridlist/src/useGridListSection.ts | 15 +++---- .../react-aria-components/src/GridList.tsx | 43 ++++++++----------- 2 files changed, 25 insertions(+), 33 deletions(-) diff --git a/packages/@react-aria/gridlist/src/useGridListSection.ts b/packages/@react-aria/gridlist/src/useGridListSection.ts index e9bbc167759..29434d80442 100644 --- a/packages/@react-aria/gridlist/src/useGridListSection.ts +++ b/packages/@react-aria/gridlist/src/useGridListSection.ts @@ -11,12 +11,9 @@ */ import {DOMAttributes} from '@react-types/shared'; -import {ReactNode} from 'react'; -import {useId, useLabels} from '@react-aria/utils'; +import {useLabels, useSlotId} 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 } @@ -26,7 +23,7 @@ export interface GridListSectionAria { rowProps: DOMAttributes, /** Props for the heading element, if any. */ - headingProps: DOMAttributes, + rowHeaderProps: DOMAttributes, /** Props for the grid's row group element. */ rowGroupProps: DOMAttributes @@ -38,8 +35,8 @@ export interface GridListSectionAria { * @param props - Props for the section. */ export function useGridListSection(props: AriaGridListSectionProps): GridListSectionAria { - let {heading, 'aria-label': ariaLabel} = props; - let headingId = useId(); + let {'aria-label': ariaLabel} = props; + let headingId = useSlotId(); let labelProps = useLabels({ 'aria-label': ariaLabel, 'aria-labelledby': headingId @@ -49,10 +46,10 @@ export function useGridListSection(props: AriaGridListSectionProps): GridListSec rowProps: { role: 'row' }, - headingProps: heading ? { + rowHeaderProps: { id: headingId, role: 'rowheader' - } : {}, + }, rowGroupProps: { role: 'rowgroup', ...labelProps diff --git a/packages/react-aria-components/src/GridList.tsx b/packages/react-aria-components/src/GridList.tsx index ce1543f6a11..f9c3891f900 100644 --- a/packages/react-aria-components/src/GridList.tsx +++ b/packages/react-aria-components/src/GridList.tsx @@ -13,8 +13,8 @@ import {AriaGridListProps, DraggableItemResult, DragPreviewRenderer, DropIndicat import {ButtonContext} from './Button'; import {CheckboxContext} from './RSPContexts'; 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 {CollectionProps, CollectionRendererContext, DefaultCollectionRenderer, ItemRenderProps, SectionProps} from './Collection'; +import {ContextValue, DEFAULT_SLOT, Provider, RenderProps, SlotProps, StyleProps, StyleRenderProps, useContextProps, useRenderProps} 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'; @@ -246,8 +246,7 @@ function GridListInner({props, collection, gridListRef: ref}: values={[ [ListStateContext, state], [DragAndDropContext, {dragAndDropHooks, dragState, dropState}], - [DropIndicatorContext, {render: GridListDropIndicatorWrapper}], - [SectionContext, {name: 'GridListSection', render: GridListSectionInner}] + [DropIndicatorContext, {render: GridListDropIndicatorWrapper}] ]}> {isListDroppable && } extends SectionProps {} -function GridListSectionInner(props: GridListSectionProps, ref: ForwardedRef, section: Node, className = 'react-aria-GridListSection') { +/** + * A GridListSection represents a section within a GridList. + */ +export const GridListSection = /*#__PURE__*/ createBranchComponent('section', (props: GridListSectionProps, ref: ForwardedRef, item: Node) => { let state = useContext(ListStateContext)!; let {dragAndDropHooks, dropState} = useContext(DragAndDropContext)!; let {CollectionBranch} = useContext(CollectionRendererContext); - let [headingRef, heading] = useSlot(); - let {headingProps, rowProps, rowGroupProps} = useGridListSection({ - heading, + let headingRef = useRef(null); + let {rowHeaderProps, rowProps, rowGroupProps} = useGridListSection({ 'aria-label': props['aria-label'] ?? undefined }); let renderProps = useRenderProps({ - defaultClassName: className, + defaultClassName: 'react-aria-GridListSection', className: props.className, style: props.style, values: {} @@ -591,35 +592,29 @@ function GridListSectionInner(props: GridListSectionProps, ref={ref}>
); -} +}); const GridListHeaderContext = createContext | null>(null); export const GridListHeader = /*#__PURE__*/ createLeafComponent('header', function Header(props: HTMLAttributes, ref: ForwardedRef) { [props, ref] = useContextProps(props, ref, HeaderContext); - let rowProps = useContext(GridListHeaderContext); + let rowHeaderProps = useContext(GridListHeaderContext); return ( -
-
+
+
{props.children} -
-
+
+ ); }); - - -/** - * A GridListSection represents a section within a GridList. - */ -export const GridListSection = /*#__PURE__*/ createBranchComponent('section', GridListSectionInner); From 5e6236d1d713b9459744ea4f9403eeada77eaa57 Mon Sep 17 00:00:00 2001 From: Yihui Liao <44729383+yihuiliao@users.noreply.github.com> Date: Mon, 11 Aug 2025 17:21:02 -0700 Subject: [PATCH 08/11] fix aria-rowindex --- .../gridlist/src/useGridListItem.ts | 5 +++- .../gridlist/src/useGridListSection.ts | 24 +++++++++++++++---- .../react-aria-components/src/GridList.tsx | 8 ++++--- .../stories/GridList.stories.tsx | 2 +- 4 files changed, 29 insertions(+), 10 deletions(-) diff --git a/packages/@react-aria/gridlist/src/useGridListItem.ts b/packages/@react-aria/gridlist/src/useGridListItem.ts index 296bb37e31d..2ecfc4f8125 100644 --- a/packages/@react-aria/gridlist/src/useGridListItem.ts +++ b/packages/@react-aria/gridlist/src/useGridListItem.ts @@ -291,7 +291,10 @@ export function useGridListItem(props: AriaGridListItemOptions, state: ListSt }); if (isVirtualized) { - rowProps['aria-rowindex'] = node.index + 1; + 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; } let gridCellProps = { diff --git a/packages/@react-aria/gridlist/src/useGridListSection.ts b/packages/@react-aria/gridlist/src/useGridListSection.ts index 29434d80442..9d6203710f5 100644 --- a/packages/@react-aria/gridlist/src/useGridListSection.ts +++ b/packages/@react-aria/gridlist/src/useGridListSection.ts @@ -10,12 +10,17 @@ * governing permissions and limitations under the License. */ -import {DOMAttributes} from '@react-types/shared'; +import {DOMAttributes, 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, + /** Whether the list row is contained in a virtual scroller. */ + isVirtualized?: boolean, + /** An object representing the list item. Contains all the relevant information that makes up the list row. */ + node: RSNode } export interface GridListSectionAria { @@ -34,17 +39,26 @@ export interface GridListSectionAria { * See `useGridList` for more details about grid list. * @param props - Props for the section. */ -export function useGridListSection(props: AriaGridListSectionProps): GridListSectionAria { - let {'aria-label': ariaLabel} = props; +export function useGridListSection(props: AriaGridListSectionProps, state: ListState): GridListSectionAria { + let {'aria-label': ariaLabel, isVirtualized, node} = props; let headingId = useSlotId(); let labelProps = useLabels({ 'aria-label': ariaLabel, 'aria-labelledby': headingId }); + let rowIndex: number | undefined = undefined; + + if (isVirtualized) { + let {collection} = state; + let filteredCollection = [...collection.getKeys()].filter((key) => collection.getItem(key)?.type !== 'header'); + let prevItem = node.prevKey ? state.collection.getItem(node.prevKey) : undefined; + rowIndex = prevItem ? filteredCollection.findIndex((key) => key === prevItem.key) + 2 : node.index + 1; + } return { rowProps: { - role: 'row' + role: 'row', + 'aria-rowindex': rowIndex }, rowHeaderProps: { id: headingId, diff --git a/packages/react-aria-components/src/GridList.tsx b/packages/react-aria-components/src/GridList.tsx index f9c3891f900..3c59a326c05 100644 --- a/packages/react-aria-components/src/GridList.tsx +++ b/packages/react-aria-components/src/GridList.tsx @@ -571,11 +571,13 @@ export interface GridListSectionProps extends SectionProps {} export const GridListSection = /*#__PURE__*/ createBranchComponent('section', (props: GridListSectionProps, ref: ForwardedRef, item: Node) => { let state = useContext(ListStateContext)!; let {dragAndDropHooks, dropState} = useContext(DragAndDropContext)!; - let {CollectionBranch} = useContext(CollectionRendererContext); + let {CollectionBranch, isVirtualized} = useContext(CollectionRendererContext); let headingRef = useRef(null); let {rowHeaderProps, rowProps, rowGroupProps} = useGridListSection({ - 'aria-label': props['aria-label'] ?? undefined - }); + 'aria-label': props['aria-label'] ?? undefined, + isVirtualized, + node: item + }, state); let renderProps = useRenderProps({ defaultClassName: 'react-aria-GridListSection', className: props.className, diff --git a/packages/react-aria-components/stories/GridList.stories.tsx b/packages/react-aria-components/stories/GridList.stories.tsx index 0eb599f1e94..88f8f3664bb 100644 --- a/packages/react-aria-components/stories/GridList.stories.tsx +++ b/packages/react-aria-components/stories/GridList.stories.tsx @@ -212,7 +212,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 < 100; i++) { + for (let i = 0; i < 3; i++) { items.push({id: `item_${s}_${i}`, name: `Section ${s}, Item ${i}`}); } sections.push({id: `section_${s}`, name: `Section ${s}`, children: items}); From 7aea99e08278b419c19e457328c06e7fc8d5cbbc Mon Sep 17 00:00:00 2001 From: Yihui Liao <44729383+yihuiliao@users.noreply.github.com> Date: Tue, 12 Aug 2025 18:13:06 -0700 Subject: [PATCH 09/11] remove aria-rolindex for headers --- .../gridlist/src/useGridListSection.ts | 25 ++++--------------- .../react-aria-components/src/GridList.tsx | 8 +++--- 2 files changed, 8 insertions(+), 25 deletions(-) diff --git a/packages/@react-aria/gridlist/src/useGridListSection.ts b/packages/@react-aria/gridlist/src/useGridListSection.ts index 9d6203710f5..d273bb8fe53 100644 --- a/packages/@react-aria/gridlist/src/useGridListSection.ts +++ b/packages/@react-aria/gridlist/src/useGridListSection.ts @@ -10,17 +10,12 @@ * governing permissions and limitations under the License. */ -import {DOMAttributes, Node as RSNode} from '@react-types/shared'; -import type {ListState} from '@react-stately/list'; +import {DOMAttributes} from '@react-types/shared'; 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, - /** Whether the list row is contained in a virtual scroller. */ - isVirtualized?: boolean, - /** An object representing the list item. Contains all the relevant information that makes up the list row. */ - node: RSNode + 'aria-label'?: string } export interface GridListSectionAria { @@ -39,26 +34,16 @@ export interface GridListSectionAria { * See `useGridList` for more details about grid list. * @param props - Props for the section. */ -export function useGridListSection(props: AriaGridListSectionProps, state: ListState): GridListSectionAria { - let {'aria-label': ariaLabel, isVirtualized, node} = props; +export function useGridListSection(props: AriaGridListSectionProps): GridListSectionAria { + let {'aria-label': ariaLabel} = props; let headingId = useSlotId(); let labelProps = useLabels({ 'aria-label': ariaLabel, 'aria-labelledby': headingId }); - let rowIndex: number | undefined = undefined; - - if (isVirtualized) { - let {collection} = state; - let filteredCollection = [...collection.getKeys()].filter((key) => collection.getItem(key)?.type !== 'header'); - let prevItem = node.prevKey ? state.collection.getItem(node.prevKey) : undefined; - rowIndex = prevItem ? filteredCollection.findIndex((key) => key === prevItem.key) + 2 : node.index + 1; - } - return { rowProps: { - role: 'row', - 'aria-rowindex': rowIndex + role: 'row' }, rowHeaderProps: { id: headingId, diff --git a/packages/react-aria-components/src/GridList.tsx b/packages/react-aria-components/src/GridList.tsx index 3c59a326c05..f9c3891f900 100644 --- a/packages/react-aria-components/src/GridList.tsx +++ b/packages/react-aria-components/src/GridList.tsx @@ -571,13 +571,11 @@ export interface GridListSectionProps extends SectionProps {} export const GridListSection = /*#__PURE__*/ createBranchComponent('section', (props: GridListSectionProps, ref: ForwardedRef, item: Node) => { let state = useContext(ListStateContext)!; let {dragAndDropHooks, dropState} = useContext(DragAndDropContext)!; - let {CollectionBranch, isVirtualized} = useContext(CollectionRendererContext); + let {CollectionBranch} = useContext(CollectionRendererContext); let headingRef = useRef(null); let {rowHeaderProps, rowProps, rowGroupProps} = useGridListSection({ - 'aria-label': props['aria-label'] ?? undefined, - isVirtualized, - node: item - }, state); + 'aria-label': props['aria-label'] ?? undefined + }); let renderProps = useRenderProps({ defaultClassName: 'react-aria-GridListSection', className: props.className, From 88a387d10c4155f83962e37522a8575c5532cd15 Mon Sep 17 00:00:00 2001 From: Yihui Liao <44729383+yihuiliao@users.noreply.github.com> Date: Tue, 12 Aug 2025 20:37:27 -0700 Subject: [PATCH 10/11] pass state and ref to hook --- packages/@react-aria/gridlist/src/useGridListSection.ts | 6 ++++-- packages/react-aria-components/src/GridList.tsx | 7 +++---- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/@react-aria/gridlist/src/useGridListSection.ts b/packages/@react-aria/gridlist/src/useGridListSection.ts index d273bb8fe53..4222ae39b0e 100644 --- a/packages/@react-aria/gridlist/src/useGridListSection.ts +++ b/packages/@react-aria/gridlist/src/useGridListSection.ts @@ -10,7 +10,8 @@ * governing permissions and limitations under the License. */ -import {DOMAttributes} from '@react-types/shared'; +import {DOMAttributes, RefObject} from '@react-types/shared'; +import type {ListState} from '@react-stately/list'; import {useLabels, useSlotId} from '@react-aria/utils'; export interface AriaGridListSectionProps { @@ -34,13 +35,14 @@ export interface GridListSectionAria { * See `useGridList` for more details about grid list. * @param props - Props for the section. */ -export function useGridListSection(props: AriaGridListSectionProps): GridListSectionAria { +export function useGridListSection(props: AriaGridListSectionProps, state: ListState, ref: RefObject): GridListSectionAria { let {'aria-label': ariaLabel} = props; let headingId = useSlotId(); let labelProps = useLabels({ 'aria-label': ariaLabel, 'aria-labelledby': headingId }); + return { rowProps: { role: 'row' diff --git a/packages/react-aria-components/src/GridList.tsx b/packages/react-aria-components/src/GridList.tsx index f9c3891f900..825d584f21e 100644 --- a/packages/react-aria-components/src/GridList.tsx +++ b/packages/react-aria-components/src/GridList.tsx @@ -570,12 +570,12 @@ export interface GridListSectionProps extends SectionProps {} */ export const GridListSection = /*#__PURE__*/ createBranchComponent('section', (props: GridListSectionProps, ref: ForwardedRef, item: Node) => { let state = useContext(ListStateContext)!; - let {dragAndDropHooks, dropState} = useContext(DragAndDropContext)!; let {CollectionBranch} = useContext(CollectionRendererContext); let headingRef = useRef(null); + ref = useObjectRef(ref); let {rowHeaderProps, rowProps, rowGroupProps} = useGridListSection({ 'aria-label': props['aria-label'] ?? undefined - }); + }, state, ref); let renderProps = useRenderProps({ defaultClassName: 'react-aria-GridListSection', className: props.className, @@ -597,8 +597,7 @@ export const GridListSection = /*#__PURE__*/ createBranchComponent('section', + parent={item} /> ); From 6bcdd2aa836c2fd9a258225541a157d2a2f9a45f Mon Sep 17 00:00:00 2001 From: Yihui Liao <44729383+yihuiliao@users.noreply.github.com> Date: Tue, 12 Aug 2025 20:43:43 -0700 Subject: [PATCH 11/11] fix lint --- packages/@react-aria/gridlist/src/useGridListSection.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/@react-aria/gridlist/src/useGridListSection.ts b/packages/@react-aria/gridlist/src/useGridListSection.ts index 4222ae39b0e..f7d8bce9433 100644 --- a/packages/@react-aria/gridlist/src/useGridListSection.ts +++ b/packages/@react-aria/gridlist/src/useGridListSection.ts @@ -35,6 +35,7 @@ export interface GridListSectionAria { * See `useGridList` for more details about grid list. * @param props - Props for the section. */ +// eslint-disable-next-line @typescript-eslint/no-unused-vars export function useGridListSection(props: AriaGridListSectionProps, state: ListState, ref: RefObject): GridListSectionAria { let {'aria-label': ariaLabel} = props; let headingId = useSlotId();