@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.
- package/dist/{Table-DYN-bp0e.js → Table-Dj7PXqO_.js} +405 -387
- package/dist/Table-Dj7PXqO_.js.map +1 -0
- package/dist/components/Button/Button.d.ts +2 -1
- package/dist/components/Button/Button.d.ts.map +1 -1
- package/dist/components/Button/styles.css.d.ts +1 -0
- package/dist/components/Button/styles.css.d.ts.map +1 -1
- package/dist/components/ButtonGroup/ButtonGroup.stories.d.ts +14 -0
- package/dist/components/ButtonGroup/ButtonGroup.stories.d.ts.map +1 -1
- package/dist/components/DragDrop/DragDrop.d.ts +95 -14
- package/dist/components/DragDrop/DragDrop.d.ts.map +1 -1
- package/dist/components/DragDrop/DragDrop.meta.d.ts.map +1 -1
- package/dist/components/DragDrop/DragDropBoard.stories.d.ts +69 -0
- package/dist/components/DragDrop/DragDropBoard.stories.d.ts.map +1 -0
- package/dist/components/DragDrop/styles.css.d.ts +80 -3
- package/dist/components/DragDrop/styles.css.d.ts.map +1 -1
- package/dist/components/LinkButton/styles.css.d.ts +1 -0
- package/dist/components/LinkButton/styles.css.d.ts.map +1 -1
- package/dist/components/Menu/Menu.d.ts +4 -1
- package/dist/components/Menu/Menu.d.ts.map +1 -1
- package/dist/components/Menu/styles.css.d.ts +14 -0
- package/dist/components/Menu/styles.css.d.ts.map +1 -1
- package/dist/components/Meter/Meter.d.ts +17 -0
- package/dist/components/Meter/Meter.d.ts.map +1 -0
- package/dist/components/Meter/Meter.meta.d.ts +3 -0
- package/dist/components/Meter/Meter.meta.d.ts.map +1 -0
- package/dist/components/Meter/Meter.stories.d.ts +12 -0
- package/dist/components/Meter/Meter.stories.d.ts.map +1 -0
- package/dist/components/Meter/styles.css.d.ts +34 -0
- package/dist/components/Meter/styles.css.d.ts.map +1 -0
- package/dist/components/Popover/Popover.d.ts +14 -2
- package/dist/components/Popover/Popover.d.ts.map +1 -1
- package/dist/components/Popover/Popover.meta.d.ts.map +1 -1
- package/dist/components/Popover/PopoverLayerContext.d.ts +3 -0
- package/dist/components/Popover/PopoverLayerContext.d.ts.map +1 -1
- package/dist/components/Popover/PopoverRaised.stories.d.ts +28 -0
- package/dist/components/Popover/PopoverRaised.stories.d.ts.map +1 -0
- package/dist/components/Popover/styles.css.d.ts +15 -0
- package/dist/components/Popover/styles.css.d.ts.map +1 -1
- package/dist/components/Select/Select.d.ts +4 -1
- package/dist/components/Select/Select.d.ts.map +1 -1
- package/dist/components/Select/styles.css.d.ts +9 -0
- package/dist/components/Select/styles.css.d.ts.map +1 -1
- package/dist/components/Timeline/Timeline.d.ts +71 -0
- package/dist/components/Timeline/Timeline.d.ts.map +1 -0
- package/dist/components/Timeline/Timeline.meta.d.ts +3 -0
- package/dist/components/Timeline/Timeline.meta.d.ts.map +1 -0
- package/dist/components/Timeline/Timeline.stories.d.ts +26 -0
- package/dist/components/Timeline/Timeline.stories.d.ts.map +1 -0
- package/dist/components/Timeline/styles.css.d.ts +158 -0
- package/dist/components/Timeline/styles.css.d.ts.map +1 -0
- package/dist/components/Toast/Toast.meta.d.ts.map +1 -1
- package/dist/components/Toast/Toast.stories.d.ts +9 -0
- package/dist/components/Toast/Toast.stories.d.ts.map +1 -1
- package/dist/components/Toast/ToastProvider.d.ts +11 -5
- package/dist/components/Toast/ToastProvider.d.ts.map +1 -1
- package/dist/components/Toast/index.d.ts +1 -1
- package/dist/components/Toast/index.d.ts.map +1 -1
- package/dist/components/Toast/styles.css.d.ts +6 -0
- package/dist/components/Toast/styles.css.d.ts.map +1 -1
- package/dist/components/Toggle/Toggle.d.ts +2 -1
- package/dist/components/Toggle/Toggle.d.ts.map +1 -1
- package/dist/components/Toggle/styles.css.d.ts +1 -0
- package/dist/components/Toggle/styles.css.d.ts.map +1 -1
- package/dist/index.css +1 -1
- package/dist/index.d.ts +6 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4992 -3845
- package/dist/index.js.map +1 -1
- package/dist/shared/ModalContext.d.ts +7 -0
- package/dist/shared/ModalContext.d.ts.map +1 -1
- package/dist/shared/exports.typecheck.d.ts +9 -0
- package/dist/shared/exports.typecheck.d.ts.map +1 -0
- package/dist/shared/types.d.ts +2 -0
- package/dist/shared/types.d.ts.map +1 -1
- package/dist/table.js +1 -1
- package/package.json +2 -2
- package/src/components/Button/Button.tsx +2 -1
- package/src/components/Button/styles.css.ts +3 -0
- package/src/components/ButtonGroup/ButtonGroup.stories.tsx +172 -14
- package/src/components/DragDrop/DragDrop.meta.ts +22 -21
- package/src/components/DragDrop/DragDrop.tsx +1147 -246
- package/src/components/DragDrop/DragDropBoard.stories.tsx +668 -0
- package/src/components/DragDrop/styles.css.ts +74 -4
- package/src/components/LinkButton/styles.css.ts +4 -0
- package/src/components/Menu/Menu.meta.ts +1 -1
- package/src/components/Menu/Menu.tsx +17 -2
- package/src/components/Menu/styles.css.ts +18 -0
- package/src/components/Meter/Meter.meta.ts +24 -0
- package/src/components/Meter/Meter.stories.tsx +64 -0
- package/src/components/Meter/Meter.tsx +55 -0
- package/src/components/Meter/styles.css.ts +30 -0
- package/src/components/Popover/Popover.meta.ts +2 -1
- package/src/components/Popover/Popover.tsx +29 -4
- package/src/components/Popover/PopoverLayerContext.ts +3 -0
- package/src/components/Popover/PopoverRaised.stories.tsx +334 -0
- package/src/components/Popover/styles.css.ts +21 -0
- package/src/components/Select/Select.meta.ts +1 -1
- package/src/components/Select/Select.tsx +5 -1
- package/src/components/Select/styles.css.ts +12 -0
- package/src/components/Timeline/Timeline.meta.ts +46 -0
- package/src/components/Timeline/Timeline.stories.tsx +238 -0
- package/src/components/Timeline/Timeline.tsx +332 -0
- package/src/components/Timeline/styles.css.ts +162 -0
- package/src/components/Toast/Toast.meta.ts +2 -1
- package/src/components/Toast/Toast.stories.tsx +84 -0
- package/src/components/Toast/ToastProvider.tsx +49 -11
- package/src/components/Toast/index.ts +7 -1
- package/src/components/Toast/styles.css.ts +2 -0
- package/src/components/Toggle/Toggle.tsx +2 -1
- package/src/components/Toggle/styles.css.ts +2 -0
- package/src/index.ts +18 -0
- package/src/shared/ModalContext.ts +8 -1
- package/src/shared/exports.typecheck.ts +16 -0
- package/src/shared/types.ts +3 -0
- 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
|
+
}
|