Skip to content

Commit

Permalink
feat: nested collection support (#7379)
Browse files Browse the repository at this point in the history
* 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]>
  • Loading branch information
nwidynski and devongovett authored Feb 20, 2025
1 parent a7b8580 commit 4f47a68
Show file tree
Hide file tree
Showing 13 changed files with 142 additions and 22 deletions.
3 changes: 2 additions & 1 deletion packages/@react-aria/dnd/src/ListDropTargetDelegate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,8 @@ export class ListDropTargetDelegate implements DropTargetDelegate {
let isSecondaryRTL = this.layout === 'grid' && this.orientation === 'vertical' && this.direction === 'rtl';
let isFlowRTL = this.layout === 'stack' ? isPrimaryRTL : isSecondaryRTL;

let elements = this.ref.current.querySelectorAll('[data-key]');
let collection = this.ref.current?.dataset.collection;
let elements = this.ref.current.querySelectorAll(collection ? `[data-collection="${CSS.escape(collection)}"]` : '[data-key]');
let elementMap = new Map<string, HTMLElement>();
for (let item of elements) {
if (item instanceof HTMLElement && item.dataset.key != null) {
Expand Down
9 changes: 8 additions & 1 deletion packages/@react-aria/grid/src/useGrid.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@ import {useSelectableCollection} from '@react-aria/selection';
export interface GridProps extends DOMProps, AriaLabelingProps {
/** Whether the grid uses virtual scrolling. */
isVirtualized?: boolean,
/**
* Whether typeahead navigation is disabled.
* @default false
*/
disallowTypeAhead?: boolean,
/**
* An optional keyboard delegate implementation for type to select,
* to override the default.
Expand Down Expand Up @@ -66,6 +71,7 @@ export interface GridAria {
export function useGrid<T>(props: GridProps, state: GridState<T, GridCollection<T>>, ref: RefObject<HTMLElement | null>): GridAria {
let {
isVirtualized,
disallowTypeAhead,
keyboardDelegate,
focusMode,
scrollRef,
Expand Down Expand Up @@ -99,7 +105,8 @@ export function useGrid<T>(props: GridProps, state: GridState<T, GridCollection<
selectionManager: manager,
keyboardDelegate: delegate,
isVirtualized,
scrollRef
scrollRef,
disallowTypeAhead
});

let id = useId(props.id);
Expand Down
7 changes: 7 additions & 0 deletions packages/@react-aria/gridlist/src/useGridList.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,11 @@ export interface AriaGridListProps<T> extends GridListProps<T>, DOMProps, AriaLa
export interface AriaGridListOptions<T> extends Omit<AriaGridListProps<T>, 'children'> {
/** Whether the list uses virtual scrolling. */
isVirtualized?: boolean,
/**
* Whether typeahead navigation is disabled.
* @default false
*/
disallowTypeAhead?: boolean,
/**
* An optional keyboard delegate implementation for type to select,
* to override the default.
Expand Down Expand Up @@ -98,6 +103,7 @@ export function useGridList<T>(props: AriaGridListOptions<T>, state: ListState<T
keyboardDelegate,
layoutDelegate,
onAction,
disallowTypeAhead,
linkBehavior = 'action',
keyboardNavigationBehavior = 'arrow'
} = props;
Expand All @@ -117,6 +123,7 @@ export function useGridList<T>(props: AriaGridListOptions<T>, state: ListState<T
selectOnFocus: state.selectionManager.selectionBehavior === 'replace',
shouldFocusWrap: props.shouldFocusWrap,
linkBehavior,
disallowTypeAhead,
autoFocus: props.autoFocus
});

Expand Down
2 changes: 1 addition & 1 deletion packages/@react-aria/menu/src/useMenuItem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -308,7 +308,7 @@ export function useMenuItem<T>(props: AriaMenuItemProps, state: TreeState<T>, re
domProps,
linkProps,
isTrigger
? {onFocus: itemProps.onFocus, 'data-key': itemProps['data-key']}
? {onFocus: itemProps.onFocus, 'data-collection': itemProps['data-collection'], 'data-key': itemProps['data-key']}
: itemProps,
pressProps,
hoverProps,
Expand Down
3 changes: 2 additions & 1 deletion packages/@react-aria/selection/src/DOMLayoutDelegate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
* governing permissions and limitations under the License.
*/

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

export class DOMLayoutDelegate implements LayoutDelegate {
Expand All @@ -24,7 +25,7 @@ export class DOMLayoutDelegate implements LayoutDelegate {
if (!container) {
return null;
}
let item = key != null ? container.querySelector(`[data-key="${CSS.escape(key.toString())}"]`) : null;
let item = key != null ? getItemElement(this.ref, key) : null;
if (!item) {
return null;
}
Expand Down
21 changes: 11 additions & 10 deletions packages/@react-aria/selection/src/useSelectableCollection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import {flushSync} from 'react-dom';
import {FocusEvent, KeyboardEvent, useEffect, useRef} from 'react';
import {focusSafely, getInteractionModality} from '@react-aria/interactions';
import {getFocusableTreeWalker, moveVirtualFocus} from '@react-aria/focus';
import {isNonContiguousSelectionModifier} from './utils';
import {getItemElement, isNonContiguousSelectionModifier, useCollectionId} from './utils';
import {MultipleSelectionManager} from '@react-stately/selection';
import {useLocale} from '@react-aria/i18n';
import {useTypeSelect} from './useTypeSelect';
Expand Down Expand Up @@ -140,7 +140,7 @@ export function useSelectableCollection(options: AriaSelectableCollectionOptions
manager.setFocusedKey(key, childFocus);
});

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

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

let collectionId = useCollectionId(manager.collection);
return {
collectionProps: {
...handlers,
tabIndex
}
collectionProps: mergeProps(handlers, {
tabIndex,
'data-collection': collectionId
})
};
}
3 changes: 2 additions & 1 deletion packages/@react-aria/selection/src/useSelectableItem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@

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

itemProps['data-collection'] = getCollectionId(manager.collection);
itemProps['data-key'] = key;
itemPressProps.preventFocusOnPress = shouldUseVirtualFocus;

Expand Down
24 changes: 23 additions & 1 deletion packages/@react-aria/selection/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@
* governing permissions and limitations under the License.
*/

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

interface Event {
altKey: boolean,
Expand All @@ -23,3 +25,23 @@ export function isNonContiguousSelectionModifier(e: Event) {
// On Windows and Ubuntu, Alt + Space has a system wide meaning.
return isAppleDevice() ? e.altKey : e.ctrlKey;
}

export function getItemElement(collectionRef: RefObject<HTMLElement | null>, key: Key) {
let selector = `[data-key="${CSS.escape(String(key))}"]`;
let collection = collectionRef.current?.dataset.collection;
if (collection) {
selector = `[data-collection="${CSS.escape(collection)}"]${selector}`;
}
return collectionRef.current?.querySelector(selector);
}

const collectionMap = new WeakMap();
export function useCollectionId(collection: Collection<any>) {
let id = useId();
collectionMap.set(collection, id);
return id;
}

export function getCollectionId(collection: Collection<any>) {
return collectionMap.get(collection)!;
}
4 changes: 2 additions & 2 deletions packages/@react-spectrum/tag/src/TagGroup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -77,11 +77,11 @@ export const TagGroup = React.forwardRef(function TagGroup<T extends object>(pro
: new ListCollection([...state.collection])) as Collection<Node<T>>;
return new ListKeyboardDelegate({
collection,
ref: domRef,
ref: tagsRef,
direction,
orientation: 'horizontal'
});
}, [direction, isCollapsed, state.collection, tagState.visibleTagCount, domRef]) as ListKeyboardDelegate<T>;
}, [direction, isCollapsed, state.collection, tagState.visibleTagCount, tagsRef]) as ListKeyboardDelegate<T>;
// Remove onAction from props so it doesn't make it into useGridList.
delete props.onAction;
let {gridProps, labelProps, descriptionProps, errorMessageProps} = useTagGroup({...props, keyboardDelegate}, state, tagsRef);
Expand Down
5 changes: 5 additions & 0 deletions packages/react-aria-components/src/GridList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,11 @@ export interface GridListRenderProps {
}

export interface GridListProps<T> extends Omit<AriaGridListProps<T>, 'children'>, CollectionProps<T>, StyleRenderProps<GridListRenderProps>, SlotProps, ScrollableProps<HTMLDivElement> {
/**
* Whether typeahead navigation is disabled.
* @default false
*/
disallowTypeAhead?: boolean,
/** How multiple selection should behave in the collection. */
selectionBehavior?: SelectionBehavior,
/** The drag and drop hooks returned by `useDragAndDrop` used to enable drag and drop behavior for the GridList. */
Expand Down
16 changes: 14 additions & 2 deletions packages/react-aria-components/stories/GridList.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,7 @@ export function TagGroupInsideGridList() {
<GridList
className={styles.menu}
aria-label="Grid list with tag group"
keyboardNavigationBehavior="tab"
style={{
width: 300,
height: 300
Expand All @@ -189,8 +190,19 @@ export function TagGroupInsideGridList() {
</TagList>
</TagGroup>
</MyGridListItem>
<MyGridListItem>1,2 <Button>Actions</Button></MyGridListItem>
<MyGridListItem>1,3 <Button>Actions</Button></MyGridListItem>
<MyGridListItem>
1,2 <Button>Actions</Button>
</MyGridListItem>
<MyGridListItem>
1,3
<TagGroup aria-label="Tag group">
<TagList style={{display: 'flex', gap: 10}}>
<Tag key="1">Tag 1</Tag>
<Tag key="2">Tag 2</Tag>
<Tag key="3">Tag 3</Tag>
</TagList>
</TagGroup>
</MyGridListItem>
</GridList>
);
}
23 changes: 23 additions & 0 deletions packages/react-aria-components/test/GridList.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -495,6 +495,29 @@ describe('GridList', () => {
expect(checkbox).toBeInTheDocument();
});

it('should support nested collections with colliding keys', async () => {
let {container} = render(
<GridList aria-label="CardView" keyboardNavigationBehavior="Tab">
<GridListItem id="1" textValue="Card">
<GridList aria-label="Previews">
<GridListItem id="1">Paco de Lucia</GridListItem>
</GridList>
</GridListItem>
</GridList>
);

let itemMap = new Map();
let items = container.querySelectorAll('[data-key]');

for (let item of items) {
if (item instanceof HTMLElement) {
let key = item.dataset.collection + ':' + item.dataset.key;
expect(itemMap.has(key)).toBe(false);
itemMap.set(key, item);
}
}
});

describe('drag and drop', () => {
it('should support drag button slot', () => {
let {getAllByRole} = render(<DraggableGridList />);
Expand Down
44 changes: 42 additions & 2 deletions packages/react-aria-components/test/Table.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
*/

import {act, fireEvent, installPointerEvent, mockClickDefault, pointerMap, render, triggerLongPress, within} from '@react-spectrum/test-utils-internal';
import {Button, Cell, Checkbox, Collection, Column, ColumnResizer, Dialog, DialogTrigger, DropIndicator, Label, Modal, ResizableTableContainer, Row, Table, TableBody, TableHeader, TableLayout, useDragAndDrop, useTableOptions, Virtualizer} from '../';
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 '../';
import {composeStories} from '@storybook/react';
import {DataTransfer, DragEvent} from '@react-aria/dnd/test/mocks';
import React, {useMemo, useRef, useState} from 'react';
Expand Down Expand Up @@ -64,7 +64,7 @@ function MyRow({id, columns, children, ...otherProps}) {
let {selectionBehavior, allowsDragging} = useTableOptions();

return (
<Row id={id} {...otherProps}>
<Row id={id} {...otherProps} columns={columns}>
{allowsDragging && (
<Cell>
<Button slot="drag"></Button>
Expand Down Expand Up @@ -125,6 +125,31 @@ let TestTable = ({tableProps, tableHeaderProps, columnProps, tableBodyProps, row
</Table>
);

let EditableTable = ({tableProps, tableHeaderProps, columnProps, tableBodyProps, rowProps, cellProps}) => (
<Table aria-label="Files" {...tableProps}>
<MyTableHeader {...tableHeaderProps}>
<MyColumn id="name" isRowHeader {...columnProps}>Name</MyColumn>
<MyColumn {...columnProps}>Type</MyColumn>
<MyColumn {...columnProps}>Actions</MyColumn>
</MyTableHeader>
<TableBody {...tableBodyProps}>
<MyRow id="1" textValue="Edit" {...rowProps}>
<Cell {...cellProps}>Games</Cell>
<Cell {...cellProps}>File folder</Cell>
<Cell {...cellProps}>
<TagGroup aria-label="Tag group">
<TagList>
<Tag id="1">Tag 1</Tag>
<Tag id="2">Tag 2</Tag>
<Tag id="3">Tag 3</Tag>
</TagList>
</TagGroup>
</Cell>
</MyRow>
</TableBody>
</Table>
);

let DraggableTable = (props) => {
let {dragAndDropHooks} = useDragAndDrop({
getItems: (keys) => [...keys].map((key) => ({'text/plain': key})),
Expand Down Expand Up @@ -890,6 +915,21 @@ describe('Table', () => {
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']);
});

it('should support nested collections with colliding keys', async () => {
let {container} = render(<EditableTable />);

let itemMap = new Map();
let items = container.querySelectorAll('[data-key]');

for (let item of items) {
if (item instanceof HTMLElement) {
let key = item.dataset.collection + ':' + item.dataset.key;
expect(itemMap.has(key)).toBe(false);
itemMap.set(key, item);
}
}
});

describe('colSpan', () => {
it('should render table with colSpans', () => {
let {getAllByRole} = render(<TableCellColSpan />);
Expand Down

1 comment on commit 4f47a68

@rspbot
Copy link

@rspbot rspbot commented on 4f47a68 Feb 21, 2025

Choose a reason for hiding this comment

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

Please sign in to comment.