@duro-app/ui 5.13.0 → 5.14.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/README.md +49 -0
- package/dist/{Table-Bms2ftt3.js → Table-DnaoH-h5.js} +747 -635
- package/dist/Table-DnaoH-h5.js.map +1 -0
- package/dist/components/Button/Button.d.ts.map +1 -1
- package/dist/components/ColorMode/ColorMode.d.ts +2 -1
- package/dist/components/ColorMode/ColorMode.d.ts.map +1 -1
- package/dist/components/ColorMode/storage.d.ts +4 -0
- package/dist/components/ColorMode/storage.d.ts.map +1 -0
- package/dist/components/ColorMode/styles.css.d.ts +4 -0
- package/dist/components/ColorMode/styles.css.d.ts.map +1 -1
- package/dist/components/ColorMode/useSystemScheme.d.ts +7 -0
- package/dist/components/ColorMode/useSystemScheme.d.ts.map +1 -0
- package/dist/components/Combobox/Combobox.d.ts +7 -2
- package/dist/components/Combobox/Combobox.d.ts.map +1 -1
- package/dist/components/Combobox/comboboxState.d.ts +29 -0
- package/dist/components/Combobox/comboboxState.d.ts.map +1 -0
- package/dist/components/Combobox/native.css.d.ts +27 -0
- package/dist/components/Combobox/native.css.d.ts.map +1 -0
- package/dist/components/Combobox/useComboboxRoot.d.ts +6 -10
- package/dist/components/Combobox/useComboboxRoot.d.ts.map +1 -1
- package/dist/components/Drawer/Drawer.d.ts +5 -2
- package/dist/components/Drawer/Drawer.d.ts.map +1 -1
- package/dist/components/Drawer/DrawerSnap.stories.d.ts +2 -0
- package/dist/components/Drawer/DrawerSnap.stories.d.ts.map +1 -1
- package/dist/components/Drawer/native.css.d.ts +4 -0
- package/dist/components/Drawer/native.css.d.ts.map +1 -1
- package/dist/components/Drawer/styles.css.d.ts +4 -0
- package/dist/components/Drawer/styles.css.d.ts.map +1 -1
- package/dist/components/Form/FormSubmitContext.d.ts +8 -0
- package/dist/components/Form/FormSubmitContext.d.ts.map +1 -0
- package/dist/components/Listbox/native.css.d.ts +84 -0
- package/dist/components/Listbox/native.css.d.ts.map +1 -0
- package/dist/components/Select/Select.d.ts +9 -2
- package/dist/components/Select/Select.d.ts.map +1 -1
- package/dist/components/Select/Select.stories.d.ts +11 -0
- package/dist/components/Select/Select.stories.d.ts.map +1 -1
- package/dist/components/Select/native.css.d.ts +11 -0
- package/dist/components/Select/native.css.d.ts.map +1 -0
- package/dist/components/Select/selectState.d.ts +21 -0
- package/dist/components/Select/selectState.d.ts.map +1 -0
- package/dist/components/Select/styles.css.d.ts +10 -0
- package/dist/components/Select/styles.css.d.ts.map +1 -1
- package/dist/components/Select/useSelectRoot.d.ts +6 -8
- package/dist/components/Select/useSelectRoot.d.ts.map +1 -1
- package/dist/components/ThemeProvider/ThemeOverrides.stories.d.ts +13 -0
- package/dist/components/ThemeProvider/ThemeOverrides.stories.d.ts.map +1 -0
- package/dist/components/ThemeProvider/ThemeProvider.d.ts +9 -2
- package/dist/components/ThemeProvider/ThemeProvider.d.ts.map +1 -1
- package/dist/components/ThemeProvider/overrideRule.d.ts +7 -0
- package/dist/components/ThemeProvider/overrideRule.d.ts.map +1 -0
- package/dist/components/ThemeProvider/overrides.d.ts +16 -0
- package/dist/components/ThemeProvider/overrides.d.ts.map +1 -0
- package/dist/index.css +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3302 -3268
- package/dist/index.js.map +1 -1
- package/dist/shared/textOf.d.ts +7 -0
- package/dist/shared/textOf.d.ts.map +1 -0
- package/dist/table.js +1 -1
- package/package.json +3 -2
- package/src/components/Button/Button.tsx +12 -1
- package/src/components/ColorMode/ColorMode.meta.ts +1 -1
- package/src/components/ColorMode/ColorMode.tsx +16 -37
- package/src/components/ColorMode/storage.native.ts +8 -0
- package/src/components/ColorMode/storage.ts +19 -0
- package/src/components/ColorMode/styles.css.ts +6 -0
- package/src/components/ColorMode/useSystemScheme.native.ts +6 -0
- package/src/components/ColorMode/useSystemScheme.ts +19 -0
- package/src/components/Combobox/Combobox.meta.ts +1 -1
- package/src/components/Combobox/Combobox.native.tsx +295 -0
- package/src/components/Combobox/Combobox.tsx +14 -8
- package/src/components/Combobox/comboboxState.ts +107 -0
- package/src/components/Combobox/native.css.ts +33 -0
- package/src/components/Combobox/useComboboxRoot.ts +17 -91
- package/src/components/Drawer/Drawer.meta.ts +1 -1
- package/src/components/Drawer/Drawer.native.tsx +29 -7
- package/src/components/Drawer/Drawer.tsx +11 -2
- package/src/components/Drawer/DrawerSnap.stories.tsx +22 -2
- package/src/components/Drawer/native.css.ts +4 -0
- package/src/components/Drawer/styles.css.ts +4 -0
- package/src/components/Form/Form.meta.ts +1 -1
- package/src/components/Form/Form.native.tsx +120 -0
- package/src/components/Form/FormSubmitContext.ts +12 -0
- package/src/components/Listbox/Listbox.meta.ts +1 -1
- package/src/components/Listbox/Listbox.native.tsx +153 -0
- package/src/components/Listbox/native.css.ts +91 -0
- package/src/components/ScrollArea/ScrollArea.meta.ts +1 -1
- package/src/components/ScrollArea/ScrollArea.native.tsx +61 -0
- package/src/components/Select/Select.meta.ts +1 -1
- package/src/components/Select/Select.native.tsx +224 -0
- package/src/components/Select/Select.stories.tsx +49 -1
- package/src/components/Select/Select.tsx +20 -3
- package/src/components/Select/native.css.ts +13 -0
- package/src/components/Select/selectState.ts +36 -0
- package/src/components/Select/styles.css.ts +11 -0
- package/src/components/Select/useSelectRoot.ts +9 -30
- package/src/components/ThemeProvider/ThemeOverrides.stories.tsx +113 -0
- package/src/components/ThemeProvider/ThemeProvider.meta.ts +1 -1
- package/src/components/ThemeProvider/ThemeProvider.native.tsx +36 -7
- package/src/components/ThemeProvider/ThemeProvider.tsx +42 -6
- package/src/components/ThemeProvider/overrideRule.ts +39 -0
- package/src/components/ThemeProvider/overrides.ts +27 -0
- package/src/form.native.ts +7 -0
- package/src/index.native.ts +15 -0
- package/src/index.ts +1 -0
- package/src/shared/focusInput.native.ts +18 -0
- package/src/shared/textOf.ts +13 -0
- 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
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
-
|
|
76
|
-
|
|
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
|
-
[
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
153
|
-
[snapPoints,
|
|
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:
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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} = {
|
|
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}),
|
|
@@ -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',
|