@duro-app/ui 5.17.0 → 5.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/dist/Table-DnaoH-h5.js.map +1 -1
  2. package/dist/components/AppShell/AppShell.d.ts.map +1 -1
  3. package/dist/components/AppShell/AppShell.stories.d.ts +5 -0
  4. package/dist/components/AppShell/AppShell.stories.d.ts.map +1 -1
  5. package/dist/components/Menu/native.css.d.ts +1 -0
  6. package/dist/components/Menu/native.css.d.ts.map +1 -1
  7. package/dist/components/Popover/native.css.d.ts +1 -0
  8. package/dist/components/Popover/native.css.d.ts.map +1 -1
  9. package/dist/components/SideNav/SideNav.d.ts +2 -1
  10. package/dist/components/SideNav/SideNav.d.ts.map +1 -1
  11. package/dist/components/SideNav/SideNav.meta.d.ts.map +1 -1
  12. package/dist/components/TextLink/native.css.d.ts +19 -0
  13. package/dist/components/TextLink/native.css.d.ts.map +1 -0
  14. package/dist/components/Tree/Tree.d.ts +12 -22
  15. package/dist/components/Tree/Tree.d.ts.map +1 -1
  16. package/dist/components/Tree/Tree.stories.d.ts +33 -0
  17. package/dist/components/Tree/Tree.stories.d.ts.map +1 -1
  18. package/dist/components/Tree/TreeContext.d.ts +5 -5
  19. package/dist/components/Tree/TreeContext.d.ts.map +1 -1
  20. package/dist/components/Tree/TreeFocusStore.d.ts +25 -0
  21. package/dist/components/Tree/TreeFocusStore.d.ts.map +1 -0
  22. package/dist/components/Tree/TreeItemRow.d.ts +25 -0
  23. package/dist/components/Tree/TreeItemRow.d.ts.map +1 -0
  24. package/dist/components/Tree/TreeRow.d.ts +3 -0
  25. package/dist/components/Tree/TreeRow.d.ts.map +1 -0
  26. package/dist/components/Tree/TreeRowProps.d.ts +41 -0
  27. package/dist/components/Tree/TreeRowProps.d.ts.map +1 -0
  28. package/dist/components/Tree/TreeRows.d.ts +70 -0
  29. package/dist/components/Tree/TreeRows.d.ts.map +1 -0
  30. package/dist/components/Tree/TreeWindow.d.ts +21 -0
  31. package/dist/components/Tree/TreeWindow.d.ts.map +1 -0
  32. package/dist/components/Tree/styles.css.d.ts +69 -1
  33. package/dist/components/Tree/styles.css.d.ts.map +1 -1
  34. package/dist/components/Tree/useTreeKeyboard.d.ts +15 -9
  35. package/dist/components/Tree/useTreeKeyboard.d.ts.map +1 -1
  36. package/dist/index.css +1 -1
  37. package/dist/index.js +3798 -3477
  38. package/dist/index.js.map +1 -1
  39. package/dist/shared/InLandmarkContext.d.ts +7 -0
  40. package/dist/shared/InLandmarkContext.d.ts.map +1 -0
  41. package/dist/shared/navigate.d.ts.map +1 -1
  42. package/package.json +3 -3
  43. package/src/components/AppShell/AppShell.native.tsx +17 -14
  44. package/src/components/AppShell/AppShell.stories.tsx +30 -0
  45. package/src/components/AppShell/AppShell.tsx +3 -2
  46. package/src/components/Menu/Menu.native.tsx +4 -24
  47. package/src/components/Menu/native.css.ts +4 -0
  48. package/src/components/Panel/Panel.meta.ts +1 -1
  49. package/src/components/Popover/native.css.ts +4 -0
  50. package/src/components/SideNav/SideNav.meta.ts +1 -0
  51. package/src/components/SideNav/SideNav.tsx +18 -4
  52. package/src/components/TextLink/TextLink.meta.ts +1 -1
  53. package/src/components/TextLink/TextLink.native.tsx +55 -0
  54. package/src/components/TextLink/native.css.ts +36 -0
  55. package/src/components/Tree/Tree.meta.ts +1 -1
  56. package/src/components/Tree/Tree.stories.tsx +376 -1
  57. package/src/components/Tree/Tree.tsx +88 -131
  58. package/src/components/Tree/TreeContext.ts +5 -5
  59. package/src/components/Tree/TreeFocusStore.ts +73 -0
  60. package/src/components/Tree/TreeItemRow.tsx +71 -0
  61. package/src/components/Tree/TreeRow.native.tsx +96 -0
  62. package/src/components/Tree/TreeRow.tsx +112 -0
  63. package/src/components/Tree/TreeRowProps.ts +42 -0
  64. package/src/components/Tree/TreeRows.ts +139 -0
  65. package/src/components/Tree/TreeWindow.native.tsx +19 -0
  66. package/src/components/Tree/TreeWindow.tsx +118 -0
  67. package/src/components/Tree/styles.css.ts +81 -2
  68. package/src/components/Tree/useTreeKeyboard.ts +86 -42
  69. package/src/index.native.ts +4 -0
  70. package/src/shared/InLandmarkContext.ts +8 -0
  71. package/src/shared/followLink.native.ts +33 -0
  72. package/src/shared/navigate.ts +2 -0
