@duro-app/ui 5.18.0 → 5.20.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 (75) hide show
  1. package/dist/{Table-DnaoH-h5.js → Table-Cw1B14R3.js} +4 -2
  2. package/dist/Table-Cw1B14R3.js.map +1 -0
  3. package/dist/components/AppShell/AppShell.d.ts.map +1 -1
  4. package/dist/components/AppShell/AppShell.stories.d.ts +5 -0
  5. package/dist/components/AppShell/AppShell.stories.d.ts.map +1 -1
  6. package/dist/components/Menu/native.css.d.ts +1 -0
  7. package/dist/components/Menu/native.css.d.ts.map +1 -1
  8. package/dist/components/Popover/native.css.d.ts +1 -0
  9. package/dist/components/Popover/native.css.d.ts.map +1 -1
  10. package/dist/components/SideNav/SideNav.d.ts +2 -1
  11. package/dist/components/SideNav/SideNav.d.ts.map +1 -1
  12. package/dist/components/SideNav/SideNav.meta.d.ts.map +1 -1
  13. package/dist/components/TextLink/native.css.d.ts +19 -0
  14. package/dist/components/TextLink/native.css.d.ts.map +1 -0
  15. package/dist/components/Tree/Tree.d.ts +12 -22
  16. package/dist/components/Tree/Tree.d.ts.map +1 -1
  17. package/dist/components/Tree/Tree.stories.d.ts +33 -0
  18. package/dist/components/Tree/Tree.stories.d.ts.map +1 -1
  19. package/dist/components/Tree/TreeContext.d.ts +5 -5
  20. package/dist/components/Tree/TreeContext.d.ts.map +1 -1
  21. package/dist/components/Tree/TreeFocusStore.d.ts +25 -0
  22. package/dist/components/Tree/TreeFocusStore.d.ts.map +1 -0
  23. package/dist/components/Tree/TreeItemRow.d.ts +25 -0
  24. package/dist/components/Tree/TreeItemRow.d.ts.map +1 -0
  25. package/dist/components/Tree/TreeRow.d.ts +3 -0
  26. package/dist/components/Tree/TreeRow.d.ts.map +1 -0
  27. package/dist/components/Tree/TreeRowProps.d.ts +41 -0
  28. package/dist/components/Tree/TreeRowProps.d.ts.map +1 -0
  29. package/dist/components/Tree/TreeRows.d.ts +70 -0
  30. package/dist/components/Tree/TreeRows.d.ts.map +1 -0
  31. package/dist/components/Tree/TreeWindow.d.ts +21 -0
  32. package/dist/components/Tree/TreeWindow.d.ts.map +1 -0
  33. package/dist/components/Tree/styles.css.d.ts +69 -1
  34. package/dist/components/Tree/styles.css.d.ts.map +1 -1
  35. package/dist/components/Tree/useTreeKeyboard.d.ts +15 -9
  36. package/dist/components/Tree/useTreeKeyboard.d.ts.map +1 -1
  37. package/dist/index.css +1 -1
  38. package/dist/index.js +3798 -3477
  39. package/dist/index.js.map +1 -1
  40. package/dist/shared/InLandmarkContext.d.ts +7 -0
  41. package/dist/shared/InLandmarkContext.d.ts.map +1 -0
  42. package/dist/shared/navigate.d.ts.map +1 -1
  43. package/dist/table.js +1 -1
  44. package/package.json +3 -3
  45. package/src/components/AppShell/AppShell.native.tsx +17 -14
  46. package/src/components/AppShell/AppShell.stories.tsx +30 -0
  47. package/src/components/AppShell/AppShell.tsx +3 -2
  48. package/src/components/Menu/Menu.native.tsx +4 -24
  49. package/src/components/Menu/native.css.ts +4 -0
  50. package/src/components/Panel/Panel.meta.ts +1 -1
  51. package/src/components/Popover/native.css.ts +4 -0
  52. package/src/components/SideNav/SideNav.meta.ts +1 -0
  53. package/src/components/SideNav/SideNav.tsx +18 -4
  54. package/src/components/TextLink/TextLink.meta.ts +1 -1
  55. package/src/components/TextLink/TextLink.native.tsx +55 -0
  56. package/src/components/TextLink/native.css.ts +36 -0
  57. package/src/components/Tree/Tree.meta.ts +1 -1
  58. package/src/components/Tree/Tree.stories.tsx +376 -1
  59. package/src/components/Tree/Tree.tsx +88 -131
  60. package/src/components/Tree/TreeContext.ts +5 -5
  61. package/src/components/Tree/TreeFocusStore.ts +73 -0
  62. package/src/components/Tree/TreeItemRow.tsx +71 -0
  63. package/src/components/Tree/TreeRow.native.tsx +96 -0
  64. package/src/components/Tree/TreeRow.tsx +112 -0
  65. package/src/components/Tree/TreeRowProps.ts +42 -0
  66. package/src/components/Tree/TreeRows.ts +139 -0
  67. package/src/components/Tree/TreeWindow.native.tsx +19 -0
  68. package/src/components/Tree/TreeWindow.tsx +118 -0
  69. package/src/components/Tree/styles.css.ts +81 -2
  70. package/src/components/Tree/useTreeKeyboard.ts +86 -42
  71. package/src/index.native.ts +4 -0
  72. package/src/shared/InLandmarkContext.ts +8 -0
  73. package/src/shared/followLink.native.ts +33 -0
  74. package/src/shared/navigate.ts +2 -0
  75. package/dist/Table-DnaoH-h5.js.map +0 -1
