@duro-app/ui 3.1.0 → 3.2.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": "3.1.0",
3
+ "version": "3.2.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -64,7 +64,7 @@
64
64
  },
65
65
  "dependencies": {
66
66
  "@tanstack/react-virtual": "^3.14.6",
67
- "@duro-app/tokens": "^3.1.0"
67
+ "@duro-app/tokens": "^3.2.0"
68
68
  },
69
69
  "devDependencies": {
70
70
  "@babel/preset-typescript": "^7.28.0",
@@ -0,0 +1,49 @@
1
+ import type {ComponentMeta} from '../component-meta'
2
+
3
+ export const meta: ComponentMeta = {
4
+ description:
5
+ 'Move items between zones with one pointer — mouse, pen and touch through the same pointer-event path (touch holds briefly, then drags; a moving touch stays a scroll). A ghost follows the pointer, the zone under it lights up, and the Root announces picks and drops to screen readers. Dragging is pointer-only by design: keep a button or remove affordance that does the same thing.',
6
+ anatomy: {required: ['Root', 'Zone', 'Item']},
7
+ whenToUse: [
8
+ 'Slotting things into places — people into approval gates, cards into columns, files into folders',
9
+ 'Reordering a short row or list when the order is visible and meaningful',
10
+ ],
11
+ whenNotToUse: [
12
+ 'As the ONLY way to do something — every drag needs a tap/click equivalent (WCAG 2.5.7); this component does not provide one',
13
+ 'Long lists that scroll while dragging — the ghost follows the pointer but zones do not auto-scroll',
14
+ 'Sorting tabular data — sort the Table instead',
15
+ 'React Native — Item renders its children without drag behaviour there',
16
+ ],
17
+ relatedTo: [
18
+ {
19
+ component: 'Tag',
20
+ kind: 'composition',
21
+ relationship: 'A removable Tag inside an Item gives the drop target its non-drag remove path',
22
+ },
23
+ {
24
+ component: 'ScrollArea',
25
+ kind: 'contrast',
26
+ relationship: 'ScrollArea drags a thumb along one axis; DragDrop moves items between zones',
27
+ },
28
+ ],
29
+ example: `<DragDrop.Root onDrop={({item, target}) => move(item.id, target.zone, target.index)}>
30
+ <DragDrop.Zone id="roster" label="Roster">
31
+ <Cluster gap="sm">
32
+ {people.map((p) => (
33
+ <DragDrop.Item key={p.id} id={p.id} zone="roster" label={p.name} data={p}>
34
+ <Button variant="secondary" size="small" onClick={() => slot(p)}>{p.name}</Button>
35
+ </DragDrop.Item>
36
+ ))}
37
+ </Cluster>
38
+ </DragDrop.Zone>
39
+ <DragDrop.Zone id="gates" label="Gates" orientation="horizontal">
40
+ <Inline gap="sm">
41
+ {gates.map((g) => (
42
+ <DragDrop.Item key={g.id} id={g.id} zone="gates" label={g.name} data={g}>
43
+ <Tag removable onRemove={() => unslot(g)}>{g.name}</Tag>
44
+ </DragDrop.Item>
45
+ ))}
46
+ </Inline>
47
+ </DragDrop.Zone>
48
+ </DragDrop.Root>`,
49
+ }
@@ -0,0 +1,249 @@
1
+ import type {Meta, StoryObj} from '@storybook/react'
2
+ import {expect, fn} from 'storybook/test'
3
+ import {useState} from 'react'
4
+ import {css, html} from 'react-strict-dom'
5
+ import {DragDrop, type DragDropEvent} from './DragDrop'
6
+ import {Button} from '../Button/Button'
7
+ import {Cluster} from '../Cluster/Cluster'
8
+ import {Inline} from '../Inline/Inline'
9
+ import {Stack} from '../Stack/Stack'
10
+ import {Tag} from '../Tag/Tag'
11
+ import {Text} from '../Text/Text'
12
+ import {colors} from '@duro-app/tokens/tokens/colors.css'
13
+ import {spacing, radii} from '@duro-app/tokens/tokens/spacing.css'
14
+
15
+ const meta: Meta<typeof DragDrop.Root> = {
16
+ title: 'Interaction/DragDrop',
17
+ component: DragDrop.Root,
18
+ }
19
+
20
+ export default meta
21
+ type Story = StoryObj<typeof DragDrop.Root>
22
+
23
+ const localStyles = css.create({
24
+ zone: {
25
+ padding: spacing.md,
26
+ minHeight: spacing.xxl,
27
+ borderWidth: 1,
28
+ borderStyle: 'dashed',
29
+ borderColor: colors.border,
30
+ borderRadius: radii.sm,
31
+ },
32
+ })
33
+
34
+ interface Person {
35
+ id: string
36
+ name: string
37
+ }
38
+
39
+ const ALL: Person[] = [
40
+ {id: 'p1', name: 'Marie'},
41
+ {id: 'p2', name: 'Léo'},
42
+ {id: 'p3', name: 'Noor'},
43
+ ]
44
+
45
+ /**
46
+ * The gate-board shape: a roster of people and a row of gates. Drag a person
47
+ * onto the gates row to slot them (or tap them — the button is the non-drag
48
+ * path), drag a gate back to the roster to unslot (or use its remove button).
49
+ */
50
+ function Board({onDrop}: {onDrop?: (e: DragDropEvent<Person>) => void}) {
51
+ const [gates, setGates] = useState<Person[]>([])
52
+ const roster = ALL.filter((p) => !gates.some((g) => g.id === p.id))
53
+
54
+ const slot = (p: Person, index = gates.length) =>
55
+ setGates((prev) => {
56
+ const without = prev.filter((g) => g.id !== p.id)
57
+ return [...without.slice(0, index), p, ...without.slice(index)]
58
+ })
59
+ const unslot = (p: Person) => setGates((prev) => prev.filter((g) => g.id !== p.id))
60
+
61
+ return (
62
+ <DragDrop.Root<Person>
63
+ onDrop={(e) => {
64
+ onDrop?.(e)
65
+ if (e.target.zone === 'gates') slot(e.item.data, e.target.index)
66
+ else unslot(e.item.data)
67
+ }}
68
+ >
69
+ <Stack gap="md">
70
+ <Text variant="label">Roster</Text>
71
+ <DragDrop.Zone id="roster" label="Roster">
72
+ <html.div style={localStyles.zone}>
73
+ <Cluster gap="sm">
74
+ {roster.map((p) => (
75
+ <DragDrop.Item key={p.id} id={p.id} zone="roster" label={p.name} data={p}>
76
+ <Button variant="secondary" size="small" onClick={() => slot(p)}>
77
+ {p.name}
78
+ </Button>
79
+ </DragDrop.Item>
80
+ ))}
81
+ {roster.length === 0 && <Text color="muted">Everyone is at a gate.</Text>}
82
+ </Cluster>
83
+ </html.div>
84
+ </DragDrop.Zone>
85
+
86
+ <Text variant="label">Gates</Text>
87
+ <DragDrop.Zone id="gates" label="Gates" orientation="horizontal">
88
+ <html.div style={localStyles.zone}>
89
+ <Inline gap="sm">
90
+ {gates.map((g) => (
91
+ <DragDrop.Item key={g.id} id={g.id} zone="gates" label={g.name} data={g}>
92
+ <Tag variant="info" removable onRemove={() => unslot(g)}>
93
+ {g.name}
94
+ </Tag>
95
+ </DragDrop.Item>
96
+ ))}
97
+ {gates.length === 0 && <Text color="muted">Drop a person here.</Text>}
98
+ </Inline>
99
+ </html.div>
100
+ </DragDrop.Zone>
101
+ </Stack>
102
+ </DragDrop.Root>
103
+ )
104
+ }
105
+
106
+ export const RosterToGates: Story = {
107
+ render: () => <Board />,
108
+ }
109
+
110
+ const pointerDrag = async (
111
+ from: Element,
112
+ to: Element,
113
+ opts: {pointerType?: 'mouse' | 'touch'; hold?: number} = {},
114
+ ) => {
115
+ const {pointerType = 'mouse', hold = 0} = opts
116
+ const a = from.getBoundingClientRect()
117
+ const b = to.getBoundingClientRect()
118
+ const start = {x: a.left + a.width / 2, y: a.top + a.height / 2}
119
+ const end = {x: b.left + b.width / 2, y: b.top + b.height / 2}
120
+ const init = (x: number, y: number) => ({
121
+ bubbles: true,
122
+ cancelable: true,
123
+ pointerId: 7,
124
+ pointerType,
125
+ isPrimary: true,
126
+ button: 0,
127
+ buttons: 1,
128
+ clientX: x,
129
+ clientY: y,
130
+ })
131
+ from.dispatchEvent(new PointerEvent('pointerdown', init(start.x, start.y)))
132
+ if (hold) await new Promise((r) => setTimeout(r, hold))
133
+ const steps = 8
134
+ for (let i = 1; i <= steps; i++) {
135
+ const x = start.x + ((end.x - start.x) * i) / steps
136
+ const y = start.y + ((end.y - start.y) * i) / steps
137
+ document.dispatchEvent(new PointerEvent('pointermove', init(x, y)))
138
+ await new Promise((r) => requestAnimationFrame(() => r(null)))
139
+ }
140
+ document.dispatchEvent(new PointerEvent('pointerup', {...init(end.x, end.y), buttons: 0}))
141
+ }
142
+
143
+ /** A mouse drag from the roster into the gates row slots the person. */
144
+ export const MouseDrag: Story = {
145
+ args: {onDrop: fn()},
146
+ render: (args) => <Board onDrop={args.onDrop as never} />,
147
+ play: async ({canvas, args}) => {
148
+ const marie = await canvas.findByRole('button', {name: 'Marie'})
149
+ const gatesZone = canvas.getByText('Drop a person here.')
150
+ await pointerDrag(marie.parentElement as Element, gatesZone)
151
+ await expect(args.onDrop).toHaveBeenCalledTimes(1)
152
+ const call = (args.onDrop as ReturnType<typeof fn>).mock.calls[0][0] as DragDropEvent<Person>
153
+ await expect(call.item.id).toBe('p1')
154
+ await expect(call.target).toEqual({zone: 'gates', index: 0})
155
+ // Marie left the roster and became a gate.
156
+ await expect(canvas.queryByRole('button', {name: 'Marie'})).toBeNull()
157
+ await expect(canvas.getByText('Marie')).toBeInTheDocument()
158
+ await expect(canvas.getByRole('status')).toHaveTextContent('Dropped Marie in Gates.')
159
+ },
160
+ }
161
+
162
+ /** A touch that moves right away is a scroll: no drag, no drop. A touch that
163
+ * holds first becomes a drag. */
164
+ export const TouchHoldThenDrag: Story = {
165
+ args: {onDrop: fn()},
166
+ render: (args) => <Board onDrop={args.onDrop as never} />,
167
+ play: async ({canvas, args}) => {
168
+ const leo = await canvas.findByRole('button', {name: 'Léo'})
169
+ const gatesZone = canvas.getByText('Drop a person here.')
170
+ await pointerDrag(leo.parentElement as Element, gatesZone, {pointerType: 'touch', hold: 0})
171
+ await expect(args.onDrop).not.toHaveBeenCalled()
172
+ await pointerDrag(leo.parentElement as Element, gatesZone, {pointerType: 'touch', hold: 250})
173
+ await expect(args.onDrop).toHaveBeenCalledTimes(1)
174
+ await expect(canvas.queryByRole('button', {name: 'Léo'})).toBeNull()
175
+ },
176
+ }
177
+
178
+ /**
179
+ * iOS Safari under load: the hold timer never fires while the finger is down
180
+ * and every pointermove arrives in one burst just before pointerup. The hold
181
+ * is judged by event timestamps, so the drag still activates and drops.
182
+ */
183
+ export const TouchBurstAfterHold: Story = {
184
+ args: {onDrop: fn()},
185
+ render: (args) => <Board onDrop={args.onDrop as never} />,
186
+ play: async ({canvas, args}) => {
187
+ const noor = await canvas.findByRole('button', {name: 'Noor'})
188
+ const gatesZone = canvas.getByText('Drop a person here.')
189
+ // Swallow the component's hold timer for the duration of the gesture.
190
+ const original = window.setTimeout.bind(window)
191
+ window.setTimeout = ((fn: TimerHandler, ms?: number, ...rest: unknown[]) =>
192
+ ms === 180 ? 0 : original(fn, ms, ...rest)) as typeof window.setTimeout
193
+ try {
194
+ const a = (noor.parentElement as Element).getBoundingClientRect()
195
+ const b = gatesZone.getBoundingClientRect()
196
+ const init = (x: number, y: number, buttons = 1) => ({
197
+ bubbles: true,
198
+ cancelable: true,
199
+ pointerId: 8,
200
+ pointerType: 'touch',
201
+ isPrimary: true,
202
+ button: 0,
203
+ buttons,
204
+ clientX: x,
205
+ clientY: y,
206
+ })
207
+ ;(noor.parentElement as Element).dispatchEvent(
208
+ new PointerEvent('pointerdown', init(a.left + a.width / 2, a.top + a.height / 2)),
209
+ )
210
+ await new Promise((r) => original(r, 300))
211
+ // The whole move stream lands at once, then the release.
212
+ const ex = b.left + b.width / 2
213
+ const ey = b.top + b.height / 2
214
+ for (let i = 1; i <= 4; i++) {
215
+ document.dispatchEvent(
216
+ new PointerEvent(
217
+ 'pointermove',
218
+ init(a.left + ((ex - a.left) * i) / 4, a.top + ((ey - a.top) * i) / 4),
219
+ ),
220
+ )
221
+ }
222
+ await new Promise((r) => requestAnimationFrame(() => r(null)))
223
+ // Checkpoint: the burst alone must have activated the drag.
224
+ await expect(canvas.getByRole('status')).toHaveTextContent('Picked up Noor.')
225
+ document.dispatchEvent(new PointerEvent('pointerup', init(ex, ey, 0)))
226
+ } finally {
227
+ window.setTimeout = original
228
+ }
229
+ // The release came from a native event; React flushes it asynchronously.
230
+ await new Promise((r) => setTimeout(r, 50))
231
+ await expect(canvas.getByRole('status')).toHaveTextContent('Dropped Noor in Gates.')
232
+ await expect(args.onDrop).toHaveBeenCalledTimes(1)
233
+ await expect(canvas.queryByRole('button', {name: 'Noor'})).toBeNull()
234
+ },
235
+ }
236
+
237
+ /** A plain click on an item still reaches the button inside: no drag starts
238
+ * under the movement threshold. */
239
+ export const TapStaysAClick: Story = {
240
+ args: {onDrop: fn()},
241
+ render: (args) => <Board onDrop={args.onDrop as never} />,
242
+ play: async ({canvas, args, userEvent}) => {
243
+ const noor = await canvas.findByRole('button', {name: 'Noor'})
244
+ await userEvent.click(noor)
245
+ await expect(args.onDrop).not.toHaveBeenCalled()
246
+ await expect(canvas.queryByRole('button', {name: 'Noor'})).toBeNull()
247
+ await expect(canvas.getByText('Noor')).toBeInTheDocument()
248
+ },
249
+ }