diff --git a/packages/@react-aria/collections/src/useCachedChildren.ts b/packages/@react-aria/collections/src/useCachedChildren.ts index 1ce6c9143be..770e8aaf289 100644 --- a/packages/@react-aria/collections/src/useCachedChildren.ts +++ b/packages/@react-aria/collections/src/useCachedChildren.ts @@ -45,11 +45,11 @@ export function useCachedChildren(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; } diff --git a/packages/@react-aria/dnd/src/DropTargetKeyboardNavigation.ts b/packages/@react-aria/dnd/src/DropTargetKeyboardNavigation.ts index fee624a4567..7c370aaa569 100644 --- a/packages/@react-aria/dnd/src/DropTargetKeyboardNavigation.ts +++ b/packages/@react-aria/dnd/src/DropTargetKeyboardNavigation.ts @@ -59,6 +59,10 @@ function nextDropTarget( nextKey = keyboardDelegate.getKeyBelow?.(target.key); } let nextCollectionKey = collection.getKeyAfter(target.key); + let nextCollectionNode = nextCollectionKey && collection.getItem(nextCollectionKey); + if (nextCollectionNode && nextCollectionNode.type === 'content') { + nextCollectionKey = nextCollectionKey ? collection.getKeyAfter(nextCollectionKey) : null; + } // If the keyboard delegate did not move to the next key in the collection, // jump to that key with the same drop position. Otherwise, try the other @@ -100,7 +104,7 @@ function nextDropTarget( } case 'after': { // If this is the last sibling in a level, traverse to the parent. - let targetNode = collection.getItem(target.key); + let targetNode = collection.getItem(target.key); if (targetNode && targetNode.nextKey == null && targetNode.parentKey != null) { // If the parent item has an item after it, use the "before" position. let parentNode = collection.getItem(targetNode.parentKey); diff --git a/packages/@react-aria/gridlist/package.json b/packages/@react-aria/gridlist/package.json index 1ded8da028d..8e03ba0af23 100644 --- a/packages/@react-aria/gridlist/package.json +++ b/packages/@react-aria/gridlist/package.json @@ -26,6 +26,7 @@ "url": "https://github.com/adobe/react-spectrum" }, "dependencies": { + "@react-aria/collections": "^3.0.0", "@react-aria/focus": "^3.21.2", "@react-aria/grid": "^3.14.5", "@react-aria/i18n": "^3.12.13", diff --git a/packages/@react-aria/gridlist/src/useGridListItem.ts b/packages/@react-aria/gridlist/src/useGridListItem.ts index 2ecfc4f8125..d8861560516 100644 --- a/packages/@react-aria/gridlist/src/useGridListItem.ts +++ b/packages/@react-aria/gridlist/src/useGridListItem.ts @@ -11,7 +11,8 @@ */ import {chain, getScrollParent, mergeProps, scrollIntoViewport, useSlotId, useSyntheticLinkProps} from '@react-aria/utils'; -import {DOMAttributes, FocusableElement, Key, RefObject, Node as RSNode} from '@react-types/shared'; +import {Collection, DOMAttributes, FocusableElement, Key, RefObject, Node as RSNode} from '@react-types/shared'; +import {CollectionNode} from '@react-aria/collections'; import {focusSafely, getFocusableTreeWalker} from '@react-aria/focus'; import {getRowId, listMap} from './utils'; import {HTMLAttributes, KeyboardEvent as ReactKeyboardEvent, useRef} from 'react'; @@ -100,11 +101,11 @@ export function useGridListItem(props: AriaGridListItemOptions, state: ListSt let isExpanded = hasChildRows ? state.expandedKeys.has(node.key) : undefined; let setSize = 1; - if (node.level > 0 && node?.parentKey != null) { + if (node.level >= 0 && node?.parentKey != null) { let parent = state.collection.getItem(node.parentKey); if (parent) { // siblings must exist because our original node exists - let siblings = state.collection.getChildren?.(parent.key)!; + let siblings = getDirectChildren(parent as CollectionNode, state.collection as Collection>); setSize = [...siblings].filter(row => row.type === 'item').length; } } else { @@ -324,3 +325,13 @@ function last(walker: TreeWalker) { } while (last); return next; } + +function getDirectChildren(parent: CollectionNode, collection: Collection>) { + let node = parent?.firstChildKey != null ? collection.getItem(parent.firstChildKey) : null; + let siblings: CollectionNode[] = []; + while (node) { + siblings.push(node); + node = node.nextKey != null ? collection.getItem(node.nextKey) : null; + } + return siblings; +} diff --git a/packages/@react-stately/data/src/useTreeData.ts b/packages/@react-stately/data/src/useTreeData.ts index 5baf6d8f3f9..2efd5082eec 100644 --- a/packages/@react-stately/data/src/useTreeData.ts +++ b/packages/@react-stately/data/src/useTreeData.ts @@ -504,7 +504,7 @@ function moveItems( // decrement the index if the child being removed is in the target parent and before the target index // the root node is special, it is null, and will not have a key, however, a parentKey can still point to it if ((child.parentKey === toParent - || child.parentKey === toParent?.key) + || child.parentKey === toParent?.key) && keyArray.includes(child.key) && (toParent?.children ? toParent.children.indexOf(child) : items.indexOf(child)) < originalToIndex) { toIndex--; diff --git a/packages/react-aria-components/src/GridList.tsx b/packages/react-aria-components/src/GridList.tsx index ec837fabffe..d435699d7a9 100644 --- a/packages/react-aria-components/src/GridList.tsx +++ b/packages/react-aria-components/src/GridList.tsx @@ -646,7 +646,7 @@ export const GridListSection = /*#__PURE__*/ createBranchComponent(SectionNode, }); export const GridListHeaderContext = createContext, HTMLDivElement>>({}); -const GridListHeaderInnerContext = createContext | null>(null); +export const GridListHeaderInnerContext = createContext | null>(null); export const GridListHeader = /*#__PURE__*/ createLeafComponent(HeaderNode, function Header(props: HTMLAttributes, ref: ForwardedRef) { [props, ref] = useContextProps(props, ref, GridListHeaderContext); diff --git a/packages/react-aria-components/src/Tree.tsx b/packages/react-aria-components/src/Tree.tsx index 6eeac03c73e..3570112d6e0 100644 --- a/packages/react-aria-components/src/Tree.tsx +++ b/packages/react-aria-components/src/Tree.tsx @@ -10,7 +10,7 @@ * governing permissions and limitations under the License. */ -import {AriaTreeItemOptions, AriaTreeProps, DraggableItemResult, DropIndicatorAria, DropIndicatorProps, DroppableCollectionResult, FocusScope, ListKeyboardDelegate, mergeProps, useCollator, useFocusRing, useGridListSelectionCheckbox, useHover, useId, useLocale, useTree, useTreeItem, useVisuallyHidden} from 'react-aria'; +import {AriaTreeItemOptions, AriaTreeProps, DraggableItemResult, DropIndicatorAria, DropIndicatorProps, DroppableCollectionResult, FocusScope, ListKeyboardDelegate, mergeProps, useCollator, useFocusRing, useGridListSection, useGridListSelectionCheckbox, useHover, useId, useLocale, useTree, useTreeItem, useVisuallyHidden} from 'react-aria'; import {ButtonContext} from './Button'; import {CheckboxContext} from './RSPContexts'; import { @@ -25,39 +25,107 @@ import { useContextProps, useRenderProps } from './utils'; -import {Collection, CollectionBuilder, CollectionNode, createBranchComponent, createLeafComponent, LoaderNode, useCachedChildren} from '@react-aria/collections'; -import {CollectionProps, CollectionRendererContext, DefaultCollectionRenderer, ItemRenderProps} from './Collection'; +import {Collection, CollectionBuilder, CollectionNode, createBranchComponent, createLeafComponent, LoaderNode, SectionNode, useCachedChildren} from '@react-aria/collections'; +import {CollectionProps, CollectionRendererContext, DefaultCollectionRenderer, ItemRenderProps, SectionProps} from './Collection'; import {DisabledBehavior, DragPreviewRenderer, Expandable, forwardRefType, GlobalDOMAttributes, HoverEvents, Key, LinkDOMProps, MultipleSelection, PressEvents, RefObject, SelectionMode} from '@react-types/shared'; import {DragAndDropContext, DropIndicatorContext, useDndPersistedKeys, useRenderDropIndicator} from './DragAndDrop'; import {DragAndDropHooks} from './useDragAndDrop'; import {DraggableCollectionState, DroppableCollectionState, Collection as ICollection, Node, SelectionBehavior, TreeState, useTreeState} from 'react-stately'; import {filterDOMProps, inertValue, LoadMoreSentinelProps, useLoadMoreSentinel, useObjectRef} from '@react-aria/utils'; -import React, {createContext, ForwardedRef, forwardRef, JSX, ReactNode, useContext, useEffect, useMemo, useRef, useState} from 'react'; +import {GridListHeader, GridListHeaderContext, GridListHeaderInnerContext} from './GridList'; +import React, {createContext, ForwardedRef, forwardRef, HTMLAttributes, JSX, ReactNode, useContext, useEffect, useMemo, useRef, useState} from 'react'; import {SelectionIndicatorContext} from './SelectionIndicator'; import {SharedElementTransition} from './SharedElementTransition'; import {TreeDropTargetDelegate} from './TreeDropTargetDelegate'; import {useControlledState} from '@react-stately/utils'; class TreeCollection implements ICollection> { - private flattenedRows: Node[]; private keyMap: Map> = new Map(); private itemCount: number = 0; + private firstKey; + private lastKey; + private expandedKeys; constructor(opts) { - let {collection, expandedKeys} = opts; - let {flattenedRows, keyMap, itemCount} = flattenTree(collection, {expandedKeys}); - this.flattenedRows = flattenedRows; + let {collection, lastExpandedKeys, expandedKeys} = opts; + let {keyMap, itemCount} = flattenTree(collection, {expandedKeys}); // Use generated keyMap because it contains the modified collection nodes (aka it adjusts the indexes so that they ignore the existence of the Content items) this.keyMap = keyMap; this.itemCount = itemCount; + this.firstKey = [...this.keyMap.keys()][0]; + this.lastKey = [...this.keyMap.keys()][this.keyMap.size - 1]; + this.expandedKeys = expandedKeys; + + // diff lastExpandedKeys and expandedKeys so we only clone what has changed + for (let key of expandedKeys) { + if (!lastExpandedKeys.has(key)) { + // traverse upward until you hit a section, and clone it + let currentKey = key; + while (currentKey != null) { + let item = this.getItem(currentKey) as CollectionNode; + if (item?.type === 'section') { + // replace the item with a clone + this.keyMap.set(currentKey, item.clone()); + break; + } else { + currentKey = item?.parentKey; + } + } + } + } + + for (let key of lastExpandedKeys) { + if (!expandedKeys.has(key)) { + let currentKey = key; + while (currentKey != null) { + let item = this.getItem(currentKey) as CollectionNode; + if (item?.type === 'section') { + // replace the item with a clone + this.keyMap.set(currentKey, item.clone()); + break; + } else { + currentKey = item?.parentKey; + } + } + } + } } - // TODO: should this collection's getters reflect the flattened structure or the original structure - // If we respresent the flattened structure, it is easier for the keyboard nav but harder to find all the nodes *[Symbol.iterator]() { - yield* this.flattenedRows; + function* traverseDepthFirst(node: CollectionNode | null, expandedKeys: Set) { + if (!node) { + return; + } + + // Always yield the current node first + yield node; + + // If node is expanded, traverse its children + if (expandedKeys.has(node.key) && node.firstChildKey) { + let firstChild = keyMap.get(node.firstChildKey); + // Skip content nodes + while (firstChild && firstChild.type === 'content') { + firstChild = firstChild && firstChild.nextKey ? keyMap.get(firstChild.nextKey) : undefined; + } + if (firstChild) { + yield* traverseDepthFirst(firstChild, expandedKeys); + } + } + + // Then traverse to next sibling + let nextNode = node && node.nextKey ? keyMap.get(node.nextKey) : null; + if (nextNode) { + yield* traverseDepthFirst(nextNode, expandedKeys); + } + } + + let keyMap = this.keyMap; + let expandedKeys = this.expandedKeys; + let node: Node | undefined = this.firstKey != null ? this.keyMap.get(this.firstKey) : undefined; + yield* traverseDepthFirst(node as CollectionNode, expandedKeys); } + get size() { return this.itemCount; } @@ -71,37 +139,179 @@ class TreeCollection implements ICollection> { } at(idx: number) { - return this.flattenedRows[idx]; + let keyMap = this.keyMap; + let expandedKeys = this.expandedKeys; + + function getKeyAfter(key: Key) { + let node = keyMap.get(key); + if (!node) { + return null; + } + + if ((expandedKeys.has(node.key) || node.type !== 'item') && node.firstChildKey != null) { + node = keyMap.get(node.firstChildKey); + while (node && node.type === 'content' && node.nextKey != null) { + node = keyMap.get(node.nextKey); + } + return node ? node.key : null; + } + + while (node) { + if (node.nextKey != null) { + return node.nextKey; + } + + if (node.parentKey != null) { + node = keyMap.get(node.parentKey); + } else { + return null; + } + } + + return null; + } + + let firstKey = this.getFirstKey(); + let node = firstKey ? keyMap.get(firstKey) : null; + for (let i = 0; i < idx; i++) { + if (node) { + let keyAfter = getKeyAfter(node.key); + node = keyAfter ? keyMap.get(keyAfter) : null; + } + } + return node as Node; } getFirstKey() { - return this.flattenedRows[0]?.key; + let node = this.keyMap.get(this.firstKey); + if (!node) { + return null; + } + + // Skip over any nodes that aren't an item node (e.g. section or header node) + while (node) { + if (node.type !== 'item' && node.firstChildKey) { + node = this.keyMap.get(node.firstChildKey); + } else { + break; + } + } + + return node ? node.key : null; } getLastKey() { - return this.flattenedRows[this.flattenedRows.length - 1]?.key; + let node = this.lastKey != null ? this.keyMap.get(this.lastKey) : null; + + if (!node) { + return null; + } + + // If the node's parent is expanded, then we can assume that this is the actual last key + if (node.parentKey && this.expandedKeys.has(node.parentKey)) { + return node.key; + } + + // If the node's parent is not expanded, find the top-most non-expanded node since it's possible for them to be nested + let parentNode = node.parentKey ? this.keyMap.get(node.parentKey) : null; + while (parentNode && parentNode.type !== 'section' && node && node.parentKey && !this.expandedKeys.has(parentNode.key)) { + node = this.keyMap.get(node.parentKey); + parentNode = node && node.parentKey ? this.keyMap.get(node.parentKey) : null; + } + + return node?.key ?? null; } getKeyAfter(key: Key) { - let index = this.flattenedRows.findIndex(row => row.key === key); - return this.flattenedRows[index + 1]?.key; + let node = this.keyMap.get(key); + if (!node) { + return null; + } + + if ((this.expandedKeys.has(node.key) || node.type !== 'item') && node.firstChildKey != null) { + return node.firstChildKey; + } + + while (node) { + if (node.nextKey != null) { + return node.nextKey; + } + + if (node.parentKey != null) { + node = this.keyMap.get(node.parentKey); + } else { + return null; + } + } + + return null; } getKeyBefore(key: Key) { - let index = this.flattenedRows.findIndex(row => row.key === key); - return this.flattenedRows[index - 1]?.key; + let node = this.keyMap.get(key); + if (!node) { + return null; + } + + if (node.prevKey != null) { + node = this.keyMap.get(node.prevKey); + + while (node && node.type !== 'item' && node.lastChildKey != null) { + node = this.keyMap.get(node.lastChildKey); + } + + // If the lastChildKey is expanded, check its lastChildKey + while (node && this.expandedKeys.has(node.key) && node.lastChildKey != null) { + node = this.keyMap.get(node.lastChildKey); + } + + return node?.key ?? null; + } + + return node.parentKey; } - // Note that this will return Content nodes in addition to nested TreeItems getChildren(key: Key): Iterable> { let keyMap = this.keyMap; + let expandedKeys = this.expandedKeys; return { *[Symbol.iterator]() { + function* traverseDepthFirst(node: CollectionNode | null, expandedKeys: Set) { + if (!node) { + return; + } + + // Always yield the current node first + yield node; + + // If node is expanded, traverse its children + if (expandedKeys.has(node.key) && node.firstChildKey) { + let firstChild = keyMap.get(node.firstChildKey); + // Skip content nodes + while (firstChild && firstChild.type === 'content') { + firstChild = firstChild && firstChild.nextKey ? keyMap.get(firstChild.nextKey) : undefined; + } + if (firstChild) { + yield* traverseDepthFirst(firstChild, expandedKeys); + } + } + + // Then traverse to next sibling + let nextNode = node && node.nextKey ? keyMap.get(node.nextKey) : null; + if (nextNode) { + yield* traverseDepthFirst(nextNode, expandedKeys); + } + } + let parent = keyMap.get(key); - let node = parent?.firstChildKey != null ? keyMap.get(parent.firstChildKey) : null; - while (node) { - yield node as Node; - node = node.nextKey != null ? keyMap.get(node.nextKey) : undefined; + let node = parent?.firstChildKey ? keyMap.get(parent.firstChildKey) : null; + if (parent && parent.type === 'section' && node) { + yield* traverseDepthFirst(node, expandedKeys); + } else { + while (node) { + yield node as Node; + node = node.nextKey != null ? keyMap.get(node.nextKey) : undefined; + } } } }; @@ -234,14 +444,22 @@ function TreeInner({props, collection, treeRef: ref}: TreeInne // Kinda annoying that we have to replicate this code here as well as in useTreeState, but don't want to add // flattenCollection stuff to useTreeState. Think about this later let [expandedKeys, setExpandedKeys] = useControlledState( - propExpandedKeys ? convertExpanded(propExpandedKeys) : undefined, - propDefaultExpandedKeys ? convertExpanded(propDefaultExpandedKeys) : new Set(), + propExpandedKeys ? new Set(propExpandedKeys) : undefined, + propDefaultExpandedKeys ? new Set(propDefaultExpandedKeys) : new Set(), onExpandedChange ); - let flattenedCollection = useMemo(() => { - return new TreeCollection({collection, expandedKeys}); - }, [collection, expandedKeys]); + let [lastCollection, setLastCollection] = useState(collection); + let [lastExpandedKeys, setLastExpandedKeys] = useState(expandedKeys); + let [flattenedCollection, setFlattenedCollection] = useState(() => new TreeCollection({collection, lastExpandedKeys: new Set(), expandedKeys})); + + + // if the lastExpandedKeys is not the same as the currentExpandedKeys or the collection has changed, then run this! + if (!areSetsEqual(lastExpandedKeys, expandedKeys) || collection !== lastCollection) { + setFlattenedCollection(new TreeCollection({collection, lastExpandedKeys, expandedKeys})); + setLastCollection(collection); + setLastExpandedKeys(expandedKeys); + } let state = useTreeState({ ...props, @@ -308,7 +526,7 @@ function TreeInner({props, collection, treeRef: ref}: TreeInne if (e.target.type === 'item') { let key = e.target.key; let item = state.collection.getItem(key); - let isExpanded = expandedKeys !== 'all' && expandedKeys.has(key); + let isExpanded = expandedKeys.has(key); if (item && item.hasChildNodes && (!isExpanded || dragAndDropHooks?.isVirtualDragging?.())) { state.toggleKey(key); } @@ -825,21 +1043,11 @@ export const TreeLoadMoreItem = createLeafComponent(LoaderNode, function TreeLoa ); }); -function convertExpanded(expanded: 'all' | Iterable): 'all' | Set { - if (!expanded) { - return new Set(); - } - - return expanded === 'all' - ? 'all' - : new Set(expanded); -} interface TreeGridCollectionOptions { expandedKeys: Set } interface FlattenedTree { - flattenedRows: Node[], keyMap: Map>, itemCount: number } @@ -849,12 +1057,11 @@ function flattenTree(collection: TreeCollection, opts: TreeGridCollectionO expandedKeys = new Set() } = opts; let keyMap: Map> = new Map(); - let flattenedRows: Node[] = []; // Need to count the items here because BaseCollection will return the full item count regardless if items are hidden via collapsed rows let itemCount = 0; let parentLookup: Map = new Map(); - let visitNode = (node: Node) => { + let visitNode = (node: Node, isInSection: boolean) => { if (node.type === 'item' || node.type === 'loader') { let parentKey = node?.parentKey; let clone = {...node}; @@ -866,6 +1073,12 @@ function flattenTree(collection: TreeCollection, opts: TreeGridCollectionO clone.index = node?.index != null ? node?.index - 1 : 0; } + if (isInSection) { + if (node.type === 'item') { + clone.level = node?.level != null ? node?.level - 1 : 0; + } + } + // For loader nodes that have a parent (aka non-root level loaders), these need their levels incremented by 1 for parity with their sibiling rows // (Collection only increments the level if it is a "item" type node). if (node.type === 'loader') { @@ -884,7 +1097,6 @@ function flattenTree(collection: TreeCollection, opts: TreeGridCollectionO itemCount++; } - flattenedRows.push(modifiedNode); parentLookup.set(modifiedNode.key, true); } } else if (node.type !== null) { @@ -892,16 +1104,15 @@ function flattenTree(collection: TreeCollection, opts: TreeGridCollectionO } for (let child of collection.getChildren(node.key)) { - visitNode(child); + visitNode(child, isInSection); } }; for (let node of collection) { - visitNode(node); + visitNode(node, node.type === 'section'); } return { - flattenedRows, keyMap, itemCount }; @@ -1000,3 +1211,64 @@ function RootDropIndicator() { ); } + +export interface GridListSectionProps extends SectionProps {} + +/** + * A TreeSection represents a section within a Tree. + */ +export const TreeSection = /*#__PURE__*/ createBranchComponent(SectionNode, (props: GridListSectionProps, ref: ForwardedRef, item: Node) => { + let state = useContext(TreeStateContext)!; + let {CollectionBranch} = useContext(CollectionRendererContext); + let headingRef = useRef(null); + ref = useObjectRef(ref); + let {rowHeaderProps, rowProps, rowGroupProps} = useGridListSection({ + 'aria-label': props['aria-label'] ?? undefined + }, state, ref); + let renderProps = useRenderProps({ + defaultClassName: 'react-aria-TreeSection', + className: props.className, + style: props.style, + values: {} + }); + + let DOMProps = filterDOMProps(props as any, {global: true}); + delete DOMProps.id; + + return ( +
+ + + +
+ ); +}); + +export const TreeHeader = (props: HTMLAttributes): ReactNode => { + return ( + + {props.children} + + ); +}; + +function areSetsEqual(a: Set, b: Set) { + if (a.size !== b.size) { + return false; + } + + for (let item of a) { + if (!b.has(item)) { + return false; + } + } + return true; +} diff --git a/packages/react-aria-components/src/index.ts b/packages/react-aria-components/src/index.ts index dd3d1db5a38..fd6e47ddc74 100644 --- a/packages/react-aria-components/src/index.ts +++ b/packages/react-aria-components/src/index.ts @@ -77,7 +77,7 @@ export {ToggleButton, ToggleButtonContext} from './ToggleButton'; export {ToggleButtonGroup, ToggleButtonGroupContext, ToggleGroupStateContext} from './ToggleButtonGroup'; export {Toolbar, ToolbarContext} from './Toolbar'; export {TooltipTrigger, Tooltip, TooltipTriggerStateContext, TooltipContext} from './Tooltip'; -export {TreeLoadMoreItem, Tree, TreeItem, TreeContext, TreeItemContent, TreeStateContext} from './Tree'; +export {TreeLoadMoreItem, Tree, TreeItem, TreeContext, TreeItemContent, TreeHeader, TreeSection, TreeStateContext} from './Tree'; export {useDrag, useDrop} from '@react-aria/dnd'; export {useDragAndDrop} from './useDragAndDrop'; export {DropIndicator, DropIndicatorContext, DragAndDropContext} from './DragAndDrop'; diff --git a/packages/react-aria-components/stories/Tree.stories.tsx b/packages/react-aria-components/stories/Tree.stories.tsx index f086ed99b07..fff4cc523c7 100644 --- a/packages/react-aria-components/stories/Tree.stories.tsx +++ b/packages/react-aria-components/stories/Tree.stories.tsx @@ -11,7 +11,7 @@ */ import {action} from '@storybook/addon-actions'; -import {Button, Checkbox, CheckboxProps, Collection, DroppableCollectionReorderEvent, isTextDropItem, Key, ListLayout, Menu, MenuTrigger, Popover, Text, Tree, TreeItem, TreeItemContent, TreeItemProps, TreeProps, useDragAndDrop, Virtualizer} from 'react-aria-components'; +import {Button, Checkbox, CheckboxProps, Collection, DroppableCollectionReorderEvent, isTextDropItem, Key, ListLayout, Menu, MenuTrigger, Popover, Text, Tree, TreeHeader, TreeItem, TreeItemContent, TreeItemProps, TreeProps, TreeSection, useDragAndDrop, Virtualizer} from 'react-aria-components'; import {classNames} from '@react-spectrum/utils'; import {Meta, StoryFn, StoryObj} from '@storybook/react'; import {MyMenuItem} from './utils'; @@ -268,6 +268,85 @@ export const TreeExampleStatic: StoryObj = { } }; +const TreeExampleSectionRender = (args) => ( + + classNames(styles, 'tree-item', { + focused: isFocused, + 'focus-visible': isFocusVisible, + selected: isSelected, + hovered: isHovered + })}> + + Reports + + + + Photo Header + Photos + Edited Photos + + + Project Header + + + + Projects-1A + + + + Projects-2 + + + Projects-3 + + + Project-4 + + classNames(styles, 'tree-item', { + focused: isFocused, + 'focus-visible': isFocusVisible, + selected: isSelected, + hovered: isHovered + })}> + + {({isFocused}) => ( + {`${isFocused} Tests`} + )} + + + +); + +export const TreeExampleSection = { + render: TreeExampleSectionRender, + args: { + selectionMode: 'none', + selectionBehavior: 'toggle', + disabledBehavior: 'selection', + disallowClearAll: false + }, + argTypes: { + selectionMode: { + control: 'radio', + options: ['none', 'single', 'multiple'] + }, + selectionBehavior: { + control: 'radio', + options: ['toggle', 'replace'] + }, + disabledBehavior: { + control: 'radio', + options: ['selection', 'all'] + } + } +}; + export const TreeExampleStaticNoActions: StoryObj = { render: (args) => , args: { @@ -326,6 +405,40 @@ let rows = [ ]} ]; +let rowsWithSections = [ + {id: 'section_1', name: 'Section 1', childItems: [ + {id: 'projects', name: 'Projects', childItems: [ + {id: 'project-1', name: 'Project 1'}, + {id: 'project-2', name: 'Project 2', childItems: [ + {id: 'project-2A', name: 'Project 2A'}, + {id: 'project-2B', name: 'Project 2B'}, + {id: 'project-2C', name: 'Project 2C'} + ]}, + {id: 'project-3', name: 'Project 3'}, + {id: 'project-4', name: 'Project 4'}, + {id: 'project-5', name: 'Project 5', childItems: [ + {id: 'project-5A', name: 'Project 5A'}, + {id: 'project-5B', name: 'Project 5B'}, + {id: 'project-5C', name: 'Project 5C'} + ]} + ]} + ]}, + {id: 'section_2', name: 'Section 2', childItems: [ + {id: 'reports', name: 'Reports', childItems: [ + {id: 'reports-1', name: 'Reports 1', childItems: [ + {id: 'reports-1A', name: 'Reports 1A', childItems: [ + {id: 'reports-1AB', name: 'Reports 1AB', childItems: [ + {id: 'reports-1ABC', name: 'Reports 1ABC'} + ]} + ]}, + {id: 'reports-1B', name: 'Reports 1B'}, + {id: 'reports-1C', name: 'Reports 1C'} + ]}, + {id: 'reports-2', name: 'Reports 2'} + ]} + ]} +]; + const MyTreeLoader = (props) => { let {omitChildren} = props; return ( @@ -442,12 +555,46 @@ const TreeExampleDynamicRender = (args: TreeProps): JSX.Ele ); }; +const TreeSectionExampleDynamicRender = (args: TreeProps): JSX.Element => { + let treeData = useTreeData({ + initialItems: args.items as any ?? rowsWithSections, + getKey: item => item.id, + getChildren: item => item.childItems + }); + + return ( + + + {section => ( + + {section.value.name} + + {item => + ( + {item.value.name} + ) + } + + + )} + + + ); +}; + + export const TreeExampleDynamic: StoryObj = { ...TreeExampleStatic, render: (args) => , parameters: undefined }; +export const TreeSectionDynamic: StoryObj = { + ...TreeExampleStatic, + render: (args) => , + parameters: undefined +}; + export const WithActions: StoryObj = { ...TreeExampleDynamic, args: { @@ -1198,6 +1345,86 @@ export const TreeWithDragAndDropVirtualized = { }; +const VirtualizedTreeExampleSectionRender = (args) => ( + + + + Photo Header + Photos + Edited Photos + + + Project Header + + + + Projects-1A + + + + Projects-2 + + + Projects-3 + + Project-4 + + + classNames(styles, 'tree-item', { + focused: isFocused, + 'focus-visible': isFocusVisible, + selected: isSelected, + hovered: isHovered + })}> + + Reports + + + classNames(styles, 'tree-item', { + focused: isFocused, + 'focus-visible': isFocusVisible, + selected: isSelected, + hovered: isHovered + })}> + + {({isFocused}) => ( + {`${isFocused} Tests`} + )} + + + + +); + +export const VirtualizedTreeSectionRender = { + render: VirtualizedTreeExampleSectionRender, + args: { + selectionMode: 'none', + selectionBehavior: 'toggle', + disabledBehavior: 'selection', + disallowClearAll: false + }, + argTypes: { + selectionMode: { + control: 'radio', + options: ['none', 'single', 'multiple'] + }, + selectionBehavior: { + control: 'radio', + options: ['toggle', 'replace'] + }, + disabledBehavior: { + control: 'radio', + options: ['selection', 'all'] + } + } +}; interface ITreeItem { id: string, name: string, diff --git a/packages/react-aria-components/test/Tree.test.tsx b/packages/react-aria-components/test/Tree.test.tsx index cd1c893fdfc..f0406e9b681 100644 --- a/packages/react-aria-components/test/Tree.test.tsx +++ b/packages/react-aria-components/test/Tree.test.tsx @@ -12,7 +12,7 @@ import {act, fireEvent, mockClickDefault, pointerMap, render, setupIntersectionObserverMock, within} from '@react-spectrum/test-utils-internal'; import {AriaTreeTests} from './AriaTree.test-util'; -import {Button, Checkbox, Collection, DropIndicator, ListLayout, Text, Tree, TreeItem, TreeItemContent, TreeLoadMoreItem, useDragAndDrop, Virtualizer} from '../'; +import {Button, Checkbox, Collection, DropIndicator, ListLayout, Text, Tree, TreeHeader, TreeItem, TreeItemContent, TreeLoadMoreItem, TreeSection, useDragAndDrop, Virtualizer} from '../'; import {composeStories} from '@storybook/react'; // @ts-ignore import {DataTransfer, DragEvent} from '@react-aria/dnd/test/mocks'; @@ -71,6 +71,30 @@ let StaticTree = ({treeProps = {}, rowProps = {}}) => ( ); +let StaticSectionTree = ({treeProps = {}, rowProps = {}}) => ( + + + Photos + + + Section 2 + + + + Projects-1A + + + + Projects-2 + + + Projects-3 + + + + +); + let rows = [ {id: 'projects', name: 'Projects', childItems: [ {id: 'project-1', name: 'Project 1'}, @@ -101,6 +125,40 @@ let rows = [ ]} ]; +let rowsWithSections = [ + {id: 'section_1', name: 'Section 1', childItems: [ + {id: 'projects', name: 'Projects', childItems: [ + {id: 'project-1', name: 'Project 1'}, + {id: 'project-2', name: 'Project 2', childItems: [ + {id: 'project-2A', name: 'Project 2A'}, + {id: 'project-2B', name: 'Project 2B'}, + {id: 'project-2C', name: 'Project 2C'} + ]}, + {id: 'project-3', name: 'Project 3'}, + {id: 'project-4', name: 'Project 4'}, + {id: 'project-5', name: 'Project 5', childItems: [ + {id: 'project-5A', name: 'Project 5A'}, + {id: 'project-5B', name: 'Project 5B'}, + {id: 'project-5C', name: 'Project 5C'} + ]} + ]} + ]}, + {id: 'section_2', name: 'Section 2', childItems: [ + {id: 'reports', name: 'Reports', childItems: [ + {id: 'reports-1', name: 'Reports 1', childItems: [ + {id: 'reports-1A', name: 'Reports 1A', childItems: [ + {id: 'reports-1AB', name: 'Reports 1AB', childItems: [ + {id: 'reports-1ABC', name: 'Reports 1ABC'} + ]} + ]}, + {id: 'reports-1B', name: 'Reports 1B'}, + {id: 'reports-1C', name: 'Reports 1C'} + ]}, + {id: 'reports-2', name: 'Reports 2'} + ]} + ]} +]; + let DynamicTreeItem = (props) => { return ( @@ -142,6 +200,25 @@ let DynamicTree = ({treeProps = {}, rowProps = {}}) => ( ); +let DynamicSectionTree = ({treeProps = {}, rowProps = {}}) => ( + + + {section => ( + + {section.name} + + {item => ( + + {item.name} + + )} + + + )} + + +); + let DraggableTree = (props) => { let {dragAndDropHooks} = useDragAndDrop({ getItems: (keys) => [...keys].map((key) => ({'text/plain': key})), @@ -1882,6 +1959,133 @@ describe('Tree', () => { expect(onClick).toHaveBeenCalledTimes(1); }); }); + + describe('sections', () => { + it('should support sections', () => { + let {getAllByRole} = render(); + + let groups = getAllByRole('rowgroup'); + expect(groups).toHaveLength(2); + + expect(groups[0]).toHaveClass('react-aria-TreeSection'); + expect(groups[1]).toHaveClass('react-aria-TreeSection'); + + expect(groups[0].getAttribute('aria-label')).toEqual('Section 1'); + + expect(groups[1]).toHaveAttribute('aria-labelledby'); + const labelId = groups[1].getAttribute('aria-labelledby'); + const labelElement = labelId ? document.getElementById(labelId) : null; + expect(labelElement).not.toBeNull(); + expect(labelElement).toHaveTextContent('Section 2'); + }); + }); + + it('should have the expected attributes on the rows in sections', () => { + let {getAllByRole} = render(); + + let rows = getAllByRole('row'); + let rowNoChild = rows[0]; + expect(rowNoChild).toHaveAttribute('aria-label', 'Photos'); + expect(rowNoChild).not.toHaveAttribute('aria-expanded'); + expect(rowNoChild).not.toHaveAttribute('data-expanded'); + expect(rowNoChild).toHaveAttribute('data-level', '1'); + expect(rowNoChild).not.toHaveAttribute('data-has-child-items'); + expect(rowNoChild).toHaveAttribute('data-rac'); + + let header = rows[1]; + expect(header).toHaveClass('react-aria-TreeHeader'); + expect(within(header).getByRole('rowheader')).toHaveTextContent('Section 2'); + + let rowWithChildren = rows[2]; + // Row has action since it is expandable but not selectable. + expect(rowWithChildren).toHaveAttribute('aria-label', 'Projects'); + expect(rowWithChildren).toHaveAttribute('data-expanded', 'true'); + expect(rowWithChildren).toHaveAttribute('data-level', '1'); + expect(rowWithChildren).toHaveAttribute('data-has-child-items', 'true'); + expect(rowWithChildren).toHaveAttribute('data-rac'); + + let level2ChildRow = rows[3]; + expect(level2ChildRow).toHaveAttribute('aria-label', 'Projects-1'); + expect(level2ChildRow).toHaveAttribute('data-expanded', 'true'); + expect(level2ChildRow).toHaveAttribute('data-level', '2'); + expect(level2ChildRow).toHaveAttribute('data-has-child-items', 'true'); + expect(level2ChildRow).toHaveAttribute('data-rac'); + + let level3ChildRow = rows[4]; + expect(level3ChildRow).toHaveAttribute('aria-label', 'Projects-1A'); + expect(level3ChildRow).not.toHaveAttribute('data-expanded'); + expect(level3ChildRow).toHaveAttribute('data-level', '3'); + expect(level3ChildRow).not.toHaveAttribute('data-has-child-items'); + expect(level3ChildRow).toHaveAttribute('data-rac'); + + let level2ChildRow2 = rows[5]; + expect(level2ChildRow2).toHaveAttribute('aria-label', 'Projects-2'); + expect(level2ChildRow2).not.toHaveAttribute('data-expanded'); + expect(level2ChildRow2).toHaveAttribute('data-level', '2'); + expect(level2ChildRow2).not.toHaveAttribute('data-has-child-items'); + expect(level2ChildRow2).toHaveAttribute('data-rac'); + + let level2ChildRow3 = rows[6]; + expect(level2ChildRow3).toHaveAttribute('aria-label', 'Projects-3'); + expect(level2ChildRow3).not.toHaveAttribute('data-expanded'); + expect(level2ChildRow3).toHaveAttribute('data-level', '2'); + expect(level2ChildRow3).not.toHaveAttribute('data-has-child-items'); + expect(level2ChildRow3).toHaveAttribute('data-rac'); + }); + + it('should support dynamic trees with sections', () => { + let {getByRole, getAllByRole} = render(); + let tree = getByRole('treegrid'); + expect(tree).toHaveAttribute('class', 'react-aria-Tree'); + + let rows = getAllByRole('row'); + expect(rows).toHaveLength(22); + + + let header = rows[0]; + expect(header).toHaveClass('react-aria-TreeHeader'); + expect(within(header).getByRole('rowheader')).toHaveTextContent('Section 1'); + + // Check the rough structure to make sure dynamic rows are rendering as expected (just checks the expandable rows and their attributes) + expect(rows[1]).toHaveAttribute('aria-label', 'Projects'); + expect(rows[1]).toHaveAttribute('aria-expanded', 'true'); + expect(rows[1]).toHaveAttribute('aria-level', '1'); + expect(rows[1]).toHaveAttribute('aria-posinset', '1'); // aria-posinset value is relative to their section + expect(rows[1]).toHaveAttribute('aria-setsize', '1'); // aria-setsize value is relative to their section + expect(rows[1]).toHaveAttribute('data-has-child-items', 'true'); + + expect(rows[3]).toHaveAttribute('aria-label', 'Project 2'); + expect(rows[3]).toHaveAttribute('aria-expanded', 'true'); + expect(rows[3]).toHaveAttribute('aria-level', '2'); + expect(rows[3]).toHaveAttribute('aria-posinset', '2'); + expect(rows[3]).toHaveAttribute('aria-setsize', '5'); + expect(rows[3]).toHaveAttribute('data-has-child-items', 'true'); + + expect(rows[9]).toHaveAttribute('aria-label', 'Project 5'); + expect(rows[9]).toHaveAttribute('aria-expanded', 'true'); + expect(rows[9]).toHaveAttribute('aria-level', '2'); + expect(rows[9]).toHaveAttribute('aria-posinset', '5'); + expect(rows[9]).toHaveAttribute('aria-setsize', '5'); + expect(rows[9]).toHaveAttribute('data-has-child-items', 'true'); + + header = rows[13]; + expect(header).toHaveClass('react-aria-TreeHeader'); + expect(within(header).getByRole('rowheader')).toHaveTextContent('Section 2'); + + expect(rows[14]).toHaveAttribute('aria-label', 'Reports'); + expect(rows[14]).toHaveAttribute('aria-expanded', 'true'); + expect(rows[14]).toHaveAttribute('aria-level', '1'); + expect(rows[14]).toHaveAttribute('aria-posinset', '1'); + expect(rows[14]).toHaveAttribute('aria-setsize', '1'); + expect(rows[14]).toHaveAttribute('data-has-child-items', 'true'); + + expect(rows[18]).toHaveAttribute('aria-label', 'Reports 1ABC'); + expect(rows[18]).toHaveAttribute('aria-level', '5'); + expect(rows[18]).toHaveAttribute('aria-posinset', '1'); + expect(rows[18]).toHaveAttribute('aria-setsize', '1'); + }); + + }); AriaTreeTests({ diff --git a/yarn.lock b/yarn.lock index 72cc56dbf8d..7bb383f5c5d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5759,6 +5759,7 @@ __metadata: version: 0.0.0-use.local resolution: "@react-aria/gridlist@workspace:packages/@react-aria/gridlist" dependencies: + "@react-aria/collections": "npm:^3.0.0" "@react-aria/focus": "npm:^3.21.2" "@react-aria/grid": "npm:^3.14.5" "@react-aria/i18n": "npm:^3.12.13"