@@ -1,8 +1,12 @@
1
- import {useState} from 'react'
1
+ import {Profiler, useEffect, useLayoutEffect, useRef, useState} from 'react'
2
+ import {flushSync} from 'react-dom'
2
3
  import {html} from 'react-strict-dom'
3
4
  import type {Meta, StoryObj} from '@storybook/react'
4
5
  import {expect, fn} from 'storybook/test'
5
6
  import {Tree} from './Tree'
7
+ import {Badge} from '../Badge/Badge'
8
+ import {Inline} from '../Inline/Inline'
9
+ import {Text} from '../Text/Text'
6
10
  import {clickLink} from '../../docs/clickLink'
7
11
  import {onThemeSurface} from '../../docs/themedSurface'
8
12
  import type {OnNavigate} from '../../shared/navigate'
@@ -209,6 +213,10 @@ export const WithLinks: StoryObj<{onNavigate: OnNavigate}> = {
209
213
  )
210
214
  const item = (name: RegExp) => canvas.getByRole('treeitem', {name})
211
215
 
216
+ // the name is the label then the meta; a leaf without meta is its label
217
+ await expect(canvas.getByRole('treeitem', {name: 'Operations 31'})).toBeInTheDocument()
218
+ await expect(canvas.getByRole('treeitem', {name: 'Start here'})).toBeInTheDocument()
219
+
212
220
  // a real link, outside the tab order
213
221
  const link = canvas.getByRole('link', {name: /Clusters and bootstrap/})
214
222
  await expect(link).toHaveAttribute('href', '#clusters')
@@ -241,3 +249,370 @@ export const WithLinks: StoryObj<{onNavigate: OnNavigate}> = {
241
249
  await expect(item(/Runbooks/)).toHaveAttribute('aria-selected', 'false')
242
250
  },
243
251
  }
