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

Open
wants to merge 17 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 7 commits
Commits
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
14 changes: 10 additions & 4 deletions packages/@react-aria/collections/src/useCachedChildren.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
* governing permissions and limitations under the License.
*/

import {cloneElement, ReactElement, ReactNode, useMemo} from 'react';
import {Children, cloneElement, ReactElement, ReactNode, useMemo} from 'react';
import {Key} from '@react-types/shared';

export interface CachedChildrenOptions<T> {
Expand Down Expand Up @@ -45,11 +45,11 @@ export function useCachedChildren<T extends object>(props: CachedChildrenOptions
rendered = children(item);
// @ts-ignore
let key = rendered.props.id ?? item.key ?? item.id;

if (key == null) {
throw new Error('Could not determine key for item');
}

if (idScope) {
key = idScope + ':' + key;
}
Expand All @@ -64,7 +64,13 @@ export function useCachedChildren<T extends object>(props: CachedChildrenOptions
}
return res;
} else if (typeof children !== 'function') {
return children;
return Children.map(children, (child: any, index) => {
if (!child || !idScope) {return child;}

let key = `${idScope}:${child.props.id ?? index + 1}`;

return cloneElement(child, addIdAndValue ? {key, id: key} : {key});
});
}
}, [children, items, cache, idScope, addIdAndValue]);
}
9 changes: 8 additions & 1 deletion packages/@react-aria/gridlist/src/useGridList.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,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,
nwidynski marked this conversation as resolved.
Show resolved Hide resolved
/**
* An optional keyboard delegate implementation for type to select,
* to override the default.
Expand Down Expand Up @@ -95,6 +100,7 @@ export function useGridList<T>(props: AriaGridListOptions<T>, state: ListState<T
keyboardDelegate,
layoutDelegate,
onAction,
disallowTypeAhead,
linkBehavior = 'action',
keyboardNavigationBehavior = 'arrow'
} = props;
Expand All @@ -113,7 +119,8 @@ export function useGridList<T>(props: AriaGridListOptions<T>, state: ListState<T
isVirtualized,
selectOnFocus: state.selectionManager.selectionBehavior === 'replace',
shouldFocusWrap: props.shouldFocusWrap,
linkBehavior
linkBehavior,
disallowTypeAhead
});

let id = useId(props.id);
Expand Down
4 changes: 2 additions & 2 deletions packages/@react-aria/gridlist/src/useGridListItem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -271,8 +271,8 @@ export function useGridListItem<T>(props: AriaGridListItemOptions, state: ListSt
onFocus,
// 'aria-label': [(node.textValue || undefined), rowAnnouncement].filter(Boolean).join(', '),
'aria-label': node.textValue || undefined,
'aria-selected': state.selectionManager.canSelectItem(node.key) ? state.selectionManager.isSelected(node.key) : undefined,
'aria-disabled': state.selectionManager.isDisabled(node.key) || undefined,
'aria-selected': itemStates.allowsSelection ? itemStates.isSelected : undefined,
'aria-disabled': itemStates.isDisabled || undefined,
'aria-labelledby': descriptionId && node.textValue ? `${getRowId(state, node.key)} ${descriptionId}` : undefined,
id: getRowId(state, node.key)
});
Expand Down
40 changes: 33 additions & 7 deletions packages/react-aria-components/src/GridList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@ import {ContextValue, DEFAULT_SLOT, Provider, RenderProps, ScrollableProps, Slot
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, useObjectRef} from '@react-aria/utils';
import {forwardRefType, HoverEvents, Key, LinkDOMProps, RefObject} from '@react-types/shared';
import {filterDOMProps, useId, useObjectRef} from '@react-aria/utils';
import {forwardRefType, HoverEvents, Key, LinkDOMProps, RefObject, Selection} from '@react-types/shared';
import {ListStateContext} from './ListBox';
import React, {createContext, ForwardedRef, forwardRef, HTMLAttributes, JSX, ReactNode, useContext, useEffect, useMemo, useRef} from 'react';
import {TextContext} from './Text';
Expand Down 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 All @@ -81,8 +86,10 @@ export const GridList = /*#__PURE__*/ (forwardRef as forwardRefType)(function Gr
// Render the portal first so that we have the collection by the time we render the DOM in SSR.
[props, ref] = useContextProps(props, ref, GridListContext);

props.id = useId(props.id);

return (
<CollectionBuilder content={<Collection {...props} />}>
<CollectionBuilder content={<Collection {...props} idScope={props.id} />}>
{collection => <GridListInner props={props} collection={collection} gridListRef={ref} />}
</CollectionBuilder>
);
Expand All @@ -97,38 +104,57 @@ interface GridListInnerProps<T extends object> {
function GridListInner<T extends object>({props, collection, gridListRef: ref}: GridListInnerProps<T>) {
let {dragAndDropHooks, keyboardNavigationBehavior = 'arrow', layout = 'stack'} = props;
let {CollectionRoot, isVirtualized, layoutDelegate, dropTargetDelegate: ctxDropTargetDelegate} = useContext(CollectionRendererContext);

// Unscope the node keys for convenient access.
let disabledKeys = useMemo(() => !props.disabledKeys ? props.disabledKeys : [...props.disabledKeys].map(key => `${props.id}:${key}`), [props.disabledKeys, props.id]);
let selectedKeys = useMemo(() => !props.selectedKeys || props.selectedKeys === 'all' ? props.selectedKeys : [...props.selectedKeys].map(key => `${props.id}:${key}`), [props.selectedKeys, props.id]);
let defaultSelectedKeys = useMemo(() => !props.defaultSelectedKeys || props.defaultSelectedKeys === 'all' ? props.defaultSelectedKeys : [...props.defaultSelectedKeys].map(key => `${props.id}:${key}`), [props.defaultSelectedKeys, props.id]);
let onSelectionChange = useMemo(() => !props.onSelectionChange ? props.onSelectionChange : (keys: Selection) => {
props.onSelectionChange?.(new Set([...keys].map(key => typeof key === 'string' ? key.replace(`${props.id}:`, '') : key)));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [props.onSelectionChange, props.id]);
let onAction = useMemo(() => !props.onAction ? props.onAction : (key: Key) => {
props.onAction?.(typeof key === 'string' ? key.replace(`${props.id}:`, '') : key);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [props.onAction, props.id]);
nwidynski marked this conversation as resolved.
Show resolved Hide resolved

let state = useListState({
...props,
collection,
selectedKeys,
disabledKeys,
onSelectionChange,
defaultSelectedKeys,
children: undefined,
layoutDelegate
});

let selectionManager = state.selectionManager;
let collator = useCollator({usage: 'search', sensitivity: 'base'});
let {disabledBehavior, disabledKeys} = state.selectionManager;
let {disabledBehavior} = selectionManager;
let {direction} = useLocale();
let keyboardDelegate = useMemo(() => (
new ListKeyboardDelegate({
collection,
collator,
ref,
disabledKeys,
disabledKeys: selectionManager.disabledKeys,
disabledBehavior,
layoutDelegate,
layout,
direction
})
), [collection, ref, layout, disabledKeys, disabledBehavior, layoutDelegate, collator, direction]);
), [collection, ref, layout, selectionManager.disabledKeys, disabledBehavior, layoutDelegate, collator, direction]);

let {gridProps} = useGridList({
...props,
onAction,
keyboardDelegate,
// Only tab navigation is supported in grid layout.
keyboardNavigationBehavior: layout === 'grid' ? 'tab' : keyboardNavigationBehavior,
isVirtualized
}, state, ref);

let selectionManager = state.selectionManager;
let isListDraggable = !!dragAndDropHooks?.useDraggableCollectionState;
let isListDroppable = !!dragAndDropHooks?.useDroppableCollectionState;
let dragHooksProvided = useRef(isListDraggable);
Expand Down
4 changes: 3 additions & 1 deletion packages/react-aria-components/src/Table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1137,9 +1137,11 @@ export const Row = /*#__PURE__*/ createBranchComponent(
throw new Error('No id detected for the Row element. The Row element requires a id to be provided to it when the cells are rendered dynamically.');
}

let scope = props.columns ? props.id : undefined;
let dependencies = [props.value].concat(props.dependencies);

return (
<Collection dependencies={dependencies} items={props.columns} idScope={props.id}>
<Collection dependencies={dependencies} items={props.columns} idScope={scope}>
{props.children}
</Collection>
);
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 @@ -180,6 +180,7 @@ export function TagGroupInsideGridList() {
<GridList
className={styles.menu}
aria-label="Grid list with tag group"
keyboardNavigationBehavior="tab"
style={{
width: 300,
height: 300
Expand All @@ -194,8 +195,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>
);
}
2 changes: 1 addition & 1 deletion packages/react-aria-components/test/Table.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,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