@suphark/ui 0.37.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 CHANGED
@@ -2,7 +2,21 @@
2
2
 
3
3
  รูปแบบตาม [Keep a Changelog](https://keepachangelog.com/) · semver: patch = หน้าตา/บั๊ก, minor = component/prop ใหม่, major = เปลี่ยน/ลบ props
4
4
 
5
- ## [0.37.0] — 2026-10-07
5
+ ## [0.38.0] — 2026-10-07
6
+
7
+ ### Fixed
8
+
9
+ - **`@suphark/ui/sortable` ลากกลุ่มแล้วไม่เห็นอะไรขยับ** (เจ้าของแจ้ง): หัวกลุ่มมักอยู่ใน `SortableGroup` ซึ่งเป็น context ของรายการ dnd-kit จึงหากลุ่มในลำดับไม่เจอ ไม่มีแถวไหนได้ transform เลยจนปล่อย · ตอนนี้ provider คำนวณระยะเลื่อนของกลุ่มเอง และเลื่อน **ทั้งก้อน** (หัวกลุ่ม + แถวรายการ) — กลุ่มอื่นหลบเท่าความสูงทั้งก้อน, ช่องจาง ๆ ของกลุ่มที่ลากเลื่อนไปจุดวาง (ไม่มี overlay = ทั้งก้อนลอยตามเมาส์) · ที่วางของกลุ่มวัดรวมแถวรายการ ใช้ได้ทุกโครงสร้างโดยไม่ต้องแก้ call site · วัดบนหน้า demo: ลาก "ฐานข้อมูล" ลงเลย "อีเมล" → ฐานข้อมูลทั้งก้อน +134px, อีเมลทั้งก้อน −135px, ปล่อยแล้วไม่มี style ค้าง
10
+ - แถวต้นทางจางค้างหลังปล่อย (เจอกับหัวกลุ่ม): side effect ของ drop animation ใน dnd-kit จำ opacity ที่เราตั้งไว้เป็นค่าเดิมแล้วเขียนคืน — ปิด side effect นั้น (opacity คุมผ่าน style ของ React อยู่แล้ว)
11
+
12
+ ### Added
13
+
14
+ - `SortableProvider multiSelect` (+ `selected`/`onSelectedChange` ถ้าจะคุมเอง): เลือกหลายรายการแล้วลากไปพร้อมกัน — ⌘/Ctrl+คลิก ทีละตัว, Shift+คลิก เป็นช่วง (เฉพาะแถวที่แสดง), คลิกเฉย ๆ ล้าง (คลิกปุ่ม/ช่องกรอกในแถวไม่นับ) · ที่จับ: `x` เลือก/ไม่เลือก, ⌘/Ctrl+A ทั้งหมด, Esc ล้าง · ลากตัวที่เลือก = ทุกตัวที่เลือกจางลงตรงที่เดิมแล้วไปรวมที่จุดวาง เรียงตามลำดับเดิม (ข้ามกลุ่มได้) · ลากตัวที่ไม่ได้เลือก = ลากตัวเดียวและล้างการเลือก
15
+ - `useSortableRow` คืน `selected` + `selectProps` (กระจายลงแถว มี `data-selected` ไว้ทำสี) · `useSortableState` คืน `selected`/`clearSelection` (ใช้คู่ `SelectionBar`)
16
+ - `renderOverlay` ได้ `SortableDrag` = `{ kind, id, ids }` (`ids` = ทุกตัวที่ลากไปด้วยกัน — ทำการ์ดซ้อน + จำนวน) · `SortableAnnouncementContext.count` + `UiText.sortable.startedMany` ("เริ่มลาก {count} รายการที่เลือกไว้")
17
+ - helper pure: `flatItems(order)`, `gatherItems(order, activeId, ids)`
18
+ - showcase: `/components/sortable-list` เปิด `multiSelect` + `SelectionBar` + การ์ดซ้อนตอนลากหลายคีย์
19
+
6
20
 
7
21
  ### Added
8
22
 
package/README.md CHANGED
@@ -234,6 +234,7 @@ const [order, setOrder] = useState(() =>
234
234
  - ตั้ง `id` ของ `SortableProvider` ได้ (ไม่ตั้ง = `useId()`) — ต้องคงที่ระหว่าง SSR กับ client ไม่งั้น `aria-describedby` ของ dnd-kit จะ hydration mismatch · ตรวจได้จากที่จับ: `aria-describedby` จะเป็น **id ที่ส่งเข้าไปตรง ๆ** (ไม่มี prefix) และต้องชี้ element ที่มีข้อความวิธีลาก — element นี้สร้างหลัง hydrate จึงต้องรอหน้า mount ก่อนค่อย query
235
235
  - `onCommit` ล้มเหลว (throw หรือ `{ ok: false, error }`) = คืนลำดับเดิม + toast; ถ้าอยากจัดการ error เองใส่ `onCommitError`
236
236
  - การ์ดลอยตามเมาส์: `renderOverlay={(drag) => drag.kind === "item" ? <ItemCard id={drag.id} /> : <GroupCard id={drag.id} />}` (วาดใน portal จึงเป็น `div` ได้ ไม่ต้องเป็น `<tr>`) — ไม่ใส่ = แถวขยับเองแบบเดิม · โฟลเดอร์ที่ย่ออยู่: `useSortableDropZone(id, { onDwell: collapsed ? open : undefined })` ลากค้าง 600ms แล้วเปิดให้ · วางบนหัวกลุ่ม = ขึ้นบนสุดของกลุ่ม, วางบนโซนของ `UNGROUPED` = ต่อท้าย
237
+ - เลือกหลายรายการแล้วลากพร้อมกัน: `SortableProvider multiSelect` แล้วกระจาย `row.selectProps` ลงแถว (ทำสีด้วย `data-[selected]:bg-primary/10`) — ⌘/Ctrl+คลิก, Shift+คลิก, ที่จับกด `x` / ⌘/Ctrl+A, Esc ล้าง · แถบนับใช้ `SelectionBar` + `useSortableState().selected/clearSelection` · `renderOverlay` ได้ `drag.ids` ไว้ทำการ์ดซ้อน
237
238
  - `sortableOrderToPayload(order)` คืน `{ groups, items }` — `groups` คือลำดับของกลุ่ม, `items` คือรายการในแต่ละกลุ่มเรียงตามนั้น (ของที่ยังไม่มีกลุ่มมาก่อนด้วย `groupId: null`)
238
239
  - `SortableProvider disabled` (เช่นระหว่างกรอง ซึ่งลำดับที่เห็นไม่ใช่ลำดับจริง) ทำให้ `DragHandle` จางลงและกดไม่ได้ — ไม่อยากให้เห็นเลยใส่ `hideWhenDisabled`
239
240
  - ข้อความ screen reader: ใส่ `announcements`/`screenReaderInstructions` เป็น prop (callback ได้ `{ id, kind, groupId, index, total }`) หรือปล่อยให้ใช้แม่แบบจาก `UiText.sortable`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@suphark/ui",
3
- "version": "0.37.0",
3
+ "version": "0.38.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Design system กลางของ Suphark apps: shadcn/ui (base-nova, Base UI, Tailwind v4) + shared components + token/ฟอนต์ Noto Sans Thai Looped — ส่งเป็น source ให้แอป Next transpile เอง",
@@ -28,6 +28,8 @@ export interface UiText {
28
28
  instructions: string;
29
29
  /** ใช้ `{index}` / `{total}` */
30
30
  started: string;
31
+ /** เริ่มลากหลายรายการที่เลือกไว้ — ใช้ `{count}` */
32
+ startedMany: string;
31
33
  moved: string;
32
34
  dropped: string;
33
35
  cancelled: string;
@@ -255,6 +257,7 @@ export const UI_TEXT_TH: UiText = {
255
257
  handle: "ลากเพื่อจัดลำดับ",
256
258
  instructions: "กด Space เพื่อเริ่มลาก ใช้ปุ่มลูกศรเลื่อน แล้วกด Space อีกครั้งเพื่อวาง (Esc = ยกเลิก)",
257
259
  started: "เริ่มลากรายการที่ {index} จาก {total}",
260
+ startedMany: "เริ่มลาก {count} รายการที่เลือกไว้",
258
261
  moved: "ย้ายไปตำแหน่งที่ {index} จาก {total}",
259
262
  dropped: "วางที่ตำแหน่งที่ {index} จาก {total}",
260
263
  cancelled: "ยกเลิกการลาก",
@@ -425,6 +428,7 @@ export const UI_TEXT_EN: UiText = {
425
428
  instructions:
426
429
  "Press Space to start dragging, use arrow keys to move, Space again to drop (Esc to cancel)",
427
430
  started: "Picked up item {index} of {total}",
431
+ startedMany: "Picked up {count} selected items",
428
432
  moved: "Moved to position {index} of {total}",
429
433
  dropped: "Dropped at position {index} of {total}",
430
434
  cancelled: "Dragging cancelled",
@@ -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,6 +34,8 @@ 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
41
  reorderGroups,
@@ -46,6 +49,8 @@ import { GripVerticalIcon } from "lucide-react";
46
49
  import {
47
50
  type CSSProperties,
48
51
  createContext,
52
+ type KeyboardEvent,
53
+ type MouseEvent,
49
54
  type ReactNode,
50
55
  useCallback,
51
56
  useContext,
@@ -54,6 +59,7 @@ import {
54
59
  useMemo,
55
60
  useRef,
56
61
  useState,
62
+ useSyncExternalStore,
57
63
  } from "react";
58
64
  import { createPortal } from "react-dom";
59
65
  import { toast } from "sonner";
@@ -72,9 +78,48 @@ const SORT_TRANSITION = { duration: 200, easing: EASING };
72
78
  const DROP_ANIMATION: DropAnimation = {
73
79
  duration: 200,
74
80
  easing: EASING,
75
- sideEffects: defaultDropAnimationSideEffects({ styles: { active: { opacity: "0.4" } } }),
81
+ // ช่องจาง ๆ ของแถวต้นทางเราใส่ผ่าน style ของ React เองแล้ว — side effect ของ dnd-kit จะจำค่า 0.4 นั้น
82
+ // ไว้เป็น "ค่าเดิม" แล้วเขียนคืนหลังปล่อย ทำให้แถวจางค้าง (เจอกับหัวกลุ่ม)
83
+ sideEffects: null,
76
84
  };
77
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
+
78
123
  interface SortableState {
79
124
  /** กำลังลากชนิดไหนอยู่ (null = ไม่ได้ลาก) — ใช้ปิดที่วางของอีกชนิด */
80
125
  dragging: SortableKind | null;
@@ -82,22 +127,52 @@ interface SortableState {
82
127
  handleLabel: string;
83
128
  /** มี `renderOverlay` = แถวต้นทางเป็นที่ว่างจาง ๆ ส่วนของที่ลอยตามเมาส์คือ overlay */
84
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;
85
140
  }
86
141
 
142
+ const EMPTY = new Set<string>();
87
143
  const SortableStateContext = createContext<SortableState>({
88
144
  dragging: null,
89
145
  disabled: true,
90
146
  handleLabel: "ลากเพื่อจัดลำดับ",
91
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(),
92
156
  });
93
157
 
94
158
  /**
95
159
  * สถานะการลากตอนนี้ — ใช้ตัดสินใจเรนเดอร์ที่วางเอง เช่นแถว "ลากมาที่นี่เพื่อเอาออกจากกลุ่ม"
96
160
  * ที่ควรโผล่เฉพาะตอน `dragging === "item"` (กลุ่มที่ไม่มีหัวกลุ่มต้องมีที่วางของตัวเอง)
161
+ * `selected`/`clearSelection` ใช้ทำแถบ "เลือก N รายการ" เมื่อเปิด `multiSelect`
97
162
  */
98
- 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
+ } {
99
169
  const state = useContext(SortableStateContext);
100
- 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
+ };
101
176
  }
102
177
 
103
178
  /** ข้อมูลของสิ่งที่กำลังลาก ส่งให้แอปประกอบข้อความเอง (แอปรู้ชื่อคีย์/ชื่อกลุ่ม แพ็กเกจไม่รู้) */
@@ -109,6 +184,8 @@ export interface SortableAnnouncementContext {
109
184
  /** ตำแหน่งในกลุ่ม/ในลำดับกลุ่ม เริ่มที่ 1 */
110
185
  index: number;
111
186
  total: number;
187
+ /** จำนวนรายการที่ลากไปด้วยกัน (ลากแบบ bulk > 1) */
188
+ count: number;
112
189
  }
113
190
 
114
191
  export interface SortableAnnouncements {
@@ -155,7 +232,17 @@ export interface SortableProviderProps {
155
232
  * ส่งมา = แถวต้นทางกลายเป็นช่องว่างจาง ๆ บอกตำแหน่งที่จะวาง และมีแอนิเมชันตอนปล่อย
156
233
  * วาดใน portal ที่ `body` จึงคืน `<div>` ได้แม้รายการจริงเป็นแถว `<tr>` ของตาราง
157
234
  */
158
- renderOverlay?: (drag: { kind: SortableKind; id: string }) => ReactNode;
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;
159
246
  children: ReactNode;
160
247
  }
161
248
 
@@ -179,13 +266,25 @@ export function SortableProvider({
179
266
  announcements,
180
267
  id,
181
268
  renderOverlay,
269
+ multiSelect = false,
270
+ selected: selectedProp,
271
+ onSelectedChange,
182
272
  children,
183
273
  }: SortableProviderProps) {
184
274
  const fallbackId = useId();
185
275
  const text = useUiText();
186
276
  const mounted = useMounted();
187
277
  const [dragging, setDragging] = useState<SortableKind | null>(null);
188
- const [activeDrag, setActiveDrag] = useState<{ kind: SortableKind; id: string } | 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, []);
189
288
  // กันกระพริบตอนย้ายข้ามกลุ่ม: จำเป้าหมายล่าสุดไว้ใช้ตอนที่ layout กำลังขยับแล้วหาเป้าหมายไม่เจอ
190
289
  const lastOverId = useRef<UniqueIdentifier | null>(null);
191
290
  const movedAcross = useRef(false);
@@ -194,6 +293,147 @@ export function SortableProvider({
194
293
  const latest = useRef(order);
195
294
  latest.current = order;
196
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
+
197
437
  // ย้ายข้ามกลุ่มเสร็จแล้ว (render ลำดับใหม่ไปแล้ว) ค่อยปลดธงในเฟรมถัดไป
198
438
  useEffect(() => {
199
439
  const frame = requestAnimationFrame(() => {
@@ -253,11 +493,51 @@ export function SortableProvider({
253
493
  beforeDrag.current = latest.current;
254
494
  lastOverId.current = null;
255
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
+ }
256
513
  setDragging(kind);
257
- setActiveDrag({ kind, id: rawOf(active.id) });
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);
258
534
  }
259
535
 
260
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
+ }
261
541
  if (!over || kindOf(active.id) !== "i") return;
262
542
  const current = latest.current;
263
543
  const from = containerOf(active.id, current);
@@ -308,9 +588,9 @@ export function SortableProvider({
308
588
 
309
589
  function onDragEnd({ active, over }: DragEndEvent) {
310
590
  const snapshot = beforeDrag.current;
591
+ const carried = bulk.current;
311
592
  beforeDrag.current = null;
312
- setDragging(null);
313
- setActiveDrag(null);
593
+ endDrag();
314
594
  const current = latest.current;
315
595
  let next = current;
316
596
  if (over && kindOf(active.id) === "g" && kindOf(over.id) === "g") {
@@ -318,6 +598,8 @@ export function SortableProvider({
318
598
  } else if (over && kindOf(active.id) === "i" && kindOf(over.id) === "i") {
319
599
  next = reorderItems(current, rawOf(active.id), rawOf(over.id));
320
600
  }
601
+ // bulk: ตัวที่เลือกไว้ตัวอื่นตามมารวมที่จุดวางของตัวที่จับ (เรียงตามลำดับเดิม)
602
+ if (carried) next = gatherItems(next, rawOf(active.id), carried);
321
603
  if (next !== current) {
322
604
  latest.current = next;
323
605
  onOrderChange(next);
@@ -328,8 +610,7 @@ export function SortableProvider({
328
610
  function onDragCancel() {
329
611
  const snapshot = beforeDrag.current;
330
612
  beforeDrag.current = null;
331
- setDragging(null);
332
- setActiveDrag(null);
613
+ endDrag();
333
614
  if (snapshot) {
334
615
  latest.current = snapshot;
335
616
  onOrderChange(snapshot);
@@ -351,6 +632,7 @@ export function SortableProvider({
351
632
  kind: "group",
352
633
  index: current.groups.indexOf(raw) + 1,
353
634
  total: current.groups.length,
635
+ count: 1,
354
636
  };
355
637
  }
356
638
  const group = groupOfItem(current, raw);
@@ -361,6 +643,7 @@ export function SortableProvider({
361
643
  groupId: group,
362
644
  index: list.indexOf(raw) + 1,
363
645
  total: list.length,
646
+ count: bulk.current?.length ?? 1,
364
647
  };
365
648
  }, []);
366
649
 
@@ -397,12 +680,15 @@ export function SortableProvider({
397
680
  ) =>
398
681
  (template ?? fallback)
399
682
  .replace("{index}", String(ctx.index))
400
- .replace("{total}", String(ctx.total));
683
+ .replace("{total}", String(ctx.total))
684
+ .replace("{count}", String(ctx.count));
401
685
  const announce =
402
686
  (
403
687
  own: ((context: SortableAnnouncementContext) => string | undefined) | undefined,
404
688
  template: string | undefined,
405
689
  fallback: string,
690
+ // ประโยคแทนเมื่อลากหลายรายการพร้อมกัน
691
+ many: { template: string | undefined; fallback: string } | null,
406
692
  // ระหว่างลาก = ตำแหน่งของเป้าหมาย; ตอนปล่อย order อัปเดตแล้ว จึงใช้ตำแหน่งจริงของตัวที่ลาก
407
693
  useTarget = false,
408
694
  ) =>
@@ -416,39 +702,84 @@ export function SortableProvider({
416
702
  const context = useTarget
417
703
  ? describeTarget(active.id, over?.id ?? null)
418
704
  : describe(active.id);
419
- 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);
420
712
  };
421
713
  return {
422
714
  onDragStart: announce(
423
715
  announcements?.onDragStart,
424
716
  text.sortable?.started,
425
717
  "เริ่มลากรายการที่ {index} จาก {total}",
718
+ { template: text.sortable?.startedMany, fallback: "เริ่มลาก {count} รายการที่เลือกไว้" },
426
719
  ),
427
720
  onDragOver: announce(
428
721
  announcements?.onDragOver,
429
722
  text.sortable?.moved,
430
723
  "ย้ายไปตำแหน่งที่ {index} จาก {total}",
724
+ null,
431
725
  true,
432
726
  ),
433
727
  onDragEnd: announce(
434
728
  announcements?.onDragEnd,
435
729
  text.sortable?.dropped,
436
730
  "วางที่ตำแหน่งที่ {index} จาก {total}",
731
+ null,
732
+ ),
733
+ onDragCancel: announce(
734
+ announcements?.onDragCancel,
735
+ text.sortable?.cancelled,
736
+ "ยกเลิกการลาก",
737
+ null,
437
738
  ),
438
- onDragCancel: announce(announcements?.onDragCancel, text.sortable?.cancelled, "ยกเลิกการลาก"),
439
739
  };
440
740
  }, [announcements, describeTarget, text.sortable]);
441
741
 
442
742
  const groupIds = useMemo(() => order.groups.map((id) => dndId("group", id)), [order.groups]);
443
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
+
444
753
  const state = useMemo<SortableState>(
445
754
  () => ({
446
755
  dragging,
447
756
  disabled,
448
757
  handleLabel: handleLabel ?? text.sortable?.handle ?? "ลากเพื่อจัดลำดับ",
449
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,
450
767
  }),
451
- [dragging, disabled, handleLabel, text.sortable?.handle, renderOverlay],
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
+ ],
452
783
  );
453
784
 
454
785
  return (
@@ -458,13 +789,15 @@ export function SortableProvider({
458
789
  sensors={sensors}
459
790
  collisionDetection={collisionDetection}
460
791
  // วัดตำแหน่งที่วางใหม่ตลอดระหว่างลาก — ย้ายข้ามกลุ่มแล้ว layout เปลี่ยน ต้องใช้ตำแหน่งปัจจุบัน
461
- measuring={{ droppable: { strategy: MeasuringStrategy.Always } }}
792
+ // (ตอนลากกลุ่ม วัดหัวกลุ่มรวมแถวรายการทั้งก้อน — ดู measureDroppable)
793
+ measuring={measuring}
462
794
  accessibility={{
463
795
  screenReaderInstructions: { draggable: instructions },
464
796
  announcements: dndAnnouncements,
465
797
  }}
466
798
  onDragStart={onDragStart}
467
799
  onDragOver={onDragOver}
800
+ onDragMove={onDragMove}
468
801
  onDragEnd={onDragEnd}
469
802
  onDragCancel={onDragCancel}
470
803
  >
@@ -511,8 +844,18 @@ export interface SortableRow {
511
844
  /** props ที่ต้องแปะที่ "ที่จับ" — ส่งต่อให้ `<DragHandle {...handleProps} />` */
512
845
  handleProps: Record<string, unknown>;
513
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>;
514
854
  }
515
855
 
856
+ const INTERACTIVE =
857
+ "a,button,input,select,textarea,label,[contenteditable=''],[contenteditable='true'],[role=button],[role=checkbox],[role=switch],[role=combobox]";
858
+
516
859
  /**
517
860
  * ทำให้แถวหนึ่งลากได้ — แอปเอา `ref`/`style` ไปแปะที่ `<TableRow>` และ `handleProps` ที่ `<DragHandle>`
518
861
  * `type: "group"` = ลากทั้งกลุ่ม, `"item"` = ลากรายการในกลุ่ม
@@ -533,21 +876,105 @@ export function useSortableRow({
533
876
  disabled: disabled || state.disabled,
534
877
  transition: SORT_TRANSITION,
535
878
  });
536
- const dragStyle: CSSProperties = !sortable.isDragging
537
- ? {}
538
- : state.overlay
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);
885
+ },
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
539
914
  ? // มี overlay ลอยตามเมาส์แล้ว แถวต้นทางเหลือเป็นช่องจาง ๆ บอกตำแหน่งที่จะวาง
540
- { opacity: 0.4 }
541
- : { position: "relative", zIndex: 1, opacity: 0.85 };
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
+
542
971
  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 },
972
+ ref,
973
+ style,
974
+ handleProps,
550
975
  isDragging: sortable.isDragging,
976
+ selected: isSelected,
977
+ selectProps,
551
978
  };
552
979
  }
553
980
 
@@ -617,6 +1044,7 @@ export function DragHandle({
617
1044
  aria-label={label ?? state.handleLabel}
618
1045
  disabled={off}
619
1046
  className={cn("cursor-grab touch-none active:cursor-grabbing", className)}
1047
+ data-sortable-handle=""
620
1048
  {...handleProps}
621
1049
  >
622
1050
  <GripVerticalIcon />
@@ -105,6 +105,31 @@ export function moveItemToGroup(
105
105
  };
106
106
  }
107
107
 
108
+ /** รายการทั้งหมดเรียงตามที่แสดง: ของที่ไม่มีกลุ่มก่อน แล้วไล่ตามลำดับกลุ่ม */
109
+ export function flatItems(order: SortableOrder): string[] {
110
+ return [UNGROUPED, ...order.groups].flatMap((group) => order.items[group] ?? []);
111
+ }
112
+
113
+ /**
114
+ * ลากหลายรายการพร้อมกัน (bulk): ดึงรายการใน `ids` ทั้งหมดมารวมที่ตำแหน่งของ `activeId`
115
+ * (ตัวที่ผู้ใช้จับลาก) เรียงตามลำดับใน `ids` — ตัวอื่นหายจากที่เดิม ไม่ว่าจะอยู่กลุ่มไหน
116
+ */
117
+ export function gatherItems(
118
+ order: SortableOrder,
119
+ activeId: string,
120
+ ids: ReadonlyArray<string>,
121
+ ): SortableOrder {
122
+ const present = new Set(flatItems(order));
123
+ const moving = ids.filter((id) => present.has(id));
124
+ if (moving.length <= 1 || !moving.includes(activeId)) return order;
125
+ const others = new Set(moving.filter((id) => id !== activeId));
126
+ const items: Record<string, string[]> = {};
127
+ for (const [group, list] of Object.entries(order.items)) {
128
+ items[group] = list.flatMap((id) => (others.has(id) ? [] : id === activeId ? moving : [id]));
129
+ }
130
+ return { ...order, items };
131
+ }
132
+
108
133
  /** ลำดับเหมือนกันไหม (ใช้ตัดสินว่าต้องบันทึกหรือไม่) */
109
134
  export function sameOrder(a: SortableOrder, b: SortableOrder): boolean {
110
135
  if (a === b) return true;
package/src/sortable.ts CHANGED
@@ -7,6 +7,7 @@ export {
7
7
  DragHandle,
8
8
  type SortableAnnouncementContext,
9
9
  type SortableAnnouncements,
10
+ type SortableDrag,
10
11
  SortableGroup,
11
12
  type SortableKind,
12
13
  SortableProvider,
@@ -17,6 +18,8 @@ export {
17
18
  useSortableState,
18
19
  } from "./components/sortable/sortable-list";
19
20
  export {
21
+ flatItems,
22
+ gatherItems,
20
23
  groupOfItem,
21
24
  moveItemToGroup,
22
25
  moveWithin,