Skip to content

Commit 0df71d9

Browse files
authored
feat: support sections and headers in RAC gridlist (#8667)
* initialize gridlist section and headers * add test for gridlist sections * add tests, cleanup * fix lint * remove spacing * merge props, useLabels * follow-up * fix aria-rowindex * remove aria-rolindex for headers * pass state and ref to hook * fix lint
1 parent b2a4bda commit 0df71d9

File tree

8 files changed

+308
-6
lines changed

8 files changed

+308
-6
lines changed

packages/@react-aria/gridlist/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
export {useGridList} from './useGridList';
1414
export {useGridListItem} from './useGridListItem';
1515
export {useGridListSelectionCheckbox} from './useGridListSelectionCheckbox';
16+
export {useGridListSection} from './useGridListSection';
1617

1718
export type {AriaGridListOptions, AriaGridListProps, GridListAria, GridListProps} from './useGridList';
1819
export type {AriaGridListItemOptions, GridListItemAria} from './useGridListItem';

packages/@react-aria/gridlist/src/useGridListItem.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -291,7 +291,10 @@ export function useGridListItem<T>(props: AriaGridListItemOptions, state: ListSt
291291
});
292292

293293
if (isVirtualized) {
294-
rowProps['aria-rowindex'] = node.index + 1;
294+
let {collection} = state;
295+
let nodes = [...collection];
296+
// TODO: refactor ListCollection to store an absolute index of a node's position?
297+
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;
295298
}
296299

