@duro-app/ui 5.27.0 → 5.29.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 (32) hide show
  1. package/dist/components/List/List.d.ts.map +1 -1
  2. package/dist/components/List/List.stories.d.ts +1 -0
  3. package/dist/components/List/List.stories.d.ts.map +1 -1
  4. package/dist/components/List/styles.css.d.ts +19 -0
  5. package/dist/components/List/styles.css.d.ts.map +1 -1
  6. package/dist/components/Select/Select.d.ts +6 -2
  7. package/dist/components/Select/Select.d.ts.map +1 -1
  8. package/dist/components/Select/Select.stories.d.ts +6 -0
  9. package/dist/components/Select/Select.stories.d.ts.map +1 -1
  10. package/dist/components/Select/SelectContext.d.ts +1 -1
  11. package/dist/components/Select/SelectContext.d.ts.map +1 -1
  12. package/dist/components/Select/selectState.d.ts +7 -1
  13. package/dist/components/Select/selectState.d.ts.map +1 -1
  14. package/dist/components/Select/styles.css.d.ts +4 -0
  15. package/dist/components/Select/styles.css.d.ts.map +1 -1
  16. package/dist/components/Select/useSelectRoot.d.ts.map +1 -1
  17. package/dist/index.css +1 -1
  18. package/dist/index.js +2855 -2802
  19. package/dist/index.js.map +1 -1
  20. package/package.json +2 -2
  21. package/src/components/List/List.meta.ts +1 -1
  22. package/src/components/List/List.stories.tsx +54 -0
  23. package/src/components/List/List.tsx +49 -11
  24. package/src/components/List/styles.css.ts +22 -1
  25. package/src/components/Select/Select.meta.ts +1 -1
  26. package/src/components/Select/Select.native.tsx +15 -4
  27. package/src/components/Select/Select.stories.tsx +52 -0
  28. package/src/components/Select/Select.tsx +27 -6
  29. package/src/components/Select/SelectContext.ts +1 -1
  30. package/src/components/Select/selectState.ts +17 -1
  31. package/src/components/Select/styles.css.ts +4 -0
  32. 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.27.0",
