@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.
@@ -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
- /** ลากกลุ่มชนได้แค่กลุ่ม ลากรายการชนได้แค่รายการกับหัวกลุ่ม — ไม่งั้น closestCenter สลับชนิดกันมั่ว */
60
- const collision: CollisionDetection = (args) => {
61
- const draggingGroup = kindOf(args.active.id) === "g";
62
- return closestCenter({
63
- ...args,
64
- droppableContainers: args.droppableContainers.filter(
65
- (container) => (kindOf(container.id) === "g") === draggingGroup,
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(): { dragging: SortableKind | null; disabled: boolean } {
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 { dragging: state.dragging, disabled: state.disabled };
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
- setDragging(kindOf(active.id) === "g" ? "group" : "item");
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
- const next = moveItemToGroup(
196
- current,
197
- rawOf(active.id),
198
- to,
199
- kindOf(over.id) === "i" ? rawOf(over.id) : null,
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
- setDragging(null);
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
- setDragging(null);
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
- return own?.(context) ?? fill(template, fallback, context);
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
- [dragging, disabled, handleLabel, text.sortable?.handle],
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={collision}
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
- return {
440
- ref: sortable.setNodeRef,
441
- style: {
442
- transform: CSS.Translate.toString(sortable.transform),
443
- transition: sortable.transition,
444
- ...(sortable.isDragging ? { position: "relative", zIndex: 1, opacity: 0.85 } : {}),
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
- handleProps: { ...sortable.attributes, ...sortable.listeners },
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(groupId: string | null): {
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 />