@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.
- package/dist/chunk-RBYWGBQ2.js +2752 -0
- package/dist/chunk-RBYWGBQ2.js.map +1 -0
- package/dist/index.d.ts +5574 -57
- package/dist/index.js +21650 -11206
- package/dist/index.js.map +1 -1
- package/dist/rich-composer-impl-5NO443A6.js +1859 -0
- package/dist/rich-composer-impl-5NO443A6.js.map +1 -0
- package/dist/styles.css +1570 -0
- package/dist/tokens.css +112 -0
- package/package.json +8 -1
- package/src/__tests__/avatar.test.tsx +272 -0
- package/src/__tests__/bar-chart.test.tsx +809 -0
- package/src/__tests__/board.test.tsx +420 -0
- package/src/__tests__/chart-math.test.ts +922 -0
- package/src/__tests__/chart-series.test.ts +339 -0
- package/src/__tests__/code-block.test.tsx +134 -0
- package/src/__tests__/display-popover.test.tsx +195 -0
- package/src/__tests__/display-store.test.tsx +307 -0
- package/src/__tests__/donut-chart.test.tsx +397 -0
- package/src/__tests__/dropdown-menu.test.tsx +156 -2
- package/src/__tests__/filter-menu.test.tsx +175 -0
- package/src/__tests__/gauge-ring.test.tsx +233 -0
- package/src/__tests__/line-chart.test.tsx +612 -0
- package/src/__tests__/ranked-bars.test.tsx +343 -0
- package/src/__tests__/remark-underline.test.ts +194 -0
- package/src/__tests__/sortable.test.tsx +394 -0
- package/src/__tests__/sparkline.test.tsx +368 -0
- package/src/__tests__/stat-layer.test.tsx +271 -0
- package/src/__tests__/stat-strip.test.tsx +175 -0
- package/src/__tests__/status.test.tsx +293 -0
- package/src/__tests__/tabs.test.tsx +303 -0
- package/src/__tests__/token-shape.test.ts +132 -2
- package/src/avatar.tsx +352 -0
- package/src/bar-chart.tsx +1214 -0
- package/src/board.tsx +658 -0
- package/src/chart-frame.tsx +960 -0
- package/src/chart-legend.tsx +304 -0
- package/src/chart-tooltip.tsx +267 -0
- package/src/code-block.tsx +62 -8
- package/src/delta-chip.tsx +263 -0
- package/src/detail/__tests__/activity-pane.test.tsx +369 -0
- package/src/detail/__tests__/detail-chrome.test.tsx +134 -0
- package/src/detail/__tests__/detail-surface.test.tsx +529 -0
- package/src/detail/__tests__/field-row.test.tsx +357 -0
- package/src/detail/activity-pane.tsx +611 -0
- package/src/detail/calendar.tsx +355 -0
- package/src/detail/detail-divider.tsx +261 -0
- package/src/detail/detail-header.tsx +287 -0
- package/src/detail/detail-menu.tsx +254 -0
- package/src/detail/detail-surface.tsx +1110 -0
- package/src/detail/field-list.tsx +196 -0
- package/src/detail/field-row.tsx +1131 -0
- package/src/detail/index.ts +58 -0
- package/src/detail/segmented-choice.tsx +94 -0
- package/src/detail/types.ts +129 -0
- package/src/display-popover.tsx +487 -0
- package/src/display-store.tsx +301 -0
- package/src/donut-chart.tsx +988 -0
- package/src/dropdown-menu.tsx +290 -19
- package/src/filter-core/EXPORTS.md +85 -0
- package/src/filter-core/__tests__/columns.test.ts +159 -0
- package/src/filter-core/__tests__/faceting.test.ts +193 -0
- package/src/filter-core/__tests__/filter-fns.test.ts +519 -0
- package/src/filter-core/__tests__/operators.test.ts +235 -0
- package/src/filter-core/__tests__/state.test.ts +268 -0
- package/src/filter-core/__tests__/url.test.ts +350 -0
- package/src/filter-core/columns.ts +134 -0
- package/src/filter-core/date-utils.ts +38 -0
- package/src/filter-core/examples/task-filter-columns.ts +121 -0
- package/src/filter-core/faceting.ts +120 -0
- package/src/filter-core/filter-fns.ts +335 -0
- package/src/filter-core/index.ts +105 -0
- package/src/filter-core/operators.ts +433 -0
- package/src/filter-core/state.ts +280 -0
- package/src/filter-core/types.ts +247 -0
- package/src/filter-core/url.ts +261 -0
- package/src/filter-dropdown.tsx +12 -0
- package/src/filter-menu.tsx +649 -0
- package/src/floating-drawer.tsx +19 -1
- package/src/gauge-ring.tsx +435 -0
- package/src/hue.ts +52 -0
- package/src/index.ts +303 -0
- package/src/kbd.tsx +27 -4
- package/src/lib/chart.ts +866 -0
- package/src/lib/focus.ts +43 -1
- package/src/lib/layer-stack.ts +30 -3
- package/src/lib/remark-underline.ts +443 -0
- package/src/lib/series.ts +169 -0
- package/src/line-chart.tsx +1176 -0
- package/src/markdown.tsx +26 -7
- package/src/modal.tsx +42 -18
- package/src/progress-ledger.tsx +304 -0
- package/src/ranked-bars.tsx +386 -0
- package/src/segmented-pill.tsx +32 -9
- package/src/sortable.tsx +520 -1
- package/src/sparkline.tsx +416 -0
- package/src/stat-card.tsx +376 -0
- package/src/stat-strip.tsx +327 -0
- package/src/status.tsx +215 -0
- package/src/styles.css +1570 -0
- package/src/tabs.tsx +206 -25
- package/src/task-card.tsx +610 -0
- package/src/thread/__tests__/comment-body-hostile.test.tsx +331 -0
- package/src/thread/__tests__/comment-tree.test.ts +151 -0
- package/src/thread/__tests__/emoji.test.ts +187 -0
- package/src/thread/__tests__/fixtures/thread-fixture.ts +252 -0
- package/src/thread/__tests__/link-preview-source.test.ts +120 -0
- package/src/thread/__tests__/link-preview.test.tsx +600 -0
- package/src/thread/__tests__/markdown-format.test.ts +82 -0
- package/src/thread/__tests__/markdown-spec.test.ts +469 -0
- package/src/thread/__tests__/relative-time.test.ts +71 -0
- package/src/thread/__tests__/rich-composer.test.tsx +799 -0
- package/src/thread/__tests__/scroll-caret.test.ts +58 -0
- package/src/thread/__tests__/suggestion-list.test.tsx +648 -0
- package/src/thread/__tests__/thread-scroll-ownership.test.tsx +88 -0
- package/src/thread/__tests__/thread.test.tsx +742 -0
- package/src/thread/__tests__/use-attachments.test.tsx +679 -0
- package/src/thread/actions.tsx +196 -0
- package/src/thread/attachments.tsx +1071 -0
- package/src/thread/comment-body.tsx +148 -0
- package/src/thread/comment-tree.ts +182 -0
- package/src/thread/comment.tsx +967 -0
- package/src/thread/composer-footer.tsx +125 -0
- package/src/thread/composer.tsx +319 -0
- package/src/thread/emoji.ts +283 -0
- package/src/thread/index.ts +153 -0
- package/src/thread/link-preview.tsx +341 -0
- package/src/thread/markdown-format.ts +155 -0
- package/src/thread/markdown-spec.ts +754 -0
- package/src/thread/rail.tsx +372 -0
- package/src/thread/reactions.tsx +415 -0
- package/src/thread/relative-time.tsx +94 -0
- package/src/thread/rich-composer-impl.tsx +1601 -0
- package/src/thread/rich-composer.tsx +195 -0
- package/src/thread/scroll-caret.ts +37 -0
- package/src/thread/suggestion-list.tsx +182 -0
- package/src/thread/thread.tsx +718 -0
- package/src/thread/types.ts +232 -0
- package/src/thread/use-attachments.ts +598 -0
- package/src/thread/use-now.ts +73 -0
- package/src/thread/use-thread.ts +316 -0
- 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 {
|
|
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
|
+
}
|