@lovett/ui 0.0.11 → 0.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.
Files changed (142) hide show
  1. package/dist/chunk-RBYWGBQ2.js +2752 -0
  2. package/dist/chunk-RBYWGBQ2.js.map +1 -0
  3. package/dist/index.d.ts +5574 -57
  4. package/dist/index.js +21650 -11206
  5. package/dist/index.js.map +1 -1
  6. package/dist/rich-composer-impl-5NO443A6.js +1859 -0
  7. package/dist/rich-composer-impl-5NO443A6.js.map +1 -0
  8. package/dist/styles.css +1570 -0
  9. package/dist/tokens.css +112 -0
  10. package/package.json +8 -1
  11. package/src/__tests__/avatar.test.tsx +272 -0
  12. package/src/__tests__/bar-chart.test.tsx +809 -0
  13. package/src/__tests__/board.test.tsx +420 -0
  14. package/src/__tests__/chart-math.test.ts +922 -0
  15. package/src/__tests__/chart-series.test.ts +339 -0
  16. package/src/__tests__/code-block.test.tsx +134 -0
  17. package/src/__tests__/display-popover.test.tsx +195 -0
  18. package/src/__tests__/display-store.test.tsx +307 -0
  19. package/src/__tests__/donut-chart.test.tsx +397 -0
  20. package/src/__tests__/dropdown-menu.test.tsx +156 -2
  21. package/src/__tests__/filter-menu.test.tsx +175 -0
  22. package/src/__tests__/gauge-ring.test.tsx +233 -0
  23. package/src/__tests__/line-chart.test.tsx +612 -0
  24. package/src/__tests__/ranked-bars.test.tsx +343 -0
  25. package/src/__tests__/remark-underline.test.ts +194 -0
  26. package/src/__tests__/sortable.test.tsx +394 -0
  27. package/src/__tests__/sparkline.test.tsx +368 -0
  28. package/src/__tests__/stat-layer.test.tsx +271 -0
  29. package/src/__tests__/stat-strip.test.tsx +175 -0
  30. package/src/__tests__/status.test.tsx +293 -0
  31. package/src/__tests__/tabs.test.tsx +303 -0
  32. package/src/__tests__/token-shape.test.ts +132 -2
  33. package/src/avatar.tsx +352 -0
  34. package/src/bar-chart.tsx +1214 -0
  35. package/src/board.tsx +658 -0
  36. package/src/chart-frame.tsx +960 -0
  37. package/src/chart-legend.tsx +304 -0
  38. package/src/chart-tooltip.tsx +267 -0
  39. package/src/code-block.tsx +62 -8
  40. package/src/delta-chip.tsx +263 -0
  41. package/src/detail/__tests__/activity-pane.test.tsx +369 -0
  42. package/src/detail/__tests__/detail-chrome.test.tsx +134 -0
  43. package/src/detail/__tests__/detail-surface.test.tsx +529 -0
  44. package/src/detail/__tests__/field-row.test.tsx +357 -0
  45. package/src/detail/activity-pane.tsx +611 -0
  46. package/src/detail/calendar.tsx +355 -0
  47. package/src/detail/detail-divider.tsx +261 -0
  48. package/src/detail/detail-header.tsx +287 -0
  49. package/src/detail/detail-menu.tsx +254 -0
  50. package/src/detail/detail-surface.tsx +1110 -0
  51. package/src/detail/field-list.tsx +196 -0
  52. package/src/detail/field-row.tsx +1131 -0
  53. package/src/detail/index.ts +58 -0
  54. package/src/detail/segmented-choice.tsx +94 -0
  55. package/src/detail/types.ts +129 -0
  56. package/src/display-popover.tsx +487 -0
  57. package/src/display-store.tsx +301 -0
  58. package/src/donut-chart.tsx +988 -0
  59. package/src/dropdown-menu.tsx +290 -19
  60. package/src/filter-core/EXPORTS.md +85 -0
  61. package/src/filter-core/__tests__/columns.test.ts +159 -0
  62. package/src/filter-core/__tests__/faceting.test.ts +193 -0
  63. package/src/filter-core/__tests__/filter-fns.test.ts +519 -0
  64. package/src/filter-core/__tests__/operators.test.ts +235 -0
  65. package/src/filter-core/__tests__/state.test.ts +268 -0
  66. package/src/filter-core/__tests__/url.test.ts +350 -0
  67. package/src/filter-core/columns.ts +134 -0
  68. package/src/filter-core/date-utils.ts +38 -0
  69. package/src/filter-core/examples/task-filter-columns.ts +121 -0
  70. package/src/filter-core/faceting.ts +120 -0
  71. package/src/filter-core/filter-fns.ts +335 -0
  72. package/src/filter-core/index.ts +105 -0
  73. package/src/filter-core/operators.ts +433 -0
  74. package/src/filter-core/state.ts +280 -0
  75. package/src/filter-core/types.ts +247 -0
  76. package/src/filter-core/url.ts +261 -0
  77. package/src/filter-dropdown.tsx +12 -0
  78. package/src/filter-menu.tsx +649 -0
  79. package/src/floating-drawer.tsx +19 -1
  80. package/src/gauge-ring.tsx +435 -0
  81. package/src/hue.ts +52 -0
  82. package/src/index.ts +303 -0
  83. package/src/kbd.tsx +27 -4
  84. package/src/lib/chart.ts +866 -0
  85. package/src/lib/focus.ts +43 -1
  86. package/src/lib/layer-stack.ts +30 -3
  87. package/src/lib/remark-underline.ts +443 -0
  88. package/src/lib/series.ts +169 -0
  89. package/src/line-chart.tsx +1176 -0
  90. package/src/markdown.tsx +26 -7
  91. package/src/modal.tsx +42 -18
  92. package/src/progress-ledger.tsx +304 -0
  93. package/src/ranked-bars.tsx +386 -0
  94. package/src/segmented-pill.tsx +32 -9
  95. package/src/sortable.tsx +520 -1
  96. package/src/sparkline.tsx +416 -0
  97. package/src/stat-card.tsx +376 -0
  98. package/src/stat-strip.tsx +327 -0
  99. package/src/status.tsx +215 -0
  100. package/src/styles.css +1570 -0
  101. package/src/tabs.tsx +206 -25
  102. package/src/task-card.tsx +610 -0
  103. package/src/thread/__tests__/comment-body-hostile.test.tsx +331 -0
  104. package/src/thread/__tests__/comment-tree.test.ts +151 -0
  105. package/src/thread/__tests__/emoji.test.ts +187 -0
  106. package/src/thread/__tests__/fixtures/thread-fixture.ts +252 -0
  107. package/src/thread/__tests__/link-preview-source.test.ts +120 -0
  108. package/src/thread/__tests__/link-preview.test.tsx +600 -0
  109. package/src/thread/__tests__/markdown-format.test.ts +82 -0
  110. package/src/thread/__tests__/markdown-spec.test.ts +469 -0
  111. package/src/thread/__tests__/relative-time.test.ts +71 -0
  112. package/src/thread/__tests__/rich-composer.test.tsx +799 -0
  113. package/src/thread/__tests__/scroll-caret.test.ts +58 -0
  114. package/src/thread/__tests__/suggestion-list.test.tsx +648 -0
  115. package/src/thread/__tests__/thread-scroll-ownership.test.tsx +88 -0
  116. package/src/thread/__tests__/thread.test.tsx +742 -0
  117. package/src/thread/__tests__/use-attachments.test.tsx +679 -0
  118. package/src/thread/actions.tsx +196 -0
  119. package/src/thread/attachments.tsx +1071 -0
  120. package/src/thread/comment-body.tsx +148 -0
  121. package/src/thread/comment-tree.ts +182 -0
  122. package/src/thread/comment.tsx +967 -0
  123. package/src/thread/composer-footer.tsx +125 -0
  124. package/src/thread/composer.tsx +319 -0
  125. package/src/thread/emoji.ts +283 -0
  126. package/src/thread/index.ts +153 -0
  127. package/src/thread/link-preview.tsx +341 -0
  128. package/src/thread/markdown-format.ts +155 -0
  129. package/src/thread/markdown-spec.ts +754 -0
  130. package/src/thread/rail.tsx +372 -0
  131. package/src/thread/reactions.tsx +415 -0
  132. package/src/thread/relative-time.tsx +94 -0
  133. package/src/thread/rich-composer-impl.tsx +1601 -0
  134. package/src/thread/rich-composer.tsx +195 -0
  135. package/src/thread/scroll-caret.ts +37 -0
  136. package/src/thread/suggestion-list.tsx +182 -0
  137. package/src/thread/thread.tsx +718 -0
  138. package/src/thread/types.ts +232 -0
  139. package/src/thread/use-attachments.ts +598 -0
  140. package/src/thread/use-now.ts +73 -0
  141. package/src/thread/use-thread.ts +316 -0
  142. package/src/tokens.css +112 -0
