@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.
- package/dist/Table-DnaoH-h5.js.map +1 -1
- package/dist/components/AppShell/AppShell.d.ts.map +1 -1
- package/dist/components/AppShell/AppShell.stories.d.ts +5 -0
- package/dist/components/AppShell/AppShell.stories.d.ts.map +1 -1
- package/dist/components/Menu/native.css.d.ts +1 -0
- package/dist/components/Menu/native.css.d.ts.map +1 -1
- package/dist/components/Popover/native.css.d.ts +1 -0
- package/dist/components/Popover/native.css.d.ts.map +1 -1
- package/dist/components/SideNav/SideNav.d.ts +2 -1
- package/dist/components/SideNav/SideNav.d.ts.map +1 -1
- package/dist/components/SideNav/SideNav.meta.d.ts.map +1 -1
- package/dist/components/TextLink/native.css.d.ts +19 -0
- package/dist/components/TextLink/native.css.d.ts.map +1 -0
- package/dist/components/Tree/Tree.d.ts +12 -22
- package/dist/components/Tree/Tree.d.ts.map +1 -1
- package/dist/components/Tree/Tree.stories.d.ts +33 -0
- package/dist/components/Tree/Tree.stories.d.ts.map +1 -1
- package/dist/components/Tree/TreeContext.d.ts +5 -5
- package/dist/components/Tree/TreeContext.d.ts.map +1 -1
- package/dist/components/Tree/TreeFocusStore.d.ts +25 -0
- package/dist/components/Tree/TreeFocusStore.d.ts.map +1 -0
- package/dist/components/Tree/TreeItemRow.d.ts +25 -0
- package/dist/components/Tree/TreeItemRow.d.ts.map +1 -0
- package/dist/components/Tree/TreeRow.d.ts +3 -0
- package/dist/components/Tree/TreeRow.d.ts.map +1 -0
- package/dist/components/Tree/TreeRowProps.d.ts +41 -0
- package/dist/components/Tree/TreeRowProps.d.ts.map +1 -0
- package/dist/components/Tree/TreeRows.d.ts +70 -0
- package/dist/components/Tree/TreeRows.d.ts.map +1 -0
- package/dist/components/Tree/TreeWindow.d.ts +21 -0
- package/dist/components/Tree/TreeWindow.d.ts.map +1 -0
- package/dist/components/Tree/styles.css.d.ts +69 -1
- package/dist/components/Tree/styles.css.d.ts.map +1 -1
- package/dist/components/Tree/useTreeKeyboard.d.ts +15 -9
- package/dist/components/Tree/useTreeKeyboard.d.ts.map +1 -1
- package/dist/index.css +1 -1
- package/dist/index.js +3798 -3477
- package/dist/index.js.map +1 -1
- package/dist/shared/InLandmarkContext.d.ts +7 -0
- package/dist/shared/InLandmarkContext.d.ts.map +1 -0
- package/dist/shared/navigate.d.ts.map +1 -1
- package/package.json +3 -3
- package/src/components/AppShell/AppShell.native.tsx +17 -14
- package/src/components/AppShell/AppShell.stories.tsx +30 -0
- package/src/components/AppShell/AppShell.tsx +3 -2
- package/src/components/Menu/Menu.native.tsx +4 -24
- package/src/components/Menu/native.css.ts +4 -0
- package/src/components/Panel/Panel.meta.ts +1 -1
- package/src/components/Popover/native.css.ts +4 -0
- package/src/components/SideNav/SideNav.meta.ts +1 -0
- package/src/components/SideNav/SideNav.tsx +18 -4
- package/src/components/TextLink/TextLink.meta.ts +1 -1
- package/src/components/TextLink/TextLink.native.tsx +55 -0
- package/src/components/TextLink/native.css.ts +36 -0
- package/src/components/Tree/Tree.meta.ts +1 -1
- package/src/components/Tree/Tree.stories.tsx +376 -1
- package/src/components/Tree/Tree.tsx +88 -131
- package/src/components/Tree/TreeContext.ts +5 -5
- package/src/components/Tree/TreeFocusStore.ts +73 -0
- package/src/components/Tree/TreeItemRow.tsx +71 -0
- package/src/components/Tree/TreeRow.native.tsx +96 -0
- package/src/components/Tree/TreeRow.tsx +112 -0
- package/src/components/Tree/TreeRowProps.ts +42 -0
- package/src/components/Tree/TreeRows.ts +139 -0
- package/src/components/Tree/TreeWindow.native.tsx +19 -0
- package/src/components/Tree/TreeWindow.tsx +118 -0
- package/src/components/Tree/styles.css.ts +81 -2
- package/src/components/Tree/useTreeKeyboard.ts +86 -42
- package/src/index.native.ts +4 -0
- package/src/shared/InLandmarkContext.ts +8 -0
- package/src/shared/followLink.native.ts +33 -0
- 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,
|
|
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
|
|
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
|
-
|
|
55
|
-
//
|
|
56
|
-
//
|
|
57
|
-
// the
|
|
58
|
-
const [
|
|
59
|
-
const
|
|
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
|
-
|
|
83
|
-
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
keyboardFocus,
|
|
136
|
+
focus,
|
|
137
|
+
expandLabel,
|
|
102
138
|
}}
|
|
103
139
|
>
|
|
104
140
|
<TreeLevelContext.Provider value={1}>
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
122
|
-
|
|
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
|
-
<
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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
|
|