@duro-app/ui 5.18.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
@@ -30,6 +30,37 @@ export const styles = css.create({
30
30
  outlineWidth: 0,
31
31
  outlineStyle: 'none',
32
32
  },
33
+ // the scroll container of a windowed tree (role="tree"): fills its parent,
34
+ // or stops at `maxHeight`
35
+ windowScroller: {
36
+ position: 'relative',
37
+ width: '100%',
38
+ height: '100%',
39
+ minHeight: 0,
40
+ margin: 0,
41
+ padding: 0,
42
+ listStyleType: 'none',
43
+ overflowY: 'auto',
44
+ overscrollBehavior: 'contain',
45
+ },
46
+ windowScrollerMax: (maxHeight: number | string) => ({
47
+ height: 'auto',
48
+ maxHeight,
49
+ }),
50
+ // the full virtual height, so the scrollbar is the tree's real length
51
+ windowTotal: (height: number) => ({
52
+ position: 'relative',
53
+ width: '100%',
54
+ height,
55
+ }),
56
+ // a windowed row: flat, placed by the window
57
+ windowRow: (top: number) => ({
58
+ position: 'absolute',
59
+ top: 0,
60
+ left: 0,
61
+ width: '100%',
62
+ transform: `translateY(${top}px)`,
63
+ }),
33
64
  row: {
34
65
  display: 'flex',
35
66
  alignItems: 'center',
@@ -73,9 +104,57 @@ export const styles = css.create({
73
104
  fontWeight: typography.fontWeightMedium,
74
105
  backgroundColor: colors.bgCardHover,
75
106
  },
76
- // RSD on native lays out only display: flex, not the web's inline-flex.
77
- nativeFlex: {
107
+ // Native: the outer row View, holding the chevron control and the press
108
+ // target as siblings (the level indent is added by the row).
109
+ nativeRow: {
78
110
  display: 'flex',
111
+ flexDirection: 'row',
112
+ alignItems: 'center',
113
+ paddingRight: spacing.sm,
114
+ borderRadius: radii.sm,
115
+ },
116
+ // Native: the pressable part of the row.
117
+ pressTarget: {
118
+ display: 'flex',
119
+ flexDirection: 'row',
120
+ alignItems: 'center',
121
+ flexGrow: 1,
122
+ flexShrink: 1,
123
+ gap: spacing.xs,
124
+ paddingTop: microSpacing.px5,
125
+ paddingBottom: microSpacing.px5,
126
+ borderRadius: radii.sm,
127
+ backgroundColor: {
128
+ default: 'transparent',
129
+ ':active': colors.bgCardHover,
130
+ },
131
+ },
132
+ // Native: the branch's chevron control, at least the WCAG 2.5.8 minimum.
133
+ chevronControl: {
134
+ display: 'flex',
135
+ alignItems: 'center',
136
+ justifyContent: 'center',
137
+ minWidth: sizes.chip,
138
+ minHeight: sizes.chip,
139
+ flexShrink: 0,
140
+ },
141
+ chevronGlyph: {
142
+ color: colors.textMuted,
143
+ },
144
+ // Native: a leaf's gutter, as wide as the chevron control, inside the press target.
145
+ chevronSpacer: {
146
+ width: sizes.chip,
147
+ flexShrink: 0,
148
+ },
149
+ labelBox: {
150
+ display: 'flex',
151
+ flexGrow: 1,
152
+ flexShrink: 1,
153
+ minWidth: 0,
154
+ },
155
+ metaBox: {
156
+ display: 'flex',
157
+ flexShrink: 0,
79
158
  },
80
159
  chevron: {
81
160
  display: 'inline-flex',
@@ -1,31 +1,70 @@
1
- import {type ReactNode, useEffect, useLayoutEffect, useRef} from 'react'
1
+ import {type RefObject, useEffect, useLayoutEffect, useRef} from 'react'
2
+ import type {TreeFocusStore} from './TreeFocusStore'
3
+ import type {TreeRowModel} from './TreeRows'
2
4
 
3
5
  /** Typed characters within this many ms build one typeahead prefix. */
4
6
  const TYPEAHEAD_RESET_MS = 500
5
7
 
6
8
  interface TreeKeyboardInput {
7
- rootRef: React.RefObject<HTMLUListElement | null>
8
- focusValue: string | null
9
- setFocusValue: (value: string | null) => void
10
- setKeyboardFocus: (on: boolean) => void
9
+ rootRef: RefObject<HTMLElement | null>
10
+ focus: TreeFocusStore
11
+ /** the visible rows when the tree is windowed (most are not in the DOM), else null */
12
+ windowedRows: readonly TreeRowModel[] | null
13
+ /** a change here re-checks the tab stop: the items or the open branches */
14
+ children: unknown
11
15
  open: ReadonlyArray<string>
12
- children: ReactNode
13
16
  onSelect: (value: string) => void
14
17
  setExpanded: (value: string, open: boolean) => void
15
18
  }
16
19
 
20
+ /** What navigation needs of an item, from the row model or from the DOM. */
21
+ interface NavItem {
22
+ value: string
23
+ text: string
24
+ /** undefined for a leaf */
25
+ expanded: boolean | undefined
26
+ parent: string | null
27
+ }
28
+
29
+ const itemSelector = (value: string) => `[data-tree-value="${CSS.escape(value)}"]`
30
+
31
+ /** The rendered treeitems as nav items: DOM order IS the visible order. */
32
+ function readDom(root: HTMLElement): NavItem[] {
33
+ return Array.from(root.querySelectorAll<HTMLElement>('[role="treeitem"]')).map((el) => {
34
+ const expanded = el.getAttribute('aria-expanded')
35
+ const parent = el.parentElement?.closest<HTMLElement>('[role="treeitem"]')
36
+ return {
37
+ value: el.dataset.treeValue ?? '',
38
+ text: el.dataset.treeText ?? '',
39
+ expanded: expanded === null ? undefined : expanded === 'true',
40
+ parent: parent && root.contains(parent) ? (parent.dataset.treeValue ?? null) : null,
41
+ }
42
+ })
43
+ }
44
+
45
+ function readRows(rows: readonly TreeRowModel[]): NavItem[] {
46
+ return rows.map((row) => ({
47
+ value: row.value,
48
+ text: row.text,
49
+ expanded: row.hasChildren ? row.open : undefined,
50
+ parent: row.parent,
51
+ }))
52
+ }
53
+
17
54
  /**
18
55
  * Web: the tree's roving tab stop, arrow keys, Home/End, Enter and typeahead
19
- * (WAI-ARIA tree pattern), read from the rendered DOM. Native has none of
20
- * them (plan N0, Decision 6): useTreeKeyboard.native.ts.
56
+ * (WAI-ARIA tree pattern). A nested tree reads its items from the DOM; a
57
+ * windowed one from the row model, since most rows are not mounted: a move to
58
+ * a row that is not mounted goes through the focus store, and the window
59
+ * scrolls to it and focuses it. Native has none of them (plan N0, Decision 6):
60
+ * useTreeKeyboard.native.ts.
21
61
  */
22
62
  export function useTreeKeyboard({
23
63
  rootRef,
24
- focusValue,
25
- setFocusValue,
26
- setKeyboardFocus,
27
- open,
64
+ focus,
65
+ windowedRows,
28
66
  children,
67
+ open,
29
68
  onSelect,
30
69
  setExpanded,
31
70
  }: TreeKeyboardInput) {
@@ -34,32 +73,33 @@ export function useTreeKeyboard({
34
73
  useLayoutEffect(() => {
35
74
  const root = rootRef.current
36
75
  if (!root) return
37
- const tabbable = root.querySelector('[role="treeitem"][tabindex="0"]')
38
- if (tabbable) return
39
- const first = root.querySelector<HTMLElement>('[role="treeitem"]')
40
- const next = first?.dataset.treeValue ?? null
41
- if (next !== focusValue) setFocusValue(next)
42
- }, [rootRef, focusValue, setFocusValue, open, children])
76
+ if (root.querySelector('[role="treeitem"][tabindex="0"]')) return
77
+ const first =
78
+ windowedRows?.[0]?.value ??
79
+ root.querySelector<HTMLElement>('[role="treeitem"]')?.dataset.treeValue ??
80
+ null
81
+ focus.setValue(first)
82
+ }, [rootRef, focus, windowedRows, children, open])
43
83
 
44
- // Keyboard: DOM order over the rendered items IS the visible order (a
45
- // closed branch renders no children), so navigation reads it directly.
46
84
  const typeahead = useRef({text: '', at: 0})
47
85
  useEffect(() => {
48
86
  const root = rootRef.current
49
87
  if (!root) return
50
88
  const rootEl = root
51
- const items = () => Array.from(rootEl.querySelectorAll<HTMLElement>('[role="treeitem"]'))
52
- const focusItem = (el: HTMLElement | null | undefined) => {
53
- if (!el) return
54
- el.focus()
89
+ const read = () => (windowedRows ? readRows(windowedRows) : readDom(rootEl))
90
+ const focusItem = (item: NavItem | undefined) => {
91
+ if (!item) return
92
+ const el = rootEl.querySelector<HTMLElement>(itemSelector(item.value))
93
+ if (el) el.focus()
94
+ else focus.request(item.value)
55
95
  }
56
96
  function onKeyDown(e: KeyboardEvent) {
57
97
  const current = (e.target as HTMLElement).closest<HTMLElement>('[role="treeitem"]')
58
98
  if (!current || !rootEl.contains(current)) return
59
- const list = items()
60
- const i = list.indexOf(current)
99
+ const list = read()
61
100
  const val = current.dataset.treeValue ?? ''
62
- const isOpen = current.getAttribute('aria-expanded')
101
+ const i = list.findIndex((item) => item.value === val)
102
+ const here = list[i]
63
103
  switch (e.key) {
64
104
  case 'ArrowDown':
65
105
  e.preventDefault()
@@ -71,17 +111,16 @@ export function useTreeKeyboard({
71
111
  return
72
112
  case 'ArrowRight':
73
113
  e.preventDefault()
74
- if (isOpen === 'false') setExpanded(val, true)
75
- else if (isOpen === 'true') focusItem(list[i + 1])
114
+ if (here?.expanded === false) setExpanded(val, true)
115
+ else if (here?.expanded === true) focusItem(list[i + 1])
76
116
  return
77
117
  case 'ArrowLeft': {
78
118
  e.preventDefault()
79
- if (isOpen === 'true') {
119
+ if (here?.expanded === true) {
80
120
  setExpanded(val, false)
81
121
  return
82
122
  }
83
- const parent = current.parentElement?.closest<HTMLElement>('[role="treeitem"]')
84
- if (parent && rootEl.contains(parent)) focusItem(parent)
123
+ focusItem(list.find((item) => item.value === here?.parent))
85
124
  return
86
125
  }
87
126
  case 'Home':
@@ -99,7 +138,12 @@ export function useTreeKeyboard({
99
138
  // runs onNavigate, or the browser navigates)
100
139
  const link = current.querySelector<HTMLElement>(':scope > [data-tree-link]')
101
140
  if (link) link.click()
102
- else onSelect(val)
141
+ else {
142
+ onSelect(val)
143
+ // a branch with no link toggles too, as a click on its row does
144
+ const expanded = current.getAttribute('aria-expanded')
145
+ if (expanded !== null) setExpanded(val, expanded !== 'true')
146
+ }
103
147
  return
104
148
  }
105
149
  }
@@ -116,9 +160,9 @@ export function useTreeKeyboard({
116
160
  const needle = repeat ? typed[0] : typed
117
161
  const start = repeat ? i + 1 : i
118
162
  for (let k = 0; k < list.length; k++) {
119
- const el = list[(start + k) % list.length]
120
- if ((el.dataset.treeText ?? '').toLocaleLowerCase().startsWith(needle)) {
121
- focusItem(el)
163
+ const item = list[(start + k) % list.length]
164
+ if (item.text.toLocaleLowerCase().startsWith(needle)) {
165
+ focusItem(item)
122
166
  break
123
167
  }
124
168
  }
@@ -128,14 +172,14 @@ export function useTreeKeyboard({
128
172
  function onFocusIn(e: FocusEvent) {
129
173
  const item = (e.target as HTMLElement).closest<HTMLElement>('[role="treeitem"]')
130
174
  const v = item?.dataset.treeValue
131
- if (v) setFocusValue(v)
132
- setKeyboardFocus(item?.matches(':focus-visible') ?? false)
175
+ if (v) focus.setValue(v)
176
+ focus.setRing(item?.matches(':focus-visible') ?? false)
133
177
  }
134
178
  function onFocusOut(e: FocusEvent) {
135
- if (!rootEl.contains(e.relatedTarget as Node | null)) setKeyboardFocus(false)
179
+ if (!rootEl.contains(e.relatedTarget as Node | null)) focus.setRing(false)
136
180
  }
137
- const onPointerDown = () => setKeyboardFocus(false)
138
- const onKey = () => setKeyboardFocus(true)
181
+ const onPointerDown = () => focus.setRing(false)
182
+ const onKey = () => focus.setRing(true)
139
183
  root.addEventListener('keydown', onKeyDown)
140
184
  root.addEventListener('keydown', onKey)
141
185
  root.addEventListener('focusin', onFocusIn)
@@ -148,5 +192,5 @@ export function useTreeKeyboard({
148
192
  root.removeEventListener('focusout', onFocusOut)
149
193
  root.removeEventListener('pointerdown', onPointerDown)
150
194
  }
151
- }, [rootRef, onSelect, setExpanded, setFocusValue, setKeyboardFocus])
195
+ }, [rootRef, focus, windowedRows, onSelect, setExpanded])
152
196
  }
@@ -24,6 +24,7 @@ export {Heading, type HeadingVariant} from './components/Heading/Heading'
24
24
  export {Input, type InputVariant} from './components/Input/Input'
25
25
  export {Switch} from './components/Switch/Switch'
26
26
  export {Text, type TextVariant, type TextColor} from './components/Text/Text'
27
+ export {TextLink, type TextLinkVariant} from './components/TextLink/TextLink'
27
28
  export {Textarea, type TextareaVariant} from './components/Textarea/Textarea'
28
29
 
29
30
  // Form field wrappers (RN-safe — used by Input/Textarea/Checkbox)
@@ -95,11 +96,14 @@ export {
95
96
 
96
97
  // Layout primitives
97
98
  export {PageShell, type PageShellMaxWidth, type PageShellPadding} from './components/PageShell'
99
+ export {Panel} from './components/Panel/Panel'
98
100
  export {Stack, type SpacingKey} from './components/Stack/Stack'
99
101
  export {Inline} from './components/Inline/Inline'
100
102
  export {Cluster} from './components/Cluster/Cluster'
101
103
  export {Grid} from './components/Grid/Grid'
102
104
  export type {GridTrack} from './shared/length'
105
+ // ADR-0002: the shape a link part's onNavigate receives (native: followLink)
106
+ export type {LinkClickEvent, OnNavigate} from './shared/navigate'
103
107
 
104
108
  // Theming — resolves to ThemeProvider.native.tsx on Metro (RN-safe variant:
105
109
  // no display:contents / portal mount; applies the theme to a flex container).
@@ -0,0 +1,8 @@
1
+ import {createContext} from 'react'
2
+
3
+ /**
4
+ * True inside `AppShell.Rail`, which is already a named navigation landmark.
5
+ * A `SideNav.Root` reads it and renders no second navigation landmark: a plain
6
+ * block, or `role="group"` carrying its `aria-label` when it has one.
7
+ */
8
+ export const InLandmarkContext = createContext(false)
@@ -0,0 +1,33 @@
1
+ import {Linking} from 'react-native'
2
+ import type {LinkClickEvent, OnNavigate} from './navigate'
3
+
4
+ /**
5
+ * Follows a link on native (ADR-0002, native addendum): a native link part
6
+ * renders no `href`, because react-strict-dom replaces the press handler of an
7
+ * `html.a` that has one. It builds a tracked `LinkClickEvent` (primary button,
8
+ * no modifier), hands it to `onNavigate`, and opens the URL with the system
9
+ * (`Linking.openURL`) unless `onNavigate` called `event.preventDefault()`.
10
+ * `source` names the part in the warning when the URL cannot be opened.
11
+ */
12
+ export function followLink(href: string, onNavigate: OnNavigate | undefined, source: string): void {
13
+ let prevented = false
14
+ const event: LinkClickEvent = {
15
+ button: 0,
16
+ metaKey: false,
17
+ ctrlKey: false,
18
+ shiftKey: false,
19
+ altKey: false,
20
+ get defaultPrevented() {
21
+ return prevented
22
+ },
23
+ preventDefault() {
24
+ prevented = true
25
+ },
26
+ }
27
+ onNavigate?.(href, event)
28
+ if (!prevented) {
29
+ Linking.openURL(href).catch((error: unknown) => {
30
+ console.warn(`[duro] ${source}: could not open ${href}: ${String(error)}`)
31
+ })
32
+ }
33
+ }
@@ -1,4 +1,6 @@
1
1
  /* Router-agnostic links (docs/adr/0002-link-parts-navigate-through-onnavigate.md).
2
+ * On native a link part renders no `href` and goes through `followLink`
3
+ * (followLink.native.ts), which calls the same `onNavigate` (ADR-0002 addendum).
2
4
  *
3
5
  * Every link part renders a real `<a href>`, so the browser keeps what a link
4
6
  * does on its own: cmd/ctrl-click and middle-click open a tab, shift-click a