@suphark/ui 0.37.0 → 0.39.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,11 +5,12 @@ import {
5
5
  closestCenter,
6
6
  DndContext,
7
7
  type DragEndEvent,
8
+ type DragMoveEvent,
8
9
  type DragOverEvent,
9
10
  DragOverlay,
10
11
  type DragStartEvent,
11
12
  type DropAnimation,
12
- defaultDropAnimationSideEffects,
13
+ getClientRect,
13
14
  getFirstCollision,
14
15
  KeyboardSensor,
15
16
  MeasuringStrategy,
@@ -33,10 +34,14 @@ import { useUiText } from "@suphark/ui/components/shared/ui-text";
33
34
  import { useMounted } from "@suphark/ui/hooks/use-mounted";
34
35
  import { errorMessage } from "@suphark/ui/lib/action-result";
35
36
  import {
37
+ flatItems,
38
+ gatherItems,
36
39
  groupOfItem,
37
40
  moveItemToGroup,
38
- reorderGroups,
41
+ moveItemToRoot,
42
+ moveRootEntry,
39
43
  reorderItems,
44
+ rootEntries,
40
45
  type SortableOrder,
41
46
  sameOrder,
42
47
  UNGROUPED,
@@ -46,6 +51,8 @@ import { GripVerticalIcon } from "lucide-react";
46
51
  import {
47
52
  type CSSProperties,
48
53
  createContext,
54
+ type KeyboardEvent,
55
+ type MouseEvent,
49
56
  type ReactNode,
50
57
  useCallback,
51
58
  useContext,
@@ -54,6 +61,7 @@ import {
54
61
  useMemo,
55
62
  useRef,
56
63
  useState,
64
+ useSyncExternalStore,
57
65
  } from "react";
58
66
  import { createPortal } from "react-dom";
59
67
  import { toast } from "sonner";
@@ -72,9 +80,53 @@ const SORT_TRANSITION = { duration: 200, easing: EASING };
72
80
  const DROP_ANIMATION: DropAnimation = {
73
81
  duration: 200,
74
82
  easing: EASING,
75
- sideEffects: defaultDropAnimationSideEffects({ styles: { active: { opacity: "0.4" } } }),
83
+ // ช่องจาง ๆ ของแถวต้นทางเราใส่ผ่าน style ของ React เองแล้ว — side effect ของ dnd-kit จะจำค่า 0.4 นั้น
84
+ // ไว้เป็น "ค่าเดิม" แล้วเขียนคืนหลังปล่อย ทำให้แถวจางค้าง (เจอกับหัวกลุ่ม)
85
+ sideEffects: null,
76
86
  };
77
87
 
88
+ /** สิ่งที่กำลังลาก — `ids` = ทุกรายการที่ลากไปด้วยกัน (ลากแบบ bulk; ลากตัวเดียว = `[id]`) */
89
+ export interface SortableDrag {
90
+ kind: SortableKind;
91
+ id: string;
92
+ ids: string[];
93
+ }
94
+
95
+ type SelectMode = "toggle" | "range" | "all" | "clear";
96
+
97
+ /** ระยะเลื่อนของหัวกลุ่มแต่ละกลุ่มระหว่างลากกลุ่ม — แถวรายการในกลุ่มอ่านไปเลื่อนตาม (ทั้งก้อนขยับพร้อมกัน) */
98
+ interface GroupShift {
99
+ transform: string | undefined;
100
+ transition: string | undefined;
101
+ }
102
+
103
+ function createShiftStore() {
104
+ const shifts = new Map<string, GroupShift>();
105
+ const listeners = new Set<() => void>();
106
+ return {
107
+ get: (id: string) => shifts.get(id),
108
+ set(id: string, shift: GroupShift | undefined) {
109
+ const prev = shifts.get(id);
110
+ if (prev?.transform === shift?.transform && prev?.transition === shift?.transition) return;
111
+ if (shift) shifts.set(id, shift);
112
+ else shifts.delete(id);
113
+ for (const listener of listeners) listener();
114
+ },
115
+ clear() {
116
+ if (shifts.size === 0) return;
117
+ shifts.clear();
118
+ for (const listener of listeners) listener();
119
+ },
120
+ subscribe(listener: () => void) {
121
+ listeners.add(listener);
122
+ return () => {
123
+ listeners.delete(listener);
124
+ };
125
+ },
126
+ };
127
+ }
128
+ type ShiftStore = ReturnType<typeof createShiftStore>;
129
+
78
130
  interface SortableState {
79
131
  /** กำลังลากชนิดไหนอยู่ (null = ไม่ได้ลาก) — ใช้ปิดที่วางของอีกชนิด */
80
132
  dragging: SortableKind | null;
@@ -82,22 +134,57 @@ interface SortableState {
82
134
  handleLabel: string;
83
135
  /** มี `renderOverlay` = แถวต้นทางเป็นที่ว่างจาง ๆ ส่วนของที่ลอยตามเมาส์คือ overlay */
84
136
  overlay: boolean;
137
+ /** กลุ่มที่กำลังถูกลาก (แถวรายการของกลุ่มนี้จางตามหัวกลุ่ม) */
138
+ activeGroup: string | null;
139
+ /** กำลังลากของในชั้นบนสุด (กลุ่ม หรือรายการไม่มีกลุ่มเมื่อ `freeLayout`) — ระยะเลื่อนมาจาก provider */
140
+ rootDrag: boolean;
141
+ freeLayout: boolean;
142
+ /** รายการอื่นที่ลากไปพร้อมกันแบบ bulk (ไม่รวมตัวที่จับ) — แสดงจาง ๆ ระหว่างลาก */
143
+ carried: ReadonlySet<string>;
144
+ multiSelect: boolean;
145
+ selected: ReadonlySet<string>;
146
+ select: (id: string, mode: SelectMode) => void;
147
+ groupOf: (itemId: string) => string | undefined;
148
+ register: (kind: SortableKind, id: string, node: HTMLElement | null) => void;
149
+ shifts: ShiftStore;
85
150
  }
86
151
 
152
+ const EMPTY = new Set<string>();
87
153
  const SortableStateContext = createContext<SortableState>({
88
154
  dragging: null,
89
155
  disabled: true,
90
156
  handleLabel: "ลากเพื่อจัดลำดับ",
91
157
  overlay: false,
158
+ activeGroup: null,
159
+ rootDrag: false,
160
+ freeLayout: false,
161
+ carried: EMPTY,
162
+ multiSelect: false,
163
+ selected: EMPTY,
164
+ select: () => {},
165
+ groupOf: () => undefined,
166
+ register: () => {},
167
+ shifts: createShiftStore(),
92
168
  });
93
169
 
94
170
  /**
95
171
  * สถานะการลากตอนนี้ — ใช้ตัดสินใจเรนเดอร์ที่วางเอง เช่นแถว "ลากมาที่นี่เพื่อเอาออกจากกลุ่ม"
96
172
  * ที่ควรโผล่เฉพาะตอน `dragging === "item"` (กลุ่มที่ไม่มีหัวกลุ่มต้องมีที่วางของตัวเอง)
173
+ * `selected`/`clearSelection` ใช้ทำแถบ "เลือก N รายการ" เมื่อเปิด `multiSelect`
97
174
  */
98
- export function useSortableState(): { dragging: SortableKind | null; disabled: boolean } {
175
+ export function useSortableState(): {
176
+ dragging: SortableKind | null;
177
+ disabled: boolean;
178
+ selected: string[];
179
+ clearSelection: () => void;
180
+ } {
99
181
  const state = useContext(SortableStateContext);
100
- return { dragging: state.dragging, disabled: state.disabled };
182
+ return {
183
+ dragging: state.dragging,
184
+ disabled: state.disabled,
185
+ selected: [...state.selected],
186
+ clearSelection: () => state.select("", "clear"),
187
+ };
101
188
  }
102
189
 
103
190
  /** ข้อมูลของสิ่งที่กำลังลาก ส่งให้แอปประกอบข้อความเอง (แอปรู้ชื่อคีย์/ชื่อกลุ่ม แพ็กเกจไม่รู้) */
@@ -109,6 +196,8 @@ export interface SortableAnnouncementContext {
109
196
  /** ตำแหน่งในกลุ่ม/ในลำดับกลุ่ม เริ่มที่ 1 */
110
197
  index: number;
111
198
  total: number;
199
+ /** จำนวนรายการที่ลากไปด้วยกัน (ลากแบบ bulk > 1) */
200
+ count: number;
112
201
  }
113
202
 
114
203
  export interface SortableAnnouncements {
@@ -155,7 +244,24 @@ export interface SortableProviderProps {
155
244
  * ส่งมา = แถวต้นทางกลายเป็นช่องว่างจาง ๆ บอกตำแหน่งที่จะวาง และมีแอนิเมชันตอนปล่อย
156
245
  * วาดใน portal ที่ `body` จึงคืน `<div>` ได้แม้รายการจริงเป็นแถว `<tr>` ของตาราง
157
246
  */
158
- renderOverlay?: (drag: { kind: SortableKind; id: string }) => ReactNode;
247
+ renderOverlay?: (drag: SortableDrag) => ReactNode;
248
+ /**
249
+ * เลือกหลายรายการแล้วลากไปพร้อมกัน: ⌘/Ctrl+คลิก = เลือกทีละตัว, Shift+คลิก = เลือกเป็นช่วง,
250
+ * คลิกเฉย ๆ = ล้าง · ที่จับ (คีย์บอร์ด): `x` = เลือก/ไม่เลือก, ⌘/Ctrl+A = ทั้งหมด, Esc = ล้าง
251
+ * แถวต้องกระจาย `selectProps` จาก `useSortableRow` (มี `data-selected` ไว้ทำสีแถวที่เลือก)
252
+ * ลากตัวที่เลือกอยู่ = ลากทุกตัวที่เลือกไปรวมที่จุดวาง เรียงตามลำดับเดิม
253
+ */
254
+ multiSelect?: boolean;
255
+ /**
256
+ * ชั้นบนสุดจัดได้อิสระ: กลุ่มกับรายการที่ไม่มีกลุ่มสลับที่กันได้ทุกแบบ (กลุ่มขึ้นไปเหนือรายการ /
257
+ * รายการไปอยู่ระหว่างกลุ่ม / เอารายการออกจากกลุ่มไปวางก่อนกลุ่มไหนก็ได้) — แอปต้องเรนเดอร์ตาม
258
+ * `rootEntries(order)` และบันทึก `sortableOrderToPayload(order).root` · ชี้ครึ่งบนของหัวกลุ่ม = วางก่อนกลุ่ม,
259
+ * ครึ่งล่าง = เข้ากลุ่มเป็นตัวแรก · ไม่เปิด = รายการไม่มีกลุ่มอยู่บนสุดเสมอ (แบบเดิม)
260
+ */
261
+ freeLayout?: boolean;
262
+ /** รายการที่เลือก (ควบคุมจากภายนอก) — ไม่ส่ง = provider เก็บเอง */
263
+ selected?: string[];
264
+ onSelectedChange?: (ids: string[]) => void;
159
265
  children: ReactNode;
160
266
  }
161
267
 
@@ -179,13 +285,26 @@ export function SortableProvider({
179
285
  announcements,
180
286
  id,
181
287
  renderOverlay,
288
+ multiSelect = false,
289
+ freeLayout = false,
290
+ selected: selectedProp,
291
+ onSelectedChange,
182
292
  children,
183
293
  }: SortableProviderProps) {
184
294
  const fallbackId = useId();
185
295
  const text = useUiText();
186
296
  const mounted = useMounted();
187
297
  const [dragging, setDragging] = useState<SortableKind | null>(null);
188
- const [activeDrag, setActiveDrag] = useState<{ kind: SortableKind; id: string } | null>(null);
298
+ const [activeDrag, setActiveDrag] = useState<SortableDrag | null>(null);
299
+ const draggingRef = useRef<SortableKind | null>(null);
300
+ // รายการที่ลากไปด้วยกันแบบ bulk (รวมตัวที่จับ เรียงตามลำดับที่แสดง) — null = ลากตัวเดียว
301
+ const bulk = useRef<string[] | null>(null);
302
+ const nodes = useRef({
303
+ groups: new Map<string, HTMLElement>(),
304
+ items: new Map<string, HTMLElement>(),
305
+ groupOfNode: new WeakMap<HTMLElement, string>(),
306
+ });
307
+ const shifts = useMemo(createShiftStore, []);
189
308
  // กันกระพริบตอนย้ายข้ามกลุ่ม: จำเป้าหมายล่าสุดไว้ใช้ตอนที่ layout กำลังขยับแล้วหาเป้าหมายไม่เจอ
190
309
  const lastOverId = useRef<UniqueIdentifier | null>(null);
191
310
  const movedAcross = useRef(false);
@@ -194,6 +313,229 @@ export function SortableProvider({
194
313
  const latest = useRef(order);
195
314
  latest.current = order;
196
315
 
316
+ // ---------- เลือกหลายรายการ ----------
317
+ const [ownSelected, setOwnSelected] = useState<string[]>([]);
318
+ const selectedList = selectedProp ?? ownSelected;
319
+ const selected = useMemo<ReadonlySet<string>>(() => {
320
+ if (!multiSelect || selectedList.length === 0) return EMPTY;
321
+ const present = new Set(flatItems(order));
322
+ return new Set(selectedList.filter((x) => present.has(x)));
323
+ }, [multiSelect, selectedList, order]);
324
+ const selectedRef = useRef(selected);
325
+ selectedRef.current = selected;
326
+ const anchor = useRef<string | null>(null);
327
+ const onSelectedChangeRef = useRef(onSelectedChange);
328
+ onSelectedChangeRef.current = onSelectedChange;
329
+ const controlled = selectedProp !== undefined;
330
+
331
+ const setSelected = useCallback(
332
+ (ids: string[]) => {
333
+ if (!controlled) setOwnSelected(ids);
334
+ onSelectedChangeRef.current?.(ids);
335
+ },
336
+ [controlled],
337
+ );
338
+
339
+ const select = useCallback(
340
+ (itemId: string, mode: SelectMode) => {
341
+ const all = flatItems(latest.current);
342
+ // เลือกเป็นช่วง/ทั้งหมด = เฉพาะแถวที่แสดงอยู่ (ไม่หยิบของในกลุ่มที่ย่อไว้โดยไม่รู้ตัว)
343
+ const visible = all.filter((x) => nodes.current.items.get(x)?.isConnected);
344
+ const current = selectedRef.current;
345
+ let next: Set<string>;
346
+ if (mode === "clear") next = new Set();
347
+ else if (mode === "all") next = new Set(visible);
348
+ else if (mode === "toggle") {
349
+ next = new Set(current);
350
+ if (next.has(itemId)) next.delete(itemId);
351
+ else next.add(itemId);
352
+ anchor.current = itemId;
353
+ } else {
354
+ const from = visible.indexOf(anchor.current ?? itemId);
355
+ const to = visible.indexOf(itemId);
356
+ next = new Set(current);
357
+ if (from >= 0 && to >= 0) {
358
+ for (const x of visible.slice(Math.min(from, to), Math.max(from, to) + 1)) next.add(x);
359
+ } else next.add(itemId);
360
+ if (anchor.current === null) anchor.current = itemId;
361
+ }
362
+ if (mode === "clear" || mode === "all") anchor.current = null;
363
+ if (next.size === current.size && [...next].every((x) => current.has(x))) return;
364
+ setSelected(all.filter((x) => next.has(x)));
365
+ },
366
+ [setSelected],
367
+ );
368
+
369
+ // Esc ที่ไหนก็ได้ = ล้างการเลือก (ระหว่างลาก Esc เป็นของการยกเลิกลาก)
370
+ const hasSelection = selected.size > 0;
371
+ useEffect(() => {
372
+ if (!hasSelection) return;
373
+ function onKey(event: globalThis.KeyboardEvent) {
374
+ if (event.key === "Escape" && draggingRef.current === null) select("", "clear");
375
+ }
376
+ document.addEventListener("keydown", onKey);
377
+ return () => document.removeEventListener("keydown", onKey);
378
+ }, [hasSelection, select]);
379
+
380
+ const register = useCallback((kind: SortableKind, rowId: string, node: HTMLElement | null) => {
381
+ const map = kind === "group" ? nodes.current.groups : nodes.current.items;
382
+ if (node) {
383
+ map.set(rowId, node);
384
+ if (kind === "group") nodes.current.groupOfNode.set(node, rowId);
385
+ } else map.delete(rowId);
386
+ }, []);
387
+
388
+ /** กรอบของทั้งก้อน: หัวกลุ่ม + แถวรายการที่แสดงอยู่ (ไม่สนใจ transform ระหว่างลาก) */
389
+ const blockRect = useCallback((groupId: string, header: HTMLElement) => {
390
+ let { top, bottom, left, right } = getClientRect(header, { ignoreTransform: true });
391
+ for (const itemId of latest.current.items[groupId] ?? []) {
392
+ const node = nodes.current.items.get(itemId);
393
+ if (!node?.isConnected) continue;
394
+ const r = getClientRect(node, { ignoreTransform: true });
395
+ top = Math.min(top, r.top);
396
+ bottom = Math.max(bottom, r.bottom);
397
+ left = Math.min(left, r.left);
398
+ right = Math.max(right, r.right);
399
+ }
400
+ return { top, bottom, left, right, width: right - left, height: bottom - top };
401
+ }, []);
402
+
403
+ /**
404
+ * ขนาดของที่วางตอนลากกลุ่ม = หัวกลุ่ม "รวมแถวรายการของกลุ่มนั้น" ทั้งก้อน
405
+ * (เดิมวัดแค่หัวกลุ่ม กลุ่มอื่นจึงเลื่อนหลบแค่ความสูงหัวกลุ่มเดียว แถวรายการไม่ขยับ)
406
+ */
407
+ const measureDroppable = useCallback(
408
+ (element: HTMLElement) => {
409
+ const groupId =
410
+ draggingRef.current === "group" ? nodes.current.groupOfNode.get(element) : undefined;
411
+ return groupId
412
+ ? blockRect(groupId, element)
413
+ : getClientRect(element, { ignoreTransform: true });
414
+ },
415
+ [blockRect],
416
+ );
417
+
418
+ // ---------- เลื่อนทั้งก้อนในชั้นบนสุด (แบบรายการ: ตัวอื่นหลบ ช่องว่างของตัวที่ลากเลื่อนไปจุดวาง) ----------
419
+ const freeRef = useRef(freeLayout);
420
+ freeRef.current = freeLayout;
421
+ const overlayOn = Boolean(renderOverlay);
422
+
423
+ /**
424
+ * ลำดับที่ตัวที่ลากเรียงอยู่ด้วย (เป็น dnd id) — `freeLayout` = ชั้นบนสุดทั้งหมด,
425
+ * ไม่เปิด = เฉพาะกลุ่ม (รายการไม่มีกลุ่มเรียงกันเองด้วย dnd-kit) · null = ไม่ใช่การลากในชั้นบนสุด
426
+ */
427
+ const entryKeys = useCallback((current: SortableOrder, activeKey: string): string[] | null => {
428
+ if (freeRef.current) {
429
+ const keys = rootEntries(current).map((e) => dndId(e.kind, e.id));
430
+ return keys.includes(activeKey) ? keys : null;
431
+ }
432
+ return kindOf(activeKey) === "g" ? current.groups.map((g) => dndId("group", g)) : null;
433
+ }, []);
434
+
435
+ /** กรอบของสิ่งหนึ่งในชั้นบนสุด: กลุ่ม = ทั้งก้อน, รายการ = แถวเดียว */
436
+ const measureKey = useCallback(
437
+ (key: string) => {
438
+ const raw = rawOf(key);
439
+ if (kindOf(key) === "g") {
440
+ const header = nodes.current.groups.get(raw);
441
+ return header?.isConnected ? blockRect(raw, header) : null;
442
+ }
443
+ const node = nodes.current.items.get(raw);
444
+ return node?.isConnected ? getClientRect(node, { ignoreTransform: true }) : null;
445
+ },
446
+ [blockRect],
447
+ );
448
+
449
+ /** จะวาง "ก่อน" อะไร (dnd id; null = ท้ายสุดของลำดับ) — null ทั้ง object = ไม่ใช่เป้าหมายในชั้นบนสุด */
450
+ const [target, setTargetState] = useState<{ before: string | null } | null>(null);
451
+ const targetRef = useRef(target);
452
+ const setTarget = useCallback((next: { before: string | null } | null) => {
453
+ const prev = targetRef.current;
454
+ if (prev === next || (prev && next && prev.before === next.before)) return;
455
+ targetRef.current = next;
456
+ setTargetState(next);
457
+ }, []);
458
+
459
+ useEffect(() => {
460
+ if (!activeDrag) return;
461
+ const activeKey = dndId(activeDrag.kind, activeDrag.id);
462
+ const keys = entryKeys(order, activeKey);
463
+ if (!keys) {
464
+ // รายการที่ลากเข้าไปอยู่ในกลุ่มแล้ว → dnd-kit ของกลุ่มนั้นคุมต่อ
465
+ shifts.clear();
466
+ return;
467
+ }
468
+ const rest = keys.filter((k) => k !== activeKey);
469
+ const at = target
470
+ ? target.before === null
471
+ ? rest.length
472
+ : rest.indexOf(target.before)
473
+ : keys.indexOf(activeKey);
474
+ const final = [...rest.slice(0, Math.max(0, at)), activeKey, ...rest.slice(Math.max(0, at))];
475
+ const rects = new Map(keys.map((k) => [k, measureKey(k)] as const));
476
+ const first = rects.get(keys[0] ?? "");
477
+ const second = rects.get(keys[1] ?? "");
478
+ if (!first) return;
479
+ // ช่องไฟระหว่างก้อน (ตาราง = 0, การ์ดมีระยะห่าง) — ใช้ระยะจริงระหว่างสองก้อนแรก
480
+ const gap = second ? Math.max(0, second.top - first.bottom) : 0;
481
+ const transition = `transform ${SORT_TRANSITION.duration}ms ${EASING}`;
482
+ let cursor = first.top;
483
+ for (const key of final) {
484
+ const rect = rects.get(key);
485
+ if (!rect) continue;
486
+ const dy = cursor - rect.top;
487
+ cursor += rect.height + gap;
488
+ // ไม่มี overlay = ตัวที่ลากลอยตามเมาส์เอง (กลุ่ม: onDragMove, รายการ: dnd-kit)
489
+ if (key === activeKey && !overlayOn) continue;
490
+ shifts.set(key, { transform: `translate3d(0px, ${dy}px, 0)`, transition });
491
+ }
492
+ }, [activeDrag, target, order, overlayOn, shifts, entryKeys, measureKey]);
493
+
494
+ /** ตั้งเป้าหมายในชั้นบนสุดจากสิ่งที่ชี้อยู่ + ตัวที่ลากเลยครึ่งของมันหรือยัง */
495
+ function updateTarget(
496
+ active: DragMoveEvent["active"],
497
+ over: DragMoveEvent["over"],
498
+ keyboard: boolean,
499
+ ) {
500
+ const activeKey = String(active.id);
501
+ const keys = entryKeys(latest.current, activeKey);
502
+ if (!keys) {
503
+ setTarget(null);
504
+ return;
505
+ }
506
+ if (!over) return;
507
+ const overKey = String(over.id);
508
+ const nextAfter = (index: number) => keys.slice(index + 1).find((k) => k !== activeKey) ?? null;
509
+ if (kindOf(overKey) === "z" && rawOf(overKey) === UNGROUPED) {
510
+ setTarget({ before: null });
511
+ } else if (overKey === activeKey) {
512
+ setTarget({ before: nextAfter(keys.indexOf(activeKey)) });
513
+ } else if (keys.includes(overKey)) {
514
+ if (kindOf(activeKey) === "i" && kindOf(overKey) === "g") {
515
+ // รายการชี้ครึ่งบนของหัวกลุ่ม = วางก่อนกลุ่มนั้น
516
+ setTarget({ before: overKey });
517
+ return;
518
+ }
519
+ const a = keys.indexOf(activeKey);
520
+ const o = keys.indexOf(overKey);
521
+ const rect = measureKey(overKey);
522
+ const dragged = active.rect.current.translated;
523
+ // คีย์บอร์ดขยับทีละตำแหน่งไปทับกลางเป้าหมายพอดี → ใช้ทิศทางแทน
524
+ const after =
525
+ keyboard || !rect || !dragged
526
+ ? o > a
527
+ : dragged.top + dragged.height / 2 > (rect.top + rect.bottom) / 2;
528
+ setTarget({ before: after ? nextAfter(o) : overKey });
529
+ } else {
530
+ setTarget(null);
531
+ }
532
+ }
533
+
534
+ const measuring = useMemo(
535
+ () => ({ droppable: { strategy: MeasuringStrategy.Always, measure: measureDroppable } }),
536
+ [measureDroppable],
537
+ );
538
+
197
539
  // ย้ายข้ามกลุ่มเสร็จแล้ว (render ลำดับใหม่ไปแล้ว) ค่อยปลดธงในเฟรมถัดไป
198
540
  useEffect(() => {
199
541
  const frame = requestAnimationFrame(() => {
@@ -212,10 +554,16 @@ export function SortableProvider({
212
554
  */
213
555
  const collisionDetection: CollisionDetection = useCallback((args) => {
214
556
  if (kindOf(args.active.id) === "g") {
215
- return closestCenter({
216
- ...args,
217
- droppableContainers: args.droppableContainers.filter((c) => kindOf(c.id) === "g"),
218
- });
557
+ // freeLayout: กลุ่มเรียงปนกับรายการไม่มีกลุ่ม → เทียบกับทั้งสองอย่าง (สิ่งที่อยู่ใต้เมาส์ก่อน)
558
+ const loose = new Set(latest.current.items[UNGROUPED] ?? []);
559
+ const rootLevel = args.droppableContainers.filter(
560
+ (c) =>
561
+ kindOf(c.id) === "g" ||
562
+ (freeRef.current && kindOf(c.id) === "i" && loose.has(rawOf(c.id))),
563
+ );
564
+ const scoped = { ...args, droppableContainers: rootLevel };
565
+ const under = freeRef.current ? pointerWithin(scoped) : [];
566
+ return under.length > 0 ? under.slice(0, 1) : closestCenter(scoped);
219
567
  }
220
568
  const candidates = args.droppableContainers.filter((c) => kindOf(c.id) !== "g");
221
569
  const scoped = { ...args, droppableContainers: candidates };
@@ -223,14 +571,27 @@ export function SortableProvider({
223
571
  const hits = underPointer.length > 0 ? underPointer : rectIntersection(scoped);
224
572
  let overId = getFirstCollision(hits, "id");
225
573
  if (overId != null) {
226
- if (kindOf(overId) === "z") {
227
- const ids = new Set(latest.current.items[rawOf(overId)] ?? []);
228
- const inGroup = candidates.filter(
229
- (c) => kindOf(c.id) === "i" && c.id !== args.active.id && ids.has(rawOf(c.id)),
574
+ const header = kindOf(overId) === "z" ? args.droppableRects.get(overId) : undefined;
575
+ const pointer = args.pointerCoordinates;
576
+ if (
577
+ freeRef.current &&
578
+ header &&
579
+ pointer &&
580
+ rawOf(overId) !== UNGROUPED &&
581
+ pointer.y < header.top + header.height / 2
582
+ ) {
583
+ // freeLayout: ครึ่งบนของหัวกลุ่ม = วาง "ก่อนกลุ่ม" ในชั้นบนสุด (ครึ่งล่าง = เข้ากลุ่มเป็นตัวแรก)
584
+ overId = dndId("group", rawOf(overId));
585
+ } else if (kindOf(overId) === "z" && rawOf(overId) !== UNGROUPED) {
586
+ // (โซนของ UNGROUPED = ที่วาง "ท้ายสุด" ไม่ใช่หัวกลุ่ม — ห้ามเปลี่ยนเป็นรายการแรก)
587
+ // ชี้หัวกลุ่ม = "บนสุดของกลุ่ม" → เป้าหมายคือรายการแรกที่แสดงอยู่ "รวมตัวที่ลากเอง"
588
+ // (เดิมเลือกตัวที่ใกล้ที่สุดที่ไม่ใช่ตัวที่ลาก: ลากเข้ากลุ่มแล้วขึ้นเป็นตัวแรกถูก แต่พอปล่อย
589
+ // onDragEnd ย้ายไปแทนที่ตัวแรกเดิม → กลายเป็นตัวที่สอง วางเป็นรายการแรกไม่ได้)
590
+ const rendered = new Set(
591
+ candidates.filter((c) => kindOf(c.id) === "i").map((c) => rawOf(c.id)),
230
592
  );
231
- if (inGroup.length > 0) {
232
- overId = closestCenter({ ...args, droppableContainers: inGroup })[0]?.id ?? overId;
233
- }
593
+ const first = (latest.current.items[rawOf(overId)] ?? []).find((x) => rendered.has(x));
594
+ if (first !== undefined) overId = dndId("item", first);
234
595
  }
235
596
  lastOverId.current = overId;
236
597
  return [{ id: overId }];
@@ -253,16 +614,82 @@ export function SortableProvider({
253
614
  beforeDrag.current = latest.current;
254
615
  lastOverId.current = null;
255
616
  const kind: SortableKind = kindOf(active.id) === "g" ? "group" : "item";
617
+ const raw = rawOf(active.id);
618
+ const chosen = selectedRef.current;
619
+ bulk.current = null;
620
+ if (kind === "item" && chosen.has(raw) && chosen.size > 1) {
621
+ bulk.current = flatItems(latest.current).filter((x) => chosen.has(x));
622
+ } else if (kind === "item" && chosen.size > 0 && !chosen.has(raw)) {
623
+ // ลากตัวที่ไม่ได้เลือก = ลากตัวนั้นตัวเดียว และล้างการเลือกเดิม (แบบ Finder)
624
+ select("", "clear");
625
+ }
626
+ draggingRef.current = kind;
627
+ setTarget(null);
256
628
  setDragging(kind);
257
- setActiveDrag({ kind, id: rawOf(active.id) });
629
+ setActiveDrag({ kind, id: raw, ids: bulk.current ?? [raw] });
630
+ }
631
+
632
+ function onDragMove({ active, over, delta, activatorEvent }: DragMoveEvent) {
633
+ updateTarget(active, over, activatorEvent instanceof KeyboardEvent);
634
+ // ไม่มี overlay: ก้อนของกลุ่มที่ลากลอยตามเมาส์ทั้งก้อน (หัวกลุ่ม + แถวรายการ)
635
+ if (overlayOn || kindOf(active.id) !== "g") return;
636
+ shifts.set(String(active.id), {
637
+ transform: `translate3d(${delta.x}px, ${delta.y}px, 0)`,
638
+ transition: undefined,
639
+ });
640
+ }
641
+
642
+ function endDrag() {
643
+ draggingRef.current = null;
644
+ bulk.current = null;
645
+ setTarget(null);
646
+ // ล้างระยะเลื่อนพร้อมกับที่ลำดับใหม่ render (รอ effect = มีเฟรมที่แถวใหม่ยังติด transform เก่า → กระพริบ)
647
+ shifts.clear();
648
+ setDragging(null);
649
+ setActiveDrag(null);
258
650
  }
259
651
 
260
- function onDragOver({ active, over }: DragOverEvent) {
261
- if (!over || kindOf(active.id) !== "i") return;
652
+ function onDragOver({ active, over, activatorEvent }: DragOverEvent) {
653
+ const keyboard = activatorEvent instanceof KeyboardEvent;
654
+ if (!over || kindOf(active.id) !== "i") {
655
+ updateTarget(active, over, keyboard);
656
+ return;
657
+ }
262
658
  const current = latest.current;
659
+ const raw = rawOf(active.id);
660
+ const apply = (next: SortableOrder) => {
661
+ if (next === current) return;
662
+ movedAcross.current = true;
663
+ latest.current = next;
664
+ onOrderChange(next);
665
+ };
666
+ // ครึ่งบนของหัวกลุ่ม (freeLayout) = ออกมาวางก่อนกลุ่มนั้นในชั้นบนสุด
667
+ if (kindOf(over.id) === "g") {
668
+ if (groupOfItem(current, raw) !== UNGROUPED) {
669
+ apply(moveItemToRoot(current, raw, { kind: "group", id: rawOf(over.id) }));
670
+ }
671
+ updateTarget(active, over, keyboard);
672
+ return;
673
+ }
263
674
  const from = containerOf(active.id, current);
264
675
  const to = containerOf(over.id, current);
265
- if (!from || !to || from === to) return;
676
+ if (!from || !to || from === to) {
677
+ updateTarget(active, over, keyboard);
678
+ return;
679
+ }
680
+ if (to === UNGROUPED) {
681
+ // ออกจากกลุ่มมาชั้นบนสุด: ก่อน/หลังรายการที่ชี้ตามครึ่งแถว (ถัดไปอาจเป็นกลุ่ม) หรือท้ายสุด
682
+ const root = rootEntries(current);
683
+ let before: (typeof root)[number] | null = null;
684
+ if (kindOf(over.id) === "i") {
685
+ const at = root.findIndex((e) => e.kind === "item" && e.id === rawOf(over.id));
686
+ const dragged = active.rect.current.translated;
687
+ const below = dragged != null && dragged.top > over.rect.top + over.rect.height / 2;
688
+ before = root[at + (below ? 1 : 0)] ?? null;
689
+ }
690
+ apply(moveItemToRoot(current, raw, before));
691
+ return;
692
+ }
266
693
  // วางก่อนหรือหลังแถวเป้าหมาย ตามว่าตัวที่ลากเลยครึ่งแถวนั้นลงมาหรือยัง
267
694
  // (เดิมแทรก "ก่อน" เสมอ — ลากเข้าจากด้านล่างแล้วรายการกระโดดข้ามขึ้นไป)
268
695
  let before: string | null = null;
@@ -272,17 +699,12 @@ export function SortableProvider({
272
699
  const dragged = active.rect.current.translated;
273
700
  const below = dragged != null && dragged.top > over.rect.top + over.rect.height / 2;
274
701
  before = list[at + (below ? 1 : 0)] ?? null;
275
- } else if (to !== UNGROUPED) {
702
+ } else {
276
703
  // วางบนหัวโฟลเดอร์ = ขึ้นบนสุดของโฟลเดอร์ (ตรงกับตำแหน่งที่เห็น และไม่กระโดดตอนโฟลเดอร์ที่ย่อไว้เปิดออก)
277
704
  // ส่วนโซน "ไม่มีกลุ่ม" อยู่ท้ายตาราง จึงต่อท้าย
278
705
  before = current.items[to]?.[0] ?? null;
279
706
  }
280
- const next = moveItemToGroup(current, rawOf(active.id), to, before);
281
- if (next !== current) {
282
- movedAcross.current = true;
283
- latest.current = next;
284
- onOrderChange(next);
285
- }
707
+ apply(moveItemToGroup(current, raw, to, before));
286
708
  }
287
709
 
288
710
  /** ล้มเหลว = คืนลำดับเดิมเสมอ แล้วส่ง error ต่อให้แอป (ไม่มี `onCommitError` = ขึ้น toast ให้) */
@@ -308,16 +730,39 @@ export function SortableProvider({
308
730
 
309
731
  function onDragEnd({ active, over }: DragEndEvent) {
310
732
  const snapshot = beforeDrag.current;
733
+ const carried = bulk.current;
734
+ // อ่านเป้าหมายก่อน endDrag (endDrag ล้างทิ้ง)
735
+ const finalTarget = targetRef.current;
311
736
  beforeDrag.current = null;
312
- setDragging(null);
313
- setActiveDrag(null);
737
+ endDrag();
314
738
  const current = latest.current;
315
739
  let next = current;
316
- if (over && kindOf(active.id) === "g" && kindOf(over.id) === "g") {
317
- next = reorderGroups(current, rawOf(active.id), rawOf(over.id));
740
+ const activeKey = String(active.id);
741
+ const keys = entryKeys(current, activeKey);
742
+ if (keys && finalTarget) {
743
+ // ชั้นบนสุด: แปลง "ก่อน X / ท้ายลำดับ" เป็นตำแหน่งใน root (ลำดับที่ไม่รวมตัวที่ลาก)
744
+ const rootRest = rootEntries(current)
745
+ .map((e) => dndId(e.kind, e.id))
746
+ .filter((k) => k !== activeKey);
747
+ const last = keys.filter((k) => k !== activeKey).at(-1);
748
+ const index =
749
+ finalTarget.before !== null
750
+ ? rootRest.indexOf(finalTarget.before)
751
+ : last
752
+ ? rootRest.indexOf(last) + 1
753
+ : rootRest.length;
754
+ if (index >= 0) {
755
+ next = moveRootEntry(
756
+ current,
757
+ { kind: kindOf(activeKey) === "g" ? "group" : "item", id: rawOf(activeKey) },
758
+ index,
759
+ );
760
+ }
318
761
  } else if (over && kindOf(active.id) === "i" && kindOf(over.id) === "i") {
319
762
  next = reorderItems(current, rawOf(active.id), rawOf(over.id));
320
763
  }
764
+ // bulk: ตัวที่เลือกไว้ตัวอื่นตามมารวมที่จุดวางของตัวที่จับ (เรียงตามลำดับเดิม)
765
+ if (carried) next = gatherItems(next, rawOf(active.id), carried);
321
766
  if (next !== current) {
322
767
  latest.current = next;
323
768
  onOrderChange(next);
@@ -328,8 +773,7 @@ export function SortableProvider({
328
773
  function onDragCancel() {
329
774
  const snapshot = beforeDrag.current;
330
775
  beforeDrag.current = null;
331
- setDragging(null);
332
- setActiveDrag(null);
776
+ endDrag();
333
777
  if (snapshot) {
334
778
  latest.current = snapshot;
335
779
  onOrderChange(snapshot);
@@ -351,6 +795,7 @@ export function SortableProvider({
351
795
  kind: "group",
352
796
  index: current.groups.indexOf(raw) + 1,
353
797
  total: current.groups.length,
798
+ count: 1,
354
799
  };
355
800
  }
356
801
  const group = groupOfItem(current, raw);
@@ -361,6 +806,7 @@ export function SortableProvider({
361
806
  groupId: group,
362
807
  index: list.indexOf(raw) + 1,
363
808
  total: list.length,
809
+ count: bulk.current?.length ?? 1,
364
810
  };
365
811
  }, []);
366
812
 
@@ -397,12 +843,15 @@ export function SortableProvider({
397
843
  ) =>
398
844
  (template ?? fallback)
399
845
  .replace("{index}", String(ctx.index))
400
- .replace("{total}", String(ctx.total));
846
+ .replace("{total}", String(ctx.total))
847
+ .replace("{count}", String(ctx.count));
401
848
  const announce =
402
849
  (
403
850
  own: ((context: SortableAnnouncementContext) => string | undefined) | undefined,
404
851
  template: string | undefined,
405
852
  fallback: string,
853
+ // ประโยคแทนเมื่อลากหลายรายการพร้อมกัน
854
+ many: { template: string | undefined; fallback: string } | null,
406
855
  // ระหว่างลาก = ตำแหน่งของเป้าหมาย; ตอนปล่อย order อัปเดตแล้ว จึงใช้ตำแหน่งจริงของตัวที่ลาก
407
856
  useTarget = false,
408
857
  ) =>
@@ -416,39 +865,92 @@ export function SortableProvider({
416
865
  const context = useTarget
417
866
  ? describeTarget(active.id, over?.id ?? null)
418
867
  : describe(active.id);
419
- return own?.(context) ?? fill(template, fallback, context);
868
+ if (own) {
869
+ const said = own(context);
870
+ if (said !== undefined) return said;
871
+ }
872
+ return many && context.count > 1
873
+ ? fill(many.template, many.fallback, context)
874
+ : fill(template, fallback, context);
420
875
  };
421
876
  return {
422
877
  onDragStart: announce(
423
878
  announcements?.onDragStart,
424
879
  text.sortable?.started,
425
880
  "เริ่มลากรายการที่ {index} จาก {total}",
881
+ { template: text.sortable?.startedMany, fallback: "เริ่มลาก {count} รายการที่เลือกไว้" },
426
882
  ),
427
883
  onDragOver: announce(
428
884
  announcements?.onDragOver,
429
885
  text.sortable?.moved,
430
886
  "ย้ายไปตำแหน่งที่ {index} จาก {total}",
887
+ null,
431
888
  true,
432
889
  ),
433
890
  onDragEnd: announce(
434
891
  announcements?.onDragEnd,
435
892
  text.sortable?.dropped,
436
893
  "วางที่ตำแหน่งที่ {index} จาก {total}",
894
+ null,
895
+ ),
896
+ onDragCancel: announce(
897
+ announcements?.onDragCancel,
898
+ text.sortable?.cancelled,
899
+ "ยกเลิกการลาก",
900
+ null,
437
901
  ),
438
- onDragCancel: announce(announcements?.onDragCancel, text.sortable?.cancelled, "ยกเลิกการลาก"),
439
902
  };
440
903
  }, [announcements, describeTarget, text.sortable]);
441
904
 
442
905
  const groupIds = useMemo(() => order.groups.map((id) => dndId("group", id)), [order.groups]);
443
906
 
907
+ const groupOf = useCallback((itemId: string) => groupOfItem(order, itemId), [order]);
908
+ const rootDrag =
909
+ activeDrag !== null &&
910
+ (activeDrag.kind === "group" ||
911
+ (freeLayout && groupOfItem(order, activeDrag.id) === UNGROUPED));
912
+ const carried = useMemo<ReadonlySet<string>>(
913
+ () =>
914
+ activeDrag && activeDrag.ids.length > 1
915
+ ? new Set(activeDrag.ids.filter((x) => x !== activeDrag.id))
916
+ : EMPTY,
917
+ [activeDrag],
918
+ );
919
+
444
920
  const state = useMemo<SortableState>(
445
921
  () => ({
446
922
  dragging,
447
923
  disabled,
448
924
  handleLabel: handleLabel ?? text.sortable?.handle ?? "ลากเพื่อจัดลำดับ",
449
925
  overlay: Boolean(renderOverlay),
926
+ activeGroup: activeDrag?.kind === "group" ? activeDrag.id : null,
927
+ rootDrag,
928
+ freeLayout,
929
+ carried,
930
+ multiSelect,
931
+ selected,
932
+ select,
933
+ groupOf,
934
+ register,
935
+ shifts,
450
936
  }),
451
- [dragging, disabled, handleLabel, text.sortable?.handle, renderOverlay],
937
+ [
938
+ dragging,
939
+ disabled,
940
+ handleLabel,
941
+ text.sortable?.handle,
942
+ renderOverlay,
943
+ activeDrag,
944
+ rootDrag,
945
+ freeLayout,
946
+ carried,
947
+ multiSelect,
948
+ selected,
949
+ select,
950
+ groupOf,
951
+ register,
952
+ shifts,
953
+ ],
452
954
  );
453
955
 
454
956
  return (
@@ -458,13 +960,15 @@ export function SortableProvider({
458
960
  sensors={sensors}
459
961
  collisionDetection={collisionDetection}
460
962
  // วัดตำแหน่งที่วางใหม่ตลอดระหว่างลาก — ย้ายข้ามกลุ่มแล้ว layout เปลี่ยน ต้องใช้ตำแหน่งปัจจุบัน
461
- measuring={{ droppable: { strategy: MeasuringStrategy.Always } }}
963
+ // (ตอนลากกลุ่ม วัดหัวกลุ่มรวมแถวรายการทั้งก้อน — ดู measureDroppable)
964
+ measuring={measuring}
462
965
  accessibility={{
463
966
  screenReaderInstructions: { draggable: instructions },
464
967
  announcements: dndAnnouncements,
465
968
  }}
466
969
  onDragStart={onDragStart}
467
970
  onDragOver={onDragOver}
971
+ onDragMove={onDragMove}
468
972
  onDragEnd={onDragEnd}
469
973
  onDragCancel={onDragCancel}
470
974
  >
@@ -511,8 +1015,18 @@ export interface SortableRow {
511
1015
  /** props ที่ต้องแปะที่ "ที่จับ" — ส่งต่อให้ `<DragHandle {...handleProps} />` */
512
1016
  handleProps: Record<string, unknown>;
513
1017
  isDragging: boolean;
1018
+ /** รายการนี้ถูกเลือกอยู่ (`multiSelect`) */
1019
+ selected: boolean;
1020
+ /**
1021
+ * กระจายลงแถวเมื่อเปิด `multiSelect`: คลิกพร้อม ⌘/Ctrl/Shift เพื่อเลือก + `data-selected`
1022
+ * สำหรับทำสี (`data-[selected]:bg-primary/10`) — ปิด `multiSelect` หรือเป็นแถวกลุ่ม = object ว่าง
1023
+ */
1024
+ selectProps: Record<string, unknown>;
514
1025
  }
515
1026
 
1027
+ const INTERACTIVE =
1028
+ "a,button,input,select,textarea,label,[contenteditable=''],[contenteditable='true'],[role=button],[role=checkbox],[role=switch],[role=combobox]";
1029
+
516
1030
  /**
517
1031
  * ทำให้แถวหนึ่งลากได้ — แอปเอา `ref`/`style` ไปแปะที่ `<TableRow>` และ `handleProps` ที่ `<DragHandle>`
518
1032
  * `type: "group"` = ลากทั้งกลุ่ม, `"item"` = ลากรายการในกลุ่ม
@@ -533,21 +1047,117 @@ export function useSortableRow({
533
1047
  disabled: disabled || state.disabled,
534
1048
  transition: SORT_TRANSITION,
535
1049
  });
536
- const dragStyle: CSSProperties = !sortable.isDragging
537
- ? {}
538
- : state.overlay
1050
+ const { register, shifts, select } = state;
1051
+ const { setNodeRef } = sortable;
1052
+ const ref = useCallback(
1053
+ (node: HTMLElement | null) => {
1054
+ setNodeRef(node);
1055
+ register(type, id, node);
1056
+ },
1057
+ [setNodeRef, register, type, id],
1058
+ );
1059
+
1060
+ const transform = CSS.Translate.toString(sortable.transform);
1061
+ // ระหว่างลากกลุ่ม provider คำนวณระยะเลื่อนของแต่ละกลุ่มเอง — หัวกลุ่มและแถวรายการของกลุ่มอ่านค่าเดียวกัน
1062
+ // (หัวกลุ่มมักอยู่ใน `SortableGroup` ซึ่งเป็น context ของรายการ dnd-kit จึงคำนวณให้หัวกลุ่มไม่ได้)
1063
+ const groupId = type === "item" ? state.groupOf(id) : id;
1064
+ const loose = type === "item" && groupId === UNGROUPED;
1065
+ // ระยะเลื่อนในชั้นบนสุด: กลุ่ม/รายการไม่มีกลุ่ม อ่านของตัวเอง, รายการในกลุ่มอ่านของกลุ่ม
1066
+ const shiftKey =
1067
+ type === "group" || loose ? dndId(type, id) : groupId ? dndId("group", groupId) : undefined;
1068
+ const shift = useSyncExternalStore(
1069
+ shifts.subscribe,
1070
+ () => (shiftKey ? shifts.get(shiftKey) : undefined),
1071
+ () => undefined,
1072
+ );
1073
+
1074
+ let style: CSSProperties = { transform, transition: sortable.transition };
1075
+ if (loose && state.freeLayout && state.rootDrag) {
1076
+ style =
1077
+ sortable.isDragging && !state.overlay
1078
+ ? { ...style, position: "relative", zIndex: 1, opacity: 0.85 }
1079
+ : {
1080
+ transform: shift?.transform,
1081
+ transition: shift?.transition,
1082
+ ...(sortable.isDragging || state.carried.has(id) ? { opacity: 0.4 } : {}),
1083
+ };
1084
+ } else if (type === "group" && state.rootDrag) {
1085
+ const own = state.activeGroup === id;
1086
+ style = {
1087
+ transform: shift?.transform,
1088
+ transition: shift?.transition,
1089
+ ...(own
1090
+ ? state.overlay
1091
+ ? { opacity: 0.4 }
1092
+ : { position: "relative", zIndex: 1, opacity: 0.85 }
1093
+ : {}),
1094
+ };
1095
+ } else if (sortable.isDragging) {
1096
+ style = state.overlay
539
1097
  ? // มี overlay ลอยตามเมาส์แล้ว แถวต้นทางเหลือเป็นช่องจาง ๆ บอกตำแหน่งที่จะวาง
540
- { opacity: 0.4 }
541
- : { position: "relative", zIndex: 1, opacity: 0.85 };
1098
+ { ...style, opacity: 0.4 }
1099
+ : { ...style, position: "relative", zIndex: 1, opacity: 0.85 };
1100
+ } else if (type === "item" && state.rootDrag && shift) {
1101
+ const own = state.activeGroup !== null && state.activeGroup === groupId;
1102
+ style = {
1103
+ transform: shift.transform,
1104
+ transition: shift.transition,
1105
+ ...(own
1106
+ ? state.overlay
1107
+ ? { opacity: 0.4 }
1108
+ : { position: "relative", zIndex: 1, opacity: 0.85 }
1109
+ : {}),
1110
+ };
1111
+ } else if (type === "item" && state.carried.has(id)) {
1112
+ // ลากแบบ bulk: ตัวที่ตามไปด้วยจางลงตรงที่เดิม แล้วไปรวมที่จุดวางตอนปล่อย
1113
+ style = { ...style, opacity: 0.4 };
1114
+ }
1115
+
1116
+ const selectable = type === "item" && state.multiSelect;
1117
+ const isSelected = selectable && state.selected.has(id);
1118
+ const listeners = sortable.listeners;
1119
+ const handleProps: Record<string, unknown> = { ...sortable.attributes, ...listeners };
1120
+ if (selectable) {
1121
+ handleProps.onKeyDown = (event: KeyboardEvent<HTMLElement>) => {
1122
+ const mod = event.metaKey || event.ctrlKey;
1123
+ if (!mod && !event.altKey && event.key.toLowerCase() === "x") {
1124
+ event.preventDefault();
1125
+ select(id, "toggle");
1126
+ return;
1127
+ }
1128
+ if (mod && event.key.toLowerCase() === "a") {
1129
+ event.preventDefault();
1130
+ select(id, "all");
1131
+ return;
1132
+ }
1133
+ listeners?.onKeyDown?.(event);
1134
+ };
1135
+ }
1136
+ const selectProps: Record<string, unknown> = selectable
1137
+ ? {
1138
+ "data-selected": isSelected ? "" : undefined,
1139
+ onMouseDown: (event: MouseEvent<HTMLElement>) => {
1140
+ // Shift+คลิก ไม่ให้เบราว์เซอร์ลากคลุมข้อความทั้งแถว
1141
+ if (event.shiftKey) event.preventDefault();
1142
+ },
1143
+ onClick: (event: MouseEvent<HTMLElement>) => {
1144
+ const target = event.target as Element;
1145
+ const control = target.closest(INTERACTIVE);
1146
+ if (control && !control.closest("[data-sortable-handle]")) return;
1147
+ if (event.metaKey || event.ctrlKey) select(id, "toggle");
1148
+ else if (event.shiftKey) select(id, "range");
1149
+ else if (state.selected.size > 0) select(id, "clear");
1150
+ },
1151
+ }
1152
+ : {};
1153
+
542
1154
  return {
543
- ref: sortable.setNodeRef,
544
- style: {
545
- transform: CSS.Translate.toString(sortable.transform),
546
- transition: sortable.transition,
547
- ...dragStyle,
548
- },
549
- handleProps: { ...sortable.attributes, ...sortable.listeners },
1155
+ ref,
1156
+ style,
1157
+ handleProps,
550
1158
  isDragging: sortable.isDragging,
1159
+ selected: isSelected,
1160
+ selectProps,
551
1161
  };
552
1162
  }
553
1163
 
@@ -617,6 +1227,7 @@ export function DragHandle({
617
1227
  aria-label={label ?? state.handleLabel}
618
1228
  disabled={off}
619
1229
  className={cn("cursor-grab touch-none active:cursor-grabbing", className)}
1230
+ data-sortable-handle=""
620
1231
  {...handleProps}
621
1232
  >
622
1233
  <GripVerticalIcon />