@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,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
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
+
}
|