@duro-app/ui 5.6.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 (60) hide show
  1. package/dist/{Table-B1sWL3GA.js → Table-Dj7PXqO_.js} +3 -2
  2. package/dist/Table-Dj7PXqO_.js.map +1 -0
  3. package/dist/components/DragDrop/DragDrop.d.ts +95 -14
  4. package/dist/components/DragDrop/DragDrop.d.ts.map +1 -1
  5. package/dist/components/DragDrop/DragDrop.meta.d.ts.map +1 -1
  6. package/dist/components/DragDrop/DragDropBoard.stories.d.ts +69 -0
  7. package/dist/components/DragDrop/DragDropBoard.stories.d.ts.map +1 -0
  8. package/dist/components/DragDrop/styles.css.d.ts +80 -3
  9. package/dist/components/DragDrop/styles.css.d.ts.map +1 -1
  10. package/dist/components/Meter/Meter.d.ts +17 -0
  11. package/dist/components/Meter/Meter.d.ts.map +1 -0
  12. package/dist/components/Meter/Meter.meta.d.ts +3 -0
  13. package/dist/components/Meter/Meter.meta.d.ts.map +1 -0
  14. package/dist/components/Meter/Meter.stories.d.ts +12 -0
  15. package/dist/components/Meter/Meter.stories.d.ts.map +1 -0
  16. package/dist/components/Meter/styles.css.d.ts +34 -0
  17. package/dist/components/Meter/styles.css.d.ts.map +1 -0
  18. package/dist/components/Timeline/Timeline.d.ts +71 -0
  19. package/dist/components/Timeline/Timeline.d.ts.map +1 -0
  20. package/dist/components/Timeline/Timeline.meta.d.ts +3 -0
  21. package/dist/components/Timeline/Timeline.meta.d.ts.map +1 -0
  22. package/dist/components/Timeline/Timeline.stories.d.ts +26 -0
  23. package/dist/components/Timeline/Timeline.stories.d.ts.map +1 -0
  24. package/dist/components/Timeline/styles.css.d.ts +158 -0
  25. package/dist/components/Timeline/styles.css.d.ts.map +1 -0
  26. package/dist/components/Toast/Toast.meta.d.ts.map +1 -1
  27. package/dist/components/Toast/Toast.stories.d.ts +9 -0
  28. package/dist/components/Toast/Toast.stories.d.ts.map +1 -1
  29. package/dist/components/Toast/ToastProvider.d.ts +11 -5
  30. package/dist/components/Toast/ToastProvider.d.ts.map +1 -1
  31. package/dist/components/Toast/index.d.ts +1 -1
  32. package/dist/components/Toast/index.d.ts.map +1 -1
  33. package/dist/components/Toast/styles.css.d.ts +6 -0
  34. package/dist/components/Toast/styles.css.d.ts.map +1 -1
  35. package/dist/index.css +1 -1
  36. package/dist/index.d.ts +4 -2
  37. package/dist/index.d.ts.map +1 -1
  38. package/dist/index.js +5029 -3927
  39. package/dist/index.js.map +1 -1
  40. package/dist/table.js +1 -1
  41. package/package.json +2 -2
  42. package/src/components/DragDrop/DragDrop.meta.ts +22 -21
  43. package/src/components/DragDrop/DragDrop.tsx +1147 -246
  44. package/src/components/DragDrop/DragDropBoard.stories.tsx +668 -0
  45. package/src/components/DragDrop/styles.css.ts +74 -4
  46. package/src/components/Meter/Meter.meta.ts +24 -0
  47. package/src/components/Meter/Meter.stories.tsx +64 -0
  48. package/src/components/Meter/Meter.tsx +55 -0
  49. package/src/components/Meter/styles.css.ts +30 -0
  50. package/src/components/Timeline/Timeline.meta.ts +46 -0
  51. package/src/components/Timeline/Timeline.stories.tsx +238 -0
  52. package/src/components/Timeline/Timeline.tsx +332 -0
  53. package/src/components/Timeline/styles.css.ts +162 -0
  54. package/src/components/Toast/Toast.meta.ts +2 -1
  55. package/src/components/Toast/Toast.stories.tsx +84 -0
  56. package/src/components/Toast/ToastProvider.tsx +49 -11
  57. package/src/components/Toast/index.ts +7 -1
  58. package/src/components/Toast/styles.css.ts +2 -0
  59. package/src/index.ts +16 -0
  60. package/dist/Table-B1sWL3GA.js.map +0 -1
@@ -3,33 +3,55 @@ import {
3
3
  useCallback,
4
4
  useContext,
5
5
  useEffect,
6
+ useId,
7
+ useLayoutEffect,
6
8
  useMemo,
7
9
  useRef,
8
10
  useState,
11
+ useSyncExternalStore,
9
12
  type ReactNode,
10
13
  } from 'react'
11
14
  import {createPortal} from 'react-dom'
12
15
  import {html} from 'react-strict-dom'
16
+ import type {SpacingToken} from '@duro-app/tokens/keys'
13
17
  import {styles} from './styles.css'
14
18
  import {usePortalMount} from '../ThemeProvider/ThemeProvider'
15
19
  import {isNative} from '../../platform'
16
20
  import {visuallyHidden} from '../../styles/visually-hidden.css'
17
21
 
18
22
  // ---------------------------------------------------------------------------
19
- // DragDrop — move items between zones with one pointer, on mouse, pen and
20
- // touch alike.
23
+ // DragDrop — move items between zones, by pointer (mouse, pen and touch
24
+ // through one pointer-event path) or by keyboard.
21
25
  //
22
- // Pointer events are the single input path: a press arms a drag, movement
23
- // past a small threshold (or, on touch, a short hold) starts it, pointer
24
- // capture keeps the stream on the item, and release resolves the zone under
25
- // the pointer. The dragged item stays in the DOM (faded) while a ghost copy
26
- // follows the pointer, so layout never shifts mid-drag and a cancelled drag
27
- // costs nothing.
26
+ // Pointer: a press arms a drag, movement past a small threshold (or, on
27
+ // touch, a short hold) starts it, and release resolves the zone under the
28
+ // pointer. The innermost zone that accepts the item wins
29
+ // (document.elementsFromPoint), so a zone nested in another (a card inside a
30
+ // column) receives its own drops. The dragged item stays in the DOM (faded)
31
+ // while a ghost follows the pointer. Item rects are measured once at pick-up
32
+ // and shifted by how far their scrolling ancestors have scrolled since, so an
33
+ // optional placeholder (renderPlaceholder) never feeds back into the index,
34
+ // and a small hysteresis keeps the index from flickering at a midpoint.
35
+ // Near the edge of a scrolling ancestor (or the window) the drag scrolls it.
28
36
  //
29
- // Dragging is a pointer gesture only. Every consumer MUST keep a non-drag way
30
- // to do the same thing (a button that adds, a remove affordance) — that is
31
- // the keyboard and assistive path, and WCAG 2.5.7 requires it. The Root's
32
- // live region announces picks and drops so screen readers follow along.
37
+ // Keyboard: put a DragDrop.Handle in an Item. Handles are one roving tab
38
+ // stop per zone; Up/Down and Left/Right move focus between them. Space picks
39
+ // the item up, Up/Down move it within its zone, Left/Right move it across
40
+ // zones (in `order`, else DOM order), Space or Enter drops, Escape (or Tab)
41
+ // cancels, and focus returns to the item's handle either way.
42
+ //
43
+ // State lives in an external store read with useSyncExternalStore and
44
+ // per-part selectors: a drag re-renders only the parts whose answer changed
45
+ // (the two items around a moving placeholder, the zones on pick-up and drop),
46
+ // and the ghost is moved by writing its transform, not by rendering.
47
+ //
48
+ // Every announcement (pick, move, drop, cancel, refuse) goes through the
49
+ // Root's live region and the optional `announce` prop, so an app can supply
50
+ // its own catalog strings. A zone can refuse with a reason
51
+ // (`accepts` → `{ok: false, reason}`): the ghost shows it and it is announced.
52
+ //
53
+ // WCAG 2.5.7 still applies to pointer-only consumers: an Item without a
54
+ // Handle has no keyboard path, so keep a button that does the same thing.
33
55
  //
34
56
  // Native: react-strict-dom does not route pointer capture on React Native,
35
57
  // so Item renders its children without drag behaviour there and the consumer's
@@ -54,19 +76,66 @@ export interface DragDropEvent<T = unknown> {
54
76
  readonly target: DragDropTarget
55
77
  }
56
78
 