252
+
253
+ // --- Perf: 300 leaves (measurement harness, plan 2026-10-15 section 3) ---
254
+
255
+ const BRANCHES = 20
256
+ const LEAVES_PER_BRANCH = 15
257
+ const BRANCH_VALUES = Array.from({length: BRANCHES}, (_, b) => `branch-${b}`)
258
+ const PERF_REMOUNT_CEILING_MS = 2000
259
+
260
+ interface TreePerf {
261
+ mountTimes: number[]
262
+ keyTimes: number[]
263
+ eventEntries: number[]
264
+ renders: number
265
+ resetRenders: () => void
266
+ remount: (n: number) => Promise<number[]>
267
+ scroll: () => Promise<{worstFrameGap: number; longTasks: number}>
268
+ }
269
+
270
+ declare global {
271
+ interface Window {
272
+ __treeRenders?: number
273
+ __treePerf?: TreePerf
274
+ }
275
+ }
276
+
277
+ const nextFrame = () => new Promise<number>((resolve) => requestAnimationFrame(resolve))
278
+
279
+ function countCommit() {
280
+ window.__treeRenders = (window.__treeRenders ?? 0) + 1
281
+ }
282
+
283
+ function PerfTree() {
284
+ let leaf = 0
285
+ // the default virtualizeThreshold (150) applies: the tree has 320 items
286
+ return (
287
+ <Profiler id="tree" onRender={countCommit}>
288
+ <Tree.Root aria-label="Sessions" defaultExpanded={BRANCH_VALUES} maxHeight="listMaxH">
289
+ {BRANCH_VALUES.map((branch, b) => (
290
+ <Tree.Item key={branch} value={branch} label={`Project ${b}`}>
291
+ {Array.from({length: LEAVES_PER_BRANCH}, () => {
292
+ const i = leaf++
293
+ return (
294
+ <Tree.Item
295
+ key={i}
296
+ value={`leaf-${i}`}
297
+ label={`Session ${i}`}
298
+ href={`/s/${i}`}
299
+ onNavigate={(_href, event) => event.preventDefault()}
300
+ meta={
301
+ <Inline gap="sm">
302
+ <Text variant="caption" color="muted">
303
+ {`${i}m ago`}
304
+ </Text>
305
+ <Badge size="sm">Running</Badge>
306
+ </Inline>
307
+ }
308
+ />
309
+ )
310
+ })}
311
+ </Tree.Item>
312
+ ))}
313
+ </Tree.Root>
314
+ </Profiler>
315
+ )
316
+ }
317
+
318
+ function CommitProbe({onCommit}: {onCommit: () => void}) {
319
+ useLayoutEffect(() => {
320
+ onCommit()
321
+ })
322
+ return null
323
+ }
324
+
325
+ function PerfHarness() {
326
+ const [mounted, setMounted] = useState(true)
327
+ const hostRef = useRef<HTMLDivElement>(null)
328
+ const commitRef = useRef<(() => void) | null>(null)
329
+
330
+ useEffect(() => {
331
+ const host = hostRef.current
332
+ if (host === null) return
333
+ const longTaskTimes: number[] = []
334
+ const observers: PerformanceObserver[] = []
335
+ const observe = (
336
+ init: PerformanceObserverInit,
337
+ onEntries: (e: PerformanceEntryList) => void,
338
+ ) => {
339
+ try {
340
+ const observer = new PerformanceObserver((list) => onEntries(list.getEntries()))
341
+ observer.observe(init)
342
+ observers.push(observer)
343
+ } catch {
344
+ // an entry type this browser lacks: that measure stays empty
345
+ }
346
+ }
347
+ const perf: TreePerf = {
348
+ mountTimes: [],
349
+ keyTimes: [],
350
+ eventEntries: [],
351
+ renders: 0,
352
+ resetRenders: () => {
353
+ window.__treeRenders = 0
354
+ },
355
+ remount: async (n) => {
356
+ const times: number[] = []
357
+ for (let k = 0; k < n; k++) {
358
+ // commit the unmount before the frame: batched with the mount below, the
359
+ // two updates would cancel out and no commit would follow
360
+ flushSync(() => setMounted(false))
361
+ await nextFrame()
362
+ const started = performance.now()
363
+ const done = new Promise<void>((resolve) => {
364
+ commitRef.current = () => {
365
+ commitRef.current = null
366
+ requestAnimationFrame(() => {
367
+ times.push(performance.now() - started)
368
+ resolve()
369
+ })
370
+ }
371
+ })
372
+ setMounted(true)
373
+ await done
374
+ }
375
+ perf.mountTimes.push(...times)
376
+ return times
377
+ },
378
+ scroll: async () => {
379
+ const viewport = host.querySelector<HTMLElement>('[role="tree"]')
380
+ if (viewport === null) return {worstFrameGap: 0, longTasks: 0}
381
+ const before = longTaskTimes.length
382
+ const distance = viewport.scrollHeight - viewport.clientHeight
383
+ let worst = 0
384
+ let last = await nextFrame()
385
+ for (let frame = 1; frame <= 60; frame++) {
386
+ viewport.scrollTop = (distance * frame) / 60
387
+ const now = await nextFrame()
388
+ worst = Math.max(worst, now - last)
389
+ last = now
390
+ }
391
+ return {worstFrameGap: worst, longTasks: longTaskTimes.length - before}
392
+ },
393
+ }
394
+ Object.defineProperty(perf, 'renders', {get: () => window.__treeRenders ?? 0})
395
+ window.__treePerf = perf
396
+
397
+ observe({type: 'longtask'}, (entries) => longTaskTimes.push(...entries.map((e) => e.startTime)))
398
+ observe(
399
+ {type: 'event', durationThreshold: 16, buffered: false} as PerformanceObserverInit,
400
+ (entries) => {
401
+ for (const entry of entries)
402
+ if (entry.name === 'keydown') perf.eventEntries.push(entry.duration)
403
+ },
404
+ )
405
+
406
+ // keydown dispatch to the next frame after a row's tabindex flips
407
+ let pending: number | null = null
408
+ const onKeyDown = () => {
409
+ pending = performance.now()
410
+ }
411
+ const flips = new MutationObserver((records) => {
412
+ if (pending === null) return
413
+ if (!records.some((r) => (r.target as Element).getAttribute('tabindex') === '0')) return
414
+ const started = pending
415
+ pending = null
416
+ requestAnimationFrame(() => perf.keyTimes.push(performance.now() - started))
417
+ })
418
+ host.addEventListener('keydown', onKeyDown, true)
419
+ flips.observe(host, {subtree: true, attributes: true, attributeFilter: ['tabindex']})
420
+
421
+ return () => {
422
+ host.removeEventListener('keydown', onKeyDown, true)
423
+ flips.disconnect()
424
+ for (const observer of observers) observer.disconnect()
425
+ delete window.__treePerf
426
+ }
427
+ }, [])
428
+
429
+ return (
430
+ <html.div ref={hostRef}>
431
+ {mounted ? (
432
+ <>
433
+ <PerfTree />
434
+ <CommitProbe onCommit={() => commitRef.current?.()} />
435
+ </>
436
+ ) : null}
437
+ </html.div>
438
+ )
439
+ }
440
+
441
+ /**
442
+ * Measurement harness for the cost of a 300-leaf tree (20 branches of 15 links,
443
+ * Happier-shaped meta). Read `window.__treePerf` from a benchmark run; the play
444
+ * function only proves the harness works, with a generous ceiling.
445
+ */
446
+ export const Perf300: Story = {
447
+ name: 'Perf: 300 leaves',
448
+ render: () => <PerfHarness />,
449
+ play: async ({canvas}) => {
450
+ // windowed: the 320 rows are not all in the DOM
451
+ const mounted = canvas.getAllByRole('treeitem').length
452
+ await expect(mounted).toBeGreaterThan(0)
453
+ await expect(mounted).toBeLessThan(BRANCHES + BRANCHES * LEAVES_PER_BRANCH)
454
+ const perf = window.__treePerf
455
+ await expect(perf).toBeDefined()
456
+ const times = (await perf?.remount(5)) ?? []
457
+ await expect(times).toHaveLength(5)
458
+ for (const time of times) await expect(time).toBeLessThan(PERF_REMOUNT_CEILING_MS)
459
+ },
460
+ }
461
+
462
+ // --- Windowing above the threshold ---
463
+
464
+ const WINDOW_BRANCHES = 10
465
+ const WINDOW_LEAVES = 30
466
+ const WINDOW_ROWS = WINDOW_BRANCHES * (1 + WINDOW_LEAVES)
467
+ const windowBranchValues = Array.from({length: WINDOW_BRANCHES}, (_, b) => `group-${b}`)
468
+
469
+ function WindowedTree() {
470
+ return (
471
+ <Tree.Root
472
+ aria-label="Files"
473
+ defaultExpanded={windowBranchValues}
474
+ virtualizeThreshold={100}
475
+ maxHeight="listMaxH"
476
+ >
477
+ {windowBranchValues.map((group, b) => (
478
+ <Tree.Item key={group} value={group} label={`Folder${String(b).padStart(2, '0')}`}>
479
+ {Array.from({length: WINDOW_LEAVES}, (_, l) => (
480
+ <Tree.Item
481
+ key={l}
482
+ value={`${group}-file-${l}`}
483
+ label={`File-${String(b).padStart(2, '0')}-${String(l).padStart(2, '0')}`}
484
+ />
485
+ ))}
486
+ </Tree.Item>
487
+ ))}
488
+ </Tree.Root>
489
+ )
490
+ }
491
+
492
+ /** Above virtualizeThreshold only the rows in view mount, as flat treeitems. */
493
+ export const Windowed: Story = {
494
+ decorators: [onThemeSurface],
495
+ render: () => <WindowedTree />,
496
+ parameters: {a11y: {test: 'error'}},
497
+ play: async ({canvas, userEvent}) => {
498
+ const tree = canvas.getByRole('tree', {name: 'Files'})
499
+ const mounted = canvas.getAllByRole('treeitem')
500
+ await expect(mounted.length).toBeGreaterThan(0)
501
+ await expect(mounted.length).toBeLessThan(WINDOW_ROWS)
502
+
503
+ // the first rows: a branch, then its first leaves, with level, size and position
504
+ const first = canvas.getByRole('treeitem', {name: 'Folder00'})
505
+ await expect(first).toHaveAttribute('aria-level', '1')
506
+ await expect(first).toHaveAttribute('aria-setsize', String(WINDOW_BRANCHES))
507
+ await expect(first).toHaveAttribute('aria-posinset', '1')
508
+ await expect(first).toHaveAttribute('aria-expanded', 'true')
509
+ const leaf = canvas.getByRole('treeitem', {name: 'File-00-02'})
510
+ await expect(leaf).toHaveAttribute('aria-level', '2')
511
+ await expect(leaf).toHaveAttribute('aria-setsize', String(WINDOW_LEAVES))
512
+ await expect(leaf).toHaveAttribute('aria-posinset', '3')
513
+ await expect(leaf).not.toHaveAttribute('aria-expanded')
514
+ await expect(leaf).toHaveAttribute('aria-selected', 'false')
515
+
516
+ // one tab stop
517
+ await expect(tree.querySelectorAll('[role="treeitem"][tabindex="0"]')).toHaveLength(1)
518
+ await userEvent.tab()
519
+ await expect(first).toHaveFocus()
520
+
521
+ // End reaches the last row, which is not mounted yet
522
+ await expect(canvas.queryByRole('treeitem', {name: 'File-09-29'})).toBeNull()
523
+ await userEvent.keyboard('{End}')
524
+ const last = await canvas.findByRole('treeitem', {name: 'File-09-29'})
525
+ await expect(last).toHaveFocus()
526
+ await expect(last).toHaveAttribute('aria-posinset', String(WINDOW_LEAVES))
527
+ await expect(canvas.getAllByRole('treeitem').length).toBeLessThan(WINDOW_ROWS)
528
+
529
+ // Home goes back to the first row
530
+ await userEvent.keyboard('{Home}')
531
+ await expect(await canvas.findByRole('treeitem', {name: 'Folder00'})).toHaveFocus()
532
+
533
+ // typeahead reaches an off-screen row
534
+ await expect(canvas.queryByRole('treeitem', {name: 'Folder08'})).toBeNull()
535
+ // The prefix resets after a 500 ms gap (TYPEAHEAD_RESET_MS), and every prefix match
536
+ // scrolls and mounts rows: on a loaded runner one stall mid-word resets it. Retype
537
+ // once the window has passed rather than fail on timing.
538
+ let folder: HTMLElement | null = null
539
+ let lastError: unknown
540
+ for (let attempt = 0; attempt < 3 && folder === null; attempt++) {
541
+ if (attempt > 0) {
542
+ console.warn(`Tree Windowed: typeahead needed attempt ${attempt + 1} to reach Folder08`)
543
+ await new Promise((resolve) => setTimeout(resolve, 600))
544
+ }
545
+ await userEvent.keyboard('Folder08')
546
+ try {
547
+ folder = await canvas.findByRole('treeitem', {name: 'Folder08'}, {timeout: 3000})
548
+ } catch (error) {
549
+ lastError = error
550
+ }
551
+ }
552
+ if (folder === null) {
553
+ throw Object.assign(new Error('typeahead did not reach Folder08 in 3 attempts'), {
554
+ cause: lastError,
555
+ })
556
+ }
557
+ await expect(folder).toHaveFocus()
558
+ await expect(folder).toHaveAttribute('aria-posinset', '9')
559
+
560
+ // Left closes the branch, and the rows below shift up
561
+ await userEvent.keyboard('{ArrowLeft}')
562
+ await expect(folder).toHaveAttribute('aria-expanded', 'false')
563
+ },
564
+ }
565
+
566
+ /** A branch whose rows come from a component: the collect pass cannot see them. */
567
+ function WrappedRows({branch}: {branch: number}) {
568
+ return Array.from({length: 40}, (_, i) => (
569
+ <Tree.Item key={i} value={`w-${branch}-${i}`} label={`Wrapped ${branch}-${i}`} />
570
+ ))
571
+ }
572
+
573
+ /**
574
+ * Past the threshold, but one branch's rows sit inside a component: the tree
575
+ * falls back to rendering every row, so none of them vanish.
576
+ */
577
+ export const WindowedFallback: Story = {
578
+ name: 'Windowed: wrapped rows fall back',
579
+ render: () => (
580
+ <Tree.Root
581
+ aria-label="Fallback"
582
+ defaultExpanded={Array.from({length: 5}, (_, b) => `wb-${b}`)}
583
+ virtualizeThreshold={3}
584
+ >
585
+ {Array.from({length: 5}, (_, b) => (
586
+ <Tree.Item key={b} value={`wb-${b}`} label={`Wrapped branch ${b}`}>
587
+ <WrappedRows branch={b} />
588
+ </Tree.Item>
589
+ ))}
590
+ </Tree.Root>
591
+ ),
592
+ play: async ({canvas}) => {
593
+ // 5 branches + 5 x 40 wrapped rows, all mounted: no row lost to the window
594
+ await expect(canvas.getAllByRole('treeitem')).toHaveLength(205)
595
+ await expect(canvas.getByRole('treeitem', {name: 'Wrapped 4-39'})).toBeInTheDocument()
596
+ },
597
+ }
598
+
599
+ /** A branch with no link toggles on a click of its row (a folder); Enter too. */
600
+ export const BranchRowToggles: Story = {
601
+ name: 'Branch row toggles',
602
+ render: () => (
603
+ <Tree.Root aria-label="Folders">
604
+ <Tree.Item value="src" label="src">
605
+ <Tree.Item value="app" label="app.ts" />
606
+ </Tree.Item>
607
+ </Tree.Root>
608
+ ),
609
+ play: async ({canvas, userEvent}) => {
610
+ const branch = canvas.getByRole('treeitem', {name: 'src'})
611
+ await expect(branch).toHaveAttribute('aria-expanded', 'false')
612
+ await userEvent.click(canvas.getByText('src'))
613
+ await expect(branch).toHaveAttribute('aria-expanded', 'true')
614
+ await expect(branch).toHaveAttribute('aria-selected', 'true')
615
+ await userEvent.keyboard('{Enter}')
616
+ await expect(branch).toHaveAttribute('aria-expanded', 'false')
617
+ },
618
+ }
@@ -1,11 +1,14 @@
1
- import {Children, type ReactNode, useCallback, useContext, useId, useRef, useState} from 'react'
1
+ import {Children, type ReactNode, useCallback, useContext, useRef, useState} from 'react'
2
2
  import {html} from 'react-strict-dom'
