Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 1 addition & 20 deletions packages/react-aria-components/src/NavigationTree.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
import {
ChildrenOrFunction,
ClassNameOrFunction,
closestVisibleKey,
ContextValue,
dom,
Provider,
Expand Down Expand Up @@ -320,26 +321,6 @@ function findKeyForRoute(collection: Collection<Node<unknown>>, route: string):
return null;
}

// Walks up from `key` to the closest ancestor that is actually rendered (all ancestors expanded).
// Returns `key` unchanged when already visible. A collapsed ancestor hides everything beneath it,
// so the highest collapsed ancestor is the closest visible row.
function closestVisibleKey(
collection: Collection<Node<unknown>>,
expandedKeys: Set<Key>,
key: Key
): Key {
let target = key;
let node = collection.getItem(key);
while (node?.parentKey != null) {
let parent = collection.getItem(node.parentKey);
if (parent?.type === 'item' && !expandedKeys.has(node.parentKey)) {
target = node.parentKey;
}
node = parent;
}
return target;
}

// Moves the tree's focused key to the item matching selectedRoute. Runs when the route or the
// collection changes; the shared syncedRouteRef dedupes across items so it fires once per change.
function useRouteFocusSync({state}: {state: TreeState<unknown>}): void {
Expand Down
17 changes: 17 additions & 0 deletions packages/react-aria-components/src/Table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {ButtonContext} from './Button';
import {CheckboxContext, CheckboxFieldContext} from './Checkbox';
import {
ClassNameOrFunction,
closestVisibleKey,
ContextValue,
DEFAULT_SLOT,
dom,
Expand Down Expand Up @@ -850,6 +851,22 @@ function TableInner({props, forwardedRef: ref, selectionState, collection}: Tabl
isRootDropTarget = dropState.isDropTarget({type: 'root'});
}

// Dragged rows keep focus so it can follow them when they are dropped.
useEffect(() => {
let node =
selectionState.focusedKey != null ? collection.getItem(selectionState.focusedKey) : null;
// A cell is visible whenever its row is.
let rowKey = node?.type === 'cell' ? node.parentKey : node?.key;
if (rowKey == null || dragState?.isDragging(rowKey)) {
return;
}
let visibleKey = closestVisibleKey(collection, expandedKeys, rowKey);
if (visibleKey !== rowKey) {
selectionState.setFocusedKey(visibleKey);
}
// eslint-disable-next-line react-hooks/exhaustive-deps

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what was omitted and why?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It leaves out dragState and state.selectionManager (selectionState in Table). Both are new objects on every render, so listing them makes the effect run on every render. For selectionManager that's harmless. For dragState it isn't: endDrag calls setDragging(false), and on that render the dragged key is no longer "dragging" but the move hasn't re-parented it yet. Running there would redirect focus to the collapsed ancestor, and focus would no longer follow the moved item. So the effect only re-runs when the collection, the expanded keys or the focused key change. useTreeState uses the same pattern for its own focused-key cleanup effect.

I checked this on top of #10623: with dragState in the deps, its keeps the drag usable until Enter test fails, with focus on projects instead of project-1.

}, [collection, expandedKeys, selectionState.focusedKey]);

let {focusProps, isFocused, isFocusVisible} = useFocusRing();
let renderProps = useRenderProps({
...props,
Expand Down
14 changes: 14 additions & 0 deletions packages/react-aria-components/src/Tree.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {CheckboxContext, CheckboxFieldContext} from './Checkbox';
import {
ChildrenOrFunction,
ClassNameOrFunction,
closestVisibleKey,
ContextValue,
DEFAULT_SLOT,
dom,
Expand Down Expand Up @@ -569,6 +570,19 @@ function TreeInner<T>({props, collection, treeRef: ref}: TreeInnerProps<T>) {
isRootDropTarget = dropState.isDropTarget({type: 'root'});
}

// Dragged items keep focus so it can follow them when they are dropped.
useEffect(() => {
let focusedKey = state.selectionManager.focusedKey;
if (focusedKey == null || dragState?.isDragging(focusedKey)) {
return;
}
let visibleKey = closestVisibleKey(state.collection, expandedKeys, focusedKey);
if (visibleKey !== focusedKey) {
state.selectionManager.setFocusedKey(visibleKey);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [state.collection, expandedKeys, state.selectionManager.focusedKey]);

let isTreeDraggable = !!(hasDragHooks && !dragState?.isDisabled);

let {focusProps, isFocused, isFocusVisible} = useFocusRing();
Expand Down
27 changes: 26 additions & 1 deletion packages/react-aria-components/src/utils.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,14 @@
* governing permissions and limitations under the License.
*/

import {AriaLabelingProps, RefObject, DOMProps as SharedDOMProps} from '@react-types/shared';
import {
AriaLabelingProps,
Collection,
Key,
Node,
RefObject,
DOMProps as SharedDOMProps
} from '@react-types/shared';
import {mergeProps} from 'react-aria/mergeProps';
import {mergeRefs} from 'react-aria/mergeRefs';
import React, {
Expand Down Expand Up @@ -404,6 +411,24 @@ export function removeDataAttributes<T>(props: T): T {
return filteredProps;
}

// A collapsed ancestor hides everything below it, so the highest one is the closest visible row.
export function closestVisibleKey<T>(
collection: Collection<Node<T>>,
expandedKeys: Set<Key>,
key: Key
): Key {
let target = key;
let node = collection.getItem(key);
while (node?.parentKey != null) {
let parent = collection.getItem(node.parentKey);
if (parent?.type === 'item' && !expandedKeys.has(node.parentKey)) {
target = node.parentKey;
}
node = parent;
}
return target;
}

// Override base type to change the default.
export interface RACValidation {
/**
Expand Down
Loading
Loading