297300
let gridCellProps = {
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
/*
2+
* Copyright 2020 Adobe. All rights reserved.
3+
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
4+
* you may not use this file except in compliance with the License. You may obtain a copy
5+
* of the License at http://www.apache.org/licenses/LICENSE-2.0
6+
*
7+
* Unless required by applicable law or agreed to in writing, software distributed under
8+
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9+
* OF ANY KIND, either express or implied. See the License for the specific language
10+
* governing permissions and limitations under the License.
11+
*/
12+
13+
import {DOMAttributes, RefObject} from '@react-types/shared';
14+
import type {ListState} from '@react-stately/list';
15+
import {useLabels, useSlotId} from '@react-aria/utils';
16+
17+
export interface AriaGridListSectionProps {
18+
/** An accessibility label for the section. Required if `heading` is not present. */
19+
'aria-label'?: string
20+
}
21+
22+
export interface GridListSectionAria {
23+
/** Props for the wrapper list item. */
24+
rowProps: DOMAttributes,
25+
26+
/** Props for the heading element, if any. */
27+
rowHeaderProps: DOMAttributes,
28+
29+
/** Props for the grid's row group element. */
30+
rowGroupProps: DOMAttributes
31+
}
32+
33+
/**
34+
* Provides the behavior and accessibility implementation for a section in a grid list.
35+
* See `useGridList` for more details about grid list.
36+
* @param props - Props for the section.
37+
*/
38+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
39+
export function useGridListSection<T>(props: AriaGridListSectionProps, state: ListState<T>, ref: RefObject<HTMLElement | null>): GridListSectionAria {
40+
let {'aria-label': ariaLabel} = props;
41+
let headingId = useSlotId();
42+
let labelProps = useLabels({
43+
'aria-label': ariaLabel,
44+
'aria-labelledby': headingId
45+
});
46+
47+
return {
48+
rowProps: {
49+
role: 'row'
50+
},
51+
rowHeaderProps: {
52+
id: headingId,
53+
role: 'rowheader'
54+
},
55+
rowGroupProps: {
56+
role: 'rowgroup',
57+
...labelProps
58+
}
59+
};
60+
}

packages/react-aria-components/src/GridList.tsx

Lines changed: 59 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,17 +9,18 @@
99
* OF ANY KIND, either express or implied. See the License for the specific language
1010
* governing permissions and limitations under the License.
1111
*/
12-
import {AriaGridListProps, DraggableItemResult, DragPreviewRenderer, DropIndicatorAria, DroppableCollectionResult, FocusScope, ListKeyboardDelegate, mergeProps, useCollator, useFocusRing, useGridList, useGridListItem, useGridListSelectionCheckbox, useHover, useLocale, useVisuallyHidden} from 'react-aria';
12+
import {AriaGridListProps, DraggableItemResult, DragPreviewRenderer, DropIndicatorAria, DroppableCollectionResult, FocusScope, ListKeyboardDelegate, mergeProps, useCollator, useFocusRing, useGridList, useGridListItem, useGridListSection, useGridListSelectionCheckbox, useHover, useLocale, useVisuallyHidden} from 'react-aria';
1313
import {ButtonContext} from './Button';
1414
import {CheckboxContext} from './RSPContexts';
15-
import {Collection, CollectionBuilder, createLeafComponent} from '@react-aria/collections';
16-
import {CollectionProps, CollectionRendererContext, DefaultCollectionRenderer, ItemRenderProps} from './Collection';
15+
import {Collection, CollectionBuilder, createBranchComponent, createLeafComponent} from '@react-aria/collections';
16+
import {CollectionProps, CollectionRendererContext, DefaultCollectionRenderer, ItemRenderProps, SectionProps} from './Collection';
1717
import {ContextValue, DEFAULT_SLOT, Provider, RenderProps, SlotProps, StyleProps, StyleRenderProps, useContextProps, useRenderProps} from './utils';
1818
import {DragAndDropContext, DropIndicatorContext, DropIndicatorProps, useDndPersistedKeys, useRenderDropIndicator} from './DragAndDrop';
1919
import {DragAndDropHooks} from './useDragAndDrop';
2020
import {DraggableCollectionState, DroppableCollectionState, Collection as ICollection, ListState, Node, SelectionBehavior, useListState} from 'react-stately';
2121
import {filterDOMProps, inertValue, LoadMoreSentinelProps, useLoadMoreSentinel, useObjectRef} from '@react-aria/utils';
2222
import {forwardRefType, GlobalDOMAttributes, HoverEvents, Key, LinkDOMProps, PressEvents, RefObject} from '@react-types/shared';
23+
import {HeaderContext} from './Header';
2324
import {ListStateContext} from './ListBox';
2425
import React, {createContext, ForwardedRef, forwardRef, HTMLAttributes, JSX, ReactNode, useContext, useEffect, useMemo, useRef} from 'react';
2526
import {TextContext} from './Text';
@@ -561,3 +562,58 @@ export const GridListLoadMoreItem = createLeafComponent('loader', function GridL
561562
</>
562563
);
563564
});
565+
566+
export interface GridListSectionProps<T> extends SectionProps<T> {}
567+
568+
/**
569+
* A GridListSection represents a section within a GridList.
570+
*/
571+
export const GridListSection = /*#__PURE__*/ createBranchComponent('section', <T extends object>(props: GridListSectionProps<T>, ref: ForwardedRef<HTMLElement>, item: Node<T>) => {
572+
let state = useContext(ListStateContext)!;
573+
let {CollectionBranch} = useContext(CollectionRendererContext);
574+
let headingRef = useRef(null);
575+
ref = useObjectRef<HTMLElement>(ref);
576+
let {rowHeaderProps, rowProps, rowGroupProps} = useGridListSection({
577+
'aria-label': props['aria-label'] ?? undefined
578+
}, state, ref);
579+
let renderProps = useRenderProps({
580+
defaultClassName: 'react-aria-GridListSection',
581+
className: props.className,
582+
style: props.style,
583+
values: {}
584+
});
585+
586+
let DOMProps = filterDOMProps(props as any, {global: true});
587+
delete DOMProps.id;
588+
589+
return (
590+
<section
591+
{...mergeProps(DOMProps, renderProps, rowGroupProps)}
592+
ref={ref}>
593+
<Provider
594+
values={[
595+
[HeaderContext, {...rowProps, ref: headingRef}],
596+
[GridListHeaderContext, {...rowHeaderProps}]
597+
]}>
598+
<CollectionBranch
599+
collection={state.collection}
600+
parent={item} />
601+
</Provider>
602+
</section>
603+
);
604+
});
605+
606+
const GridListHeaderContext = createContext<HTMLAttributes<HTMLElement> | null>(null);
607+
608+
export const GridListHeader = /*#__PURE__*/ createLeafComponent('header', function Header(props: HTMLAttributes<HTMLElement>, ref: ForwardedRef<HTMLElement>) {
609+
[props, ref] = useContextProps(props, ref, HeaderContext);
610+
let rowHeaderProps = useContext(GridListHeaderContext);
611+
612+
return (
613+
<header {...props} ref={ref}>
614+
<div {...rowHeaderProps} style={{display: 'contents'}}>
615+
{props.children}
616+
</div>
617+
</header>
618+
);
619+
});

packages/react-aria-components/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ export {DropZone, DropZoneContext} from './DropZone';
3939
export {FieldError, FieldErrorContext} from './FieldError';
4040
export {FileTrigger} from './FileTrigger';
4141
export {Form, FormContext} from './Form';
42-
export {GridListLoadMoreItem, GridList, GridListItem, GridListContext} from './GridList';
42+
export {GridListLoadMoreItem, GridList, GridListItem, GridListContext, GridListHeader, GridListSection} from './GridList';
4343
export {Group, GroupContext} from './Group';
4444
export {Header, HeaderContext} from './Header';
4545
export {Heading} from './Heading';

