@suphark/ui 0.34.0 → 0.38.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/CHANGELOG.md +57 -0
- package/README.md +16 -1
- package/package.json +1 -1
- package/src/components/app-shell/app-shell.tsx +8 -1
- package/src/components/shared/action-form.tsx +6 -1
- package/src/components/shared/button/button.tsx +59 -1
- package/src/components/shared/form/form-invalid-notice.tsx +226 -0
- package/src/components/shared/form-dialog.tsx +5 -1
- package/src/components/shared/ui-text.tsx +11 -0
- package/src/components/sortable/sortable-list.tsx +586 -33
- package/src/index.ts +5 -0
- package/src/lib/sortable-order.ts +25 -0
- package/src/sortable.ts +3 -0
|
@@ -5,10 +5,18 @@ import {
|
|
|
5
5
|
closestCenter,
|
|
6
6
|
DndContext,
|
|
7
7
|
type DragEndEvent,
|
|
8
|
+
type DragMoveEvent,
|
|
8
9
|
type DragOverEvent,
|
|
10
|
+
DragOverlay,
|
|
9
11
|
type DragStartEvent,
|
|
12
|
+
type DropAnimation,
|
|
13
|
+
getClientRect,
|
|
14
|
+
getFirstCollision,
|
|
10
15
|
KeyboardSensor,
|
|
16
|
+
MeasuringStrategy,
|
|
11
17
|
PointerSensor,
|
|
18
|
+
pointerWithin,
|
|
19
|
+
rectIntersection,
|
|
12
20
|
type UniqueIdentifier,
|
|
13
21
|
useDroppable,
|
|
14
22
|
useSensor,
|
|
@@ -23,8 +31,11 @@ import {
|
|
|
23
31
|
import { CSS } from "@dnd-kit/utilities";
|
|
24
32
|
import { Button } from "@suphark/ui/components/shared/button/button";
|
|
25
33
|
import { useUiText } from "@suphark/ui/components/shared/ui-text";
|
|
34
|
+
import { useMounted } from "@suphark/ui/hooks/use-mounted";
|
|
26
35
|
import { errorMessage } from "@suphark/ui/lib/action-result";
|
|
27
36
|
import {
|
|
37
|
+
flatItems,
|
|
38
|
+
gatherItems,
|
|
28
39
|
groupOfItem,
|
|
29
40
|
moveItemToGroup,
|
|
30
41
|
reorderGroups,
|
|
@@ -38,14 +49,19 @@ import { GripVerticalIcon } from "lucide-react";
|
|
|
38
49
|
import {
|
|
39
50
|
type CSSProperties,
|
|
40
51
|
createContext,
|
|
52
|
+
type KeyboardEvent,
|
|
53
|
+
type MouseEvent,
|
|
41
54
|
type ReactNode,
|
|
42
55
|
useCallback,
|
|
43
56
|
useContext,
|
|
57
|
+
useEffect,
|
|
44
58
|
useId,
|
|
45
59
|
useMemo,
|
|
46
60
|
useRef,
|
|
47
61
|
useState,
|
|
62
|
+
useSyncExternalStore,
|
|
48
63
|
} from "react";
|
|
64
|
+
import { createPortal } from "react-dom";
|
|
49
65
|
import { toast } from "sonner";
|
|
50
66
|
|
|
51
67
|
export type SortableKind = "group" | "item";
|
|
@@ -56,37 +72,107 @@ const dndId = (kind: SortableKind | "zone", id: string) =>
|
|
|
56
72
|
const kindOf = (id: UniqueIdentifier) => String(id).slice(0, 1);
|
|
57
73
|
const rawOf = (id: UniqueIdentifier) => String(id).slice(2);
|
|
58
74
|
|
|
59
|
-
/**
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
75
|
+
/** จังหวะเดียวกันทั้งแถวที่เลื่อนหลบและตอนปล่อย — ease-out สั้น ๆ แบบบอร์ดงาน (Trello/Todoist) */
|
|
76
|
+
const EASING = "cubic-bezier(0.25, 1, 0.5, 1)";
|
|
77
|
+
const SORT_TRANSITION = { duration: 200, easing: EASING };
|
|
78
|
+
const DROP_ANIMATION: DropAnimation = {
|
|
79
|
+
duration: 200,
|
|
80
|
+
easing: EASING,
|
|
81
|
+
// ช่องจาง ๆ ของแถวต้นทางเราใส่ผ่าน style ของ React เองแล้ว — side effect ของ dnd-kit จะจำค่า 0.4 นั้น
|
|
82
|
+
// ไว้เป็น "ค่าเดิม" แล้วเขียนคืนหลังปล่อย ทำให้แถวจางค้าง (เจอกับหัวกลุ่ม)
|
|
83
|
+
sideEffects: null,
|
|
68
84
|
};
|
|
69
85
|
|
|
86
|
+
/** สิ่งที่กำลังลาก — `ids` = ทุกรายการที่ลากไปด้วยกัน (ลากแบบ bulk; ลากตัวเดียว = `[id]`) */
|
|
87
|
+
export interface SortableDrag {
|
|
88
|
+
kind: SortableKind;
|
|
89
|
+
id: string;
|
|
90
|
+
ids: string[];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
type SelectMode = "toggle" | "range" | "all" | "clear";
|
|
94
|
+
|
|
95
|
+
/** ระยะเลื่อนของหัวกลุ่มแต่ละกลุ่มระหว่างลากกลุ่ม — แถวรายการในกลุ่มอ่านไปเลื่อนตาม (ทั้งก้อนขยับพร้อมกัน) */
|
|
96
|
+
interface GroupShift {
|
|
97
|
+
transform: string | undefined;
|
|
98
|
+
transition: string | undefined;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function createShiftStore() {
|
|
102
|
+
const shifts = new Map<string, GroupShift>();
|
|
103
|
+
const listeners = new Set<() => void>();
|
|
104
|
+
return {
|
|
105
|
+
get: (id: string) => shifts.get(id),
|
|
106
|
+
set(id: string, shift: GroupShift | undefined) {
|
|
107
|
+
const prev = shifts.get(id);
|
|
108
|
+
if (prev?.transform === shift?.transform && prev?.transition === shift?.transition) return;
|
|
109
|
+
if (shift) shifts.set(id, shift);
|
|
110
|
+
else shifts.delete(id);
|
|
111
|
+
for (const listener of listeners) listener();
|
|
112
|
+
},
|
|
113
|
+
subscribe(listener: () => void) {
|
|
114
|
+
listeners.add(listener);
|
|
115
|
+
return () => {
|
|
116
|
+
listeners.delete(listener);
|
|
117
|
+
};
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
type ShiftStore = ReturnType<typeof createShiftStore>;
|
|
122
|
+
|
|
70
123
|
interface SortableState {
|
|
71
124
|
/** กำลังลากชนิดไหนอยู่ (null = ไม่ได้ลาก) — ใช้ปิดที่วางของอีกชนิด */
|
|
72
125
|
dragging: SortableKind | null;
|
|
73
126
|
disabled: boolean;
|
|
74
127
|
handleLabel: string;
|
|
128
|
+
/** มี `renderOverlay` = แถวต้นทางเป็นที่ว่างจาง ๆ ส่วนของที่ลอยตามเมาส์คือ overlay */
|
|
129
|
+
overlay: boolean;
|
|
130
|
+
/** กลุ่มที่กำลังถูกลาก (แถวรายการของกลุ่มนี้จางตามหัวกลุ่ม) */
|
|
131
|
+
activeGroup: string | null;
|
|
132
|
+
/** รายการอื่นที่ลากไปพร้อมกันแบบ bulk (ไม่รวมตัวที่จับ) — แสดงจาง ๆ ระหว่างลาก */
|
|
133
|
+
carried: ReadonlySet<string>;
|
|
134
|
+
multiSelect: boolean;
|
|
135
|
+
selected: ReadonlySet<string>;
|
|
136
|
+
select: (id: string, mode: SelectMode) => void;
|
|
137
|
+
groupOf: (itemId: string) => string | undefined;
|
|
138
|
+
register: (kind: SortableKind, id: string, node: HTMLElement | null) => void;
|
|
139
|
+
shifts: ShiftStore;
|
|
75
140
|
}
|
|
76
141
|
|
|
142
|
+
const EMPTY = new Set<string>();
|
|
77
143
|
const SortableStateContext = createContext<SortableState>({
|
|
78
144
|
dragging: null,
|
|
79
145
|
disabled: true,
|
|
80
146
|
handleLabel: "ลากเพื่อจัดลำดับ",
|
|
147
|
+
overlay: false,
|
|
148
|
+
activeGroup: null,
|
|
149
|
+
carried: EMPTY,
|
|
150
|
+
multiSelect: false,
|
|
151
|
+
selected: EMPTY,
|
|
152
|
+
select: () => {},
|
|
153
|
+
groupOf: () => undefined,
|
|
154
|
+
register: () => {},
|
|
155
|
+
shifts: createShiftStore(),
|
|
81
156
|
});
|
|
82
157
|
|
|
83
158
|
/**
|
|
84
159
|
* สถานะการลากตอนนี้ — ใช้ตัดสินใจเรนเดอร์ที่วางเอง เช่นแถว "ลากมาที่นี่เพื่อเอาออกจากกลุ่ม"
|
|
85
160
|
* ที่ควรโผล่เฉพาะตอน `dragging === "item"` (กลุ่มที่ไม่มีหัวกลุ่มต้องมีที่วางของตัวเอง)
|
|
161
|
+
* `selected`/`clearSelection` ใช้ทำแถบ "เลือก N รายการ" เมื่อเปิด `multiSelect`
|
|
86
162
|
*/
|
|
87
|
-
export function useSortableState(): {
|
|
163
|
+
export function useSortableState(): {
|
|
164
|
+
dragging: SortableKind | null;
|
|
165
|
+
disabled: boolean;
|
|
166
|
+
selected: string[];
|
|
167
|
+
clearSelection: () => void;
|
|
168
|
+
} {
|
|
88
169
|
const state = useContext(SortableStateContext);
|
|
89
|
-
return {
|
|
170
|
+
return {
|
|
171
|
+
dragging: state.dragging,
|
|
172
|
+
disabled: state.disabled,
|
|
173
|
+
selected: [...state.selected],
|
|
174
|
+
clearSelection: () => state.select("", "clear"),
|
|
175
|
+
};
|
|
90
176
|
}
|
|
91
177
|
|
|
92
178
|
/** ข้อมูลของสิ่งที่กำลังลาก ส่งให้แอปประกอบข้อความเอง (แอปรู้ชื่อคีย์/ชื่อกลุ่ม แพ็กเกจไม่รู้) */
|
|
@@ -98,6 +184,8 @@ export interface SortableAnnouncementContext {
|
|
|
98
184
|
/** ตำแหน่งในกลุ่ม/ในลำดับกลุ่ม เริ่มที่ 1 */
|
|
99
185
|
index: number;
|
|
100
186
|
total: number;
|
|
187
|
+
/** จำนวนรายการที่ลากไปด้วยกัน (ลากแบบ bulk > 1) */
|
|
188
|
+
count: number;
|
|
101
189
|
}
|
|
102
190
|
|
|
103
191
|
export interface SortableAnnouncements {
|
|
@@ -139,6 +227,22 @@ export interface SortableProviderProps {
|
|
|
139
227
|
* (เช่น `values-${environmentId}`) ช่วยตอนไล่ปัญหา
|
|
140
228
|
*/
|
|
141
229
|
id?: string;
|
|
230
|
+
/**
|
|
231
|
+
* สิ่งที่ลอยตามเมาส์ระหว่างลาก (แบบการ์ดใน Trello/Todoist) — ไม่ส่ง = ลากตัวแถวเองเหมือนเดิม
|
|
232
|
+
* ส่งมา = แถวต้นทางกลายเป็นช่องว่างจาง ๆ บอกตำแหน่งที่จะวาง และมีแอนิเมชันตอนปล่อย
|
|
233
|
+
* วาดใน portal ที่ `body` จึงคืน `<div>` ได้แม้รายการจริงเป็นแถว `<tr>` ของตาราง
|
|
234
|
+
*/
|
|
235
|
+
renderOverlay?: (drag: SortableDrag) => ReactNode;
|
|
236
|
+
/**
|
|
237
|
+
* เลือกหลายรายการแล้วลากไปพร้อมกัน: ⌘/Ctrl+คลิก = เลือกทีละตัว, Shift+คลิก = เลือกเป็นช่วง,
|
|
238
|
+
* คลิกเฉย ๆ = ล้าง · ที่จับ (คีย์บอร์ด): `x` = เลือก/ไม่เลือก, ⌘/Ctrl+A = ทั้งหมด, Esc = ล้าง
|
|
239
|
+
* แถวต้องกระจาย `selectProps` จาก `useSortableRow` (มี `data-selected` ไว้ทำสีแถวที่เลือก)
|
|
240
|
+
* ลากตัวที่เลือกอยู่ = ลากทุกตัวที่เลือกไปรวมที่จุดวาง เรียงตามลำดับเดิม
|
|
241
|
+
*/
|
|
242
|
+
multiSelect?: boolean;
|
|
243
|
+
/** รายการที่เลือก (ควบคุมจากภายนอก) — ไม่ส่ง = provider เก็บเอง */
|
|
244
|
+
selected?: string[];
|
|
245
|
+
onSelectedChange?: (ids: string[]) => void;
|
|
142
246
|
children: ReactNode;
|
|
143
247
|
}
|
|
144
248
|
|
|
@@ -161,16 +265,220 @@ export function SortableProvider({
|
|
|
161
265
|
screenReaderInstructions,
|
|
162
266
|
announcements,
|
|
163
267
|
id,
|
|
268
|
+
renderOverlay,
|
|
269
|
+
multiSelect = false,
|
|
270
|
+
selected: selectedProp,
|
|
271
|
+
onSelectedChange,
|
|
164
272
|
children,
|
|
165
273
|
}: SortableProviderProps) {
|
|
166
274
|
const fallbackId = useId();
|
|
167
275
|
const text = useUiText();
|
|
276
|
+
const mounted = useMounted();
|
|
168
277
|
const [dragging, setDragging] = useState<SortableKind | null>(null);
|
|
278
|
+
const [activeDrag, setActiveDrag] = useState<SortableDrag | null>(null);
|
|
279
|
+
const draggingRef = useRef<SortableKind | null>(null);
|
|
280
|
+
// รายการที่ลากไปด้วยกันแบบ bulk (รวมตัวที่จับ เรียงตามลำดับที่แสดง) — null = ลากตัวเดียว
|
|
281
|
+
const bulk = useRef<string[] | null>(null);
|
|
282
|
+
const nodes = useRef({
|
|
283
|
+
groups: new Map<string, HTMLElement>(),
|
|
284
|
+
items: new Map<string, HTMLElement>(),
|
|
285
|
+
groupOfNode: new WeakMap<HTMLElement, string>(),
|
|
286
|
+
});
|
|
287
|
+
const shifts = useMemo(createShiftStore, []);
|
|
288
|
+
// กันกระพริบตอนย้ายข้ามกลุ่ม: จำเป้าหมายล่าสุดไว้ใช้ตอนที่ layout กำลังขยับแล้วหาเป้าหมายไม่เจอ
|
|
289
|
+
const lastOverId = useRef<UniqueIdentifier | null>(null);
|
|
290
|
+
const movedAcross = useRef(false);
|
|
169
291
|
const beforeDrag = useRef<SortableOrder | null>(null);
|
|
170
292
|
// อ่านลำดับล่าสุดตอนปล่อย (onDragOver เปลี่ยนไปหลายรอบแล้ว prop ยังไม่ทัน)
|
|
171
293
|
const latest = useRef(order);
|
|
172
294
|
latest.current = order;
|
|
173
295
|
|
|
296
|
+
// ---------- เลือกหลายรายการ ----------
|
|
297
|
+
const [ownSelected, setOwnSelected] = useState<string[]>([]);
|
|
298
|
+
const selectedList = selectedProp ?? ownSelected;
|
|
299
|
+
const selected = useMemo<ReadonlySet<string>>(() => {
|
|
300
|
+
if (!multiSelect || selectedList.length === 0) return EMPTY;
|
|
301
|
+
const present = new Set(flatItems(order));
|
|
302
|
+
return new Set(selectedList.filter((x) => present.has(x)));
|
|
303
|
+
}, [multiSelect, selectedList, order]);
|
|
304
|
+
const selectedRef = useRef(selected);
|
|
305
|
+
selectedRef.current = selected;
|
|
306
|
+
const anchor = useRef<string | null>(null);
|
|
307
|
+
const onSelectedChangeRef = useRef(onSelectedChange);
|
|
308
|
+
onSelectedChangeRef.current = onSelectedChange;
|
|
309
|
+
const controlled = selectedProp !== undefined;
|
|
310
|
+
|
|
311
|
+
const setSelected = useCallback(
|
|
312
|
+
(ids: string[]) => {
|
|
313
|
+
if (!controlled) setOwnSelected(ids);
|
|
314
|
+
onSelectedChangeRef.current?.(ids);
|
|
315
|
+
},
|
|
316
|
+
[controlled],
|
|
317
|
+
);
|
|
318
|
+
|
|
319
|
+
const select = useCallback(
|
|
320
|
+
(itemId: string, mode: SelectMode) => {
|
|
321
|
+
const all = flatItems(latest.current);
|
|
322
|
+
// เลือกเป็นช่วง/ทั้งหมด = เฉพาะแถวที่แสดงอยู่ (ไม่หยิบของในกลุ่มที่ย่อไว้โดยไม่รู้ตัว)
|
|
323
|
+
const visible = all.filter((x) => nodes.current.items.get(x)?.isConnected);
|
|
324
|
+
const current = selectedRef.current;
|
|
325
|
+
let next: Set<string>;
|
|
326
|
+
if (mode === "clear") next = new Set();
|
|
327
|
+
else if (mode === "all") next = new Set(visible);
|
|
328
|
+
else if (mode === "toggle") {
|
|
329
|
+
next = new Set(current);
|
|
330
|
+
if (next.has(itemId)) next.delete(itemId);
|
|
331
|
+
else next.add(itemId);
|
|
332
|
+
anchor.current = itemId;
|
|
333
|
+
} else {
|
|
334
|
+
const from = visible.indexOf(anchor.current ?? itemId);
|
|
335
|
+
const to = visible.indexOf(itemId);
|
|
336
|
+
next = new Set(current);
|
|
337
|
+
if (from >= 0 && to >= 0) {
|
|
338
|
+
for (const x of visible.slice(Math.min(from, to), Math.max(from, to) + 1)) next.add(x);
|
|
339
|
+
} else next.add(itemId);
|
|
340
|
+
if (anchor.current === null) anchor.current = itemId;
|
|
341
|
+
}
|
|
342
|
+
if (mode === "clear" || mode === "all") anchor.current = null;
|
|
343
|
+
if (next.size === current.size && [...next].every((x) => current.has(x))) return;
|
|
344
|
+
setSelected(all.filter((x) => next.has(x)));
|
|
345
|
+
},
|
|
346
|
+
[setSelected],
|
|
347
|
+
);
|
|
348
|
+
|
|
349
|
+
// Esc ที่ไหนก็ได้ = ล้างการเลือก (ระหว่างลาก Esc เป็นของการยกเลิกลาก)
|
|
350
|
+
const hasSelection = selected.size > 0;
|
|
351
|
+
useEffect(() => {
|
|
352
|
+
if (!hasSelection) return;
|
|
353
|
+
function onKey(event: globalThis.KeyboardEvent) {
|
|
354
|
+
if (event.key === "Escape" && draggingRef.current === null) select("", "clear");
|
|
355
|
+
}
|
|
356
|
+
document.addEventListener("keydown", onKey);
|
|
357
|
+
return () => document.removeEventListener("keydown", onKey);
|
|
358
|
+
}, [hasSelection, select]);
|
|
359
|
+
|
|
360
|
+
const register = useCallback((kind: SortableKind, rowId: string, node: HTMLElement | null) => {
|
|
361
|
+
const map = kind === "group" ? nodes.current.groups : nodes.current.items;
|
|
362
|
+
if (node) {
|
|
363
|
+
map.set(rowId, node);
|
|
364
|
+
if (kind === "group") nodes.current.groupOfNode.set(node, rowId);
|
|
365
|
+
} else map.delete(rowId);
|
|
366
|
+
}, []);
|
|
367
|
+
|
|
368
|
+
/** กรอบของทั้งก้อน: หัวกลุ่ม + แถวรายการที่แสดงอยู่ (ไม่สนใจ transform ระหว่างลาก) */
|
|
369
|
+
const blockRect = useCallback((groupId: string, header: HTMLElement) => {
|
|
370
|
+
let { top, bottom, left, right } = getClientRect(header, { ignoreTransform: true });
|
|
371
|
+
for (const itemId of latest.current.items[groupId] ?? []) {
|
|
372
|
+
const node = nodes.current.items.get(itemId);
|
|
373
|
+
if (!node?.isConnected) continue;
|
|
374
|
+
const r = getClientRect(node, { ignoreTransform: true });
|
|
375
|
+
top = Math.min(top, r.top);
|
|
376
|
+
bottom = Math.max(bottom, r.bottom);
|
|
377
|
+
left = Math.min(left, r.left);
|
|
378
|
+
right = Math.max(right, r.right);
|
|
379
|
+
}
|
|
380
|
+
return { top, bottom, left, right, width: right - left, height: bottom - top };
|
|
381
|
+
}, []);
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* ขนาดของที่วางตอนลากกลุ่ม = หัวกลุ่ม "รวมแถวรายการของกลุ่มนั้น" ทั้งก้อน
|
|
385
|
+
* (เดิมวัดแค่หัวกลุ่ม กลุ่มอื่นจึงเลื่อนหลบแค่ความสูงหัวกลุ่มเดียว แถวรายการไม่ขยับ)
|
|
386
|
+
*/
|
|
387
|
+
const measureDroppable = useCallback(
|
|
388
|
+
(element: HTMLElement) => {
|
|
389
|
+
const groupId =
|
|
390
|
+
draggingRef.current === "group" ? nodes.current.groupOfNode.get(element) : undefined;
|
|
391
|
+
return groupId
|
|
392
|
+
? blockRect(groupId, element)
|
|
393
|
+
: getClientRect(element, { ignoreTransform: true });
|
|
394
|
+
},
|
|
395
|
+
[blockRect],
|
|
396
|
+
);
|
|
397
|
+
|
|
398
|
+
// ---------- เลื่อนทั้งก้อนระหว่างลากกลุ่ม (แบบรายการ: กลุ่มอื่นหลบ ช่องว่างของกลุ่มที่ลากเลื่อนไปจุดวาง) ----------
|
|
399
|
+
const blocks = useRef(new Map<string, { top: number; bottom: number }>());
|
|
400
|
+
const [overGroup, setOverGroup] = useState<string | null>(null);
|
|
401
|
+
const overlayOn = Boolean(renderOverlay);
|
|
402
|
+
useEffect(() => {
|
|
403
|
+
const active = activeDrag?.kind === "group" ? activeDrag.id : null;
|
|
404
|
+
const groups = order.groups;
|
|
405
|
+
if (!active) {
|
|
406
|
+
for (const g of groups) shifts.set(g, undefined);
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
const a = groups.indexOf(active);
|
|
410
|
+
const o = overGroup ? groups.indexOf(overGroup) : a;
|
|
411
|
+
const at = (i: number) => blocks.current.get(groups[i] ?? "");
|
|
412
|
+
const self = at(a);
|
|
413
|
+
if (!self || o < 0) return;
|
|
414
|
+
// ระยะที่กลุ่มอื่นหลบ = จากหัวของกลุ่มที่ลากไปถึงหัวของกลุ่มถัดไป (รวมช่องไฟ)
|
|
415
|
+
const step = (at(a + 1)?.top ?? self.bottom) - self.top;
|
|
416
|
+
const transition = `transform ${SORT_TRANSITION.duration}ms ${EASING}`;
|
|
417
|
+
groups.forEach((g, i) => {
|
|
418
|
+
let dy = 0;
|
|
419
|
+
if (i === a) {
|
|
420
|
+
if (!overlayOn) return; // ไม่มี overlay = หัวกลุ่มลอยตามเมาส์ (onDragMove)
|
|
421
|
+
dy =
|
|
422
|
+
o > a
|
|
423
|
+
? (at(o)?.bottom ?? self.bottom) - self.bottom
|
|
424
|
+
: o < a
|
|
425
|
+
? (at(o)?.top ?? self.top) - self.top
|
|
426
|
+
: 0;
|
|
427
|
+
} else if (a < o && i > a && i <= o) dy = -step;
|
|
428
|
+
else if (o < a && i >= o && i < a) dy = step;
|
|
429
|
+
shifts.set(g, { transform: `translate3d(0px, ${dy}px, 0)`, transition });
|
|
430
|
+
});
|
|
431
|
+
}, [activeDrag, overGroup, order.groups, overlayOn, shifts]);
|
|
432
|
+
const measuring = useMemo(
|
|
433
|
+
() => ({ droppable: { strategy: MeasuringStrategy.Always, measure: measureDroppable } }),
|
|
434
|
+
[measureDroppable],
|
|
435
|
+
);
|
|
436
|
+
|
|
437
|
+
// ย้ายข้ามกลุ่มเสร็จแล้ว (render ลำดับใหม่ไปแล้ว) ค่อยปลดธงในเฟรมถัดไป
|
|
438
|
+
useEffect(() => {
|
|
439
|
+
const frame = requestAnimationFrame(() => {
|
|
440
|
+
movedAcross.current = false;
|
|
441
|
+
});
|
|
442
|
+
return () => cancelAnimationFrame(frame);
|
|
443
|
+
}, [order]);
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* เลือกเป้าหมายแบบบอร์ดงาน (สูตรเดียวกับตัวอย่าง multiple containers ของ dnd-kit):
|
|
447
|
+
* - ลากกลุ่ม → เทียบกับกลุ่มด้วยกันเท่านั้น
|
|
448
|
+
* - ลากรายการ → ใช้ "สิ่งที่อยู่ใต้เมาส์จริง" ก่อน (pointerWithin) ไม่ใช่ตัวที่ใกล้ที่สุดทั้งตาราง
|
|
449
|
+
* (เดิมใช้ closestCenter ทั้งตาราง ทำให้แถวรับวางท้ายตารางแย่งเป้าหมาย รายการกระโดดไปไกลจากเมาส์)
|
|
450
|
+
* - ชี้หัวกลุ่มที่เปิดอยู่ → เลือกรายการในกลุ่มที่ใกล้ที่สุดแทน (วางลงในกลุ่มตรงตำแหน่ง)
|
|
451
|
+
* - หาไม่เจอชั่วขณะตอน layout ขยับ → ใช้เป้าหมายล่าสุด ไม่ให้แถวสั่นไปมา
|
|
452
|
+
*/
|
|
453
|
+
const collisionDetection: CollisionDetection = useCallback((args) => {
|
|
454
|
+
if (kindOf(args.active.id) === "g") {
|
|
455
|
+
return closestCenter({
|
|
456
|
+
...args,
|
|
457
|
+
droppableContainers: args.droppableContainers.filter((c) => kindOf(c.id) === "g"),
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
const candidates = args.droppableContainers.filter((c) => kindOf(c.id) !== "g");
|
|
461
|
+
const scoped = { ...args, droppableContainers: candidates };
|
|
462
|
+
const underPointer = pointerWithin(scoped);
|
|
463
|
+
const hits = underPointer.length > 0 ? underPointer : rectIntersection(scoped);
|
|
464
|
+
let overId = getFirstCollision(hits, "id");
|
|
465
|
+
if (overId != null) {
|
|
466
|
+
if (kindOf(overId) === "z") {
|
|
467
|
+
const ids = new Set(latest.current.items[rawOf(overId)] ?? []);
|
|
468
|
+
const inGroup = candidates.filter(
|
|
469
|
+
(c) => kindOf(c.id) === "i" && c.id !== args.active.id && ids.has(rawOf(c.id)),
|
|
470
|
+
);
|
|
471
|
+
if (inGroup.length > 0) {
|
|
472
|
+
overId = closestCenter({ ...args, droppableContainers: inGroup })[0]?.id ?? overId;
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
lastOverId.current = overId;
|
|
476
|
+
return [{ id: overId }];
|
|
477
|
+
}
|
|
478
|
+
if (movedAcross.current) lastOverId.current = args.active.id;
|
|
479
|
+
return lastOverId.current != null ? [{ id: lastOverId.current }] : [];
|
|
480
|
+
}, []);
|
|
481
|
+
|
|
174
482
|
const sensors = useSensors(
|
|
175
483
|
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
|
176
484
|
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
|
|
@@ -183,22 +491,75 @@ export function SortableProvider({
|
|
|
183
491
|
|
|
184
492
|
function onDragStart({ active }: DragStartEvent) {
|
|
185
493
|
beforeDrag.current = latest.current;
|
|
186
|
-
|
|
494
|
+
lastOverId.current = null;
|
|
495
|
+
const kind: SortableKind = kindOf(active.id) === "g" ? "group" : "item";
|
|
496
|
+
const raw = rawOf(active.id);
|
|
497
|
+
const chosen = selectedRef.current;
|
|
498
|
+
bulk.current = null;
|
|
499
|
+
if (kind === "item" && chosen.has(raw) && chosen.size > 1) {
|
|
500
|
+
bulk.current = flatItems(latest.current).filter((x) => chosen.has(x));
|
|
501
|
+
} else if (kind === "item" && chosen.size > 0 && !chosen.has(raw)) {
|
|
502
|
+
// ลากตัวที่ไม่ได้เลือก = ลากตัวนั้นตัวเดียว และล้างการเลือกเดิม (แบบ Finder)
|
|
503
|
+
select("", "clear");
|
|
504
|
+
}
|
|
505
|
+
draggingRef.current = kind;
|
|
506
|
+
if (kind === "group") {
|
|
507
|
+
blocks.current.clear();
|
|
508
|
+
for (const [groupId, header] of nodes.current.groups) {
|
|
509
|
+
if (header.isConnected) blocks.current.set(groupId, blockRect(groupId, header));
|
|
510
|
+
}
|
|
511
|
+
setOverGroup(raw);
|
|
512
|
+
}
|
|
513
|
+
setDragging(kind);
|
|
514
|
+
setActiveDrag({ kind, id: raw, ids: bulk.current ?? [raw] });
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/** ไม่มี overlay: ก้อนของกลุ่มที่ลากลอยตามเมาส์ทั้งก้อน (หัวกลุ่ม + แถวรายการ) */
|
|
518
|
+
function onDragMove({ active, delta }: DragMoveEvent) {
|
|
519
|
+
if (overlayOn || kindOf(active.id) !== "g") return;
|
|
520
|
+
shifts.set(rawOf(active.id), {
|
|
521
|
+
transform: `translate3d(${delta.x}px, ${delta.y}px, 0)`,
|
|
522
|
+
transition: undefined,
|
|
523
|
+
});
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
function endDrag() {
|
|
527
|
+
draggingRef.current = null;
|
|
528
|
+
bulk.current = null;
|
|
529
|
+
setOverGroup(null);
|
|
530
|
+
// ล้างระยะเลื่อนพร้อมกับที่ลำดับใหม่ render (รอ effect = มีเฟรมที่แถวใหม่ยังติด transform เก่า → กระพริบ)
|
|
531
|
+
for (const g of nodes.current.groups.keys()) shifts.set(g, undefined);
|
|
532
|
+
setDragging(null);
|
|
533
|
+
setActiveDrag(null);
|
|
187
534
|
}
|
|
188
535
|
|
|
189
536
|
function onDragOver({ active, over }: DragOverEvent) {
|
|
537
|
+
if (over && kindOf(active.id) === "g" && kindOf(over.id) === "g") {
|
|
538
|
+
setOverGroup(rawOf(over.id));
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
190
541
|
if (!over || kindOf(active.id) !== "i") return;
|
|
191
542
|
const current = latest.current;
|
|
192
543
|
const from = containerOf(active.id, current);
|
|
193
544
|
const to = containerOf(over.id, current);
|
|
194
545
|
if (!from || !to || from === to) return;
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
546
|
+
// วางก่อนหรือหลังแถวเป้าหมาย ตามว่าตัวที่ลากเลยครึ่งแถวนั้นลงมาหรือยัง
|
|
547
|
+
// (เดิมแทรก "ก่อน" เสมอ — ลากเข้าจากด้านล่างแล้วรายการกระโดดข้ามขึ้นไป)
|
|
548
|
+
let before: string | null = null;
|
|
549
|
+
if (kindOf(over.id) === "i") {
|
|
550
|
+
const list = current.items[to] ?? [];
|
|
551
|
+
const at = list.indexOf(rawOf(over.id));
|
|
552
|
+
const dragged = active.rect.current.translated;
|
|
553
|
+
const below = dragged != null && dragged.top > over.rect.top + over.rect.height / 2;
|
|
554
|
+
before = list[at + (below ? 1 : 0)] ?? null;
|
|
555
|
+
} else if (to !== UNGROUPED) {
|
|
556
|
+
// วางบนหัวโฟลเดอร์ = ขึ้นบนสุดของโฟลเดอร์ (ตรงกับตำแหน่งที่เห็น และไม่กระโดดตอนโฟลเดอร์ที่ย่อไว้เปิดออก)
|
|
557
|
+
// ส่วนโซน "ไม่มีกลุ่ม" อยู่ท้ายตาราง จึงต่อท้าย
|
|
558
|
+
before = current.items[to]?.[0] ?? null;
|
|
559
|
+
}
|
|
560
|
+
const next = moveItemToGroup(current, rawOf(active.id), to, before);
|
|
201
561
|
if (next !== current) {
|
|
562
|
+
movedAcross.current = true;
|
|
202
563
|
latest.current = next;
|
|
203
564
|
onOrderChange(next);
|
|
204
565
|
}
|
|
@@ -227,8 +588,9 @@ export function SortableProvider({
|
|
|
227
588
|
|
|
228
589
|
function onDragEnd({ active, over }: DragEndEvent) {
|
|
229
590
|
const snapshot = beforeDrag.current;
|
|
591
|
+
const carried = bulk.current;
|
|
230
592
|
beforeDrag.current = null;
|
|
231
|
-
|
|
593
|
+
endDrag();
|
|
232
594
|
const current = latest.current;
|
|
233
595
|
let next = current;
|
|
234
596
|
if (over && kindOf(active.id) === "g" && kindOf(over.id) === "g") {
|
|
@@ -236,6 +598,8 @@ export function SortableProvider({
|
|
|
236
598
|
} else if (over && kindOf(active.id) === "i" && kindOf(over.id) === "i") {
|
|
237
599
|
next = reorderItems(current, rawOf(active.id), rawOf(over.id));
|
|
238
600
|
}
|
|
601
|
+
// bulk: ตัวที่เลือกไว้ตัวอื่นตามมารวมที่จุดวางของตัวที่จับ (เรียงตามลำดับเดิม)
|
|
602
|
+
if (carried) next = gatherItems(next, rawOf(active.id), carried);
|
|
239
603
|
if (next !== current) {
|
|
240
604
|
latest.current = next;
|
|
241
605
|
onOrderChange(next);
|
|
@@ -246,7 +610,7 @@ export function SortableProvider({
|
|
|
246
610
|
function onDragCancel() {
|
|
247
611
|
const snapshot = beforeDrag.current;
|
|
248
612
|
beforeDrag.current = null;
|
|
249
|
-
|
|
613
|
+
endDrag();
|
|
250
614
|
if (snapshot) {
|
|
251
615
|
latest.current = snapshot;
|
|
252
616
|
onOrderChange(snapshot);
|
|
@@ -268,6 +632,7 @@ export function SortableProvider({
|
|
|
268
632
|
kind: "group",
|
|
269
633
|
index: current.groups.indexOf(raw) + 1,
|
|
270
634
|
total: current.groups.length,
|
|
635
|
+
count: 1,
|
|
271
636
|
};
|
|
272
637
|
}
|
|
273
638
|
const group = groupOfItem(current, raw);
|
|
@@ -278,6 +643,7 @@ export function SortableProvider({
|
|
|
278
643
|
groupId: group,
|
|
279
644
|
index: list.indexOf(raw) + 1,
|
|
280
645
|
total: list.length,
|
|
646
|
+
count: bulk.current?.length ?? 1,
|
|
281
647
|
};
|
|
282
648
|
}, []);
|
|
283
649
|
|
|
@@ -314,12 +680,15 @@ export function SortableProvider({
|
|
|
314
680
|
) =>
|
|
315
681
|
(template ?? fallback)
|
|
316
682
|
.replace("{index}", String(ctx.index))
|
|
317
|
-
.replace("{total}", String(ctx.total))
|
|
683
|
+
.replace("{total}", String(ctx.total))
|
|
684
|
+
.replace("{count}", String(ctx.count));
|
|
318
685
|
const announce =
|
|
319
686
|
(
|
|
320
687
|
own: ((context: SortableAnnouncementContext) => string | undefined) | undefined,
|
|
321
688
|
template: string | undefined,
|
|
322
689
|
fallback: string,
|
|
690
|
+
// ประโยคแทนเมื่อลากหลายรายการพร้อมกัน
|
|
691
|
+
many: { template: string | undefined; fallback: string } | null,
|
|
323
692
|
// ระหว่างลาก = ตำแหน่งของเป้าหมาย; ตอนปล่อย order อัปเดตแล้ว จึงใช้ตำแหน่งจริงของตัวที่ลาก
|
|
324
693
|
useTarget = false,
|
|
325
694
|
) =>
|
|
@@ -333,38 +702,84 @@ export function SortableProvider({
|
|
|
333
702
|
const context = useTarget
|
|
334
703
|
? describeTarget(active.id, over?.id ?? null)
|
|
335
704
|
: describe(active.id);
|
|
336
|
-
|
|
705
|
+
if (own) {
|
|
706
|
+
const said = own(context);
|
|
707
|
+
if (said !== undefined) return said;
|
|
708
|
+
}
|
|
709
|
+
return many && context.count > 1
|
|
710
|
+
? fill(many.template, many.fallback, context)
|
|
711
|
+
: fill(template, fallback, context);
|
|
337
712
|
};
|
|
338
713
|
return {
|
|
339
714
|
onDragStart: announce(
|
|
340
715
|
announcements?.onDragStart,
|
|
341
716
|
text.sortable?.started,
|
|
342
717
|
"เริ่มลากรายการที่ {index} จาก {total}",
|
|
718
|
+
{ template: text.sortable?.startedMany, fallback: "เริ่มลาก {count} รายการที่เลือกไว้" },
|
|
343
719
|
),
|
|
344
720
|
onDragOver: announce(
|
|
345
721
|
announcements?.onDragOver,
|
|
346
722
|
text.sortable?.moved,
|
|
347
723
|
"ย้ายไปตำแหน่งที่ {index} จาก {total}",
|
|
724
|
+
null,
|
|
348
725
|
true,
|
|
349
726
|
),
|
|
350
727
|
onDragEnd: announce(
|
|
351
728
|
announcements?.onDragEnd,
|
|
352
729
|
text.sortable?.dropped,
|
|
353
730
|
"วางที่ตำแหน่งที่ {index} จาก {total}",
|
|
731
|
+
null,
|
|
732
|
+
),
|
|
733
|
+
onDragCancel: announce(
|
|
734
|
+
announcements?.onDragCancel,
|
|
735
|
+
text.sortable?.cancelled,
|
|
736
|
+
"ยกเลิกการลาก",
|
|
737
|
+
null,
|
|
354
738
|
),
|
|
355
|
-
onDragCancel: announce(announcements?.onDragCancel, text.sortable?.cancelled, "ยกเลิกการลาก"),
|
|
356
739
|
};
|
|
357
740
|
}, [announcements, describeTarget, text.sortable]);
|
|
358
741
|
|
|
359
742
|
const groupIds = useMemo(() => order.groups.map((id) => dndId("group", id)), [order.groups]);
|
|
360
743
|
|
|
744
|
+
const groupOf = useCallback((itemId: string) => groupOfItem(order, itemId), [order]);
|
|
745
|
+
const carried = useMemo<ReadonlySet<string>>(
|
|
746
|
+
() =>
|
|
747
|
+
activeDrag && activeDrag.ids.length > 1
|
|
748
|
+
? new Set(activeDrag.ids.filter((x) => x !== activeDrag.id))
|
|
749
|
+
: EMPTY,
|
|
750
|
+
[activeDrag],
|
|
751
|
+
);
|
|
752
|
+
|
|
361
753
|
const state = useMemo<SortableState>(
|
|
362
754
|
() => ({
|
|
363
755
|
dragging,
|
|
364
756
|
disabled,
|
|
365
757
|
handleLabel: handleLabel ?? text.sortable?.handle ?? "ลากเพื่อจัดลำดับ",
|
|
758
|
+
overlay: Boolean(renderOverlay),
|
|
759
|
+
activeGroup: activeDrag?.kind === "group" ? activeDrag.id : null,
|
|
760
|
+
carried,
|
|
761
|
+
multiSelect,
|
|
762
|
+
selected,
|
|
763
|
+
select,
|
|
764
|
+
groupOf,
|
|
765
|
+
register,
|
|
766
|
+
shifts,
|
|
366
767
|
}),
|
|
367
|
-
[
|
|
768
|
+
[
|
|
769
|
+
dragging,
|
|
770
|
+
disabled,
|
|
771
|
+
handleLabel,
|
|
772
|
+
text.sortable?.handle,
|
|
773
|
+
renderOverlay,
|
|
774
|
+
activeDrag,
|
|
775
|
+
carried,
|
|
776
|
+
multiSelect,
|
|
777
|
+
selected,
|
|
778
|
+
select,
|
|
779
|
+
groupOf,
|
|
780
|
+
register,
|
|
781
|
+
shifts,
|
|
782
|
+
],
|
|
368
783
|
);
|
|
369
784
|
|
|
370
785
|
return (
|
|
@@ -372,13 +787,17 @@ export function SortableProvider({
|
|
|
372
787
|
<DndContext
|
|
373
788
|
id={id ?? fallbackId}
|
|
374
789
|
sensors={sensors}
|
|
375
|
-
collisionDetection={
|
|
790
|
+
collisionDetection={collisionDetection}
|
|
791
|
+
// วัดตำแหน่งที่วางใหม่ตลอดระหว่างลาก — ย้ายข้ามกลุ่มแล้ว layout เปลี่ยน ต้องใช้ตำแหน่งปัจจุบัน
|
|
792
|
+
// (ตอนลากกลุ่ม วัดหัวกลุ่มรวมแถวรายการทั้งก้อน — ดู measureDroppable)
|
|
793
|
+
measuring={measuring}
|
|
376
794
|
accessibility={{
|
|
377
795
|
screenReaderInstructions: { draggable: instructions },
|
|
378
796
|
announcements: dndAnnouncements,
|
|
379
797
|
}}
|
|
380
798
|
onDragStart={onDragStart}
|
|
381
799
|
onDragOver={onDragOver}
|
|
800
|
+
onDragMove={onDragMove}
|
|
382
801
|
onDragEnd={onDragEnd}
|
|
383
802
|
onDragCancel={onDragCancel}
|
|
384
803
|
>
|
|
@@ -387,6 +806,16 @@ export function SortableProvider({
|
|
|
387
806
|
<SortableContext items={groupIds} strategy={verticalListSortingStrategy}>
|
|
388
807
|
{children}
|
|
389
808
|
</SortableContext>
|
|
809
|
+
{renderOverlay &&
|
|
810
|
+
mounted &&
|
|
811
|
+
createPortal(
|
|
812
|
+
<DragOverlay dropAnimation={DROP_ANIMATION}>
|
|
813
|
+
{activeDrag ? (
|
|
814
|
+
<div className="cursor-grabbing">{renderOverlay(activeDrag)}</div>
|
|
815
|
+
) : null}
|
|
816
|
+
</DragOverlay>,
|
|
817
|
+
document.body,
|
|
818
|
+
)}
|
|
390
819
|
</DndContext>
|
|
391
820
|
</SortableStateContext.Provider>
|
|
392
821
|
);
|
|
@@ -415,8 +844,18 @@ export interface SortableRow {
|
|
|
415
844
|
/** props ที่ต้องแปะที่ "ที่จับ" — ส่งต่อให้ `<DragHandle {...handleProps} />` */
|
|
416
845
|
handleProps: Record<string, unknown>;
|
|
417
846
|
isDragging: boolean;
|
|
847
|
+
/** รายการนี้ถูกเลือกอยู่ (`multiSelect`) */
|
|
848
|
+
selected: boolean;
|
|
849
|
+
/**
|
|
850
|
+
* กระจายลงแถวเมื่อเปิด `multiSelect`: คลิกพร้อม ⌘/Ctrl/Shift เพื่อเลือก + `data-selected`
|
|
851
|
+
* สำหรับทำสี (`data-[selected]:bg-primary/10`) — ปิด `multiSelect` หรือเป็นแถวกลุ่ม = object ว่าง
|
|
852
|
+
*/
|
|
853
|
+
selectProps: Record<string, unknown>;
|
|
418
854
|
}
|
|
419
855
|
|
|
856
|
+
const INTERACTIVE =
|
|
857
|
+
"a,button,input,select,textarea,label,[contenteditable=''],[contenteditable='true'],[role=button],[role=checkbox],[role=switch],[role=combobox]";
|
|
858
|
+
|
|
420
859
|
/**
|
|
421
860
|
* ทำให้แถวหนึ่งลากได้ — แอปเอา `ref`/`style` ไปแปะที่ `<TableRow>` และ `handleProps` ที่ `<DragHandle>`
|
|
422
861
|
* `type: "group"` = ลากทั้งกลุ่ม, `"item"` = ลากรายการในกลุ่ม
|
|
@@ -435,16 +874,107 @@ export function useSortableRow({
|
|
|
435
874
|
const sortable = useSortable({
|
|
436
875
|
id: dndId(type, id),
|
|
437
876
|
disabled: disabled || state.disabled,
|
|
877
|
+
transition: SORT_TRANSITION,
|
|
438
878
|
});
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
879
|
+
const { register, shifts, select } = state;
|
|
880
|
+
const { setNodeRef } = sortable;
|
|
881
|
+
const ref = useCallback(
|
|
882
|
+
(node: HTMLElement | null) => {
|
|
883
|
+
setNodeRef(node);
|
|
884
|
+
register(type, id, node);
|
|
445
885
|
},
|
|
446
|
-
|
|
886
|
+
[setNodeRef, register, type, id],
|
|
887
|
+
);
|
|
888
|
+
|
|
889
|
+
const transform = CSS.Translate.toString(sortable.transform);
|
|
890
|
+
const groupDrag = state.dragging === "group";
|
|
891
|
+
// ระหว่างลากกลุ่ม provider คำนวณระยะเลื่อนของแต่ละกลุ่มเอง — หัวกลุ่มและแถวรายการของกลุ่มอ่านค่าเดียวกัน
|
|
892
|
+
// (หัวกลุ่มมักอยู่ใน `SortableGroup` ซึ่งเป็น context ของรายการ dnd-kit จึงคำนวณให้หัวกลุ่มไม่ได้)
|
|
893
|
+
const groupId = type === "item" ? state.groupOf(id) : id;
|
|
894
|
+
const shift = useSyncExternalStore(
|
|
895
|
+
shifts.subscribe,
|
|
896
|
+
() => (groupId ? shifts.get(groupId) : undefined),
|
|
897
|
+
() => undefined,
|
|
898
|
+
);
|
|
899
|
+
|
|
900
|
+
let style: CSSProperties = { transform, transition: sortable.transition };
|
|
901
|
+
if (type === "group" && groupDrag) {
|
|
902
|
+
const own = state.activeGroup === id;
|
|
903
|
+
style = {
|
|
904
|
+
transform: shift?.transform,
|
|
905
|
+
transition: shift?.transition,
|
|
906
|
+
...(own
|
|
907
|
+
? state.overlay
|
|
908
|
+
? { opacity: 0.4 }
|
|
909
|
+
: { position: "relative", zIndex: 1, opacity: 0.85 }
|
|
910
|
+
: {}),
|
|
911
|
+
};
|
|
912
|
+
} else if (sortable.isDragging) {
|
|
913
|
+
style = state.overlay
|
|
914
|
+
? // มี overlay ลอยตามเมาส์แล้ว แถวต้นทางเหลือเป็นช่องจาง ๆ บอกตำแหน่งที่จะวาง
|
|
915
|
+
{ ...style, opacity: 0.4 }
|
|
916
|
+
: { ...style, position: "relative", zIndex: 1, opacity: 0.85 };
|
|
917
|
+
} else if (type === "item" && groupDrag && shift) {
|
|
918
|
+
const own = state.activeGroup !== null && state.activeGroup === groupId;
|
|
919
|
+
style = {
|
|
920
|
+
transform: shift.transform,
|
|
921
|
+
transition: shift.transition,
|
|
922
|
+
...(own
|
|
923
|
+
? state.overlay
|
|
924
|
+
? { opacity: 0.4 }
|
|
925
|
+
: { position: "relative", zIndex: 1, opacity: 0.85 }
|
|
926
|
+
: {}),
|
|
927
|
+
};
|
|
928
|
+
} else if (type === "item" && state.carried.has(id)) {
|
|
929
|
+
// ลากแบบ bulk: ตัวที่ตามไปด้วยจางลงตรงที่เดิม แล้วไปรวมที่จุดวางตอนปล่อย
|
|
930
|
+
style = { ...style, opacity: 0.4 };
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
const selectable = type === "item" && state.multiSelect;
|
|
934
|
+
const isSelected = selectable && state.selected.has(id);
|
|
935
|
+
const listeners = sortable.listeners;
|
|
936
|
+
const handleProps: Record<string, unknown> = { ...sortable.attributes, ...listeners };
|
|
937
|
+
if (selectable) {
|
|
938
|
+
handleProps.onKeyDown = (event: KeyboardEvent<HTMLElement>) => {
|
|
939
|
+
const mod = event.metaKey || event.ctrlKey;
|
|
940
|
+
if (!mod && !event.altKey && event.key.toLowerCase() === "x") {
|
|
941
|
+
event.preventDefault();
|
|
942
|
+
select(id, "toggle");
|
|
943
|
+
return;
|
|
944
|
+
}
|
|
945
|
+
if (mod && event.key.toLowerCase() === "a") {
|
|
946
|
+
event.preventDefault();
|
|
947
|
+
select(id, "all");
|
|
948
|
+
return;
|
|
949
|
+
}
|
|
950
|
+
listeners?.onKeyDown?.(event);
|
|
951
|
+
};
|
|
952
|
+
}
|
|
953
|
+
const selectProps: Record<string, unknown> = selectable
|
|
954
|
+
? {
|
|
955
|
+
"data-selected": isSelected ? "" : undefined,
|
|
956
|
+
onMouseDown: (event: MouseEvent<HTMLElement>) => {
|
|
957
|
+
// Shift+คลิก ไม่ให้เบราว์เซอร์ลากคลุมข้อความทั้งแถว
|
|
958
|
+
if (event.shiftKey) event.preventDefault();
|
|
959
|
+
},
|
|
960
|
+
onClick: (event: MouseEvent<HTMLElement>) => {
|
|
961
|
+
const target = event.target as Element;
|
|
962
|
+
const control = target.closest(INTERACTIVE);
|
|
963
|
+
if (control && !control.closest("[data-sortable-handle]")) return;
|
|
964
|
+
if (event.metaKey || event.ctrlKey) select(id, "toggle");
|
|
965
|
+
else if (event.shiftKey) select(id, "range");
|
|
966
|
+
else if (state.selected.size > 0) select(id, "clear");
|
|
967
|
+
},
|
|
968
|
+
}
|
|
969
|
+
: {};
|
|
970
|
+
|
|
971
|
+
return {
|
|
972
|
+
ref,
|
|
973
|
+
style,
|
|
974
|
+
handleProps,
|
|
447
975
|
isDragging: sortable.isDragging,
|
|
976
|
+
selected: isSelected,
|
|
977
|
+
selectProps,
|
|
448
978
|
};
|
|
449
979
|
}
|
|
450
980
|
|
|
@@ -452,7 +982,18 @@ export function useSortableRow({
|
|
|
452
982
|
* ทำให้หัวกลุ่ม (หรือพื้นที่ว่างของกลุ่ม) รับการวางรายการได้ — กลุ่มที่ย่ออยู่ก็ยังรับได้
|
|
453
983
|
* ปิดเองเมื่อกำลังลากกลุ่ม (ไม่ใช่รายการ)
|
|
454
984
|
*/
|
|
455
|
-
export function useSortableDropZone(
|
|
985
|
+
export function useSortableDropZone(
|
|
986
|
+
groupId: string | null,
|
|
987
|
+
options?: {
|
|
988
|
+
/**
|
|
989
|
+
* ลากรายการค้างไว้เหนือที่วางนี้นานพอ — ใช้เปิดกลุ่มที่ย่ออยู่ให้เองแบบโฟลเดอร์ใน Finder/Todoist
|
|
990
|
+
* (ไม่ส่ง = ไม่ทำอะไร)
|
|
991
|
+
*/
|
|
992
|
+
onDwell?: () => void;
|
|
993
|
+
/** นานเท่าไรถึงนับว่าค้าง (ms) — ค่าเริ่มต้น 600 */
|
|
994
|
+
dwellMs?: number;
|
|
995
|
+
},
|
|
996
|
+
): {
|
|
456
997
|
ref: (node: HTMLElement | null) => void;
|
|
457
998
|
isOver: boolean;
|
|
458
999
|
} {
|
|
@@ -462,6 +1003,17 @@ export function useSortableDropZone(groupId: string | null): {
|
|
|
462
1003
|
id: dndId("zone", groupId ?? UNGROUPED),
|
|
463
1004
|
disabled: state.disabled || state.dragging !== "item",
|
|
464
1005
|
});
|
|
1006
|
+
const onDwell = options?.onDwell;
|
|
1007
|
+
const dwellMs = options?.dwellMs ?? 600;
|
|
1008
|
+
const hovering = droppable.isOver && state.dragging === "item";
|
|
1009
|
+
// เก็บ callback ล่าสุดไว้ใน ref — แอปส่งฟังก์ชันใหม่ทุก render ก็ไม่ทำให้นับเวลาใหม่
|
|
1010
|
+
const dwellRef = useRef(onDwell);
|
|
1011
|
+
dwellRef.current = onDwell;
|
|
1012
|
+
useEffect(() => {
|
|
1013
|
+
if (!hovering || !dwellRef.current) return;
|
|
1014
|
+
const timer = setTimeout(() => dwellRef.current?.(), dwellMs);
|
|
1015
|
+
return () => clearTimeout(timer);
|
|
1016
|
+
}, [hovering, dwellMs]);
|
|
465
1017
|
return { ref: droppable.setNodeRef, isOver: droppable.isOver };
|
|
466
1018
|
}
|
|
467
1019
|
|
|
@@ -492,6 +1044,7 @@ export function DragHandle({
|
|
|
492
1044
|
aria-label={label ?? state.handleLabel}
|
|
493
1045
|
disabled={off}
|
|
494
1046
|
className={cn("cursor-grab touch-none active:cursor-grabbing", className)}
|
|
1047
|
+
data-sortable-handle=""
|
|
495
1048
|
{...handleProps}
|
|
496
1049
|
>
|
|
497
1050
|
<GripVerticalIcon />
|