@duro-app/ui 5.5.0 → 5.7.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 (115) hide show
  1. package/dist/{Table-DYN-bp0e.js → Table-Dj7PXqO_.js} +405 -387
  2. package/dist/Table-Dj7PXqO_.js.map +1 -0
  3. package/dist/components/Button/Button.d.ts +2 -1
  4. package/dist/components/Button/Button.d.ts.map +1 -1
  5. package/dist/components/Button/styles.css.d.ts +1 -0
  6. package/dist/components/Button/styles.css.d.ts.map +1 -1
  7. package/dist/components/ButtonGroup/ButtonGroup.stories.d.ts +14 -0
  8. package/dist/components/ButtonGroup/ButtonGroup.stories.d.ts.map +1 -1
  9. package/dist/components/DragDrop/DragDrop.d.ts +95 -14
  10. package/dist/components/DragDrop/DragDrop.d.ts.map +1 -1
  11. package/dist/components/DragDrop/DragDrop.meta.d.ts.map +1 -1
  12. package/dist/components/DragDrop/DragDropBoard.stories.d.ts +69 -0
  13. package/dist/components/DragDrop/DragDropBoard.stories.d.ts.map +1 -0
  14. package/dist/components/DragDrop/styles.css.d.ts +80 -3
  15. package/dist/components/DragDrop/styles.css.d.ts.map +1 -1
  16. package/dist/components/LinkButton/styles.css.d.ts +1 -0
  17. package/dist/components/LinkButton/styles.css.d.ts.map +1 -1
  18. package/dist/components/Menu/Menu.d.ts +4 -1
  19. package/dist/components/Menu/Menu.d.ts.map +1 -1
  20. package/dist/components/Menu/styles.css.d.ts +14 -0
  21. package/dist/components/Menu/styles.css.d.ts.map +1 -1
  22. package/dist/components/Meter/Meter.d.ts +17 -0
  23. package/dist/components/Meter/Meter.d.ts.map +1 -0
  24. package/dist/components/Meter/Meter.meta.d.ts +3 -0
  25. package/dist/components/Meter/Meter.meta.d.ts.map +1 -0
  26. package/dist/components/Meter/Meter.stories.d.ts +12 -0
  27. package/dist/components/Meter/Meter.stories.d.ts.map +1 -0
  28. package/dist/components/Meter/styles.css.d.ts +34 -0
  29. package/dist/components/Meter/styles.css.d.ts.map +1 -0
  30. package/dist/components/Popover/Popover.d.ts +14 -2
  31. package/dist/components/Popover/Popover.d.ts.map +1 -1
  32. package/dist/components/Popover/Popover.meta.d.ts.map +1 -1
  33. package/dist/components/Popover/PopoverLayerContext.d.ts +3 -0
  34. package/dist/components/Popover/PopoverLayerContext.d.ts.map +1 -1
  35. package/dist/components/Popover/PopoverRaised.stories.d.ts +28 -0
  36. package/dist/components/Popover/PopoverRaised.stories.d.ts.map +1 -0
  37. package/dist/components/Popover/styles.css.d.ts +15 -0
  38. package/dist/components/Popover/styles.css.d.ts.map +1 -1
  39. package/dist/components/Select/Select.d.ts +4 -1
  40. package/dist/components/Select/Select.d.ts.map +1 -1
  41. package/dist/components/Select/styles.css.d.ts +9 -0
  42. package/dist/components/Select/styles.css.d.ts.map +1 -1
  43. package/dist/components/Timeline/Timeline.d.ts +71 -0
  44. package/dist/components/Timeline/Timeline.d.ts.map +1 -0
  45. package/dist/components/Timeline/Timeline.meta.d.ts +3 -0
  46. package/dist/components/Timeline/Timeline.meta.d.ts.map +1 -0
  47. package/dist/components/Timeline/Timeline.stories.d.ts +26 -0
  48. package/dist/components/Timeline/Timeline.stories.d.ts.map +1 -0
  49. package/dist/components/Timeline/styles.css.d.ts +158 -0
  50. package/dist/components/Timeline/styles.css.d.ts.map +1 -0
  51. package/dist/components/Toast/Toast.meta.d.ts.map +1 -1
  52. package/dist/components/Toast/Toast.stories.d.ts +9 -0
  53. package/dist/components/Toast/Toast.stories.d.ts.map +1 -1
  54. package/dist/components/Toast/ToastProvider.d.ts +11 -5
  55. package/dist/components/Toast/ToastProvider.d.ts.map +1 -1
  56. package/dist/components/Toast/index.d.ts +1 -1
  57. package/dist/components/Toast/index.d.ts.map +1 -1
  58. package/dist/components/Toast/styles.css.d.ts +6 -0
  59. package/dist/components/Toast/styles.css.d.ts.map +1 -1
  60. package/dist/components/Toggle/Toggle.d.ts +2 -1
  61. package/dist/components/Toggle/Toggle.d.ts.map +1 -1
  62. package/dist/components/Toggle/styles.css.d.ts +1 -0
  63. package/dist/components/Toggle/styles.css.d.ts.map +1 -1
  64. package/dist/index.css +1 -1
  65. package/dist/index.d.ts +6 -2
  66. package/dist/index.d.ts.map +1 -1
  67. package/dist/index.js +4992 -3845
  68. package/dist/index.js.map +1 -1
  69. package/dist/shared/ModalContext.d.ts +7 -0
  70. package/dist/shared/ModalContext.d.ts.map +1 -1
  71. package/dist/shared/exports.typecheck.d.ts +9 -0
  72. package/dist/shared/exports.typecheck.d.ts.map +1 -0
  73. package/dist/shared/types.d.ts +2 -0
  74. package/dist/shared/types.d.ts.map +1 -1
  75. package/dist/table.js +1 -1
  76. package/package.json +2 -2
  77. package/src/components/Button/Button.tsx +2 -1
  78. package/src/components/Button/styles.css.ts +3 -0
  79. package/src/components/ButtonGroup/ButtonGroup.stories.tsx +172 -14
  80. package/src/components/DragDrop/DragDrop.meta.ts +22 -21
  81. package/src/components/DragDrop/DragDrop.tsx +1147 -246
  82. package/src/components/DragDrop/DragDropBoard.stories.tsx +668 -0
  83. package/src/components/DragDrop/styles.css.ts +74 -4
  84. package/src/components/LinkButton/styles.css.ts +4 -0
  85. package/src/components/Menu/Menu.meta.ts +1 -1
  86. package/src/components/Menu/Menu.tsx +17 -2
  87. package/src/components/Menu/styles.css.ts +18 -0
  88. package/src/components/Meter/Meter.meta.ts +24 -0
  89. package/src/components/Meter/Meter.stories.tsx +64 -0
  90. package/src/components/Meter/Meter.tsx +55 -0
  91. package/src/components/Meter/styles.css.ts +30 -0
  92. package/src/components/Popover/Popover.meta.ts +2 -1
  93. package/src/components/Popover/Popover.tsx +29 -4
  94. package/src/components/Popover/PopoverLayerContext.ts +3 -0
  95. package/src/components/Popover/PopoverRaised.stories.tsx +334 -0
  96. package/src/components/Popover/styles.css.ts +21 -0
  97. package/src/components/Select/Select.meta.ts +1 -1
  98. package/src/components/Select/Select.tsx +5 -1
  99. package/src/components/Select/styles.css.ts +12 -0
  100. package/src/components/Timeline/Timeline.meta.ts +46 -0
  101. package/src/components/Timeline/Timeline.stories.tsx +238 -0
  102. package/src/components/Timeline/Timeline.tsx +332 -0
  103. package/src/components/Timeline/styles.css.ts +162 -0
  104. package/src/components/Toast/Toast.meta.ts +2 -1
  105. package/src/components/Toast/Toast.stories.tsx +84 -0
  106. package/src/components/Toast/ToastProvider.tsx +49 -11
  107. package/src/components/Toast/index.ts +7 -1
  108. package/src/components/Toast/styles.css.ts +2 -0
  109. package/src/components/Toggle/Toggle.tsx +2 -1
  110. package/src/components/Toggle/styles.css.ts +2 -0
  111. package/src/index.ts +18 -0
  112. package/src/shared/ModalContext.ts +8 -1
  113. package/src/shared/exports.typecheck.ts +16 -0
  114. package/src/shared/types.ts +3 -0
  115. package/dist/Table-DYN-bp0e.js.map +0 -1
