@giddaa-housing/ui 3.9.0 → 3.10.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/kanban.js ADDED
@@ -0,0 +1,705 @@
1
+ "use client";
2
+ import { ChevronLeft, ChevronRight, GripVertical } from "./icons.js";
3
+ import { cn } from "./utils/cn.js";
4
+ import { Button } from "./button.js";
5
+ import { jsx, jsxs } from "react/jsx-runtime";
6
+ import * as React from "react";
7
+ import { combine } from "@atlaskit/pragmatic-drag-and-drop/dist/cjs/entry-point/combine.js";
8
+ import { draggable, dropTargetForElements, monitorForElements } from "@atlaskit/pragmatic-drag-and-drop/dist/cjs/entry-point/element/adapter.js";
9
+ import { autoScrollForElements, autoScrollWindowForElements } from "@atlaskit/pragmatic-drag-and-drop-auto-scroll/dist/cjs/entry-point/element.js";
10
+ import { attachClosestEdge, extractClosestEdge } from "@atlaskit/pragmatic-drag-and-drop-hitbox/dist/cjs/closest-edge.js";
11
+ //#region src/lib/kanban-feedback.ts
12
+ /**
13
+ * A flash queued for a card that has not mounted yet expires after this, so a
14
+ * card that never comes back cannot flash minutes later.
15
+ */
16
+ const FLASH_PENDING_TTL = 1e3;
17
+ /**
18
+ * Creates one board's feedback channel.
19
+ *
20
+ * A dropped card is often remounting — it moved column, so React unmounts it
21
+ * from one list and mounts it in another. `flashCard` therefore waits a frame
22
+ * before looking for the card, and leaves the flash queued for a card that
23
+ * arrives later still; whichever card is subscribed first wins it, once.
24
+ */
25
+ function createKanbanFeedback() {
26
+ const announcementListeners = /* @__PURE__ */ new Set();
27
+ const flashListeners = /* @__PURE__ */ new Map();
28
+ let announcementId = 0;
29
+ let pendingFlash = null;
30
+ const deliverPendingFlash = () => {
31
+ if (!pendingFlash) return;
32
+ if (Date.now() > pendingFlash.expiresAt) {
33
+ pendingFlash = null;
34
+ return;
35
+ }
36
+ const listeners = flashListeners.get(pendingFlash.cardId);
37
+ if (!listeners?.size) return;
38
+ pendingFlash = null;
39
+ for (const listener of listeners) listener();
40
+ };
41
+ return {
42
+ announce: (message) => {
43
+ announcementId += 1;
44
+ const announcement = {
45
+ id: announcementId,
46
+ message
47
+ };
48
+ for (const listener of announcementListeners) listener(announcement);
49
+ },
50
+ flashCard: (cardId) => {
51
+ pendingFlash = {
52
+ cardId,
53
+ expiresAt: Date.now() + FLASH_PENDING_TTL
54
+ };
55
+ if (typeof requestAnimationFrame === "undefined") {
56
+ deliverPendingFlash();
57
+ return;
58
+ }
59
+ requestAnimationFrame(deliverPendingFlash);
60
+ },
61
+ subscribeToAnnouncement: (listener) => {
62
+ announcementListeners.add(listener);
63
+ return () => announcementListeners.delete(listener);
64
+ },
65
+ subscribeToFlash: (cardId, onFlash) => {
66
+ const listeners = flashListeners.get(cardId) ?? /* @__PURE__ */ new Set();
67
+ listeners.add(onFlash);
68
+ flashListeners.set(cardId, listeners);
69
+ if (pendingFlash?.cardId === cardId) deliverPendingFlash();
70
+ return () => {
71
+ listeners.delete(onFlash);
72
+ if (listeners.size === 0) flashListeners.delete(cardId);
73
+ };
74
+ }
75
+ };
76
+ }
77
+ //#endregion
78
+ //#region src/kanban.tsx
79
+ /**
80
+ * Applies a card move to the two columns' id lists and returns the new lists.
81
+ * For an in-column move both returned lists are the same reordered list.
82
+ */
83
+ function applyKanbanCardMove({ move, sourceIds, targetIds }) {
84
+ const { cardId, fromColumnId, toColumnId, targetCardId, edge } = move;
85
+ const inColumn = fromColumnId === toColumnId;
86
+ if (inColumn && targetCardId === null) return {
87
+ changed: false,
88
+ sourceIds: [...sourceIds],
89
+ targetIds: [...sourceIds]
90
+ };
91
+ const withoutCard = sourceIds.filter((id) => id !== cardId);
92
+ const insertionBase = inColumn ? withoutCard : [...targetIds];
93
+ const insertionIndex = getInsertionIndex(insertionBase, targetCardId, edge);
94
+ insertionBase.splice(insertionIndex, 0, cardId);
95
+ return {
96
+ changed: inColumn ? !areSameOrder(sourceIds, insertionBase) : true,
97
+ sourceIds: inColumn ? insertionBase : withoutCard,
98
+ targetIds: insertionBase
99
+ };
100
+ }
101
+ /**
102
+ * Where the card slots into a list the card itself is no longer part of. A
103
+ * drop on the column (no target card) lands at the top, matching the drop
104
+ * placeholder's position.
105
+ */
106
+ function getInsertionIndex(ids, targetCardId, edge) {
107
+ if (targetCardId === null) return 0;
108
+ const targetIndex = ids.indexOf(targetCardId);
109
+ if (targetIndex === -1) return 0;
110
+ return edge === "bottom" ? targetIndex + 1 : targetIndex;
111
+ }
112
+ const areSameOrder = (a, b) => a.length === b.length && a.every((id, index) => id === b[index]);
113
+ /**
114
+ * Orders `ids` by a saved order: ids the saved order knows come first, in that
115
+ * order; the rest keep their given (API) order after them.
116
+ */
117
+ function sortIdsBySavedOrder(ids, savedOrder) {
118
+ if (!savedOrder || savedOrder.length === 0) return [...ids];
119
+ const present = new Set(ids);
120
+ const known = savedOrder.filter((id) => present.has(id));
121
+ const knownSet = new Set(known);
122
+ const unknown = ids.filter((id) => !knownSet.has(id));
123
+ return [...known, ...unknown];
124
+ }
125
+ /**
126
+ * Per-board card ordering, persisted to this browser's localStorage. Column
127
+ * membership belongs to the server; the only thing saved here is where inside
128
+ * a column the user dropped each card, so the board comes back arranged the
129
+ * way they left it.
130
+ *
131
+ * A column is untouched (pure API order) until its first drop; from then on
132
+ * its saved order wins, with cards the order has never seen keeping API order
133
+ * after the arranged ones.
134
+ *
135
+ * ```tsx
136
+ * const order = useKanbanCardOrder("lead-pipeline-order");
137
+ * const cards = order.sortColumn(stage.id, ids).map((id) => byId[id]);
138
+ * // …after a drop
139
+ * order.saveColumns({ [move.fromColumnId]: sourceIds, [move.toColumnId]: targetIds });
140
+ * ```
141
+ */
142
+ function useKanbanCardOrder(storageKey) {
143
+ const [order, setOrder] = React.useState({});
144
+ React.useEffect(() => {
145
+ setOrder(readOrder(storageKey));
146
+ }, [storageKey]);
147
+ return {
148
+ sortColumn: React.useCallback((columnId, ids) => sortIdsBySavedOrder(ids, order[columnId]), [order]),
149
+ saveColumns: React.useCallback((columns) => {
150
+ setOrder((previous) => {
151
+ const next = {
152
+ ...previous,
153
+ ...columns
154
+ };
155
+ writeOrder(storageKey, next);
156
+ return next;
157
+ });
158
+ }, [storageKey])
159
+ };
160
+ }
161
+ function readOrder(storageKey) {
162
+ try {
163
+ const stored = window.localStorage.getItem(storageKey);
164
+ if (!stored) return {};
165
+ const parsed = JSON.parse(stored);
166
+ return isColumnOrder(parsed) ? parsed : {};
167
+ } catch {
168
+ return {};
169
+ }
170
+ }
171
+ function writeOrder(storageKey, order) {
172
+ try {
173
+ window.localStorage.setItem(storageKey, JSON.stringify(order));
174
+ } catch {}
175
+ }
176
+ const isColumnOrder = (value) => typeof value === "object" && value !== null && !Array.isArray(value) && Object.values(value).every((ids) => Array.isArray(ids) && ids.every((id) => typeof id === "string"));
177
+ /**
178
+ * The board's live region. Its own component with its own state, so an
179
+ * announcement re-renders one visually hidden node rather than the board.
180
+ *
181
+ * The message is keyed, so the same message twice is a new node in the region
182
+ * and gets announced twice — a screen reader ignores a live region whose text
183
+ * it has already read.
184
+ */
185
+ function KanbanLiveRegion({ feedback }) {
186
+ const [announcement, setAnnouncement] = React.useState(null);
187
+ React.useEffect(() => feedback.subscribeToAnnouncement(setAnnouncement), [feedback]);
188
+ return /* @__PURE__ */ jsx("div", {
189
+ "aria-atomic": "true",
190
+ "aria-live": "polite",
191
+ "data-slot": "kanban-live-region",
192
+ className: "sr-only",
193
+ children: announcement ? /* @__PURE__ */ jsx("p", { children: announcement.message }, announcement.id) : null
194
+ });
195
+ }
196
+ const KanbanBoardContext = React.createContext(null);
197
+ function useKanbanBoardContext() {
198
+ const context = React.useContext(KanbanBoardContext);
199
+ if (!context) throw new Error("Kanban components must be used within <KanbanRoot>.");
200
+ return context;
201
+ }
202
+ const KanbanDragContext = React.createContext(null);
203
+ const KanbanScrollContext = React.createContext(null);
204
+ const KanbanColumnContext = React.createContext(null);
205
+ function useKanbanColumnContext() {
206
+ const context = React.useContext(KanbanColumnContext);
207
+ if (!context) throw new Error("Kanban column components must be used within <KanbanColumn>.");
208
+ return context;
209
+ }
210
+ /**
211
+ * The in-flight drag, for app-level styling beyond what the slots do. Null
212
+ * when nothing is being dragged.
213
+ */
214
+ function useKanbanDrag() {
215
+ return React.useContext(KanbanDragContext);
216
+ }
217
+ /**
218
+ * The board's horizontal scroll position, for controls placed anywhere inside
219
+ * `KanbanRoot`. `canScroll*` is false at the corresponding end and on a board
220
+ * that does not overflow at all, so both being false means "nothing to scroll".
221
+ */
222
+ function useKanbanScroll() {
223
+ const context = React.useContext(KanbanScrollContext);
224
+ if (!context) throw new Error("useKanbanScroll must be used within <KanbanRoot>.");
225
+ return context;
226
+ }
227
+ /**
228
+ * The board's live region and card flash, for moves the app makes outside a
229
+ * drag — the "move to" menu that keyboard users need. Announce what changed
230
+ * and flash the card so it can be found in its new column.
231
+ *
232
+ * ```tsx
233
+ * const { announce, flashCard } = useKanbanFeedback();
234
+ *
235
+ * moveLead(lead.id, stage.id);
236
+ * flashCard(lead.id);
237
+ * announce(`${lead.name} moved to ${stage.name}.`);
238
+ * ```
239
+ */
240
+ function useKanbanFeedback() {
241
+ const { announce, flashCard } = useKanbanBoardContext();
242
+ return {
243
+ announce,
244
+ flashCard
245
+ };
246
+ }
247
+ const CARD_DATA_TYPE = "kanban-card";
248
+ const COLUMN_DATA_TYPE = "kanban-column";
249
+ const isCardDragData = (data, instanceId) => data.type === CARD_DATA_TYPE && data.instanceId === instanceId;
250
+ const isColumnDropData = (data, instanceId) => data.type === COLUMN_DATA_TYPE && data.instanceId === instanceId;
251
+ /**
252
+ * Watches the board's drags. Isolated custom hook so components stay free of
253
+ * direct effects; Pragmatic's monitor is a document-level subscription that is
254
+ * naturally setup-on-mount and cleanup-on-unmount.
255
+ */
256
+ function useKanbanMonitor({ announceMove, feedback, instanceId, onCardMove, setDrag }) {
257
+ const handlersRef = React.useRef({
258
+ announceMove,
259
+ onCardMove
260
+ });
261
+ React.useEffect(() => {
262
+ handlersRef.current = {
263
+ announceMove,
264
+ onCardMove
265
+ };
266
+ });
267
+ React.useEffect(() => {
268
+ return monitorForElements({
269
+ canMonitor: ({ source }) => isCardDragData(source.data, instanceId),
270
+ onDragStart: ({ source }) => {
271
+ const data = source.data;
272
+ setDrag({
273
+ cardId: data.cardId,
274
+ fromColumnId: data.columnId,
275
+ overColumnId: data.columnId
276
+ });
277
+ },
278
+ onDropTargetChange: ({ location }) => {
279
+ const overColumnId = getOverColumnId(location.current.dropTargets);
280
+ setDrag((drag) => drag ? {
281
+ ...drag,
282
+ overColumnId
283
+ } : drag);
284
+ },
285
+ onDrop: ({ source, location }) => {
286
+ setDrag(null);
287
+ const data = source.data;
288
+ const targets = location.current.dropTargets;
289
+ const cardTarget = targets.find((target) => isCardDragData(target.data, instanceId));
290
+ const columnTarget = targets.find((target) => isColumnDropData(target.data, instanceId));
291
+ const cardData = cardTarget?.data;
292
+ const columnData = columnTarget?.data;
293
+ const toColumnId = cardData?.columnId ?? columnData?.columnId;
294
+ if (!toColumnId) return;
295
+ const edge = cardTarget && cardData ? extractClosestEdge(cardTarget.data) ?? "top" : null;
296
+ const move = {
297
+ cardId: data.cardId,
298
+ fromColumnId: data.columnId,
299
+ toColumnId,
300
+ targetCardId: cardData?.cardId ?? null,
301
+ edge
302
+ };
303
+ const { announceMove, onCardMove } = handlersRef.current;
304
+ onCardMove?.(move);
305
+ feedback.flashCard(move.cardId);
306
+ const announcement = announceMove?.(move);
307
+ if (announcement) feedback.announce(announcement);
308
+ }
309
+ });
310
+ }, [
311
+ feedback,
312
+ instanceId,
313
+ setDrag
314
+ ]);
315
+ }
316
+ function getOverColumnId(dropTargets) {
317
+ for (const target of dropTargets) if (target.data.type === CARD_DATA_TYPE || target.data.type === COLUMN_DATA_TYPE) return target.data.columnId;
318
+ return null;
319
+ }
320
+ /** Sub-pixel slack, so a board scrolled to its end does not read as scrollable. */
321
+ const SCROLL_EDGE_TOLERANCE = 1;
322
+ const EMPTY_SCROLL_STATE = {
323
+ canScrollLeft: false,
324
+ canScrollRight: false,
325
+ columnCount: 0,
326
+ visibleColumnCount: 0
327
+ };
328
+ /**
329
+ * Measures the columns, ignoring any trailing action tile (an "Add stage"
330
+ * button is a board child but not a column). `null` before the first column
331
+ * mounts, when there is nothing to measure.
332
+ */
333
+ function getColumnMetrics(board) {
334
+ const columns = board.querySelectorAll("[data-slot=\"kanban-column\"]");
335
+ const first = columns[0];
336
+ if (!first) return null;
337
+ const parsedGap = Number.parseFloat(getComputedStyle(board).columnGap);
338
+ const gap = Number.isNaN(parsedGap) ? 0 : parsedGap;
339
+ return {
340
+ count: columns.length,
341
+ gap,
342
+ stride: first.getBoundingClientRect().width + gap
343
+ };
344
+ }
345
+ /**
346
+ * How many whole columns the viewport holds. `n` columns occupy
347
+ * `n * stride - gap`, since the last one has no gap after it.
348
+ *
349
+ * Never less than one: a viewport narrower than a single column still shows
350
+ * that column, and "showing 0 of 7" would be a lie.
351
+ */
352
+ function getVisibleColumnCount(clientWidth, { count, gap, stride }) {
353
+ if (stride <= 0) return count;
354
+ return Math.min(count, Math.max(1, Math.floor((clientWidth + gap) / stride)));
355
+ }
356
+ /** Tracks which way the board can still be scrolled, if either. */
357
+ function useKanbanScrollState(board) {
358
+ const [state, setState] = React.useState(EMPTY_SCROLL_STATE);
359
+ React.useEffect(() => {
360
+ if (!board) {
361
+ setState(EMPTY_SCROLL_STATE);
362
+ return;
363
+ }
364
+ const update = () => {
365
+ const { clientWidth, scrollLeft, scrollWidth } = board;
366
+ const metrics = getColumnMetrics(board);
367
+ const next = {
368
+ canScrollLeft: scrollLeft > SCROLL_EDGE_TOLERANCE,
369
+ canScrollRight: scrollLeft + clientWidth < scrollWidth - SCROLL_EDGE_TOLERANCE,
370
+ columnCount: metrics?.count ?? 0,
371
+ visibleColumnCount: metrics ? getVisibleColumnCount(clientWidth, metrics) : 0
372
+ };
373
+ setState((previous) => previous.canScrollLeft === next.canScrollLeft && previous.canScrollRight === next.canScrollRight && previous.columnCount === next.columnCount && previous.visibleColumnCount === next.visibleColumnCount ? previous : next);
374
+ };
375
+ update();
376
+ board.addEventListener("scroll", update, { passive: true });
377
+ const resize = new ResizeObserver(update);
378
+ resize.observe(board);
379
+ for (const child of board.children) resize.observe(child);
380
+ const mutation = new MutationObserver(() => {
381
+ for (const child of board.children) resize.observe(child);
382
+ update();
383
+ });
384
+ mutation.observe(board, { childList: true });
385
+ return () => {
386
+ board.removeEventListener("scroll", update);
387
+ resize.disconnect();
388
+ mutation.disconnect();
389
+ };
390
+ }, [board]);
391
+ return state;
392
+ }
393
+ /**
394
+ * Registers Pragmatic auto-scrolling on whatever element the returned callback
395
+ * ref is attached to, so dragging towards an edge scrolls the board or the
396
+ * column under the pointer.
397
+ */
398
+ function useKanbanAutoScroll(instanceId) {
399
+ const [element, setElement] = React.useState(null);
400
+ React.useEffect(() => {
401
+ if (!element) return;
402
+ return autoScrollForElements({
403
+ element,
404
+ canScroll: ({ source }) => isCardDragData(source.data, instanceId)
405
+ });
406
+ }, [element, instanceId]);
407
+ return setElement;
408
+ }
409
+ function KanbanRoot({ announceMove, children, className, onCardMove, ...props }) {
410
+ const [instanceId] = React.useState(() => Symbol("kanban"));
411
+ const [feedback] = React.useState(createKanbanFeedback);
412
+ const [drag, setDrag] = React.useState(null);
413
+ const [board, setBoard] = React.useState(null);
414
+ useKanbanMonitor({
415
+ announceMove,
416
+ feedback,
417
+ instanceId,
418
+ onCardMove,
419
+ setDrag
420
+ });
421
+ const scrollState = useKanbanScrollState(board);
422
+ React.useEffect(() => {
423
+ return autoScrollWindowForElements({ canScroll: ({ source }) => isCardDragData(source.data, instanceId) });
424
+ }, [instanceId]);
425
+ const scroll = React.useCallback((direction) => {
426
+ if (!board) return;
427
+ const distance = getColumnMetrics(board)?.stride ?? Math.round(board.clientWidth * .8);
428
+ board.scrollBy({ left: direction === "left" ? -distance : distance });
429
+ }, [board]);
430
+ const boardValue = React.useMemo(() => ({
431
+ ...feedback,
432
+ instanceId,
433
+ setBoard
434
+ }), [feedback, instanceId]);
435
+ const scrollValue = React.useMemo(() => ({
436
+ ...scrollState,
437
+ scroll
438
+ }), [scrollState, scroll]);
439
+ return /* @__PURE__ */ jsx(KanbanBoardContext.Provider, {
440
+ value: boardValue,
441
+ children: /* @__PURE__ */ jsx(KanbanDragContext.Provider, {
442
+ value: drag,
443
+ children: /* @__PURE__ */ jsx(KanbanScrollContext.Provider, {
444
+ value: scrollValue,
445
+ children: /* @__PURE__ */ jsxs("div", {
446
+ "data-slot": "kanban",
447
+ className: cn("flex flex-col gap-4", className),
448
+ ...props,
449
+ children: [children, /* @__PURE__ */ jsx(KanbanLiveRegion, { feedback })]
450
+ })
451
+ })
452
+ })
453
+ });
454
+ }
455
+ function KanbanBoard({ className, ...props }) {
456
+ const { instanceId, setBoard } = useKanbanBoardContext();
457
+ const setAutoScrollElement = useKanbanAutoScroll(instanceId);
458
+ return /* @__PURE__ */ jsx("div", {
459
+ ref: React.useCallback((element) => {
460
+ setAutoScrollElement(element);
461
+ setBoard(element);
462
+ }, [setAutoScrollElement, setBoard]),
463
+ "data-slot": "kanban-board",
464
+ className: cn("flex items-stretch gap-4 overflow-x-auto motion-safe:scroll-smooth", className),
465
+ ...props
466
+ });
467
+ }
468
+ /**
469
+ * Steps the board one column in `direction`, and disables itself at that end.
470
+ * Keyboard users reach the same scroll through the board itself, so the
471
+ * buttons are a pointer affordance rather than the only way across.
472
+ */
473
+ function KanbanScrollButton({ className, direction, onClick, ...props }) {
474
+ const { canScrollLeft, canScrollRight, scroll } = useKanbanScroll();
475
+ const isLeft = direction === "left";
476
+ return /* @__PURE__ */ jsx(Button, {
477
+ type: "button",
478
+ size: "icon",
479
+ variant: "tertiary-outline",
480
+ "data-slot": "kanban-scroll-button",
481
+ "aria-label": isLeft ? "Scroll board left" : "Scroll board right",
482
+ disabled: isLeft ? !canScrollLeft : !canScrollRight,
483
+ className: cn("size-9 rounded-md", className),
484
+ onClick: (event) => {
485
+ onClick?.(event);
486
+ if (event.defaultPrevented) return;
487
+ scroll(direction);
488
+ },
489
+ ...props,
490
+ children: isLeft ? /* @__PURE__ */ jsx(ChevronLeft, { className: "size-4" }) : /* @__PURE__ */ jsx(ChevronRight, { className: "size-4" })
491
+ });
492
+ }
493
+ /**
494
+ * The pair of scroll buttons. On a board that fits its viewport both are
495
+ * disabled rather than hidden — the row holds its place (and its "showing
496
+ * N of M" caption) instead of vanishing on wide screens.
497
+ */
498
+ function KanbanScrollControls({ className, ...props }) {
499
+ return /* @__PURE__ */ jsxs("div", {
500
+ "data-slot": "kanban-scroll-controls",
501
+ className: cn("flex items-center gap-2", className),
502
+ ...props,
503
+ children: [/* @__PURE__ */ jsx(KanbanScrollButton, { direction: "left" }), /* @__PURE__ */ jsx(KanbanScrollButton, { direction: "right" })]
504
+ });
505
+ }
506
+ function KanbanColumn({ children, className, columnId, dropDisabled = false, ...props }) {
507
+ const { instanceId } = useKanbanBoardContext();
508
+ const drag = useKanbanDrag();
509
+ const ref = React.useRef(null);
510
+ React.useEffect(() => {
511
+ const element = ref.current;
512
+ if (!element || dropDisabled) return;
513
+ return dropTargetForElements({
514
+ element,
515
+ canDrop: ({ source }) => isCardDragData(source.data, instanceId),
516
+ getData: () => ({
517
+ type: COLUMN_DATA_TYPE,
518
+ instanceId,
519
+ columnId
520
+ }),
521
+ getIsSticky: () => true
522
+ });
523
+ }, [
524
+ columnId,
525
+ dropDisabled,
526
+ instanceId
527
+ ]);
528
+ const value = React.useMemo(() => ({
529
+ columnId,
530
+ dropDisabled
531
+ }), [columnId, dropDisabled]);
532
+ const isDropTarget = !dropDisabled && drag?.overColumnId === columnId;
533
+ return /* @__PURE__ */ jsx(KanbanColumnContext.Provider, {
534
+ value,
535
+ children: /* @__PURE__ */ jsx("section", {
536
+ ref,
537
+ "data-slot": "kanban-column",
538
+ "data-drop-target": isDropTarget || void 0,
539
+ className: cn("flex w-70 shrink-0 flex-col gap-3 rounded-2xl border border-line-subtle bg-surface-raised p-3", "data-drop-target:border-line-strong", className),
540
+ ...props,
541
+ children
542
+ })
543
+ });
544
+ }
545
+ function KanbanColumnHeader({ className, ...props }) {
546
+ return /* @__PURE__ */ jsx("div", {
547
+ "data-slot": "kanban-column-header",
548
+ className: cn("flex flex-col gap-1", className),
549
+ ...props
550
+ });
551
+ }
552
+ function KanbanColumnTitle({ className, ...props }) {
553
+ return /* @__PURE__ */ jsx("h3", {
554
+ "data-slot": "kanban-column-title",
555
+ className: cn("flex items-center gap-2 text-gdt-sm font-semibold text-fg-primary", className),
556
+ ...props
557
+ });
558
+ }
559
+ function KanbanColumnDot({ className, ...props }) {
560
+ return /* @__PURE__ */ jsx("span", {
561
+ "aria-hidden": true,
562
+ "data-slot": "kanban-column-dot",
563
+ className: cn("size-2 shrink-0 rounded-full", className),
564
+ ...props
565
+ });
566
+ }
567
+ function KanbanColumnCount({ className, ...props }) {
568
+ return /* @__PURE__ */ jsx("span", {
569
+ "data-slot": "kanban-column-count",
570
+ className: cn("shrink-0 rounded-full bg-surface-brand-subtle px-2 py-0.5 text-gdt-xs text-fg-primary", className),
571
+ ...props
572
+ });
573
+ }
574
+ function KanbanColumnDescription({ className, ...props }) {
575
+ return /* @__PURE__ */ jsx("p", {
576
+ "data-slot": "kanban-column-description",
577
+ className: cn("text-gdt-xs text-fg-secondary", className),
578
+ ...props
579
+ });
580
+ }
581
+ function KanbanColumnBody({ className, ...props }) {
582
+ const { instanceId } = useKanbanBoardContext();
583
+ return /* @__PURE__ */ jsx("div", {
584
+ ref: useKanbanAutoScroll(instanceId),
585
+ "data-slot": "kanban-column-body",
586
+ className: cn("flex min-h-0 flex-1 flex-col gap-2.5 overflow-y-auto", className),
587
+ ...props
588
+ });
589
+ }
590
+ /**
591
+ * Shown only while a card from another column hovers this one — the "drop here
592
+ * to move" affordance. Dropping on the column (rather than on a card) inserts
593
+ * at the top, which is where this placeholder sits.
594
+ */
595
+ function KanbanDropPlaceholder({ className, children, ...props }) {
596
+ const drag = useKanbanDrag();
597
+ const { columnId } = useKanbanColumnContext();
598
+ if (!(drag !== null && drag.overColumnId === columnId && drag.fromColumnId !== columnId)) return null;
599
+ return /* @__PURE__ */ jsx("div", {
600
+ "data-slot": "kanban-drop-placeholder",
601
+ className: cn("flex min-h-44 shrink-0 flex-col items-center justify-end rounded-xl border border-dashed border-line-strong bg-surface-brand-subtle px-3 py-4", className),
602
+ ...props,
603
+ children: /* @__PURE__ */ jsx("p", {
604
+ className: "text-center text-gdt-xs text-fg-brand",
605
+ children
606
+ })
607
+ });
608
+ }
609
+ const KanbanCardContext = React.createContext(null);
610
+ function KanbanCard({ cardId, children, className, dragDisabled = false, ...props }) {
611
+ const { instanceId, subscribeToFlash } = useKanbanBoardContext();
612
+ const { columnId, dropDisabled } = useKanbanColumnContext();
613
+ const ref = React.useRef(null);
614
+ const [dragHandle, setDragHandle] = React.useState(null);
615
+ const [isDragging, setIsDragging] = React.useState(false);
616
+ const [closestEdge, setClosestEdge] = React.useState(null);
617
+ const [justMoved, setJustMoved] = React.useState(false);
618
+ React.useEffect(() => {
619
+ const element = ref.current;
620
+ if (!element) return;
621
+ const data = {
622
+ type: CARD_DATA_TYPE,
623
+ instanceId,
624
+ cardId,
625
+ columnId
626
+ };
627
+ return combine(...dragDisabled ? [] : [draggable({
628
+ element,
629
+ dragHandle: dragHandle ?? void 0,
630
+ getInitialData: () => data,
631
+ onDragStart: () => setIsDragging(true),
632
+ onDrop: () => setIsDragging(false)
633
+ })], ...dropDisabled ? [] : [dropTargetForElements({
634
+ element,
635
+ canDrop: ({ source }) => isCardDragData(source.data, instanceId) && source.data.cardId !== cardId,
636
+ getData: ({ input, element: targetElement }) => attachClosestEdge(data, {
637
+ element: targetElement,
638
+ input,
639
+ allowedEdges: ["top", "bottom"]
640
+ }),
641
+ onDrag: ({ self }) => {
642
+ const edge = extractClosestEdge(self.data);
643
+ setClosestEdge(edge === "top" || edge === "bottom" ? edge : null);
644
+ },
645
+ onDragLeave: () => setClosestEdge(null),
646
+ onDrop: () => setClosestEdge(null)
647
+ })]);
648
+ }, [
649
+ cardId,
650
+ columnId,
651
+ dragDisabled,
652
+ dragHandle,
653
+ dropDisabled,
654
+ instanceId
655
+ ]);
656
+ React.useEffect(() => subscribeToFlash(cardId, () => setJustMoved(true)), [cardId, subscribeToFlash]);
657
+ React.useEffect(() => {
658
+ if (!justMoved) return;
659
+ const timeout = setTimeout(() => setJustMoved(false), 700);
660
+ return () => clearTimeout(timeout);
661
+ }, [justMoved]);
662
+ const cardValue = React.useMemo(() => ({ setDragHandle }), []);
663
+ return /* @__PURE__ */ jsx(KanbanCardContext.Provider, {
664
+ value: cardValue,
665
+ children: /* @__PURE__ */ jsxs("div", {
666
+ ref,
667
+ "data-slot": "kanban-card",
668
+ "data-dragging": isDragging || void 0,
669
+ "data-just-moved": justMoved || void 0,
670
+ className: cn("relative shrink-0 rounded-xl border border-line-subtle bg-canvas", !dragDisabled && !dragHandle && "cursor-grab active:cursor-grabbing", "data-dragging:opacity-40", "data-just-moved:animate-kanban-card-flash", className),
671
+ ...props,
672
+ children: [children, closestEdge ? /* @__PURE__ */ jsx(KanbanCardDropIndicator, { edge: closestEdge }) : null]
673
+ })
674
+ });
675
+ }
676
+ /**
677
+ * Restricts dragging to this element instead of the whole card. Worth adding
678
+ * as soon as a card holds its own controls — a menu, a link, a checkbox —
679
+ * since a drag started on one of those is a drag the user did not ask for.
680
+ * Renders a grip button; pass `children` to make it anything else.
681
+ */
682
+ function KanbanCardDragHandle({ children, className, ...props }) {
683
+ const context = React.useContext(KanbanCardContext);
684
+ if (!context) throw new Error("<KanbanCardDragHandle> must be used within <KanbanCard>.");
685
+ return /* @__PURE__ */ jsx("button", {
686
+ ref: context.setDragHandle,
687
+ type: "button",
688
+ "data-slot": "kanban-card-drag-handle",
689
+ "aria-hidden": true,
690
+ tabIndex: -1,
691
+ className: cn("inline-flex shrink-0 cursor-grab items-center justify-center rounded-md text-fg-secondary active:cursor-grabbing", className),
692
+ ...props,
693
+ children: children ?? /* @__PURE__ */ jsx(GripVertical, { className: "size-4" })
694
+ });
695
+ }
696
+ /** Insertion line drawn in the gap above or below the hovered card. */
697
+ function KanbanCardDropIndicator({ edge }) {
698
+ return /* @__PURE__ */ jsx("span", {
699
+ "aria-hidden": true,
700
+ "data-slot": "kanban-card-drop-indicator",
701
+ className: cn("pointer-events-none absolute inset-x-0 z-10 h-0.5 rounded-full bg-surface-brand", edge === "top" ? "-top-1.5" : "-bottom-1.5")
702
+ });
703
+ }
704
+ //#endregion
705
+ export { KanbanBoard, KanbanCard, KanbanCardDragHandle, KanbanColumn, KanbanColumnBody, KanbanColumnCount, KanbanColumnDescription, KanbanColumnDot, KanbanColumnHeader, KanbanColumnTitle, KanbanDropPlaceholder, KanbanRoot, KanbanScrollButton, KanbanScrollControls, applyKanbanCardMove, sortIdsBySavedOrder, useKanbanCardOrder, useKanbanDrag, useKanbanFeedback, useKanbanScroll };