package/src/sortable.tsx CHANGED
@@ -9,15 +9,24 @@
9
9
  * - <DragHandle> is a Lucide-grip handle pre-wired to the listeners
10
10
  */
11
11
 
12
+ import { useEffect, useRef, useState } from 'react'
12
13
  import type { CSSProperties, HTMLAttributes, ReactNode } from 'react'
13
14
  import {
14
15
  DndContext,
16
+ DragOverlay,
17
+ MeasuringStrategy,
15
18
  closestCenter,
19
+ closestCorners,
20
+ pointerWithin,
21
+ useDroppable,
16
22
  KeyboardSensor,
17
23
  PointerSensor,
18
24
  useSensor,
19
25
  useSensors,
26
+ type CollisionDetection,
20
27
  type DragEndEvent,
28
+ type DragOverEvent,
29
+ type DragStartEvent,
21
30
  } from '@dnd-kit/core'
22
31
  import {
23
32
  arrayMove,
@@ -86,13 +95,51 @@ export function SortableItem({
86
95
  }: {
87
96
  id: string
88
97
  children: (props: {
98
+ /** attributes + listeners merged — the single-node case, where the whole
99
+ * node IS the handle (a `<DragHandle>`, a settings row's grip). */
89
100
  dragHandleProps: HTMLAttributes<HTMLElement>
101
+ /**
102
+ * The ACTIVATORS — `onPointerDown` from the PointerSensor, `onKeyDown` from
103
+ * the KeyboardSensor. Whatever element receives these is what starts a drag.
104
+ *
105
+ * Split from `dragAttributes` so a card can be a drop target and a
106
+ * transform target (`setNodeRef` + `style`) WITHOUT being a drag handle.
107
+ * Spread these onto the card root and a pointer-down anywhere on it starts
108
+ * a drag, which is the bug this split exists to prevent: the card's own
109
+ * buttons, links and text selection all stop working as themselves.
110
+ */
111
+ dragListeners: HTMLAttributes<HTMLElement>
112
+ /**
113
+ * `role="button"`, `tabIndex`, `aria-roledescription`, `aria-describedby`.
114
+ *
115
+ * These belong on the SAME element as `dragListeners` — they describe the
116
+ * handle to assistive tech, and dnd-kit's instruction text is reached
117
+ * through the `aria-describedby` here. Putting them on a card that holds
118
+ * its own buttons makes it an interactive element wrapping interactive
119
+ * elements, and `role="button"`'s presentational children erase the
120
+ * buttons inside it.
121
+ */
122
+ dragAttributes: HTMLAttributes<HTMLElement>
123
+ /** The draggable NODE — measured, transformed, and used as the drop rect. */
90
124
  setNodeRef: (node: HTMLElement | null) => void
125
+ /**
126
+ * The HANDLE node. dnd-kit restores focus here when a keyboard drag ends;
127
+ * without it focus lands on the card and the next arrow key does nothing.
128
+ */
129
+ setActivatorNodeRef: (node: HTMLElement | null) => void
91
130
  style: CSSProperties
92
131
  isDragging: boolean
93
132
  }) => ReactNode
94
133
  }) {
95
- const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
134
+ const {
135
+ attributes,
136
+ listeners,
137
+ setNodeRef,
138
+ setActivatorNodeRef,
139
+ transform,
140
+ transition,
141
+ isDragging,
142
+ } = useSortable({
96
143
  id,
97
144
  })
98
145
 
@@ -108,7 +155,14 @@ export function SortableItem({
108
155
  <>
109
156
  {children({
110
157
  dragHandleProps: { ...attributes, ...listeners } as HTMLAttributes<HTMLElement>,
158
+ dragListeners: { ...listeners } as HTMLAttributes<HTMLElement>,
159
+ // Attributes ONLY. This was `{ ...attributes, ...listeners }` — byte
160
+ // identical to `dragHandleProps` — so the "split" was not a split, and
161
+ // every consumer that took `dragAttributes` for the aria wiring got the
162
+ // pointer activators with it.
163
+ dragAttributes: { ...attributes } as HTMLAttributes<HTMLElement>,
111
164
  setNodeRef,
165
+ setActivatorNodeRef,
112
166
  style,
113
167
  isDragging,
114
168
  })}
@@ -135,3 +189,468 @@ export function DragHandle({
135
189
  </button>
136
190
  )
137
191
  }
192
+
193
+ /* ============================================================================
194
+ * Multi-container drag — ADR-146 slice A.
195
+ *
196
+ * <SortableList> above is single-container: one list, reorder within it. A
197
+ * kanban needs a card to LEAVE its column, which dnd-kit models as a droppable
198
+ * per container plus a DragOverlay that follows the pointer while the source
199
+ * item stays in place.
200
+ *
201
+ * Kept from the single-container wrapper on purpose:
202
+ * - the KeyboardSensor, so a card can cross columns without a pointer
203
+ * (interface-standards §6 — non-negotiable, not polish)
204
+ * - the 4px pointer activation constraint, so a click on a card still reads
205
+ * as a click and not a 1px drag
206
+ *
207
+ * Added:
208
+ * - pointer-first collision (`pointerFirstCollision` below), which resolves
209
+ * the drop target from the CURSOR and falls back to `closestCorners` when
210
+ * there is no cursor. That fallback is not a nicety: it is the keyboard
211
+ * drag's only path.
212
+ * - `MeasuringStrategy.Always`, because a container's height changes as
213
+ * cards enter and leave it and a cached rect drops the item in the wrong
214
+ * lane.
215
+ * - screen-reader announcements naming the destination container, not its id.
216
+ * ========================================================================== */
217
+
218
+ /**
219
+ * Pointer first, `closestCorners` second — dnd-kit's documented shape for a
220
+ * multi-container board (the pointer, then a rect-based fallback; which rect
221
+ * strategy is the choice made at the bottom of this note), and the answer to
222
+ * two defects rather than one.
223
+ *
224
+ * The history is a ladder, and each rung is still true:
225
+ *
226
+ * `closestCenter` measured the dragged card's CENTRE against each droppable
227
+ * and mis-resolved whenever the card was taller than the row it was over,
228
+ * which is the normal case on a board with variable-height cards.
229
+ *
230
+ * `closestCorners` measured the card's four CORNERS instead. Better, and
231
+ * still wrong in the same family: it measures the CARD. A tall card's
232
+ * corners stay nearer its source lane until most of its body has crossed the
233
+ * boundary, so the user has to drag the whole card into the destination
234
+ * rather than point at it. Reported as "I have to literally drag the full
235
+ * entire card over the lane so it catches it".
236
+ *
237
+ * `pointerWithin` measures the POINTER, so a card is caught by the lane the
238
+ * cursor is over regardless of how tall the card is. Card height stops being
239
+ * an input at all, which is why this does not reintroduce the defect
240
+ * `closestCorners` was adopted to fix — it retires the whole measurement.
241
+ *
242
+ * `pointerWithin` returns EVERY droppable containing the pointer, ordered by
243
+ * the pointer's mean distance to that rect's four corners — so the tightest
244
+ * box around the cursor wins. A pointer inside a card yields [card, column]
245
+ * and `over` resolves to the card, which is what gives a cross-column drop its
246
+ * insertion INDEX. A pointer in the column's empty space below the last card
247
+ * yields just the column, and the item appends. Both are wanted.
248
+ *
249
+ * It returns nothing at all when `pointerCoordinates` is null, and dnd-kit
250
+ * derives those from the activator event's `clientX`/`clientY`. A KeyboardEvent
251
+ * has neither, so EVERY keyboard drag takes the fallback — a pointer-only
252
+ * strategy would leave `over` permanently null and break cross-column keyboard
253
+ * movement silently, gates all green. The fallback is therefore `closestCorners`
254
+ * and not `rectIntersection`: keyboard drags keep the exact resolution they
255
+ * have been tested against, and the pointer-outside-every-droppable case (the
256
+ * board's margins and gutters) keeps resolving to the nearest lane rather than
257
+ * to nothing. `rectIntersection` would answer that case by area of overlap —
258
+ * "most of the card", the defect above, back in the gutters.
259
+ */
260
+ export const pointerFirstCollision: CollisionDetection = (args) => {
261
+ const byPointer = pointerWithin(args)
262
+ return byPointer.length > 0 ? byPointer : closestCorners(args)
263
+ }
264
+
265
+ /** containerId -> the ids it holds, in order. */
266
+ export type SortableContainers = Readonly<Record<string, readonly string[]>>
267
+
268
+ export interface MultiSortableMove {
269
+ itemId: string
270
+ fromContainerId: string
271
+ toContainerId: string
272
+ /** Destination index within `toContainerId`, after the move. */
273
+ toIndex: number
274
+ }
275
+
276
+ export interface MultiSortableListProps {
277
+ containers: SortableContainers
278
+ /** Container ids in board order. Also the set treated as drop targets. */
279
+ containerOrder: readonly string[]
280
+ /**
281
+ * Commit the move. Called from the drop handler with the arrangement the
282
+ * board is already showing, so the consumer's job is to make `containers`
283
+ * agree — it is not being asked to produce a frame.
284
+ *
285
+ * It used to say "called synchronously ... so a `setState` here batches with
286
+ * the primitive's own reset". True, and it described the wrong consumer. A
287
+ * consumer that patches its cache synchronously never saw the ghost; one
288
+ * that patches from a mutation's `onMutate` — a microtask at best, an
289
+ * `await cancelQueries` at worst — is not in that batch, and got a frame of
290
+ * the pre-drag arrangement. See the preview note below for what replaced it.
291
+ */
292
+ onMove: (move: MultiSortableMove, next: SortableContainers) => void
293
+ /**
294
+ * Override the drop-target strategy. Defaults to `pointerFirstCollision`,
295
+ * whose reasoning is in its own docblock.
296
+ *
297
+ * A prop because this component has now changed collision strategy twice
298
+ * (`closestCenter` → `closestCorners` → pointer-first) and both times a
299
+ * consumer had to wait for a release of this package to get the fix. The
300
+ * escape hatch costs one optional prop; a third such wait costs a release.
301
+ * Overriding it is opting out of the reasoning above, including the keyboard
302
+ * fallback — a bare `pointerWithin` here disables keyboard dragging.
303
+ */
304
+ collisionDetection?: CollisionDetection | undefined
305
+ /**
306
+ * What follows the pointer. Without this the card appears to vanish.
307
+ *
308
+ * Receives the item's container in the LIVE arrangement, which during a
309
+ * cross-column drag is the DESTINATION, not the origin. Without it the
310
+ * overlay renders in the origin's colour while the placeholder underneath
311
+ * already shows the destination's — one card, on screen twice, in two
312
+ * different hues.
313
+ */
314
+ renderOverlay?: ((itemId: string, containerId: string | null) => ReactNode) | undefined
315
+ /** Human name for an id, for both the overlay's a11y text and announcements. */
316
+ labelForItem?: ((itemId: string) => string) | undefined
317
+ labelForContainer?: ((containerId: string) => string) | undefined
318
+ /**
319
+ * Receives the live arrangement, which differs from `containers` mid-drag,
320
+ * and the container the drag is currently resolved INTO.
321
+ *
322
+ * The second argument exists because `useDroppable().isOver` cannot answer
323
+ * it on a board: every collision strategy here resolves `over` to a sortable
324
+ * ITEM whenever a column holds one — `closestCorners` by distance, and
325
+ * `pointerWithin` because a pointer inside a card is inside its column too
326
+ * and the card's centre is nearer — so the column's own droppable never wins
327
+ * and its `isOver` measured `false` on every column during real pointer AND
328
+ * keyboard drags. The container has to be derived from the resolved `over`
329
+ * id — the same `containerOf` the announcements already use.
330
+ */
331
+ children: (
332
+ arrangement: SortableContainers,
333
+ overContainerId: string | null,
334
+ ) => ReactNode
335
+ }
336
+
337
+ /**
338
+ * The arrangement the board renders INSTEAD of `containers`, and the thing
339
+ * `containers` has to catch up to.
340
+ *
341
+ * Defect: dropping a card in another lane showed it snap back to its origin
342
+ * and then forward again — "like an invisible or ghost presence". The old
343
+ * `handleDragEnd` cleared the preview and then called `onMove`. Clearing means
344
+ * rendering from `containers`, and `containers` is the consumer's cache, which
345
+ * at that instant still holds the PRE-DRAG arrangement. One frame in the old
346
+ * lane, then the consumer's patch lands and moves it again. That frame is the
347
+ * ghost.
348
+ *
349
+ * Reordering the two lines does not fix it. Both are in one discrete event
350
+ * handler, React flushes that as one batch, and an asynchronous consumer patch
351
+ * is not in the batch either way — the render it produces still has the
352
+ * preview cleared and `containers` stale. The fix is not to clear the preview
353
+ * at the drop at all: hold it, and let the next `containers` supersede it.
354
+ *
355
+ * So the preview is held against the exact `containers` value it was committed
356
+ * over. Any new one means the consumer has spoken — a patch, a rollback, a
357
+ * refetch, all of them authoritative — and the preview is dropped in the same
358
+ * render, not a frame later.
359
+ *
360
+ * WHEN THE CONSUMER NEVER PATCHES. A held preview is a claim that a move
361
+ * landed. A consumer that rejects the move, fails its write without rolling
362
+ * back, or simply ignores `onMove` never produces a new `containers`, and an
363
+ * unbounded hold would leave the board asserting a move that did not happen —
364
+ * silently, until something unrelated invalidated the query. So the hold is
365
+ * bounded: after `PREVIEW_SETTLE_MS` the preview expires and `containers`
366
+ * wins, which renders as the card returning to its lane. That is the correct
367
+ * picture of a rejected move, and the only picture available, because the
368
+ * primitive has no other way to be told.
369
+ *
370
+ * The cost is paid by a consumer that commits WITHOUT an optimistic patch and
371
+ * waits for a server round trip: past the deadline the card returns and then
372
+ * moves again when the response lands. That is the ghost, delayed — and it is
373
+ * honest, because a board that does not yet know the move landed should not be
374
+ * drawing it. The fix for that consumer is an optimistic patch, which is the
375
+ * pattern this component is shaped around.
376
+ */
377
+ interface DragPreview {
378
+ arrangement: SortableContainers
379
+ /** The `containers` identity this was committed over. A new one supersedes. */
380
+ basis: SortableContainers
381
+ /** False while the drag is in flight — a live preview is never superseded. */
382
+ settled: boolean
383
+ }
384
+
385
+ /**
386
+ * How long a settled preview outlives the drop before `containers` wins.
387
+ *
388
+ * Floor: dnd-kit's drop animation is 250ms, and the preview has to still be
389
+ * under the overlay when it lands or the overlay animates onto an empty slot.
390
+ * Ceiling: a rejected move has to read as rejected rather than as accepted,
391
+ * and half a second is about where a snap-back stops looking like a response.
392
+ * The healthy path never reaches either — an optimistic patch supersedes this
393
+ * in a frame or two, and the timer is cleared.
394
+ */
395
+ export const PREVIEW_SETTLE_MS = 500
396
+
397
+ function moveBetween(
398
+ arrangement: SortableContainers,
399
+ itemId: string,
400
+ from: string,
401
+ to: string,
402
+ index: number,
403
+ ): SortableContainers {
404
+ const next: Record<string, readonly string[]> = { ...arrangement }
405
+ const fromItems = (next[from] ?? []).filter((id) => id !== itemId)
406
+ const toItems = from === to ? [...fromItems] : [...(next[to] ?? [])]
407
+ const clamped = Math.max(0, Math.min(index, toItems.length))
408
+ toItems.splice(clamped, 0, itemId)
409
+ next[from] = fromItems
410
+ next[to] = toItems
411
+ return next
412
+ }
413
+
414
+ export function MultiSortableList({
415
+ containers,
416
+ containerOrder,
417
+ onMove,
418
+ collisionDetection = pointerFirstCollision,
419
+ renderOverlay,
420
+ labelForItem,
421
+ labelForContainer,
422
+ children,
423
+ }: MultiSortableListProps) {
424
+ const sensors = useSensors(
425
+ useSensor(PointerSensor, { activationConstraint: { distance: 4 } }),
426
+ useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
427
+ )
428
+
429
+ const [activeId, setActiveId] = useState<string | null>(null)
430
+ const [overContainerId, setOverContainerId] = useState<string | null>(null)
431
+ const [preview, setPreview] = useState<DragPreview | null>(null)
432
+ // The arrangement the drag started from, so a cancel restores it exactly.
433
+ const originRef = useRef<SortableContainers | null>(null)
434
+ const settleTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
435
+
436
+ useEffect(
437
+ () => () => {
438
+ if (settleTimer.current !== null) clearTimeout(settleTimer.current)
439
+ },
440
+ [],
441
+ )
442
+
443
+ const stopSettleTimer = () => {
444
+ if (settleTimer.current !== null) {
445
+ clearTimeout(settleTimer.current)
446
+ settleTimer.current = null
447
+ }
448
+ }
449
+
450
+ // Derived, not stored: on the render where `containers` finally carries the
451
+ // move, the board is already right — no second commit, no frame in between.
452
+ const superseded = preview !== null && preview.settled && preview.basis !== containers
453
+ const arrangement = preview !== null && !superseded ? preview.arrangement : containers
454
+
455
+ // And a supersede is FINAL. `containers` can return to the exact object the
456
+ // preview was held against — a rolled-back optimistic write restores the
457
+ // previous reference, not a copy of it — and a preview still sitting in state
458
+ // would then stop looking superseded and draw the move a second time, after
459
+ // the consumer had undone it. Dropping it here rather than in an effect keeps
460
+ // that to zero frames: React re-runs this render with the state already gone.
461
+ // The pending timer is left to fire into a null preview, where it is a no-op.
462
+ if (superseded) setPreview(null)
463
+
464
+ /**
465
+ * Show `next` until `containers` says otherwise. Called at every exit from a
466
+ * drag — committed, no-op, dropped outside, cancelled — so there is exactly
467
+ * one place where the board stops previewing.
468
+ *
469
+ * `next === containers` covers the ordinary cancel: the board is already
470
+ * rendering the truth, so there is nothing to hold and no timer to run.
471
+ */
472
+ const holdPreview = (next: SortableContainers) => {
473
+ stopSettleTimer()
474
+ if (next === containers) {
475
+ setPreview(null)
476
+ return
477
+ }
478
+ setPreview({ arrangement: next, basis: containers, settled: true })
479
+ settleTimer.current = setTimeout(() => {
480
+ settleTimer.current = null
481
+ setPreview(null)
482
+ }, PREVIEW_SETTLE_MS)
483
+ }
484
+
485
+ const containerOf = (id: string): string | null => {
486
+ if (containerOrder.includes(id)) return id
487
+ for (const containerId of containerOrder) {
488
+ if ((arrangement[containerId] ?? []).includes(id)) return containerId
489
+ }
490
+ return null
491
+ }
492
+
493
+ const nameItem = (id: string) => labelForItem?.(id) ?? id
494
+ const nameContainer = (id: string) => labelForContainer?.(id) ?? id
495
+
496
+ const handleDragStart = (event: DragStartEvent) => {
497
+ const id = String(event.active.id)
498
+ setActiveId(id)
499
+ stopSettleTimer()
500
+ // `arrangement`, not `containers`. A previous drop may still be held while
501
+ // its consumer patch is in flight, and rebasing on `containers` here would
502
+ // yank that card back to its old lane the moment the user picks up the
503
+ // next one — the ghost again, one drag later. The drag starts from what is
504
+ // on screen, which is also what the user is dragging.
505
+ originRef.current = arrangement
506
+ setPreview({ arrangement, basis: containers, settled: false })
507
+ // The card starts over its own column, so the drop target is lit from the
508
+ // first frame rather than only once the pointer crosses a boundary.
509
+ setOverContainerId(containerOf(id))
510
+ }
511
+
512
+ const handleDragOver = (event: DragOverEvent) => {
513
+ const { active, over } = event
514
+ if (!over) {
515
+ setOverContainerId(null)
516
+ return
517
+ }
518
+ const itemId = String(active.id)
519
+ const overId = String(over.id)
520
+ const from = containerOf(itemId)
521
+ const to = containerOf(overId)
522
+ // Set BEFORE the same-container early return: a reorder inside one column
523
+ // still has a drop target, and it is that column.
524
+ setOverContainerId(to)
525
+ if (!from || !to || from === to) return
526
+
527
+ const overItems = arrangement[to] ?? []
528
+ const overIndex = overItems.indexOf(overId)
529
+ const index = overIndex === -1 ? overItems.length : overIndex
530
+ setPreview({
531
+ arrangement: moveBetween(arrangement, itemId, from, to, index),
532
+ basis: containers,
533
+ settled: false,
534
+ })
535
+ }
536
+
537
+ const handleDragEnd = (event: DragEndEvent) => {
538
+ const { active, over } = event
539
+ const itemId = String(active.id)
540
+ const origin = originRef.current ?? containers
541
+ const from = Object.keys(origin).find((key) =>
542
+ (origin[key] ?? []).includes(itemId),
543
+ )
544
+ setActiveId(null)
545
+ setOverContainerId(null)
546
+ originRef.current = null
547
+
548
+ // Dropped outside every column, or an id the origin does not hold. Nothing
549
+ // is committed, so nothing will patch: the board goes back to what it was
550
+ // showing when the drag began.
551
+ if (!over || !from) {
552
+ holdPreview(origin)
553
+ return
554
+ }
555
+
556
+ const overId = String(over.id)
557
+ const to = containerOf(overId) ?? from
558
+ const overItems = (arrangement[to] ?? []).filter((id) => id !== itemId)
559
+ const overIndex = overItems.indexOf(overId)
560
+ const index = overIndex === -1 ? overItems.length : overIndex
561
+ const next = moveBetween(origin, itemId, from, to, index)
562
+
563
+ // Same lane, same slot. No `onMove`, therefore no patch to wait for.
564
+ if (from === to && (origin[from] ?? []).indexOf(itemId) === index) {
565
+ holdPreview(origin)
566
+ return
567
+ }
568
+
569
+ // Hold BEFORE handing over. The primitive's own state is then complete and
570
+ // consistent whatever `onMove` does, including throw.
571
+ holdPreview(next)
572
+ onMove({ itemId, fromContainerId: from, toContainerId: to, toIndex: index }, next)
573
+ }
574
+
575
+ const handleDragCancel = () => {
576
+ setActiveId(null)
577
+ setOverContainerId(null)
578
+ const origin = originRef.current ?? containers
579
+ originRef.current = null
580
+ // Not `setPreview(null)`: clearing renders `containers`, which is only the
581
+ // right answer when the drag started from it. If a previous drop is still
582
+ // held, cancelling this one must return to THAT, not undo both.
583
+ holdPreview(origin)
584
+ }
585
+
586
+ return (
587
+ <DndContext
588
+ sensors={sensors}
589
+ collisionDetection={collisionDetection}
590
+ measuring={{ droppable: { strategy: MeasuringStrategy.Always } }}
591
+ onDragStart={handleDragStart}
592
+ onDragOver={handleDragOver}
593
+ onDragEnd={handleDragEnd}
594
+ onDragCancel={handleDragCancel}
595
+ accessibility={{
596
+ announcements: {
597
+ onDragStart: ({ active }) => `Picked up ${nameItem(String(active.id))}.`,
598
+ onDragOver: ({ active, over }) =>
599
+ over
600
+ ? `${nameItem(String(active.id))} is over ${nameContainer(
601
+ containerOf(String(over.id)) ?? String(over.id),
602
+ )}.`
603
+ : `${nameItem(String(active.id))} is no longer over a column.`,
604
+ onDragEnd: ({ active, over }) =>
605
+ over
606
+ ? `Moved ${nameItem(String(active.id))} to ${nameContainer(
607
+ containerOf(String(over.id)) ?? String(over.id),
608
+ )}.`
609
+ : `${nameItem(String(active.id))} was dropped outside a column and returned.`,
610
+ onDragCancel: ({ active }) =>
611
+ `Cancelled. ${nameItem(String(active.id))} returned to where it started.`,
612
+ },
613
+ }}
614
+ >
615
+ {children(arrangement, overContainerId)}
616
+ <DragOverlay>
617
+ {activeId && renderOverlay ? renderOverlay(activeId, containerOf(activeId)) : null}
618
+ </DragOverlay>
619
+ </DndContext>
620
+ )
621
+ }
622
+
623
+ /**
624
+ * One droppable container inside a <MultiSortableList>.
625
+ *
626
+ * Renders through a prop so the consumer owns the container's chrome — the
627
+ * board column's drag-over state steps its BORDER rather than tinting with the
628
+ * accent (design brief §4), and only the consumer knows what its border is.
629
+ *
630
+ * `isOver` here is `useDroppable`'s raw answer and is NOT the board's drag-over
631
+ * signal: `over` resolves to a sortable item whenever the column holds one, so
632
+ * this reads `false` for the column the card is actually over. It is kept
633
+ * because the droppable registration is what makes an EMPTY column a drop
634
+ * target at all — and, under pointer-first collision, what makes the column's
635
+ * empty space below the last card a target for an append. The state a consumer
636
+ * should paint comes from `MultiSortableList`'s `overContainerId`.
637
+ */
638
+ export function SortableDropZone({
639
+ id,
640
+ items,
641
+ children,
642
+ }: {
643
+ id: string
644
+ items: readonly string[]
645
+ children: (props: {
646
+ setNodeRef: (node: HTMLElement | null) => void
647
+ isOver: boolean
648
+ }) => ReactNode
649
+ }) {
650
+ const { setNodeRef, isOver } = useDroppable({ id })
651
+ return (
652
+ <SortableContext items={[...items]} strategy={verticalListSortingStrategy}>
653
+ {children({ setNodeRef, isOver })}
654
+ </SortableContext>
655
+ )
656
+ }