@stll/ui 0.59.1 → 0.60.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/README.md +120 -4
  2. package/dist/components/accordion.d.ts +17 -3
  3. package/dist/components/accordion.js +17 -3
  4. package/dist/components/button.d.ts +56 -5
  5. package/dist/components/button.js +97 -15
  6. package/dist/components/button.native-classes.d.ts +17 -0
  7. package/dist/components/button.native-classes.js +176 -0
  8. package/dist/components/button.native.d.ts +5 -2
  9. package/dist/components/button.native.js +17 -134
  10. package/dist/components/button.shared.d.ts +24 -2
  11. package/dist/components/button.shared.js +15 -1
  12. package/dist/components/combobox.d.ts +3 -1
  13. package/dist/components/combobox.js +3 -2
  14. package/dist/components/date-picker-popover.d.ts +2 -0
  15. package/dist/components/date-picker-popover.js +5 -3
  16. package/dist/components/details-grid.d.ts +21 -2
  17. package/dist/components/details-grid.js +75 -20
  18. package/dist/components/form.d.ts +9 -3
  19. package/dist/components/form.js +24 -3
  20. package/dist/components/input-control.d.ts +9 -0
  21. package/dist/components/input-control.js +10 -1
  22. package/dist/components/input.d.ts +7 -1
  23. package/dist/components/input.js +4 -4
  24. package/dist/components/label.d.ts +24 -2
  25. package/dist/components/label.js +21 -2
  26. package/dist/components/number-input.d.ts +9 -2
  27. package/dist/components/number-input.js +18 -6
  28. package/dist/components/textarea.d.ts +14 -1
  29. package/dist/components/textarea.js +11 -5
  30. package/dist/index.d.ts +10 -6
  31. package/dist/index.js +6 -3
  32. package/dist/kanban/index.d.ts +5 -2
  33. package/dist/kanban/index.js +4 -1
  34. package/dist/kanban/sortable-edge.d.ts +39 -0
  35. package/dist/kanban/sortable-edge.js +27 -0
  36. package/dist/kanban/sortable-interactions.d.ts +145 -0
  37. package/dist/kanban/sortable-interactions.js +503 -0
  38. package/dist/kanban/sortable-interactions.logic.d.ts +79 -0
  39. package/dist/kanban/sortable-interactions.logic.js +210 -0
  40. package/dist/kanban/subgroup-board.d.ts +27 -2
  41. package/dist/kanban/subgroup-board.js +26 -1
  42. package/dist/kanban/virtual-cell.d.ts +18 -1
  43. package/dist/kanban/virtual-cell.js +70 -17
  44. package/package.json +5 -1
