@duro-app/ui 3.0.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/dist/components/DragDrop/DragDrop.d.ts +67 -0
- package/dist/components/DragDrop/DragDrop.d.ts.map +1 -0
- package/dist/components/DragDrop/DragDrop.meta.d.ts +3 -0
- package/dist/components/DragDrop/DragDrop.meta.d.ts.map +1 -0
- package/dist/components/DragDrop/DragDrop.stories.d.ts +21 -0
- package/dist/components/DragDrop/DragDrop.stories.d.ts.map +1 -0
- package/dist/components/DragDrop/styles.css.d.ts +52 -0
- package/dist/components/DragDrop/styles.css.d.ts.map +1 -0
- package/dist/components/Grid/Grid.d.ts +13 -3
- package/dist/components/Grid/Grid.d.ts.map +1 -1
- package/dist/components/Grid/Grid.meta.d.ts.map +1 -1
- package/dist/components/Grid/Grid.stories.d.ts +15 -0
- package/dist/components/Grid/Grid.stories.d.ts.map +1 -1
- package/dist/components/Grid/columns.d.ts +27 -0
- package/dist/components/Grid/columns.d.ts.map +1 -0
- package/dist/components/Grid/styles.css.d.ts +71 -0
- package/dist/components/Grid/styles.css.d.ts.map +1 -1
- package/dist/index.css +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2961 -2489
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/components/DragDrop/DragDrop.meta.ts +49 -0
- package/src/components/DragDrop/DragDrop.stories.tsx +249 -0
- package/src/components/DragDrop/DragDrop.tsx +502 -0
- package/src/components/DragDrop/styles.css.ts +62 -0
- package/src/components/Grid/Grid.meta.ts +8 -2
- package/src/components/Grid/Grid.stories.tsx +83 -1
- package/src/components/Grid/Grid.tsx +117 -7
- package/src/components/Grid/columns.ts +58 -0
- package/src/components/Grid/styles.css.ts +72 -8
- package/src/index.ts +7 -0
|
@@ -0,0 +1,502 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createContext,
|
|
3
|
+
useCallback,
|
|
4
|
+
useContext,
|
|
5
|
+
useEffect,
|
|
6
|
+
useMemo,
|
|
7
|
+
useRef,
|
|
8
|
+
useState,
|
|
9
|
+
type ReactNode,
|
|
10
|
+
} from 'react'
|
|
11
|
+
import {html} from 'react-strict-dom'
|
|
12
|
+
import {styles} from './styles.css'
|
|
13
|
+
import {isNative} from '../../platform'
|
|
14
|
+
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// DragDrop — move items between zones with one pointer, on mouse, pen and
|
|
17
|
+
// touch alike.
|
|
18
|
+
//
|
|
19
|
+
// Pointer events are the single input path: a press arms a drag, movement
|
|
20
|
+
// past a small threshold (or, on touch, a short hold) starts it, pointer
|
|
21
|
+
// capture keeps the stream on the item, and release resolves the zone under
|
|
22
|
+
// the pointer. The dragged item stays in the DOM (faded) while a ghost copy
|
|
23
|
+
// follows the pointer, so layout never shifts mid-drag and a cancelled drag
|
|
24
|
+
// costs nothing.
|
|
25
|
+
//
|
|
26
|
+
// Dragging is a pointer gesture only. Every consumer MUST keep a non-drag way
|
|
27
|
+
// to do the same thing (a button that adds, a remove affordance) — that is
|
|
28
|
+
// the keyboard and assistive path, and WCAG 2.5.7 requires it. The Root's
|
|
29
|
+
// live region announces picks and drops so screen readers follow along.
|
|
30
|
+
//
|
|
31
|
+
// Native: react-strict-dom does not route pointer capture on React Native,
|
|
32
|
+
// so Item renders its children without drag behaviour there and the consumer's
|
|
33
|
+
// non-drag path is the only path. Not exported from the native barrel.
|
|
34
|
+
// ---------------------------------------------------------------------------
|
|
35
|
+
|
|
36
|
+
export interface DragDropItemData<T = unknown> {
|
|
37
|
+
readonly id: string
|
|
38
|
+
readonly zone: string
|
|
39
|
+
readonly data: T
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface DragDropTarget {
|
|
43
|
+
readonly zone: string
|
|
44
|
+
/** Insertion index among the target zone's items, from the pointer's
|
|
45
|
+
* position along the zone's axis. Excludes the dragged item itself. */
|
|
46
|
+
readonly index: number
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface DragDropEvent<T = unknown> {
|
|
50
|
+
readonly item: DragDropItemData<T>
|
|
51
|
+
readonly target: DragDropTarget
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
interface ZoneRecord {
|
|
55
|
+
readonly el: HTMLElement
|
|
56
|
+
readonly orientation: 'horizontal' | 'vertical'
|
|
57
|
+
readonly accepts?: (item: DragDropItemData) => boolean
|
|
58
|
+
readonly label: string
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
interface ItemRecord {
|
|
62
|
+
readonly el: HTMLElement
|
|
63
|
+
readonly zone: string
|
|
64
|
+
readonly data: unknown
|
|
65
|
+
readonly label: string
|
|
66
|
+
readonly preview: ReactNode
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
interface DragSession {
|
|
70
|
+
readonly id: string
|
|
71
|
+
readonly pointerId: number
|
|
72
|
+
/** Event time of the press, for judging the touch hold by timestamps. */
|
|
73
|
+
readonly downAt: number
|
|
74
|
+
readonly startX: number
|
|
75
|
+
readonly startY: number
|
|
76
|
+
readonly offsetX: number
|
|
77
|
+
readonly offsetY: number
|
|
78
|
+
readonly width: number
|
|
79
|
+
readonly height: number
|
|
80
|
+
readonly source: HTMLElement
|
|
81
|
+
active: boolean
|
|
82
|
+
holdTimer: ReturnType<typeof setTimeout> | null
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
interface DragState {
|
|
86
|
+
readonly id: string
|
|
87
|
+
readonly preview: ReactNode
|
|
88
|
+
readonly width: number
|
|
89
|
+
readonly height: number
|
|
90
|
+
readonly x: number
|
|
91
|
+
readonly y: number
|
|
92
|
+
readonly over: DragDropTarget | null
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
interface RootContextValue {
|
|
96
|
+
registerZone: (id: string, record: ZoneRecord) => () => void
|
|
97
|
+
registerItem: (id: string, record: ItemRecord) => () => void
|
|
98
|
+
begin: (id: string, e: React.PointerEvent) => void
|
|
99
|
+
drag: DragState | null
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const RootContext = createContext<RootContextValue | null>(null)
|
|
103
|
+
|
|
104
|
+
const useRoot = (part: string): RootContextValue => {
|
|
105
|
+
const ctx = useContext(RootContext)
|
|
106
|
+
if (!ctx) throw new Error(`DragDrop.${part} must be used within DragDrop.Root`)
|
|
107
|
+
return ctx
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** How far the pointer moves before a press becomes a drag (mouse/pen), so a
|
|
111
|
+
* click stays a click. */
|
|
112
|
+
const MOVE_THRESHOLD_PX = 6
|
|
113
|
+
/** How long a touch holds still before it becomes a drag. Below this a touch
|
|
114
|
+
* that moves is a scroll, and the browser gets it back via pointercancel. */
|
|
115
|
+
const TOUCH_HOLD_MS = 180
|
|
116
|
+
|
|
117
|
+
// ---------------------------------------------------------------------------
|
|
118
|
+
// Root
|
|
119
|
+
// ---------------------------------------------------------------------------
|
|
120
|
+
|
|
121
|
+
export interface DragDropRootProps<T = unknown> {
|
|
122
|
+
/** Called when an item is released over a zone that accepts it. Reordering
|
|
123
|
+
* inside the item's own zone arrives here too, with the new index. */
|
|
124
|
+
onDrop: (event: DragDropEvent<T>) => void
|
|
125
|
+
/** Overrides the default screen-reader announcements. */
|
|
126
|
+
announce?: (event: {kind: 'pick' | 'drop' | 'cancel'; item: string; zone?: string}) => string
|
|
127
|
+
children: ReactNode
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function Root<T = unknown>({onDrop, announce, children}: DragDropRootProps<T>) {
|
|
131
|
+
const zones = useRef(new Map<string, ZoneRecord>())
|
|
132
|
+
const items = useRef(new Map<string, ItemRecord>())
|
|
133
|
+
const session = useRef<DragSession | null>(null)
|
|
134
|
+
const frame = useRef<number | null>(null)
|
|
135
|
+
const [drag, setDrag] = useState<DragState | null>(null)
|
|
136
|
+
const [announcement, setAnnouncement] = useState('')
|
|
137
|
+
|
|
138
|
+
const say = useCallback(
|
|
139
|
+
(kind: 'pick' | 'drop' | 'cancel', itemId: string, zoneId?: string) => {
|
|
140
|
+
const item = items.current.get(itemId)?.label ?? itemId
|
|
141
|
+
const zone = zoneId ? (zones.current.get(zoneId)?.label ?? zoneId) : undefined
|
|
142
|
+
const text = announce
|
|
143
|
+
? announce({kind, item, zone})
|
|
144
|
+
: kind === 'pick'
|
|
145
|
+
? `Picked up ${item}.`
|
|
146
|
+
: kind === 'drop'
|
|
147
|
+
? `Dropped ${item} in ${zone ?? 'place'}.`
|
|
148
|
+
: `Cancelled moving ${item}.`
|
|
149
|
+
setAnnouncement(text)
|
|
150
|
+
},
|
|
151
|
+
[announce],
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
const registerZone = useCallback((id: string, record: ZoneRecord) => {
|
|
155
|
+
zones.current.set(id, record)
|
|
156
|
+
return () => {
|
|
157
|
+
zones.current.delete(id)
|
|
158
|
+
}
|
|
159
|
+
}, [])
|
|
160
|
+
|
|
161
|
+
const registerItem = useCallback((id: string, record: ItemRecord) => {
|
|
162
|
+
items.current.set(id, record)
|
|
163
|
+
return () => {
|
|
164
|
+
items.current.delete(id)
|
|
165
|
+
}
|
|
166
|
+
}, [])
|
|
167
|
+
|
|
168
|
+
/** The zone under the pointer that accepts the dragged item, and where in
|
|
169
|
+
* it the item would land. */
|
|
170
|
+
const locate = useCallback((itemId: string, x: number, y: number): DragDropTarget | null => {
|
|
171
|
+
const item = items.current.get(itemId)
|
|
172
|
+
if (!item) return null
|
|
173
|
+
const payload: DragDropItemData = {id: itemId, zone: item.zone, data: item.data}
|
|
174
|
+
for (const [zoneId, zone] of zones.current) {
|
|
175
|
+
const r = zone.el.getBoundingClientRect()
|
|
176
|
+
if (x < r.left || x > r.right || y < r.top || y > r.bottom) continue
|
|
177
|
+
if (zone.accepts && !zone.accepts(payload)) return null
|
|
178
|
+
const horizontal = zone.orientation === 'horizontal'
|
|
179
|
+
let index = 0
|
|
180
|
+
for (const [otherId, other] of items.current) {
|
|
181
|
+
if (otherId === itemId || other.zone !== zoneId) continue
|
|
182
|
+
const or = other.el.getBoundingClientRect()
|
|
183
|
+
const mid = horizontal ? (or.left + or.right) / 2 : (or.top + or.bottom) / 2
|
|
184
|
+
if ((horizontal ? x : y) > mid) index += 1
|
|
185
|
+
}
|
|
186
|
+
return {zone: zoneId, index}
|
|
187
|
+
}
|
|
188
|
+
return null
|
|
189
|
+
}, [])
|
|
190
|
+
|
|
191
|
+
const finish = useCallback(
|
|
192
|
+
(dropped: boolean) => {
|
|
193
|
+
const s = session.current
|
|
194
|
+
if (!s) return
|
|
195
|
+
if (s.holdTimer) clearTimeout(s.holdTimer)
|
|
196
|
+
if (frame.current !== null) cancelAnimationFrame(frame.current)
|
|
197
|
+
frame.current = null
|
|
198
|
+
session.current = null
|
|
199
|
+
try {
|
|
200
|
+
if (s.source.hasPointerCapture(s.pointerId)) s.source.releasePointerCapture(s.pointerId)
|
|
201
|
+
} catch {
|
|
202
|
+
// already released
|
|
203
|
+
}
|
|
204
|
+
if (!s.active) return
|
|
205
|
+
setDrag((current) => {
|
|
206
|
+
const target = current?.over ?? null
|
|
207
|
+
const item = items.current.get(s.id)
|
|
208
|
+
if (dropped && target && item) {
|
|
209
|
+
onDrop({item: {id: s.id, zone: item.zone, data: item.data as T}, target})
|
|
210
|
+
say('drop', s.id, target.zone)
|
|
211
|
+
} else {
|
|
212
|
+
say('cancel', s.id)
|
|
213
|
+
}
|
|
214
|
+
return null
|
|
215
|
+
})
|
|
216
|
+
},
|
|
217
|
+
[onDrop, say],
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
const activate = useCallback(
|
|
221
|
+
(s: DragSession, x: number, y: number) => {
|
|
222
|
+
s.active = true
|
|
223
|
+
const item = items.current.get(s.id)
|
|
224
|
+
setDrag({
|
|
225
|
+
id: s.id,
|
|
226
|
+
preview: item?.preview ?? null,
|
|
227
|
+
width: s.width,
|
|
228
|
+
height: s.height,
|
|
229
|
+
x: x - s.offsetX,
|
|
230
|
+
y: y - s.offsetY,
|
|
231
|
+
over: locate(s.id, x, y),
|
|
232
|
+
})
|
|
233
|
+
say('pick', s.id)
|
|
234
|
+
},
|
|
235
|
+
[locate, say],
|
|
236
|
+
)
|
|
237
|
+
|
|
238
|
+
const begin = useCallback(
|
|
239
|
+
(id: string, e: React.PointerEvent) => {
|
|
240
|
+
if (session.current || e.button !== 0) return
|
|
241
|
+
const source = e.currentTarget as HTMLElement
|
|
242
|
+
const rect = source.getBoundingClientRect()
|
|
243
|
+
const s: DragSession = {
|
|
244
|
+
id,
|
|
245
|
+
pointerId: e.pointerId,
|
|
246
|
+
downAt: e.timeStamp,
|
|
247
|
+
startX: e.clientX,
|
|
248
|
+
startY: e.clientY,
|
|
249
|
+
offsetX: e.clientX - rect.left,
|
|
250
|
+
offsetY: e.clientY - rect.top,
|
|
251
|
+
width: rect.width,
|
|
252
|
+
height: rect.height,
|
|
253
|
+
source,
|
|
254
|
+
active: false,
|
|
255
|
+
holdTimer: null,
|
|
256
|
+
}
|
|
257
|
+
session.current = s
|
|
258
|
+
// Capture keeps the stream on the item if the pointer leaves the window;
|
|
259
|
+
// the document listeners below carry the drag either way, so a browser
|
|
260
|
+
// (or a synthetic event) without an active pointer is not an error.
|
|
261
|
+
try {
|
|
262
|
+
source.setPointerCapture(e.pointerId)
|
|
263
|
+
} catch {
|
|
264
|
+
// no active pointer to capture
|
|
265
|
+
}
|
|
266
|
+
if (e.pointerType === 'touch') {
|
|
267
|
+
s.holdTimer = setTimeout(() => {
|
|
268
|
+
s.holdTimer = null
|
|
269
|
+
if (session.current === s && !s.active) activate(s, s.startX, s.startY)
|
|
270
|
+
}, TOUCH_HOLD_MS)
|
|
271
|
+
}
|
|
272
|
+
},
|
|
273
|
+
[activate],
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
// Move/up/cancel listen on the document: pointer capture keeps the events
|
|
277
|
+
// flowing to the source element, but a single document listener survives
|
|
278
|
+
// the item re-rendering under the pointer.
|
|
279
|
+
useEffect(() => {
|
|
280
|
+
const onMove = (e: PointerEvent) => {
|
|
281
|
+
const s = session.current
|
|
282
|
+
if (!s || e.pointerId !== s.pointerId) return
|
|
283
|
+
if (!s.active) {
|
|
284
|
+
const moved = Math.hypot(e.clientX - s.startX, e.clientY - s.startY)
|
|
285
|
+
if (e.pointerType === 'touch') {
|
|
286
|
+
// The hold is judged by event time, not by the timer having fired:
|
|
287
|
+
// a busy main thread (seen on iOS Safari) can withhold the timer
|
|
288
|
+
// AND every pointermove until the finger lifts, then deliver them
|
|
289
|
+
// in one burst. Since touch-action:none keeps the browser from
|
|
290
|
+
// panning on the item, a move stream that arrives at all is ours;
|
|
291
|
+
// the hold only separates a drag from a quick flick.
|
|
292
|
+
if (e.timeStamp - s.downAt >= TOUCH_HOLD_MS) {
|
|
293
|
+
if (s.holdTimer) clearTimeout(s.holdTimer)
|
|
294
|
+
s.holdTimer = null
|
|
295
|
+
activate(s, s.startX, s.startY)
|
|
296
|
+
} else {
|
|
297
|
+
// Moved before the hold elapsed: a flick, not a drag.
|
|
298
|
+
if (moved > MOVE_THRESHOLD_PX) finish(false)
|
|
299
|
+
return
|
|
300
|
+
}
|
|
301
|
+
} else {
|
|
302
|
+
if (moved < MOVE_THRESHOLD_PX) return
|
|
303
|
+
activate(s, e.clientX, e.clientY)
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
if (frame.current !== null) return
|
|
307
|
+
frame.current = requestAnimationFrame(() => {
|
|
308
|
+
frame.current = null
|
|
309
|
+
const x = e.clientX
|
|
310
|
+
const y = e.clientY
|
|
311
|
+
setDrag((current) =>
|
|
312
|
+
current
|
|
313
|
+
? {...current, x: x - s.offsetX, y: y - s.offsetY, over: locate(s.id, x, y)}
|
|
314
|
+
: current,
|
|
315
|
+
)
|
|
316
|
+
})
|
|
317
|
+
}
|
|
318
|
+
const onUp = (e: PointerEvent) => {
|
|
319
|
+
const s = session.current
|
|
320
|
+
if (!s || e.pointerId !== s.pointerId) return
|
|
321
|
+
// Resolve the target from the release point, not the last frame.
|
|
322
|
+
if (s.active) {
|
|
323
|
+
const over = locate(s.id, e.clientX, e.clientY)
|
|
324
|
+
setDrag((current) => (current ? {...current, over} : current))
|
|
325
|
+
}
|
|
326
|
+
finish(true)
|
|
327
|
+
}
|
|
328
|
+
const onCancel = (e: PointerEvent) => {
|
|
329
|
+
const s = session.current
|
|
330
|
+
if (!s || e.pointerId !== s.pointerId) return
|
|
331
|
+
finish(false)
|
|
332
|
+
}
|
|
333
|
+
const onKey = (e: KeyboardEvent) => {
|
|
334
|
+
if (e.key === 'Escape' && session.current) finish(false)
|
|
335
|
+
}
|
|
336
|
+
document.addEventListener('pointermove', onMove)
|
|
337
|
+
document.addEventListener('pointerup', onUp)
|
|
338
|
+
document.addEventListener('pointercancel', onCancel)
|
|
339
|
+
document.addEventListener('keydown', onKey)
|
|
340
|
+
return () => {
|
|
341
|
+
document.removeEventListener('pointermove', onMove)
|
|
342
|
+
document.removeEventListener('pointerup', onUp)
|
|
343
|
+
document.removeEventListener('pointercancel', onCancel)
|
|
344
|
+
document.removeEventListener('keydown', onKey)
|
|
345
|
+
}
|
|
346
|
+
}, [activate, finish, locate])
|
|
347
|
+
|
|
348
|
+
const value = useMemo<RootContextValue>(
|
|
349
|
+
() => ({registerZone, registerItem, begin, drag}),
|
|
350
|
+
[registerZone, registerItem, begin, drag],
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
return (
|
|
354
|
+
<RootContext.Provider value={value}>
|
|
355
|
+
{children}
|
|
356
|
+
{drag && (
|
|
357
|
+
<html.div
|
|
358
|
+
aria-hidden
|
|
359
|
+
style={[styles.ghost, styles.ghostAt(drag.x, drag.y, drag.width, drag.height)]}
|
|
360
|
+
>
|
|
361
|
+
{drag.preview}
|
|
362
|
+
</html.div>
|
|
363
|
+
)}
|
|
364
|
+
<html.div role="status" aria-live="polite" aria-atomic style={styles.liveRegion}>
|
|
365
|
+
{announcement}
|
|
366
|
+
</html.div>
|
|
367
|
+
</RootContext.Provider>
|
|
368
|
+
)
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// ---------------------------------------------------------------------------
|
|
372
|
+
// Zone
|
|
373
|
+
// ---------------------------------------------------------------------------
|
|
374
|
+
|
|
375
|
+
export interface DragDropZoneProps<T = unknown> {
|
|
376
|
+
id: string
|
|
377
|
+
/** Read to screen readers when an item is dropped here. */
|
|
378
|
+
label: string
|
|
379
|
+
/** Which axis items flow along; decides how the insertion index is read
|
|
380
|
+
* from the pointer. */
|
|
381
|
+
orientation?: 'horizontal' | 'vertical'
|
|
382
|
+
/** Refuse an item; the zone then never lights up for it. */
|
|
383
|
+
accepts?: (item: DragDropItemData<T>) => boolean
|
|
384
|
+
children: ReactNode
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function Zone<T = unknown>({
|
|
388
|
+
id,
|
|
389
|
+
label,
|
|
390
|
+
orientation = 'horizontal',
|
|
391
|
+
accepts,
|
|
392
|
+
children,
|
|
393
|
+
}: DragDropZoneProps<T>) {
|
|
394
|
+
const {registerZone, drag} = useRoot('Zone')
|
|
395
|
+
const ref = useRef<HTMLDivElement>(null)
|
|
396
|
+
|
|
397
|
+
useEffect(() => {
|
|
398
|
+
const el = ref.current
|
|
399
|
+
if (!el || isNative) return
|
|
400
|
+
return registerZone(id, {
|
|
401
|
+
el,
|
|
402
|
+
orientation,
|
|
403
|
+
label,
|
|
404
|
+
accepts: accepts as ZoneRecord['accepts'],
|
|
405
|
+
})
|
|
406
|
+
}, [id, label, orientation, accepts, registerZone])
|
|
407
|
+
|
|
408
|
+
const over = drag?.over?.zone === id
|
|
409
|
+
const dragging = drag !== null
|
|
410
|
+
|
|
411
|
+
return (
|
|
412
|
+
<html.div
|
|
413
|
+
ref={ref}
|
|
414
|
+
style={[styles.zone, dragging && styles.zoneReady, over && styles.zoneOver]}
|
|
415
|
+
>
|
|
416
|
+
{children}
|
|
417
|
+
</html.div>
|
|
418
|
+
)
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// ---------------------------------------------------------------------------
|
|
422
|
+
// Item
|
|
423
|
+
// ---------------------------------------------------------------------------
|
|
424
|
+
|
|
425
|
+
export interface DragDropItemProps<T = unknown> {
|
|
426
|
+
id: string
|
|
427
|
+
/** The zone this item currently lives in. */
|
|
428
|
+
zone: string
|
|
429
|
+
/** Read to screen readers when picked up or dropped. */
|
|
430
|
+
label: string
|
|
431
|
+
/** Carried to onDrop untouched. */
|
|
432
|
+
data?: T
|
|
433
|
+
/** What follows the pointer; defaults to the children. */
|
|
434
|
+
preview?: ReactNode
|
|
435
|
+
disabled?: boolean
|
|
436
|
+
children: ReactNode
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
function Item<T = unknown>({
|
|
440
|
+
id,
|
|
441
|
+
zone,
|
|
442
|
+
label,
|
|
443
|
+
data,
|
|
444
|
+
preview,
|
|
445
|
+
disabled = false,
|
|
446
|
+
children,
|
|
447
|
+
}: DragDropItemProps<T>) {
|
|
448
|
+
const {registerItem, begin, drag} = useRoot('Item')
|
|
449
|
+
const ref = useRef<HTMLDivElement>(null)
|
|
450
|
+
|
|
451
|
+
useEffect(() => {
|
|
452
|
+
const el = ref.current
|
|
453
|
+
if (!el || isNative) return
|
|
454
|
+
return registerItem(id, {el, zone, data, label, preview: preview ?? children})
|
|
455
|
+
}, [id, zone, data, label, preview, children, registerItem])
|
|
456
|
+
|
|
457
|
+
// WebKit only honours the prefixed user-select, and a touch held on
|
|
458
|
+
// selectable text becomes iOS's selection gesture (loupe and handles)
|
|
459
|
+
// before our hold can turn it into a drag; the callout is the same race
|
|
460
|
+
// for links and images. StyleX emits neither prefixed property and
|
|
461
|
+
// react-strict-dom's types have no vendor keys, so they are set on the
|
|
462
|
+
// element directly. Both inherit into the item's children.
|
|
463
|
+
useEffect(() => {
|
|
464
|
+
const el = ref.current
|
|
465
|
+
if (!el || isNative) return
|
|
466
|
+
const style = el.style as CSSStyleDeclaration & {webkitTouchCallout?: string}
|
|
467
|
+
style.webkitUserSelect = 'none'
|
|
468
|
+
style.webkitTouchCallout = 'none'
|
|
469
|
+
}, [])
|
|
470
|
+
|
|
471
|
+
const onPointerDown = useCallback(
|
|
472
|
+
(e: React.PointerEvent) => {
|
|
473
|
+
if (disabled) return
|
|
474
|
+
begin(id, e)
|
|
475
|
+
},
|
|
476
|
+
[begin, disabled, id],
|
|
477
|
+
)
|
|
478
|
+
|
|
479
|
+
if (isNative) return <>{children}</>
|
|
480
|
+
|
|
481
|
+
const lifted = drag?.id === id
|
|
482
|
+
return (
|
|
483
|
+
<html.div
|
|
484
|
+
ref={ref}
|
|
485
|
+
onPointerDown={onPointerDown}
|
|
486
|
+
style={[styles.item, disabled && styles.itemDisabled, lifted && styles.itemLifted]}
|
|
487
|
+
>
|
|
488
|
+
{children}
|
|
489
|
+
</html.div>
|
|
490
|
+
)
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/** Current drag, for consumers that render their own placeholder or ghost. */
|
|
494
|
+
export function useDragDrop(): {
|
|
495
|
+
readonly dragging: string | null
|
|
496
|
+
readonly over: DragDropTarget | null
|
|
497
|
+
} {
|
|
498
|
+
const ctx = useContext(RootContext)
|
|
499
|
+
return {dragging: ctx?.drag?.id ?? null, over: ctx?.drag?.over ?? null}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
export const DragDrop = {Root, Zone, Item}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import {css} from 'react-strict-dom'
|
|
2
|
+
import {colors} from '@duro-app/tokens/tokens/colors.css'
|
|
3
|
+
import {radii} from '@duro-app/tokens/tokens/spacing.css'
|
|
4
|
+
import {duration, easing} from '@duro-app/tokens/tokens/motion.css'
|
|
5
|
+
|
|
6
|
+
export const styles = css.create({
|
|
7
|
+
// The item is the drag handle: no browser panning starts on it, so a touch
|
|
8
|
+
// hold becomes a drag instead of a scroll, and text inside never selects
|
|
9
|
+
// mid-gesture.
|
|
10
|
+
item: {
|
|
11
|
+
touchAction: 'none',
|
|
12
|
+
userSelect: 'none',
|
|
13
|
+
cursor: 'grab',
|
|
14
|
+
transitionProperty: 'opacity',
|
|
15
|
+
transitionDuration: duration.fast,
|
|
16
|
+
transitionTimingFunction: easing.standard,
|
|
17
|
+
},
|
|
18
|
+
itemDisabled: {
|
|
19
|
+
cursor: 'default',
|
|
20
|
+
},
|
|
21
|
+
// The source stays in flow while its ghost travels, so nothing reflows.
|
|
22
|
+
itemLifted: {
|
|
23
|
+
opacity: 0.4,
|
|
24
|
+
cursor: 'grabbing',
|
|
25
|
+
},
|
|
26
|
+
zone: {
|
|
27
|
+
borderRadius: radii.sm,
|
|
28
|
+
transitionProperty: 'box-shadow, background-color',
|
|
29
|
+
transitionDuration: duration.fast,
|
|
30
|
+
transitionTimingFunction: easing.standard,
|
|
31
|
+
},
|
|
32
|
+
// Every zone shows it can receive while something is in the air; the one
|
|
33
|
+
// under the pointer lights up.
|
|
34
|
+
zoneReady: {
|
|
35
|
+
boxShadow: `inset 0 0 0 1px ${colors.border}`,
|
|
36
|
+
},
|
|
37
|
+
zoneOver: {
|
|
38
|
+
boxShadow: `inset 0 0 0 2px ${colors.accent}`,
|
|
39
|
+
backgroundColor: colors.infoBg,
|
|
40
|
+
},
|
|
41
|
+
ghost: {
|
|
42
|
+
position: 'fixed',
|
|
43
|
+
top: 0,
|
|
44
|
+
left: 0,
|
|
45
|
+
pointerEvents: 'none',
|
|
46
|
+
zIndex: 1000,
|
|
47
|
+
opacity: 0.9,
|
|
48
|
+
cursor: 'grabbing',
|
|
49
|
+
},
|
|
50
|
+
ghostAt: (x: number, y: number, width: number, height: number) => ({
|
|
51
|
+
width,
|
|
52
|
+
height,
|
|
53
|
+
transform: `translate3d(${x}px, ${y}px, 0)`,
|
|
54
|
+
}),
|
|
55
|
+
// Same visually-hidden recipe as TagGroup's live region.
|
|
56
|
+
liveRegion: {
|
|
57
|
+
position: 'absolute',
|
|
58
|
+
width: 1,
|
|
59
|
+
height: 1,
|
|
60
|
+
overflow: 'hidden',
|
|
61
|
+
},
|
|
62
|
+
})
|
|
@@ -2,11 +2,11 @@ import type {ComponentMeta} from '../component-meta'
|
|
|
2
2
|
|
|
3
3
|
export const meta: ComponentMeta = {
|
|
4
4
|
description:
|
|
5
|
-
'
|
|
5
|
+
'Grid layout. Columns are a count (1-6), a list of weights ([1, 2] for one-third / two-thirds), responsive auto-fit via minColumnWidth, or a named split layout (list/detail, nav/content) that collapses to one column on the width of its own container, so it is safe to nest and to place beside a DetailPanel. Weights rather than CSS template strings, so the same props render as CSS grid on web and as a wrapping flex row on native.',
|
|
6
6
|
whenToUse: [
|
|
7
7
|
'Card grids, dashboard layouts, multi-column forms',
|
|
8
8
|
'Responsive layouts that should auto-adjust column count',
|
|
9
|
-
'A list/detail or nav/content screen — layout="split" | "split-wide", never a hand-rolled gridTemplateColumns with its own @media',
|
|
9
|
+
'A list/detail or nav/content screen — layout="split" | "split-wide", never a hand-rolled gridTemplateColumns with its own @media; a split inside a split is fine, each collapses on its own room',
|
|
10
10
|
],
|
|
11
11
|
whenNotToUse: [
|
|
12
12
|
'Single-column vertical layout — use Stack',
|
|
@@ -42,5 +42,11 @@ export const meta: ComponentMeta = {
|
|
|
42
42
|
<Card>A</Card>
|
|
43
43
|
<Card>B</Card>
|
|
44
44
|
<Card>C</Card>
|
|
45
|
+
</Grid>
|
|
46
|
+
|
|
47
|
+
// Weighted: a one-third / two-thirds split (works on native too)
|
|
48
|
+
<Grid columns={[1, 2]} gap="md">
|
|
49
|
+
<Card>Sidebar</Card>
|
|
50
|
+
<Card>Content</Card>
|
|
45
51
|
</Grid>`,
|
|
46
52
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type {Meta, StoryObj} from '@storybook/react'
|
|
2
|
+
import {expect} from 'storybook/test'
|
|
2
3
|
import {css, html} from 'react-strict-dom'
|
|
3
4
|
import {Grid} from './Grid'
|
|
4
5
|
import {Stack} from '../Stack/Stack'
|
|
@@ -19,6 +20,7 @@ const meta: Meta<typeof Grid> = {
|
|
|
19
20
|
columns: {
|
|
20
21
|
control: 'select',
|
|
21
22
|
options: [1, 2, 3, 4, 5, 6],
|
|
23
|
+
description: 'A count, or weights such as [1, 2] for a one-third / two-thirds split',
|
|
22
24
|
},
|
|
23
25
|
minColumnWidth: {control: 'text'},
|
|
24
26
|
layout: {control: 'select', options: [undefined, 'split', 'split-wide']},
|
|
@@ -41,6 +43,12 @@ const localStyles = css.create({
|
|
|
41
43
|
fontSize: typography.fontSizeXs,
|
|
42
44
|
color: colors.textMuted,
|
|
43
45
|
},
|
|
46
|
+
frame: (width: number) => ({
|
|
47
|
+
width,
|
|
48
|
+
borderWidth: 1,
|
|
49
|
+
borderStyle: 'dashed',
|
|
50
|
+
borderColor: colors.border,
|
|
51
|
+
}),
|
|
44
52
|
})
|
|
45
53
|
|
|
46
54
|
const Cell = ({children}: {children: string}) => (
|
|
@@ -89,7 +97,8 @@ export const Split: Story = {
|
|
|
89
97
|
render: () => (
|
|
90
98
|
<Stack gap="sm">
|
|
91
99
|
<html.span style={localStyles.label}>
|
|
92
|
-
layout="split" — list ≥ 240px beside the detail, one column
|
|
100
|
+
layout="split" — list ≥ 240px beside the detail, one column when the container is
|
|
101
|
+
narrower than sm; split-wide collapses below md
|
|
93
102
|
</html.span>
|
|
94
103
|
<Grid layout="split" gap="lg">
|
|
95
104
|
<Cell>list</Cell>
|
|
@@ -103,6 +112,79 @@ export const Split: Story = {
|
|
|
103
112
|
),
|
|
104
113
|
}
|
|
105
114
|
|
|
115
|
+
/**
|
|
116
|
+
* A split inside a split: a list/detail board in the content column of a
|
|
117
|
+
* nav/content shell. Each collapses on its OWN container: at 1120 both split;
|
|
118
|
+
* at 900 the shell still splits (≥ md) while the board — left 588px, under
|
|
119
|
+
* sm — stacks; at 700 the shell stacks and the board, now given the whole
|
|
120
|
+
* width, splits again; at 600 both stack. Keyed on the viewport, both would
|
|
121
|
+
* open at 768 and leave the detail pane ~150px.
|
|
122
|
+
*/
|
|
123
|
+
export const NestedSplits: Story = {
|
|
124
|
+
render: () => (
|
|
125
|
+
<Stack gap="lg">
|
|
126
|
+
{[1120, 900, 700, 600].map((width) => (
|
|
127
|
+
<Stack key={width} gap="sm">
|
|
128
|
+
<html.span style={localStyles.label}>frame {width}px</html.span>
|
|
129
|
+
<html.div style={localStyles.frame(width)} data-testid={`frame-${width}`}>
|
|
130
|
+
<Grid layout="split-wide" gap="xl">
|
|
131
|
+
<Cell>nav</Cell>
|
|
132
|
+
<Grid layout="split" gap="md">
|
|
133
|
+
<Cell>list</Cell>
|
|
134
|
+
<Cell>detail</Cell>
|
|
135
|
+
</Grid>
|
|
136
|
+
</Grid>
|
|
137
|
+
</html.div>
|
|
138
|
+
</Stack>
|
|
139
|
+
))}
|
|
140
|
+
</Stack>
|
|
141
|
+
),
|
|
142
|
+
play: async ({canvas}) => {
|
|
143
|
+
const tracks = (frame: HTMLElement, text: string) => {
|
|
144
|
+
const grid = canvas.getAllByText(text).find((el) => frame.contains(el))
|
|
145
|
+
?.parentElement as HTMLElement
|
|
146
|
+
return getComputedStyle(grid).gridTemplateColumns.split(' ').length
|
|
147
|
+
}
|
|
148
|
+
const expected: Record<number, [number, number]> = {
|
|
149
|
+
1120: [2, 2],
|
|
150
|
+
900: [2, 1],
|
|
151
|
+
700: [1, 2],
|
|
152
|
+
600: [1, 1],
|
|
153
|
+
}
|
|
154
|
+
for (const [width, [outer, inner]] of Object.entries(expected)) {
|
|
155
|
+
const frame = canvas.getByTestId(`frame-${width}`)
|
|
156
|
+
await expect(tracks(frame, 'nav'), `shell at ${width}`).toBe(outer)
|
|
157
|
+
await expect(tracks(frame, 'list'), `board at ${width}`).toBe(inner)
|
|
158
|
+
}
|
|
159
|
+
},
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Weighted columns: the portable form of `grid-template-columns: 1fr 2fr`.
|
|
164
|
+
* Weights are what the native Grid can honour too (as flex bases), where a
|
|
165
|
+
* CSS template string would be dropped.
|
|
166
|
+
*/
|
|
167
|
+
export const WeightedColumns: Story = {
|
|
168
|
+
render: () => (
|
|
169
|
+
<Grid gap="md" columns={[1, 2]}>
|
|
170
|
+
<Cell>Sidebar (1)</Cell>
|
|
171
|
+
<Cell>Content (2)</Cell>
|
|
172
|
+
<Cell>Sidebar (1)</Cell>
|
|
173
|
+
<Cell>Content (2)</Cell>
|
|
174
|
+
</Grid>
|
|
175
|
+
),
|
|
176
|
+
play: async ({canvas}) => {
|
|
177
|
+
// The grid is the cells' parent (Storybook wraps the story in its own
|
|
178
|
+
// divs). The browser resolves `1fr 2fr` to pixel tracks, so assert the
|
|
179
|
+
// ratio rather than the string.
|
|
180
|
+
const root = canvas.getAllByText('Sidebar (1)')[0].parentElement as HTMLElement
|
|
181
|
+
const tracks = getComputedStyle(root).gridTemplateColumns.split(' ').map(Number.parseFloat)
|
|
182
|
+
await expect(tracks).toHaveLength(2)
|
|
183
|
+
// Second track is twice the first, within a pixel of rounding.
|
|
184
|
+
await expect(Math.abs(tracks[1] - 2 * tracks[0])).toBeLessThan(1.5)
|
|
185
|
+
},
|
|
186
|
+
}
|
|
187
|
+
|
|
106
188
|
export const WithContainerQuery: Story = {
|
|
107
189
|
render: function Render() {
|
|
108
190
|
const {ref, size} = useContainerQuery<HTMLDivElement>()
|