Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: nested collection support #7379

Merged
merged 23 commits into from
Feb 20, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
83ab27d
feat: nested collection support
nwidynski Nov 15, 2024
642d6ae
feat: add support for custom node keys
nwidynski Dec 13, 2024
9f24163
Merge branch 'main' into document_prefix
nwidynski Dec 13, 2024
10a3290
fix: key generation for static scenario
nwidynski Dec 13, 2024
ab327ca
fix: lint error
nwidynski Dec 13, 2024
e78c176
chore: remove key normalization
nwidynski Dec 13, 2024
4e3d68e
fix: static node key scopes & table tests
nwidynski Dec 14, 2024
8e9dfb8
fix: grid id mismatch
nwidynski Dec 18, 2024
060673c
fix: reordering in virtualized gridlist
nwidynski Dec 19, 2024
90acf6c
fix: drag and drop
nwidynski Jan 6, 2025
aab8a0c
feat: node key util & grid/table support
nwidynski Jan 7, 2025
c814b48
chore: upgrade pkg-lock and leverage combined scope in builder
nwidynski Jan 7, 2025
aad0e44
Merge branch 'main' into document_prefix
nwidynski Jan 7, 2025
466e4f0
fix: duplicate import
nwidynski Jan 7, 2025
aede6fc
chore: merge upstream
nwidynski Jan 23, 2025
7cbb3ad
fix: table rendering in docs
nwidynski Jan 23, 2025
f90758f
chore: fix build
nwidynski Jan 23, 2025
e04da95
Merge branch 'main' into document_prefix
nwidynski Feb 6, 2025
d25b5ca
Merge branch 'main' of github.com:adobe/react-spectrum into document_…
devongovett Feb 15, 2025
c0c33a8
contain changes to selection package
devongovett Feb 15, 2025
0f6a1f2
revert idScope change
devongovett Feb 20, 2025
22964a3
Merge branch 'main' of github.com:adobe/react-spectrum into document_…
devongovett Feb 20, 2025
2ce6172
Merge branch 'main' of github.com:adobe/react-spectrum into document_…
devongovett Feb 20, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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