@duro-app/ui 5.28.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.
Files changed (32) hide show
  1. package/dist/{Table-Cw1B14R3.js → Table-ypIvytAS.js} +677 -664
  2. package/dist/Table-ypIvytAS.js.map +1 -0
  3. package/dist/components/List/List.d.ts.map +1 -1
  4. package/dist/components/List/List.stories.d.ts +1 -0
  5. package/dist/components/List/List.stories.d.ts.map +1 -1
  6. package/dist/components/List/styles.css.d.ts +19 -0
  7. package/dist/components/List/styles.css.d.ts.map +1 -1
  8. package/dist/components/Menu/Menu.d.ts +3 -1
  9. package/dist/components/Menu/Menu.d.ts.map +1 -1
  10. package/dist/components/Menu/Menu.stories.d.ts +1 -0
  11. package/dist/components/Menu/Menu.stories.d.ts.map +1 -1
  12. package/dist/components/Menu/MenuContext.d.ts +2 -1
  13. package/dist/components/Menu/MenuContext.d.ts.map +1 -1
  14. package/dist/components/Menu/styles.css.d.ts +4 -0
  15. package/dist/components/Menu/styles.css.d.ts.map +1 -1
  16. package/dist/components/Menu/useMenuRoot.d.ts.map +1 -1
  17. package/dist/index.js +2335 -2295
  18. package/dist/index.js.map +1 -1
  19. package/dist/table.js +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/Menu/Menu.meta.ts +1 -1
  26. package/src/components/Menu/Menu.native.tsx +18 -3
  27. package/src/components/Menu/Menu.stories.tsx +42 -0
  28. package/src/components/Menu/Menu.tsx +13 -5
  29. package/src/components/Menu/MenuContext.ts +2 -1
  30. package/src/components/Menu/styles.css.ts +4 -0
  31. package/src/components/Menu/useMenuRoot.ts +15 -6
  32. package/dist/Table-Cw1B14R3.js.map +0 -1
package/dist/table.js CHANGED
@@ -1,4 +1,4 @@
1
- import { M as F, q as gt, R as it, H as kt, t as pt, v as bt, w as ft, x as mt, b as ht, g as yt, r as St, y as Tt } from "./Table-Cw1B14R3.js";
1
+ import { M as F, q as gt, R as it, H as kt, t as pt, v as bt, w as ft, x as mt, b as ht, g as yt, r as St, y as Tt } from "./Table-ypIvytAS.js";
2
2
  import { jsx as u, jsxs as q } from "react/jsx-runtime";
3
3
  import { c as H, m as p, d as b } from "./compiler-runtime-BuugS4D0.js";
4
4
  import { flexRender as E, useReactTable as rt, getPaginationRowModel as Ct, getFilteredRowModel as vt, getSortedRowModel as at, getCoreRowModel as st } from "@tanstack/react-table";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@duro-app/ui",
3
- "version": "5.28.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.28.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
- '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 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.