3
+ "version": "5.29.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.27.0"
95
+ "@duro-app/tokens": "^5.29.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
- 'Vertical list of interactive items. Ideal for narrow spaces (detail panels, sidebars) where Table would truncate. Supports selection, descriptions, and action slots.',
5
+ 'Vertical list of interactive items. Ideal for narrow spaces (detail panels, sidebars) where Table would truncate. Supports selection, descriptions, and action slots. An Item with onClick is pressable anywhere on its row, and its Content becomes a button: focusable, pressed with Enter or Space, announced with its text, disabled with the Item; the Actions stay beside it, so a Button there never nests in it.',
6
6
  whenToUse: [
7
7
  'Displaying items in a narrow container (< 480px)',
8
8
  'Detail panels, drawers, and sidebars',
@@ -221,3 +221,57 @@ function DetailPanelDemo() {
221
221
  export const SingleSelection: Story = {
222
222
  render: () => <DetailPanelDemo />,
223
223
  }
224
+
225
+ // --- Clickable items ---
226
+
227
+ function ClickableDemo() {
228
+ const [picked, setPicked] = useState<string[]>([])
229
+ const turns = [
230
+ {
231
+ id: 't1',
232
+ text: 'Fix the login bug where a stale token logs the user out mid-session',
233
+ reply: 'Done',
234
+ },
235
+ {id: 't2', text: 'Add a test for it', reply: 'Added auth.test.ts'},
236
+ {id: 't3', text: 'Still sending', reply: null},
237
+ ]
238
+ return (
239
+ <html.div style={narrowContainer.box}>
240
+ <List.Root aria-label="Turns">
241
+ {turns.map((turn) => (
242
+ <List.Item
243
+ key={turn.id}
244
+ disabled={turn.reply === null}
245
+ onClick={() => setPicked((all) => [...all, turn.id])}
246
+ >
247
+ <List.Content>
248
+ <List.Text>{turn.text}</List.Text>
249
+ {turn.reply ? <List.Description>{turn.reply}</List.Description> : null}
250
+ </List.Content>
251
+ <List.Actions>
252
+ <Badge size="sm">Turn</Badge>
253
+ </List.Actions>
254
+ </List.Item>
255
+ ))}
256
+ </List.Root>
257
+ <html.p>Picked: {picked.join(', ')}</html.p>
258
+ </html.div>
259
+ )
260
+ }
261
+
262
+ export const ClickableItems: Story = {
263
+ render: () => <ClickableDemo />,
264
+ play: async ({canvas, userEvent}) => {
265
+ const buttons = canvas.getAllByRole('button')
266
+ await expect(buttons).toHaveLength(3)
267
+ // The keyboard reaches the item and presses it, once.
268
+ await userEvent.tab()
269
+ await expect(buttons[0]).toHaveFocus()
270
+ await userEvent.keyboard('{Enter}')
271
+ await expect(canvas.getByText('Picked: t1')).toBeInTheDocument()
272
+ // A press on the row outside the text is one press too.
273
+ await userEvent.click(canvas.getAllByText('Turn')[1])
274
+ await expect(canvas.getByText('Picked: t1, t2')).toBeInTheDocument()
275
+ await expect(buttons[2]).toBeDisabled()
276
+ },
277
+ }
@@ -1,4 +1,4 @@
1
- import {type ReactNode, createContext, useContext} from 'react'
1
+ import {type ReactNode, createContext, useContext, useMemo} from 'react'
2
2
  import {html} from 'react-strict-dom'
3
3
  import {styles} from './styles.css'
4
4
 
@@ -50,27 +50,65 @@ interface ItemProps {
50
50
  onClick?: () => void
51
51
  }
52
52
 
53
+ // What a clickable Item hands its Content, which becomes the item's button.
54
+ interface ItemActionValue {
55
+ onClick: () => void
56
+ disabled: boolean
57
+ }
58
+
59
+ const ItemActionContext = createContext<ItemActionValue | null>(null)
60
+
53
61
  function Item({children, selected, disabled, onClick}: ItemProps) {
54
62
  const {selectionMode} = useList()
55
63
  const isSelectable = selectionMode !== 'none'
64
+ const action = useMemo(
65
+ () => (onClick ? {onClick, disabled: disabled === true} : null),
66
+ [onClick, disabled],
67
+ )
56
68
 
57
69
  return (
58
- <html.div
59
- role="listitem"
60
- aria-selected={isSelectable ? selected : undefined}
61
- aria-disabled={disabled || undefined}
62
- onClick={disabled ? undefined : onClick}
63
- style={[styles.item, selected && styles.itemSelected, disabled && styles.itemDisabled]}
64
- >
65
- {children}
66
- </html.div>
70
+ <ItemActionContext.Provider value={action}>
71
+ <html.div
72
+ role="listitem"
73
+ aria-selected={isSelectable ? selected : undefined}
74
+ aria-disabled={disabled || undefined}
75
+ // A press anywhere on the row; the Content's button is what keyboards and screen
76
+ // readers reach.
77
+ onClick={disabled ? undefined : onClick}
78
+ style={[
79
+ styles.item,
80
+ onClick != null && !disabled && styles.itemClickable,
81
+ selected && styles.itemSelected,
82
+ disabled && styles.itemDisabled,
83
+ ]}
84
+ >
85
+ {children}
86
+ </html.div>
87
+ </ItemActionContext.Provider>
67
88
  )
68
89
  }
69
90
 
70
91
  // --- Content: wraps primary text + description ---
71
92
 
72
93
  function Content({children}: {children: ReactNode}) {
73
- return <html.div style={styles.content}>{children}</html.div>
94
+ const action = useContext(ItemActionContext)
95
+ if (!action) return <html.div style={styles.content}>{children}</html.div>
96
+ // In a clickable Item the Content is a real button: focusable, pressed with Enter or Space,
97
+ // announced with its text. The Actions stay beside it, never nested inside.
98
+ return (
99
+ <html.button
100
+ type="button"
101
+ disabled={action.disabled}
102
+ onClick={(event) => {
103
+ // The row handles a press too; this one has been answered.
104
+ event.stopPropagation()
105
+ action.onClick()
106
+ }}
107
+ style={[styles.content, styles.contentButton]}
108
+ >
109
+ {children}
110
+ </html.button>
111
+ )
74
112
  }
75
113
 
76
114
  // --- Text: primary label ---
@@ -1,6 +1,6 @@
1
1
  import {css} from 'react-strict-dom'
2
2
  import {colors} from '@duro-app/tokens/tokens/colors.css'
3
- import {microSpacing, spacing} from '@duro-app/tokens/tokens/spacing.css'
3
+ import {microSpacing, radii, spacing} from '@duro-app/tokens/tokens/spacing.css'
4
4
  import {typography} from '@duro-app/tokens/tokens/typography.css'
5
5
  import {duration} from '@duro-app/tokens/tokens/motion.css'
6
6
  import {borders} from '@duro-app/tokens/tokens/borders.css'
@@ -40,6 +40,9 @@ export const styles = css.create({
40
40
  ':hover': colors.bgCardHover,
41
41
  },
42
42
  },
43
+ itemClickable: {
44
+ cursor: 'pointer',
45
+ },
43
46
  itemDisabled: {
44
47
  opacity: 0.5,
45
48
  pointerEvents: 'none',
@@ -57,6 +60,24 @@ export const styles = css.create({
57
60
  minWidth: 0,
58
61
  },
59
62
 
63
+ // The Content of a clickable Item: a button that reads as the row's text.
64
+ contentButton: {
65
+ alignItems: 'stretch',
66
+ textAlign: 'start',
67
+ backgroundColor: 'transparent',
68
+ borderWidth: 0,
69
+ padding: 0,
70
+ margin: 0,
71
+ font: 'inherit',
72
+ color: 'inherit',
73
+ cursor: 'inherit',
74
+ outlineWidth: {default: 0, ':focus-visible': borders.focusRing},
75
+ outlineStyle: {default: 'none', ':focus-visible': 'solid'},
76
+ outlineColor: {default: 'transparent', ':focus-visible': colors.accent},
77
+ outlineOffset: {default: 0, ':focus-visible': borders.focusOffset},
78
+ borderRadius: radii.xs,
79
+ },
80
+
60
81
  // --- Primary text ---
61
82
  text: {
62
83
  fontSize: typography.fontSizeSm,
@@ -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({defaultValue, value, onValueChange, initialLabels, children}: RootProps) {
70
- const state = useSelectState({defaultValue, value, onValueChange, initialLabels})
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({name, defaultValue, value, onValueChange, initialLabels, children}: RootProps) {
28
- const {ctx, rootRef} = useSelectRoot({defaultValue, value, onValueChange, initialLabels})
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) => {
@@ -121,6 +121,10 @@ export const styles = css.create({
121
121
  itemHighlighted: {
122
122
  backgroundColor: colors.bgCardHover,
123
123
  },
124
+ itemDisabled: {
125
+ color: colors.textDisabled,
126
+ cursor: 'not-allowed',
127
+ },
124
128
  hidden: {
125
129
  display: 'none',
126
130
  },
@@ -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(new Map<string, {value: string; element: HTMLElement}>())
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 = orderRef.current
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((id: string, itemValue: string, element: HTMLElement) => {
46
- itemsRef.current.set(id, {value: itemValue, element})
47
- const map = itemsRef.current
48
- const ids = [...map.keys()]
49
- ids.sort((a, b) => {
50
- const elA = map.get(a)?.element
51
- const elB = map.get(b)?.element
52
- if (!elA || !elB) return 0
53
- return elA.compareDocumentPosition(elB) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1
54
- })
55
- orderRef.current = ids
56
- return () => {
57
- itemsRef.current.delete(id)
58
- orderRef.current = orderRef.current.filter((i) => i !== id)
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 = orderRef.current
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
  () => ({