@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/css/generated/shared.css +1 -1
- package/css/theme.css +15 -0
- package/dist/accordion.js +2 -2
- package/dist/calendar.js +1 -1
- package/dist/{combobox-Jfz8hryr.js → combobox-pFh77rhm.js} +5 -1
- package/dist/combobox.js +1 -1
- package/dist/date-picker.d.ts +1 -1
- package/dist/dialog.js +1 -1
- package/dist/empty-state.js +2 -2
- package/dist/{icons-C9JXR5Xv.d.ts → icons-e71GaFK-.d.ts} +2 -1
- package/dist/icons.d.ts +2 -2
- package/dist/icons.js +39 -1
- package/dist/input-group.js +1 -1
- package/dist/kanban.d.ts +214 -0
- package/dist/kanban.js +705 -0
- package/dist/media-gallery-hero.d.ts +7 -1
- package/dist/media-gallery-hero.js +24 -9
- package/dist/media-player.js +33 -2
- package/dist/{picker-qxWi9wZ1.d.ts → picker-QtFHPRqg.d.ts} +1 -1
- package/dist/rating.d.ts +1 -1
- package/dist/scroll-spy.d.ts +27 -4
- package/dist/scroll-spy.js +45 -7
- package/dist/section-jumper.d.ts +7 -1
- package/dist/section-jumper.js +86 -25
- package/dist/select.js +1 -1
- package/dist/styles.css +170 -29
- package/dist/tabs.d.ts +38 -2
- package/dist/tabs.js +76 -10
- package/dist/time-picker.d.ts +1 -1
- package/package.json +8 -1
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 };
|