Skip to content

Commit 0d34100

Browse files
authored
refactor: switch to react aria focus in navigator (#4120)
Related to #3399 Rewrote keyboard navigation in navigator tree with focus manager from @react-aria/focus. Now navigating with keyboard rely on dom instead of data representation. It will help to integrate ":root" without huge rewrite.
1 parent 5746590 commit 0d34100

File tree

1 file changed

+86
-205
lines changed
  • packages/design-system/src/components/tree

1 file changed

+86
-205
lines changed

packages/design-system/src/components/tree/tree.tsx

Lines changed: 86 additions & 205 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
import type { KeyboardEvent as ReactKeyboardEvent } from "react";
1+
import type { ReactNode } from "react";
22
import { useState, useMemo, useRef, useCallback, useEffect } from "react";
33
import { createPortal } from "react-dom";
4+
import { FocusScope, useFocusManager } from "@react-aria/focus";
45
import { ListPositionIndicator } from "../list-position-indicator";
56
import {
67
TreeNode,
@@ -30,6 +31,59 @@ import {
3031
import { ScrollArea } from "../scroll-area";
3132
import { theme } from "../..";
3233

34+
const KeyboardNavigation = ({
35+
editingItemId,
36+
onExpandedChange,
37+
children,
38+
}: {
39+
editingItemId: ItemId | undefined;
40+
onExpandedChange: (expanded?: boolean) => void;
41+
children: ReactNode;
42+
}) => {
43+
const focusManager = useFocusManager();
44+
return (
45+
<div
46+
onKeyDown={(event) => {
47+
if (event.defaultPrevented) {
48+
return;
49+
}
50+
// prevent navigating while editing nodes
51+
if (editingItemId) {
52+
return;
53+
}
54+
if (event.key === "ArrowUp") {
55+
focusManager.focusPrevious({
56+
accept: (node) => node.hasAttribute("data-item-button-id"),
57+
});
58+
// prevent scrolling
59+
event.preventDefault();
60+
}
61+
if (event.key === "ArrowDown") {
62+
focusManager.focusNext({
63+
accept: (node) => node.hasAttribute("data-item-button-id"),
64+
});
65+
// prevent scrolling
66+
event.preventDefault();
67+
}
68+
if (event.key === "ArrowLeft") {
69+
onExpandedChange(false);
70+
//
71+
}
72+
if (event.key === "ArrowRight") {
73+
onExpandedChange(true);
74+
}
75+
if (event.key === " ") {
76+
onExpandedChange();
77+
// prevent scrolling
78+
event.preventDefault();
79+
}
80+
}}
81+
>
82+
{children}
83+
</div>
84+
);
85+
};
86+
3387
export type TreeProps<Data extends { id: string }> = {
3488
root: Data;
3589
selectedItemSelector: undefined | ItemSelector;
@@ -267,17 +321,6 @@ export const Tree = <Data extends { id: string }>({
267321

268322
useDragCursor(dragItemSelector !== undefined);
269323

270-
const keyboardNavigation = useKeyboardNavigation({
271-
root,
272-
getItemChildren,
273-
isItemHidden,
274-
selectedItemSelector,
275-
getIsExpanded,
276-
setIsExpanded,
277-
onEsc: dragHandlers.cancelCurrentDrag,
278-
editingItemId,
279-
});
280-
281324
return (
282325
<ScrollArea
283326
// TODO allow resizing of the panel instead.
@@ -287,6 +330,7 @@ export const Tree = <Data extends { id: string }>({
287330
overflow: "hidden",
288331
flexBasis: 0,
289332
flexGrow: 1,
333+
"&:hover": showNestingLineVars(),
290334
}}
291335
ref={(element) => {
292336
rootRef.current = element;
@@ -296,35 +340,37 @@ export const Tree = <Data extends { id: string }>({
296340
}}
297341
onScroll={dropHandlers.handleScroll}
298342
>
299-
<Box
300-
ref={keyboardNavigation.rootRef}
301-
onBlur={keyboardNavigation.handleBlur}
302-
onKeyDown={keyboardNavigation.handleKeyDown}
303-
onClick={keyboardNavigation.handleClick}
304-
css={{
305-
// To not intersect last element with the scroll
306-
marginBottom: theme.spacing[7],
307-
"&:hover": showNestingLineVars(),
308-
}}
309-
>
310-
<TreeNode
311-
renderItem={renderItem}
312-
getItemChildren={getItemChildren}
313-
getItemProps={getItemProps}
314-
isItemHidden={isItemHidden}
315-
onSelect={onSelect}
316-
onHover={onHover}
317-
selectedItemSelector={selectedItemSelector}
318-
highlightedItemSelector={highlightedItemSelector}
319-
itemData={root}
320-
getIsExpanded={getIsExpanded}
321-
setIsExpanded={(itemSelector, value, all) => {
322-
setIsExpanded(itemSelector, value, all);
323-
dropHandlers.handleDomMutation();
343+
<FocusScope>
344+
<KeyboardNavigation
345+
editingItemId={editingItemId}
346+
onExpandedChange={(expanded) => {
347+
if (selectedItemSelector) {
348+
expanded ??= getIsExpanded(selectedItemSelector) === false;
349+
setIsExpanded(selectedItemSelector, expanded);
350+
}
324351
}}
325-
dropTargetItemSelector={shiftedDropTarget?.itemSelector}
326-
/>
327-
</Box>
352+
>
353+
<TreeNode
354+
renderItem={renderItem}
355+
getItemChildren={getItemChildren}
356+
getItemProps={getItemProps}
357+
isItemHidden={isItemHidden}
358+
onSelect={onSelect}
359+
onHover={onHover}
360+
selectedItemSelector={selectedItemSelector}
361+
highlightedItemSelector={highlightedItemSelector}
362+
itemData={root}
363+
getIsExpanded={getIsExpanded}
364+
setIsExpanded={(itemSelector, value, all) => {
365+
setIsExpanded(itemSelector, value, all);
366+
dropHandlers.handleDomMutation();
367+
}}
368+
dropTargetItemSelector={shiftedDropTarget?.itemSelector}
369+
/>
370+
</KeyboardNavigation>
371+
</FocusScope>
372+
{/* To not intersect last element with the scroll */}
373+
<Box css={{ height: theme.spacing[7] }}></Box>
328374
{shiftedDropTarget?.placement &&
329375
createPortal(
330376
<ListPositionIndicator
@@ -339,171 +385,6 @@ export const Tree = <Data extends { id: string }>({
339385
);
340386
};
341387

342-
const useKeyboardNavigation = <Data extends { id: string }>({
343-
root,
344-
selectedItemSelector,
345-
getItemChildren,
346-
isItemHidden,
347-
getIsExpanded,
348-
setIsExpanded,
349-
onEsc,
350-
editingItemId,
351-
}: {
352-
root: Data;
353-
selectedItemSelector: undefined | ItemSelector;
354-
getItemChildren: (itemSelector: ItemSelector) => Data[];
355-
isItemHidden: (itemSelector: ItemSelector) => boolean;
356-
getIsExpanded: (itemSelector: ItemSelector) => boolean;
357-
setIsExpanded: (
358-
itemSelector: ItemSelector,
359-
value: boolean,
360-
all?: boolean
361-
) => void;
362-
onEsc: () => void;
363-
editingItemId: ItemId | undefined;
364-
}) => {
365-
const flatCurrentlyExpandedTree = useMemo(() => {
366-
const result: ItemSelector[] = [];
367-
const traverse = (itemSelector: ItemSelector) => {
368-
if (isItemHidden(itemSelector) === false) {
369-
result.push(itemSelector);
370-
}
371-
if (getIsExpanded(itemSelector)) {
372-
for (const child of getItemChildren(itemSelector)) {
373-
traverse([child.id, ...itemSelector]);
374-
}
375-
}
376-
};
377-
traverse([root.id]);
378-
return result;
379-
}, [root, getIsExpanded, getItemChildren, isItemHidden]);
380-
381-
const rootRef = useRef<HTMLDivElement>(null);
382-
383-
const handleKeyDown = (event: ReactKeyboardEvent) => {
384-
// skip if nothing is selected in the tree
385-
if (selectedItemSelector === undefined) {
386-
return;
387-
}
388-
389-
if (editingItemId !== undefined) {
390-
return;
391-
}
392-
393-
if (
394-
event.key === "ArrowRight" &&
395-
getIsExpanded(selectedItemSelector) === false
396-
) {
397-
setIsExpanded(selectedItemSelector, true);
398-
}
399-
if (event.key === "ArrowLeft" && getIsExpanded(selectedItemSelector)) {
400-
setIsExpanded(selectedItemSelector, false);
401-
}
402-
if (event.key === " ") {
403-
setIsExpanded(
404-
selectedItemSelector,
405-
getIsExpanded(selectedItemSelector) === false
406-
);
407-
// prevent scrolling
408-
event.preventDefault();
409-
}
410-
if (event.key === "ArrowUp") {
411-
const index = flatCurrentlyExpandedTree.findIndex((itemSelector) =>
412-
areItemSelectorsEqual(itemSelector, selectedItemSelector)
413-
);
414-
if (index > 0) {
415-
setFocus(flatCurrentlyExpandedTree[index - 1], "changing");
416-
// prevent scrolling
417-
event.preventDefault();
418-
}
419-
}
420-
if (event.key === "ArrowDown") {
421-
const index = flatCurrentlyExpandedTree.findIndex((itemSelector) =>
422-
areItemSelectorsEqual(itemSelector, selectedItemSelector)
423-
);
424-
if (index < flatCurrentlyExpandedTree.length - 1) {
425-
setFocus(flatCurrentlyExpandedTree[index + 1], "changing");
426-
// prevent scrolling
427-
event.preventDefault();
428-
}
429-
}
430-
if (event.key === "Escape") {
431-
onEsc();
432-
}
433-
};
434-
435-
const setFocus = useCallback(
436-
(itemSelector: ItemSelector, reason: "restoring" | "changing") => {
437-
const [itemId] = itemSelector;
438-
const itemButton = getElementByItemSelector(
439-
rootRef.current ?? undefined,
440-
itemSelector
441-
)?.querySelector(`[data-item-button-id="${itemId}"]`);
442-
if (itemButton instanceof HTMLElement) {
443-
itemButton.focus({ preventScroll: reason === "restoring" });
444-
}
445-
},
446-
[rootRef]
447-
);
448-
449-
const hadFocus = useRef(false);
450-
const prevRoot = useRef(root);
451-
useEffect(() => {
452-
const haveFocus =
453-
rootRef.current?.contains(document.activeElement) === true;
454-
455-
const isRootChanged = prevRoot.current !== root;
456-
prevRoot.current = root;
457-
458-
// If we've lost focus due to a root update, we want to get it back.
459-
// This can happen when we delete an item or on drag-end.
460-
if (
461-
isRootChanged &&
462-
haveFocus === false &&
463-
hadFocus.current === true &&
464-
selectedItemSelector !== undefined
465-
) {
466-
setFocus(selectedItemSelector, "restoring");
467-
}
468-
}, [root, rootRef, selectedItemSelector, setFocus]);
469-
470-
// onBlur doesn't fire when the activeElement is removed from the DOM
471-
useEffect(() => {
472-
const haveFocus =
473-
rootRef.current?.contains(document.activeElement) === true;
474-
hadFocus.current = haveFocus;
475-
});
476-
477-
return {
478-
rootRef,
479-
handleKeyDown,
480-
handleClick(event: React.MouseEvent<Element>) {
481-
if (editingItemId) {
482-
return;
483-
}
484-
485-
// When clicking on an item button make sure it gets focused.
486-
// (see https://zellwk.com/blog/inconsistent-button-behavior/)
487-
const itemButton = (event.target as HTMLElement).closest(
488-
"[data-item-button-id]"
489-
);
490-
if (itemButton instanceof HTMLElement) {
491-
itemButton.focus();
492-
return;
493-
}
494-
495-
// When clicking anywhere else in the tree,
496-
// make sure the selected item doesn't loose focus.
497-
if (selectedItemSelector !== undefined) {
498-
setFocus(selectedItemSelector, "restoring");
499-
}
500-
},
501-
handleBlur() {
502-
hadFocus.current = false;
503-
},
504-
};
505-
};
506-
507388
const useExpandState = <Data extends { id: string }>({
508389
selectedItemSelector,
509390
getItemChildren,

0 commit comments

Comments
 (0)