@duro-app/ui 5.27.0 → 5.28.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/components/Select/Select.d.ts +6 -2
- package/dist/components/Select/Select.d.ts.map +1 -1
- package/dist/components/Select/Select.stories.d.ts +6 -0
- package/dist/components/Select/Select.stories.d.ts.map +1 -1
- package/dist/components/Select/SelectContext.d.ts +1 -1
- package/dist/components/Select/SelectContext.d.ts.map +1 -1
- package/dist/components/Select/selectState.d.ts +7 -1
- package/dist/components/Select/selectState.d.ts.map +1 -1
- package/dist/components/Select/styles.css.d.ts +4 -0
- package/dist/components/Select/styles.css.d.ts.map +1 -1
- package/dist/components/Select/useSelectRoot.d.ts.map +1 -1
- package/dist/index.css +1 -1
- package/dist/index.js +1702 -1689
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/components/Select/Select.meta.ts +1 -1
- package/src/components/Select/Select.native.tsx +15 -4
- package/src/components/Select/Select.stories.tsx +52 -0
- package/src/components/Select/Select.tsx +27 -6
- package/src/components/Select/SelectContext.ts +1 -1
- package/src/components/Select/selectState.ts +17 -1
- package/src/components/Select/styles.css.ts +4 -0
- package/src/components/Select/useSelectRoot.ts +32 -22
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@duro-app/ui",
|
|
3
|
-
"version": "5.
|
|
3
|
+
"version": "5.28.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -92,7 +92,7 @@
|
|
|
92
92
|
"dependencies": {
|
|
93
93
|
"@tanstack/react-virtual": "^3.14.13",
|
|
94
94
|
"qrcode": "^1.5.4",
|
|
95
|
-
"@duro-app/tokens": "^5.
|
|
95
|
+
"@duro-app/tokens": "^5.28.0"
|
|
96
96
|
},
|
|
97
97
|
"devDependencies": {
|
|
98
98
|
"@babel/preset-typescript": "^7.28.0",
|
|
@@ -2,7 +2,7 @@ import type {ComponentMeta} from '../component-meta'
|
|
|
2
2
|
|
|
3
3
|
export const meta: ComponentMeta = {
|
|
4
4
|
description:
|
|
5
|
-
'Dropdown select for choosing one value from a list. Compound component — Root is required (throws without it). Select.Trigger takes size="small" to match small Toggles and Buttons in a toolbar. Select.Popup takes label (the list\'s accessible name) and empty (shown in place of the list when it has no items). On the web the list opens below the trigger, or above it when it would overflow the viewport below and fits above. On React Native the trigger opens a bottom sheet (a Drawer at the medium and large detents) titled by label, with a Cancel text button (cancelLabel); the chosen option is filled with a trailing check, and a press picks an option and closes the sheet. Native has no arrow keys: each option is its own screen-reader stop.',
|
|
5
|
+
'Dropdown select for choosing one value from a list. Compound component — Root is required (throws without it). Select.Trigger takes size="small" to match small Toggles and Buttons in a toolbar. Select.Popup takes label (the list\'s accessible name) and empty (shown in place of the list when it has no items). On the web the list opens below the trigger, or above it when it would overflow the viewport below and fits above. On React Native the trigger opens a bottom sheet (a Drawer at the medium and large detents) titled by label, with a Cancel text button (cancelLabel); the chosen option is filled with a trailing check, and a press picks an option and closes the sheet. Native has no arrow keys: each option is its own screen-reader stop. An Item can be disabled (shown, skipped by the keys, inert to a press, announced as disabled), and onOpenChange on Root reports the list opening and closing, so work such as checking which options are available starts only once someone is choosing.',
|
|
6
6
|
whenToUse: [
|
|
7
7
|
'User must pick one option from a predefined list',
|
|
8
8
|
'Form field for country, category, role selection',
|
|
@@ -57,6 +57,8 @@ interface RootProps {
|
|
|
57
57
|
value?: string
|
|
58
58
|
onValueChange?: (value: string | null) => void
|
|
59
59
|
initialLabels?: Record<string, string>
|
|
60
|
+
/** Called when the sheet opens or closes. */
|
|
61
|
+
onOpenChange?: (open: boolean) => void
|
|
60
62
|
children: ReactNode
|
|
61
63
|
}
|
|
62
64
|
|
|
@@ -66,8 +68,15 @@ interface RootProps {
|
|
|
66
68
|
* one and closes the sheet (C0 direction A, "Check"). The screen reader's
|
|
67
69
|
* focus returns to the trigger.
|
|
68
70
|
*/
|
|
69
|
-
function Root({
|
|
70
|
-
|
|
71
|
+
function Root({
|
|
72
|
+
defaultValue,
|
|
73
|
+
value,
|
|
74
|
+
onValueChange,
|
|
75
|
+
initialLabels,
|
|
76
|
+
onOpenChange,
|
|
77
|
+
children,
|
|
78
|
+
}: RootProps) {
|
|
79
|
+
const state = useSelectState({defaultValue, value, onValueChange, initialLabels, onOpenChange})
|
|
71
80
|
const {open, setOpen, setValue} = state
|
|
72
81
|
const listboxId = useId()
|
|
73
82
|
const triggerRef = useRef<unknown>(null)
|
|
@@ -191,10 +200,12 @@ function Popup({children, label, cancelLabel = 'Cancel', empty}: PopupProps) {
|
|
|
191
200
|
|
|
192
201
|
interface ItemProps {
|
|
193
202
|
value: string
|
|
203
|
+
/** Shown but not choosable. */
|
|
204
|
+
disabled?: boolean
|
|
194
205
|
children: ReactNode
|
|
195
206
|
}
|
|
196
207
|
|
|
197
|
-
function Item({value: itemValue, children}: ItemProps) {
|
|
208
|
+
function Item({value: itemValue, disabled = false, children}: ItemProps) {
|
|
198
209
|
const collecting = useContext(CollectContext)
|
|
199
210
|
const {value, registerLabel} = useSelect()
|
|
200
211
|
const label = textOf(children)
|
|
@@ -203,7 +214,7 @@ function Item({value: itemValue, children}: ItemProps) {
|
|
|
203
214
|
}, [collecting, itemValue, label, registerLabel])
|
|
204
215
|
if (collecting) return null
|
|
205
216
|
return (
|
|
206
|
-
<Listbox.Option value={itemValue} selected={value === itemValue}>
|
|
217
|
+
<Listbox.Option value={itemValue} selected={value === itemValue} disabled={disabled}>
|
|
207
218
|
{children}
|
|
208
219
|
</Listbox.Option>
|
|
209
220
|
)
|
|
@@ -297,3 +297,55 @@ export const OpensAboveNearTheBottomEdge: Story = {
|
|
|
297
297
|
await expect(listBox.top).toBeGreaterThanOrEqual(0)
|
|
298
298
|
},
|
|
299
299
|
}
|
|
300
|
+
|
|
301
|
+
const onAgentOpenChange = fn()
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* An option can be `disabled` (shown, not choosable: an agent that cannot take
|
|
305
|
+
* over), and `onOpenChange` reports the list opening and closing, so work such
|
|
306
|
+
* as checking which options are available starts only once someone is choosing.
|
|
307
|
+
*/
|
|
308
|
+
export const DisabledOptionAndOpenChange: Story = {
|
|
309
|
+
render: () => (
|
|
310
|
+
<Select.Root defaultValue="claude" onOpenChange={onAgentOpenChange}>
|
|
311
|
+
<Select.Trigger aria-label="Agent">
|
|
312
|
+
<Select.Value />
|
|
313
|
+
<Select.Icon />
|
|
314
|
+
</Select.Trigger>
|
|
315
|
+
<Select.Popup label="Agent">
|
|
316
|
+
<Select.Item value="claude">Claude</Select.Item>
|
|
317
|
+
<Select.Item value="gemini" disabled>
|
|
318
|
+
Gemini
|
|
319
|
+
</Select.Item>
|
|
320
|
+
<Select.Item value="codex">Codex</Select.Item>
|
|
321
|
+
</Select.Popup>
|
|
322
|
+
</Select.Root>
|
|
323
|
+
),
|
|
324
|
+
play: async ({canvas, userEvent}) => {
|
|
325
|
+
onAgentOpenChange.mockClear()
|
|
326
|
+
const trigger = canvas.getByRole('combobox')
|
|
327
|
+
await userEvent.click(trigger)
|
|
328
|
+
await waitFor(() => expect(onAgentOpenChange).toHaveBeenCalledWith(true))
|
|
329
|
+
const gemini = screen.getByRole('option', {name: 'Gemini'})
|
|
330
|
+
await expect(gemini).toHaveAttribute('aria-disabled', 'true')
|
|
331
|
+
|
|
332
|
+
// The keys step over it: from Claude, Down lands on Codex.
|
|
333
|
+
await userEvent.keyboard('{ArrowDown}')
|
|
334
|
+
await waitFor(() =>
|
|
335
|
+
expect(trigger).toHaveAttribute(
|
|
336
|
+
'aria-activedescendant',
|
|
337
|
+
screen.getByRole('option', {name: 'Codex'}).id,
|
|
338
|
+
),
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
// A press does nothing: the value stays and the list stays open.
|
|
342
|
+
await userEvent.click(gemini)
|
|
343
|
+
await expect(trigger).toHaveTextContent(/Claude/)
|
|
344
|
+
await expect(trigger).toHaveAttribute('aria-expanded', 'true')
|
|
345
|
+
|
|
346
|
+
// Escape from the trigger closes it, and the close is reported too.
|
|
347
|
+
trigger.focus()
|
|
348
|
+
await userEvent.keyboard('{Escape}')
|
|
349
|
+
await waitFor(() => expect(onAgentOpenChange).toHaveBeenLastCalledWith(false))
|
|
350
|
+
},
|
|
351
|
+
}
|
|
@@ -21,11 +21,27 @@ interface RootProps {
|
|
|
21
21
|
value?: string
|
|
22
22
|
onValueChange?: (value: string | null) => void
|
|
23
23
|
initialLabels?: Record<string, string>
|
|
24
|
+
/** Called when the list opens or closes (to start work only once someone is choosing). */
|
|
25
|
+
onOpenChange?: (open: boolean) => void
|
|
24
26
|
children: ReactNode
|
|
25
27
|
}
|
|
26
28
|
|
|
27
|
-
function Root({
|
|
28
|
-
|
|
29
|
+
function Root({
|
|
30
|
+
name,
|
|
31
|
+
defaultValue,
|
|
32
|
+
value,
|
|
33
|
+
onValueChange,
|
|
34
|
+
initialLabels,
|
|
35
|
+
onOpenChange,
|
|
36
|
+
children,
|
|
37
|
+
}: RootProps) {
|
|
38
|
+
const {ctx, rootRef} = useSelectRoot({
|
|
39
|
+
defaultValue,
|
|
40
|
+
value,
|
|
41
|
+
onValueChange,
|
|
42
|
+
initialLabels,
|
|
43
|
+
onOpenChange,
|
|
44
|
+
})
|
|
29
45
|
|
|
30
46
|
return (
|
|
31
47
|
<SelectContext.Provider value={ctx}>
|
|
@@ -217,10 +233,12 @@ function Popup({children, label, empty}: PopupProps) {
|
|
|
217
233
|
// --- Item ---
|
|
218
234
|
interface ItemProps {
|
|
219
235
|
value: string
|
|
236
|
+
/** Shown but not choosable: skipped by the arrow keys, inert to a press, announced as disabled. */
|
|
237
|
+
disabled?: boolean
|
|
220
238
|
children: ReactNode
|
|
221
239
|
}
|
|
222
240
|
|
|
223
|
-
function Item({value: itemValue, children}: ItemProps) {
|
|
241
|
+
function Item({value: itemValue, disabled = false, children}: ItemProps) {
|
|
224
242
|
const {
|
|
225
243
|
value: selectedValue,
|
|
226
244
|
setValue,
|
|
@@ -246,10 +264,11 @@ function Item({value: itemValue, children}: ItemProps) {
|
|
|
246
264
|
useEffect(() => {
|
|
247
265
|
const el = ref.current
|
|
248
266
|
if (!el) return
|
|
249
|
-
return registerItem(id, itemValue, el)
|
|
250
|
-
}, [id, itemValue, registerItem])
|
|
267
|
+
return registerItem(id, itemValue, el, disabled)
|
|
268
|
+
}, [disabled, id, itemValue, registerItem])
|
|
251
269
|
|
|
252
270
|
const handleClick = () => {
|
|
271
|
+
if (disabled) return
|
|
253
272
|
setValue(itemValue)
|
|
254
273
|
close()
|
|
255
274
|
}
|
|
@@ -260,12 +279,14 @@ function Item({value: itemValue, children}: ItemProps) {
|
|
|
260
279
|
id={id}
|
|
261
280
|
role="option"
|
|
262
281
|
aria-selected={isSelected}
|
|
282
|
+
aria-disabled={disabled || undefined}
|
|
263
283
|
onClick={handleClick}
|
|
264
|
-
onPointerEnter={() => setHighlightedId(id)}
|
|
284
|
+
onPointerEnter={disabled ? undefined : () => setHighlightedId(id)}
|
|
265
285
|
style={[
|
|
266
286
|
styles.item,
|
|
267
287
|
isSelected && styles.itemSelected,
|
|
268
288
|
isHighlighted && styles.itemHighlighted,
|
|
289
|
+
disabled && styles.itemDisabled,
|
|
269
290
|
]}
|
|
270
291
|
>
|
|
271
292
|
{children}
|
|
@@ -11,7 +11,7 @@ export interface SelectContextValue {
|
|
|
11
11
|
listboxId: string
|
|
12
12
|
highlightedId: string | null
|
|
13
13
|
setHighlightedId: (id: string | null) => void
|
|
14
|
-
registerItem: (id: string, value: string, element: HTMLElement) => () => void
|
|
14
|
+
registerItem: (id: string, value: string, element: HTMLElement, disabled?: boolean) => () => void
|
|
15
15
|
triggerRef: React.RefObject<HTMLButtonElement | null>
|
|
16
16
|
}
|
|
17
17
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {useCallback, useState} from 'react'
|
|
1
|
+
import {useCallback, useEffect, useRef, useState} from 'react'
|
|
2
2
|
import {useControllableValue} from '../../hooks/useControllableValue'
|
|
3
3
|
|
|
4
4
|
export interface SelectStateOptions {
|
|
@@ -6,6 +6,12 @@ export interface SelectStateOptions {
|
|
|
6
6
|
value?: string
|
|
7
7
|
onValueChange?: (value: string | null) => void
|
|
8
8
|
initialLabels?: Record<string, string>
|
|
9
|
+
/**
|
|
10
|
+
* Called when the list opens or closes, by any means (a press, a key, a pick,
|
|
11
|
+
* Escape, a press outside): for work that should start only once someone is
|
|
12
|
+
* choosing, such as checking which options are available.
|
|
13
|
+
*/
|
|
14
|
+
onOpenChange?: (open: boolean) => void
|
|
9
15
|
}
|
|
10
16
|
|
|
11
17
|
/**
|
|
@@ -19,6 +25,7 @@ export function useSelectState({
|
|
|
19
25
|
value: controlledValue,
|
|
20
26
|
onValueChange,
|
|
21
27
|
initialLabels,
|
|
28
|
+
onOpenChange,
|
|
22
29
|
}: SelectStateOptions) {
|
|
23
30
|
const [value, setValue] = useControllableValue<string | null>(
|
|
24
31
|
controlledValue,
|
|
@@ -26,6 +33,15 @@ export function useSelectState({
|
|
|
26
33
|
onValueChange,
|
|
27
34
|
)
|
|
28
35
|
const [open, setOpen] = useState(false)
|
|
36
|
+
// Reported after the change commits, once per transition; never for the first render.
|
|
37
|
+
const onOpenChangeRef = useRef(onOpenChange)
|
|
38
|
+
onOpenChangeRef.current = onOpenChange
|
|
39
|
+
const reportedOpenRef = useRef(open)
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
if (reportedOpenRef.current === open) return
|
|
42
|
+
reportedOpenRef.current = open
|
|
43
|
+
onOpenChangeRef.current?.(open)
|
|
44
|
+
}, [open])
|
|
29
45
|
const [labels, setLabels] = useState<Record<string, string>>(initialLabels ?? {})
|
|
30
46
|
|
|
31
47
|
const registerLabel = useCallback((v: string, label: string) => {
|
|
@@ -12,8 +12,15 @@ export function useSelectRoot(options: SelectStateOptions) {
|
|
|
12
12
|
const listboxId = useId()
|
|
13
13
|
const rootRef = useRef<HTMLDivElement>(null)
|
|
14
14
|
const triggerRef = useRef<HTMLButtonElement | null>(null)
|
|
15
|
-
const itemsRef = useRef(
|
|
15
|
+
const itemsRef = useRef(
|
|
16
|
+
new Map<string, {value: string; element: HTMLElement; disabled: boolean}>(),
|
|
17
|
+
)
|
|
16
18
|
const orderRef = useRef<string[]>([])
|
|
19
|
+
// What the keys move through: the options in document order, without the disabled ones.
|
|
20
|
+
const enabledOrder = useCallback(
|
|
21
|
+
() => orderRef.current.filter((id) => !itemsRef.current.get(id)?.disabled),
|
|
22
|
+
[],
|
|
23
|
+
)
|
|
17
24
|
|
|
18
25
|
const close = useCallback(() => {
|
|
19
26
|
setOpen(false)
|
|
@@ -25,7 +32,7 @@ export function useSelectRoot(options: SelectStateOptions) {
|
|
|
25
32
|
setOpen((prev) => {
|
|
26
33
|
if (!prev) {
|
|
27
34
|
const items = itemsRef.current
|
|
28
|
-
const order =
|
|
35
|
+
const order = enabledOrder()
|
|
29
36
|
let found: string | null = null
|
|
30
37
|
for (const id of order) {
|
|
31
38
|
const item = items.get(id)
|
|
@@ -40,24 +47,27 @@ export function useSelectRoot(options: SelectStateOptions) {
|
|
|
40
47
|
}
|
|
41
48
|
return !prev
|
|
42
49
|
})
|
|
43
|
-
}, [value, setOpen])
|
|
50
|
+
}, [value, setOpen, enabledOrder])
|
|
44
51
|
|
|
45
|
-
const registerItem = useCallback(
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
52
|
+
const registerItem = useCallback(
|
|
53
|
+
(id: string, itemValue: string, element: HTMLElement, disabled = false) => {
|
|
54
|
+
itemsRef.current.set(id, {value: itemValue, element, disabled})
|
|
55
|
+
const map = itemsRef.current
|
|
56
|
+
const ids = [...map.keys()]
|
|
57
|
+
ids.sort((a, b) => {
|
|
58
|
+
const elA = map.get(a)?.element
|
|
59
|
+
const elB = map.get(b)?.element
|
|
60
|
+
if (!elA || !elB) return 0
|
|
61
|
+
return elA.compareDocumentPosition(elB) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1
|
|
62
|
+
})
|
|
63
|
+
orderRef.current = ids
|
|
64
|
+
return () => {
|
|
65
|
+
itemsRef.current.delete(id)
|
|
66
|
+
orderRef.current = orderRef.current.filter((i) => i !== id)
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
[],
|
|
70
|
+
)
|
|
61
71
|
|
|
62
72
|
// Closed: ArrowDown/ArrowUp on the trigger open the listbox (the APG
|
|
63
73
|
// select-only combobox), on the selected option or the first. In a Toolbar
|
|
@@ -82,7 +92,7 @@ export function useSelectRoot(options: SelectStateOptions) {
|
|
|
82
92
|
if (!root || !open) return
|
|
83
93
|
|
|
84
94
|
function handleKeyDown(e: KeyboardEvent) {
|
|
85
|
-
const order =
|
|
95
|
+
const order = enabledOrder()
|
|
86
96
|
const items = itemsRef.current
|
|
87
97
|
if (e.key === 'Escape') {
|
|
88
98
|
e.preventDefault()
|
|
@@ -124,7 +134,7 @@ export function useSelectRoot(options: SelectStateOptions) {
|
|
|
124
134
|
setHighlightedId((prev) => {
|
|
125
135
|
if (prev) {
|
|
126
136
|
const item = items.get(prev)
|
|
127
|
-
if (item) {
|
|
137
|
+
if (item && !item.disabled) {
|
|
128
138
|
setValue(item.value)
|
|
129
139
|
close()
|
|
130
140
|
}
|
|
@@ -142,7 +152,7 @@ export function useSelectRoot(options: SelectStateOptions) {
|
|
|
142
152
|
|
|
143
153
|
root.addEventListener('keydown', handleKeyDown)
|
|
144
154
|
return () => root.removeEventListener('keydown', handleKeyDown)
|
|
145
|
-
}, [open, close, setValue])
|
|
155
|
+
}, [open, close, setValue, enabledOrder])
|
|
146
156
|
|
|
147
157
|
const ctx: SelectContextValue = useMemo(
|
|
148
158
|
() => ({
|