3
- import {SPACING_PX} from '@duro-app/tokens/keys'
4
3
  import {styles} from './styles.css'
5
- import {isNative} from '../../platform'
6
4
  import {TreeContext, TreeLevelContext, useTree} from './TreeContext'
5
+ import {createTreeFocusStore} from './TreeFocusStore'
6
+ import {TreeItemRow} from './TreeItemRow'
7
+ import {devWarnOnce} from '../../shared/devWarnOnce'
8
+ import {collectRows, itemText, type TreeItemProps, type TreeRowModel} from './TreeRows'
9
+ import {CAN_WINDOW, TreeWindow} from './TreeWindow'
7
10
  import {useTreeKeyboard} from './useTreeKeyboard'
8
- import {isPlainClick, type LinkClickEvent, type OnNavigate} from '../../shared/navigate'
11
+ import type {Length} from '../../shared/length'
9
12
 
10
13
  /* Tree — a hierarchy of items (the WAI-ARIA tree pattern): single selection,
11
14
  * expandable branches, one tab stop (roving tabindex), and the keyboard of a
@@ -17,7 +20,11 @@ import {isPlainClick, type LinkClickEvent, type OnNavigate} from '../../shared/n
17
20
  * An item with `href` renders its row as a real link (cmd-click opens a tab,
18
21
  * "Copy link" works) that stays out of the tab order: the treeitem keeps the
19
22
  * single tab stop and the keyboard above, and Enter or Space on it follows
20
- * the link, through `onNavigate` when given. */
23
+ * the link, through `onNavigate` when given.
24
+ *
25
+ * Past `virtualizeThreshold` items (web) the tree is windowed: a collect pass
26
+ * reads the items' props into rows, and only the rows in view are mounted, as
27
+ * flat treeitems with aria-level, aria-setsize and aria-posinset. */
21
28
 