packages/react-aria-components/stories/GridList.stories.tsx

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,10 +21,12 @@ import {
2121
DropIndicator,
2222
GridLayout,
2323
GridList,
24+
GridListHeader,
2425
GridListItem,
2526
GridListItemProps,
2627
GridListLoadMoreItem,
2728
GridListProps,
29+
GridListSection,
2830
Heading,
2931
ListLayout,
3032
Modal,
@@ -145,6 +147,105 @@ const MyCheckbox = ({children, ...props}: CheckboxProps) => {
145147
);
146148
};
147149

150+
151+
export const GridListSectionExample = (args) => (
152+
<GridList
153+
{...args}
154+
className={styles.menu}
155+
aria-label="test gridlist"
156+
style={{
157+
width: 400,
158+
height: 400
159+
}}>
160+
<GridListSection>
161+
<GridListHeader>Section 1</GridListHeader>
162+
<MyGridListItem>1,1 <Button>Actions</Button></MyGridListItem>
163+
<MyGridListItem>1,2 <Button>Actions</Button></MyGridListItem>
164+
<MyGridListItem>1,3 <Button>Actions</Button></MyGridListItem>
165+
</GridListSection>
166+
<GridListSection>
167+
<GridListHeader>Section 2</GridListHeader>
168+
<MyGridListItem>2,1 <Button>Actions</Button></MyGridListItem>
169+
<MyGridListItem>2,2 <Button>Actions</Button></MyGridListItem>
170+
<MyGridListItem>2,3 <Button>Actions</Button></MyGridListItem>
171+
</GridListSection>
172+
<GridListSection>
173+
<GridListHeader>Section 3</GridListHeader>
174+
<MyGridListItem>3,1 <Button>Actions</Button></MyGridListItem>
175+
<MyGridListItem>3,2 <Button>Actions</Button></MyGridListItem>
176+
<MyGridListItem>3,3 <Button>Actions</Button></MyGridListItem>
177+
</GridListSection>
178+
</GridList>
179+
);
180+
181+
GridListSectionExample.story = {
182+
args: {
183+
layout: 'stack',
184+
escapeKeyBehavior: 'clearSelection',
185+
shouldSelectOnPressUp: false
186+
},
187+
argTypes: {
188+
layout: {
189+
control: 'radio',
190+
options: ['stack', 'grid']
191+
},
192+
keyboardNavigationBehavior: {
193+
control: 'radio',
194+
options: ['arrow', 'tab']
195+
},
196+
selectionMode: {
197+
control: 'radio',
198+
options: ['none', 'single', 'multiple']
199+
},
200+
selectionBehavior: {
201+
control: 'radio',
202+
options: ['toggle', 'replace']
203+
},
204+
escapeKeyBehavior: {
205+
control: 'radio',
206+
options: ['clearSelection', 'none']
207+
}
208+
}
209+
};
210+
211+
export function VirtualizedGridListSection() {
212+
let sections: {id: string, name: string, children: {id: string, name: string}[]}[] = [];
213+
for (let s = 0; s < 10; s++) {
214+
let items: {id: string, name: string}[] = [];
215+
for (let i = 0; i < 3; i++) {
216+
items.push({id: `item_${s}_${i}`, name: `Section ${s}, Item ${i}`});
217+
}
218+
sections.push({id: `section_${s}`, name: `Section ${s}`, children: items});
219+
}
220+
221+
return (
222+
<Virtualizer
223+
layout={ListLayout}
224+
layoutOptions={{
225+
rowHeight: 25
226+
}}>
227+
<GridList
228+
className={styles.menu}
229+
// selectionMode="multiple"
230+
style={{height: 400}}
231+
aria-label="virtualized with grid section"
232+
items={sections}>
233+
<Collection items={sections}>
234+
{section => (
235+
<GridListSection>
236+
<GridListHeader>{section.name}</GridListHeader>
237+
<Collection items={section.children} >
238+
{item => <MyGridListItem>{item.name}</MyGridListItem>}
239+
</Collection>
240+
</GridListSection>
241+
)}
242+
</Collection>
243+
</GridList>
244+
</Virtualizer>
245+
);
246+
}
247+
248+
148249
const VirtualizedGridListRender = (args: GridListProps<any> & {isLoading: boolean}) => {
149250
let items: {id: number, name: string}[] = [];
150251
for (let i = 0; i < 10000; i++) {

packages/react-aria-components/test/GridList.test.js

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,9 @@ import {
2020
DropIndicator,
2121
GridList,
2222
GridListContext,
23+
GridListHeader,
2324
GridListItem,
25+
GridListSection,
2426
Label,
2527
ListLayout,
2628
Modal,
@@ -45,6 +47,23 @@ let TestGridList = ({listBoxProps, itemProps}) => (
4547
</GridList>
4648
);
4749

50+
let TestGridListSections = ({listBoxProps, itemProps}) => (
51+
<GridList aria-label="Test" {...listBoxProps}>
52+
<GridListSection>
53+
<GridListHeader>Favorite Animal</GridListHeader>
54+
<GridListItem {...itemProps} id="cat" textValue="Cat"><Checkbox slot="selection" /> Cat</GridListItem>
55+
<GridListItem {...itemProps} id="dog" textValue="Dog"><Checkbox slot="selection" /> Dog</GridListItem>
56+
<GridListItem {...itemProps} id="kangaroo" textValue="Kangaroo"><Checkbox slot="selection" /> Kangaroo</GridListItem>
57+
</GridListSection>
58+
<GridListSection aria-label="Favorite Ice Cream">
59+
<GridListItem {...itemProps} id="cat" textValue="Vanilla"><Checkbox slot="selection" />Vanilla</GridListItem>
60+
<GridListItem {...itemProps} id="dog" textValue="Chocolate"><Checkbox slot="selection" />Chocolate</GridListItem>
61+
<GridListItem {...itemProps} id="kangaroo" textValue="Strawberry"><Checkbox slot="selection" />Strawberry</GridListItem>
62+
</GridListSection>
63+
</GridList>
64+
);
65+
66+
4867
let DraggableGridList = (props) => {
4968
let {dragAndDropHooks} = useDragAndDrop({
5069
getItems: (keys) => [...keys].map((key) => ({'text/plain': key})),
@@ -413,6 +432,68 @@ describe('GridList', () => {
413432
expect(items[2]).toHaveAttribute('aria-selected', 'true');
414433
});
415434

435+
it('should support sections', () => {
436+
let {getAllByRole} = render(<TestGridListSections />);
437+
438+
let groups = getAllByRole('rowgroup');
439+
expect(groups).toHaveLength(2);
440+
441+
expect(groups[0]).toHaveClass('react-aria-GridListSection');
442+
expect(groups[1]).toHaveClass('react-aria-GridListSection');
443+
444+
expect(groups[0]).toHaveAttribute('aria-labelledby');
445+
expect(document.getElementById(groups[0].getAttribute('aria-labelledby'))).toHaveTextContent('Favorite Animal');
446+
expect(groups[1].getAttribute('aria-label')).toEqual('Favorite Ice Cream');
447+
});
448+
449+
it('should update collection when moving item to a different section', () => {
450+
let {getAllByRole, rerender} = render(
451+
<GridList aria-label="Test">
452+
<GridListSection id="veggies">
453+
<GridListHeader>Veggies</GridListHeader>
454+
<GridListItem key="lettuce" id="lettuce">Lettuce</GridListItem>
455+
<GridListItem key="tomato" id="tomato">Tomato</GridListItem>
456+
<GridListItem key="onion" id="onion">Onion</GridListItem>
457+
</GridListSection>
458+
<GridListSection id="meats">
459+
<GridListHeader>Meats</GridListHeader>
460+
<GridListItem key="ham" id="ham">Ham</GridListItem>
461+
<GridListItem key="tuna" id="tuna">Tuna</GridListItem>
462+
<GridListItem key="tofu" id="tofu">Tofu</GridListItem>
463+
</GridListSection>
464+
</GridList>
465+
);
466+
467+
let sections = getAllByRole('rowgroup');
468+
let items = within(sections[0]).getAllByRole('gridcell');
469+
expect(items).toHaveLength(3);
470+
items = within(sections[1]).getAllByRole('gridcell');
471+
expect(items).toHaveLength(3);
472+
473+
rerender(
474+
<GridList aria-label="Test">
475+
<GridListSection id="veggies">
476+
<GridListHeader>Veggies</GridListHeader>
477+
<GridListItem key="lettuce" id="lettuce">Lettuce</GridListItem>
478+
<GridListItem key="tomato" id="tomato">Tomato</GridListItem>
479+
<GridListItem key="onion" id="onion">Onion</GridListItem>
480+
<GridListItem key="ham" id="ham">Ham</GridListItem>
481+
</GridListSection>
482+
<GridListSection id="meats">
483+
<GridListHeader>Meats</GridListHeader>
484+
<GridListItem key="tuna" id="tuna">Tuna</GridListItem>
485+
<GridListItem key="tofu" id="tofu">Tofu</GridListItem>
486+
</GridListSection>
487+
</GridList>
488+
);
489+
490+
sections = getAllByRole('rowgroup');
491+
items = within(sections[0]).getAllByRole('gridcell');
492+
expect(items).toHaveLength(4);
493+
items = within(sections[1]).getAllByRole('gridcell');
494+
expect(items).toHaveLength(2);
495+
});
496+
416497
describe('selectionBehavior="replace"', () => {
417498
// Required for proper touch detection
418499
installPointerEvent();

0 commit comments

Comments
 (0)