From 375fa6e778733171808bb645c3ed2ba5324adac9 Mon Sep 17 00:00:00 2001 From: chirokas <157580465+chirokas@users.noreply.github.com> Date: Sat, 30 Aug 2025 12:42:08 +0000 Subject: [PATCH 1/4] feat(RAC): Automatic parent-child selection in Tree --- .../grid/src/useGridSelectionAnnouncement.ts | 4 +- .../grid/src/useGridSelectionCheckbox.ts | 2 + packages/@react-aria/menu/src/useMenuItem.ts | 4 +- .../@react-stately/grid/src/useGridState.ts | 4 +- .../@react-stately/list/src/useListState.ts | 6 +- .../selection/src/SelectionManager.ts | 42 +-- .../selection/src/TreeSelectionManager.ts | 266 ++++++++++++++++++ .../@react-stately/selection/src/index.ts | 4 +- .../@react-stately/selection/src/types.ts | 18 +- .../selection/src/useTreeSelectionState.ts | 32 +++ .../@react-stately/tree/src/useTreeState.ts | 14 +- packages/react-aria-components/src/Menu.tsx | 8 +- packages/react-aria-components/src/Tree.tsx | 7 +- .../stories/Tree.stories.tsx | 70 ++++- .../react-aria-components/test/Tree.test.tsx | 155 ++++++++++ 15 files changed, 595 insertions(+), 41 deletions(-) create mode 100644 packages/@react-stately/selection/src/TreeSelectionManager.ts create mode 100644 packages/@react-stately/selection/src/useTreeSelectionState.ts diff --git a/packages/@react-aria/grid/src/useGridSelectionAnnouncement.ts b/packages/@react-aria/grid/src/useGridSelectionAnnouncement.ts index c3d84564c32..bb9904e6fda 100644 --- a/packages/@react-aria/grid/src/useGridSelectionAnnouncement.ts +++ b/packages/@react-aria/grid/src/useGridSelectionAnnouncement.ts @@ -14,7 +14,7 @@ import {announce} from '@react-aria/live-announcer'; import {Collection, Key, Node, Selection} from '@react-types/shared'; // @ts-ignore import intlMessages from '../intl/*.json'; -import {SelectionManager} from '@react-stately/selection'; +import {MultipleSelectionManager} from '@react-stately/selection'; import {useEffectEvent, useUpdateEffect} from '@react-aria/utils'; import {useLocalizedStringFormatter} from '@react-aria/i18n'; import {useRef} from 'react'; @@ -33,7 +33,7 @@ interface GridSelectionState { /** A set of items that are disabled. */ disabledKeys: Set, /** A selection manager to read and update multiple selection state. */ - selectionManager: SelectionManager + selectionManager: MultipleSelectionManager } export function useGridSelectionAnnouncement(props: GridSelectionAnnouncementProps, state: GridSelectionState): void { diff --git a/packages/@react-aria/grid/src/useGridSelectionCheckbox.ts b/packages/@react-aria/grid/src/useGridSelectionCheckbox.ts index 7326146fb57..04f92c65ea6 100644 --- a/packages/@react-aria/grid/src/useGridSelectionCheckbox.ts +++ b/packages/@react-aria/grid/src/useGridSelectionCheckbox.ts @@ -30,6 +30,7 @@ export function useGridSelectionCheckbox>(props: let checkboxId = useId(); let isDisabled = !state.selectionManager.canSelectItem(key); let isSelected = state.selectionManager.isSelected(key); + let isIndeterminate = state.selectionManager.isIndeterminate?.(key); // Checkbox should always toggle selection, regardless of selectionBehavior. let onChange = () => manager.toggleSelection(key); @@ -42,6 +43,7 @@ export function useGridSelectionCheckbox>(props: 'aria-label': stringFormatter.format('select'), isSelected, isDisabled, + isIndeterminate, onChange } }; diff --git a/packages/@react-aria/menu/src/useMenuItem.ts b/packages/@react-aria/menu/src/useMenuItem.ts index 30440abd5d7..184277ea5a2 100644 --- a/packages/@react-aria/menu/src/useMenuItem.ts +++ b/packages/@react-aria/menu/src/useMenuItem.ts @@ -16,7 +16,7 @@ import {getItemCount} from '@react-stately/collections'; import {isFocusVisible, useFocus, useHover, useKeyboard, usePress} from '@react-aria/interactions'; import {menuData} from './utils'; import {MouseEvent, useRef} from 'react'; -import {SelectionManager} from '@react-stately/selection'; +import {MultipleSelectionManager} from '@react-stately/selection'; import {TreeState} from '@react-stately/tree'; import {useSelectableItem} from '@react-aria/selection'; @@ -95,7 +95,7 @@ export interface AriaMenuItemProps extends DOMProps, PressEvents, HoverEvents, K 'aria-controls'?: string, /** Override of the selection manager. By default, `state.selectionManager` is used. */ - selectionManager?: SelectionManager + selectionManager?: MultipleSelectionManager } /** diff --git a/packages/@react-stately/grid/src/useGridState.ts b/packages/@react-stately/grid/src/useGridState.ts index 818c23391cb..963803dd91b 100644 --- a/packages/@react-stately/grid/src/useGridState.ts +++ b/packages/@react-stately/grid/src/useGridState.ts @@ -1,7 +1,7 @@ import {getChildNodes, getFirstItem, getLastItem} from '@react-stately/collections'; import {GridCollection, GridNode} from '@react-types/grid'; import {Key} from '@react-types/shared'; -import {MultipleSelectionState, MultipleSelectionStateProps, SelectionManager, useMultipleSelectionState} from '@react-stately/selection'; +import {MultipleSelectionManager, MultipleSelectionState, MultipleSelectionStateProps, SelectionManager, useMultipleSelectionState} from '@react-stately/selection'; import {useEffect, useMemo, useRef} from 'react'; export interface GridState> { @@ -9,7 +9,7 @@ export interface GridState> { /** A set of keys for rows that are disabled. */ disabledKeys: Set, /** A selection manager to read and update row selection state. */ - selectionManager: SelectionManager, + selectionManager: MultipleSelectionManager, /** Whether keyboard navigation is disabled, such as when the arrow keys should be handled by a component within a cell. */ isKeyboardNavigationDisabled: boolean } diff --git a/packages/@react-stately/list/src/useListState.ts b/packages/@react-stately/list/src/useListState.ts index c373633426e..57dfd0dd6ad 100644 --- a/packages/@react-stately/list/src/useListState.ts +++ b/packages/@react-stately/list/src/useListState.ts @@ -12,7 +12,7 @@ import {Collection, CollectionStateBase, Key, LayoutDelegate, Node} from '@react-types/shared'; import {ListCollection} from './ListCollection'; -import {MultipleSelectionStateProps, SelectionManager, useMultipleSelectionState} from '@react-stately/selection'; +import {MultipleSelectionManager, MultipleSelectionStateProps, SelectionManager, useMultipleSelectionState} from '@react-stately/selection'; import {useCallback, useEffect, useMemo, useRef} from 'react'; import {useCollection} from '@react-stately/collections'; @@ -36,7 +36,7 @@ export interface ListState { disabledKeys: Set, /** A selection manager to read and update multiple selection state. */ - selectionManager: SelectionManager + selectionManager: MultipleSelectionManager } /** @@ -84,7 +84,7 @@ export function UNSTABLE_useFilteredListState(state: ListState }; } -function useFocusedKeyReset(collection: Collection>, selectionManager: SelectionManager) { +function useFocusedKeyReset(collection: Collection>, selectionManager: MultipleSelectionManager) { // Reset focused key if that item is deleted from the collection. const cachedCollection = useRef> | null>(null); useEffect(() => { diff --git a/packages/@react-stately/selection/src/SelectionManager.ts b/packages/@react-stately/selection/src/SelectionManager.ts index 0a0d9f8bd75..91071ce1bf0 100644 --- a/packages/@react-stately/selection/src/SelectionManager.ts +++ b/packages/@react-stately/selection/src/SelectionManager.ts @@ -36,7 +36,7 @@ interface SelectionManagerOptions { */ export class SelectionManager implements MultipleSelectionManager { collection: Collection>; - private state: MultipleSelectionState; + protected state: MultipleSelectionState; private allowsCellSelection: boolean; private _isSelectAll: boolean | null; private layoutDelegate: LayoutDelegate | null; @@ -49,6 +49,14 @@ export class SelectionManager implements MultipleSelectionManager { this.layoutDelegate = options?.layoutDelegate || null; } + protected get selection(): ISelection { + return this.state.selectedKeys; + } + + protected setSelection(selection: ISelection) { + this.state.setSelectedKeys(selection); + } + /** * The type of selection that is allowed in the collection. */ @@ -116,9 +124,9 @@ export class SelectionManager implements MultipleSelectionManager { * The currently selected keys in the collection. */ get selectedKeys(): Set { - return this.state.selectedKeys === 'all' + return this.selection === 'all' ? new Set(this.getSelectAllKeys()) - : this.state.selectedKeys; + : this.selection; } /** @@ -141,9 +149,9 @@ export class SelectionManager implements MultipleSelectionManager { if (mappedKey == null) { return false; } - return this.state.selectedKeys === 'all' + return this.selection === 'all' ? this.canSelectItem(mappedKey) - : this.state.selectedKeys.has(mappedKey); + : this.selection.has(mappedKey); } /** @@ -161,7 +169,7 @@ export class SelectionManager implements MultipleSelectionManager { return false; } - if (this.state.selectedKeys === 'all') { + if (this.selection === 'all') { return true; } @@ -170,14 +178,14 @@ export class SelectionManager implements MultipleSelectionManager { } let allKeys = this.getSelectAllKeys(); - let selectedKeys = this.state.selectedKeys; + let selectedKeys = this.selection; this._isSelectAll = allKeys.every(k => selectedKeys.has(k)); return this._isSelectAll; } get firstSelectedKey(): Key | null { let first: Node | null = null; - for (let key of this.state.selectedKeys) { + for (let key of this.selectedKeys) { let item = this.collection.getItem(key); if (!first || (item && compareNodeOrder(this.collection, item, first) < 0)) { first = item; @@ -189,7 +197,7 @@ export class SelectionManager implements MultipleSelectionManager { get lastSelectedKey(): Key | null { let last: Node | null = null; - for (let key of this.state.selectedKeys) { + for (let key of this.selectedKeys) { let item = this.collection.getItem(key); if (!last || (item && compareNodeOrder(this.collection, item, last) > 0)) { last = item; @@ -228,10 +236,10 @@ export class SelectionManager implements MultipleSelectionManager { let selection: Selection; // Only select the one key if coming from a select all. - if (this.state.selectedKeys === 'all') { + if (this.selection === 'all') { selection = new Selection([mappedToKey], mappedToKey, mappedToKey); } else { - let selectedKeys = this.state.selectedKeys as Selection; + let selectedKeys = this.selection as Selection; let anchorKey = selectedKeys.anchorKey ?? mappedToKey; selection = new Selection(selectedKeys, anchorKey, mappedToKey); for (let key of this.getKeyRange(anchorKey, selectedKeys.currentKey ?? mappedToKey)) { @@ -245,7 +253,7 @@ export class SelectionManager implements MultipleSelectionManager { } } - this.state.setSelectedKeys(selection); + this.setSelection(selection); } private getKeyRange(from: Key, to: Key) { @@ -285,7 +293,7 @@ export class SelectionManager implements MultipleSelectionManager { return []; } - private getKey(key: Key) { + protected getKey(key: Key) { let item = this.collection.getItem(key); if (!item) { // ¯\_(ツ)_/¯ @@ -327,7 +335,7 @@ export class SelectionManager implements MultipleSelectionManager { return; } - let keys = new Selection(this.state.selectedKeys === 'all' ? this.getSelectAllKeys() : this.state.selectedKeys); + let keys = new Selection(this.selection === 'all' ? this.getSelectAllKeys() : this.selection); if (keys.has(mappedKey)) { keys.delete(mappedKey); // TODO: move anchor to last selected key... @@ -342,7 +350,7 @@ export class SelectionManager implements MultipleSelectionManager { return; } - this.state.setSelectedKeys(keys); + this.setSelection(keys); } /** @@ -362,7 +370,7 @@ export class SelectionManager implements MultipleSelectionManager { ? new Selection([mappedKey], mappedKey, mappedKey) : new Selection(); - this.state.setSelectedKeys(selection); + this.setSelection(selection); } /** @@ -384,7 +392,7 @@ export class SelectionManager implements MultipleSelectionManager { } } - this.state.setSelectedKeys(selection); + this.setSelection(selection); } private getSelectAllKeys() { diff --git a/packages/@react-stately/selection/src/TreeSelectionManager.ts b/packages/@react-stately/selection/src/TreeSelectionManager.ts new file mode 100644 index 00000000000..2126c62bdde --- /dev/null +++ b/packages/@react-stately/selection/src/TreeSelectionManager.ts @@ -0,0 +1,266 @@ +/* + * Copyright 2025 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import {Collection, Selection as ISelection, Key, Node} from '@react-types/shared'; +import {getChildNodes} from '@react-stately/collections'; +import {Selection} from './Selection'; +import {SelectionManager} from './SelectionManager'; +import {TreeSelectionState} from './types'; + +export class TreeSelectionManager extends SelectionManager { + protected state: TreeSelectionState; + private _indeterminateKeys: Set | null; + private _selectedKeys: Set | null; + + constructor(collection: Collection>, state: TreeSelectionState) { + super(collection, state); + this.state = state; + this._indeterminateKeys = null; + this._selectedKeys = null; + } + + protected get selection() { + if (this.state.selectedKeys === 'all' || !this.isAutoTristate) { + return this.state.selectedKeys; + } + + if (this._selectedKeys != null) { + return this._selectedKeys; + } + this._selectedKeys = new Selection(this.state.selectedKeys); + this.applyAutoSelection(this.state.selectedKeys, this._selectedKeys); + return this._selectedKeys; + } + + protected setSelection(selection: ISelection) { + if (selection === 'all' || selection.size === 0) { + this.state.setSelectedKeys(selection); + return; + } + this.propagateSelection(selection, this.selectionBehavior === 'toggle' ? this.selectedKeys : undefined); + } + + private get indeterminateKeys() { + if (this._indeterminateKeys != null) { + return this._indeterminateKeys; + } + + let keys = (this._indeterminateKeys = new Set()); + if (this.selection !== 'all') { + this.selection.forEach(key => { + for (let parent of getAncestors(this.collection, key)) { + if (this.isSelected(parent.key)) { + continue; + } + + if (this.isPartiallyChecked(parent.key)) { + keys.add(parent.key); + } + } + }); + } + return keys; + } + + private get isAutoTristate() { + return this.state.selectionPropagation && this.state.selectionMode === 'multiple'; + } + + private get whatToShow() { + return this.isAutoTristate ? this.state.selectionStrategy : 'all'; + } + + forceUpdate(): void { + if (this.state.selectedKeys !== 'all' && this.state.selectedKeys.size > 0) { + let keys = this.filter(this.selectedKeys); + this.state.setSelectedKeys(keys); + } + } + + isIndeterminate(key: Key): boolean { + if (!this.isAutoTristate) { + return false; + } + + if (this.isSelected(key)) { + return false; + } + + let mappedKey = this.getKey(key); + if (mappedKey == null) { + return false; + } + return this.indeterminateKeys.has(mappedKey); + } + + private applyAutoSelection(selectedKeys: Set, result: Set) { + selectedKeys.forEach(key => { + for (let child of getDescendants(this.collection, key)) { + if (this.canSelectItem(child.key)) { + result.add(child.key); + } + } + }); + + selectedKeys.forEach(key => { + for (let parent of getAncestors(this.collection, key)) { + if (!this.canSelectItem(parent.key)) { + break; + } + + let hasUnselectedItem = [...getChildren(this.collection, parent.key)].some(child => !result.has(child.key)); + if (hasUnselectedItem) { + break; + } + result.add(parent.key); + } + }); + } + + private filter(selectedKeys: Set) { + if (this.whatToShow === 'all' || selectedKeys.size <= 1) { + return selectedKeys; + } + + let keys = selectedKeys instanceof Selection ? new Selection([], selectedKeys.anchorKey, selectedKeys.currentKey) : new Set(); + if (this.whatToShow === 'parent') { + for (let currentKey of selectedKeys) { + let isChild = false; + for (let parent of getAncestors(this.collection, currentKey)) { + if (selectedKeys.has(parent.key)) { + isChild = true; + break; + } + } + + if (!isChild) { + keys.add(currentKey); + } + } + } else { + for (let currentKey of selectedKeys) { + let isParent = false; + for (let child of getChildren(this.collection, currentKey)) { + if (selectedKeys.has(child.key)) { + isParent = true; + break; + } + } + + if (!isParent) { + keys.add(currentKey); + } + } + } + return keys; + } + + private isPartiallyChecked(key: Key) { + let queue: Node[] = []; + queue.push(...getChildren(this.collection, key)); + + let hasSelectedItem = false; + let hasUnselectedItem = false; + while (queue.length) { + let length = queue.length; + for (let i = 0; i < length; i++) { + let child = queue.shift()!; + if (this.selectedKeys.has(child.key)) { + hasSelectedItem = true; + } else { + hasUnselectedItem = true; + } + + if (hasSelectedItem && hasUnselectedItem) { + return true; + } + + if (hasUnselectedItem) { + queue.push(...getChildren(this.collection, child.key)); + } + } + } + return false; + } + + private propagateSelection(newSelectedKeys: Set, oldSelectedKeys: Set = new Set()): void { + let addedKeys = diffSelection(newSelectedKeys, oldSelectedKeys); + let removedKeys = diffSelection(oldSelectedKeys, newSelectedKeys); + if (addedKeys.size === 0 && removedKeys.size === 0) { + return; + } + + let selectedKeys = new Set(newSelectedKeys); + if (this.isAutoTristate) { + this.applyAutoSelection(addedKeys, selectedKeys); + + removedKeys.forEach(key => { + for (let child of getDescendants(this.collection, key)) { + selectedKeys.delete(child.key); + } + }); + + removedKeys.forEach(key => { + for (let parent of getAncestors(this.collection, key)) { + selectedKeys.delete(parent.key); + } + }); + } + + // @ts-ignore + let selection = new Selection(this.filter(selectedKeys), newSelectedKeys.anchorKey, newSelectedKeys.currentKey); + this.state.setSelectedKeys(selection); + } +} + +function* getAncestors(collection: Collection>, key: Key) { + let item = collection.getItem(key); + while (item?.parentKey != null) { + item = collection.getItem(item.parentKey); + if (item?.type === 'item') { + yield item; + } + } +} + +function* getChildren(collection: Collection>, key: Key) { + let item = collection.getItem(key); + if (item?.type === 'item') { + for (let child of getChildNodes(item, collection)) { + if (child?.type === 'item') { + yield child; + } + } + } +} + +function* getDescendants(collection: Collection>, key: Key) { + for (let child of getChildren(collection, key)) { + yield child; + yield* getDescendants(collection, child.key); + } +} + +function diffSelection(a: ISelection, b: ISelection): Set { + let res = new Set(); + if (a === 'all' || b === 'all') { + return res; + } + + for (let key of a.keys()) { + if (!b.has(key)) { + res.add(key); + } + } + + return res; +} diff --git a/packages/@react-stately/selection/src/index.ts b/packages/@react-stately/selection/src/index.ts index d938b1bae33..ad51f08d042 100644 --- a/packages/@react-stately/selection/src/index.ts +++ b/packages/@react-stately/selection/src/index.ts @@ -11,6 +11,8 @@ */ export type {MultipleSelectionStateProps} from './useMultipleSelectionState'; -export type {FocusState, SingleSelectionState, MultipleSelectionState, MultipleSelectionManager} from './types'; +export type {FocusState, SingleSelectionState, MultipleSelectionState, MultipleSelectionManager, SelectionStrategy} from './types'; export {useMultipleSelectionState} from './useMultipleSelectionState'; export {SelectionManager} from './SelectionManager'; +export {TreeSelectionManager} from './TreeSelectionManager'; +export {useTreeSelectionState} from './useTreeSelectionState'; diff --git a/packages/@react-stately/selection/src/types.ts b/packages/@react-stately/selection/src/types.ts index 8eafe03307b..d206ea20f94 100644 --- a/packages/@react-stately/selection/src/types.ts +++ b/packages/@react-stately/selection/src/types.ts @@ -12,7 +12,6 @@ import {Collection, DisabledBehavior, FocusStrategy, Key, LongPressEvent, Node, PressEvent, Selection, SelectionBehavior, SelectionMode} from '@react-types/shared'; - export interface FocusState { /** Whether the collection is currently focused. */ readonly isFocused: boolean, @@ -75,6 +74,11 @@ export interface MultipleSelectionManager extends FocusState { readonly disabledKeys: Set, /** Whether `disabledKeys` applies to selection, actions, or both. */ readonly disabledBehavior: DisabledBehavior, + /** + * The raw selection value for the collection. + * Either 'all' for select all, or a set of keys. + */ + readonly rawSelection: Selection, /** Returns whether a key is selected. */ isSelected(key: Key): boolean, /** Returns whether the current selection is equal to the given selection. */ @@ -109,5 +113,15 @@ export interface MultipleSelectionManager extends FocusState { /** Returns the props for the given item. */ getItemProps(key: Key): any, /** The collection of nodes that the selection manager handles. */ - collection: Collection> + collection: Collection>, + withCollection(collection: Collection>): MultipleSelectionManager, + /** Returns whether a key is indeterminate. */ + isIndeterminate?(key: Key): boolean +} + +export type SelectionStrategy = 'all' | 'child' | 'parent'; + +export interface TreeSelectionState extends MultipleSelectionState { + readonly selectionPropagation: boolean, + readonly selectionStrategy: SelectionStrategy } diff --git a/packages/@react-stately/selection/src/useTreeSelectionState.ts b/packages/@react-stately/selection/src/useTreeSelectionState.ts new file mode 100644 index 00000000000..98b84380678 --- /dev/null +++ b/packages/@react-stately/selection/src/useTreeSelectionState.ts @@ -0,0 +1,32 @@ +/* + * Copyright 2025 Adobe. All rights reserved. + * This file is licensed to you under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. You may obtain a copy + * of the License at http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under + * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS + * OF ANY KIND, either express or implied. See the License for the specific language + * governing permissions and limitations under the License. + */ + +import {MultipleSelectionStateProps, useMultipleSelectionState} from '@react-stately/selection'; +import {SelectionStrategy, TreeSelectionState} from './types'; +import {useMemo} from 'react'; + +export interface TreeSelectionStateProps extends MultipleSelectionStateProps { + selectionPropagation?: boolean, + selectionStrategy?: SelectionStrategy +} + +export function useTreeSelectionState(props: TreeSelectionStateProps): TreeSelectionState { + const { + selectionPropagation = false, + selectionStrategy = 'all' + } = props; + const selectionState = useMultipleSelectionState(props); + return useMemo(() => Object.defineProperties({ + selectionPropagation, + selectionStrategy + }, Object.getOwnPropertyDescriptors(selectionState)) as TreeSelectionState, [selectionPropagation, selectionState, selectionStrategy]); +} diff --git a/packages/@react-stately/tree/src/useTreeState.ts b/packages/@react-stately/tree/src/useTreeState.ts index c454a13a9fe..c8f872d28b4 100644 --- a/packages/@react-stately/tree/src/useTreeState.ts +++ b/packages/@react-stately/tree/src/useTreeState.ts @@ -11,7 +11,7 @@ */ import {Collection, CollectionStateBase, DisabledBehavior, Expandable, Key, MultipleSelection, Node} from '@react-types/shared'; -import {SelectionManager, useMultipleSelectionState} from '@react-stately/selection'; +import {MultipleSelectionManager, TreeSelectionManager, useTreeSelectionState} from '@react-stately/selection'; import {TreeCollection} from './TreeCollection'; import {useCallback, useEffect, useMemo} from 'react'; import {useCollection} from '@react-stately/collections'; @@ -38,7 +38,7 @@ export interface TreeState { setExpandedKeys(keys: Set): void, /** A selection manager to read and update multiple selection state. */ - readonly selectionManager: SelectionManager + readonly selectionManager: MultipleSelectionManager } /** @@ -56,7 +56,7 @@ export function useTreeState(props: TreeProps): TreeState props.disabledKeys ? new Set(props.disabledKeys) : new Set() , [props.disabledKeys]); @@ -75,13 +75,19 @@ export function useTreeState(props: TreeProps): TreeState new TreeSelectionManager(tree, selectionState), [tree, selectionState]); + useEffect(() => { + selectionManager.forceUpdate(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [tree, props.selectedKeys, selectionState.selectionStrategy]); + return { collection: tree, expandedKeys, disabledKeys, toggleKey: onToggle, setExpandedKeys, - selectionManager: new SelectionManager(tree, selectionState) + selectionManager }; } diff --git a/packages/react-aria-components/src/Menu.tsx b/packages/react-aria-components/src/Menu.tsx index 5798c5d43bc..d886dc8e191 100644 --- a/packages/react-aria-components/src/Menu.tsx +++ b/packages/react-aria-components/src/Menu.tsx @@ -20,7 +20,7 @@ import {filterDOMProps, useObjectRef, useResizeObserver} from '@react-aria/utils import {FocusStrategy, forwardRefType, GlobalDOMAttributes, HoverEvents, Key, LinkDOMProps, MultipleSelection, PressEvents} from '@react-types/shared'; import {HeaderContext} from './Header'; import {KeyboardContext} from './Keyboard'; -import {MultipleSelectionState, SelectionManager, useMultipleSelectionState} from '@react-stately/selection'; +import {MultipleSelectionManager, MultipleSelectionState, SelectionManager, useMultipleSelectionState} from '@react-stately/selection'; import {OverlayTriggerStateContext} from './Dialog'; import {PopoverContext} from './Popover'; import {PressResponder} from '@react-aria/interactions'; @@ -44,7 +44,7 @@ import {TextContext} from './Text'; export const MenuContext = createContext, HTMLDivElement>>(null); export const MenuStateContext = createContext | null>(null); export const RootMenuTriggerStateContext = createContext(null); -const SelectionManagerContext = createContext(null); +const SelectionManagerContext = createContext(null); export interface MenuTriggerProps extends BaseMenuTriggerProps { children: ReactNode @@ -273,9 +273,9 @@ export interface MenuSectionProps extends SectionProps, MultipleSelection // A subclass of SelectionManager that forwards focus-related properties to the parent, // but has its own local selection state. class GroupSelectionManager extends SelectionManager { - private parent: SelectionManager; + private parent: MultipleSelectionManager; - constructor(parent: SelectionManager, state: MultipleSelectionState) { + constructor(parent: MultipleSelectionManager, state: MultipleSelectionState) { super(parent.collection, state); this.parent = parent; } diff --git a/packages/react-aria-components/src/Tree.tsx b/packages/react-aria-components/src/Tree.tsx index 701ad3fc921..cc53bce48f9 100644 --- a/packages/react-aria-components/src/Tree.tsx +++ b/packages/react-aria-components/src/Tree.tsx @@ -22,6 +22,7 @@ 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 {SelectionStrategy} from '@react-stately/selection'; import {TreeDropTargetDelegate} from './TreeDropTargetDelegate'; import {useControlledState} from '@react-stately/utils'; @@ -148,7 +149,11 @@ export interface TreeProps extends Omit, 'children'>, Multip */ disabledBehavior?: DisabledBehavior, /** The drag and drop hooks returned by `useDragAndDrop` used to enable drag and drop behavior for the Tree. */ - dragAndDropHooks?: DragAndDropHooks> + dragAndDropHooks?: DragAndDropHooks>, + /** Whether selection propagates between parent and child nodes. */ + selectionPropagation?: boolean, + /** Specifies which keys are included in the selection. */ + selectionStrategy?: SelectionStrategy } diff --git a/packages/react-aria-components/stories/Tree.stories.tsx b/packages/react-aria-components/stories/Tree.stories.tsx index 6a005514922..b407582012b 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, Selection, Text, Tree, TreeItem, TreeItemContent, TreeItemProps, TreeProps, useDragAndDrop, Virtualizer} from 'react-aria-components'; import {classNames} from '@react-spectrum/utils'; import {Meta, StoryFn, StoryObj} from '@storybook/react'; import {MyMenuItem} from './utils'; @@ -245,7 +245,9 @@ export const TreeExampleStatic: StoryObj = { args: { selectionMode: 'none', selectionBehavior: 'toggle', - disabledBehavior: 'selection' + disabledBehavior: 'selection', + selectionPropagation: false, + selectionStrategy: 'all' }, argTypes: { selectionMode: { @@ -259,6 +261,11 @@ export const TreeExampleStatic: StoryObj = { disabledBehavior: { control: 'radio', options: ['selection', 'all'] + }, + selectionPropagation: {control: 'boolean'}, + selectionStrategy: { + control: 'radio', + options: ['all', 'child', 'parent'] } }, parameters: { @@ -431,8 +438,13 @@ const TreeExampleDynamicRender = (args: TreeProps): JSX.Ele getChildren: item => item.childItems }); + let onSelectionChange = s => { + args.onSelectionChange?.(s); + action('onSelectionChange')(s); + }; + return ( - + {(item) => ( {item.value.name} @@ -1273,3 +1285,55 @@ export const HugeVirtualizedTree: StoryObj = { }, render: (args) => }; + +export const Selected: StoryObj = { + args: { + selectionBehavior: 'toggle', + selectionMode: 'multiple', + selectionPropagation: false, + selectionStrategy: 'all' + }, + argTypes: { + selectionBehavior: { + control: 'radio', + options: ['toggle', 'replace'] + }, + selectionMode: { + control: 'radio', + options: ['none', 'single', 'multiple'] + }, + selectionPropagation: {control: 'boolean'}, + selectionStrategy: { + control: 'radio', + options: ['all', 'child', 'parent'] + } + }, + render: (args) => +}; + +const ControlledSelectionTreeRender = (args: TreeProps) => { + let [selectedKeys, setSelectedKeys] = useState(new Set()); + + let selectProjects = () => { + if (selectedKeys !== 'all' && !selectedKeys.has('projects')) { + setSelectedKeys(new Set([...selectedKeys, 'projects'])); + } + }; + + return ( + <> + + + + ); +}; + +export const ControlledSelectionTree: StoryObj = { + ...TreeExampleDynamic, + args: { + ...TreeExampleDynamic.args, + selectionMode: 'multiple' + }, + name: 'Controlled selection', + render: (args) => +}; diff --git a/packages/react-aria-components/test/Tree.test.tsx b/packages/react-aria-components/test/Tree.test.tsx index cd1c893fdfc..c7e39f92e57 100644 --- a/packages/react-aria-components/test/Tree.test.tsx +++ b/packages/react-aria-components/test/Tree.test.tsx @@ -1882,6 +1882,137 @@ describe('Tree', () => { expect(onClick).toHaveBeenCalledTimes(1); }); }); + + describe('selection propagation', () => { + let items = rows; + + it('should select all children when the parent is selected', async () => { + let {getAllByRole, getByLabelText} = render(); + let rows = getAllByRole('row'); + + expect(rows).toHaveLength(20); + + for (let row of rows) { + let checkbox = within(row).getByRole('checkbox'); + expect(checkbox).not.toBeChecked(); + } + + let row = getByLabelText('Projects'); + let checkbox = within(row).getByRole('checkbox'); + + expect(row).toHaveAttribute('data-key', 'projects'); + await user.click(checkbox); + + let expectedKeys = collectIds(items, 'projects'); + for (let row of rows) { + let checkbox = within(row).getByRole('checkbox'); + if (expectedKeys.has(row.dataset.key)) { + expect(checkbox).toBeChecked(); + } else { + expect(checkbox).not.toBeChecked(); + } + } + + expect(onSelectionChange).toHaveBeenCalledTimes(1); + expect(new Set(onSelectionChange.mock.calls[0][0])).toEqual(expectedKeys); + await user.click(checkbox); + + for (let row of rows) { + let checkbox = within(row).getByRole('checkbox'); + expect(checkbox).not.toBeChecked(); + } + + expect(onSelectionChange).toHaveBeenCalledTimes(2); + expect(new Set(onSelectionChange.mock.calls[1][0])).toEqual(new Set()); + }); + + it('should select the parent when all children are selected', async () => { + let {getAllByRole, getByLabelText} = render(); + let rows = getAllByRole('row'); + let row = getByLabelText('Reports 1ABC'); + let checkbox = within(row).getByRole('checkbox'); + + expect(row).toHaveAttribute('data-key', 'reports-1ABC'); + await user.click(checkbox); + + let expectedKeys = collectIds(items, 'reports-1A'); + for (let row of rows) { + let checkbox = within(row).getByRole('checkbox'); + if (expectedKeys.has(row.dataset.key)) { + expect(checkbox).toBeChecked(); + } else { + expect(checkbox).not.toBeChecked(); + } + } + + expect(onSelectionChange).toHaveBeenCalledTimes(1); + expect(new Set(onSelectionChange.mock.calls[0][0])).toEqual(expectedKeys); + await user.click(checkbox); + + for (let row of rows) { + let checkbox = within(row).getByRole('checkbox'); + expect(checkbox).not.toBeChecked(); + } + + expect(onSelectionChange).toHaveBeenCalledTimes(2); + expect(new Set(onSelectionChange.mock.calls[1][0])).toEqual(new Set()); + }); + + it('should update the indeterminate state of the parent when a child is selected', async () => { + let {getByLabelText} = render(); + let checkbox1 = within(getByLabelText('Projects')).getByRole('checkbox'); + let checkbox2 = within(getByLabelText('Project 2')).getByRole('checkbox'); + + expect(checkbox1).not.toBePartiallyChecked(); + expect(checkbox2).not.toBePartiallyChecked(); + await user.click(within(getByLabelText('Project 2A')).getByRole('checkbox')); + expect(onSelectionChange).toHaveBeenCalledTimes(1); + expect(new Set(onSelectionChange.mock.calls[0][0])).toEqual(new Set(['project-2A'])); + expect(checkbox1).toBePartiallyChecked(); + expect(checkbox2).toBePartiallyChecked(); + }); + + it('should work with selectionStrategy=parent', async () => { + let {getAllByRole, getByLabelText} = render(); + let row = getByLabelText('Projects'); + let checkbox = within(row).getByRole('checkbox'); + + await user.click(checkbox); + expect(onSelectionChange).toHaveBeenCalledTimes(1); + expect(new Set(onSelectionChange.mock.calls[0][0])).toEqual(new Set(['projects'])); + + let expectedKeys = collectIds(items, 'projects'); + for (let row of getAllByRole('row')) { + let checkbox = within(row).getByRole('checkbox'); + if (expectedKeys.has(row.dataset.key)) { + expect(checkbox).toBeChecked(); + } else { + expect(checkbox).not.toBeChecked(); + } + } + }); + + it('should work with selectionStrategy=child', async () => { + let {getAllByRole, getByLabelText} = render(); + let row = getByLabelText('Project 2'); + let checkbox = within(row).getByRole('checkbox'); + + await user.click(checkbox); + + let expectedKeys = collectIds(items, 'project-2'); + for (let row of getAllByRole('row')) { + let checkbox = within(row).getByRole('checkbox'); + if (expectedKeys.has(row.dataset.key)) { + expect(checkbox).toBeChecked(); + } else { + expect(checkbox).not.toBeChecked(); + } + } + + expect(onSelectionChange).toHaveBeenCalledTimes(1); + expect(new Set(onSelectionChange.mock.calls[0][0])).toEqual(new Set(['project-2A', 'project-2B', 'project-2C'])); + }); + }); }); AriaTreeTests({ @@ -2056,3 +2187,27 @@ AriaTreeTests({ ) } }); + +function collectIds(tree, id) { + const ids = new Set(); + + const collect = items => { + items?.forEach(item => { + ids.add(item.id); + collect(item.childItems); + }); + }; + + const traverse = items => { + return items?.some(item => { + if (item.id === id) { + ids.add(item.id); + collect(item.childItems); + return true; + } + return !!traverse(item.childItems); + }); + }; + traverse(tree); + return ids; +} From 37a32238ed1d56e82bff4c32a761dbbf5814aefc Mon Sep 17 00:00:00 2001 From: chirokas <157580465+chirokas@users.noreply.github.com> Date: Sat, 30 Aug 2025 13:01:59 +0000 Subject: [PATCH 2/4] fix --- packages/@react-stately/selection/src/useTreeSelectionState.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/@react-stately/selection/src/useTreeSelectionState.ts b/packages/@react-stately/selection/src/useTreeSelectionState.ts index 98b84380678..40e2897d871 100644 --- a/packages/@react-stately/selection/src/useTreeSelectionState.ts +++ b/packages/@react-stately/selection/src/useTreeSelectionState.ts @@ -10,7 +10,7 @@ * governing permissions and limitations under the License. */ -import {MultipleSelectionStateProps, useMultipleSelectionState} from '@react-stately/selection'; +import {MultipleSelectionStateProps, useMultipleSelectionState} from './useMultipleSelectionState'; import {SelectionStrategy, TreeSelectionState} from './types'; import {useMemo} from 'react'; From ce35af3687958621b598479d677ebf19b48e0c68 Mon Sep 17 00:00:00 2001 From: chirokas <157580465+chirokas@users.noreply.github.com> Date: Mon, 1 Sep 2025 09:06:55 +0000 Subject: [PATCH 3/4] add tests --- .../react-aria-components/test/Tree.test.tsx | 145 +++++++++++++----- 1 file changed, 110 insertions(+), 35 deletions(-) diff --git a/packages/react-aria-components/test/Tree.test.tsx b/packages/react-aria-components/test/Tree.test.tsx index c7e39f92e57..e39392638eb 100644 --- a/packages/react-aria-components/test/Tree.test.tsx +++ b/packages/react-aria-components/test/Tree.test.tsx @@ -1971,46 +1971,121 @@ describe('Tree', () => { expect(checkbox1).toBePartiallyChecked(); expect(checkbox2).toBePartiallyChecked(); }); - - it('should work with selectionStrategy=parent', async () => { - let {getAllByRole, getByLabelText} = render(); - let row = getByLabelText('Projects'); - let checkbox = within(row).getByRole('checkbox'); - - await user.click(checkbox); - expect(onSelectionChange).toHaveBeenCalledTimes(1); - expect(new Set(onSelectionChange.mock.calls[0][0])).toEqual(new Set(['projects'])); - - let expectedKeys = collectIds(items, 'projects'); - for (let row of getAllByRole('row')) { + + describe('should work with selectionStrategy=parent', () => { + it('should select all children when the parent is selected', async () => { + let {getAllByRole, getByLabelText} = render( + + ); + + let row = getByLabelText('Projects'); let checkbox = within(row).getByRole('checkbox'); - if (expectedKeys.has(row.dataset.key)) { - expect(checkbox).toBeChecked(); - } else { - expect(checkbox).not.toBeChecked(); + await user.click(checkbox); + + let expectedKeys = collectIds(items, 'projects'); + for (let row of getAllByRole('row')) { + let checkbox = within(row).getByRole('checkbox'); + if (expectedKeys.has(row.dataset.key)) { + expect(checkbox).toBeChecked(); + } else { + expect(checkbox).not.toBeChecked(); + } } - } + + expect(onSelectionChange).toHaveBeenCalledTimes(1); + expect(new Set(onSelectionChange.mock.calls[0][0])).toEqual(new Set(['projects'])); + }); + + it('should select the parent when all children are selected', async () => { + let {getAllByRole, getByLabelText} = render( + + ); + + let row = getByLabelText('Reports 1ABC'); + let checkbox = within(row).getByRole('checkbox'); + await user.click(checkbox); + + let expectedKeys = collectIds(items, 'reports-1A'); + for (let row of getAllByRole('row')) { + let checkbox = within(row).getByRole('checkbox'); + if (expectedKeys.has(row.dataset.key)) { + expect(checkbox).toBeChecked(); + } else { + expect(checkbox).not.toBeChecked(); + } + } + + expect(onSelectionChange).toHaveBeenCalledTimes(1); + expect(new Set(onSelectionChange.mock.calls[0][0])).toEqual(new Set(['reports-1A'])); + }); }); - - it('should work with selectionStrategy=child', async () => { - let {getAllByRole, getByLabelText} = render(); - let row = getByLabelText('Project 2'); - let checkbox = within(row).getByRole('checkbox'); - - await user.click(checkbox); - - let expectedKeys = collectIds(items, 'project-2'); - for (let row of getAllByRole('row')) { + + describe('should work with selectionStrategy=child', () => { + it('should select all children when the parent is selected', async () => { + let {getAllByRole, getByLabelText} = render( + + ); + + let row = getByLabelText('Project 2'); let checkbox = within(row).getByRole('checkbox'); - if (expectedKeys.has(row.dataset.key)) { - expect(checkbox).toBeChecked(); - } else { - expect(checkbox).not.toBeChecked(); + await user.click(checkbox); + + let expectedKeys = collectIds(items, 'project-2'); + for (let row of getAllByRole('row')) { + let checkbox = within(row).getByRole('checkbox'); + if (expectedKeys.has(row.dataset.key)) { + expect(checkbox).toBeChecked(); + } else { + expect(checkbox).not.toBeChecked(); + } } - } - - expect(onSelectionChange).toHaveBeenCalledTimes(1); - expect(new Set(onSelectionChange.mock.calls[0][0])).toEqual(new Set(['project-2A', 'project-2B', 'project-2C'])); + + expect(onSelectionChange).toHaveBeenCalledTimes(1); + expect(new Set(onSelectionChange.mock.calls[0][0])).toEqual(new Set(['project-2A', 'project-2B', 'project-2C'])); + }); + + it('should select the parent when all children are selected', async () => { + let {getAllByRole, getByLabelText} = render( + + ); + + let row = getByLabelText('Reports 1ABC'); + let checkbox = within(row).getByRole('checkbox'); + await user.click(checkbox); + + let expectedKeys = collectIds(items, 'reports-1A'); + for (let row of getAllByRole('row')) { + let checkbox = within(row).getByRole('checkbox'); + if (expectedKeys.has(row.dataset.key)) { + expect(checkbox).toBeChecked(); + } else { + expect(checkbox).not.toBeChecked(); + } + } + + expect(onSelectionChange).toHaveBeenCalledTimes(1); + expect(new Set(onSelectionChange.mock.calls[0][0])).toEqual(new Set(['reports-1ABC'])); + }); }); }); }); From 95e8d62d7bacbcc52c8eb65fe75d64acbb4fd716 Mon Sep 17 00:00:00 2001 From: chirokas <157580465+chirokas@users.noreply.github.com> Date: Thu, 4 Sep 2025 08:15:48 +0000 Subject: [PATCH 4/4] Speed up indeterminateKeys computation; add JSDoc @default --- .../@react-stately/selection/src/TreeSelectionManager.ts | 6 +++--- packages/react-aria-components/src/Tree.tsx | 5 ++++- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/@react-stately/selection/src/TreeSelectionManager.ts b/packages/@react-stately/selection/src/TreeSelectionManager.ts index 2126c62bdde..a4007329ccb 100644 --- a/packages/@react-stately/selection/src/TreeSelectionManager.ts +++ b/packages/@react-stately/selection/src/TreeSelectionManager.ts @@ -59,10 +59,10 @@ export class TreeSelectionManager extends SelectionManager { this.selection.forEach(key => { for (let parent of getAncestors(this.collection, key)) { if (this.isSelected(parent.key)) { - continue; + break; } - if (this.isPartiallyChecked(parent.key)) { + if (this.isPartiallySelected(parent.key)) { keys.add(parent.key); } } @@ -164,7 +164,7 @@ export class TreeSelectionManager extends SelectionManager { return keys; } - private isPartiallyChecked(key: Key) { + private isPartiallySelected(key: Key) { let queue: Node[] = []; queue.push(...getChildren(this.collection, key)); diff --git a/packages/react-aria-components/src/Tree.tsx b/packages/react-aria-components/src/Tree.tsx index cc53bce48f9..501f0725c07 100644 --- a/packages/react-aria-components/src/Tree.tsx +++ b/packages/react-aria-components/src/Tree.tsx @@ -152,7 +152,10 @@ export interface TreeProps extends Omit, 'children'>, Multip dragAndDropHooks?: DragAndDropHooks>, /** Whether selection propagates between parent and child nodes. */ selectionPropagation?: boolean, - /** Specifies which keys are included in the selection. */ + /** + * Specifies which keys are included in the selection. + * @default 'all' + */ selectionStrategy?: SelectionStrategy }