@duro-app/ui 5.13.0 → 5.15.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 (128) hide show
  1. package/README.md +49 -0
  2. package/dist/{Table-Bms2ftt3.js → Table-DnaoH-h5.js} +747 -635
  3. package/dist/Table-DnaoH-h5.js.map +1 -0
  4. package/dist/components/Button/Button.d.ts.map +1 -1
  5. package/dist/components/ColorMode/ColorMode.d.ts +2 -1
  6. package/dist/components/ColorMode/ColorMode.d.ts.map +1 -1
  7. package/dist/components/ColorMode/storage.d.ts +4 -0
  8. package/dist/components/ColorMode/storage.d.ts.map +1 -0
  9. package/dist/components/ColorMode/styles.css.d.ts +4 -0
  10. package/dist/components/ColorMode/styles.css.d.ts.map +1 -1
  11. package/dist/components/ColorMode/useSystemScheme.d.ts +7 -0
  12. package/dist/components/ColorMode/useSystemScheme.d.ts.map +1 -0
  13. package/dist/components/Combobox/Combobox.d.ts +7 -2
  14. package/dist/components/Combobox/Combobox.d.ts.map +1 -1
  15. package/dist/components/Combobox/comboboxState.d.ts +29 -0
  16. package/dist/components/Combobox/comboboxState.d.ts.map +1 -0
  17. package/dist/components/Combobox/native.css.d.ts +27 -0
  18. package/dist/components/Combobox/native.css.d.ts.map +1 -0
  19. package/dist/components/Combobox/useComboboxRoot.d.ts +6 -10
  20. package/dist/components/Combobox/useComboboxRoot.d.ts.map +1 -1
  21. package/dist/components/Drawer/Drawer.d.ts +5 -2
  22. package/dist/components/Drawer/Drawer.d.ts.map +1 -1
  23. package/dist/components/Drawer/DrawerSnap.stories.d.ts +2 -0
  24. package/dist/components/Drawer/DrawerSnap.stories.d.ts.map +1 -1
  25. package/dist/components/Drawer/native.css.d.ts +4 -0
  26. package/dist/components/Drawer/native.css.d.ts.map +1 -1
  27. package/dist/components/Drawer/styles.css.d.ts +4 -0
  28. package/dist/components/Drawer/styles.css.d.ts.map +1 -1
  29. package/dist/components/Form/FormSubmitContext.d.ts +8 -0
  30. package/dist/components/Form/FormSubmitContext.d.ts.map +1 -0
  31. package/dist/components/Listbox/native.css.d.ts +84 -0
  32. package/dist/components/Listbox/native.css.d.ts.map +1 -0
  33. package/dist/components/QrCode/QrCode.d.ts +8 -0
  34. package/dist/components/QrCode/QrCode.d.ts.map +1 -0
  35. package/dist/components/QrCode/QrCode.meta.d.ts +3 -0
  36. package/dist/components/QrCode/QrCode.meta.d.ts.map +1 -0
  37. package/dist/components/QrCode/QrCode.stories.d.ts +8 -0
  38. package/dist/components/QrCode/QrCode.stories.d.ts.map +1 -0
  39. package/dist/components/QrCode/geometry.d.ts +14 -0
  40. package/dist/components/QrCode/geometry.d.ts.map +1 -0
  41. package/dist/components/QrCode/styles.css.d.ts +9 -0
  42. package/dist/components/QrCode/styles.css.d.ts.map +1 -0
  43. package/dist/components/QrCode/types.d.ts +7 -0
  44. package/dist/components/QrCode/types.d.ts.map +1 -0
  45. package/dist/components/Select/Select.d.ts +9 -2
  46. package/dist/components/Select/Select.d.ts.map +1 -1
  47. package/dist/components/Select/Select.stories.d.ts +11 -0
  48. package/dist/components/Select/Select.stories.d.ts.map +1 -1
  49. package/dist/components/Select/native.css.d.ts +11 -0
  50. package/dist/components/Select/native.css.d.ts.map +1 -0
  51. package/dist/components/Select/selectState.d.ts +21 -0
  52. package/dist/components/Select/selectState.d.ts.map +1 -0
  53. package/dist/components/Select/styles.css.d.ts +10 -0
  54. package/dist/components/Select/styles.css.d.ts.map +1 -1
  55. package/dist/components/Select/useSelectRoot.d.ts +6 -8
  56. package/dist/components/Select/useSelectRoot.d.ts.map +1 -1
  57. package/dist/components/ThemeProvider/ThemeOverrides.stories.d.ts +13 -0
  58. package/dist/components/ThemeProvider/ThemeOverrides.stories.d.ts.map +1 -0
  59. package/dist/components/ThemeProvider/ThemeProvider.d.ts +9 -2
  60. package/dist/components/ThemeProvider/ThemeProvider.d.ts.map +1 -1
  61. package/dist/components/ThemeProvider/overrideRule.d.ts +7 -0
  62. package/dist/components/ThemeProvider/overrideRule.d.ts.map +1 -0
  63. package/dist/components/ThemeProvider/overrides.d.ts +16 -0
  64. package/dist/components/ThemeProvider/overrides.d.ts.map +1 -0
  65. package/dist/index.css +1 -1
  66. package/dist/index.d.ts +2 -1
  67. package/dist/index.d.ts.map +1 -1
  68. package/dist/index.js +6230 -4656
  69. package/dist/index.js.map +1 -1
  70. package/dist/shared/textOf.d.ts +7 -0
  71. package/dist/shared/textOf.d.ts.map +1 -0
  72. package/dist/table.js +1 -1
  73. package/package.json +5 -2
  74. package/src/components/Button/Button.tsx +12 -1
  75. package/src/components/ColorMode/ColorMode.meta.ts +1 -1
  76. package/src/components/ColorMode/ColorMode.tsx +16 -37
  77. package/src/components/ColorMode/storage.native.ts +8 -0
  78. package/src/components/ColorMode/storage.ts +19 -0
  79. package/src/components/ColorMode/styles.css.ts +6 -0
  80. package/src/components/ColorMode/useSystemScheme.native.ts +6 -0
  81. package/src/components/ColorMode/useSystemScheme.ts +19 -0
  82. package/src/components/Combobox/Combobox.meta.ts +1 -1
  83. package/src/components/Combobox/Combobox.native.tsx +295 -0
  84. package/src/components/Combobox/Combobox.tsx +14 -8
  85. package/src/components/Combobox/comboboxState.ts +107 -0
  86. package/src/components/Combobox/native.css.ts +33 -0
  87. package/src/components/Combobox/useComboboxRoot.ts +17 -91
  88. package/src/components/Drawer/Drawer.meta.ts +1 -1
  89. package/src/components/Drawer/Drawer.native.tsx +29 -7
  90. package/src/components/Drawer/Drawer.tsx +11 -2
  91. package/src/components/Drawer/DrawerSnap.stories.tsx +22 -2
  92. package/src/components/Drawer/native.css.ts +4 -0
  93. package/src/components/Drawer/styles.css.ts +4 -0
  94. package/src/components/Form/Form.meta.ts +1 -1
  95. package/src/components/Form/Form.native.tsx +120 -0
  96. package/src/components/Form/FormSubmitContext.ts +12 -0
  97. package/src/components/Listbox/Listbox.meta.ts +1 -1
  98. package/src/components/Listbox/Listbox.native.tsx +153 -0
  99. package/src/components/Listbox/native.css.ts +91 -0
  100. package/src/components/QrCode/QrCode.meta.ts +18 -0
  101. package/src/components/QrCode/QrCode.native.tsx +31 -0
  102. package/src/components/QrCode/QrCode.stories.tsx +44 -0
  103. package/src/components/QrCode/QrCode.tsx +30 -0
  104. package/src/components/QrCode/geometry.ts +34 -0
  105. package/src/components/QrCode/styles.css.ts +12 -0
  106. package/src/components/QrCode/types.ts +6 -0
  107. package/src/components/ScrollArea/ScrollArea.meta.ts +1 -1
  108. package/src/components/ScrollArea/ScrollArea.native.tsx +61 -0
  109. package/src/components/Select/Select.meta.ts +1 -1
  110. package/src/components/Select/Select.native.tsx +224 -0
  111. package/src/components/Select/Select.stories.tsx +49 -1
  112. package/src/components/Select/Select.tsx +20 -3
  113. package/src/components/Select/native.css.ts +13 -0
  114. package/src/components/Select/selectState.ts +36 -0
  115. package/src/components/Select/styles.css.ts +11 -0
  116. package/src/components/Select/useSelectRoot.ts +9 -30
  117. package/src/components/ThemeProvider/ThemeOverrides.stories.tsx +113 -0
  118. package/src/components/ThemeProvider/ThemeProvider.meta.ts +1 -1
  119. package/src/components/ThemeProvider/ThemeProvider.native.tsx +36 -7
  120. package/src/components/ThemeProvider/ThemeProvider.tsx +42 -6
  121. package/src/components/ThemeProvider/overrideRule.ts +39 -0
  122. package/src/components/ThemeProvider/overrides.ts +27 -0
  123. package/src/form.native.ts +7 -0
  124. package/src/index.native.ts +16 -0
  125. package/src/index.ts +2 -0
  126. package/src/shared/focusInput.native.ts +18 -0
  127. package/src/shared/textOf.ts +13 -0
  128. package/dist/Table-Bms2ftt3.js.map +0 -1