@@ -0,0 +1,210 @@
1
+ import { closestCorners, getFirstCollision, pointerWithin } from "@dnd-kit/core";
2
+ //#region src/kanban/sortable-interactions.logic.ts
3
+ const KANBAN_DROP_TARGET_TYPES = {
4
+ CELL: "kanban-cell",
5
+ ITEM: "kanban-item"
6
+ };
7
+ const isRecord = (value) => typeof value === "object" && value !== null;
8
+ const isUniqueIdentifier = (value) => typeof value === "string" || typeof value === "number";
9
+ const isCellPosition = (value) => isRecord(value) && typeof value["column"] === "number" && typeof value["lane"] === "number" && Number.isInteger(value["column"]) && Number.isInteger(value["lane"]) && value["column"] >= 0 && value["lane"] >= 0;
10
+ const isKanbanCellVirtualNavigation = (value) => isRecord(value) && (value["type"] === "static" || value["type"] === "virtual" && typeof value["requestScroll"] === "function");
11
+ const isKanbanCellDropData = (value) => isRecord(value) && value["type"] === KANBAN_DROP_TARGET_TYPES.CELL && Array.isArray(value["itemIds"]) && value["itemIds"].every(isUniqueIdentifier) && isKanbanCellVirtualNavigation(value["navigation"]) && isCellPosition(value["position"]);
12
+ const isKanbanItemDropData = (value) => {
13
+ if (!isRecord(value) || value["type"] !== KANBAN_DROP_TARGET_TYPES.ITEM) return false;
14
+ const navigation = value["navigation"];
15
+ const current = isRecord(navigation) ? navigation["current"] : void 0;
16
+ return isRecord(navigation) && isRecord(current) && (current["type"] === "idle" || (current["type"] === "ready" || current["type"] === "pending") && isUniqueIdentifier(current["targetId"]));
17
+ };
18
+ const getKanbanKeyboardTargetState = (value) => isKanbanItemDropData(value) ? value.navigation.current : void 0;
19
+ /**
20
+ * dnd-kit computes collisions while rendering but publishes the resulting drop
21
+ * target to its sensor context a render later, and resolves a drop from that
22
+ * published value. A drag that ends before the two agree, which every input
23
+ * can do because a single move produces a single render, drops the item on the
24
+ * previously published target.
25
+ *
26
+ * Keyboard navigation names its target outright, so it is compared directly: a
27
+ * pending target is still waiting for a virtual cell to mount an offscreen row
28
+ * and has produced no collision at all, and a ready one is only settled once it
29
+ * is the published target. Until then both the collisions and the published
30
+ * target still describe where the board was before the user navigated away.
31
+ */
32
+ const isKanbanDropSettled = ({ active, collisions, over }) => {
33
+ const target = getKanbanKeyboardTargetState(active?.data.current);
34
+ if (target?.type === "pending") return false;
35
+ if (target?.type === "ready") return over?.id === target.targetId;
36
+ return getFirstCollision(collisions, "id") === (over?.id ?? null);
37
+ };
38
+ const clearKanbanKeyboardTarget = (value) => {
39
+ if (isKanbanItemDropData(value) && value.navigation.current.type !== "idle") value.navigation.current = { type: "idle" };
40
+ };
41
+ const getSortableData = (value) => {
42
+ if (!isRecord(value)) return null;
43
+ const sortable = value["sortable"];
44
+ if (!isRecord(sortable) || !isUniqueIdentifier(sortable["containerId"]) || typeof sortable["index"] !== "number" || !Number.isInteger(sortable["index"]) || !Array.isArray(sortable["items"]) || !sortable["items"].every(isUniqueIdentifier)) return null;
45
+ return {
46
+ containerId: sortable["containerId"],
47
+ index: sortable["index"],
48
+ items: sortable["items"]
49
+ };
50
+ };
51
+ const isKanbanDroppable = ({ data }) => {
52
+ const value = data.current;
53
+ return isKanbanCellDropData(value) || isKanbanItemDropData(value);
54
+ };
55
+ /**
56
+ * Pointer and touch input must be inside a registered board target. Once that
57
+ * boundary is established, closest corners gives stable ranking across cells.
58
+ */
59
+ const KANBAN_BOARD_COLLISION_DETECTION = (args) => {
60
+ const boardContainers = args.droppableContainers.filter(isKanbanDroppable);
61
+ if (args.pointerCoordinates === null) {
62
+ const activeData = args.active.data.current;
63
+ if (isKanbanItemDropData(activeData)) {
64
+ if (activeData.navigation.current.type === "pending") return [];
65
+ if (activeData.navigation.current.type !== "ready") return closestCorners({
66
+ ...args,
67
+ droppableContainers: boardContainers
68
+ });
69
+ const { targetId } = activeData.navigation.current;
70
+ return boardContainers.some(({ id }) => id === targetId) ? [{ id: targetId }] : [];
71
+ }
72
+ return closestCorners({
73
+ ...args,
74
+ droppableContainers: boardContainers
75
+ });
76
+ }
77
+ const intersections = pointerWithin({
78
+ ...args,
79
+ droppableContainers: boardContainers
80
+ });
81
+ if (intersections.length === 0) return [];
82
+ const intersectingIds = new Set(intersections.map((intersection) => intersection.id));
83
+ const cellIds = new Set(boardContainers.filter(({ data }) => isKanbanCellDropData(data.current)).map(({ id }) => id));
84
+ return closestCorners({
85
+ ...args,
86
+ droppableContainers: boardContainers.filter(({ id, data }) => {
87
+ if (!intersectingIds.has(id)) return false;
88
+ const cellId = getSortableData(data.current)?.containerId;
89
+ return cellId === void 0 || !cellIds.has(cellId) || intersectingIds.has(cellId);
90
+ })
91
+ });
92
+ };
93
+ const getAdjacentCell = (cells, current, direction) => {
94
+ let columnOffset = 0;
95
+ let laneOffset = 0;
96
+ switch (direction) {
97
+ case "down":
98
+ laneOffset = 1;
99
+ break;
100
+ case "left":
101
+ columnOffset = -1;
102
+ break;
103
+ case "right":
104
+ columnOffset = 1;
105
+ break;
106
+ case "up": laneOffset = -1;
107
+ }
108
+ return cells.find(({ position }) => position.column === current.position.column + columnOffset && position.lane === current.position.lane + laneOffset);
109
+ };
110
+ const getKanbanKeyboardTarget = ({ activeId, cells, currentCellId, currentOverId, direction }) => {
111
+ const currentCell = cells.find(({ id }) => id === currentCellId);
112
+ if (currentCell === void 0) return;
113
+ const overIndex = currentCell.itemIds.indexOf(currentOverId);
114
+ const activeIndex = currentCell.itemIds.indexOf(activeId);
115
+ const currentIndex = overIndex !== -1 ? overIndex : activeIndex;
116
+ if (direction === "down" && currentIndex >= 0) {
117
+ const nextItem = currentCell.itemIds.at(currentIndex + 1);
118
+ if (nextItem !== void 0) return nextItem;
119
+ }
120
+ if (direction === "up" && currentIndex > 0) return currentCell.itemIds.at(currentIndex - 1);
121
+ const adjacentCell = getAdjacentCell(cells, currentCell, direction);
122
+ if (adjacentCell === void 0) return;
123
+ if (adjacentCell.itemIds.length === 0) return adjacentCell.id;
124
+ if (direction === "down") return adjacentCell.itemIds.at(0);
125
+ if (direction === "up") return adjacentCell.itemIds.at(-1);
126
+ const targetIndex = Math.max(currentIndex, 0);
127
+ return adjacentCell.itemIds.at(Math.min(targetIndex, adjacentCell.itemIds.length - 1)) ?? adjacentCell.id;
128
+ };
129
+ const getDirection = (code) => {
130
+ switch (code) {
131
+ case "ArrowDown": return "down";
132
+ case "ArrowLeft": return "left";
133
+ case "ArrowRight": return "right";
134
+ case "ArrowUp": return "up";
135
+ default: return null;
136
+ }
137
+ };
138
+ const getCellId = (container) => {
139
+ const data = container.data.current;
140
+ if (isKanbanCellDropData(data)) return container.id;
141
+ if (!isKanbanItemDropData(data)) return null;
142
+ return getSortableData(data)?.containerId ?? null;
143
+ };
144
+ /** Ordered two-dimensional navigation over item and empty-cell targets. */
145
+ const kanbanKeyboardCoordinates = (event, { active, context }) => {
146
+ const direction = getDirection(event.code);
147
+ if (direction === null) return;
148
+ event.preventDefault();
149
+ const cells = context.droppableContainers.getEnabled().filter(({ data }) => {
150
+ const value = data.current;
151
+ return isKanbanCellDropData(value);
152
+ }).flatMap((container) => {
153
+ const data = container.data.current;
154
+ return isKanbanCellDropData(data) ? [{
155
+ ...data,
156
+ id: container.id
157
+ }] : [];
158
+ });
159
+ const activeData = context.active?.data.current;
160
+ const keyboardTarget = getKanbanKeyboardTargetState(activeData);
161
+ const overId = keyboardTarget?.type === "ready" ? keyboardTarget.targetId : context.over?.id ?? active;
162
+ const overContainer = context.droppableContainers.get(overId);
163
+ const activeContainer = context.droppableContainers.get(active);
164
+ const currentCellId = (overContainer && getCellId(overContainer)) ?? (activeContainer && getCellId(activeContainer));
165
+ if (currentCellId === null || currentCellId === void 0) return;
166
+ const targetId = keyboardTarget?.type === "pending" ? keyboardTarget.targetId : getKanbanKeyboardTarget({
167
+ activeId: active,
168
+ cells,
169
+ currentCellId,
170
+ currentOverId: overId,
171
+ direction
172
+ });
173
+ if (targetId === void 0) return;
174
+ const targetNode = context.droppableContainers.get(targetId)?.node.current;
175
+ if (targetNode === null || targetNode === void 0) {
176
+ const targetCell = cells.find(({ itemIds }) => itemIds.includes(targetId));
177
+ if (isKanbanItemDropData(activeData) && targetCell?.navigation.type === "virtual") {
178
+ activeData.navigation.current = {
179
+ targetId,
180
+ type: "pending"
181
+ };
182
+ targetCell.navigation.requestScroll({
183
+ itemId: targetId,
184
+ type: "item"
185
+ });
186
+ }
187
+ return;
188
+ }
189
+ const targetRect = context.droppableRects.get(targetId);
190
+ if (targetRect === void 0) return;
191
+ if (isKanbanItemDropData(activeData)) activeData.navigation.current = {
192
+ targetId,
193
+ type: "ready"
194
+ };
195
+ requestAnimationFrame(() => {
196
+ requestAnimationFrame(() => {
197
+ targetNode.scrollIntoView({
198
+ behavior: "instant",
199
+ block: "nearest",
200
+ inline: "nearest"
201
+ });
202
+ });
203
+ });
204
+ return {
205
+ x: targetRect.left,
206
+ y: targetRect.top
207
+ };
208
+ };
209
+ //#endregion
210
+ export { KANBAN_BOARD_COLLISION_DETECTION, KANBAN_DROP_TARGET_TYPES, clearKanbanKeyboardTarget, getKanbanKeyboardTarget, getKanbanKeyboardTargetState, isKanbanCellDropData, isKanbanDropSettled, kanbanKeyboardCoordinates };
@@ -27,7 +27,11 @@ export type KanbanSubgroupCellContext<TRow> = {
27
27
  /** Number of rows in this lane/column intersection, including zero. */
28
28
  count: number;
29
29
  laneValue: string | null;
30
- /** The band this cell's column belongs to, or `null` outside any band. */
30
+ /**
31
+ * The band this cell's column belongs to, or `null` outside any band. Lets
32
+ * a host that drives its own drag-and-drop (see `dragOverBandId` on the
33
+ * board) map a cell's droppable id back to the band it should report.
34
+ */
31
35
  band: KanbanColumnBand | null;
32
36
  };
