@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.
- 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/QrCode/QrCode.d.ts +8 -0
- package/dist/components/QrCode/QrCode.d.ts.map +1 -0
- package/dist/components/QrCode/QrCode.meta.d.ts +3 -0
- package/dist/components/QrCode/QrCode.meta.d.ts.map +1 -0
- package/dist/components/QrCode/QrCode.stories.d.ts +8 -0
- package/dist/components/QrCode/QrCode.stories.d.ts.map +1 -0
- package/dist/components/QrCode/geometry.d.ts +14 -0
- package/dist/components/QrCode/geometry.d.ts.map +1 -0
- package/dist/components/QrCode/styles.css.d.ts +9 -0
- package/dist/components/QrCode/styles.css.d.ts.map +1 -0
- package/dist/components/QrCode/types.d.ts +7 -0
- package/dist/components/QrCode/types.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 +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6230 -4656
- 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 +5 -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/QrCode/QrCode.meta.ts +18 -0
- package/src/components/QrCode/QrCode.native.tsx +31 -0
- package/src/components/QrCode/QrCode.stories.tsx +44 -0
- package/src/components/QrCode/QrCode.tsx +30 -0
- package/src/components/QrCode/geometry.ts +34 -0
- package/src/components/QrCode/styles.css.ts +12 -0
- package/src/components/QrCode/types.ts +6 -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 +16 -0
- package/src/index.ts +2 -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,224 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Children,
|
|
3
|
+
createContext,
|
|
4
|
+
type ReactNode,
|
|
5
|
+
type RefObject,
|
|
6
|
+
useContext,
|
|
7
|
+
useEffect,
|
|
8
|
+
useId,
|
|
9
|
+
useRef,
|
|
10
|
+
} from 'react'
|
|
11
|
+
import {Pressable} from 'react-native'
|
|
12
|
+
import {html} from 'react-strict-dom'
|
|
13
|
+
import {styles} from './styles.css'
|
|
14
|
+
import {useSelectState} from './selectState'
|
|
15
|
+
import {Drawer} from '../Drawer/Drawer'
|
|
16
|
+
import {Listbox} from '../Listbox/Listbox'
|
|
17
|
+
import {Button} from '../Button/Button'
|
|
18
|
+
import {nativeStyles} from './native.css'
|
|
19
|
+
import {Chevron} from '../SideNav/Chevron'
|
|
20
|
+
import {useTooltipHint} from '../Tooltip/TooltipHint'
|
|
21
|
+
import {focusForAccessibility} from '../../shared/accessibilityFocus'
|
|
22
|
+
import {textOf} from '../../shared/textOf'
|
|
23
|
+
import type {ControlSize} from '../../shared/types'
|
|
24
|
+
|
|
25
|
+
/** The picker sheet's detents (plan N0, Decision 5). */
|
|
26
|
+
const SNAP_POINTS = ['medium', 'large'] as const
|
|
27
|
+
|
|
28
|
+
interface NativeSelectContextValue {
|
|
29
|
+
value: string | null
|
|
30
|
+
open: boolean
|
|
31
|
+
setOpen: (open: boolean) => void
|
|
32
|
+
choose: (value: string) => void
|
|
33
|
+
labels: Record<string, string>
|
|
34
|
+
registerLabel: (value: string, label: string) => void
|
|
35
|
+
listboxId: string
|
|
36
|
+
triggerRef: RefObject<unknown>
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const SelectContext = createContext<NativeSelectContextValue | null>(null)
|
|
40
|
+
|
|
41
|
+
function useSelect() {
|
|
42
|
+
const ctx = useContext(SelectContext)
|
|
43
|
+
if (!ctx) throw new Error('Select compound components must be used within Select.Root')
|
|
44
|
+
return ctx
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* True while Popup renders its items unseen, so each registers its label
|
|
49
|
+
* before the sheet first opens (the web reads them from the hidden list).
|
|
50
|
+
*/
|
|
51
|
+
const CollectContext = createContext(false)
|
|
52
|
+
|
|
53
|
+
interface RootProps {
|
|
54
|
+
/** The web's hidden form input; native has no form post. */
|
|
55
|
+
name?: string
|
|
56
|
+
defaultValue?: string
|
|
57
|
+
value?: string
|
|
58
|
+
onValueChange?: (value: string | null) => void
|
|
59
|
+
initialLabels?: Record<string, string>
|
|
60
|
+
children: ReactNode
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Native Select (plan N0, Slice C): the trigger opens a bottom sheet (a
|
|
65
|
+
* Drawer at the medium and large detents) listing the options; a press picks
|
|
66
|
+
* one and closes the sheet (C0 direction A, "Check"). The screen reader's
|
|
67
|
+
* focus returns to the trigger.
|
|
68
|
+
*/
|
|
69
|
+
function Root({defaultValue, value, onValueChange, initialLabels, children}: RootProps) {
|
|
70
|
+
const state = useSelectState({defaultValue, value, onValueChange, initialLabels})
|
|
71
|
+
const {open, setOpen, setValue} = state
|
|
72
|
+
const listboxId = useId()
|
|
73
|
+
const triggerRef = useRef<unknown>(null)
|
|
74
|
+
|
|
75
|
+
const wasOpen = useRef(open)
|
|
76
|
+
useEffect(() => {
|
|
77
|
+
if (wasOpen.current && !open) focusForAccessibility(triggerRef.current, 'Select')
|
|
78
|
+
wasOpen.current = open
|
|
79
|
+
}, [open])
|
|
80
|
+
|
|
81
|
+
const ctx: NativeSelectContextValue = {
|
|
82
|
+
value: state.value,
|
|
83
|
+
open,
|
|
84
|
+
setOpen,
|
|
85
|
+
choose: (v) => {
|
|
86
|
+
setValue(v)
|
|
87
|
+
setOpen(false)
|
|
88
|
+
},
|
|
89
|
+
labels: state.labels,
|
|
90
|
+
registerLabel: state.registerLabel,
|
|
91
|
+
listboxId,
|
|
92
|
+
triggerRef,
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return (
|
|
96
|
+
<SelectContext.Provider value={ctx}>
|
|
97
|
+
<Drawer.Root open={open} onOpenChange={setOpen} anchor="bottom" snapPoints={SNAP_POINTS}>
|
|
98
|
+
<html.div style={[styles.root, nativeStyles.nativeFlex]}>{children}</html.div>
|
|
99
|
+
</Drawer.Root>
|
|
100
|
+
</SelectContext.Provider>
|
|
101
|
+
)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* An RN Pressable host, so it carries an enclosing Tooltip's text as its
|
|
106
|
+
* accessibility hint (RSD drops `accessibilityHint` on html.*).
|
|
107
|
+
*/
|
|
108
|
+
function Trigger({
|
|
109
|
+
children,
|
|
110
|
+
'aria-label': ariaLabel,
|
|
111
|
+
size = 'default',
|
|
112
|
+
}: {
|
|
113
|
+
children: ReactNode
|
|
114
|
+
'aria-label'?: string
|
|
115
|
+
size?: ControlSize
|
|
116
|
+
}) {
|
|
117
|
+
const {open, setOpen, triggerRef} = useSelect()
|
|
118
|
+
const hint = useTooltipHint()
|
|
119
|
+
return (
|
|
120
|
+
<Pressable
|
|
121
|
+
ref={triggerRef as RefObject<never>}
|
|
122
|
+
onPress={() => setOpen(true)}
|
|
123
|
+
accessibilityRole="combobox"
|
|
124
|
+
accessibilityLabel={ariaLabel}
|
|
125
|
+
accessibilityHint={hint}
|
|
126
|
+
accessibilityState={{expanded: open}}
|
|
127
|
+
>
|
|
128
|
+
<html.div
|
|
129
|
+
style={[styles.trigger, nativeStyles.nativeFlex, size === 'small' && styles.triggerSmall]}
|
|
130
|
+
>
|
|
131
|
+
{children}
|
|
132
|
+
</html.div>
|
|
133
|
+
</Pressable>
|
|
134
|
+
)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function Value({placeholder}: {placeholder?: string}) {
|
|
138
|
+
const {value, labels} = useSelect()
|
|
139
|
+
const display = value ? (labels[value] ?? value) : null
|
|
140
|
+
return (
|
|
141
|
+
<html.span style={display ? styles.value : styles.placeholder}>
|
|
142
|
+
{display ?? placeholder}
|
|
143
|
+
</html.span>
|
|
144
|
+
)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function Icon({children}: {children?: ReactNode}) {
|
|
148
|
+
return (
|
|
149
|
+
<html.div style={[styles.icon, nativeStyles.nativeFlex, nativeStyles.iconDown]}>
|
|
150
|
+
{children ?? <Chevron color="textMuted" />}
|
|
151
|
+
</html.div>
|
|
152
|
+
)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
interface PopupProps {
|
|
156
|
+
children: ReactNode
|
|
157
|
+
/** The sheet's title (native) and the list's accessible name. */
|
|
158
|
+
label?: string
|
|
159
|
+
/** The sheet header's text button that closes it unchanged (native). Default: 'Cancel' */
|
|
160
|
+
cancelLabel?: string
|
|
161
|
+
/** Shown in place of the list when there are no items. */
|
|
162
|
+
empty?: ReactNode
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function Popup({children, label, cancelLabel = 'Cancel', empty}: PopupProps) {
|
|
166
|
+
const {open, choose, listboxId} = useSelect()
|
|
167
|
+
const hasItems = Children.toArray(children).length > 0
|
|
168
|
+
return (
|
|
169
|
+
<>
|
|
170
|
+
<CollectContext.Provider value={true}>{children}</CollectContext.Provider>
|
|
171
|
+
<Drawer.Portal>
|
|
172
|
+
<Drawer.Header titleAlign="center">
|
|
173
|
+
{label ? <Drawer.Title>{label}</Drawer.Title> : null}
|
|
174
|
+
<Drawer.Close>
|
|
175
|
+
<Button variant="link">{cancelLabel}</Button>
|
|
176
|
+
</Drawer.Close>
|
|
177
|
+
</Drawer.Header>
|
|
178
|
+
{!hasItems && empty != null ? (
|
|
179
|
+
<Listbox.Empty>{empty}</Listbox.Empty>
|
|
180
|
+
) : (
|
|
181
|
+
<Drawer.Body flush>
|
|
182
|
+
<Listbox.Root id={listboxId} open={open} onSelect={choose} aria-label={label}>
|
|
183
|
+
{children}
|
|
184
|
+
</Listbox.Root>
|
|
185
|
+
</Drawer.Body>
|
|
186
|
+
)}
|
|
187
|
+
</Drawer.Portal>
|
|
188
|
+
</>
|
|
189
|
+
)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
interface ItemProps {
|
|
193
|
+
value: string
|
|
194
|
+
children: ReactNode
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function Item({value: itemValue, children}: ItemProps) {
|
|
198
|
+
const collecting = useContext(CollectContext)
|
|
199
|
+
const {value, registerLabel} = useSelect()
|
|
200
|
+
const label = textOf(children)
|
|
201
|
+
useEffect(() => {
|
|
202
|
+
if (collecting && label) registerLabel(itemValue, label)
|
|
203
|
+
}, [collecting, itemValue, label, registerLabel])
|
|
204
|
+
if (collecting) return null
|
|
205
|
+
return (
|
|
206
|
+
<Listbox.Option value={itemValue} selected={value === itemValue}>
|
|
207
|
+
{children}
|
|
208
|
+
</Listbox.Option>
|
|
209
|
+
)
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function ItemText({children}: {children: ReactNode}) {
|
|
213
|
+
return <html.span>{children}</html.span>
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export const Select = {
|
|
217
|
+
Root,
|
|
218
|
+
Trigger,
|
|
219
|
+
Value,
|
|
220
|
+
Icon,
|
|
221
|
+
Popup,
|
|
222
|
+
Item,
|
|
223
|
+
ItemText,
|
|
224
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type {Meta, StoryObj} from '@storybook/react'
|
|
2
|
-
import {expect, screen} from 'storybook/test'
|
|
2
|
+
import {expect, fn, screen} from 'storybook/test'
|
|
3
3
|
import {Dialog} from '../Dialog/Dialog'
|
|
4
4
|
import {Select} from './Select'
|
|
5
5
|
|
|
@@ -213,3 +213,51 @@ export const ArrowKeysOpen: Story = {
|
|
|
213
213
|
await userEvent.keyboard('{Escape}')
|
|
214
214
|
},
|
|
215
215
|
}
|
|
216
|
+
|
|
217
|
+
const MODELS = ['claude-opus-5-5', 'claude-sonnet-5-5', 'claude-haiku-5-5', 'gpt-6', 'gemini-3-pro']
|
|
218
|
+
|
|
219
|
+
interface LabelledArgs {
|
|
220
|
+
label: string
|
|
221
|
+
empty: string
|
|
222
|
+
itemCount: number
|
|
223
|
+
onValueChange?: (value: string | null) => void
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Popup's label names the list (and titles the native sheet); empty replaces
|
|
228
|
+
* the list when there are no items. Set itemCount to 0 for the empty state.
|
|
229
|
+
*/
|
|
230
|
+
export const LabelledAndEmpty: StoryObj<LabelledArgs> = {
|
|
231
|
+
args: {
|
|
232
|
+
label: 'Model',
|
|
233
|
+
empty: 'No models. Connect a provider to choose a model.',
|
|
234
|
+
itemCount: 0,
|
|
235
|
+
onValueChange: fn(),
|
|
236
|
+
},
|
|
237
|
+
argTypes: {
|
|
238
|
+
label: {control: 'text', description: "Select.Popup: the list's accessible name"},
|
|
239
|
+
empty: {control: 'text', description: 'Select.Popup: shown when there are no items'},
|
|
240
|
+
itemCount: {control: {type: 'range', min: 0, max: MODELS.length, step: 1}},
|
|
241
|
+
},
|
|
242
|
+
render: ({label, empty, itemCount, onValueChange}) => (
|
|
243
|
+
<Select.Root onValueChange={onValueChange}>
|
|
244
|
+
<Select.Trigger aria-label={label}>
|
|
245
|
+
<Select.Value placeholder="Choose a model" />
|
|
246
|
+
<Select.Icon />
|
|
247
|
+
</Select.Trigger>
|
|
248
|
+
<Select.Popup label={label} empty={empty}>
|
|
249
|
+
{MODELS.slice(0, itemCount).map((m) => (
|
|
250
|
+
<Select.Item key={m} value={m}>
|
|
251
|
+
{m}
|
|
252
|
+
</Select.Item>
|
|
253
|
+
))}
|
|
254
|
+
</Select.Popup>
|
|
255
|
+
</Select.Root>
|
|
256
|
+
),
|
|
257
|
+
play: async ({canvas, userEvent}) => {
|
|
258
|
+
await userEvent.click(canvas.getByRole('combobox'))
|
|
259
|
+
const list = screen.getByRole('listbox', {name: 'Model'})
|
|
260
|
+
await expect(list).toHaveTextContent(/No models/)
|
|
261
|
+
await expect(screen.queryAllByRole('option')).toHaveLength(0)
|
|
262
|
+
},
|
|
263
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {type ReactNode, useRef, useId, useEffect, useLayoutEffect, useState} from 'react'
|
|
1
|
+
import {Children, type ReactNode, useRef, useId, useEffect, useLayoutEffect, useState} from 'react'
|
|
2
2
|
import {createPortal} from 'react-dom'
|
|
3
3
|
import {html} from 'react-strict-dom'
|
|
4
4
|
import {styles} from './styles.css'
|
|
@@ -126,7 +126,17 @@ function Icon({children}: {children?: ReactNode}) {
|
|
|
126
126
|
// `overflow: hidden` / `transform` containing blocks (notably Dialog/Drawer).
|
|
127
127
|
// The listbox stays mounted while closed (display: none) so Items can register
|
|
128
128
|
// their labels from DOM text before the popup is ever opened.
|
|
129
|
-
|
|
129
|
+
interface PopupProps {
|
|
130
|
+
children: ReactNode
|
|
131
|
+
/** The list's accessible name; on native also the sheet's title. */
|
|
132
|
+
label?: string
|
|
133
|
+
/** Native only: the sheet header's text button that closes it unchanged. Default: 'Cancel' */
|
|
134
|
+
cancelLabel?: string
|
|
135
|
+
/** Shown in place of the list when there are no items. */
|
|
136
|
+
empty?: ReactNode
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function Popup({children, label, empty}: PopupProps) {
|
|
130
140
|
const {open, close, listboxId, triggerRef} = useSelect()
|
|
131
141
|
const mount = usePortalMount()
|
|
132
142
|
const [coords, setCoords] = useState<{top: number; left: number; width: number} | null>(null)
|
|
@@ -164,6 +174,7 @@ function Popup({children}: {children: ReactNode}) {
|
|
|
164
174
|
ref={listboxRef}
|
|
165
175
|
id={listboxId}
|
|
166
176
|
role="listbox"
|
|
177
|
+
aria-label={label}
|
|
167
178
|
aria-hidden={!open}
|
|
168
179
|
style={[
|
|
169
180
|
styles.popup,
|
|
@@ -171,7 +182,13 @@ function Popup({children}: {children: ReactNode}) {
|
|
|
171
182
|
coords && styles.popupPosition(coords.top, coords.left, coords.width),
|
|
172
183
|
]}
|
|
173
184
|
>
|
|
174
|
-
<ControlContextBoundary>
|
|
185
|
+
<ControlContextBoundary>
|
|
186
|
+
{empty != null && Children.toArray(children).length === 0 ? (
|
|
187
|
+
<html.div style={styles.empty}>{empty}</html.div>
|
|
188
|
+
) : (
|
|
189
|
+
children
|
|
190
|
+
)}
|
|
191
|
+
</ControlContextBoundary>
|
|
175
192
|
</html.div>
|
|
176
193
|
</>
|
|
177
194
|
)
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import {css} from 'react-strict-dom'
|
|
2
|
+
|
|
3
|
+
/** Native Select additions to the shared styles. */
|
|
4
|
+
export const nativeStyles = css.create({
|
|
5
|
+
// RSD on native lays out only display: flex, not the web's inline-flex.
|
|
6
|
+
nativeFlex: {
|
|
7
|
+
display: 'flex',
|
|
8
|
+
},
|
|
9
|
+
// SideNav's chevron points right; a picker's points down.
|
|
10
|
+
iconDown: {
|
|
11
|
+
transform: 'rotate(90deg)',
|
|
12
|
+
},
|
|
13
|
+
})
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import {useCallback, useState} from 'react'
|
|
2
|
+
import {useControllableValue} from '../../hooks/useControllableValue'
|
|
3
|
+
|
|
4
|
+
export interface SelectStateOptions {
|
|
5
|
+
defaultValue?: string
|
|
6
|
+
value?: string
|
|
7
|
+
onValueChange?: (value: string | null) => void
|
|
8
|
+
initialLabels?: Record<string, string>
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The Select's state both platforms share (plan N0, Decision 10): the value
|
|
13
|
+
* (controlled or not), whether it is open, and each option's label. Focus,
|
|
14
|
+
* highlight, document order and keys are the web adapter's (useSelectRoot);
|
|
15
|
+
* native presses and the sheet are Select.native's.
|
|
16
|
+
*/
|
|
17
|
+
export function useSelectState({
|
|
18
|
+
defaultValue,
|
|
19
|
+
value: controlledValue,
|
|
20
|
+
onValueChange,
|
|
21
|
+
initialLabels,
|
|
22
|
+
}: SelectStateOptions) {
|
|
23
|
+
const [value, setValue] = useControllableValue<string | null>(
|
|
24
|
+
controlledValue,
|
|
25
|
+
defaultValue ?? null,
|
|
26
|
+
onValueChange,
|
|
27
|
+
)
|
|
28
|
+
const [open, setOpen] = useState(false)
|
|
29
|
+
const [labels, setLabels] = useState<Record<string, string>>(initialLabels ?? {})
|
|
30
|
+
|
|
31
|
+
const registerLabel = useCallback((v: string, label: string) => {
|
|
32
|
+
setLabels((prev) => (prev[v] === label ? prev : {...prev, [v]: label}))
|
|
33
|
+
}, [])
|
|
34
|
+
|
|
35
|
+
return {value, setValue, open, setOpen, labels, registerLabel}
|
|
36
|
+
}
|
|
@@ -124,4 +124,15 @@ export const styles = css.create({
|
|
|
124
124
|
hidden: {
|
|
125
125
|
display: 'none',
|
|
126
126
|
},
|
|
127
|
+
// Popup's `empty`: no items to choose from.
|
|
128
|
+
empty: {
|
|
129
|
+
paddingTop: spacing.md,
|
|
130
|
+
paddingBottom: spacing.md,
|
|
131
|
+
paddingLeft: spacing.md,
|
|
132
|
+
paddingRight: spacing.md,
|
|
133
|
+
fontFamily: typography.fontFamily,
|
|
134
|
+
fontSize: typography.fontSizeSm,
|
|
135
|
+
color: colors.textMuted,
|
|
136
|
+
textAlign: 'center',
|
|
137
|
+
},
|
|
127
138
|
})
|
|
@@ -1,27 +1,13 @@
|
|
|
1
1
|
import {useState, useCallback, useMemo, useRef, useId, useEffect} from 'react'
|
|
2
|
-
import {useControllableValue} from '../../hooks/useControllableValue'
|
|
3
2
|
import type {SelectContextValue} from './SelectContext'
|
|
3
|
+
import {useSelectState, type SelectStateOptions} from './selectState'
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
export function useSelectRoot({
|
|
13
|
-
defaultValue,
|
|
14
|
-
value: controlledValue,
|
|
15
|
-
onValueChange,
|
|
16
|
-
initialLabels,
|
|
17
|
-
}: UseSelectRootOptions) {
|
|
18
|
-
const [value, setValue] = useControllableValue<string | null>(
|
|
19
|
-
controlledValue,
|
|
20
|
-
defaultValue ?? null,
|
|
21
|
-
onValueChange,
|
|
22
|
-
)
|
|
23
|
-
const [open, setOpen] = useState(false)
|
|
24
|
-
const [labels, setLabels] = useState<Record<string, string>>(initialLabels ?? {})
|
|
5
|
+
/**
|
|
6
|
+
* The web adapter over the shared Select state: the highlighted option,
|
|
7
|
+
* document order, keys and DOM focus (the WAI-ARIA select-only combobox).
|
|
8
|
+
*/
|
|
9
|
+
export function useSelectRoot(options: SelectStateOptions) {
|
|
10
|
+
const {value, setValue, open, setOpen, labels, registerLabel} = useSelectState(options)
|
|
25
11
|
const [highlightedId, setHighlightedId] = useState<string | null>(null)
|
|
26
12
|
const listboxId = useId()
|
|
27
13
|
const rootRef = useRef<HTMLDivElement>(null)
|
|
@@ -33,7 +19,7 @@ export function useSelectRoot({
|
|
|
33
19
|
setOpen(false)
|
|
34
20
|
setHighlightedId(null)
|
|
35
21
|
triggerRef.current?.focus()
|
|
36
|
-
}, [])
|
|
22
|
+
}, [setOpen])
|
|
37
23
|
|
|
38
24
|
const toggle = useCallback(() => {
|
|
39
25
|
setOpen((prev) => {
|
|
@@ -54,14 +40,7 @@ export function useSelectRoot({
|
|
|
54
40
|
}
|
|
55
41
|
return !prev
|
|
56
42
|
})
|
|
57
|
-
}, [value])
|
|
58
|
-
|
|
59
|
-
const registerLabel = useCallback((v: string, label: string) => {
|
|
60
|
-
setLabels((prev) => {
|
|
61
|
-
if (prev[v] === label) return prev
|
|
62
|
-
return {...prev, [v]: label}
|
|
63
|
-
})
|
|
64
|
-
}, [])
|
|
43
|
+
}, [value, setOpen])
|
|
65
44
|
|
|
66
45
|
const registerItem = useCallback((id: string, itemValue: string, element: HTMLElement) => {
|
|
67
46
|
itemsRef.current.set(id, {value: itemValue, element})
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import {useState} from 'react'
|
|
2
|
+
import type {Meta, StoryObj} from '@storybook/react'
|
|
3
|
+
import {expect, waitFor} from 'storybook/test'
|
|
4
|
+
import {css, html} from 'react-strict-dom'
|
|
5
|
+
import {colors} from '@duro-app/tokens/tokens/colors.css'
|
|
6
|
+
import {spacing} from '@duro-app/tokens/tokens/spacing.css'
|
|
7
|
+
import {ThemeProvider, type ColorOverrides, type ThemeName} from './ThemeProvider'
|
|
8
|
+
import {Button} from '../Button/Button'
|
|
9
|
+
import {Text} from '../Text/Text'
|
|
10
|
+
|
|
11
|
+
const meta: Meta = {
|
|
12
|
+
title: 'Theme/ThemeProvider/Overrides',
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export default meta
|
|
16
|
+
|
|
17
|
+
const styles = css.create({
|
|
18
|
+
surface: {
|
|
19
|
+
display: 'flex',
|
|
20
|
+
flexDirection: 'column',
|
|
21
|
+
gap: spacing.sm,
|
|
22
|
+
padding: spacing.md,
|
|
23
|
+
backgroundColor: colors.bg,
|
|
24
|
+
},
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
interface OverrideArgs {
|
|
28
|
+
theme: ThemeName
|
|
29
|
+
bg: string
|
|
30
|
+
accent: string
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Colours replaced by token key; the controls change them in place. */
|
|
34
|
+
export const Playground: StoryObj<OverrideArgs> = {
|
|
35
|
+
args: {theme: 'dark', bg: '#1b2433', accent: '#ff5a1f'},
|
|
36
|
+
argTypes: {
|
|
37
|
+
theme: {control: 'inline-radio', options: ['dark', 'light', 'high-contrast']},
|
|
38
|
+
bg: {control: 'color', description: 'overrides.bg'},
|
|
39
|
+
accent: {control: 'color', description: 'overrides.accent'},
|
|
40
|
+
},
|
|
41
|
+
render: ({theme, bg, accent}) => (
|
|
42
|
+
<ThemeProvider theme={theme} overrides={{bg, accent}}>
|
|
43
|
+
<html.div style={styles.surface}>
|
|
44
|
+
<Text>The page background and the primary fill come from overrides.</Text>
|
|
45
|
+
<Button>Primary</Button>
|
|
46
|
+
</html.div>
|
|
47
|
+
</ThemeProvider>
|
|
48
|
+
),
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const A: ColorOverrides = {bg: 'rgb(27, 36, 51)', accent: 'rgb(255, 90, 31)'}
|
|
52
|
+
const B: ColorOverrides = {bg: 'rgb(10, 60, 30)', accent: 'rgb(30, 144, 255)'}
|
|
53
|
+
const STEPS: readonly (ColorOverrides | undefined)[] = [A, B, A, undefined]
|
|
54
|
+
|
|
55
|
+
/** Steps through A, B, A and none, with a nested provider under each. */
|
|
56
|
+
function Stepper() {
|
|
57
|
+
const [step, setStep] = useState(0)
|
|
58
|
+
const overrides = STEPS[step]
|
|
59
|
+
return (
|
|
60
|
+
<ThemeProvider overrides={overrides}>
|
|
61
|
+
<html.div data-testid="outer" style={styles.surface}>
|
|
62
|
+
<Button onClick={() => setStep((s) => Math.min(s + 1, STEPS.length - 1))}>Next</Button>
|
|
63
|
+
<ThemeProvider>
|
|
64
|
+
<html.div data-testid="inherits" style={styles.surface} />
|
|
65
|
+
</ThemeProvider>
|
|
66
|
+
<ThemeProvider overrides={{bg: 'rgb(60, 10, 60)', accent: 'red} body{background:red'}}>
|
|
67
|
+
<html.div data-testid="inner" style={styles.surface} />
|
|
68
|
+
</ThemeProvider>
|
|
69
|
+
</html.div>
|
|
70
|
+
</ThemeProvider>
|
|
71
|
+
)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const bgOf = (el: Element) => getComputedStyle(el).backgroundColor
|
|
75
|
+
const aliasOf = (el: Element) => getComputedStyle(el).getPropertyValue('--duro-color-bg').trim()
|
|
76
|
+
|
|
77
|
+
export const OverridesUpdateInPlace: StoryObj = {
|
|
78
|
+
render: () => <Stepper />,
|
|
79
|
+
play: async ({canvas, userEvent}) => {
|
|
80
|
+
const outer = canvas.getByTestId('outer')
|
|
81
|
+
const inherits = canvas.getByTestId('inherits')
|
|
82
|
+
const inner = canvas.getByTestId('inner')
|
|
83
|
+
const next = canvas.getByRole('button', {name: 'Next'})
|
|
84
|
+
|
|
85
|
+
// The outer provider's scope while it has overrides.
|
|
86
|
+
const provider = outer.parentElement!
|
|
87
|
+
await waitFor(() => expect(provider.getAttribute('data-duro-theme')).toBeTruthy())
|
|
88
|
+
const scope = provider.getAttribute('data-duro-theme')!
|
|
89
|
+
|
|
90
|
+
for (const step of [A, B, A]) {
|
|
91
|
+
await waitFor(() => expect(bgOf(outer)).toBe(step.bg))
|
|
92
|
+
// Button's fill eases between colours: wait for it to settle.
|
|
93
|
+
await waitFor(() => expect(getComputedStyle(next).backgroundColor).toBe(step.accent))
|
|
94
|
+
// The --duro-* alias follows the override.
|
|
95
|
+
await expect(aliasOf(outer)).toBe(step.bg)
|
|
96
|
+
// A nested provider with no overrides, same theme: the outer's colours.
|
|
97
|
+
await expect(bgOf(inherits)).toBe(step.bg)
|
|
98
|
+
await expect(aliasOf(inherits)).toBe(step.bg)
|
|
99
|
+
// A nested provider with its own: its colours inside, the outer's outside.
|
|
100
|
+
await expect(bgOf(inner)).toBe('rgb(60, 10, 60)')
|
|
101
|
+
await userEvent.click(next)
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// No overrides: the theme's own colours, and no outer rule left.
|
|
105
|
+
await waitFor(() => expect(bgOf(outer)).toBe('rgb(15, 15, 15)'))
|
|
106
|
+
const rules = [...document.querySelectorAll('style')].map((s) => s.textContent ?? '')
|
|
107
|
+
await expect(provider.hasAttribute('data-duro-theme')).toBe(false)
|
|
108
|
+
await expect(rules.filter((r) => r.includes(`"${scope}"`))).toHaveLength(0)
|
|
109
|
+
// The invalid value never reaches a rule, and nothing leaked onto body.
|
|
110
|
+
await expect(rules.some((r) => r.includes('body{'))).toBe(false)
|
|
111
|
+
await expect(getComputedStyle(document.body).backgroundColor).not.toBe('rgb(255, 0, 0)')
|
|
112
|
+
},
|
|
113
|
+
}
|
|
@@ -2,7 +2,7 @@ import type {ComponentMeta} from '../component-meta'
|
|
|
2
2
|
|
|
3
3
|
export const meta: ComponentMeta = {
|
|
4
4
|
description:
|
|
5
|
-
'Theme context provider. Must wrap the entire app at the root level. Sets dark, light, or high-contrast theme for all child components via CSS custom properties.',
|
|
5
|
+
'Theme context provider. Must wrap the entire app at the root level. Sets dark, light, or high-contrast theme for all child components via CSS custom properties. overrides replaces colours by token key ({accent: "#ff5a1f"}) and updates in place, without a re-mount: a scoped rule on the web (values that are not a colour are dropped), a runtime theme on React Native, where useResolvedColors() follows too. Overrides replace a colour whatever the theme, so pass per-mode values when the mode can change. A nested provider on the same theme keeps the outer overrides and lays its own over them; on another theme it starts from that theme\'s colours.',
|
|
6
6
|
whenToUse: [
|
|
7
7
|
'App root — required for all Duro components to render correctly',
|
|
8
8
|
'Theme switching (dark/light mode toggle)',
|
|
@@ -18,11 +18,19 @@ import {
|
|
|
18
18
|
ThemeScopeContext,
|
|
19
19
|
type ThemeScope,
|
|
20
20
|
} from '../../shared/portal/themeScope'
|
|
21
|
+
import {mergeOverrides, OverridesContext, type ColorOverrides} from './overrides'
|
|
21
22
|
|
|
22
23
|
export type ThemeName = 'dark' | 'light' | 'high-contrast'
|
|
24
|
+
export type {ColorOverrides}
|
|
23
25
|
|
|
24
26
|
interface ThemeProviderProps {
|
|
25
27
|
theme?: ThemeName
|
|
28
|
+
/**
|
|
29
|
+
* Colours that replace the theme's own, by token key (`{accent: '#ff5a1f'}`).
|
|
30
|
+
* They update in place, without a re-mount. A nested provider on the same
|
|
31
|
+
* theme keeps these and lays its own over them.
|
|
32
|
+
*/
|
|
33
|
+
overrides?: ColorOverrides
|
|
26
34
|
children: ReactNode
|
|
27
35
|
}
|
|
28
36
|
|
|
@@ -80,7 +88,7 @@ export function useResolvedColors(): Record<ColorToken, string> {
|
|
|
80
88
|
return useContext(ResolvedColorsContext)
|
|
81
89
|
}
|
|
82
90
|
|
|
83
|
-
export function ThemeProvider({theme = 'dark', children}: ThemeProviderProps) {
|
|
91
|
+
export function ThemeProvider({theme = 'dark', overrides, children}: ThemeProviderProps) {
|
|
84
92
|
const outerRegistry = useContext(HostRegistryContext)
|
|
85
93
|
const outerScope = useContext(ThemeScopeContext)
|
|
86
94
|
// The outermost provider owns the registry and draws the root host.
|
|
@@ -88,7 +96,24 @@ export function ThemeProvider({theme = 'dark', children}: ThemeProviderProps) {
|
|
|
88
96
|
outerRegistry ? null : createHostRegistry(),
|
|
89
97
|
)
|
|
90
98
|
const registry = outerRegistry ?? ownRegistry
|
|
91
|
-
const
|
|
99
|
+
const outerOverrides = useContext(OverridesContext)
|
|
100
|
+
const effective = useMemo(
|
|
101
|
+
() => mergeOverrides(outerOverrides, theme, overrides),
|
|
102
|
+
[outerOverrides, theme, overrides],
|
|
103
|
+
)
|
|
104
|
+
// A runtime theme on native, applied after the base one so RSD styles
|
|
105
|
+
// cascade it; rebuilt only when the colours change.
|
|
106
|
+
const overrideKey = JSON.stringify(effective.colors)
|
|
107
|
+
const themeStyles = useMemo(() => {
|
|
108
|
+
const base = themeMap[theme]
|
|
109
|
+
return overrideKey === '{}'
|
|
110
|
+
? base
|
|
111
|
+
: [...base, css.createTheme(colors, JSON.parse(overrideKey) as ColorOverrides)]
|
|
112
|
+
}, [theme, overrideKey])
|
|
113
|
+
const resolved = useMemo(
|
|
114
|
+
() => ({...palettes[theme], ...(JSON.parse(overrideKey) as ColorOverrides)}),
|
|
115
|
+
[theme, overrideKey],
|
|
116
|
+
)
|
|
92
117
|
const scope = useMemo<ThemeScope>(
|
|
93
118
|
() => ({styles: [...outerScope.styles, ...themeStyles]}),
|
|
94
119
|
[outerScope, themeStyles],
|
|
@@ -97,11 +122,15 @@ export function ThemeProvider({theme = 'dark', children}: ThemeProviderProps) {
|
|
|
97
122
|
return (
|
|
98
123
|
<HostRegistryContext.Provider value={registry}>
|
|
99
124
|
<ThemeScopeContext.Provider value={scope}>
|
|
100
|
-
<ResolvedColorsContext.Provider value={
|
|
101
|
-
<
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
125
|
+
<ResolvedColorsContext.Provider value={resolved}>
|
|
126
|
+
<OverridesContext.Provider value={effective}>
|
|
127
|
+
<html.div
|
|
128
|
+
style={[...themeStyles, styles.root, ownRegistry && styles.canvas] as DivStyle}
|
|
129
|
+
>
|
|
130
|
+
{children}
|
|
131
|
+
{ownRegistry ? <PortalHost placement="base" /> : null}
|
|
132
|
+
</html.div>
|
|
133
|
+
</OverridesContext.Provider>
|
|
105
134
|
</ResolvedColorsContext.Provider>
|
|
106
135
|
</ThemeScopeContext.Provider>
|
|
107
136
|
</HostRegistryContext.Provider>
|