Skip to content

Commit 4f47a68

Browse files
feat: nested collection support (#7379)
* feat: nested collection support * feat: add support for custom node keys * fix: key generation for static scenario * fix: lint error * chore: remove key normalization * fix: static node key scopes & table tests * fix: grid id mismatch * fix: reordering in virtualized gridlist * fix: drag and drop * feat: node key util & grid/table support * chore: upgrade pkg-lock and leverage combined scope in builder * fix: duplicate import * fix: table rendering in docs * chore: fix build * contain changes to selection package * revert idScope change --------- Co-authored-by: Devon Govett <[email protected]>
1 parent a7b8580 commit 4f47a68

File tree

13 files changed

+142
-22
lines changed

13 files changed

+142
-22
lines changed

packages/@react-aria/dnd/src/ListDropTargetDelegate.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,8 @@ export class ListDropTargetDelegate implements DropTargetDelegate {
8787
let isSecondaryRTL = this.layout === 'grid' && this.orientation === 'vertical' && this.direction === 'rtl';
8888
let isFlowRTL = this.layout === 'stack' ? isPrimaryRTL : isSecondaryRTL;
8989

90-
let elements = this.ref.current.querySelectorAll('[data-key]');
90+
let collection = this.ref.current?.dataset.collection;
91+
let elements = this.ref.current.querySelectorAll(collection ? `[data-collection="${CSS.escape(collection)}"]` : '[data-key]');
9192
let elementMap = new Map<string, HTMLElement>();
9293
for (let item of elements) {
9394
if (item instanceof HTMLElement && item.dataset.key != null) {

packages/@react-aria/grid/src/useGrid.ts

+8-1
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,11 @@ import {useSelectableCollection} from '@react-aria/selection';
2626
export interface GridProps extends DOMProps, AriaLabelingProps {
2727
/** Whether the grid uses virtual scrolling. */
2828
isVirtualized?: boolean,
29+
/**
30+
* Whether typeahead navigation is disabled.
31+
* @default false
32+
*/
33+
disallowTypeAhead?: boolean,
2934
/**
3035
* An optional keyboard delegate implementation for type to select,
3136
* to override the default.
@@ -66,6 +71,7 @@ export interface GridAria {
6671
export function useGrid<T>(props: GridProps, state: GridState<T, GridCollection<T>>, ref: RefObject<HTMLElement | null>): GridAria {
6772
let {
6873
isVirtualized,
74+
disallowTypeAhead,
6975
keyboardDelegate,
7076
focusMode,
7177
scrollRef,
@@ -99,7 +105,8 @@ export function useGrid<T>(props: GridProps, state: GridState<T, GridCollection<
99105
selectionManager: manager,
100106
keyboardDelegate: delegate,
101107
isVirtualized,
102-
scrollRef
108+
scrollRef,
109+
disallowTypeAhead
103110
});
104111

105112
let id = useId(props.id);

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

+7
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,11 @@ export interface AriaGridListProps<T> extends GridListProps<T>, DOMProps, AriaLa
5454
export interface AriaGridListOptions<T> extends Omit<AriaGridListProps<T>, 'children'> {
5555
/** Whether the list uses virtual scrolling. */
5656
isVirtualized?: boolean,
57+
/**
58+
* Whether typeahead navigation is disabled.
59+
* @default false
60+
*/
61+
disallowTypeAhead?: boolean,
5762
/**
5863
* An optional keyboard delegate implementation for type to select,
5964
* to override the default.
@@ -98,6 +103,7 @@ export function useGridList<T>(props: AriaGridListOptions<T>, state: ListState<T
98103
keyboardDelegate,
99104
layoutDelegate,
100105
onAction,
106+
disallowTypeAhead,
101107
linkBehavior = 'action',
102108
keyboardNavigationBehavior = 'arrow'
103109
} = props;
@@ -117,6 +123,7 @@ export function useGridList<T>(props: AriaGridListOptions<T>, state: ListState<T
117123
selectOnFocus: state.selectionManager.selectionBehavior === 'replace',
118124
shouldFocusWrap: props.shouldFocusWrap,
119125
linkBehavior,
126+
disallowTypeAhead,
120127
autoFocus: props.autoFocus
121128
});
122129

packages/@react-aria/menu/src/useMenuItem.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -308,7 +308,7 @@ export function useMenuItem<T>(props: AriaMenuItemProps, state: TreeState<T>, re
308308
domProps,
309309
linkProps,
310310
isTrigger
311-
? {onFocus: itemProps.onFocus, 'data-key': itemProps['data-key']}
311+
? {onFocus: itemProps.onFocus, 'data-collection': itemProps['data-collection'], 'data-key': itemProps['data-key']}
312312
: itemProps,
313313
pressProps,
314314
hoverProps,

packages/@react-aria/selection/src/DOMLayoutDelegate.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
* governing permissions and limitations under the License.
1111
*/
1212

13+
import {getItemElement} from './utils';
1314
import {Key, LayoutDelegate, Rect, RefObject, Size} from '@react-types/shared';
1415

1516
export class DOMLayoutDelegate implements LayoutDelegate {
@@ -24,7 +25,7 @@ export class DOMLayoutDelegate implements LayoutDelegate {
2425
if (!container) {
2526
return null;
2627
}
27-
let item = key != null ? container.querySelector(`[data-key="${CSS.escape(key.toString())}"]`) : null;
28+
let item = key != null ? getItemElement(this.ref, key) : null;
2829
if (!item) {
2930
return null;
3031
}

packages/@react-aria/selection/src/useSelectableCollection.ts

+11-10
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import {flushSync} from 'react-dom';
1616
import {FocusEvent, KeyboardEvent, useEffect, useRef} from 'react';
1717
import {focusSafely, getInteractionModality} from '@react-aria/interactions';
1818
import {getFocusableTreeWalker, moveVirtualFocus} from '@react-aria/focus';
19-
import {isNonContiguousSelectionModifier} from './utils';
19+
import {getItemElement, isNonContiguousSelectionModifier, useCollectionId} from './utils';
2020
import {MultipleSelectionManager} from '@react-stately/selection';
2121
import {useLocale} from '@react-aria/i18n';
2222
import {useTypeSelect} from './useTypeSelect';
@@ -140,7 +140,7 @@ export function useSelectableCollection(options: AriaSelectableCollectionOptions
140140
manager.setFocusedKey(key, childFocus);
141141
});
142142

143-
let item = scrollRef.current?.querySelector(`[data-key="${CSS.escape(key.toString())}"]`);
143+
let item = getItemElement(ref, key);
144144
let itemProps = manager.getItemProps(key);
145145
if (item) {
146146
router.open(item, e, itemProps.href, itemProps.routerOptions);
@@ -368,8 +368,8 @@ export function useSelectableCollection(options: AriaSelectableCollectionOptions
368368

369369
if (manager.focusedKey != null && scrollRef.current) {
370370
// Refocus and scroll the focused item into view if it exists within the scrollable region.
371-
let element = scrollRef.current.querySelector(`[data-key="${CSS.escape(manager.focusedKey.toString())}"]`) as HTMLElement;
372-
if (element) {
371+
let element = getItemElement(ref, manager.focusedKey);
372+
if (element instanceof HTMLElement) {
373373
// This prevents a flash of focus on the first/last element in the collection, or the collection itself.
374374
if (!element.contains(document.activeElement) && !shouldUseVirtualFocus) {
375375
focusWithoutScrolling(element);
@@ -496,8 +496,8 @@ export function useSelectableCollection(options: AriaSelectableCollectionOptions
496496
useEffect(() => {
497497
if (manager.isFocused && manager.focusedKey != null && (manager.focusedKey !== lastFocusedKey.current || autoFocusRef.current) && scrollRef.current && ref.current) {
498498
let modality = getInteractionModality();
499-
let element = ref.current.querySelector(`[data-key="${CSS.escape(manager.focusedKey.toString())}"]`) as HTMLElement;
500-
if (!element) {
499+
let element = getItemElement(ref, manager.focusedKey);
500+
if (!(element instanceof HTMLElement)) {
501501
// If item element wasn't found, return early (don't update autoFocusRef and lastFocusedKey).
502502
// The collection may initially be empty (e.g. virtualizer), so wait until the element exists.
503503
return;
@@ -557,10 +557,11 @@ export function useSelectableCollection(options: AriaSelectableCollectionOptions
557557
tabIndex = manager.focusedKey == null ? 0 : -1;
558558
}
559559

560+
let collectionId = useCollectionId(manager.collection);
560561
return {
561-
collectionProps: {
562-
...handlers,
563-
tabIndex
564-
}
562+
collectionProps: mergeProps(handlers, {
563+
tabIndex,
564+
'data-collection': collectionId
565+
})
565566
};
566567
}

packages/@react-aria/selection/src/useSelectableItem.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,8 @@
1212

1313
import {DOMAttributes, DOMProps, FocusableElement, Key, LongPressEvent, PointerType, PressEvent, RefObject} from '@react-types/shared';
1414
import {focusSafely, PressProps, useLongPress, usePress} from '@react-aria/interactions';
15+
import {getCollectionId, isNonContiguousSelectionModifier} from './utils';
1516
import {isCtrlKeyPressed, mergeProps, openLink, useId, useRouter} from '@react-aria/utils';
16-
import {isNonContiguousSelectionModifier} from './utils';
1717
import {moveVirtualFocus} from '@react-aria/focus';
1818
import {MultipleSelectionManager} from '@react-stately/selection';
1919
import {useEffect, useRef} from 'react';
@@ -315,6 +315,7 @@ export function useSelectableItem(options: SelectableItemOptions): SelectableIte
315315
};
316316
}
317317

318+
itemProps['data-collection'] = getCollectionId(manager.collection);
318319
itemProps['data-key'] = key;
319320
itemPressProps.preventFocusOnPress = shouldUseVirtualFocus;
320321

packages/@react-aria/selection/src/utils.ts

+23-1
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@
1010
* governing permissions and limitations under the License.
1111
*/
1212

13-
import {isAppleDevice} from '@react-aria/utils';
13+
import {Collection, Key} from '@react-types/shared';
14+
import {isAppleDevice, useId} from '@react-aria/utils';
15+
import {RefObject} from 'react';
1416

1517
interface Event {
1618
altKey: boolean,
@@ -23,3 +25,23 @@ export function isNonContiguousSelectionModifier(e: Event) {
2325
// On Windows and Ubuntu, Alt + Space has a system wide meaning.
2426
return isAppleDevice() ? e.altKey : e.ctrlKey;
2527
}
28+
29+
export function getItemElement(collectionRef: RefObject<HTMLElement | null>, key: Key) {
30+
let selector = `[data-key="${CSS.escape(String(key))}"]`;
31+
let collection = collectionRef.current?.dataset.collection;
32+
if (collection) {
33+
selector = `[data-collection="${CSS.escape(collection)}"]${selector}`;
34+
}
35+
return collectionRef.current?.querySelector(selector);
36+
}
37+
38+
const collectionMap = new WeakMap();
39+
export function useCollectionId(collection: Collection<any>) {
40+
let id = useId();
41+
collectionMap.set(collection, id);
42+
return id;
43+
}
44+
45+
export function getCollectionId(collection: Collection<any>) {
46+
return collectionMap.get(collection)!;
47+
}

packages/@react-spectrum/tag/src/TagGroup.tsx

+2-2
Original file line numberDiff line numberDiff line change
@@ -77,11 +77,11 @@ export const TagGroup = React.forwardRef(function TagGroup<T extends object>(pro
7777
: new ListCollection([...state.collection])) as Collection<Node<T>>;
7878
return new ListKeyboardDelegate({
7979
collection,
80-
ref: domRef,
80+
ref: tagsRef,
8181
direction,
8282
orientation: 'horizontal'
8383
});
84-
}, [direction, isCollapsed, state.collection, tagState.visibleTagCount, domRef]) as ListKeyboardDelegate<T>;
84+
}, [direction, isCollapsed, state.collection, tagState.visibleTagCount, tagsRef]) as ListKeyboardDelegate<T>;
8585
// Remove onAction from props so it doesn't make it into useGridList.
8686
delete props.onAction;
8787
let {gridProps, labelProps, descriptionProps, errorMessageProps} = useTagGroup({...props, keyboardDelegate}, state, tagsRef);

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

+5
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,11 @@ export interface GridListRenderProps {
5757
}
5858

5959
export interface GridListProps<T> extends Omit<AriaGridListProps<T>, 'children'>, CollectionProps<T>, StyleRenderProps<GridListRenderProps>, SlotProps, ScrollableProps<HTMLDivElement> {
60+
/**
61+
* Whether typeahead navigation is disabled.
62+
* @default false
63+
*/
64+
disallowTypeAhead?: boolean,
6065
/** How multiple selection should behave in the collection. */
6166
selectionBehavior?: SelectionBehavior,
6267
/** The drag and drop hooks returned by `useDragAndDrop` used to enable drag and drop behavior for the GridList. */

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

+14-2
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,7 @@ export function TagGroupInsideGridList() {
175175
<GridList
176176
className={styles.menu}
177177
aria-label="Grid list with tag group"
178+
keyboardNavigationBehavior="tab"
178179
style={{
179180
width: 300,
180181
height: 300
@@ -189,8 +190,19 @@ export function TagGroupInsideGridList() {
189190
</TagList>
190191
</TagGroup>
191192
</MyGridListItem>
192-
<MyGridListItem>1,2 <Button>Actions</Button></MyGridListItem>
193-
<MyGridListItem>1,3 <Button>Actions</Button></MyGridListItem>
193+
<MyGridListItem>
194+
1,2 <Button>Actions</Button>
195+
</MyGridListItem>
196+
<MyGridListItem>
197+
1,3
198+
<TagGroup aria-label="Tag group">
199+
<TagList style={{display: 'flex', gap: 10}}>
200+
<Tag key="1">Tag 1</Tag>
201+
<Tag key="2">Tag 2</Tag>
202+
<Tag key="3">Tag 3</Tag>
203+
</TagList>
204+
</TagGroup>
205+
</MyGridListItem>
194206
</GridList>
195207
);
196208
}

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

+23
Original file line numberDiff line numberDiff line change
@@ -495,6 +495,29 @@ describe('GridList', () => {
495495
expect(checkbox).toBeInTheDocument();
496496
});
497497

498+
it('should support nested collections with colliding keys', async () => {
499+
let {container} = render(
500+
<GridList aria-label="CardView" keyboardNavigationBehavior="Tab">
501+
<GridListItem id="1" textValue="Card">
502+
<GridList aria-label="Previews">
503+
<GridListItem id="1">Paco de Lucia</GridListItem>
504+
</GridList>
505+
</GridListItem>
506+
</GridList>
507+
);
508+
509+
let itemMap = new Map();
510+
let items = container.querySelectorAll('[data-key]');
511+
512+
for (let item of items) {
513+
if (item instanceof HTMLElement) {
514+
let key = item.dataset.collection + ':' + item.dataset.key;
515+
expect(itemMap.has(key)).toBe(false);
516+
itemMap.set(key, item);
517+
}
518+
}
519+
});
520+
498521
describe('drag and drop', () => {
499522
it('should support drag button slot', () => {
500523
let {getAllByRole} = render(<DraggableGridList />);

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

+42-2
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
*/
1212

1313
import {act, fireEvent, installPointerEvent, mockClickDefault, pointerMap, render, triggerLongPress, within} from '@react-spectrum/test-utils-internal';
14-
import {Button, Cell, Checkbox, Collection, Column, ColumnResizer, Dialog, DialogTrigger, DropIndicator, Label, Modal, ResizableTableContainer, Row, Table, TableBody, TableHeader, TableLayout, useDragAndDrop, useTableOptions, Virtualizer} from '../';
14+
import {Button, Cell, Checkbox, Collection, Column, ColumnResizer, Dialog, DialogTrigger, DropIndicator, Label, Modal, ResizableTableContainer, Row, Table, TableBody, TableHeader, TableLayout, Tag, TagGroup, TagList, useDragAndDrop, useTableOptions, Virtualizer} from '../';
1515
import {composeStories} from '@storybook/react';
1616
import {DataTransfer, DragEvent} from '@react-aria/dnd/test/mocks';
1717
import React, {useMemo, useRef, useState} from 'react';
@@ -64,7 +64,7 @@ function MyRow({id, columns, children, ...otherProps}) {
6464
let {selectionBehavior, allowsDragging} = useTableOptions();
6565

6666
return (
67-
<Row id={id} {...otherProps}>
67+
<Row id={id} {...otherProps} columns={columns}>
6868
{allowsDragging && (
6969
<Cell>
7070
<Button slot="drag"></Button>
@@ -125,6 +125,31 @@ let TestTable = ({tableProps, tableHeaderProps, columnProps, tableBodyProps, row
125125
</Table>
126126
);
127127

128+
let EditableTable = ({tableProps, tableHeaderProps, columnProps, tableBodyProps, rowProps, cellProps}) => (
129+
<Table aria-label="Files" {...tableProps}>
130+
<MyTableHeader {...tableHeaderProps}>
131+
<MyColumn id="name" isRowHeader {...columnProps}>Name</MyColumn>
132+
<MyColumn {...columnProps}>Type</MyColumn>
133+
<MyColumn {...columnProps}>Actions</MyColumn>
134+
</MyTableHeader>
135+
<TableBody {...tableBodyProps}>
136+
<MyRow id="1" textValue="Edit" {...rowProps}>
137+
<Cell {...cellProps}>Games</Cell>
138+
<Cell {...cellProps}>File folder</Cell>
139+
<Cell {...cellProps}>
140+
<TagGroup aria-label="Tag group">
141+
<TagList>
142+
<Tag id="1">Tag 1</Tag>
143+
<Tag id="2">Tag 2</Tag>
144+
<Tag id="3">Tag 3</Tag>
145+
</TagList>
146+
</TagGroup>
147+
</Cell>
148+
</MyRow>
149+
</TableBody>
150+
</Table>
151+
);
152+
128153
let DraggableTable = (props) => {
129154
let {dragAndDropHooks} = useDragAndDrop({
130155
getItems: (keys) => [...keys].map((key) => ({'text/plain': key})),
@@ -890,6 +915,21 @@ describe('Table', () => {
890915
expect(rows.map(r => r.textContent)).toEqual(['FooBar', 'Foo 7Bar 7', 'Foo 8Bar 8', 'Foo 9Bar 9', 'Foo 10Bar 10', 'Foo 11Bar 11', 'Foo 12Bar 12', 'Foo 13Bar 13', 'Foo 49Bar 49']);
891916
});
892917

918+
it('should support nested collections with colliding keys', async () => {
919+
let {container} = render(<EditableTable />);
920+
921+
let itemMap = new Map();
922+
let items = container.querySelectorAll('[data-key]');
923+
924+
for (let item of items) {
925+
if (item instanceof HTMLElement) {
926+
let key = item.dataset.collection + ':' + item.dataset.key;
927+
expect(itemMap.has(key)).toBe(false);
928+
itemMap.set(key, item);
929+
}
930+
}
931+
});
932+
893933
describe('colSpan', () => {
894934
it('should render table with colSpans', () => {
895935
let {getAllByRole} = render(<TableCellColSpan />);

0 commit comments

Comments
 (0)