33
37
  export type KanbanSubgroupBandHeaderContext = {
@@ -110,6 +114,27 @@ export type KanbanSubgroupBoardProps<TRow> = {
110
114
  formatCount?: ((count: number) => ReactNode) | undefined;
111
115
  footer?: ReactNode;
112
116
  className?: string | undefined;
117
+ /**
118
+ * The band whose folded slot (or, for a peeked band, whose open part) the
119
+ * host's own drag is currently over, or `null` when it is over neither.
120
+ * Omit (`undefined`) when the host does not drive drags itself — the
121
+ * board's native `dragover`/`dragenter`/`dragleave` listeners already feed
122
+ * the peek controller in that case. A host drives its own drag when it
123
+ * builds the drop targets itself (for example a `KanbanSortableBoard`,
124
+ * whose `onDragOver` reports the active `over` droppable); it maps that
125
+ * droppable back to a band id — a folded cell's context and an open
126
+ * cell's context (see `KanbanSubgroupCellContext.band`) both carry the
127
+ * band — and passes it here. The controller treats "over the band" as
128
+ * entering the open band when it is already peeked, and as hovering its
129
+ * folded slot otherwise; leaving it is reported the same way.
130
+ */
131
+ dragOverBandId?: string | null | undefined;
132
+ /**
133
+ * Whether the host's own drag (see `dragOverBandId`) is in progress.
134
+ * Flipping to `false` ends any peek immediately, mirroring the native
135
+ * `dragend`/`drop` listeners the board installs for its own drags.
136
+ */
137
+ isDragging?: boolean | undefined;
113
138
  } & KanbanSubgroupCollapseControl;
114
139
  /**
115
140
  * Reusable swimlane layout over the canonical two-axis Kanban matrix.
@@ -128,6 +153,6 @@ export type KanbanSubgroupBoardProps<TRow> = {
128
153
  * in every state; the caption line never grows past its own height, so a
129
154
  * folded band costs no vertical space.
130
155
  */
131
- export declare const KanbanSubgroupBoard: <TRow>({ scrollRef, matrix, renderColumnHeader, renderLaneIdentity, renderCell, renderLaneColumnSummary, renderLaneColumnAction, renderBandHeader, renderCollapsedBandCell, formatBandToggleLabel, formatCount, isLaneCollapsed, onLaneCollapsedChange, isBandCollapsed, onBandCollapsedChange, footer, className }: KanbanSubgroupBoardProps<TRow>) => import("react").JSX.Element;
156
+ export declare const KanbanSubgroupBoard: <TRow>({ scrollRef, matrix, renderColumnHeader, renderLaneIdentity, renderCell, renderLaneColumnSummary, renderLaneColumnAction, renderBandHeader, renderCollapsedBandCell, formatBandToggleLabel, formatCount, isLaneCollapsed, onLaneCollapsedChange, isBandCollapsed, onBandCollapsedChange, footer, className, dragOverBandId, isDragging }: KanbanSubgroupBoardProps<TRow>) => import("react").JSX.Element;
132
157
  //#endregion
133
158
  export {};
@@ -42,7 +42,7 @@ const rowsIn = (cells) => cells.reduce((sum, cell) => sum + cell.rows.length, 0)
42
42
  * in every state; the caption line never grows past its own height, so a
43
43
  * folded band costs no vertical space.
44
44
  */
45
- const KanbanSubgroupBoard = ({ scrollRef, matrix, renderColumnHeader, renderLaneIdentity, renderCell, renderLaneColumnSummary, renderLaneColumnAction, renderBandHeader, renderCollapsedBandCell, formatBandToggleLabel, formatCount = String, isLaneCollapsed, onLaneCollapsedChange, isBandCollapsed, onBandCollapsedChange, footer, className }) => {
45
+ const KanbanSubgroupBoard = ({ scrollRef, matrix, renderColumnHeader, renderLaneIdentity, renderCell, renderLaneColumnSummary, renderLaneColumnAction, renderBandHeader, renderCollapsedBandCell, formatBandToggleLabel, formatCount = String, isLaneCollapsed, onLaneCollapsedChange, isBandCollapsed, onBandCollapsedChange, footer, className, dragOverBandId, isDragging }) => {
46
46
  const [collapsedLaneValues, setCollapsedLaneValues] = useState(() => /* @__PURE__ */ new Set());
47
47
  const [expandedEmptyLaneValues, setExpandedEmptyLaneValues] = useState(() => /* @__PURE__ */ new Set());
48
48
  const [collapsedBandIds, setCollapsedBandIds] = useState(() => /* @__PURE__ */ new Set());
@@ -69,6 +69,31 @@ const KanbanSubgroupBoard = ({ scrollRef, matrix, renderColumnHeader, renderLane
69
69
  window.removeEventListener("drop", end);
70
70
  };
71
71
  }, [peek]);
72
+ const previousDragOverBandId = useRef(null);
73
+ useEffect(() => {
74
+ if (dragOverBandId === void 0 || isDragging === false || dragOverBandId === previousDragOverBandId.current) return;
75
+ const previous = previousDragOverBandId.current;
76
+ previousDragOverBandId.current = dragOverBandId;
77
+ if (previous !== null) {
78
+ if (peekingBandId === previous) peek.openDragLeave(previous);
79
+ else peek.slotDragLeave(previous);
80
+ }
81
+ if (dragOverBandId !== null) {
82
+ if (peekingBandId === dragOverBandId) peek.openDragEnter(dragOverBandId);
83
+ else peek.slotDragOver(dragOverBandId);
84
+ }
85
+ }, [
86
+ dragOverBandId,
87
+ isDragging,
88
+ peek,
89
+ peekingBandId
90
+ ]);
91
+ useEffect(() => {
92
+ if (isDragging === false) {
93
+ previousDragOverBandId.current = null;
94
+ peek.dragEnded();
95
+ }
96
+ }, [isDragging, peek]);
72
97
  const { cellsByLaneValue, countByColumnValue, ungroupedCells } = useMemo(() => {
73
98
  const laneCells = /* @__PURE__ */ new Map();
74
99
  const columnCounts = /* @__PURE__ */ new Map();
@@ -1,5 +1,8 @@
1
1
  import { Key, ReactNode, RefObject } from "react";
2
2
  import { OptionColor } from "../lib/option-color.js";
3
+ import { SortableContextProps, SortingStrategy } from "@dnd-kit/sortable";
4
+ import { UniqueIdentifier } from "@dnd-kit/core";
5
+ import { UseKanbanDropTargetOptions } from "./sortable-interactions.js";
3
6
  //#region src/kanban/virtual-cell.d.ts
4
7
  export declare const KANBAN_VIRTUAL_CELL_PAGINATION: {
5
8
  readonly NONE: "none";
@@ -14,11 +17,25 @@ export type KanbanVirtualCellPagination = {
14
17
  pageKey: string | number;
15
18
  onRequestMore: () => void;
16
19
  };
20
+ /**
21
+ * Makes a virtual cell the canonical sortable context for its rendered rows.
22
+ *
23
+ * `getRowKey` remains responsible for React and virtualizer identity. A
24
+ * separate sortable identifier avoids narrowing an existing React key contract
25
+ * merely to support dnd-kit consumers.
26
+ */
27
+ export type KanbanVirtualCellSortableContext<TRow> = {
28
+ dropTarget: Omit<UseKanbanDropTargetOptions, "itemIds" | "navigation">;
29
+ getRowId: (row: TRow) => UniqueIdentifier;
30
+ disabled?: SortableContextProps["disabled"] | undefined;
31
+ strategy?: SortingStrategy | undefined;
32
+ };
17
33
  export type KanbanVirtualCellProps<TRow> = {
18
34
  rows: readonly TRow[];
19
35
  getRowKey: (row: TRow) => Key;
20
36
  renderRow: (row: TRow) => ReactNode;
21
37
  pagination: KanbanVirtualCellPagination;
38
+ sortable?: KanbanVirtualCellSortableContext<TRow> | undefined;
22
39
  containerRef?: RefObject<HTMLDivElement | null> | undefined;
23
40
  active?: boolean | undefined;
24
41
  backgroundColor?: string | undefined;
@@ -54,5 +71,5 @@ export type KanbanVirtualCellProps<TRow> = {
54
71
  className?: string | undefined;
55
72
  };
56
73
  /** Bounded, virtualized Kanban cell with cursor-page request deduplication. */
57
- export declare const KanbanVirtualCell: <TRow>({ rows, getRowKey, renderRow, pagination, containerRef, active, backgroundColor, accent, footer, footerPlacement, estimateSize, overscan, loadMoreThreshold, className }: KanbanVirtualCellProps<TRow>) => import("react").JSX.Element;
74
+ export declare const KanbanVirtualCell: <TRow>({ rows, getRowKey, renderRow, pagination, sortable, containerRef, active, backgroundColor, accent, footer, footerPlacement, estimateSize, overscan, loadMoreThreshold, className }: KanbanVirtualCellProps<TRow>) => import("react").JSX.Element;
58
75
  //#endregion
@@ -1,9 +1,12 @@
1
1
  import { cn } from "../lib/utils.js";
2
2
  import { KANBAN_CARD_STICKY_TOP_VAR, KANBAN_STICKY_TOP_CLASS, resolveKanbanCardStickyTop } from "./sticky-lane.js";
3
+ import { useKanbanDropTarget } from "./sortable-interactions.js";
3
4
  import { resolveOptionColor } from "../lib/option-color.js";
4
5
  import { jsx, jsxs } from "react/jsx-runtime";
5
- import { useLayoutEffect, useRef, useState } from "react";
6
- import { useVirtualizer } from "@tanstack/react-virtual";
6
+ import { useId, useLayoutEffect, useRef, useState } from "react";
7
+ import { useDndContext } from "@dnd-kit/core";
8
+ import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable";
9
+ import { defaultRangeExtractor, useVirtualizer } from "@tanstack/react-virtual";
7
10
  //#region src/kanban/virtual-cell.tsx
8
11
  const DEFAULT_ESTIMATE_SIZE_PX = 128;
9
12
  const DEFAULT_OVERSCAN = 8;
@@ -26,15 +29,26 @@ const KANBAN_CELL_SURFACE_CLASS = "bg-muted/20";
26
29
  /** A caller's own surface (an explicit colour, or the accent wash), published
27
30
  * so a pinned action can repaint that one instead. */
28
31
  const KANBAN_CELL_SURFACE_VAR = "--kanban-cell-surface";
32
+ const retainActiveSortableIndex = (range, activeIndex) => {
33
+ const indexes = defaultRangeExtractor(range);
34
+ if (activeIndex < 0 || indexes.includes(activeIndex)) return indexes;
35
+ indexes.push(activeIndex);
36
+ indexes.sort((left, right) => left - right);
37
+ return indexes;
38
+ };
29
39
  const KANBAN_VIRTUAL_CELL_PAGINATION = {
30
40
  NONE: "none",
31
41
  CURSOR: "cursor"
32
42
  };
33
43
  /** Bounded, virtualized Kanban cell with cursor-page request deduplication. */
34
- const KanbanVirtualCell = ({ rows, getRowKey, renderRow, pagination, containerRef, active = false, backgroundColor, accent, footer, footerPlacement = "end", estimateSize = DEFAULT_ESTIMATE_SIZE_PX, overscan = DEFAULT_OVERSCAN, loadMoreThreshold = DEFAULT_LOAD_MORE_THRESHOLD_PX, className }) => {
44
+ const KanbanVirtualCell = ({ rows, getRowKey, renderRow, pagination, sortable, containerRef, active = false, backgroundColor, accent, footer, footerPlacement = "end", estimateSize = DEFAULT_ESTIMATE_SIZE_PX, overscan = DEFAULT_OVERSCAN, loadMoreThreshold = DEFAULT_LOAD_MORE_THRESHOLD_PX, className }) => {
35
45
  const internalRef = useRef(null);
46
+ const fallbackDropTargetId = useId();
36
47
  const scrollRef = internalRef;
37
48
  const requestedPageKeyRef = useRef(null);
49
+ const itemIds = sortable ? rows.map(sortable.getRowId) : [];
50
+ const { active: activeDrag } = useDndContext();
51
+ const activeSortableIndex = activeDrag === null ? -1 : itemIds.indexOf(activeDrag.id);
38
52
  const virtualizer = useVirtualizer({
39
53
  count: rows.length,
40
54
  estimateSize: () => estimateSize,
@@ -43,7 +57,25 @@ const KanbanVirtualCell = ({ rows, getRowKey, renderRow, pagination, containerRe
43
57
  return row === void 0 ? index : getRowKey(row);
44
58
  },
45
59
  getScrollElement: () => scrollRef.current,
46
- overscan
60
+ overscan,
61
+ rangeExtractor: (range) => retainActiveSortableIndex(range, activeSortableIndex)
62
+ });
63
+ const requestScroll = ({ itemId }) => {
64
+ const index = itemIds.indexOf(itemId);
65
+ if (index !== -1) virtualizer.scrollToIndex(index, { align: "auto" });
66
+ };
67
+ const dropTarget = useKanbanDropTarget({
68
+ disabled: sortable?.dropTarget.disabled ?? sortable === void 0,
69
+ id: sortable?.dropTarget.id ?? fallbackDropTargetId,
70
+ itemIds,
71
+ navigation: sortable === void 0 ? { type: "static" } : {
72
+ requestScroll,
73
+ type: "virtual"
74
+ },
75
+ position: sortable?.dropTarget.position ?? {
76
+ column: -1,
77
+ lane: -1
78
+ }
47
79
  });
48
80
  const handleScroll = ({ currentTarget }) => {
49
81
  if (pagination.type !== "cursor" || !pagination.hasMore || pagination.loading) return;
@@ -55,17 +87,13 @@ const KanbanVirtualCell = ({ rows, getRowKey, renderRow, pagination, containerRe
55
87
  const setScrollElement = (element) => {
56
88
  internalRef.current = element;
57
89
  if (containerRef) containerRef.current = element;
90
+ dropTarget.setNodeRef(element);
58
91
  };
59
- const accentVariants = accent === void 0 ? void 0 : resolveOptionColor(accent);
60
- const accentBackground = accentVariants === void 0 ? void 0 : `color-mix(in srgb, var(${KANBAN_CELL_ACCENT_VAR}) ${active ? KANBAN_CELL_ACCENT_ACTIVE_ALPHA : KANBAN_CELL_ACCENT_RESTING_ALPHA}%, var(--background))`;
61
- const activeAccentRing = active && accentVariants !== void 0 ? `0 0 0 2px color-mix(in srgb, var(${KANBAN_CELL_ACCENT_VAR}) ${KANBAN_CELL_ACCENT_ACTIVE_RING_ALPHA}%, transparent)` : void 0;
62
- const surface = backgroundColor ?? accentBackground;
63
- const style = surface === void 0 ? void 0 : {
64
- backgroundColor: surface,
65
- [KANBAN_CELL_SURFACE_VAR]: surface,
66
- ...activeAccentRing === void 0 ? void 0 : { boxShadow: activeAccentRing },
67
- ...accentVariants === void 0 ? void 0 : { [KANBAN_CELL_ACCENT_VAR]: accentVariants.color }
68
- };
92
+ const { surface, style, hasAccent } = resolveKanbanCellAppearance({
93
+ accent,
94
+ active,
95
+ backgroundColor
96
+ });
69
97
  const hasStickyFooter = footerPlacement === "sticky-start" && footer !== null && footer !== void 0;
70
98
  const stickyFooterRef = useRef(null);
71
99
  const [stickyFooterHeight, setStickyFooterHeight] = useState(0);
@@ -94,9 +122,10 @@ const KanbanVirtualCell = ({ rows, getRowKey, renderRow, pagination, containerRe
94
122
  children: footer
95
123
  })
96
124
  }) : null;
97
- return /* @__PURE__ */ jsxs("div", {
98
- className: cn(KANBAN_CELL_SURFACE_CLASS, "min-h-20 rounded-xl p-2", rows.length === 0 ? "overflow-visible" : "max-h-[min(60dvh,40rem)] overflow-y-auto overscroll-y-contain", active && accentVariants === void 0 && "bg-primary/5 ring-primary/50 ring-2", className),
99
- "data-kanban-cell-accent": accentVariants === void 0 ? void 0 : "true",
125
+ const content = /* @__PURE__ */ jsxs("div", {
126
+ className: cn(KANBAN_CELL_SURFACE_CLASS, "min-h-20 rounded-xl p-2", rows.length === 0 ? "overflow-visible" : "max-h-[min(60dvh,40rem)] overflow-y-auto overscroll-y-contain", active && !hasAccent && "bg-primary/5 ring-primary/50 ring-2", className),
127
+ "data-kanban-cell": sortable?.dropTarget.id,
128
+ "data-kanban-cell-accent": hasAccent ? "true" : void 0,
100
129
  onScroll: handleScroll,
101
130
  ref: setScrollElement,
102
131
  style,
@@ -127,6 +156,30 @@ const KanbanVirtualCell = ({ rows, getRowKey, renderRow, pagination, containerRe
127
156
  footerPlacement === "end" ? footer : null
128
157
  ]
129
158
  });
159
+ if (sortable === void 0) return content;
160
+ return /* @__PURE__ */ jsx(SortableContext, {
161
+ ...sortable.disabled === void 0 ? {} : { disabled: sortable.disabled },
162
+ id: sortable.dropTarget.id,
163
+ items: itemIds,
164
+ strategy: sortable.strategy ?? verticalListSortingStrategy,
165
+ children: content
166
+ });
130
167
  };
168
+ function resolveKanbanCellAppearance({ accent, active, backgroundColor }) {
169
+ const accentVariants = accent === void 0 ? void 0 : resolveOptionColor(accent);
170
+ const accentBackground = accentVariants === void 0 ? void 0 : `color-mix(in srgb, var(${KANBAN_CELL_ACCENT_VAR}) ${active ? KANBAN_CELL_ACCENT_ACTIVE_ALPHA : KANBAN_CELL_ACCENT_RESTING_ALPHA}%, var(--background))`;
171
+ const activeAccentRing = active && accentVariants !== void 0 ? `0 0 0 2px color-mix(in srgb, var(${KANBAN_CELL_ACCENT_VAR}) ${KANBAN_CELL_ACCENT_ACTIVE_RING_ALPHA}%, transparent)` : void 0;
172
+ const surface = backgroundColor ?? accentBackground;
173
+ return {
174
+ surface,
175
+ style: surface === void 0 ? void 0 : {
176
+ backgroundColor: surface,
177
+ [KANBAN_CELL_SURFACE_VAR]: surface,
178
+ ...activeAccentRing === void 0 ? void 0 : { boxShadow: activeAccentRing },
179
+ ...accentVariants === void 0 ? void 0 : { [KANBAN_CELL_ACCENT_VAR]: accentVariants.color }
180
+ },
181
+ hasAccent: accentVariants !== void 0
182
+ };
183
+ }
131
184
  //#endregion
132
185
  export { KANBAN_VIRTUAL_CELL_PAGINATION, KanbanVirtualCell };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stll/ui",
3
- "version": "0.59.1",
3
+ "version": "0.60.0",
4
4
  "description": "Stella's design system: bidi-aware React primitives built on Base UI, the dockable inspector pane, and the Tailwind v4 theme they are styled with.",
5
5
  "keywords": [
6
6
  "base-ui",
@@ -862,6 +862,8 @@
862
862
  "@atlaskit/pragmatic-drag-and-drop": "^4.0.0",
863
863
  "@atlaskit/pragmatic-drag-and-drop-auto-scroll": "^3.2.1",
864
864
  "@base-ui/react": "1.9.0",
865
+ "@dnd-kit/core": "^6.3.1",
866
+ "@dnd-kit/sortable": "^10.0.0",
865
867
  "@happy-dom/global-registrator": "21.0.4",
866
868
  "@playwright/test": "^1.63.0",
867
869
  "@stll/playwright-tools": "0.0.0",
@@ -885,6 +887,8 @@
885
887
  "@atlaskit/pragmatic-drag-and-drop": "^4.0.0",
886
888
  "@atlaskit/pragmatic-drag-and-drop-auto-scroll": "^3.2.1",
887
889
  "@base-ui/react": "^1.7.0",
890
+ "@dnd-kit/core": "^6.3.1",
891
+ "@dnd-kit/sortable": "^10.0.0",
888
892
  "@playwright/test": "^1.63.0",
889
893
  "@tanstack/react-virtual": "^3.14.13",
890
894
  "@types/react": ">=19",