@@ -1,4 +1,5 @@
1
1
  import {createContext, useContext} from 'react'
2
+ import type {TreeFocusStore} from './TreeFocusStore'
2
3
 
3
4
  export interface TreeContextValue {
4
5
  /** the selected item's value (single selection), or null */
@@ -7,11 +8,10 @@ export interface TreeContextValue {
7
8
  isExpanded: (value: string) => boolean
8
9
  toggle: (value: string) => void
9
10
  setExpanded: (value: string, expanded: boolean) => void
10
- /** the item holding the tree's single tab stop (roving tabindex) */
11
- focusValue: string | null
12
- setFocusValue: (value: string) => void
13
- /** the focus holder is focused from the keyboard (draws its ring) */
14
- keyboardFocus: boolean
11
+ /** the tab stop and the focus ring: rows subscribe, the tree does not re-render */
12
+ focus: TreeFocusStore
13
+ /** native: names a branch's chevron control from the item's text */
14
+ expandLabel?: (label: string) => string
15
15
  }
16
16
 
17
17
  export const TreeContext = createContext<TreeContextValue | null>(null)
@@ -0,0 +1,73 @@
1
+ import {useSyncExternalStore} from 'react'
2
+
3
+ /** What a row reads of the tree's focus: nothing, the tab stop, or the tab stop with the ring. */
4
+ export type RowFocus = 'none' | 'tabStop' | 'ringed'
5
+
6
+ /**
7
+ * The tree's focus, outside React state: the item holding the roving tab stop,
8
+ * whether its ring shows (keyboard focus), and a focus request an off-screen
9
+ * row waits on. A row reads only "am I the tab stop / ringed" through
10
+ * `useTreeRowFocus`, so a focus move re-renders the row that lost it and the
11
+ * row that gained it, not the tree.
12
+ */
13
+ export interface TreeFocusStore {
14
+ getValue: () => string | null
15
+ getPending: () => string | null
16
+ /** moves the tab stop */
17
+ setValue: (value: string | null) => void
18
+ setRing: (ring: boolean) => void
19
+ /** moves the tab stop and asks the window to focus the row once it mounts */
20
+ request: (value: string) => void
21
+ clearPending: () => void
22
+ subscribe: (listener: () => void) => () => void
23
+ read: (value: string) => RowFocus
24
+ }
25
+
26
+ export function createTreeFocusStore(initial: string | null): TreeFocusStore {
27
+ let value = initial
28
+ let ring = false
29
+ let pending: string | null = null
30
+ const listeners = new Set<() => void>()
31
+ const emit = () => {
32
+ for (const listener of listeners) listener()
33
+ }
34
+ return {
35
+ getValue: () => value,
36
+ getPending: () => pending,
37
+ setValue: (next) => {
38
+ if (next === value) return
39
+ value = next
40
+ emit()
41
+ },
42
+ setRing: (next) => {
43
+ if (next === ring) return
44
+ ring = next
45
+ emit()
46
+ },
47
+ request: (next) => {
48
+ value = next
49
+ pending = next
50
+ emit()
51
+ },
52
+ clearPending: () => {
53
+ if (pending === null) return
54
+ pending = null
55
+ emit()
56
+ },
57
+ subscribe: (listener) => {
58
+ listeners.add(listener)
59
+ return () => {
60
+ listeners.delete(listener)
61
+ }
62
+ },
63
+ read: (own) => (own !== value ? 'none' : ring ? 'ringed' : 'tabStop'),
64
+ }
65
+ }
66
+
67
+ export function useTreeRowFocus(store: TreeFocusStore, value: string): RowFocus {
68
+ return useSyncExternalStore(store.subscribe, () => store.read(value))
69
+ }
70
+
71
+ export function useTreePendingFocus(store: TreeFocusStore): string | null {
72
+ return useSyncExternalStore(store.subscribe, store.getPending)
73
+ }
@@ -0,0 +1,71 @@
1
+ import {type ReactNode, useId} from 'react'
2
+ import {TreeRow} from './TreeRow'
3
+ import {useTree} from './TreeContext'
4
+ import {useTreeRowFocus} from './TreeFocusStore'
5
+ import type {WindowedRow} from './TreeRowProps'
6
+ import type {OnNavigate} from '../../shared/navigate'
7
+
8
+ interface TreeItemRowProps {
9
+ value: string
10
+ level: number
11
+ label: ReactNode
12
+ meta?: ReactNode
13
+ text: string
14
+ href?: string
15
+ onNavigate?: OnNavigate
16
+ hasChildren: boolean
17
+ open: boolean
18
+ /** the open branch's children (nested tree), none in a windowed one */
19
+ branch: ReactNode
20
+ windowed?: WindowedRow
21
+ }
22
+
23
+ /**
24
+ * One row of the tree with its state: the selection from the tree, and the
25
+ * tab stop and ring from the focus store, which this row alone subscribes to.
26
+ * Shared by the nested `Tree.Item` and the windowed rows.
27
+ */
28
+ export function TreeItemRow({
29
+ value,
30
+ level,
31
+ label,
32
+ meta,
33
+ text,
34
+ href,
35
+ onNavigate,
36
+ hasChildren,
37
+ open,
38
+ branch,
39
+ windowed,
40
+ }: TreeItemRowProps) {
41
+ const {selectedValue, onSelect, toggle, focus, expandLabel} = useTree()
42
+ const rowFocus = useTreeRowFocus(focus, value)
43
+ // named by its own label (and meta) only: a branch's content includes its
44
+ // children, and a name computed from content would read the whole subtree
45
+ const labelId = useId()
46
+ const metaId = useId()
47
+
48
+ return (
49
+ <TreeRow
50
+ value={value}
51
+ level={level}
52
+ label={label}
53
+ meta={meta}
54
+ text={text}
55
+ href={href}
56
+ onNavigate={onNavigate}
57
+ hasChildren={hasChildren}
58
+ open={open}
59
+ selected={selectedValue === value}
60
+ tabStop={rowFocus !== 'none'}
61
+ ringed={rowFocus === 'ringed'}
62
+ labelId={labelId}
63
+ metaId={metaId}
64
+ onSelect={() => onSelect(value)}
65
+ onToggle={() => toggle(value)}
66
+ expandLabel={expandLabel}
67
+ branch={branch}
68
+ windowed={windowed}
69
+ />
70
+ )
71
+ }
@@ -0,0 +1,96 @@
1
+ import {html} from 'react-strict-dom'
2
+ import {SPACING_PX} from '@duro-app/tokens/keys'
3
+ import {styles} from './styles.css'
4
+ import type {TreeRowProps} from './TreeRowProps'
5
+ import {devWarnOnce} from '../../shared/devWarnOnce'
6
+ // Explicit suffix: a native-only module has no web file for moduleSuffixes to pair.
7
+ import {followLink} from '../../shared/followLink.native'
8
+
9
+ /* The native row of a Tree.Item, for both variants (with and without href).
10
+ *
11
+ * The row is a plain View holding two SIBLINGS: a branch's chevron control and
12
+ * the press target. react-strict-dom turns an `html.div` with `onClick` into an
13
+ * accessible Pressable that hides its descendants from VoiceOver, so the
14
+ * chevron can never sit inside the press target. The native `li` and group
15
+ * `ul` take role="none" (RSD defaults them to listitem/list), so nothing is
16
+ * announced twice. Label and meta are each a View container: no View inside a
17
+ * Text. Hook-free: Tree.Item owns the state. */
18
+ export function TreeRow({
19
+ level,
20
+ label,
21
+ meta,
22
+ text,
23
+ href,
24
+ onNavigate,
25
+ hasChildren,
26
+ open,
27
+ selected,
28
+ onSelect,
29
+ onToggle,
30
+ expandLabel,
31
+ branch,
32
+ }: TreeRowProps) {
33
+ if (href !== undefined && onNavigate === undefined) {
34
+ devWarnOnce(
35
+ 'Tree',
36
+ 'href-without-onNavigate',
37
+ 'Tree.Item has an href but no onNavigate: on native a relative href goes to the system (Linking.openURL) and leaves the app. Pass onNavigate to route in-app.',
38
+ )
39
+ }
40
+ const chevronLabel = expandLabel ? expandLabel(text) : text
41
+ if (hasChildren && chevronLabel === '') {
42
+ devWarnOnce(
43
+ 'Tree',
44
+ 'empty-expand-label',
45
+ 'A Tree.Item branch has no text for its expand control: give it a string label or textValue, or Tree.Root an expandLabel.',
46
+ )
47
+ }
48
+
49
+ const press = () => {
50
+ onSelect()
51
+ if (href !== undefined) followLink(href, onNavigate, 'Tree.Item')
52
+ // a branch with no link toggles on its row (a folder), as on the web
53
+ else if (hasChildren) onToggle()
54
+ }
55
+
56
+ return (
57
+ <html.li role="none" style={styles.item}>
58
+ <html.div
59
+ style={[
60
+ styles.nativeRow,
61
+ styles.indentPx(SPACING_PX.sm + Math.max(0, level - 1) * SPACING_PX.lg),
62
+ selected && styles.rowSelected,
63
+ ]}
64
+ >
65
+ {hasChildren ? (
66
+ <html.div
67
+ role="button"
68
+ aria-expanded={open}
69
+ aria-label={chevronLabel}
70
+ onClick={onToggle}
71
+ style={styles.chevronControl}
72
+ >
73
+ <html.span style={[styles.chevronGlyph, open && styles.chevronOpen]}>▸</html.span>
74
+ </html.div>
75
+ ) : null}
76
+ <html.div
77
+ role={href !== undefined ? 'link' : 'button'}
78
+ aria-selected={selected}
79
+ onClick={press}
80
+ style={styles.pressTarget}
81
+ >
82
+ {hasChildren ? null : <html.div style={styles.chevronSpacer} />}
83
+ <html.div style={styles.labelBox}>
84
+ <html.span>{label}</html.span>
85
+ </html.div>
86
+ {meta != null ? <html.div style={styles.metaBox}>{meta}</html.div> : null}
87
+ </html.div>
88
+ </html.div>
89
+ {open ? (
90
+ <html.ul role="none" style={styles.group}>
91
+ {branch}
92
+ </html.ul>
93
+ ) : null}
94
+ </html.li>
95
+ )
96
+ }
@@ -0,0 +1,112 @@
1
+ import {html} from 'react-strict-dom'
2
+ import {styles} from './styles.css'
3
+ import type {TreeRowProps} from './TreeRowProps'
4
+ import {isPlainClick, type LinkClickEvent} from '../../shared/navigate'
5
+
6
+ /* The web row of a Tree.Item: the treeitem `li` with its aria, the row as a
7
+ * real link (href) or a div, the chevron, the label and the meta. Hook-free:
8
+ * Tree.Item owns the state. TreeRow.native.tsx is the native counterpart. */
9
+ export function TreeRow({
10
+ value,
11
+ level,
12
+ label,
13
+ meta,
14
+ text,
15
+ href,
16
+ onNavigate,
17
+ hasChildren,
18
+ open,
19
+ selected,
20
+ tabStop,
21
+ ringed,
22
+ labelId,
23
+ metaId,
24
+ onSelect,
25
+ onToggle,
26
+ branch,
27
+ windowed,
28
+ }: TreeRowProps) {
29
+ const rowStyles = [
30
+ styles.indent(level),
31
+ selected && styles.rowSelected,
32
+ ringed && styles.rowFocused,
33
+ ]
34
+ const rowContent = (
35
+ <>
36
+ <html.span
37
+ aria-hidden
38
+ onClick={
39
+ hasChildren
40
+ ? (e: {stopPropagation: () => void; preventDefault: () => void}) => {
41
+ // the chevron only toggles: it never selects, nor follows a link
42
+ e.stopPropagation()
43
+ e.preventDefault()
44
+ onToggle()
45
+ }
46
+ : undefined
47
+ }
48
+ style={[styles.chevron, open && styles.chevronOpen, !hasChildren && styles.chevronLeaf]}
49
+ >
50
+ ▸
51
+ </html.span>
52
+ <html.span id={labelId} style={styles.label}>
53
+ {label}
54
+ </html.span>
55
+ {meta != null ? (
56
+ <html.span id={metaId} style={styles.meta}>
57
+ {meta}
58
+ </html.span>
59
+ ) : null}
60
+ </>
61
+ )
62
+
63
+ return (
64
+ <html.li
65
+ role="treeitem"
66
+ aria-labelledby={meta != null ? `${labelId} ${metaId}` : labelId}
67
+ aria-level={level}
68
+ aria-expanded={hasChildren ? open : undefined}
69
+ aria-selected={selected}
70
+ aria-setsize={windowed?.setSize}
71
+ aria-posinset={windowed?.posInSet}
72
+ data-index={windowed?.index}
73
+ ref={windowed?.measureRef}
74
+ tabIndex={tabStop ? 0 : -1}
75
+ data-tree-value={value}
76
+ data-tree-text={text}
77
+ style={[styles.item, windowed && styles.windowRow(windowed.top)]}
78
+ >
79
+ {href !== undefined ? (
80
+ <html.a
81
+ href={href}
82
+ tabIndex={-1}
83
+ data-tree-link=""
84
+ onClick={(e: LinkClickEvent) => {
85
+ if (!isPlainClick(e)) return
86
+ onSelect()
87
+ onNavigate?.(href, e)
88
+ }}
89
+ style={[styles.row, styles.rowLink, ...rowStyles]}
90
+ >
91
+ {rowContent}
92
+ </html.a>
93
+ ) : (
94
+ <html.div
95
+ onClick={() => {
96
+ onSelect()
97
+ // a branch with no link toggles on its row (a folder); with a link the row navigates
98
+ if (hasChildren) onToggle()
99
+ }}
100
+ style={[styles.row, ...rowStyles]}
101
+ >
102
+ {rowContent}
103
+ </html.div>
104
+ )}
105
+ {open && !windowed ? (
106
+ <html.ul role="group" style={styles.group}>
107
+ {branch}
108
+ </html.ul>
109
+ ) : null}
110
+ </html.li>
111
+ )
112
+ }
@@ -0,0 +1,42 @@
1
+ import type {ReactNode} from 'react'
2
+ import type {OnNavigate} from '../../shared/navigate'
3
+
4
+ /** What `Tree.Item` hands its row: state is decided there, markup here. */
5
+ export interface TreeRowProps {
6
+ value: string
7
+ level: number
8
+ label: ReactNode
9
+ meta?: ReactNode
10
+ /** plain text of the item (typeahead, native names) */
11
+ text: string
12
+ href?: string
13
+ onNavigate?: OnNavigate
14
+ hasChildren: boolean
15
+ open: boolean
16
+ selected: boolean
17
+ /** the item holds the tree's tab stop */
18
+ tabStop: boolean
19
+ /** draw the keyboard focus ring on the row */
20
+ ringed: boolean
21
+ labelId: string
22
+ metaId: string
23
+ onSelect: () => void
24
+ onToggle: () => void
25
+ /** the chevron control's accessible name (native) */
26
+ expandLabel?: (label: string) => string
27
+ /** the open branch's children, already inside their level context */
28
+ branch: ReactNode
29
+ /** web, windowed tree: the row is flat, placed by the window, and sets its own set size */
30
+ windowed?: WindowedRow
31
+ }
32
+
33
+ /** What a row of a windowed tree adds: the window places and measures it. */
34
+ export interface WindowedRow {
35
+ /** the row's index among the visible rows (the virtualizer's `data-index`) */
36
+ index: number
37
+ setSize: number
38
+ posInSet: number
39
+ /** distance from the top of the scrolled content, in px */
40
+ top: number
41
+ measureRef: (el: HTMLElement | null) => void
42
+ }
@@ -0,0 +1,139 @@
1
+ import {Children, Fragment, isValidElement, type ReactElement, type ReactNode} from 'react'
2
+ import type {OnNavigate} from '../../shared/navigate'
3
+
4
+ /** The props of a `Tree.Item`. */
5
+ export interface TreeItemProps {
6
+ /** unique within the tree */
7
+ value: string
8
+ /** what the row shows */
9
+ label: ReactNode
10
+ /** plain text for typeahead when `label` isn't a string */
11
+ textValue?: string
12
+ /** nested items: the branch's children (none = a leaf) */
13
+ children?: ReactNode
14
+ /** trailing content on the row (a count, a status) */
15
+ meta?: ReactNode
16
+ /**
17
+ * Makes the row a link to this URL. A plain click (or Enter / Space)
18
+ * selects the item and calls `onNavigate`; a modified click keeps the
19
+ * browser default (new tab, new window) and does not select.
20
+ */
21
+ href?: string
22
+ /**
23
+ * Client-side navigation for `href`: called for a plain primary click. Call
24
+ * `event.preventDefault()`, then your router's navigate.
25
+ */
26
+ onNavigate?: OnNavigate
27
+ }
28
+
29
+ /** One visible row of the tree, read from the items without rendering them. */
30
+ export interface TreeRowModel {
31
+ value: string
32
+ level: number
33
+ text: string
34
+ label: ReactNode
35
+ meta: ReactNode
36
+ href: string | undefined
37
+ onNavigate: OnNavigate | undefined
38
+ hasChildren: boolean
39
+ open: boolean
40
+ /** the value of the parent row, null at the top level */
41
+ parent: string | null
42
+ setSize: number
43
+ posInSet: number
44
+ }
45
+
46
+ export interface CollectedRows {
47
+ /** the rows in view order: an open branch is followed by its children */
48
+ rows: TreeRowModel[]
49
+ /** every item of the tree, open or not (what the threshold counts) */
50
+ total: number
51
+ /**
52
+ * false when a branch holds something other than `Tree.Item`s (an item
53
+ * inside another component): the pass cannot see those rows, so the tree
54
+ * must not be windowed, or they would vanish
55
+ */
56
+ complete: boolean
57
+ }
58
+
59
+ /** The plain text of an item: typeahead and names. */
60
+ export function itemText(label: ReactNode, textValue: string | undefined): string {
61
+ return textValue ?? (typeof label === 'string' ? label : '')
62
+ }
63
+
64
+ type ItemElement = ReactElement<TreeItemProps>
65
+
66
+ /** The `Tree.Item` elements among `children`, looking through fragments and arrays. */
67
+ function itemsOf(children: ReactNode, isItem: (el: ReactElement) => boolean): ItemElement[] {
68
+ const out: ItemElement[] = []
69
+ Children.forEach(children, (child) => {
70
+ if (!isValidElement<{children?: ReactNode}>(child)) return
71
+ if (child.type === Fragment) out.push(...itemsOf(child.props.children, isItem))
72
+ else if (isItem(child)) out.push(child as ItemElement)
73
+ })
74
+ return out
75
+ }
76
+
77
+ /**
78
+ * holds-until: a consumer needs to window a tree whose Items sit inside its own
79
+ * components. The pass reads JSX props only, so it cannot see an Item that a
80
+ * component renders; `complete` turns false and Tree renders unwindowed with a
81
+ * dev warning. Then: collect by rendering Items in a register-only mode.
82
+ *
83
+ * The collect pass: walks the `Tree.Item` elements of the tree and returns its
84
+ * visible rows, with each row's level and its set size and position among its
85
+ * siblings. It reads props only: no DOM, no render, no mutation of anything
86
+ * outside this call. Items must be JSX descendants of the root (through
87
+ * fragments or arrays); an item hidden inside another component is not seen.
88
+ */
89
+ export function collectRows(
90
+ children: ReactNode,
91
+ isItem: (el: ReactElement) => boolean,
92
+ isOpen: (value: string) => boolean,
93
+ ): CollectedRows {
94
+ const rows: TreeRowModel[] = []
95
+ let total = 0
96
+ let complete = true
97
+ const visit = (nodes: ReactNode, level: number, parent: string | null, visible: boolean) => {
98
+ const items = itemsOf(nodes, isItem)
99
+ items.forEach((item, index) => {
100
+ const {value, label, textValue, children: nested, meta, href, onNavigate} = item.props
101
+ const hasChildren = Children.count(nested) > 0
102
+ if (hasChildren && countLeaves(nested) !== itemsOf(nested, isItem).length) complete = false
103
+ const open = hasChildren && isOpen(value)
104
+ total += 1
105
+ if (visible) {
106
+ rows.push({
107
+ value,
108
+ level,
109
+ text: itemText(label, textValue),
110
+ label,
111
+ meta,
112
+ href,
113
+ onNavigate,
114
+ hasChildren,
115
+ open,
116
+ parent,
117
+ setSize: items.length,
118
+ posInSet: index + 1,
119
+ })
120
+ }
121
+ if (hasChildren) visit(nested, level + 1, value, visible && open)
122
+ })
123
+ }
124
+ visit(children, 1, null, true)
125
+ if (countLeaves(children) !== itemsOf(children, isItem).length) complete = false
126
+ return {rows, total, complete}
127
+ }
128
+
129
+ /** The non-fragment nodes among `children` (null, false and '' are not counted). */
130
+ function countLeaves(children: ReactNode): number {
131
+ let n = 0
132
+ Children.forEach(children, (child) => {
133
+ if (child == null || typeof child === 'boolean' || child === '') return
134
+ if (isValidElement<{children?: ReactNode}>(child) && child.type === Fragment) {
135
+ n += countLeaves(child.props.children)
136
+ } else n += 1
137
+ })
138
+ return n
139
+ }
@@ -0,0 +1,19 @@
1
+ import type {ReactNode} from 'react'
2
+ import type {Length} from '../../shared/length'
3
+ import type {TreeRowModel} from './TreeRows'
4
+
5
+ /* Native renders every row (windowing is the host app's, plan N0): the web
6
+ * window and its @tanstack/react-virtual stay out of the native graph. */
7
+ export const CAN_WINDOW = false
8
+
9
+ interface TreeWindowProps {
10
+ rows: readonly TreeRowModel[]
11
+ maxHeight?: Length
12
+ 'aria-label'?: string
13
+ 'aria-labelledby'?: string
14
+ scrollRef: unknown
15
+ }
16
+
17
+ export function TreeWindow(_props: TreeWindowProps): ReactNode {
18
+ return null
19
+ }
@@ -0,0 +1,118 @@
1
+ import {defaultRangeExtractor, useVirtualizer, type Range} from '@tanstack/react-virtual'
2
+ import {type RefObject, useEffect} from 'react'
3
+ import {html} from 'react-strict-dom'
4
+ import {SIZES_PX} from '@duro-app/tokens/keys'
5
+ import {resolveLength, type Length} from '../../shared/length'
6
+ import {useTree} from './TreeContext'
7
+ import {useTreePendingFocus} from './TreeFocusStore'
8
+ import {TreeItemRow} from './TreeItemRow'
9
+ import type {TreeRowModel} from './TreeRows'
10
+ import {styles} from './styles.css'
11
+
12
+ export const CAN_WINDOW = true
13
+
14
+ /** Rows rendered beyond each edge of the view. */
15
+ const OVERSCAN = 8
16
+ /** A row's height before it is measured: the touch target, as VirtualList. */
17
+ const ESTIMATED_ROW_PX = SIZES_PX.controlMd
18
+
19
+ interface TreeWindowProps {
20
+ rows: readonly TreeRowModel[]
21
+ maxHeight?: Length
22
+ 'aria-label'?: string
23
+ 'aria-labelledby'?: string
24
+ scrollRef: RefObject<HTMLDivElement | null>
25
+ }
26
+
27
+ /**
28
+ * The windowed tree (web): a scroll container `role="tree"` that mounts only
29
+ * the rows in view, plus the tab stop, as flat treeitems carrying their level,
30
+ * set size and position. The keyboard runs in useTreeKeyboard on the row
31
+ * model; a move to a row that is not mounted asks the focus store, and this
32
+ * window scrolls to it and focuses it once it mounts.
33
+ */
34
+ export function TreeWindow({
35
+ rows,
36
+ maxHeight,
37
+ 'aria-label': ariaLabel,
38
+ 'aria-labelledby': ariaLabelledBy,
39
+ scrollRef,
40
+ }: TreeWindowProps) {
41
+ const {focus} = useTree()
42
+ const pending = useTreePendingFocus(focus)
43
+ const virtualizer = useVirtualizer({
44
+ count: rows.length,
45
+ getScrollElement: () => scrollRef.current,
46
+ estimateSize: () => ESTIMATED_ROW_PX,
47
+ getItemKey: (index) => rows[index]?.value ?? index,
48
+ overscan: OVERSCAN,
49
+ // the tab stop stays mounted wherever the view is, so Tab can always enter
50
+ rangeExtractor: (range: Range) => {
51
+ const indexes = defaultRangeExtractor(range)
52
+ const stop = rows.findIndex((row) => row.value === focus.getValue())
53
+ if (stop < 0 || indexes.includes(stop)) return indexes
54
+ return [...indexes, stop].sort((a, b) => a - b)
55
+ },
56
+ })
57
+
58
+ const target = pending === null ? -1 : rows.findIndex((row) => row.value === pending)
59
+ useEffect(() => {
60
+ if (target >= 0) virtualizer.scrollToIndex(target)
61
+ }, [target, virtualizer])
62
+ // after the scroll the row is mounted (the extractor keeps the tab stop in
63
+ // the window): focus it and answer the request
64
+ useEffect(() => {
65
+ if (pending === null) return
66
+ if (target < 0) {
67
+ focus.clearPending()
68
+ return
69
+ }
70
+ const el = scrollRef.current?.querySelector<HTMLElement>(`[data-index="${target}"]`)
71
+ if (!el) return
72
+ el.focus({preventScroll: true})
73
+ focus.clearPending()
74
+ })
75
+
76
+ const virtualRows = virtualizer.getVirtualItems()
77
+ return (
78
+ <html.div
79
+ ref={scrollRef}
80
+ role="tree"
81
+ aria-label={ariaLabel}
82
+ aria-labelledby={ariaLabelledBy}
83
+ style={[
84
+ styles.windowScroller,
85
+ maxHeight !== undefined && styles.windowScrollerMax(resolveLength(maxHeight)),
86
+ ]}
87
+ >
88
+ <html.div role="none" style={styles.windowTotal(virtualizer.getTotalSize())}>
89
+ {virtualRows.map((virtualRow) => {
90
+ const row = rows[virtualRow.index]
91
+ if (!row) return null
92
+ return (
93
+ <TreeItemRow
94
+ key={row.value}
95
+ value={row.value}
96
+ level={row.level}
97
+ label={row.label}
98
+ meta={row.meta}
99
+ text={row.text}
100
+ href={row.href}
101
+ onNavigate={row.onNavigate}
102
+ hasChildren={row.hasChildren}
103
+ open={row.open}
104
+ branch={null}
105
+ windowed={{
106
+ index: virtualRow.index,
107
+ setSize: row.setSize,
108
+ posInSet: row.posInSet,
109
+ top: virtualRow.start,
110
+ measureRef: virtualizer.measureElement,
111
+ }}
112
+ />
113
+ )
114
+ })}
115
+ </html.div>
116
+ </html.div>
117
+ )
118
+ }