57
- interface ZoneRecord {
58
- readonly el: HTMLElement
59
- readonly orientation: 'horizontal' | 'vertical'
60
- readonly accepts?: (item: DragDropItemData) => boolean
61
- readonly label: string
79
+ /** What a zone's `accepts` answers: `true` takes the item, `false` lets it
80
+ * pass silently (an outer zone may take it), and `{ok: false, reason}`
81
+ * refuses it out loud — the reason is shown on the ghost and announced. */
82
+ export type DragDropVerdict = boolean | {readonly ok: false; readonly reason: string}
83
+
84
+ export interface DragDropRefusal {
85
+ readonly zone: string
86
+ readonly reason: string
62
87
  }
63
88
 
64
- interface ItemRecord {
65
- readonly el: HTMLElement
89
+ export interface DragDropOverEvent<T = unknown> {
90
+ readonly item: DragDropItemData<T>
91
+ /** Where the item would land now; null over nothing that accepts it. */
92
+ readonly target: DragDropTarget | null
93
+ /** The innermost zone that refused with a reason, when nothing accepted. */
94
+ readonly refused: DragDropRefusal | null
95
+ readonly keyboard: boolean
96
+ }
97
+
98
+ export interface DragDropAnnouncement {
99
+ readonly kind: 'pick' | 'move' | 'drop' | 'cancel' | 'refuse'
100
+ /** The item's label. */
101
+ readonly item: string
102
+ /** The zone's label (pick, move, drop, refuse). */
103
+ readonly zone?: string
104
+ /** 1-based position in the zone, counting the item itself. */
105
+ readonly position?: number
106
+ /** How many items the zone holds with the item in it. */
107
+ readonly count?: number
108
+ /** The zone's refusal (refuse). */
109
+ readonly reason?: string
110
+ /** Whether the drag is a keyboard one. */
111
+ readonly keyboard: boolean
112
+ }
113
+
114
+ export interface DragDropPlaceholderInfo {
66
115
  readonly zone: string
67
- readonly data: unknown
116
+ readonly index: number
117
+ /** The zone's label, e.g. for "→ In progress". */
68
118
  readonly label: string
69
- readonly preview: ReactNode
119
+ }
120
+
121
+ export type DragDropRenderPlaceholder = (info: DragDropPlaceholderInfo) => ReactNode
122
+
123
+ interface ZoneRecord {
124
+ el: HTMLElement | null
125
+ orientation: 'horizontal' | 'vertical'
126
+ label: string
127
+ accepts?: (item: DragDropItemData) => DragDropVerdict
128
+ order?: number
129
+ renderPlaceholder?: DragDropRenderPlaceholder
130
+ }
131
+
132
+ interface ItemRecord {
133
+ el: HTMLElement | null
134
+ zone: string
135
+ label: string
136
+ data: unknown
137
+ preview: ReactNode
138
+ disabled: boolean
70
139
  }
71
140
 
72
141
  interface DragSession {
@@ -85,29 +154,47 @@ interface DragSession {
85
154
  holdTimer: ReturnType<typeof setTimeout> | null
86
155
  }
87
156
 
88
- interface DragState {
157
+ /** The drag as the parts see it. Replaced (never mutated) on every change, so
158
+ * selectors can compare by reference. */
159
+ interface DragView {
89
160
  readonly id: string
161
+ readonly keyboard: boolean
162
+ readonly over: DragDropTarget | null
163
+ readonly refused: DragDropRefusal | null
164
+ /** Each zone's items in DOM order at pick-up, without the dragged one. */
165
+ readonly order: ReadonlyMap<string, readonly string[]>
166
+ }
167
+
168
+ interface GhostView {
90
169
  readonly preview: ReactNode
91
170
  readonly width: number
92
171
  readonly height: number
93
- readonly x: number
94
- readonly y: number
95
- readonly over: DragDropTarget | null
96
172
  }
97
173
 
98
- interface RootContextValue {
99
- registerZone: (id: string, record: ZoneRecord) => () => void
100
- registerItem: (id: string, record: ItemRecord) => () => void
101
- begin: (id: string, e: React.PointerEvent) => void
102
- drag: DragState | null
174
+ interface Rect {
175
+ readonly left: number
176
+ readonly top: number
177
+ readonly right: number
178
+ readonly bottom: number
103
179
  }
104
180
 
105
- const RootContext = createContext<RootContextValue | null>(null)
181
+ interface Geometry {
182
+ readonly rect: Rect
183
+ readonly scrollers: readonly Element[]
184
+ }
106
185
 
107
- const useRoot = (part: string): RootContextValue => {
108
- const ctx = useContext(RootContext)
109
- if (!ctx) throw new Error(`DragDrop.${part} must be used within DragDrop.Root`)
110
- return ctx
186
+ /** The parts of a keydown the keyboard path reads. */
187
+ interface KeyLike {
188
+ readonly key: string
189
+ preventDefault(): void
190
+ stopPropagation(): void
191
+ }
192
+
193
+ interface LatestProps {
194
+ onDrop: (event: DragDropEvent<unknown>) => void
195
+ onDragOver?: (event: DragDropOverEvent<unknown>) => void
196
+ announce?: (event: DragDropAnnouncement) => string
197
+ renderPlaceholder?: DragDropRenderPlaceholder
111
198
  }
112
199
 
113
200
  /** How far the pointer moves before a press becomes a drag (mouse/pen), so a
@@ -116,173 +203,517 @@ const MOVE_THRESHOLD_PX = 6
116
203
  /** How long a touch holds still before it becomes a drag. Below this a touch
117
204
  * that moves is a scroll, and the browser gets it back via pointercancel. */
118
205
  const TOUCH_HOLD_MS = 180
206
+ /** How far past an item's midpoint the pointer must go before the insertion
207
+ * index changes, so it does not flicker on the line. */
208
+ const HYSTERESIS_PX = 8
209
+ /** Auto-scroll: the band along a scroller's edge that scrolls it, and the
210
+ * largest step per frame (reached at the very edge). */
211
+ const EDGE_PX = 48
212
+ const MAX_STEP_PX = 16
213
+
214
+ const domOrder = (a: Element, b: Element) =>
215
+ a === b ? 0 : a.compareDocumentPosition(b) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1
216
+
217
+ const verdictOf = (v: DragDropVerdict | undefined): {ok: boolean; reason?: string} =>
218
+ v === undefined || v === true
219
+ ? {ok: true}
220
+ : v === false
221
+ ? {ok: false}
222
+ : {ok: false, reason: v.reason}
223
+
224
+ const sameTarget = (a: DragDropTarget | null, b: DragDropTarget | null) =>
225
+ a === b || (a !== null && b !== null && a.zone === b.zone && a.index === b.index)
226
+
227
+ const sameRefusal = (a: DragDropRefusal | null, b: DragDropRefusal | null) =>
228
+ a === b || (a !== null && b !== null && a.zone === b.zone && a.reason === b.reason)
229
+
230
+ function defaultAnnouncement(e: DragDropAnnouncement): string {
231
+ const where = e.position !== undefined ? `${e.zone}, position ${e.position} of ${e.count}` : ''
232
+ switch (e.kind) {
233
+ case 'pick':
234
+ return e.keyboard
235
+ ? `Picked up ${e.item}. ${where}. Arrow keys move, Space drops, Escape cancels.`
236
+ : `Picked up ${e.item}.`
237
+ case 'move':
238
+ return where
239
+ case 'drop':
240
+ return e.keyboard
241
+ ? `Dropped ${e.item} in ${where}.`
242
+ : `Dropped ${e.item} in ${e.zone ?? 'place'}.`
243
+ case 'cancel':
244
+ return `Cancelled moving ${e.item}.`
245
+ case 'refuse':
246
+ return `${e.zone}: ${e.reason}`
247
+ }
248
+ }
119
249
 
120
250
  // ---------------------------------------------------------------------------
121
- // Root
251
+ // Engine: the registry (stable), the drag store (subscribed to), and the
252
+ // pointer/keyboard state machine. One per Root, created once.
122
253
  // ---------------------------------------------------------------------------
123
254
 
124
- export interface DragDropRootProps<T = unknown> {
125
- /** Called when an item is released over a zone that accepts it. Reordering
126
- * inside the item's own zone arrives here too, with the new index. */
127
- onDrop: (event: DragDropEvent<T>) => void
128
- /** Overrides the default screen-reader announcements. */
129
- announce?: (event: {kind: 'pick' | 'drop' | 'cancel'; item: string; zone?: string}) => string
130
- children: ReactNode
131
- }
255
+ /** setPointerCapture / releasePointerCapture throw these when there is no
256
+ * active pointer to capture (a synthetic event, a pointer already gone) or
257
+ * the capture is already released. Anything else is a real error. */
258
+ const isPointerCaptureMiss = (err: unknown): boolean =>
259
+ err instanceof DOMException && (err.name === 'NotFoundError' || err.name === 'InvalidStateError')
132
260
 
133
- function Root<T = unknown>({onDrop, announce, children}: DragDropRootProps<T>) {
134
- const zones = useRef(new Map<string, ZoneRecord>())
135
- const items = useRef(new Map<string, ItemRecord>())
136
- const session = useRef<DragSession | null>(null)
137
- const frame = useRef<number | null>(null)
138
- const [drag, setDrag] = useState<DragState | null>(null)
139
- const [announcement, setAnnouncement] = useState('')
140
-
141
- const say = useCallback(
142
- (kind: 'pick' | 'drop' | 'cancel', itemId: string, zoneId?: string) => {
143
- const item = items.current.get(itemId)?.label ?? itemId
144
- const zone = zoneId ? (zones.current.get(zoneId)?.label ?? zoneId) : undefined
145
- const text = announce
146
- ? announce({kind, item, zone})
147
- : kind === 'pick'
148
- ? `Picked up ${item}.`
149
- : kind === 'drop'
150
- ? `Dropped ${item} in ${zone ?? 'place'}.`
151
- : `Cancelled moving ${item}.`
152
- setAnnouncement(text)
153
- },
154
- [announce],
155
- )
261
+ class Engine {
262
+ props: LatestProps = {onDrop: () => {}}
263
+ readonly zones = new Map<string, ZoneRecord>()
264
+ readonly items = new Map<string, ItemRecord>()
265
+ readonly handles = new Map<string, HTMLElement>()
266
+
267
+ drag: DragView | null = null
268
+ ghost: GhostView | null = null
269
+ ghostEl: HTMLElement | null = null
270
+ refusalText: string | null = null
271
+ announcement = ''
272
+ focusRequest: string | null = null
273
+
274
+ /** Roving tab stop per zone: the handle last focused there. */
275
+ private readonly stops = new Map<string, string>()
276
+ /** The first handle of each zone in DOM order, the stop until one is focused. */
277
+ private firstStops = new Map<string, string>()
278
+ private stopsDirty = false
279
+
280
+ private session: DragSession | null = null
281
+ private frame: number | null = null
282
+ private pointer = {x: 0, y: 0}
283
+ private geometry = new Map<string, Geometry>()
284
+ private baseline = new Map<Element, {left: number; top: number}>()
285
+ private scrollable = new Map<Element, boolean>()
286
+ private readonly listeners = new Set<() => void>()
156
287
 
157
- const registerZone = useCallback((id: string, record: ZoneRecord) => {
158
- zones.current.set(id, record)
288
+ // -- store ----------------------------------------------------------------
289
+
290
+ subscribe = (listener: () => void) => {
291
+ this.listeners.add(listener)
159
292
  return () => {
160
- zones.current.delete(id)
293
+ this.listeners.delete(listener)
161
294
  }
162
- }, [])
295
+ }
296
+
297
+ private emit() {
298
+ for (const l of this.listeners) l()
299
+ }
300
+
301
+ tabStop(zone: string): string | undefined {
302
+ const explicit = this.stops.get(zone)
303
+ if (explicit && this.handles.has(explicit) && this.items.get(explicit)?.zone === zone) {
304
+ return explicit
305
+ }
306
+ return this.firstStops.get(zone)
307
+ }
308
+
309
+ // -- registry -------------------------------------------------------------
163
310
 
164
- const registerItem = useCallback((id: string, record: ItemRecord) => {
165
- items.current.set(id, record)
311
+ registerZone(id: string, record: ZoneRecord) {
312
+ this.zones.set(id, record)
166
313
  return () => {
167
- items.current.delete(id)
314
+ if (this.zones.get(id) === record) this.zones.delete(id)
168
315
  }
169
- }, [])
316
+ }
170
317
 
171
- /** The zone under the pointer that accepts the dragged item, and where in
172
- * it the item would land. */
173
- const locate = useCallback((itemId: string, x: number, y: number): DragDropTarget | null => {
174
- const item = items.current.get(itemId)
175
- if (!item) return null
176
- const payload: DragDropItemData = {id: itemId, zone: item.zone, data: item.data}
177
- for (const [zoneId, zone] of zones.current) {
178
- const r = zone.el.getBoundingClientRect()
179
- if (x < r.left || x > r.right || y < r.top || y > r.bottom) continue
180
- if (zone.accepts && !zone.accepts(payload)) return null
181
- const horizontal = zone.orientation === 'horizontal'
182
- let index = 0
183
- for (const [otherId, other] of items.current) {
184
- if (otherId === itemId || other.zone !== zoneId) continue
185
- const or = other.el.getBoundingClientRect()
186
- const mid = horizontal ? (or.left + or.right) / 2 : (or.top + or.bottom) / 2
187
- if ((horizontal ? x : y) > mid) index += 1
318
+ registerItem(id: string, record: ItemRecord) {
319
+ this.items.set(id, record)
320
+ return () => {
321
+ if (this.items.get(id) === record) this.items.delete(id)
322
+ }
323
+ }
324
+
325
+ registerHandle(id: string, el: HTMLElement) {
326
+ this.handles.set(id, el)
327
+ this.recomputeStops()
328
+ return () => {
329
+ if (this.handles.get(id) === el) this.handles.delete(id)
330
+ this.recomputeStops()
331
+ }
332
+ }
333
+
334
+ /** Batched: a board mounting 300 handles recomputes once. */
335
+ private recomputeStops() {
336
+ if (this.stopsDirty) return
337
+ this.stopsDirty = true
338
+ queueMicrotask(() => {
339
+ this.stopsDirty = false
340
+ const next = new Map<string, string>()
341
+ const firstEl = new Map<string, HTMLElement>()
342
+ for (const [id, el] of this.handles) {
343
+ const zone = this.items.get(id)?.zone
344
+ if (zone === undefined) continue
345
+ const cur = firstEl.get(zone)
346
+ if (!cur || domOrder(el, cur) < 0) {
347
+ firstEl.set(zone, el)
348
+ next.set(zone, id)
349
+ }
188
350
  }
189
- return {zone: zoneId, index}
351
+ this.firstStops = next
352
+ this.emit()
353
+ })
354
+ }
355
+
356
+ private handlesIn(zone: string): string[] {
357
+ const ids: string[] = []
358
+ for (const [id] of this.handles) if (this.items.get(id)?.zone === zone) ids.push(id)
359
+ return ids.sort((a, b) => domOrder(this.handles.get(a)!, this.handles.get(b)!))
360
+ }
361
+
362
+ /** Zones in keyboard order: by `order`, then DOM order. */
363
+ private zoneSequence(): string[] {
364
+ const list = [...this.zones].filter(([, z]) => z.el)
365
+ list.sort(
366
+ ([, a], [, b]) =>
367
+ (a.order ?? Number.POSITIVE_INFINITY) - (b.order ?? Number.POSITIVE_INFINITY) ||
368
+ domOrder(a.el!, b.el!),
369
+ )
370
+ return list.map(([id]) => id)
371
+ }
372
+
373
+ // -- announcements ----------------------------------------------------------
374
+
375
+ private say(event: DragDropAnnouncement) {
376
+ const announce = this.props.announce
377
+ this.announcement = announce ? announce(event) : defaultAnnouncement(event)
378
+ this.emit()
379
+ }
380
+
381
+ private labelOf(itemId: string) {
382
+ return this.items.get(itemId)?.label ?? itemId
383
+ }
384
+
385
+ private zoneLabel(zone: string) {
386
+ return this.zones.get(zone)?.label ?? zone
387
+ }
388
+
389
+ private placeAt(target: DragDropTarget) {
390
+ const others = this.drag?.order.get(target.zone)?.length ?? 0
391
+ return {zone: this.zoneLabel(target.zone), position: target.index + 1, count: others + 1}
392
+ }
393
+
394
+ private payload(itemId: string): DragDropItemData | null {
395
+ const item = this.items.get(itemId)
396
+ return item ? {id: itemId, zone: item.zone, data: item.data} : null
397
+ }
398
+
399
+ // -- measuring ------------------------------------------------------------
400
+
401
+ private captureOrder(exclude: string): Map<string, string[]> {
402
+ const byZone = new Map<string, string[]>()
403
+ for (const [id, item] of this.items) {
404
+ if (id === exclude || !item.el) continue
405
+ const list = byZone.get(item.zone)
406
+ if (list) list.push(id)
407
+ else byZone.set(item.zone, [id])
190
408
  }
191
- return null
192
- }, [])
409
+ for (const list of byZone.values()) {
410
+ list.sort((a, b) => domOrder(this.items.get(a)!.el!, this.items.get(b)!.el!))
411
+ }
412
+ return byZone
413
+ }
193
414
 
194
- // `target` is the resolved drop target (null cancels). The consumer's
195
- // onDrop runs here, in the event handler, never inside a state updater:
196
- // React may call an updater more than once and forbids updating another
197
- // component from it.
198
- const finish = useCallback(
199
- (target: DragDropTarget | null) => {
200
- const s = session.current
201
- if (!s) return
202
- if (s.holdTimer) clearTimeout(s.holdTimer)
203
- if (frame.current !== null) cancelAnimationFrame(frame.current)
204
- frame.current = null
205
- session.current = null
206
- try {
207
- if (s.source.hasPointerCapture(s.pointerId)) s.source.releasePointerCapture(s.pointerId)
208
- } catch {
209
- // already released
415
+ private isScroller(el: Element): boolean {
416
+ const known = this.scrollable.get(el)
417
+ if (known !== undefined) return known
418
+ const s = getComputedStyle(el)
419
+ const scrolls = (o: string) => o === 'auto' || o === 'scroll' || o === 'overlay'
420
+ const yes =
421
+ (scrolls(s.overflowY) && el.scrollHeight > el.clientHeight) ||
422
+ (scrolls(s.overflowX) && el.scrollWidth > el.clientWidth)
423
+ this.scrollable.set(el, yes)
424
+ return yes
425
+ }
426
+
427
+ /** Every item's rect, once, with the scrollers it moves with. Later reads
428
+ * shift the rect by how far those have scrolled since, so a placeholder
429
+ * inserted mid-drag never moves the geometry the index is read from. */
430
+ private captureGeometry() {
431
+ this.geometry = new Map()
432
+ this.baseline = new Map()
433
+ this.scrollable = new Map()
434
+ const root = document.scrollingElement
435
+ const chains = new Map<Element, Element[]>()
436
+ const chain = (el: Element | null): Element[] => {
437
+ if (!el || el === document.body || el === document.documentElement) return root ? [root] : []
438
+ const known = chains.get(el)
439
+ if (known) return known
440
+ const up = chain(el.parentElement)
441
+ const own = this.isScroller(el) ? [el, ...up] : up
442
+ chains.set(el, own)
443
+ return own
444
+ }
445
+ for (const [id, item] of this.items) {
446
+ if (!item.el) continue
447
+ const r = item.el.getBoundingClientRect()
448
+ const scrollers = chain(item.el.parentElement)
449
+ for (const s of scrollers) {
450
+ if (!this.baseline.has(s)) this.baseline.set(s, {left: s.scrollLeft, top: s.scrollTop})
210
451
  }
211
- if (!s.active) return
212
- const item = items.current.get(s.id)
213
- setDrag(null)
214
- if (target && item) {
215
- onDrop({item: {id: s.id, zone: item.zone, data: item.data as T}, target})
216
- say('drop', s.id, target.zone)
217
- } else {
218
- say('cancel', s.id)
452
+ this.geometry.set(id, {
453
+ rect: {left: r.left, top: r.top, right: r.right, bottom: r.bottom},
454
+ scrollers,
455
+ })
456
+ }
457
+ }
458
+
459
+ private rectOf(id: string): Rect | null {
460
+ const g = this.geometry.get(id)
461
+ if (!g) return null
462
+ let dx = 0
463
+ let dy = 0
464
+ for (const s of g.scrollers) {
465
+ const b = this.baseline.get(s)
466
+ if (!b) continue
467
+ dx += s.scrollLeft - b.left
468
+ dy += s.scrollTop - b.top
469
+ }
470
+ return {
471
+ left: g.rect.left - dx,
472
+ right: g.rect.right - dx,
473
+ top: g.rect.top - dy,
474
+ bottom: g.rect.bottom - dy,
475
+ }
476
+ }
477
+
478
+ /** Where the dragged item would land at (x, y): the innermost zone that
479
+ * accepts it, and the index from the pick-up geometry, with hysteresis
480
+ * against the current index. */
481
+ private locate(
482
+ itemId: string,
483
+ x: number,
484
+ y: number,
485
+ ): {target: DragDropTarget | null; refused: DragDropRefusal | null} {
486
+ const payload = this.payload(itemId)
487
+ const d = this.drag
488
+ if (!payload || !d) return {target: null, refused: null}
489
+ let refused: DragDropRefusal | null = null
490
+ const seen = new Set<string>()
491
+ const zoneByEl = new Map<Element, string>()
492
+ for (const [id, z] of this.zones) if (z.el) zoneByEl.set(z.el, id)
493
+ for (const el of document.elementsFromPoint(x, y)) {
494
+ const zoneId = zoneByEl.get(el)
495
+ if (zoneId === undefined || seen.has(zoneId)) continue
496
+ seen.add(zoneId)
497
+ const zone = this.zones.get(zoneId)!
498
+ const verdict = verdictOf(zone.accepts?.(payload))
499
+ if (!verdict.ok) {
500
+ if (verdict.reason !== undefined && !refused)
501
+ refused = {zone: zoneId, reason: verdict.reason}
502
+ continue
219
503
  }
220
- },
221
- [onDrop, say],
222
- )
504
+ const horizontal = zone.orientation === 'horizontal'
505
+ const pos = horizontal ? x : y
506
+ const prev = d.over && d.over.zone === zoneId ? d.over.index : null
507
+ const others = d.order.get(zoneId) ?? []
508
+ let index = 0
509
+ others.forEach((otherId, k) => {
510
+ const r = this.rectOf(otherId)
511
+ if (!r) return
512
+ const mid = horizontal ? (r.left + r.right) / 2 : (r.top + r.bottom) / 2
513
+ const bias = prev === null ? 0 : k < prev ? -HYSTERESIS_PX : HYSTERESIS_PX
514
+ if (pos > mid + bias) index += 1
515
+ })
516
+ return {target: {zone: zoneId, index}, refused: null}
517
+ }
518
+ return {target: null, refused}
519
+ }
223
520
 
224
- const activate = useCallback(
225
- (s: DragSession, x: number, y: number) => {
226
- s.active = true
227
- const item = items.current.get(s.id)
228
- setDrag({
229
- id: s.id,
230
- preview: item?.preview ?? null,
231
- width: s.width,
232
- height: s.height,
233
- x: x - s.offsetX,
234
- y: y - s.offsetY,
235
- over: locate(s.id, x, y),
521
+ /** Point the drag at a new target/refusal; tells listeners only on change. */
522
+ private retarget(target: DragDropTarget | null, refused: DragDropRefusal | null) {
523
+ const d = this.drag
524
+ if (!d) return
525
+ if (sameTarget(d.over, target) && sameRefusal(d.refused, refused)) return
526
+ const wasRefused = d.refused
527
+ this.drag = {...d, over: target, refused}
528
+ this.refusalText = refused?.reason ?? null
529
+ this.paintGhost()
530
+ this.emit()
531
+ const payload = this.payload(d.id)
532
+ if (payload) this.props.onDragOver?.({item: payload, target, refused, keyboard: d.keyboard})
533
+ if (refused && !sameRefusal(wasRefused, refused)) {
534
+ this.say({
535
+ kind: 'refuse',
536
+ item: this.labelOf(d.id),
537
+ zone: this.zoneLabel(refused.zone),
538
+ reason: refused.reason,
539
+ keyboard: d.keyboard,
236
540
  })
237
- say('pick', s.id)
238
- },
239
- [locate, say],
240
- )
541
+ }
542
+ }
241
543
 
242
- const begin = useCallback(
243
- (id: string, e: React.PointerEvent) => {
244
- if (session.current || e.button !== 0) return
245
- const source = e.currentTarget as HTMLElement
246
- const rect = source.getBoundingClientRect()
247
- const s: DragSession = {
248
- id,
249
- pointerId: e.pointerId,
250
- downAt: e.timeStamp,
251
- startX: e.clientX,
252
- startY: e.clientY,
253
- offsetX: e.clientX - rect.left,
254
- offsetY: e.clientY - rect.top,
255
- width: rect.width,
256
- height: rect.height,
257
- source,
258
- active: false,
259
- holdTimer: null,
260
- }
261
- session.current = s
262
- // Capture keeps the stream on the item if the pointer leaves the window;
263
- // the document listeners below carry the drag either way, so a browser
264
- // (or a synthetic event) without an active pointer is not an error.
265
- try {
266
- source.setPointerCapture(e.pointerId)
267
- } catch {
268
- // no active pointer to capture
269
- }
270
- if (e.pointerType === 'touch') {
271
- s.holdTimer = setTimeout(() => {
272
- s.holdTimer = null
273
- if (session.current === s && !s.active) activate(s, s.startX, s.startY)
274
- }, TOUCH_HOLD_MS)
544
+ // -- ghost ------------------------------------------------------------------
545
+
546
+ /** The ghost moves by its transform, written here, so a frame of pointer
547
+ * movement renders nothing. */
548
+ paintGhost() {
549
+ const el = this.ghostEl
550
+ const s = this.session
551
+ if (!el || !s) return
552
+ el.style.transform = `translate3d(${this.pointer.x - s.offsetX}px, ${this.pointer.y - s.offsetY}px, 0)`
553
+ }
554
+
555
+ // -- auto-scroll --------------------------------------------------------------
556
+
557
+ /** Scrolls the innermost scroller under the pointer whose edge band it is
558
+ * in and that can still move that way. True when something scrolled. */
559
+ private autoScroll(x: number, y: number): boolean {
560
+ const root = document.scrollingElement
561
+ const chain: Element[] = []
562
+ let el: Element | null = document.elementsFromPoint(x, y)[0] ?? null
563
+ while (el && el !== document.body && el !== document.documentElement) {
564
+ if (this.isScroller(el)) chain.push(el)
565
+ el = el.parentElement
566
+ }
567
+ if (root) chain.push(root)
568
+ const step = (dist: number) =>
569
+ dist < EDGE_PX ? Math.ceil(MAX_STEP_PX * (1 - Math.max(dist, 0) / EDGE_PX)) : 0
570
+ for (const s of chain) {
571
+ const r: Rect =
572
+ s === root
573
+ ? {left: 0, top: 0, right: window.innerWidth, bottom: window.innerHeight}
574
+ : s.getBoundingClientRect()
575
+ const up = step(y - r.top)
576
+ const down = step(r.bottom - y)
577
+ const left = step(x - r.left)
578
+ const right = step(r.right - x)
579
+ const dy = up ? -up : down
580
+ const dx = left ? -left : right
581
+ const canY =
582
+ dy < 0 ? s.scrollTop > 0 : dy > 0 ? s.scrollTop + s.clientHeight < s.scrollHeight : false
583
+ const canX =
584
+ dx < 0 ? s.scrollLeft > 0 : dx > 0 ? s.scrollLeft + s.clientWidth < s.scrollWidth : false
585
+ if (canY || canX) {
586
+ if (canY) s.scrollTop += dy
587
+ if (canX) s.scrollLeft += dx
588
+ return true
275
589
  }
276
- },
277
- [activate],
278
- )
590
+ }
591
+ return false
592
+ }
593
+
594
+ // -- pointer --------------------------------------------------------------
595
+
596
+ private scheduleFrame() {
597
+ if (this.frame !== null) return
598
+ this.frame = requestAnimationFrame(this.tick)
599
+ }
600
+
601
+ private tick = () => {
602
+ this.frame = null
603
+ const s = this.session
604
+ if (!s || !s.active) return
605
+ const {x, y} = this.pointer
606
+ this.paintGhost()
607
+ const scrolled = this.autoScroll(x, y)
608
+ const {target, refused} = this.locate(s.id, x, y)
609
+ this.retarget(target, refused)
610
+ if (scrolled) this.scheduleFrame()
611
+ }
612
+
613
+ private onScroll = () => {
614
+ if (this.session?.active) this.scheduleFrame()
615
+ }
616
+
617
+ private activate(s: DragSession, x: number, y: number) {
618
+ s.active = true
619
+ this.pointer = {x, y}
620
+ const item = this.items.get(s.id)
621
+ this.drag = {
622
+ id: s.id,
623
+ keyboard: false,
624
+ over: null,
625
+ refused: null,
626
+ order: this.captureOrder(s.id),
627
+ }
628
+ this.captureGeometry()
629
+ this.ghost = {preview: item?.preview ?? null, width: s.width, height: s.height}
630
+ this.refusalText = null
631
+ document.addEventListener('scroll', this.onScroll, {capture: true, passive: true})
632
+ this.emit()
633
+ this.say({kind: 'pick', item: this.labelOf(s.id), keyboard: false})
634
+ const {target, refused} = this.locate(s.id, x, y)
635
+ this.retarget(target, refused)
636
+ }
637
+
638
+ begin(id: string, e: React.PointerEvent) {
639
+ if (this.session || this.drag || e.button !== 0) return
640
+ if (this.items.get(id)?.disabled) return
641
+ const source = e.currentTarget as HTMLElement
642
+ const rect = source.getBoundingClientRect()
643
+ const s: DragSession = {
644
+ id,
645
+ pointerId: e.pointerId,
646
+ downAt: e.timeStamp,
647
+ startX: e.clientX,
648
+ startY: e.clientY,
649
+ offsetX: e.clientX - rect.left,
650
+ offsetY: e.clientY - rect.top,
651
+ width: rect.width,
652
+ height: rect.height,
653
+ source,
654
+ active: false,
655
+ holdTimer: null,
656
+ }
657
+ this.session = s
658
+ // Capture keeps the stream on the item if the pointer leaves the window;
659
+ // the document listeners carry the drag either way, so a browser (or a
660
+ // synthetic event) without an active pointer is not an error.
661
+ try {
662
+ source.setPointerCapture(e.pointerId)
663
+ } catch (err) {
664
+ if (!isPointerCaptureMiss(err)) throw err
665
+ }
666
+ if (e.pointerType === 'touch') {
667
+ s.holdTimer = setTimeout(() => {
668
+ s.holdTimer = null
669
+ if (this.session === s && !s.active) this.activate(s, s.startX, s.startY)
670
+ }, TOUCH_HOLD_MS)
671
+ }
672
+ }
673
+
674
+ /** Ends a pointer drag. `commit` resolves the target from the release
675
+ * point; otherwise the drag is cancelled. The consumer's onDrop runs here,
676
+ * in the event handler, never inside a state updater. */
677
+ private finishPointer(commit: {x: number; y: number} | null) {
678
+ const s = this.session
679
+ if (!s) return
680
+ if (s.holdTimer) clearTimeout(s.holdTimer)
681
+ if (this.frame !== null) cancelAnimationFrame(this.frame)
682
+ this.frame = null
683
+ this.session = null
684
+ try {
685
+ if (s.source.hasPointerCapture(s.pointerId)) s.source.releasePointerCapture(s.pointerId)
686
+ } catch (err) {
687
+ if (!isPointerCaptureMiss(err)) throw err
688
+ }
689
+ if (!s.active) return
690
+ document.removeEventListener('scroll', this.onScroll, {capture: true})
691
+ let target: DragDropTarget | null = null
692
+ if (commit) {
693
+ const found = this.locate(s.id, commit.x, commit.y)
694
+ this.retarget(found.target, found.refused)
695
+ target = this.drag?.over ?? null
696
+ }
697
+ const announcement = target ? this.placeAt(target) : null
698
+ const payload = this.payload(s.id)
699
+ this.drag = null
700
+ this.ghost = null
701
+ this.refusalText = null
702
+ this.emit()
703
+ if (target && payload && announcement) {
704
+ this.props.onDrop({item: payload, target})
705
+ this.say({kind: 'drop', item: this.labelOf(s.id), ...announcement, keyboard: false})
706
+ } else {
707
+ this.say({kind: 'cancel', item: this.labelOf(s.id), keyboard: false})
708
+ }
709
+ }
279
710
 
280
711
  // Move/up/cancel listen on the document: pointer capture keeps the events
281
712
  // flowing to the source element, but a single document listener survives
282
713
  // the item re-rendering under the pointer.
283
- useEffect(() => {
714
+ attach() {
284
715
  const onMove = (e: PointerEvent) => {
285
- const s = session.current
716
+ const s = this.session
286
717
  if (!s || e.pointerId !== s.pointerId) return
287
718
  if (!s.active) {
288
719
  const moved = Math.hypot(e.clientX - s.startX, e.clientY - s.startY)
@@ -296,42 +727,34 @@ function Root<T = unknown>({onDrop, announce, children}: DragDropRootProps<T>) {
296
727
  if (e.timeStamp - s.downAt >= TOUCH_HOLD_MS) {
297
728
  if (s.holdTimer) clearTimeout(s.holdTimer)
298
729
  s.holdTimer = null
299
- activate(s, s.startX, s.startY)
730
+ this.activate(s, s.startX, s.startY)
300
731
  } else {
301
732
  // Moved before the hold elapsed: a flick, not a drag.
302
- if (moved > MOVE_THRESHOLD_PX) finish(null)
733
+ if (moved > MOVE_THRESHOLD_PX) this.finishPointer(null)
303
734
  return
304
735
  }
305
736
  } else {
306
737
  if (moved < MOVE_THRESHOLD_PX) return
307
- activate(s, e.clientX, e.clientY)
738
+ this.activate(s, e.clientX, e.clientY)
308
739
  }
309
740
  }
310
- if (frame.current !== null) return
311
- frame.current = requestAnimationFrame(() => {
312
- frame.current = null
313
- const x = e.clientX
314
- const y = e.clientY
315
- setDrag((current) =>
316
- current
317
- ? {...current, x: x - s.offsetX, y: y - s.offsetY, over: locate(s.id, x, y)}
318
- : current,
319
- )
320
- })
741
+ this.pointer = {x: e.clientX, y: e.clientY}
742
+ this.scheduleFrame()
321
743
  }
322
744
  const onUp = (e: PointerEvent) => {
323
- const s = session.current
745
+ const s = this.session
324
746
  if (!s || e.pointerId !== s.pointerId) return
325
747
  // Resolve the target from the release point, not the last frame.
326
- finish(s.active ? locate(s.id, e.clientX, e.clientY) : null)
748
+ this.pointer = {x: e.clientX, y: e.clientY}
749
+ this.finishPointer(s.active ? {x: e.clientX, y: e.clientY} : null)
327
750
  }
328
751
  const onCancel = (e: PointerEvent) => {
329
- const s = session.current
752
+ const s = this.session
330
753
  if (!s || e.pointerId !== s.pointerId) return
331
- finish(null)
754
+ this.finishPointer(null)
332
755
  }
333
756
  const onKey = (e: KeyboardEvent) => {
334
- if (e.key === 'Escape' && session.current) finish(null)
757
+ if (e.key === 'Escape' && this.session) this.finishPointer(null)
335
758
  }
336
759
  document.addEventListener('pointermove', onMove)
337
760
  document.addEventListener('pointerup', onUp)
@@ -342,34 +765,340 @@ function Root<T = unknown>({onDrop, announce, children}: DragDropRootProps<T>) {
342
765
  document.removeEventListener('pointerup', onUp)
343
766
  document.removeEventListener('pointercancel', onCancel)
344
767
  document.removeEventListener('keydown', onKey)
768
+ document.removeEventListener('scroll', this.onScroll, {capture: true})
769
+ if (this.frame !== null) cancelAnimationFrame(this.frame)
770
+ }
771
+ }
772
+
773
+ // -- keyboard -------------------------------------------------------------
774
+
775
+ focusHandle(id: string) {
776
+ const zone = this.items.get(id)?.zone
777
+ if (zone !== undefined && this.stops.get(zone) !== id) {
778
+ this.stops.set(zone, id)
779
+ this.emit()
780
+ }
781
+ }
782
+
783
+ private pick(id: string) {
784
+ const item = this.items.get(id)
785
+ if (!item || item.disabled || !item.el) return
786
+ const order = this.captureOrder(id)
787
+ const index = (order.get(item.zone) ?? []).filter(
788
+ (other) => domOrder(this.items.get(other)!.el!, item.el!) < 0,
789
+ ).length
790
+ this.drag = {id, keyboard: true, over: {zone: item.zone, index}, refused: null, order}
791
+ this.emit()
792
+ const target = this.drag.over!
793
+ const payload = this.payload(id)
794
+ if (payload) this.props.onDragOver?.({item: payload, target, refused: null, keyboard: true})
795
+ this.say({kind: 'pick', item: item.label, ...this.placeAt(target), keyboard: true})
796
+ }
797
+
798
+ private moveWithin(delta: number) {
799
+ const d = this.drag
800
+ if (!d?.over) return
801
+ const others = d.order.get(d.over.zone)?.length ?? 0
802
+ const index = Math.min(Math.max(d.over.index + delta, 0), others)
803
+ const target = {zone: d.over.zone, index}
804
+ this.retarget(target, null)
805
+ this.say({kind: 'move', item: this.labelOf(d.id), ...this.placeAt(target), keyboard: true})
806
+ }
807
+
808
+ private moveAcross(delta: number) {
809
+ const d = this.drag
810
+ const payload = d && this.payload(d.id)
811
+ if (!d?.over || !payload) return
812
+ const zones = this.zoneSequence()
813
+ let refused: DragDropRefusal | null = null
814
+ for (let j = zones.indexOf(d.over.zone) + delta; j >= 0 && j < zones.length; j += delta) {
815
+ const zoneId = zones[j]
816
+ const verdict = verdictOf(this.zones.get(zoneId)?.accepts?.(payload))
817
+ if (!verdict.ok) {
818
+ if (verdict.reason !== undefined && !refused)
819
+ refused = {zone: zoneId, reason: verdict.reason}
820
+ continue
821
+ }
822
+ const others = d.order.get(zoneId)?.length ?? 0
823
+ const target = {zone: zoneId, index: Math.min(d.over.index, others)}
824
+ this.retarget(target, null)
825
+ this.say({kind: 'move', item: this.labelOf(d.id), ...this.placeAt(target), keyboard: true})
826
+ return
827
+ }
828
+ if (refused) {
829
+ // Nowhere to go that way; say why, and stay put.
830
+ this.drag = {...d, refused}
831
+ this.refusalText = refused.reason
832
+ this.emit()
833
+ this.say({
834
+ kind: 'refuse',
835
+ item: this.labelOf(d.id),
836
+ zone: this.zoneLabel(refused.zone),
837
+ reason: refused.reason,
838
+ keyboard: true,
839
+ })
840
+ } else {
841
+ this.say({kind: 'move', item: this.labelOf(d.id), ...this.placeAt(d.over), keyboard: true})
842
+ }
843
+ }
844
+
845
+ private endKeyboard(commit: boolean) {
846
+ const d = this.drag
847
+ if (!d?.keyboard) return
848
+ const target = commit ? d.over : null
849
+ const announcement = target ? this.placeAt(target) : null
850
+ const payload = this.payload(d.id)
851
+ this.drag = null
852
+ this.refusalText = null
853
+ this.focusRequest = d.id
854
+ if (target) this.stops.set(target.zone, d.id)
855
+ this.emit()
856
+ if (target && payload && announcement) {
857
+ this.props.onDrop({item: payload, target})
858
+ this.say({kind: 'drop', item: this.labelOf(d.id), ...announcement, keyboard: true})
859
+ } else {
860
+ this.say({kind: 'cancel', item: this.labelOf(d.id), keyboard: true})
861
+ }
862
+ }
863
+
864
+ /** Blur while picked (Tab away, a click elsewhere) cancels. */
865
+ blurHandle(id: string) {
866
+ if (this.drag?.keyboard && this.drag.id === id) this.endKeyboard(false)
867
+ }
868
+
869
+ private moveFocus(id: string, key: string): boolean {
870
+ const zone = this.items.get(id)?.zone
871
+ if (zone === undefined) return false
872
+ const inZone = this.handlesIn(zone)
873
+ const i = inZone.indexOf(id)
874
+ let next: string | undefined
875
+ if (key === 'ArrowUp') next = inZone[i - 1]
876
+ else if (key === 'ArrowDown') next = inZone[i + 1]
877
+ else if (key === 'Home') next = inZone[0]
878
+ else if (key === 'End') next = inZone[inZone.length - 1]
879
+ else if (key === 'ArrowLeft' || key === 'ArrowRight') {
880
+ const zones = this.zoneSequence().filter((z) => z === zone || this.handlesIn(z).length > 0)
881
+ const other = zones[zones.indexOf(zone) + (key === 'ArrowRight' ? 1 : -1)]
882
+ if (other !== undefined) {
883
+ const there = this.handlesIn(other)
884
+ next = there[Math.min(Math.max(i, 0), there.length - 1)]
885
+ }
886
+ } else return false
887
+ if (next) this.handles.get(next)?.focus()
888
+ return true
889
+ }
890
+
891
+ keyDown(id: string, e: KeyLike) {
892
+ const d = this.drag
893
+ if (d?.keyboard && d.id === id) {
894
+ switch (e.key) {
895
+ case 'ArrowUp':
896
+ this.moveWithin(-1)
897
+ break
898
+ case 'ArrowDown':
899
+ this.moveWithin(1)
900
+ break
901
+ case 'ArrowLeft':
902
+ this.moveAcross(-1)
903
+ break
904
+ case 'ArrowRight':
905
+ this.moveAcross(1)
906
+ break
907
+ case ' ':
908
+ case 'Enter':
909
+ this.endKeyboard(true)
910
+ break
911
+ case 'Escape':
912
+ this.endKeyboard(false)
913
+ break
914
+ case 'Tab':
915
+ this.endKeyboard(false)
916
+ return
917
+ default:
918
+ return
919
+ }
920
+ e.preventDefault()
921
+ e.stopPropagation()
922
+ return
923
+ }
924
+ if (d || this.session) return
925
+ if (e.key === ' ') {
926
+ e.preventDefault()
927
+ this.pick(id)
928
+ return
929
+ }
930
+ if (this.moveFocus(id, e.key)) e.preventDefault()
931
+ }
932
+ }
933
+
934
+ // ---------------------------------------------------------------------------
935
+ // Contexts and selectors
936
+ // ---------------------------------------------------------------------------
937
+
938
+ interface RootContextValue {
939
+ readonly engine: Engine
940
+ readonly instructionsId: string
941
+ }
942
+
943
+ const RootContext = createContext<RootContextValue | null>(null)
944
+
945
+ const useRoot = (part: string): RootContextValue => {
946
+ const ctx = useContext(RootContext)
947
+ if (!ctx) throw new Error(`DragDrop.${part} must be used within DragDrop.Root`)
948
+ return ctx
949
+ }
950
+
951
+ function useSelect<R>(engine: Engine | null | undefined, select: (e: Engine) => R, fallback: R): R {
952
+ const get = () => (engine ? select(engine) : fallback)
953
+ return useSyncExternalStore(engine ? engine.subscribe : noopSubscribe, get, get)
954
+ }
955
+ const noopSubscribe = () => () => {}
956
+
957
+ interface ZoneContextValue {
958
+ readonly id: string
959
+ readonly list: boolean
960
+ }
961
+ const ZoneContext = createContext<ZoneContextValue | null>(null)
962
+
963
+ interface ItemContextValue {
964
+ readonly id: string
965
+ readonly zone: string
966
+ readonly label: string
967
+ }
968
+ const ItemContext = createContext<ItemContextValue | null>(null)
969
+
970
+ /** True inside the ghost: its copy of the preview may hold Zones, Items and
971
+ * Handles, which must stay inert there — never registered, never focusable. */
972
+ const GhostContext = createContext(false)
973
+
974
+ // ---------------------------------------------------------------------------
975
+ // Root
976
+ // ---------------------------------------------------------------------------
977
+
978
+ export interface DragDropRootProps<T = unknown> {
979
+ /** Called when an item is released over a zone that accepts it (or dropped
980
+ * with Space/Enter). Reordering inside the item's own zone arrives here
981
+ * too, with the new index. */
982
+ onDrop: (event: DragDropEvent<T>) => void
983
+ /** Called whenever the target under the drag changes — a new zone or
984
+ * index, nothing, or a refusal. */
985
+ onDragOver?: (event: DragDropOverEvent<T>) => void
986
+ /** Supplies every live-region string (pick, move, drop, cancel, refuse),
987
+ * e.g. from the app's message catalog. Defaults to English. */
988
+ announce?: (event: DragDropAnnouncement) => string
989
+ /** What marks the insertion point in a zone while dragging; a Zone's own
990
+ * renderPlaceholder wins. Without one, only the zone lights up. */
991
+ renderPlaceholder?: DragDropRenderPlaceholder
992
+ /** Read by screen readers on a focused Handle. */
993
+ instructions?: string
994
+ children: ReactNode
995
+ }
996
+
997
+ const DEFAULT_INSTRUCTIONS = 'Press Space to pick up. Arrow keys move, Space drops, Escape cancels.'
998
+
999
+ function Root<T = unknown>({
1000
+ onDrop,
1001
+ onDragOver,
1002
+ announce,
1003
+ renderPlaceholder,
1004
+ instructions = DEFAULT_INSTRUCTIONS,
1005
+ children,
1006
+ }: DragDropRootProps<T>) {
1007
+ const [engine] = useState(() => new Engine())
1008
+ const instructionsId = useId()
1009
+
1010
+ useLayoutEffect(() => {
1011
+ engine.props = {
1012
+ onDrop: onDrop as LatestProps['onDrop'],
1013
+ onDragOver: onDragOver as LatestProps['onDragOver'],
1014
+ announce,
1015
+ renderPlaceholder,
345
1016
  }
346
- }, [activate, finish, locate])
1017
+ })
1018
+
1019
+ useEffect(() => engine.attach(), [engine])
1020
+
1021
+ const value = useMemo<RootContextValue>(
1022
+ () => ({engine, instructionsId}),
1023
+ [engine, instructionsId],
1024
+ )
1025
+
1026
+ return (
1027
+ <RootContext.Provider value={value}>
1028
+ {children}
1029
+ <Ghost engine={engine} />
1030
+ <LiveRegion engine={engine} />
1031
+ <Instructions engine={engine} id={instructionsId} text={instructions} />
1032
+ </RootContext.Provider>
1033
+ )
1034
+ }
347
1035
 
1036
+ function LiveRegion({engine}: {engine: Engine}) {
1037
+ const text = useSelect(engine, (e) => e.announcement, '')
1038
+ return (
1039
+ <html.div role="status" aria-live="polite" aria-atomic style={visuallyHidden.base}>
1040
+ {text}
1041
+ </html.div>
1042
+ )
1043
+ }
1044
+
1045
+ /** The handles' description; rendered only while a Handle exists, so a
1046
+ * pointer-only board carries no stray text. */
1047
+ function Instructions({engine, id, text}: {engine: Engine; id: string; text: string}) {
1048
+ const any = useSelect(engine, (e) => e.handles.size > 0, false)
1049
+ if (!any) return null
1050
+ return (
1051
+ <html.div id={id} style={visuallyHidden.base}>
1052
+ {text}
1053
+ </html.div>
1054
+ )
1055
+ }
1056
+
1057
+ function Ghost({engine}: {engine: Engine}) {
1058
+ const ghost = useSelect(engine, (e) => e.ghost, null)
1059
+ const refusal = useSelect(engine, (e) => (e.ghost ? e.refusalText : null), null)
1060
+ const ref = useRef<HTMLElement | null>(null)
348
1061
  // The ghost renders in the ThemeProvider mount: inline, a Dialog's
349
1062
  // transformed, overflow-clipped panel would clip and offset it.
350
1063
  const mount = usePortalMount()
351
- const ghost = drag && (
1064
+
1065
+ useLayoutEffect(() => {
1066
+ engine.ghostEl = ref.current
1067
+ engine.paintGhost()
1068
+ return () => {
1069
+ engine.ghostEl = null
1070
+ }
1071
+ }, [engine, ghost])
1072
+
1073
+ if (!ghost) return null
1074
+ const node = (
352
1075
  <html.div
1076
+ ref={ref as never}
353
1077
  aria-hidden
354
- style={[styles.ghost, styles.ghostAt(drag.x, drag.y, drag.width, drag.height)]}
1078
+ style={[styles.ghost, styles.ghostSize(ghost.width, ghost.height)]}
355
1079
  >
356
- {drag.preview}
1080
+ <html.div style={styles.ghostTilt}>
1081
+ <GhostContext.Provider value>{ghost.preview}</GhostContext.Provider>
1082
+ </html.div>
1083
+ {refusal !== null && <html.div style={styles.refusal}>{refusal}</html.div>}
357
1084
  </html.div>
358
1085
  )
1086
+ return mount ? createPortal(node, mount) : node
1087
+ }
359
1088
 
360
- const value = useMemo<RootContextValue>(
361
- () => ({registerZone, registerItem, begin, drag}),
362
- [registerZone, registerItem, begin, drag],
363
- )
364
-
1089
+ function Placeholder({engine, zone, index}: {engine: Engine; zone: string; index: number}) {
1090
+ const record = engine.zones.get(zone)
1091
+ const render = record?.renderPlaceholder ?? engine.props.renderPlaceholder
1092
+ const ref = useRef<HTMLElement | null>(null)
1093
+ // A keyboard move keeps the insertion point in view.
1094
+ useEffect(() => {
1095
+ if (engine.drag?.keyboard) ref.current?.scrollIntoView?.({block: 'nearest', inline: 'nearest'})
1096
+ }, [engine, zone, index])
1097
+ if (!render) return null
365
1098
  return (
366
- <RootContext.Provider value={value}>
367
- {children}
368
- {ghost && (mount ? createPortal(ghost, mount) : ghost)}
369
- <html.div role="status" aria-live="polite" aria-atomic style={visuallyHidden.base}>
370
- {announcement}
371
- </html.div>
372
- </RootContext.Provider>
1099
+ <html.div ref={ref as never} aria-hidden style={styles.placeholder}>
1100
+ {render({zone, index, label: record?.label ?? zone})}
1101
+ </html.div>
373
1102
  )
374
1103
  }
375
1104
 
@@ -379,47 +1108,113 @@ function Root<T = unknown>({onDrop, announce, children}: DragDropRootProps<T>) {
379
1108
 
380
1109
  export interface DragDropZoneProps<T = unknown> {
381
1110
  id: string
382
- /** Read to screen readers when an item is dropped here. */
1111
+ /** Read to screen readers when an item moves or drops here; the list's
1112
+ * accessible name with `list`. */
383
1113
  label: string
384
1114
  /** Which axis items flow along; decides how the insertion index is read
385
1115
  * from the pointer. */
386
1116
  orientation?: 'horizontal' | 'vertical'
387
- /** Refuse an item; the zone then never lights up for it. */
388
- accepts?: (item: DragDropItemData<T>) => boolean
1117
+ /** Take the item (`true`), let it pass to an outer zone (`false`), or
1118
+ * refuse it with a reason that is shown and announced
1119
+ * (`{ok: false, reason}`). Omitted: every item is taken. */
1120
+ accepts?: (item: DragDropItemData<T>) => DragDropVerdict
1121
+ /** Keyboard order across zones (Left/Right); zones without one follow, in
1122
+ * DOM order. */
1123
+ order?: number
1124
+ /** Marks the insertion point in this zone; overrides the Root's. */
1125
+ renderPlaceholder?: DragDropRenderPlaceholder
1126
+ /** Render the zone as a labelled list (`role="list"`) laid out along
1127
+ * `orientation`, its Items as list items. Put the Items directly in it. */
1128
+ list?: boolean
1129
+ /** Gap between the Items of a `list` zone. */
1130
+ gap?: SpacingToken
1131
+ /** Stretch to fill the parent (a Timeline bar, a card) instead of sizing
1132
+ * to the content. */
1133
+ fill?: boolean
389
1134
  children: ReactNode
390
1135
  }
391
1136
 
1137
+ const gapMap = {
1138
+ xs: styles.gapXs,
1139
+ sm: styles.gapSm,
1140
+ ms: styles.gapMs,
1141
+ md: styles.gapMd,
1142
+ lg: styles.gapLg,
1143
+ xl: styles.gapXl,
1144
+ xxl: styles.gapXxl,
1145
+ xxxl: styles.gapXxxl,
1146
+ } as const satisfies Record<SpacingToken, unknown>
1147
+
392
1148
  function Zone<T = unknown>({
393
1149
  id,
394
1150
  label,
395
1151
  orientation = 'horizontal',
396
1152
  accepts,
1153
+ order,
1154
+ renderPlaceholder,
1155
+ list = false,
1156
+ gap = 'sm',
1157
+ fill = false,
397
1158
  children,
398
1159
  }: DragDropZoneProps<T>) {
399
- const {registerZone, drag} = useRoot('Zone')
1160
+ const {engine} = useRoot('Zone')
1161
+ const inGhost = useContext(GhostContext)
400
1162
  const ref = useRef<HTMLDivElement>(null)
1163
+ const [record] = useState<ZoneRecord>(() => ({el: null, orientation, label}))
401
1164
 
1165
+ // The record is read at drag time, so the latest props land on it after
1166
+ // every render without re-registering.
402
1167
  useEffect(() => {
403
- const el = ref.current
404
- if (!el || isNative) return
405
- return registerZone(id, {
406
- el,
407
- orientation,
408
- label,
409
- accepts: accepts as ZoneRecord['accepts'],
410
- })
411
- }, [id, label, orientation, accepts, registerZone])
1168
+ record.orientation = orientation
1169
+ record.label = label
1170
+ record.accepts = accepts as ZoneRecord['accepts']
1171
+ record.order = order
1172
+ record.renderPlaceholder = renderPlaceholder
1173
+ })
1174
+
1175
+ useEffect(() => {
1176
+ if (isNative || inGhost) return
1177
+ record.el = ref.current
1178
+ return engine.registerZone(id, record)
1179
+ }, [engine, id, record, inGhost])
412
1180
 
413
- const over = drag?.over?.zone === id
414
- const dragging = drag !== null
1181
+ const dragging = useSelect(engine, (e) => e.drag !== null, false)
1182
+ const over = useSelect(engine, (e) => e.drag?.over?.zone === id, false)
1183
+ const refused = useSelect(engine, (e) => e.drag?.refused?.zone === id, false)
1184
+ const placeholderAtEnd = useSelect(
1185
+ engine,
1186
+ (e) => {
1187
+ const d = e.drag
1188
+ if (!d?.over || d.over.zone !== id) return -1
1189
+ const n = d.order.get(id)?.length ?? 0
1190
+ return d.over.index >= n ? d.over.index : -1
1191
+ },
1192
+ -1,
1193
+ )
1194
+ const zoneContext = useMemo(() => ({id, list}), [id, list])
415
1195
 
416
1196
  return (
417
- <html.div
418
- ref={ref}
419
- style={[styles.zone, dragging && styles.zoneReady, over && styles.zoneOver]}
420
- >
421
- {children}
422
- </html.div>
1197
+ <ZoneContext.Provider value={zoneContext}>
1198
+ <html.div
1199
+ ref={ref}
1200
+ role={list ? 'list' : undefined}
1201
+ aria-label={list ? label : undefined}
1202
+ style={[
1203
+ styles.zone,
1204
+ list && (orientation === 'vertical' ? styles.listVertical : styles.listHorizontal),
1205
+ list && gapMap[gap],
1206
+ fill && styles.fill,
1207
+ dragging && styles.zoneReady,
1208
+ over && styles.zoneOver,
1209
+ refused && styles.zoneRefused,
1210
+ ]}
1211
+ >
1212
+ {children}
1213
+ {placeholderAtEnd >= 0 && (
1214
+ <Placeholder engine={engine} zone={id} index={placeholderAtEnd} />
1215
+ )}
1216
+ </html.div>
1217
+ </ZoneContext.Provider>
423
1218
  )
424
1219
  }
425
1220
 
@@ -431,7 +1226,8 @@ export interface DragDropItemProps<T = unknown> {
431
1226
  id: string
432
1227
  /** The zone this item currently lives in. */
433
1228
  zone: string
434
- /** Read to screen readers when picked up or dropped. */
1229
+ /** Read to screen readers when picked up or dropped; a Handle's default
1230
+ * accessible name. */
435
1231
  label: string
436
1232
  /** Carried to onDrop untouched. */
437
1233
  data?: T
@@ -450,14 +1246,34 @@ function Item<T = unknown>({
450
1246
  disabled = false,
451
1247
  children,
452
1248
  }: DragDropItemProps<T>) {
453
- const {registerItem, begin, drag} = useRoot('Item')
1249
+ const {engine} = useRoot('Item')
1250
+ const zoneCtx = useContext(ZoneContext)
1251
+ const inGhost = useContext(GhostContext)
454
1252
  const ref = useRef<HTMLDivElement>(null)
1253
+ const [record] = useState<ItemRecord>(() => ({
1254
+ el: null,
1255
+ zone,
1256
+ label,
1257
+ data,
1258
+ preview: null,
1259
+ disabled,
1260
+ }))
455
1261
 
1262
+ // Registration depends on the id only; what changes from render to render
1263
+ // (data, the preview, the children) is copied onto the record instead.
456
1264
  useEffect(() => {
457
- const el = ref.current
458
- if (!el || isNative) return
459
- return registerItem(id, {el, zone, data, label, preview: preview ?? children})
460
- }, [id, zone, data, label, preview, children, registerItem])
1265
+ record.zone = zone
1266
+ record.label = label
1267
+ record.data = data
1268
+ record.preview = preview ?? children
1269
+ record.disabled = disabled
1270
+ })
1271
+
1272
+ useEffect(() => {
1273
+ if (isNative || inGhost) return
1274
+ record.el = ref.current
1275
+ return engine.registerItem(id, record)
1276
+ }, [engine, id, record, inGhost])
461
1277
 
462
1278
  // WebKit only honours the prefixed user-select, and a touch held on
463
1279
  // selectable text becomes iOS's selection gesture (loupe and handles)
@@ -476,19 +1292,98 @@ function Item<T = unknown>({
476
1292
  const onPointerDown = useCallback(
477
1293
  (e: React.PointerEvent) => {
478
1294
  if (disabled) return
479
- begin(id, e)
1295
+ engine.begin(id, e)
480
1296
  },
481
- [begin, disabled, id],
1297
+ [engine, disabled, id],
482
1298
  )
483
1299
 
1300
+ const lifted = useSelect(engine, (e) => e.drag?.id === id, false)
1301
+ const placeholderBefore = useSelect(
1302
+ engine,
1303
+ (e) => {
1304
+ const d = e.drag
1305
+ if (!d?.over || d.over.zone !== zone) return -1
1306
+ return d.order.get(zone)?.[d.over.index] === id ? d.over.index : -1
1307
+ },
1308
+ -1,
1309
+ )
1310
+ const itemContext = useMemo(() => ({id, zone, label}), [id, zone, label])
1311
+
484
1312
  if (isNative) return <>{children}</>
485
1313
 
486
- const lifted = drag?.id === id
1314
+ return (
1315
+ <ItemContext.Provider value={itemContext}>
1316
+ {placeholderBefore >= 0 && (
1317
+ <Placeholder engine={engine} zone={zone} index={placeholderBefore} />
1318
+ )}
1319
+ <html.div
1320
+ ref={ref}
1321
+ role={zoneCtx?.list && zoneCtx.id === zone ? 'listitem' : undefined}
1322
+ onPointerDown={onPointerDown}
1323
+ style={[styles.item, disabled && styles.itemDisabled, lifted && styles.itemLifted]}
1324
+ >
1325
+ {children}
1326
+ </html.div>
1327
+ </ItemContext.Provider>
1328
+ )
1329
+ }
1330
+
1331
+ // ---------------------------------------------------------------------------
1332
+ // Handle
1333
+ // ---------------------------------------------------------------------------
1334
+
1335
+ export interface DragDropHandleProps {
1336
+ /** Defaults to the Item's label. */
1337
+ 'aria-label'?: string
1338
+ /** What the handle shows: a grip, or the item's whole face. Keep other
1339
+ * controls out of it — a handle is one widget. */
1340
+ children: ReactNode
1341
+ }
1342
+
1343
+ /**
1344
+ * The keyboard path into an Item: one focusable widget (`role="button"`) per
1345
+ * item, a roving tab stop per zone. Space picks the item up, the arrow keys
1346
+ * move it, Space or Enter drops it, Escape cancels.
1347
+ */
1348
+ function Handle({'aria-label': ariaLabel, children}: DragDropHandleProps) {
1349
+ const {engine, instructionsId} = useRoot('Handle')
1350
+ const item = useContext(ItemContext)
1351
+ const inGhost = useContext(GhostContext)
1352
+ if (!item && !inGhost) throw new Error('DragDrop.Handle must be used within DragDrop.Item')
1353
+ const ref = useRef<HTMLDivElement>(null)
1354
+ const id = item?.id ?? ''
1355
+ const zone = item?.zone ?? ''
1356
+ const label = item?.label ?? ''
1357
+
1358
+ useEffect(() => {
1359
+ const el = ref.current
1360
+ if (!el || isNative || inGhost) return
1361
+ return engine.registerHandle(id, el)
1362
+ }, [engine, id, zone, inGhost])
1363
+
1364
+ const tabStop = useSelect(engine, (e) => e.tabStop(zone) === id, false)
1365
+ const picked = useSelect(engine, (e) => e.drag?.keyboard === true && e.drag.id === id, false)
1366
+ const wantsFocus = useSelect(engine, (e) => e.focusRequest === id, false)
1367
+
1368
+ useEffect(() => {
1369
+ if (!wantsFocus) return
1370
+ engine.focusRequest = null
1371
+ ref.current?.focus()
1372
+ }, [engine, wantsFocus])
1373
+
1374
+ if (isNative || inGhost) return <>{children}</>
1375
+
487
1376
  return (
488
1377
  <html.div
489
1378
  ref={ref}
490
- onPointerDown={onPointerDown}
491
- style={[styles.item, disabled && styles.itemDisabled, lifted && styles.itemLifted]}
1379
+ role="button"
1380
+ tabIndex={tabStop || picked ? 0 : -1}
1381
+ aria-label={ariaLabel ?? label}
1382
+ aria-describedby={instructionsId}
1383
+ onKeyDown={(e: unknown) => engine.keyDown(id, e as KeyLike)}
1384
+ onFocus={() => engine.focusHandle(id)}
1385
+ onBlur={() => engine.blurHandle(id)}
1386
+ style={[styles.handle, picked && styles.handlePicked]}
492
1387
  >
493
1388
  {children}
494
1389
  </html.div>
@@ -499,9 +1394,15 @@ function Item<T = unknown>({
499
1394
  export function useDragDrop(): {
500
1395
  readonly dragging: string | null
501
1396
  readonly over: DragDropTarget | null
1397
+ readonly refused: DragDropRefusal | null
1398
+ readonly keyboard: boolean
502
1399
  } {
503
- const ctx = useContext(RootContext)
504
- return {dragging: ctx?.drag?.id ?? null, over: ctx?.drag?.over ?? null}
1400
+ const engine = useContext(RootContext)?.engine
1401
+ const dragging = useSelect(engine, (e) => e.drag?.id ?? null, null)
1402
+ const over = useSelect(engine, (e) => e.drag?.over ?? null, null)
1403
+ const refused = useSelect(engine, (e) => e.drag?.refused ?? null, null)
1404
+ const keyboard = useSelect(engine, (e) => e.drag?.keyboard ?? false, false)
1405
+ return {dragging, over, refused, keyboard}
505
1406
  }
506
1407
 
507
- export const DragDrop = {Root, Zone, Item}
1408
+ export const DragDrop = {Root, Zone, Item, Handle}