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: Add support for grid edit mode w/ nested interactive widgets #7277

Open
wants to merge 21 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
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
8 changes: 7 additions & 1 deletion packages/@react-aria/collections/src/CollectionBuilder.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import {BaseCollection} from './BaseCollection';
import {BaseNode, Document, ElementNode} from './Document';
import {CachedChildrenOptions, useCachedChildren} from './useCachedChildren';
import {createPortal} from 'react-dom';
import {forwardRefType, Node} from '@react-types/shared';
import {forwardRefType, Key, Node} from '@react-types/shared';
import {Hidden} from './Hidden';
import React, {createContext, ForwardedRef, forwardRef, JSX, ReactElement, ReactNode, useCallback, useContext, useMemo, useRef, useState} from 'react';
import {useIsSSR} from '@react-aria/ssr';
Expand All @@ -25,6 +25,7 @@ const ShallowRenderContext = createContext(false);
const CollectionDocumentContext = createContext<Document<any, BaseCollection<any>> | null>(null);

export interface CollectionBuilderProps<C extends BaseCollection<object>> {
id?: Key,
content: ReactNode,
children: (collection: C) => ReactNode,
createCollection?: () => C
Expand All @@ -51,6 +52,11 @@ export function CollectionBuilder<C extends BaseCollection<object>>(props: Colle
// This is fine. CollectionDocumentContext never changes after mounting.
// eslint-disable-next-line react-hooks/rules-of-hooks
let {collection, document} = useCollectionDocument(props.createCollection);

if (props.id) {
document.key = props.id;
}
nwidynski marked this conversation as resolved.
Show resolved Hide resolved

return (
<>
<Hidden>
Expand Down
5 changes: 3 additions & 2 deletions packages/@react-aria/collections/src/Document.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@

import {BaseCollection, CollectionNode, Mutable} from './BaseCollection';
import {ForwardedRef, ReactElement} from 'react';
import {Node} from '@react-types/shared';
import {Key, Node} from '@react-types/shared';

// This Collection implementation is perhaps a little unusual. It works by rendering the React tree into a
// Portal to a fake DOM implementation. This gives us efficient access to the tree of rendered objects, and
Expand Down Expand Up @@ -226,7 +226,7 @@ export class ElementNode<T> extends BaseNode<T> {

constructor(type: string, ownerDocument: Document<T, any>) {
super(ownerDocument);
this.node = new CollectionNode(type, `react-aria-${++ownerDocument.nodeId}`);
this.node = new CollectionNode(type, `${ownerDocument.key}-${++ownerDocument.nodeId}`);
// Start a transaction so that no updates are emitted from the collection
// until the props for this node are set. We don't know the real id for the
// node until then, so we need to avoid emitting collections in an inconsistent state.
Expand Down Expand Up @@ -304,6 +304,7 @@ export class ElementNode<T> extends BaseNode<T> {
* which is lazily copied on write during updates.
*/
export class Document<T, C extends BaseCollection<T> = BaseCollection<T>> extends BaseNode<T> {
key: Key = 'react-aria';
nodeType = 11; // DOCUMENT_FRAGMENT_NODE
ownerDocument = this;
dirtyNodes: Set<BaseNode<T>> = new Set();
Expand Down
51 changes: 45 additions & 6 deletions packages/@react-aria/gridlist/src/useGridListItem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import {chain, getScrollParent, mergeProps, scrollIntoViewport, useSlotId, useSy
import {DOMAttributes, FocusableElement, RefObject, Node as RSNode} from '@react-types/shared';
import {focusSafely, getFocusableTreeWalker} from '@react-aria/focus';
import {getLastItem} from '@react-stately/collections';
import {getRowId, listMap} from './utils';
import {getRowId, listMap, normalizeKey} from './utils';
import {HTMLAttributes, KeyboardEvent as ReactKeyboardEvent, useRef} from 'react';
import {isFocusVisible} from '@react-aria/interactions';
import type {ListState} from '@react-stately/list';
Expand Down Expand Up @@ -51,6 +51,8 @@ const EXPANSION_KEYS = {
}
};

const NAVIGATION_KEYS = new Set(['Home', 'End', 'PageUp', 'PageDown', 'ArrowRight', 'ArrowLeft', 'ArrowUp', 'ArrowDown']);

/**
* Provides the behavior and accessibility implementation for a row in a grid list.
* @param props - Props for the row.
Expand All @@ -67,7 +69,7 @@ export function useGridListItem<T>(props: AriaGridListItemOptions, state: ListSt

// let stringFormatter = useLocalizedStringFormatter(intlMessages, '@react-aria/gridlist');
let {direction} = useLocale();
let {onAction, linkBehavior, keyboardNavigationBehavior} = listMap.get(state);
let {id, onAction, linkBehavior, keyboardNavigationBehavior} = listMap.get(state);
let descriptionId = useSlotId();

// We need to track the key of the item at the time it was last focused so that we force
Expand Down Expand Up @@ -136,6 +138,14 @@ export function useGridListItem<T>(props: AriaGridListItemOptions, state: ListSt
return;
}
}

let isFocused = ref.current === document.activeElement;
let isNavigation = NAVIGATION_KEYS.has(e.key) || (e.altKey && e.key === 'Tab');

if (keyboardNavigationBehavior === 'tab' && isNavigation && !isFocused) {
e.preventDefault();
e.stopPropagation();
}

switch (e.key) {
case 'ArrowLeft': {
Expand Down Expand Up @@ -203,14 +213,20 @@ export function useGridListItem<T>(props: AriaGridListItemOptions, state: ListSt
// Prevent this event from reaching row children, e.g. menu buttons. We want arrow keys to navigate
// to the row above/below instead. We need to re-dispatch the event from a higher parent so it still
// bubbles and gets handled by useSelectableCollection.
if (!e.altKey && ref.current.contains(e.target as Element)) {
if (keyboardNavigationBehavior === 'arrow' && !e.altKey && ref.current.contains(e.target as Element)) {
e.stopPropagation();
e.preventDefault();
ref.current.parentElement.dispatchEvent(
new KeyboardEvent(e.nativeEvent.type, e.nativeEvent)
);
}
break;
case 'Escape': {
if (keyboardNavigationBehavior === 'tab') {
focusSafely(ref.current);
}
break;
}
case 'Tab': {
if (keyboardNavigationBehavior === 'tab') {
// If there is another focusable element within this item, stop propagation so the tab key
Expand Down Expand Up @@ -240,6 +256,26 @@ export function useGridListItem<T>(props: AriaGridListItemOptions, state: ListSt
}
return;
}

if (e.relatedTarget && keyboardNavigationBehavior === 'tab') {
let comparedPosition = ref.current.compareDocumentPosition(e.relatedTarget);

let isFocusWithin = Boolean(comparedPosition & Node.DOCUMENT_POSITION_CONTAINED_BY);
let isShiftTab = isFocusVisible() && Boolean(comparedPosition & Node.DOCUMENT_POSITION_FOLLOWING);
let isVirtualizedSibling = isVirtualized && e.relatedTarget.id.startsWith(id) && e.relatedTarget.role === 'row';
let isSibling = e.relatedTarget.parentElement === ref.current.parentElement || isVirtualizedSibling;
nwidynski marked this conversation as resolved.
Show resolved Hide resolved

if (isShiftTab && !isFocusWithin && !isSibling) {
let walker = getFocusableTreeWalker(ref.current);
walker.currentNode = ref.current;

let focusable = last(walker);

if (focusable) {
focusSafely(focusable);
}
}
}
};

let syntheticLinkProps = useSyntheticLinkProps(node.props);
Expand All @@ -254,16 +290,19 @@ export function useGridListItem<T>(props: AriaGridListItemOptions, state: ListSt
// });
// }

let rowId = isVirtualized ? getRowId(state, node.key) : normalizeKey(node.key);
nwidynski marked this conversation as resolved.
Show resolved Hide resolved

let rowProps: DOMAttributes = mergeProps(itemProps, linkProps, {
role: 'row',
onKeyDownCapture: onKeyDown,
onKeyDown: keyboardNavigationBehavior === 'tab' ? onKeyDown : undefined,
onKeyDownCapture: keyboardNavigationBehavior === 'tab' ? undefined : onKeyDown,
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-labelledby': descriptionId && node.textValue ? `${getRowId(state, node.key)} ${descriptionId}` : undefined,
id: getRowId(state, node.key)
'aria-labelledby': descriptionId && node.textValue ? `${rowId} ${descriptionId}` : undefined,
id: rowId
});

if (isVirtualized) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,23 +10,24 @@
* governing permissions and limitations under the License.
*/

import type {AriaGridListOptions} from './useGridList';
import {AriaGridSelectionCheckboxProps, GridSelectionCheckboxAria, useGridSelectionCheckbox} from '@react-aria/grid';
import {getRowId} from './utils';
import {getRowId, normalizeKey} from './utils';
import type {ListState} from '@react-stately/list';

/**
* Provides the behavior and accessibility implementation for a selection checkbox in a grid list.
* @param props - Props for the selection checkbox.
* @param state - State of the list, as returned by `useListState`.
*/
export function useGridListSelectionCheckbox<T>(props: AriaGridSelectionCheckboxProps, state: ListState<T>): GridSelectionCheckboxAria {
let {key} = props;
export function useGridListSelectionCheckbox<T>(props: AriaGridSelectionCheckboxProps & Pick<AriaGridListOptions<T>, 'isVirtualized'>, state: ListState<T>): GridSelectionCheckboxAria {
let {isVirtualized, key} = props;
nwidynski marked this conversation as resolved.
Show resolved Hide resolved
const {checkboxProps} = useGridSelectionCheckbox(props, state as any);

return {
checkboxProps: {
...checkboxProps,
'aria-labelledby': `${checkboxProps.id} ${getRowId(state, key)}`
'aria-labelledby': `${checkboxProps.id} ${isVirtualized ? getRowId(state, key) : normalizeKey(key)}`
}
};
}
2 changes: 1 addition & 1 deletion packages/@react-spectrum/list/src/ListViewItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ export function ListViewItem<T>(props: ListViewItemProps<T>) {
let isDroppable = isListDroppable && !isDisabled;
let {hoverProps, isHovered} = useHover({isDisabled: !allowsSelection && !hasAction});

let {checkboxProps} = useGridListSelectionCheckbox({key: item.key}, state);
let {checkboxProps} = useGridListSelectionCheckbox({key: item.key, isVirtualized: true}, state);
let hasDescription = useHasChild(`.${listStyles['react-spectrum-ListViewItem-description']}`, rowRef);

let draggableItem: DraggableItemResult;
Expand Down
8 changes: 5 additions & 3 deletions packages/react-aria-components/src/GridList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ 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 {filterDOMProps, useId, useObjectRef} from '@react-aria/utils';
import {forwardRefType, HoverEvents, Key, LinkDOMProps, RefObject} from '@react-types/shared';
import {ListStateContext} from './ListBox';
import React, {createContext, ForwardedRef, forwardRef, HTMLAttributes, JSX, ReactNode, useContext, useEffect, useMemo, useRef} from 'react';
Expand Down Expand Up @@ -77,8 +77,10 @@ function GridList<T extends object>(props: GridListProps<T>, ref: ForwardedRef<H
// 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 id={props.id} content={<Collection {...props} />}>
{collection => <GridListInner props={props} collection={collection} gridListRef={ref} />}
</CollectionBuilder>
);
Expand Down Expand Up @@ -295,7 +297,7 @@ export const GridListItem = /*#__PURE__*/ createLeafComponent('item', function G

let {isFocusVisible, focusProps} = useFocusRing();
let {checkboxProps} = useGridListSelectionCheckbox(
{key: item.key},
{key: item.key, isVirtualized},
state
);

Expand Down
6 changes: 4 additions & 2 deletions packages/react-aria-components/src/Menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import {MenuTriggerProps as BaseMenuTriggerProps, Collection as ICollection, Nod
import {Collection, CollectionBuilder, createBranchComponent, createLeafComponent} from '@react-aria/collections';
import {CollectionProps, CollectionRendererContext, ItemRenderProps, SectionContext, SectionProps, usePersistedKeys} from './Collection';
import {ContextValue, Provider, RenderProps, ScrollableProps, SlotProps, StyleProps, useContextProps, useRenderProps, useSlot, useSlottedContext} from './utils';
import {filterDOMProps, useObjectRef, useResizeObserver} from '@react-aria/utils';
import {filterDOMProps, useId, useObjectRef, useResizeObserver} from '@react-aria/utils';
import {forwardRefType, HoverEvents, Key, LinkDOMProps} from '@react-types/shared';
import {HeaderContext} from './Header';
import {KeyboardContext} from './Keyboard';
Expand Down Expand Up @@ -152,9 +152,11 @@ export interface MenuProps<T> extends Omit<AriaMenuProps<T>, 'children'>, Collec
function Menu<T extends object>(props: MenuProps<T>, ref: ForwardedRef<HTMLDivElement>) {
[props, ref] = useContextProps(props, ref, MenuContext);

props.id = useId(props.id);

// Delay rendering the actual menu until we have the collection so that auto focus works properly.
return (
<CollectionBuilder content={<Collection {...props} />}>
<CollectionBuilder id={props.id} content={<Collection {...props} />}>
{collection => collection.size > 0 && <MenuInner props={props} collection={collection} menuRef={ref} />}
</CollectionBuilder>
);
Expand Down
6 changes: 4 additions & 2 deletions packages/react-aria-components/src/Table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import {DisabledBehavior, DraggableCollectionState, DroppableCollectionState, Mu
import {DragAndDropContext, DropIndicatorContext, DropIndicatorProps, useDndPersistedKeys, useRenderDropIndicator} from './DragAndDrop';
import {DragAndDropHooks} from './useDragAndDrop';
import {DraggableItemResult, DragPreviewRenderer, DropIndicatorAria, DroppableCollectionResult, FocusScope, ListKeyboardDelegate, mergeProps, useFocusRing, useHover, useLocale, useLocalizedStringFormatter, useTable, useTableCell, useTableColumnHeader, useTableColumnResize, useTableHeaderRow, useTableRow, useTableRowGroup, useTableSelectAllCheckbox, useTableSelectionCheckbox, useVisuallyHidden} from 'react-aria';
import {filterDOMProps, isScrollable, mergeRefs, useLayoutEffect, useObjectRef, useResizeObserver} from '@react-aria/utils';
import {filterDOMProps, isScrollable, mergeRefs, useId, useLayoutEffect, useObjectRef, useResizeObserver} from '@react-aria/utils';
import {GridNode} from '@react-types/grid';
// @ts-ignore
import intlMessages from '../intl/*.json';
Expand Down Expand Up @@ -320,6 +320,8 @@ export interface TableProps extends Omit<SharedTableProps<any>, 'children'>, Sty
function Table(props: TableProps, ref: ForwardedRef<HTMLTableElement>) {
[props, ref] = useContextProps(props, ref, TableContext);

let id = useId();

// Separate selection state so we have access to it from collection components via useTableOptions.
let selectionState = useMultipleSelectionState(props);
let {selectionBehavior, selectionMode, disallowEmptySelection} = selectionState;
Expand All @@ -338,7 +340,7 @@ function Table(props: TableProps, ref: ForwardedRef<HTMLTableElement>) {
);

return (
<CollectionBuilder content={content} createCollection={() => new TableCollection<any>()}>
<CollectionBuilder id={id} content={content} createCollection={() => new TableCollection<any>()}>
{collection => <TableInner props={props} forwardedRef={ref} selectionState={selectionState} collection={collection} />}
</CollectionBuilder>
);
Expand Down
6 changes: 4 additions & 2 deletions packages/react-aria-components/src/Tree.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import {Collection, CollectionBuilder, CollectionNode, createBranchComponent, cr
import {CollectionProps, CollectionRendererContext, DefaultCollectionRenderer, ItemRenderProps, usePersistedKeys} from './Collection';
import {ContextValue, DEFAULT_SLOT, Provider, RenderProps, ScrollableProps, SlotProps, StyleRenderProps, useContextProps, useRenderProps} from './utils';
import {DisabledBehavior, Expandable, forwardRefType, HoverEvents, Key, LinkDOMProps, RefObject} from '@react-types/shared';
import {filterDOMProps, useObjectRef} from '@react-aria/utils';
import {filterDOMProps, useId, useObjectRef} from '@react-aria/utils';
import {FocusScope, mergeProps, useFocusRing, useGridListSelectionCheckbox, useHover} from 'react-aria';
import {Collection as ICollection, Node, SelectionBehavior, TreeState, useTreeState} from 'react-stately';
import React, {createContext, ForwardedRef, forwardRef, HTMLAttributes, ReactNode, useContext, useEffect, useMemo, useRef} from 'react';
Expand Down Expand Up @@ -140,8 +140,10 @@ function Tree<T extends object>(props: TreeProps<T>, ref: ForwardedRef<HTMLDivEl
// Render the portal first so that we have the collection by the time we render the DOM in SSR.
[props, ref] = useContextProps(props, ref, UNSTABLE_TreeContext);

props.id = useId(props.id);

return (
<CollectionBuilder content={<Collection {...props} />}>
<CollectionBuilder id={props.id} content={<Collection {...props} />}>
{collection => <TreeInner props={props} collection={collection} treeRef={ref} />}
</CollectionBuilder>
);
Expand Down
Loading