1
- import type { KeyboardEvent as ReactKeyboardEvent } from "react" ;
1
+ import type { ReactNode } from "react" ;
2
2
import { useState , useMemo , useRef , useCallback , useEffect } from "react" ;
3
3
import { createPortal } from "react-dom" ;
4
+ import { FocusScope , useFocusManager } from "@react-aria/focus" ;
4
5
import { ListPositionIndicator } from "../list-position-indicator" ;
5
6
import {
6
7
TreeNode ,
@@ -30,6 +31,59 @@ import {
30
31
import { ScrollArea } from "../scroll-area" ;
31
32
import { theme } from "../.." ;
32
33
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
+
33
87
export type TreeProps < Data extends { id : string } > = {
34
88
root : Data ;
35
89
selectedItemSelector : undefined | ItemSelector ;
@@ -267,17 +321,6 @@ export const Tree = <Data extends { id: string }>({
267
321
268
322
useDragCursor ( dragItemSelector !== undefined ) ;
269
323
270
- const keyboardNavigation = useKeyboardNavigation ( {
271
- root,
272
- getItemChildren,
273
- isItemHidden,
274
- selectedItemSelector,
275
- getIsExpanded,
276
- setIsExpanded,
277
- onEsc : dragHandlers . cancelCurrentDrag ,
278
- editingItemId,
279
- } ) ;
280
-
281
324
return (
282
325
< ScrollArea
283
326
// TODO allow resizing of the panel instead.
@@ -287,6 +330,7 @@ export const Tree = <Data extends { id: string }>({
287
330
overflow : "hidden" ,
288
331
flexBasis : 0 ,
289
332
flexGrow : 1 ,
333
+ "&:hover" : showNestingLineVars ( ) ,
290
334
} }
291
335
ref = { ( element ) => {
292
336
rootRef . current = element ;
@@ -296,35 +340,37 @@ export const Tree = <Data extends { id: string }>({
296
340
} }
297
341
onScroll = { dropHandlers . handleScroll }
298
342
>
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
+ }
324
351
} }
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 >
328
374
{ shiftedDropTarget ?. placement &&
329
375
createPortal (
330
376
< ListPositionIndicator
@@ -339,171 +385,6 @@ export const Tree = <Data extends { id: string }>({
339
385
) ;
340
386
} ;
341
387
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
-
507
388
const useExpandState = < Data extends { id : string } > ( {
508
389
selectedItemSelector,
509
390
getItemChildren,
0 commit comments