@@ -0,0 +1,107 @@
1
+ import {useCallback, useEffect, useState} from 'react'
2
+ import {useControllableValue} from '../../hooks/useControllableValue'
3
+
4
+ export interface ComboboxStateOptions {
5
+ defaultValue?: string
6
+ value?: string
7
+ onValueChange?: (value: string | null) => void
8
+ inputValue?: string
9
+ onInputChange?: (value: string) => void
10
+ initialLabels?: Record<string, string>
11
+ }
12
+
13
+ /** Whether an option's label matches what was typed: a case-insensitive substring. */
14
+ export function matchesQuery(label: string, query: string): boolean {
15
+ return !query || label.toLowerCase().includes(query.toLowerCase())
16
+ }
17
+
18
+ /**
19
+ * The Combobox's state both platforms share (plan N0, Decision 10): the value
20
+ * and the typed text (each controlled or not), whether it is open, each
21
+ * option's label, choosing an option, and the text/value reconcile on close.
22
+ * Highlight, document order, keys and DOM focus are the web adapter's
23
+ * (useComboboxRoot); the sheet and its pinned input are Combobox.native's.
24
+ */
25
+ export function useComboboxState({
26
+ defaultValue,
27
+ value: controlledValue,
28
+ onValueChange,
29
+ inputValue: controlledInputValue,
30
+ onInputChange,
31
+ initialLabels,
32
+ }: ComboboxStateOptions) {
33
+ const [value, setValue] = useControllableValue<string | null>(
34
+ controlledValue,
35
+ defaultValue ?? null,
36
+ onValueChange,
37
+ )
38
+ // Seed the visible text from the initially-selected value's label, so a
39
+ // defaultValue/value shows its label on mount — and so the reconcile effect
40
+ // below doesn't see "value set but input empty" and wipe the selection.
41
+ const [internalInputValue, setInternalInputValue] = useState<string>(() => {
42
+ const initial = controlledValue ?? defaultValue
43
+ return initial ? (initialLabels?.[initial] ?? '') : ''
44
+ })
45
+ const inputValue = controlledInputValue !== undefined ? controlledInputValue : internalInputValue
46
+ const setInputValue = useCallback(
47
+ (v: string) => {
48
+ if (controlledInputValue === undefined) setInternalInputValue(v)
49
+ onInputChange?.(v)
50
+ },
51
+ [controlledInputValue, onInputChange],
52
+ )
53
+
54
+ const [open, setOpen] = useState(false)
55
+ const [labels, setLabels] = useState<Record<string, string>>(initialLabels ?? {})
56
+
57
+ const registerLabel = useCallback((v: string, label: string) => {
58
+ setLabels((prev) => (prev[v] === label ? prev : {...prev, [v]: label}))
59
+ }, [])
60
+
61
+ /** Choose an option: commit it, show its label, close. */
62
+ const chooseValue = useCallback(
63
+ (itemValue: string) => {
64
+ setValue(itemValue)
65
+ setInputValue(labels[itemValue] ?? itemValue)
66
+ setOpen(false)
67
+ },
68
+ [setValue, labels, setInputValue],
69
+ )
70
+
71
+ // Reconcile input text with the committed value once the popup closes.
72
+ // Three cases when transitioning from open → closed:
73
+ // - inputValue is empty: user cleared the field, treat that as
74
+ // "remove the selection" so the consumer's onValueChange fires
75
+ // with null and downstream UI (e.g. dependent fields) can reset.
76
+ // - inputValue doesn't match the selected label: user typed something
77
+ // but didn't pick anything; restore the label so the visible text
78
+ // and the committed value stay consistent.
79
+ // - inputValue equals the selected label: nothing to do.
80
+ useEffect(() => {
81
+ if (open) return
82
+ // Treat an empty value (null OR '') as "no selection" — nothing to
83
+ // reconcile. Without this, a controlled value="" (e.g. a table column
84
+ // filter) fires setValue(null) every render → inline onValueChange →
85
+ // parent state change → re-render → infinite "Maximum update depth" loop.
86
+ if (!value) return
87
+ if (inputValue === '') {
88
+ setValue(null)
89
+ return
90
+ }
91
+ if (labels[value] && inputValue !== labels[value]) {
92
+ setInputValue(labels[value])
93
+ }
94
+ }, [open, value, labels, inputValue, setInputValue, setValue])
95
+
96
+ return {
97
+ value,
98
+ setValue,
99
+ inputValue,
100
+ setInputValue,
101
+ open,
102
+ setOpen,
103
+ labels,
104
+ registerLabel,
105
+ chooseValue,
106
+ }
107
+ }
@@ -0,0 +1,33 @@
1
+ import {css} from 'react-strict-dom'
2
+ import {colors} from '@duro-app/tokens/tokens/colors.css'
3
+ import {spacing} from '@duro-app/tokens/tokens/spacing.css'
4
+ import {typography} from '@duro-app/tokens/tokens/typography.css'
5
+
6
+ /** Native Combobox additions to the shared styles: the closed field. */
7
+ export const nativeStyles = css.create({
8
+ // RSD on native lays out only display: flex, not the web's inline-flex.
9
+ nativeFlex: {
10
+ display: 'flex',
11
+ },
12
+ // The closed field: the web input's padding, a down chevron at its end.
13
+ field: {
14
+ display: 'flex',
15
+ flexDirection: 'row',
16
+ paddingTop: spacing.sm,
17
+ paddingBottom: spacing.sm,
18
+ paddingRight: spacing.sm,
19
+ },
20
+ fieldText: {
21
+ color: colors.text,
22
+ fontFamily: typography.fontFamily,
23
+ fontSize: typography.fontSizeSm,
24
+ },
25
+ // SideNav's chevron points right; a picker's points down.
26
+ chevronDown: {
27
+ display: 'flex',
28
+ transform: 'rotate(90deg)',
29
+ },
30
+ fieldPlaceholder: {
31
+ color: colors.textMuted,
32
+ },
33
+ })
@@ -1,47 +1,14 @@
1
1
  import {useState, useCallback, useMemo, useRef, useId, useEffect} from 'react'