22
29
  // --- Root ---
23
30
 
@@ -34,8 +41,23 @@ interface RootProps {
34
41
  expanded?: ReadonlyArray<string>
35
42
  defaultExpanded?: ReadonlyArray<string>
36
43
  onExpandedChange?: (expanded: string[]) => void
44
+ /**
45
+ * Native: the accessible name of a branch's chevron control, from the item's
46
+ * text (its `textValue`, else a string `label`). Defaults to that text.
47
+ */
48
+ expandLabel?: (label: string) => string
49
+ /**
50
+ * Web: with more items than this (open or not) the tree mounts only the rows
51
+ * in view. At or below it, every row is mounted. Default 150.
52
+ */
53
+ virtualizeThreshold?: number
54
+ /** Web, windowed: caps the scroll viewport's height; without it, it fills its parent. */
55
+ maxHeight?: Length
37
56
  }
38
57
 
58
+ /** Rows of a tree that is not collected (native renders every item itself). */
59
+ const NO_ROWS: TreeRowModel[] = []
60
+
39
61
  function Root({
40
62
  children,
41
63
  'aria-label': ariaLabel,
@@ -46,17 +68,21 @@ function Root({
46
68
  expanded,
47
69
  defaultExpanded = [],
48
70
  onExpandedChange,
71
+ expandLabel,
72
+ virtualizeThreshold = 150,
73
+ maxHeight,
49
74
  }: RootProps) {
50
75
  const [selectedInner, setSelectedInner] = useState<string | null>(defaultValue)
51
76
  const selectedValue = value !== undefined ? value : selectedInner
52
77
  const [expandedInner, setExpandedInner] = useState<ReadonlyArray<string>>(defaultExpanded)
53
78
  const open = expanded ?? expandedInner
54
- const [focusValue, setFocusValue] = useState<string | null>(selectedValue)
55
- // the ring shows on the focused ROW (the item wraps its children, so a
56
- // ring on it would circle the whole branch), only for keyboard focus —
57
- // the tree's own :focus-visible
58
- const [keyboardFocus, setKeyboardFocus] = useState(false)
59
- const rootRef = useRef<HTMLUListElement>(null)
79
+ // the tab stop and the ring (on the focused ROW: the item wraps its
80
+ // children, so a ring on it would circle the whole branch; only for keyboard
81
+ // focus) live in a store the rows subscribe to: a focus move re-renders the
82
+ // old and the new row, not the tree
83
+ const [focus] = useState(() => createTreeFocusStore(selectedValue))
84
+ const listRef = useRef<HTMLUListElement>(null)
85
+ const windowRef = useRef<HTMLDivElement>(null)
60
86
 
61
87
  const onSelect = useCallback(
62
88
  (v: string) => {
@@ -77,13 +103,24 @@ function Root({
77
103
  const isExpanded = useCallback((v: string) => open.includes(v), [open])
78
104
  const toggle = useCallback((v: string) => setExpanded(v, !open.includes(v)), [open, setExpanded])
79
105
 
106
+ const {rows, total, complete} = CAN_WINDOW
107
+ ? collectRows(children, (el) => el.type === Item, isExpanded)
108
+ : {rows: NO_ROWS, total: 0, complete: true}
109
+ const windowed = CAN_WINDOW && complete && total > virtualizeThreshold
110
+ if (CAN_WINDOW && !complete && total > virtualizeThreshold) {
111
+ devWarnOnce(
112
+ 'Tree',
113
+ 'window-needs-items',
114
+ 'Tree is past virtualizeThreshold but some rows come from a component wrapping Tree.Item, which the collect pass cannot see: it renders every row unwindowed. Render Tree.Item directly (map over data) to window it.',
115
+ )
116
+ }
117
+
80
118
  useTreeKeyboard({
81
- rootRef,
82
- focusValue,
83
- setFocusValue,
84
- setKeyboardFocus,
85
- open,
119
+ rootRef: windowed ? windowRef : listRef,
120
+ focus,
121
+ windowedRows: windowed ? rows : null,
86
122
  children,
123
+ open,
87
124
  onSelect,
88
125
  setExpanded,
89
126
  })
@@ -96,21 +133,30 @@ function Root({
96
133
  isExpanded,
97
134
  toggle,
98
135
  setExpanded,
99
- focusValue,
100
- setFocusValue,
101
- keyboardFocus,
136
+ focus,
137
+ expandLabel,
102
138
  }}
103
139
  >
104
140
  <TreeLevelContext.Provider value={1}>
105
- <html.ul
106
- ref={rootRef}
107
- role="tree"
108
- aria-label={ariaLabel}
109
- aria-labelledby={ariaLabelledBy}
110
- style={styles.root}
111
- >
112
- {children}
113
- </html.ul>
141
+ {windowed ? (
142
+ <TreeWindow
143
+ rows={rows}
144
+ maxHeight={maxHeight}
145
+ aria-label={ariaLabel}
146
+ aria-labelledby={ariaLabelledBy}
147
+ scrollRef={windowRef}
148
+ />
149
+ ) : (
150
+ <html.ul
151
+ ref={listRef}
152
+ role="tree"
153
+ aria-label={ariaLabel}
154
+ aria-labelledby={ariaLabelledBy}
155
+ style={styles.root}
156
+ >
157
+ {children}
158
+ </html.ul>
159
+ )}
114
160
  </TreeLevelContext.Provider>
115
161
  </TreeContext.Provider>
116
162
  )
@@ -118,113 +164,24 @@ function Root({
118
164
 
119
165
  // --- Item ---
120
166
 
121
- interface ItemProps {
122
- /** unique within the tree */
123
- value: string
124
- /** what the row shows */
125
- label: ReactNode
126
- /** plain text for typeahead when `label` isn't a string */
127
- textValue?: string
128
- /** nested items: the branch's children (none = a leaf) */
129
- children?: ReactNode
130
- /** trailing content on the row (a count, a status) */
131
- meta?: ReactNode
132
- /**
133
- * Makes the row a link to this URL. A plain click (or Enter / Space)
134
- * selects the item and calls `onNavigate`; a modified click keeps the
135
- * browser default (new tab, new window) and does not select.
136
- */
137
- href?: string
138
- /**
139
- * Client-side navigation for `href`: called for a plain primary click. Call
140
- * `event.preventDefault()`, then your router's navigate.
141
- */
142
- onNavigate?: OnNavigate
143
- }
144
-
145
- function Item({value, label, textValue, children, meta, href, onNavigate}: ItemProps) {
146
- const {selectedValue, onSelect, isExpanded, toggle, focusValue, keyboardFocus} = useTree()
167
+ function Item({value, label, textValue, children, meta, href, onNavigate}: TreeItemProps) {
168
+ const {isExpanded} = useTree()
147
169
  const level = useContext(TreeLevelContext)
148
170
  const hasChildren = Children.count(children) > 0
149
- const open = hasChildren && isExpanded(value)
150
- const selected = selectedValue === value
151
- const text = textValue ?? (typeof label === 'string' ? label : '')
152
- // named by its own label only: a branch's content includes its children,
153
- // and a name computed from content would read the whole subtree
154
- const labelId = useId()
155
- const rowStyles = [
156
- isNative
157
- ? styles.indentPx(SPACING_PX.sm + Math.max(0, level - 1) * SPACING_PX.lg)
158
- : styles.indent(level),
159
- selected && styles.rowSelected,
160
- keyboardFocus && focusValue === value && styles.rowFocused,
161
- ]
162
- const rowContent = (
163
- <>
164
- <html.span
165
- aria-hidden
166
- onClick={(e: {stopPropagation: () => void; preventDefault: () => void}) => {
167
- if (!hasChildren) return
168
- // the chevron only toggles: it never selects, nor follows a link
169
- e.stopPropagation()
170
- e.preventDefault()
171
- toggle(value)
172
- }}
173
- style={[
174
- styles.chevron,
175
- isNative && styles.nativeFlex,
176
- open && styles.chevronOpen,
177
- !hasChildren && styles.chevronLeaf,
178
- ]}
179
- >
180
- ▸
181
- </html.span>
182
- <html.span id={labelId} style={styles.label}>
183
- {label}
184
- </html.span>
185
- {meta != null ? <html.span style={styles.meta}>{meta}</html.span> : null}
186
- </>
187
- )
188
171
 
189
172
  return (
190
- <html.li
191
- role="treeitem"
192
- aria-labelledby={labelId}
193
- aria-level={level}
194
- aria-expanded={hasChildren ? open : undefined}
195
- aria-selected={selected}
196
- tabIndex={focusValue === value ? 0 : -1}
197
- data-tree-value={value}
198
- data-tree-text={text}
199
- style={styles.item}
200
- >
201
- {href !== undefined ? (
202
- <html.a
203
- href={href}
204
- tabIndex={-1}
205
- data-tree-link=""
206
- onClick={(e: LinkClickEvent) => {
207
- if (!isPlainClick(e)) return
208
- onSelect(value)
209
- onNavigate?.(href, e)
210
- }}
211
- style={[styles.row, styles.rowLink, ...rowStyles]}
212
- >
213
- {rowContent}
214
- </html.a>
215
- ) : (
216
- <html.div onClick={() => onSelect(value)} style={[styles.row, ...rowStyles]}>
217
- {rowContent}
218
- </html.div>
219
- )}
220
- {open ? (
221
- <TreeLevelContext.Provider value={level + 1}>
222
- <html.ul role="group" style={styles.group}>
223
- {children}
224
- </html.ul>
225
- </TreeLevelContext.Provider>
226
- ) : null}
227
- </html.li>
173
+ <TreeItemRow
174
+ value={value}
175
+ level={level}
176
+ label={label}
177
+ meta={meta}
178
+ text={itemText(label, textValue)}
179
+ href={href}
180
+ onNavigate={onNavigate}
181
+ hasChildren={hasChildren}
182
+ open={hasChildren && isExpanded(value)}
183
+ branch={<TreeLevelContext.Provider value={level + 1}>{children}</TreeLevelContext.Provider>}
184
+ />
228
185
  )
229
186
  }
230
187