@@ -0,0 +1,668 @@
1
+ import type {Meta, StoryObj} from '@storybook/react'
2
+ import {expect, fn, waitFor} from 'storybook/test'
3
+ import {Profiler, useState, type ReactNode} from 'react'
4
+ import {css, html} from 'react-strict-dom'
5
+ import {
6
+ DragDrop,
7
+ type DragDropEvent,
8
+ type DragDropItemData,
9
+ type DragDropOverEvent,
10
+ } from './DragDrop'
11
+ import {Text} from '../Text/Text'
12
+ import {onThemeSurface} from '../../docs/themedSurface'
13
+ import {colors} from '@duro-app/tokens/tokens/colors.css'
14
+ import {radii, spacing} from '@duro-app/tokens/tokens/spacing.css'
15
+ import {borders} from '@duro-app/tokens/tokens/borders.css'
16
+ import {sizes} from '@duro-app/tokens/tokens/sizes.css'
17
+ import {typography} from '@duro-app/tokens/tokens/typography.css'
18
+
19
+ /**
20
+ * The board shapes DragDrop 5.x is for: columns that are labelled lists,
21
+ * cards with a keyboard handle, a card zone nested in a column zone (attach),
22
+ * a refusal with a reason, edge auto-scroll, and a 300-card board for the
23
+ * render budget.
24
+ */
25
+ interface Args {
26
+ onDrop?: (e: DragDropEvent<Card>) => void
27
+ onDragOver?: (e: DragDropOverEvent<Card>) => void
28
+ }
29
+
30
+ const meta: Meta<Args> = {
31
+ title: 'Interaction/DragDropBoard',
32
+ parameters: {a11y: {test: 'error'}},
33
+ decorators: [onThemeSurface],
34
+ }
35
+
36
+ export default meta
37
+ type Story = StoryObj<Args>
38
+
39
+ const s = css.create({
40
+ board: {
41
+ display: 'flex',
42
+ flexDirection: 'row',
43
+ alignItems: 'flex-start',
44
+ gap: spacing.md,
45
+ },
46
+ column: {
47
+ display: 'flex',
48
+ flexDirection: 'column',
49
+ gap: spacing.sm,
50
+ width: sizes.gridColXs,
51
+ flexShrink: 0,
52
+ },
53
+ // A grid stretches the zone to the column's height, so the empty space
54
+ // under the cards is part of the zone.
55
+ list: {
56
+ display: 'grid',
57
+ minHeight: sizes.dropZoneMinH,
58
+ padding: spacing.sm,
59
+ borderWidth: borders.hairline,
60
+ borderStyle: 'solid',
61
+ borderColor: colors.border,
62
+ borderRadius: radii.sm,
63
+ backgroundColor: colors.bgCard,
64
+ },
65
+ scroller: {
66
+ maxHeight: sizes.listMaxHSm,
67
+ overflowY: 'auto',
68
+ },
69
+ card: {
70
+ padding: spacing.sm,
71
+ borderWidth: borders.hairline,
72
+ borderStyle: 'solid',
73
+ borderColor: colors.border,
74
+ borderRadius: radii.sm,
75
+ backgroundColor: colors.bg,
76
+ color: colors.text,
77
+ fontSize: typography.fontSizeSm,
78
+ },
79
+ activity: {
80
+ borderStyle: 'dashed',
81
+ },
82
+ slot: {
83
+ display: 'flex',
84
+ alignItems: 'center',
85
+ justifyContent: 'center',
86
+ minHeight: sizes.controlMd,
87
+ borderWidth: borders.strong,
88
+ borderStyle: 'dashed',
89
+ borderColor: colors.accent,
90
+ borderRadius: radii.sm,
91
+ backgroundColor: colors.infoBg,
92
+ color: colors.infoText,
93
+ fontSize: typography.fontSizeXs,
94
+ },
95
+ })
96
+
97
+ interface Card {
98
+ readonly id: string
99
+ readonly title: string
100
+ readonly kind: 'task' | 'activity'
101
+ }
102
+
103
+ type Columns = Record<string, Card[]>
104
+
105
+ const COLUMNS = [
106
+ {id: 'next', name: 'Next'},
107
+ {id: 'doing', name: 'In progress'},
108
+ {id: 'done', name: 'Done'},
109
+ ] as const
110
+
111
+ const placeholder = ({label}: {label: string}) => (
112
+ <html.div style={s.slot}>
113
+ <html.span>→ {label}</html.span>
114
+ </html.div>
115
+ )
116
+
117
+ /** Move a card to (zone, index), index counted without the card itself. */
118
+ const moveCard = (cols: Columns, id: string, zone: string, index: number): Columns => {
119
+ let card: Card | undefined
120
+ const next: Columns = {}
121
+ for (const [k, list] of Object.entries(cols)) {
122
+ next[k] = list.filter((c) => (c.id === id ? ((card = c), false) : true))
123
+ }
124
+ if (!card) return cols
125
+ const target = next[zone] ?? []
126
+ next[zone] = [...target.slice(0, index), card, ...target.slice(index)]
127
+ return next
128
+ }
129
+
130
+ function CardFace({card, children}: {card: Card; children?: ReactNode}) {
131
+ return (
132
+ <html.div style={[s.card, card.kind === 'activity' && s.activity]}>
133
+ {card.title}
134
+ {children}
135
+ </html.div>
136
+ )
137
+ }
138
+
139
+ function Column({
140
+ id,
141
+ name,
142
+ cards,
143
+ order,
144
+ scroll = false,
145
+ accepts,
146
+ attach = false,
147
+ }: {
148
+ id: string
149
+ name: string
150
+ cards: Card[]
151
+ order?: number
152
+ scroll?: boolean
153
+ accepts?: (item: DragDropItemData<Card>) => boolean | {ok: false; reason: string}
154
+ attach?: boolean
155
+ }) {
156
+ return (
157
+ <html.div style={s.column}>
158
+ <Text variant="label">{name}</Text>
159
+ <html.div style={[s.list, scroll && s.scroller]}>
160
+ <DragDrop.Zone<Card>
161
+ id={id}
162
+ label={name}
163
+ orientation="vertical"
164
+ list
165
+ order={order}
166
+ accepts={accepts}
167
+ >
168
+ {cards.map((card) => (
169
+ <DragDrop.Item key={card.id} id={card.id} zone={id} label={card.title} data={card}>
170
+ <DragDrop.Handle>
171
+ {attach && card.kind === 'task' ? (
172
+ <DragDrop.Zone<Card>
173
+ id={`attach:${card.id}`}
174
+ label={`Attach to ${card.title}`}
175
+ accepts={(item) => item.data.kind === 'activity'}
176
+ >
177
+ <CardFace card={card} />
178
+ </DragDrop.Zone>
179
+ ) : (
180
+ <CardFace card={card} />
181
+ )}
182
+ </DragDrop.Handle>
183
+ </DragDrop.Item>
184
+ ))}
185
+ </DragDrop.Zone>
186
+ </html.div>
187
+ </html.div>
188
+ )
189
+ }
190
+
191
+ const tasks = (prefix: string, n: number, from = 1): Card[] =>
192
+ Array.from({length: n}, (_, i) => ({
193
+ id: `${prefix}${i + from}`,
194
+ title: `${prefix.toUpperCase()}-${i + from}`,
195
+ kind: 'task',
196
+ }))
197
+
198
+ function Kanban({
199
+ initial,
200
+ onDrop,
201
+ onDragOver,
202
+ orders,
203
+ }: {
204
+ initial: Columns
205
+ onDrop?: (e: DragDropEvent<Card>) => void
206
+ onDragOver?: (e: DragDropOverEvent<Card>) => void
207
+ orders?: Record<string, number>
208
+ }) {
209
+ const [cols, setCols] = useState(initial)
210
+ return (
211
+ <DragDrop.Root<Card>
212
+ renderPlaceholder={placeholder}
213
+ onDragOver={onDragOver}
214
+ onDrop={(e) => {
215
+ onDrop?.(e)
216
+ setCols((c) => moveCard(c, e.item.id, e.target.zone, e.target.index))
217
+ }}
218
+ >
219
+ <html.div style={s.board}>
220
+ {COLUMNS.map((c) => (
221
+ <Column
222
+ key={c.id}
223
+ id={c.id}
224
+ name={c.name}
225
+ cards={cols[c.id] ?? []}
226
+ order={orders?.[c.id]}
227
+ />
228
+ ))}
229
+ </html.div>
230
+ </DragDrop.Root>
231
+ )
232
+ }
233
+
234
+ const KANBAN: Columns = {
235
+ next: [
236
+ {id: 'a', title: 'Write spec', kind: 'task'},
237
+ {id: 'b', title: 'Review copy', kind: 'task'},
238
+ {id: 'c', title: 'Ship beta', kind: 'task'},
239
+ ],
240
+ doing: [
241
+ {id: 'd', title: 'Fix login', kind: 'task'},
242
+ {id: 'e', title: 'Tune cache', kind: 'task'},
243
+ ],
244
+ done: [],
245
+ }
246
+
247
+ const status = () => document.querySelector('[role="status"][aria-live="polite"]') as HTMLElement
248
+ const nextFrame = () => new Promise((r) => requestAnimationFrame(() => r(null)))
249
+
250
+ const point = (el: Element, fx = 0.5, fy = 0.5) => {
251
+ const r = el.getBoundingClientRect()
252
+ return {x: r.left + r.width * fx, y: r.top + r.height * fy}
253
+ }
254
+
255
+ const pointerInit = (x: number, y: number, buttons = 1) => ({
256
+ bubbles: true,
257
+ cancelable: true,
258
+ pointerId: 11,
259
+ pointerType: 'mouse',
260
+ isPrimary: true,
261
+ button: 0,
262
+ buttons,
263
+ clientX: x,
264
+ clientY: y,
265
+ })
266
+
267
+ /** Press on `from`, move through `path` a frame at a time, and leave the
268
+ * pointer down; returns a release function. */
269
+ const press = async (from: Element, path: {x: number; y: number}[], steps = 6) => {
270
+ const start = point(from)
271
+ from.dispatchEvent(new PointerEvent('pointerdown', pointerInit(start.x, start.y)))
272
+ let at = start
273
+ for (const to of path) {
274
+ for (let i = 1; i <= steps; i++) {
275
+ const x = at.x + ((to.x - at.x) * i) / steps
276
+ const y = at.y + ((to.y - at.y) * i) / steps
277
+ document.dispatchEvent(new PointerEvent('pointermove', pointerInit(x, y)))
278
+ await nextFrame()
279
+ }
280
+ at = to
281
+ }
282
+ return {
283
+ at: () => at,
284
+ release: async () => {
285
+ document.dispatchEvent(new PointerEvent('pointerup', pointerInit(at.x, at.y, 0)))
286
+ await nextFrame()
287
+ },
288
+ }
289
+ }
290
+
291
+ const itemOf = (handle: Element) => handle.parentElement as Element
292
+
293
+ // ---------------------------------------------------------------------------
294
+
295
+ /** Three labelled list columns. Tab reaches one card per column; arrows walk
296
+ * the cards; Space picks a card up, arrows move it, Space drops, Escape
297
+ * cancels. Mouse drags show the same placeholder. */
298
+ export const Kanban3: Story = {
299
+ name: 'Kanban (keyboard and pointer)',
300
+ render: () => <Kanban initial={KANBAN} />,
301
+ }
302
+
303
+ /** Space, arrows, Space: the card moves; announcements are list-style and
304
+ * focus comes back to the card. */
305
+ export const KeyboardMove: Story = {
306
+ args: {onDrop: fn()},
307
+ render: (args) => <Kanban initial={KANBAN} onDrop={args.onDrop as never} />,
308
+ play: async ({canvas, args, userEvent}) => {
309
+ // Columns are labelled lists of list items.
310
+ await expect(canvas.getByRole('list', {name: 'Next'})).toBeInTheDocument()
311
+ await expect(canvas.getAllByRole('listitem')).toHaveLength(5)
312
+ // One tab stop per column.
313
+ const handles = canvas.getAllByRole('button')
314
+ await expect(handles.filter((h) => h.tabIndex === 0)).toHaveLength(2)
315
+
316
+ const spec = canvas.getByRole('button', {name: 'Write spec'})
317
+ spec.focus()
318
+ await userEvent.keyboard(' ')
319
+ await expect(status()).toHaveTextContent('Picked up Write spec. Next, position 1 of 3.')
320
+ await expect(canvas.getByText('→ Next')).toBeInTheDocument()
321
+
322
+ await userEvent.keyboard('{ArrowDown}')
323
+ await expect(status()).toHaveTextContent('Next, position 2 of 3')
324
+ await userEvent.keyboard('{ArrowRight}')
325
+ await expect(status()).toHaveTextContent('In progress, position 2 of 3')
326
+ await expect(canvas.getByText('→ In progress')).toBeInTheDocument()
327
+ await userEvent.keyboard(' ')
328
+
329
+ await expect(args.onDrop).toHaveBeenCalledTimes(1)
330
+ const call = (args.onDrop as unknown as ReturnType<typeof fn>).mock
331
+ .calls[0][0] as DragDropEvent<Card>
332
+ await expect(call.target).toEqual({zone: 'doing', index: 1})
333
+ await expect(status()).toHaveTextContent('Dropped Write spec in In progress, position 2 of 3.')
334
+ const doing = canvas.getByRole('list', {name: 'In progress'})
335
+ await expect(doing.querySelectorAll('[role="listitem"]')).toHaveLength(3)
336
+ // Focus followed the card into its new column.
337
+ await waitFor(() =>
338
+ expect(document.activeElement).toBe(canvas.getByRole('button', {name: 'Write spec'})),
339
+ )
340
+ await expect(canvas.queryByText('→ In progress')).toBeNull()
341
+ },
342
+ }
343
+
344
+ /** Escape puts the card back, focused, and nothing is dropped. */
345
+ export const KeyboardCancel: Story = {
346
+ args: {onDrop: fn()},
347
+ render: (args) => <Kanban initial={KANBAN} onDrop={args.onDrop as never} />,
348
+ play: async ({canvas, args, userEvent}) => {
349
+ const review = canvas.getByRole('button', {name: 'Review copy'})
350
+ review.focus()
351
+ await userEvent.keyboard(' ')
352
+ await userEvent.keyboard('{ArrowRight}{ArrowRight}')
353
+ await expect(status()).toHaveTextContent('Done, position 1 of 1')
354
+ await userEvent.keyboard('{Escape}')
355
+ await expect(args.onDrop).not.toHaveBeenCalled()
356
+ await expect(status()).toHaveTextContent('Cancelled moving Review copy.')
357
+ await expect(document.activeElement).toBe(review)
358
+ await expect(canvas.queryByText('→ Done')).toBeNull()
359
+ },
360
+ }
361
+
362
+ /** Arrow keys walk the handles without picking: Up/Down in a column,
363
+ * Left/Right across columns. */
364
+ export const KeyboardRoving: Story = {
365
+ render: () => <Kanban initial={KANBAN} />,
366
+ play: async ({canvas, userEvent}) => {
367
+ canvas.getByRole('button', {name: 'Write spec'}).focus()
368
+ await userEvent.keyboard('{ArrowDown}')
369
+ await expect(document.activeElement).toBe(canvas.getByRole('button', {name: 'Review copy'}))
370
+ await userEvent.keyboard('{ArrowRight}')
371
+ await expect(document.activeElement).toBe(canvas.getByRole('button', {name: 'Tune cache'}))
372
+ // The focused card is now its column's tab stop.
373
+ await expect(canvas.getByRole('button', {name: 'Tune cache'}).tabIndex).toBe(0)
374
+ await expect(canvas.getByRole('button', {name: 'Fix login'}).tabIndex).toBe(-1)
375
+ },
376
+ }
377
+
378
+ /** `order` decides Left/Right, not the DOM: here Done comes before In
379
+ * progress. */
380
+ export const ExplicitZoneOrder: Story = {
381
+ render: () => <Kanban initial={KANBAN} orders={{next: 1, done: 2, doing: 3}} />,
382
+ play: async ({canvas, userEvent}) => {
383
+ canvas.getByRole('button', {name: 'Ship beta'}).focus()
384
+ await userEvent.keyboard(' ')
385
+ await userEvent.keyboard('{ArrowRight}')
386
+ await expect(status()).toHaveTextContent('Done, position 1 of 1')
387
+ await userEvent.keyboard('{ArrowRight}')
388
+ await expect(status()).toHaveTextContent('In progress, position 1 of 3')
389
+ await userEvent.keyboard('{Escape}')
390
+ },
391
+ }
392
+
393
+ /** A mouse drag shows the placeholder where the card would land, calls
394
+ * onDragOver as the target changes, and drops there. */
395
+ export const PointerReorder: Story = {
396
+ args: {onDrop: fn(), onDragOver: fn()},
397
+ render: (args) => (
398
+ <Kanban initial={KANBAN} onDrop={args.onDrop as never} onDragOver={args.onDragOver as never} />
399
+ ),
400
+ play: async ({canvas, args}) => {
401
+ const spec = canvas.getByRole('button', {name: 'Write spec'})
402
+ const ship = canvas.getByRole('button', {name: 'Ship beta'})
403
+ const drag = await press(itemOf(spec), [point(ship, 0.5, 0.9)])
404
+ await waitFor(() => expect(canvas.getByText('→ Next')).toBeInTheDocument())
405
+ await expect(args.onDragOver).toHaveBeenCalled()
406
+ await drag.release()
407
+ await expect(args.onDrop).toHaveBeenCalledTimes(1)
408
+ const call = (args.onDrop as unknown as ReturnType<typeof fn>).mock
409
+ .calls[0][0] as DragDropEvent<Card>
410
+ await expect(call.target).toEqual({zone: 'next', index: 2})
411
+ const next = canvas.getByRole('list', {name: 'Next'})
412
+ await expect(next.lastElementChild).toHaveTextContent('Write spec')
413
+ },
414
+ }
415
+
416
+ // ---------------------------------------------------------------------------
417
+ // Nested zones: attach an activity to a card inside a column
418
+ // ---------------------------------------------------------------------------
419
+
420
+ function AttachBoard({onDrop}: {onDrop?: (e: DragDropEvent<Card>) => void}) {
421
+ const [cols, setCols] = useState<Columns>({
422
+ inbox: [
423
+ {id: 'x1', title: 'Session on feat/kb', kind: 'activity'},
424
+ {id: 'x2', title: 'Commit 3f2a', kind: 'activity'},
425
+ ],
426
+ next: KANBAN.next,
427
+ doing: KANBAN.doing,
428
+ })
429
+ const [attached, setAttached] = useState<string[]>([])
430
+ const tasksOnly = (item: DragDropItemData<Card>) =>
431
+ item.data.kind === 'task'
432
+ ? true
433
+ : {ok: false as const, reason: 'Drop an activity on a task to attach it'}
434
+ return (
435
+ <DragDrop.Root<Card>
436
+ renderPlaceholder={placeholder}
437
+ onDrop={(e) => {
438
+ onDrop?.(e)
439
+ if (e.target.zone.startsWith('attach:')) {
440
+ setAttached((a) => [...a, `${e.item.data.title} → ${e.target.zone.slice(7)}`])
441
+ setCols((c) => ({...c, inbox: c.inbox.filter((x) => x.id !== e.item.id)}))
442
+ } else setCols((c) => moveCard(c, e.item.id, e.target.zone, e.target.index))
443
+ }}
444
+ >
445
+ <html.div style={s.board}>
446
+ <Column
447
+ id="inbox"
448
+ name="Inbox"
449
+ cards={cols.inbox}
450
+ accepts={(item) => item.data.kind === 'activity'}
451
+ />
452
+ <Column id="next" name="Next" cards={cols.next} accepts={tasksOnly} attach />
453
+ <Column id="doing" name="In progress" cards={cols.doing} accepts={tasksOnly} attach />
454
+ </html.div>
455
+ <Text color="muted">
456
+ {attached.length ? `Attached: ${attached.join(', ')}` : 'Nothing attached.'}
457
+ </Text>
458
+ </DragDrop.Root>
459
+ )
460
+ }
461
+
462
+ /** An inbox of activities beside two columns of tasks. Drop an activity on a
463
+ * task card to attach it (the card's zone sits inside the column's); drop
464
+ * it on a column and the column refuses, with its reason. */
465
+ export const NestedZones: Story = {
466
+ name: 'Nested zones (attach)',
467
+ render: () => <AttachBoard />,
468
+ }
469
+
470
+ /** The card's own zone wins over its column (innermost zone): an activity
471
+ * dropped on a card attaches to it. */
472
+ export const NestedAttach: Story = {
473
+ args: {onDrop: fn()},
474
+ render: (args) => <AttachBoard onDrop={args.onDrop as never} />,
475
+ play: async ({canvas, args}) => {
476
+ const activity = canvas.getByRole('button', {name: 'Session on feat/kb'})
477
+ const card = canvas.getByRole('button', {name: 'Fix login'})
478
+ const drag = await press(itemOf(activity), [point(card)])
479
+ await drag.release()
480
+ await expect(args.onDrop).toHaveBeenCalledTimes(1)
481
+ const call = (args.onDrop as unknown as ReturnType<typeof fn>).mock
482
+ .calls[0][0] as DragDropEvent<Card>
483
+ await expect(call.target.zone).toBe('attach:d')
484
+ await expect(canvas.getByText('Attached: Session on feat/kb → d')).toBeInTheDocument()
485
+ },
486
+ }
487
+
488
+ /** A task dragged over another task passes through the card's zone (it
489
+ * only takes activities) to the column, which reorders. */
490
+ export const NestedFallsThrough: Story = {
491
+ args: {onDrop: fn()},
492
+ render: (args) => <AttachBoard onDrop={args.onDrop as never} />,
493
+ play: async ({canvas, args}) => {
494
+ const spec = canvas.getByRole('button', {name: 'Write spec'})
495
+ const tune = canvas.getByRole('button', {name: 'Tune cache'})
496
+ const drag = await press(itemOf(spec), [point(tune, 0.5, 0.9)])
497
+ await drag.release()
498
+ const call = (args.onDrop as unknown as ReturnType<typeof fn>).mock
499
+ .calls[0][0] as DragDropEvent<Card>
500
+ await expect(call.target).toEqual({zone: 'doing', index: 2})
501
+ },
502
+ }
503
+
504
+ /** A column refuses an activity with a reason: the ghost shows it, it is
505
+ * announced, and releasing there drops nothing. */
506
+ export const RefusalWithReason: Story = {
507
+ args: {onDrop: fn()},
508
+ render: (args) => <AttachBoard onDrop={args.onDrop as never} />,
509
+ play: async ({canvas, args}) => {
510
+ const activity = canvas.getByRole('button', {name: 'Commit 3f2a'})
511
+ const doing = canvas.getByRole('list', {name: 'In progress'})
512
+ const drag = await press(itemOf(activity), [point(doing, 0.5, 0.97)])
513
+ await waitFor(() =>
514
+ expect(status()).toHaveTextContent('In progress: Drop an activity on a task to attach it'),
515
+ )
516
+ await expect(
517
+ [...document.querySelectorAll('[aria-hidden="true"]')].some((el) =>
518
+ el.textContent?.includes('Drop an activity on a task to attach it'),
519
+ ),
520
+ ).toBe(true)
521
+ await drag.release()
522
+ await expect(args.onDrop).not.toHaveBeenCalled()
523
+ await expect(status()).toHaveTextContent('Cancelled moving Commit 3f2a.')
524
+ },
525
+ }
526
+
527
+ // ---------------------------------------------------------------------------
528
+ // Auto-scroll
529
+ // ---------------------------------------------------------------------------
530
+
531
+ function ScrollingColumns() {
532
+ const [cols, setCols] = useState<Columns>({long: tasks('t', 30), other: tasks('u', 2)})
533
+ return (
534
+ <DragDrop.Root<Card>
535
+ renderPlaceholder={placeholder}
536
+ onDrop={(e) => setCols((c) => moveCard(c, e.item.id, e.target.zone, e.target.index))}
537
+ >
538
+ <html.div style={s.board}>
539
+ <Column id="long" name="Backlog" cards={cols.long} scroll />
540
+ <Column id="other" name="Later" cards={cols.other} />
541
+ </html.div>
542
+ </DragDrop.Root>
543
+ )
544
+ }
545
+
546
+ /** A 30-card column that scrolls: hold a dragged card near its edge. */
547
+ export const ScrollingColumn: Story = {
548
+ render: () => <ScrollingColumns />,
549
+ }
550
+
551
+ /** Holding a card near the bottom edge of a scrolling column scrolls it, and
552
+ * the drop lands among the cards scrolled into view. */
553
+ export const EdgeAutoScroll: Story = {
554
+ render: () => <ScrollingColumns />,
555
+ play: async ({canvas}) => {
556
+ const list = canvas.getByRole('list', {name: 'Backlog'})
557
+ const scroller = list.parentElement as HTMLElement
558
+ await expect(scroller.scrollTop).toBe(0)
559
+ const first = canvas.getByRole('button', {name: 'T-1'})
560
+ const r = scroller.getBoundingClientRect()
561
+ const edge = {x: r.left + r.width / 2, y: r.bottom - 4}
562
+ const drag = await press(itemOf(first), [edge])
563
+ for (let i = 0; i < 40; i++) await nextFrame()
564
+ await expect(scroller.scrollTop).toBeGreaterThan(100)
565
+ await drag.release()
566
+ const items = [...list.querySelectorAll('[role="listitem"]')].map((el) => el.textContent)
567
+ // T-1 left the top and landed further down.
568
+ await expect(items[0]).toBe('T-2')
569
+ await expect(items.indexOf('T-1')).toBeGreaterThan(5)
570
+ },
571
+ }
572
+
573
+ // ---------------------------------------------------------------------------
574
+ // 300 cards: the render budget
575
+ // ---------------------------------------------------------------------------
576
+
577
+ declare global {
578
+ interface Window {
579
+ __dragDropRenders?: number
580
+ }
581
+ }
582
+
583
+ const countUpdate = (_id: string, phase: string) => {
584
+ if (phase !== 'mount') window.__dragDropRenders = (window.__dragDropRenders ?? 0) + 1
585
+ }
586
+
587
+ function BigBoard() {
588
+ const [cols, setCols] = useState<Columns>({
589
+ next: tasks('n', 100),
590
+ doing: tasks('d', 100),
591
+ done: tasks('k', 100),
592
+ })
593
+ return (
594
+ <DragDrop.Root<Card>
595
+ renderPlaceholder={placeholder}
596
+ onDrop={(e) => setCols((c) => moveCard(c, e.item.id, e.target.zone, e.target.index))}
597
+ >
598
+ <html.div style={s.board}>
599
+ {COLUMNS.map((col) => (
600
+ <html.div key={col.id} style={s.column}>
601
+ <Text variant="label">{col.name}</Text>
602
+ <Profiler id={col.id} onRender={countUpdate}>
603
+ <DragDrop.Zone id={col.id} label={col.name} orientation="vertical" list>
604
+ {(cols[col.id] ?? []).map((card) => (
605
+ <Profiler key={card.id} id={card.id} onRender={countUpdate}>
606
+ <DragDrop.Item id={card.id} zone={col.id} label={card.title} data={card}>
607
+ <DragDrop.Handle>
608
+ <CardFace card={card} />
609
+ </DragDrop.Handle>
610
+ </DragDrop.Item>
611
+ </Profiler>
612
+ ))}
613
+ </DragDrop.Zone>
614
+ </Profiler>
615
+ </html.div>
616
+ ))}
617
+ </html.div>
618
+ </DragDrop.Root>
619
+ )
620
+ }
621
+
622
+ /**
623
+ * 300 cards. A pointer drag across a column re-renders at most a handful of
624
+ * zones and items per frame (the two around the moving placeholder); the
625
+ * ghost moves without rendering. The play function counts Profiler commits
626
+ * per frame and asserts fewer than 5.
627
+ */
628
+ export const Perf300: Story = {
629
+ name: 'Perf: 300 cards',
630
+ render: () => <BigBoard />,
631
+ play: async ({canvas}) => {
632
+ const first = canvas.getByRole('button', {name: 'N-1'})
633
+ const tenth = canvas.getByRole('button', {name: 'N-12'})
634
+ const start = point(first)
635
+ itemOf(first).dispatchEvent(new PointerEvent('pointerdown', pointerInit(start.x, start.y)))
636
+ // Pick up (all zones light up once), then measure only the moves.
637
+ document.dispatchEvent(new PointerEvent('pointermove', pointerInit(start.x, start.y + 20)))
638
+ await nextFrame()
639
+ await nextFrame()
640
+ // The drag really started: the placeholder is up. Without this, a drag that
641
+ // never starts renders nothing and the budget below passes vacuously.
642
+ await waitFor(() => expect(canvas.getByText('→ Next')).toBeInTheDocument())
643
+ const end = point(tenth)
644
+ const perFrame: number[] = []
645
+ const frameMs: number[] = []
646
+ const frames = 40
647
+ for (let i = 1; i <= frames; i++) {
648
+ window.__dragDropRenders = 0
649
+ const y = start.y + 20 + ((end.y - start.y - 20) * i) / frames
650
+ const t0 = performance.now()
651
+ document.dispatchEvent(new PointerEvent('pointermove', pointerInit(start.x, y)))
652
+ await nextFrame()
653
+ frameMs.push(performance.now() - t0)
654
+ perFrame.push(window.__dragDropRenders ?? 0)
655
+ }
656
+ document.dispatchEvent(new PointerEvent('pointerup', pointerInit(start.x, end.y, 0)))
657
+ await nextFrame()
658
+ const max = Math.max(...perFrame)
659
+ ;(window as unknown as {__perf300?: unknown}).__perf300 = {perFrame, frameMs}
660
+ await expect(perFrame.some((n) => n > 0)).toBe(true)
661
+ await expect(max).toBeLessThan(5)
662
+ // …and the drop landed: N-1 left the top of Next.
663
+ const next = canvas.getByRole('list', {name: 'Next'})
664
+ await waitFor(() =>
665
+ expect(next.querySelector('[role="listitem"]')?.textContent).not.toBe('N-1'),
666
+ )
667
+ },
668
+ }