2
- import {useControllableValue} from '../../hooks/useControllableValue'
3
2
  import type {ComboboxContextValue} from './ComboboxContext'
4
-
5
- interface UseComboboxRootOptions {
6
- defaultValue?: string
7
- value?: string
8
- onValueChange?: (value: string | null) => void
9
- inputValue?: string
10
- onInputChange?: (value: string) => void
11
- initialLabels?: Record<string, string>
12
- }
13
-
14
- export function useComboboxRoot({
15
- defaultValue,
16
- value: controlledValue,
17
- onValueChange,
18
- inputValue: controlledInputValue,
19
- onInputChange,
20
- initialLabels,
21
- }: UseComboboxRootOptions) {
22
- const [value, setValue] = useControllableValue<string | null>(
23
- controlledValue,
24
- defaultValue ?? null,
25
- onValueChange,
26
- )
27
- // Seed the visible text from the initially-selected value's label, so a
28
- // defaultValue/value shows its label on mount — and so the reconcile effect
29
- // below doesn't see "value set but input empty" and wipe the selection.
30
- const [internalInputValue, setInternalInputValue] = useState<string>(() => {
31
- const initial = controlledValue ?? defaultValue
32
- return initial ? (initialLabels?.[initial] ?? '') : ''
33
- })
34
- const inputValue = controlledInputValue !== undefined ? controlledInputValue : internalInputValue
35
- const setInputValue = useCallback(
36
- (v: string) => {
37
- if (controlledInputValue === undefined) setInternalInputValue(v)
38
- onInputChange?.(v)
39
- },
40
- [controlledInputValue, onInputChange],
41
- )
42
-
43
- const [open, setOpen] = useState(false)
44
- const [labels, setLabels] = useState<Record<string, string>>(initialLabels ?? {})
3
+ import {matchesQuery, useComboboxState, type ComboboxStateOptions} from './comboboxState'
4
+
5
+ /**
6
+ * The web adapter over the shared Combobox state: the highlighted option,
7
+ * document order, keys and DOM focus (the WAI-ARIA editable combobox).
8
+ */
9
+ export function useComboboxRoot(options: ComboboxStateOptions) {
10
+ const {value, inputValue, setInputValue, open, setOpen, labels, registerLabel, chooseValue} =
11
+ useComboboxState(options)
45
12
  const [highlightedId, setHighlightedId] = useState<string | null>(null)
46
13
  const listboxId = useId()
47
14
  const rootRef = useRef<HTMLDivElement>(null)
@@ -52,7 +19,7 @@ export function useComboboxRoot({
52
19
  const close = useCallback(() => {
53
20
  setOpen(false)
54
21
  setHighlightedId(null)
55
- }, [])
22
+ }, [setOpen])
56
23
 
57
24
  const openPopup = useCallback(() => {
58
25
  setOpen(true)
@@ -68,27 +35,18 @@ export function useComboboxRoot({
68
35
  }
69
36
  }
70
37
  setHighlightedId(found ?? (order.length > 0 ? order[0] : null))
71
- }, [value])
38
+ }, [value, setOpen])
72
39
 
