@duro-app/ui 5.29.0 → 5.30.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@duro-app/ui",
3
- "version": "5.29.0",
3
+ "version": "5.30.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.29.0"
95
+ "@duro-app/tokens": "^5.30.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 action menu. Triggers actions (not value selection). Compound component — Root is required (throws without it). Menu.Trigger renders the button itself: give it the label (text, an Icon), never a Button; an icon-only trigger takes aria-label, and variant="ghost" suits toolbars; size="small" matches small Toggles and Buttons (a small ghost trigger is iconButtonSm). The popup is portalled and placed against the trigger (never clipped by a sticky or overflow ancestor), capped at sizes.listMaxH with scrolling, and takes focus on open (WAI-ARIA menu button); Escape closes only the menu, not an enclosing Popover or Dialog. On React Native the popup is placed against the trigger in a modal overlay; a press outside or the Android back button closes it, and an item runs its onClick after the menu has gone (so an item can open a Dialog). Menu.LinkItem takes onNavigate(href, event) on both platforms (ADR-0002); on native, without it or without preventDefault, the link opens with the system. Native has no arrow keys: each item is its own screen-reader stop.',
5
+ 'Dropdown action menu. Triggers actions (not value selection). Compound component — Root is required (throws without it). Menu.Trigger renders the button itself: give it the label (text, an Icon), never a Button; an icon-only trigger takes aria-label, and variant="ghost" suits toolbars; size="small" matches small Toggles and Buttons (a small ghost trigger is iconButtonSm). The popup is portalled and placed against the trigger (never clipped by a sticky or overflow ancestor), capped at sizes.listMaxH with scrolling, and takes focus on open (WAI-ARIA menu button); Escape closes only the menu, not an enclosing Popover or Dialog. On React Native the popup is placed against the trigger in a modal overlay; a press outside or the Android back button closes it, and an item runs its onClick after the menu has gone (so an item can open a Dialog). Menu.LinkItem takes onNavigate(href, event) on both platforms (ADR-0002); on native, without it or without preventDefault, the link opens with the system. Native has no arrow keys: each item is its own screen-reader stop. A Menu.Item can be disabled: it stays listed and is announced as disabled, but the highlight and the arrow keys skip it and a press or Enter does nothing.',
6
6
  whenToUse: [
7
7
  'Context menu or "more actions" dropdown',
8
8
  'Navigation links in a dropdown',
@@ -197,15 +197,30 @@ const layout = StyleSheet.create({scroll: {maxHeight: SIZES_PX.listMaxH, flexGro
197
197
 
198
198
  interface ItemProps {
199
199
  onClick?: () => void
200
+ /** Shown but not available: inert to a press and announced as disabled. */
201
+ disabled?: boolean
200
202
  children: ReactNode
201
203
  }
202
204
 
203
- function Item({onClick, children}: ItemProps) {
205
+ function Item({onClick, disabled = false, children}: ItemProps) {
204
206
  const {closeThen} = useMenu()
205
207
  return (
206
- <Pressable accessibilityRole="menuitem" onPress={() => closeThen(onClick)}>
208
+ <Pressable
209
+ accessibilityRole="menuitem"
210
+ accessibilityState={{disabled}}
211
+ disabled={disabled}
212
+ onPress={() => closeThen(onClick)}
213
+ >
207
214
  {({pressed}) => (
208
- <html.div style={[styles.item, pressed && nativeStyles.itemPressed]}>{children}</html.div>
215
+ <html.div
216
+ style={[
217
+ styles.item,
218
+ pressed && nativeStyles.itemPressed,
219
+ disabled && styles.itemDisabled,
220
+ ]}
221
+ >
222
+ {children}
223
+ </html.div>
209
224
  )}
210
225
  </Pressable>
211
226
  )
@@ -539,3 +539,45 @@ export const TouchTargets: Story = {
539
539
  const touchStyles = css.create({
540
540
  row: {display: 'flex', gap: spacing.md, alignItems: 'center'},
541
541
  })
542
+
543
+ // A disabled item stays listed and is announced as disabled, but the highlight and the arrow keys
544
+ // skip it and a press does nothing (a Sync menu whose Pull is blocked while a push is running).
545
+ const onPull = fn()
546
+ const onPush = fn()
547
+ export const DisabledItem: Story = {
548
+ render: () => (
549
+ <Menu.Root>
550
+ <Menu.Trigger>Sync</Menu.Trigger>
551
+ <Menu.Popup>
552
+ <Menu.Item disabled onClick={onPull}>
553
+ Pull
554
+ </Menu.Item>
555
+ <Menu.Item onClick={onPush}>Push</Menu.Item>
556
+ <Menu.Item>Fetch</Menu.Item>
557
+ </Menu.Popup>
558
+ </Menu.Root>
559
+ ),
560
+ play: async ({canvas, userEvent}) => {
561
+ onPull.mockClear()
562
+ onPush.mockClear()
563
+ await userEvent.click(canvas.getByRole('button', {name: 'Sync'}))
564
+ const menu = await page().findByRole('menu', {name: 'Sync'})
565
+ const pull = page().getByRole('menuitem', {name: 'Pull'})
566
+ const push = page().getByRole('menuitem', {name: 'Push'})
567
+ await expect(pull).toHaveAttribute('aria-disabled', 'true')
568
+ // The highlight opens on the first item that can be chosen.
569
+ await waitFor(() => expect(menu).toHaveAttribute('aria-activedescendant', push.id))
570
+ await userEvent.click(pull)
571
+ await expect(onPull).not.toHaveBeenCalled()
572
+ await expect(menu).toBeInTheDocument()
573
+ // Up from Push wraps to Fetch, never landing on Pull.
574
+ await userEvent.keyboard('{ArrowUp}')
575
+ await expect(menu).toHaveAttribute(
576
+ 'aria-activedescendant',
577
+ page().getByRole('menuitem', {name: 'Fetch'}).id,
578
+ )
579
+ await userEvent.keyboard('{ArrowDown}')
580
+ await userEvent.keyboard('{Enter}')
581
+ await expect(onPush).toHaveBeenCalledOnce()
582
+ },
583
+ }
@@ -186,10 +186,12 @@ function Popup({children, align = 'start'}: PopupProps) {
186
186
  // --- Item ---
187
187
  interface ItemProps {
188
188
  onClick?: () => void
189
+ /** Shown but not available: skipped by the arrow keys, inert to a press, announced as disabled. */
190
+ disabled?: boolean
189
191
  children: ReactNode
190
192
  }
191
193
 
192
- function Item({onClick, children}: ItemProps) {
194
+ function Item({onClick, disabled = false, children}: ItemProps) {
193
195
  const {close, highlightedId, setHighlightedId, registerItem} = useMenu()
194
196
  const id = useId()
195
197
  const ref = useRef<HTMLDivElement>(null)
@@ -198,10 +200,11 @@ function Item({onClick, children}: ItemProps) {
198
200
  useEffect(() => {
199
201
  const el = ref.current
200
202
  if (!el) return
201
- return registerItem(id, el)
202
- }, [id, registerItem])
203
+ return registerItem(id, el, disabled)
204
+ }, [disabled, id, registerItem])
203
205
 
204
206
  const handleClick = () => {
207
+ if (disabled) return
205
208
  onClick?.()
206
209
  close()
207
210
  }
@@ -211,9 +214,14 @@ function Item({onClick, children}: ItemProps) {
211
214
  ref={ref}
212
215
  id={id}
213
216
  role="menuitem"
217
+ aria-disabled={disabled || undefined}
214
218
  onClick={handleClick}
215
- onPointerEnter={() => setHighlightedId(id)}
216
- style={[styles.item, isHighlighted && styles.itemHighlighted]}
219
+ onPointerEnter={disabled ? undefined : () => setHighlightedId(id)}
220
+ style={[
221
+ styles.item,
222
+ isHighlighted && styles.itemHighlighted,
223
+ disabled && styles.itemDisabled,
224
+ ]}
217
225
  >
218
226
  {children}
219
227
  </html.div>
@@ -9,7 +9,8 @@ export interface MenuContextValue {
9
9
  triggerId: string
10
10
  highlightedId: string | null
11
11
  setHighlightedId: (id: string | null) => void
12
- registerItem: (id: string, element: HTMLElement) => () => void
12
+ /** A disabled item stays listed but the keys skip it and Enter does nothing. */
13
+ registerItem: (id: string, element: HTMLElement, disabled?: boolean) => () => void
13
14
  triggerRef: React.RefObject<HTMLButtonElement | null>
14
15
  popupRef: React.RefObject<HTMLDivElement | null>
15
16
  }
@@ -111,6 +111,10 @@ export const styles = css.create({
111
111
  transitionProperty: 'background-color',
112
112
  transitionDuration: duration.fast,
113
113
  },
114
+ itemDisabled: {
115
+ color: colors.textDisabled,
116
+ cursor: 'not-allowed',
117
+ },
114
118
  // The fill is the highlight; the transparent outline is what forced-colors
115
119
  // mode paints, where fills are dropped.
116
120
  itemHighlighted: {
@@ -29,6 +29,12 @@ export function useMenuRoot(options: MenuRootOptions) {
29
29
  const popupRef = useRef<HTMLDivElement | null>(null)
30
30
  const itemsRef = useRef(new Map<string, HTMLElement>())
31
31
  const orderRef = useRef<string[]>([])
32
+ const disabledRef = useRef(new Set<string>())
33
+ // What the keys move through: the items in document order, without the disabled ones.
34
+ const enabledOrder = useCallback(
35
+ () => orderRef.current.filter((id) => !disabledRef.current.has(id)),
36
+ [],
37
+ )
32
38
  // Closes and leaves focus where it is (a press elsewhere put it there).
33
39
  const dismiss = useCallback(() => {
34
40
  if (!openRef.current) return
@@ -54,13 +60,13 @@ export function useMenuRoot(options: MenuRootOptions) {
54
60
  useEffect(() => {
55
61
  if (open && !wasOpenRef.current) {
56
62
  popupRef.current?.focus({preventScroll: true})
57
- const first = orderRef.current[0]
63
+ const first = enabledOrder()[0]
58
64
  if (first) setHighlightedId(first)
59
65
  } else if (!open && wasOpenRef.current) {
60
66
  setHighlightedId(null)
61
67
  }
62
68
  wasOpenRef.current = open
63
- }, [open])
69
+ }, [open, enabledOrder])
64
70
 
65
71
  // Keep the highlighted item in view when the popup scrolls.
66
72
  useEffect(() => {
@@ -69,8 +75,10 @@ export function useMenuRoot(options: MenuRootOptions) {
69
75
  el?.scrollIntoView?.({block: 'nearest'})
70
76
  }, [highlightedId])
71
77
 
72
- const registerItem = useCallback((id: string, element: HTMLElement) => {
78
+ const registerItem = useCallback((id: string, element: HTMLElement, disabled = false) => {
73
79
  itemsRef.current.set(id, element)
80
+ if (disabled) disabledRef.current.add(id)
81
+ else disabledRef.current.delete(id)
74
82
  const map = itemsRef.current
75
83
  const ids = [...map.keys()]
76
84
  ids.sort((a, b) => {
@@ -82,6 +90,7 @@ export function useMenuRoot(options: MenuRootOptions) {
82
90
  orderRef.current = ids
83
91
  return () => {
84
92
  itemsRef.current.delete(id)
93
+ disabledRef.current.delete(id)
85
94
  orderRef.current = orderRef.current.filter((i) => i !== id)
86
95
  }
87
96
  }, [])
@@ -104,7 +113,7 @@ export function useMenuRoot(options: MenuRootOptions) {
104
113
  close()
105
114
  return
106
115
  }
107
- const order = orderRef.current
116
+ const order = enabledOrder()
108
117
  if (order.length === 0) return
109
118
 
110
119
  switch (e.key) {
@@ -138,7 +147,7 @@ export function useMenuRoot(options: MenuRootOptions) {
138
147
  case ' ': {
139
148
  e.preventDefault()
140
149
  const current = highlightedRef.current
141
- if (current) itemsRef.current.get(current)?.click()
150
+ if (current && !disabledRef.current.has(current)) itemsRef.current.get(current)?.click()
142
151
  break
143
152
  }
144
153
  }
@@ -146,7 +155,7 @@ export function useMenuRoot(options: MenuRootOptions) {
146
155
 
147
156
  popup.addEventListener('keydown', handleKeyDown)
148
157
  return () => popup.removeEventListener('keydown', handleKeyDown)
149
- }, [open, close])
158
+ }, [open, close, enabledOrder])
150
159
 
151
160
  // A press outside the trigger and the popup closes the menu, and leaves
152
161
  // focus where the press put it.