40
+ // Choosing keeps focus in the input (the web's editable combobox).
73
41
  const selectValue = useCallback(
74
42
  (itemValue: string) => {
75
- setValue(itemValue)
76
- // Set input to show the selected label
77
- const label = labels[itemValue] ?? itemValue
78
- setInputValue(label)
79
- close()
43
+ chooseValue(itemValue)
44
+ setHighlightedId(null)
80
45
  inputRef.current?.focus()
81
46
  },
82
- [setValue, labels, setInputValue, close],
47
+ [chooseValue],
83
48
  )
84
49
 
85
- const registerLabel = useCallback((v: string, label: string) => {
86
- setLabels((prev) => {
87
- if (prev[v] === label) return prev
88
- return {...prev, [v]: label}
89
- })
90
- }, [])
91
-
92
50
  const registerItem = useCallback((id: string, itemValue: string, element: HTMLElement) => {
93
51
  itemsRef.current.set(id, {value: itemValue, element})
94
52
  const map = itemsRef.current
@@ -108,13 +66,10 @@ export function useComboboxRoot({
108
66
 
109
67
  // Get visible items (those matching the filter)
110
68
  const getVisibleOrder = useCallback(() => {
111
- const query = inputValue.toLowerCase()
112
- if (!query) return orderRef.current
69
+ if (!inputValue) return orderRef.current
113
70
  return orderRef.current.filter((id) => {
114
71
  const item = itemsRef.current.get(id)
115
- if (!item) return false
116
- const label = labels[item.value] ?? item.value
117
- return label.toLowerCase().includes(query)
72
+ return item ? matchesQuery(labels[item.value] ?? item.value, inputValue) : false
118
73
  })
119
74
  }, [inputValue, labels])
120
75
 
@@ -130,10 +85,6 @@ export function useComboboxRoot({
130
85
  switch (e.key) {
131
86
  case 'ArrowDown': {
132
87
  e.preventDefault()
133
- if (!open) {
134
- setOpen(true)
135
- return
136
- }
137
88
  setHighlightedId((prev) => {
138
89
  const idx = prev ? visibleOrder.indexOf(prev) : -1
139
90
  return visibleOrder[(idx + 1) % visibleOrder.length] ?? null
@@ -196,31 +147,6 @@ export function useComboboxRoot({
196
147
  item?.element.scrollIntoView({block: 'nearest'})
197
148
  }, [highlightedId])
198
149
 
199
- // Reconcile input text with the committed value once the popup closes.
200
- // Three cases when transitioning from open → closed:
201
- // - inputValue is empty: user cleared the field, treat that as
202
- // "remove the selection" so the consumer's onValueChange fires
203
- // with null and downstream UI (e.g. dependent fields) can reset.
204
- // - inputValue doesn't match the selected label: user typed something
205
- // but didn't pick anything; restore the label so the visible text
206
- // and the committed value stay consistent.
207
- // - inputValue equals the selected label: nothing to do.
208
- useEffect(() => {
209
- if (open) return
210
- // Treat an empty value (null OR '') as "no selection" — nothing to
211
- // reconcile. Without this, a controlled value="" (e.g. a table column
212
- // filter) fires setValue(null) every render → inline onValueChange →
213
- // parent state change → re-render → infinite "Maximum update depth" loop.
214
- if (!value) return
215
- if (inputValue === '') {
216
- setValue(null)
217
- return
218
- }
219
- if (labels[value] && inputValue !== labels[value]) {
220
- setInputValue(labels[value])
221
- }
222
- }, [open, value, labels, inputValue, setInputValue, setValue])
223
-
224
150
  const ctx: ComboboxContextValue = useMemo(
225
151
  () => ({
226
152
  open,
@@ -2,7 +2,7 @@ import type {ComponentMeta} from '../component-meta'
2
2
 
3
3
  export const meta: ComponentMeta = {
4
4
  description:
5
- 'Modal sliding panel from a screen edge (right, left, or bottom). Slides in with backdrop overlay. Supports swipe-to-dismiss gestures on mobile, backdrop click dismiss, and Escape key. Swipe yields to an inner Duro ScrollArea: a drag there scrolls it, and dismisses only toward the edge from the scroll start (a native overflow:auto child is not covered — use ScrollArea). A bottom drawer with snapPoints (medium and large) is a phone sheet: it rests at its detents, a grabber steps through them on a tap, a drag moves between them and dismisses below the lowest; its Body is the scroll the drag defers to. Header titleAlign="center" centres the title with a text close at the end. Use for secondary workflows, settings panels, or mobile-friendly detail views.',
5
+ 'Modal sliding panel from a screen edge (right, left, or bottom). Slides in with backdrop overlay. Supports swipe-to-dismiss gestures on mobile, backdrop click dismiss, and Escape key. Swipe yields to an inner Duro ScrollArea: a drag there scrolls it, and dismisses only toward the edge from the scroll start (a native overflow:auto child is not covered — use ScrollArea). A bottom drawer with snapPoints (medium and large) is a phone sheet: it rests at its detents, a grabber steps through them on a tap, a drag moves between them and dismisses below the lowest; its Body is the scroll the drag defers to. Header titleAlign="center" centres the title with a text close at the end. Body flush drops its side padding, so rows run to the panel\'s edges (a picker\'s list); on React Native a bottom drawer rides above the on-screen keyboard. Use for secondary workflows, settings panels, or mobile-friendly detail views.',
6
6
  whenToUse: [
7
7
  'Settings or configuration panels that slide in from the side',
8
8
  'Mobile-friendly detail views (bottom sheet pattern)',
@@ -33,6 +33,7 @@ import {ModalContext} from '../../shared/ModalContext'
33
33
  import {OverlayModal} from '../../shared/portal/OverlayModal'
34
34
  import {focusForAccessibility} from '../../shared/accessibilityFocus'
35
35
  import {useSafeAreaInsets} from '../../shared/safeArea'
36
+ import {useKeyboardHeight} from '../../shared/useKeyboardHeight'
36
37
  import {withPress} from '../../shared/pressableChild'
37
38
  import {ControlContextBoundary} from '../Toolbar/ControlContextBoundary'
38
39
  import {Icon} from '../Icon/Icon'
@@ -145,12 +146,17 @@ function Portal({children, size = 'md', grabberLabels = DEFAULT_GRABBER_LABELS}:
145
146
  } = useDrawer()
146
147
  const window = useWindowDimensions()
147
148
  const insets = useSafeAreaInsets()
149
+ // A bottom drawer rides above the on-screen keyboard: its detents measure
150
+ // from the keyboard's top edge, so a search field and its results stay seen.
151
+ const keyboard = useKeyboardHeight()
152
+ const lift = anchor === 'bottom' ? keyboard : 0
153
+ const availableHeight = window.height - lift
148
154
  const bottom = anchor === 'bottom'
149
155
  const snapping = snapPoints.length > 0
150
156
 
151
157
  const heights = useMemo(
152
- () => snapPoints.map((p) => detentHeight(p, window.height, insets.top)),
153
- [snapPoints, window.height, insets.top],
158
+ () => snapPoints.map((p) => detentHeight(p, availableHeight, insets.top)),
159
+ [snapPoints, availableHeight, insets.top],
154
160
  )
155
161
  const high = heights.length > 0 ? heights[heights.length - 1] : 0
156
162
  // Where the panel rests at its detent: its offset below its own full height.
@@ -316,11 +322,11 @@ function Portal({children, size = 'md', grabberLabels = DEFAULT_GRABBER_LABELS}:
316
322
 
317
323
  const placement = bottom
318
324
  ? snapping
319
- ? [layout.bottom, {height: high + SNAP_MAX_OVERSHOOT, bottom: -SNAP_MAX_OVERSHOOT}]
320
- : [layout.bottom, {maxHeight: window.height * CONTENT_SHEET_CAP}]
325
+ ? [layout.bottom, {height: high + SNAP_MAX_OVERSHOOT, bottom: lift - SNAP_MAX_OVERSHOOT}]
326
+ : [layout.bottom, {bottom: lift, maxHeight: availableHeight * CONTENT_SHEET_CAP}]
321
327
  : [layout.side, anchor === 'left' ? layout.left : layout.right, {maxWidth: PANEL_WIDTH[size]}]
322
328
  const padTop = bottom ? 0 : insets.top
323
- const padBottom = insets.bottom + (snapping ? SNAP_MAX_OVERSHOOT : 0)
329
+ const padBottom = (lift > 0 ? 0 : insets.bottom) + (snapping ? SNAP_MAX_OVERSHOOT : 0)
324
330
 
325
331
  const nextIndex = cycleDetent(snapIndex, snapPoints.length)
326
332
  const onGrabberAction = (e: AccessibilityActionEvent) => {
@@ -420,7 +426,13 @@ const layout = StyleSheet.create({
420
426
  * overflow. In a bottom sheet the drag defers to it: the list scrolls, and
421
427
  * moves the sheet only from its top.
422
428
  */
423
- function Body({children}: {children: ReactNode}) {
429
+ interface BodyProps {
430
+ children: ReactNode
431
+ /** No side padding: rows that run to the panel's edges, as a picker's list. */
432
+ flush?: boolean
433
+ }
434
+
435
+ function Body({children, flush = false}: BodyProps) {
424
436
  const sheet = useContext(SheetScrollContext)
425
437
  const {snapPoints} = useDrawer()
426
438
  if (!sheet) return null
@@ -431,9 +443,19 @@ function Body({children}: {children: ReactNode}) {
431
443
  onScroll={sheet.onScroll}
432
444
  scrollEventThrottle={16}
433
445
  bounces={false}
446
+ // Presses inside the sheet keep the keyboard up. A ScrollView above the
447
+ // sheet's Modal can still take the first touch, which is why picker
448
+ // rows use Gesture Handler's Pressable (Listbox.native).
449
+ keyboardShouldPersistTaps="handled"
434
450
  style={snapPoints.length > 0 ? layout.bodyFill : layout.body}
435
451
  >
436
- <html.div style={[nativeStyles.bodyContent, nativeStyles.hiddenBelow(sheet.hiddenBelow)]}>
452
+ <html.div
453
+ style={[
454
+ nativeStyles.bodyContent,
455
+ flush && nativeStyles.bodyFlush,
456
+ nativeStyles.hiddenBelow(sheet.hiddenBelow),
457
+ ]}
458
+ >
437
459
  {children}
438
460
  </html.div>
439
461
  </Animated.ScrollView>
@@ -229,12 +229,21 @@ function Portal({children, size = 'md', grabberLabels = DEFAULT_GRABBER_LABELS}:
229
229
 
230
230
  // --- Body ---
231
231
 
232
- function Body({children}: {children: ReactNode}) {
232
+ interface BodyProps {
233
+ children: ReactNode
234
+ /** No side padding: rows that run to the panel's edges, as a picker's list. */
235
+ flush?: boolean
236
+ }
237
+
238
+ function Body({children, flush = false}: BodyProps) {
233
239
  const {snapPoints} = useDrawer()
234
240
  // A snap sheet's Body is its scroll viewport: a drag there scrolls it, and
235
241
  // moves the sheet only from the top edge (or upward, below the largest detent).
236
242
  return (
237
- <html.div style={styles.body} data-duro-scroll={snapPoints.length > 0 ? '' : undefined}>
243
+ <html.div
244
+ style={[styles.body, flush && styles.bodyFlush]}
245
+ data-duro-scroll={snapPoints.length > 0 ? '' : undefined}
246
+ >
238
247
  {children}
239
248
  </html.div>
240
249
  )
@@ -19,6 +19,7 @@ interface SheetArgs {
19
19
  snapPoints: SnapPoint[]
20
20
  defaultSnapPoint: SnapPoint
21
21
  titleAlign: 'start' | 'center'
22
+ flush: boolean
22
23
  onOpenChange?: (open: boolean) => void
23
24
  }
24
25
 
@@ -26,11 +27,15 @@ const DEFAULT_ARGS: SheetArgs = {
26
27
  snapPoints: ['medium', 'large'],
27
28
  defaultSnapPoint: 'medium',
28
29
  titleAlign: 'center',
30
+ flush: false,
29
31
  }
30
32
 
31
33
  /** The picked B0 direction (docs/mockups/native-sheet/Done.dc.html): grabber, centred title, a text Done. */
32
34
  function Sheet(props: Partial<SheetArgs>) {
33
- const {snapPoints, defaultSnapPoint, titleAlign, onOpenChange} = {...DEFAULT_ARGS, ...props}
35
+ const {snapPoints, defaultSnapPoint, titleAlign, flush, onOpenChange} = {
36
+ ...DEFAULT_ARGS,
37
+ ...props,
38
+ }
34
39
  return (
35
40
  <Drawer.Root
36
41
  anchor="bottom"
@@ -46,7 +51,7 @@ function Sheet(props: Partial<SheetArgs>) {
46
51
  <Button variant="link">Done</Button>
47
52
  </Drawer.Close>
48
53
  </Drawer.Header>
49
- <Drawer.Body>
54
+ <Drawer.Body flush={flush}>
50
55
  <List.Root aria-label="Sessions">
51
56
  {SESSIONS.map((s) => (
52
57
  <List.Item key={s}>
@@ -78,6 +83,11 @@ export const SnapSheet: StoryObj<SheetArgs> = {
78
83
  options: ['start', 'center'],
79
84
  description: 'Drawer.Header: the title at the start, or centred with the close at the end',
80
85
  },
86
+ flush: {
87
+ control: 'boolean',
88
+ description:
89
+ "Drawer.Body: no side padding, so rows run to the panel's edges (a picker's list)",
90
+ },
81
91
  },
82
92
  // Keyed by the opening detent: it seeds state on mount, so a change remounts the sheet.
83
93
  render: (args) => (
@@ -203,3 +213,13 @@ export const DoneCloses: Story = {
203
213
  await waitFor(() => expect(getDialog()).toBeNull())
204
214
  },
205
215
  }
216
+
217
+ export const FlushBody: Story = {
218
+ render: () => <Sheet flush />,
219
+ play: async () => {
220
+ await waitFor(() => expect(getDialog()).not.toBeNull())
221
+ const body = getBody()
222
+ await expect(getComputedStyle(body).paddingLeft).toBe('0px')
223
+ await expect(getComputedStyle(body).paddingRight).toBe('0px')
224
+ },
225
+ }
@@ -52,6 +52,10 @@ export const nativeStyles = css.create({
52
52
  paddingRight: spacing.lg,
53
53
  paddingBottom: spacing.lg,
54
54
  },
55
+ bodyFlush: {
56
+ paddingLeft: 0,
57
+ paddingRight: 0,
58
+ },
55
59
  // A snap sheet resting below its largest detent: its lower part is off
56
60
  // screen, so the list scrolls that far further to show its end.
57
61
  hiddenBelow: (height: number) => ({paddingBottom: height}),
@@ -202,4 +202,8 @@ export const styles = css.create({
202
202
  overflowY: 'auto',
203
203
  flex: 1,
204
204
  },
205
+ bodyFlush: {
206
+ paddingLeft: 0,
207
+ paddingRight: 0,
208
+ },
205
209
  })
@@ -2,7 +2,7 @@ import type {ComponentMeta} from '../component-meta'
2
2
 
3
3
  export const meta: ComponentMeta = {
4
4
  description:
5
- 'Form wrapper with Effect Schema validation and react-hook-form integration. Provides FormContext to child Field components.',
5
+ 'Form wrapper with Effect Schema validation and react-hook-form integration. Provides FormContext to child Field components. On React Native, @duro-app/ui/form resolves to a native Form (a view, not a <form>): a Button type="submit" inside it runs the validated submit, since there is no DOM submit event.',
6
6
  whenToUse: ['Any form that needs validation', 'Multi-field forms with submit handling'],
7
7
  whenNotToUse: [
8
8
  'Single standalone field without validation — use Field.Root directly',
@@ -0,0 +1,120 @@
1
+ import {type ReactNode, useCallback, useEffect, useMemo, useRef} from 'react'
2
+ import {css, html} from 'react-strict-dom'
3
+ import {
4
+ useForm,
5
+ FormProvider,
6
+ type UseFormReturn,
7
+ type DefaultValues,
8
+ type FieldValues,
9
+ type FieldErrors,
10
+ type Path,
11
+ } from 'react-hook-form'
12
+ import {effectTsResolver} from '@hookform/resolvers/effect-ts'
13
+ import type {Schema} from 'effect'
14
+ import {FormContext, type LabelPosition, type NecessityIndicator} from './FormContext'
15
+ import {FormSubmitContext} from './FormSubmitContext'
16
+
17
+ export interface FormProps<T extends FieldValues> {
18
+ schema: Schema.Schema<T>
19
+ defaultValues: DefaultValues<T>
20
+ onSubmit: (data: T) => void | Promise<void>
21
+ disabled?: boolean
22
+ labelPosition?: LabelPosition
23
+ necessityIndicator?: NecessityIndicator
24
+ validationErrors?: Partial<Record<Path<T>, string>>
25
+ children: ReactNode | ((methods: UseFormReturn<T>) => ReactNode)
26
+ }
27
+
28
+ const formStyles = css.create({
29
+ disabled: {
30
+ opacity: 0.5,
31
+ pointerEvents: 'none',
32
+ },
33
+ })
34
+
35
+ /**
36
+ * Native Form (plan N0, Slice C): the web Form's validation and server-error
37
+ * sync, in a view rather than a <form>. A `type="submit"` Button inside it
38
+ * submits through FormSubmitContext.
39
+ */
40
+ export function Form<T extends FieldValues>({
41
+ schema,
42
+ defaultValues,
43
+ onSubmit,
44
+ disabled = false,
45
+ labelPosition = 'top',
46
+ necessityIndicator = false,
47
+ validationErrors,
48
+ children,
49
+ }: FormProps<T>) {
50
+ const methods = useForm<T>({
51
+ resolver: effectTsResolver(schema),
52
+ defaultValues,
53
+ mode: 'onTouched',
54
+ reValidateMode: 'onChange',
55
+ })
56
+
57
+ // Sync server-side validation errors into RHF
58
+ const prevErrorKeysRef = useRef<string[]>([])
59
+ useEffect(() => {
60
+ const prevKeys = prevErrorKeysRef.current
61
+ const nextKeys = validationErrors ? Object.keys(validationErrors) : []
62
+
63
+ // Clear errors that were removed
64
+ for (const key of prevKeys) {
65
+ if (!nextKeys.includes(key)) {
66
+ methods.clearErrors(key as Path<T>)
67
+ }
68
+ }
69
+
70
+ // Set new errors
71
+ if (validationErrors) {
72
+ for (const [key, message] of Object.entries(validationErrors)) {
73
+ if (message) {
74
+ methods.setError(key as Path<T>, {type: 'server', message: message as string})
75
+ }
76
+ }
77
+ }
78
+
79
+ prevErrorKeysRef.current = nextKeys
80
+ }, [validationErrors, methods])
81
+
82
+ const onInvalid = useCallback(
83
+ (errors: FieldErrors<T>) => {
84
+ const firstField = Object.keys(errors)[0]
85
+ if (firstField) {
86
+ methods.setFocus(firstField as Path<T>)
87
+ }
88
+ },
89
+ [methods],
90
+ )
91
+
92
+ // Native has no submit event: a type="submit" Button calls this instead.
93
+ const submit = useCallback(() => {
94
+ // A rejected onSubmit reaches the app's global error handler, not silence.
95
+ methods
96
+ .handleSubmit(onSubmit, onInvalid)()
97
+ .catch((error: unknown) => {
98
+ setTimeout(() => {
99
+ throw error
100
+ })
101
+ })
102
+ }, [methods, onSubmit, onInvalid])
103
+
104
+ const formCtx = useMemo(
105
+ () => ({disabled, labelPosition, necessityIndicator}),
106
+ [disabled, labelPosition, necessityIndicator],
107
+ )
108
+
109
+ return (
110
+ <FormProvider {...methods}>
111
+ <FormContext.Provider value={formCtx}>
112
+ <FormSubmitContext.Provider value={disabled ? null : submit}>
113
+ <html.div aria-disabled={disabled || undefined} style={disabled && formStyles.disabled}>
114
+ {typeof children === 'function' ? children(methods) : children}
115
+ </html.div>
116
+ </FormSubmitContext.Provider>
117
+ </FormContext.Provider>
118
+ </FormProvider>
119
+ )
120
+ }
@@ -0,0 +1,12 @@
1
+ import {createContext, useContext} from 'react'
2
+
3
+ /**
4
+ * How a `type="submit"` Button submits its Form where there is no DOM submit
5
+ * event: on native, Form provides its validated submit here. The web Form
6
+ * provides nothing; the browser's form submission does it.
7
+ */
8
+ export const FormSubmitContext = createContext<(() => void) | null>(null)
9
+
10
+ export function useFormSubmit() {
11
+ return useContext(FormSubmitContext)
12
+ }
@@ -2,7 +2,7 @@ import type {ComponentMeta} from '../component-meta'
2
2
 
3
3
  export const meta: ComponentMeta = {
4
4
  description:
5
- 'Popup list of options for an input that keeps focus — an editor typeahead (mentions, slash commands) or a custom combobox. The anchor owns focus and the keyboard: it sets the controlled highlightedId and spreads Listbox.getAnchorProps() for aria-controls / aria-expanded / aria-activedescendant. A press on an option never takes focus from the anchor. Portalled and placed against anchor() (an input rect or a caret rect), capped at a size-token maxHeight (default listMaxH).',
5
+ 'Popup list of options for an input that keeps focus — an editor typeahead (mentions, slash commands) or a custom combobox. The anchor owns focus and the keyboard: it sets the controlled highlightedId and spreads Listbox.getAnchorProps() for aria-controls / aria-expanded / aria-activedescendant. A press on an option never takes focus from the anchor. Portalled and placed against anchor() (an input rect or a caret rect), capped at a size-token maxHeight (default listMaxH). On React Native it is a plain list laid out where it is rendered (inside a Select or Combobox sheet): nothing is anchored or highlighted, the selected option is filled with a trailing check, and a press calls onSelect.',
6
6
  whenToUse: [
7
7
  'Typeahead inside a rich-text editor, where focus must stay in the editor',
8
8
  'A custom combobox whose input is not Combobox.Input',