@suphark/ui 0.38.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.
package/CHANGELOG.md CHANGED
@@ -2,7 +2,27 @@
2
2
 
3
3
  รูปแบบตาม [Keep a Changelog](https://keepachangelog.com/) · semver: patch = หน้าตา/บั๊ก, minor = component/prop ใหม่, major = เปลี่ยน/ลบ props
4
4
 
5
- ## [0.38.0] — 2026-10-07
5
+ ## [0.39.0] — 2026-10-07
6
+
7
+ ### Added
8
+
9
+ - **`SortableProvider freeLayout` — ชั้นบนสุดจัดได้อิสระ** (เจ้าของแจ้งว่าเดิม "ล็อก" ให้รายการไม่มีกลุ่มอยู่บนเสมอ กลุ่มอยู่ล่างเสมอ): กลุ่มกับรายการไม่มีกลุ่มสลับที่กันได้ทุกแบบ — กลุ่มขึ้นไปเหนือรายการ, รายการไปอยู่ระหว่างกลุ่ม, เอารายการออกจากกลุ่มไปวางก่อนกลุ่มไหนก็ได้ · หัวกลุ่ม: **ครึ่งบน = วางก่อนกลุ่ม, ครึ่งล่าง = เข้ากลุ่มเป็นตัวแรก** · ระหว่างลากทุกอย่างในชั้นบนสุดเลื่อนหลบเป็นก้อน (กลุ่ม = หัว + รายการ) · โซนท้ายตาราง = วางท้ายสุด · ไม่เปิด = แบบเดิม (แอปที่ยังเรนเดอร์รายการไม่มีกลุ่มก่อนไม่เปลี่ยนพฤติกรรม)
10
+ - `SortableOrder.root` (optional) + `rootEntries(order)` (ลำดับชั้นบนสุดสำหรับวนเรนเดอร์), `moveRootEntry`, `moveItemToRoot`, type `SortableRootEntry` · `sortableOrderToPayload(order).root` + `sortableOrderOf(..., { root })` สำหรับบันทึก/โหลด — `groups` / `items[UNGROUPED]` ยังตรงกับ root เสมอ (payload เดิมใช้ต่อได้)
11
+ - showcase: `/components/sortable-list` เปิด `freeLayout` และเรนเดอร์ตาม `rootEntries`
12
+
13
+ ### Fixed
14
+
15
+ - (จาก 0.38.1 ที่ไม่ได้ขึ้น npm แยก) ลากรายการเข้ากลุ่มแล้ววางเป็นรายการแรกไม่ได้ — ดูด้านล่าง
16
+ - โซน "ท้ายตาราง" (`useSortableDropZone(UNGROUPED)`) ถูก collision เปลี่ยนเป้าหมายเป็นรายการไม่มีกลุ่มตัวแรก → วางแล้วไปโผล่บนสุดแทนท้ายสุด
17
+
18
+ ## [0.38.1] — 2026-10-07
19
+
20
+ > ประกาศพร้อม publish แล้ว แต่ยังไม่ขึ้น npm — รวมอยู่ใน 0.39.0
21
+
22
+ ### Fixed
23
+
24
+ - **`@suphark/ui/sortable` ลากรายการเข้ากลุ่มแล้ววางเป็นรายการแรกไม่ได้** (เจ้าของแจ้ง): ระหว่างลากขึ้นเป็นตัวแรกถูก แต่พอปล่อยกลับไปเป็นตัวที่สอง — ตอนชี้หัวกลุ่ม collision เลือก "รายการที่ใกล้ที่สุดที่ไม่ใช่ตัวที่ลาก" (= ตัวแรกเดิม) แล้ว `onDragEnd` ย้ายไปแทนที่ตัวนั้น · ตอนนี้ชี้หัวกลุ่ม = รายการแรกที่แสดงอยู่ **รวมตัวที่ลากเอง** · ยืนยันบน demo: ลากจากนอกกลุ่ม / จากกลุ่มอื่น / จากท้ายกลุ่มเดียวกันขึ้นหัวกลุ่ม → เป็นตัวแรกทุกกรณี, วางครึ่งล่างของแถวยังไปอยู่ "หลัง" แถวนั้นตามเดิม (ทำซ้ำบน production v0.38.0 ได้ก่อนแก้)
25
+
6
26
 
7
27
  ### Fixed
8
28
 
package/README.md CHANGED
@@ -235,6 +235,7 @@ const [order, setOrder] = useState(() =>
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
237
  - เลือกหลายรายการแล้วลากพร้อมกัน: `SortableProvider multiSelect` แล้วกระจาย `row.selectProps` ลงแถว (ทำสีด้วย `data-[selected]:bg-primary/10`) — ⌘/Ctrl+คลิก, Shift+คลิก, ที่จับกด `x` / ⌘/Ctrl+A, Esc ล้าง · แถบนับใช้ `SelectionBar` + `useSortableState().selected/clearSelection` · `renderOverlay` ได้ `drag.ids` ไว้ทำการ์ดซ้อน
238
+ - จัดชั้นบนสุดอิสระ (กลุ่มกับรายการไม่มีกลุ่มสลับกันได้): `SortableProvider freeLayout` + เรนเดอร์ตาม `rootEntries(order)` (ครอบทั้งหมดด้วย `<SortableGroup itemIds={order.items[UNGROUPED]}>` แล้วแต่ละกลุ่มมี `SortableGroup` ของตัวเองข้างใน) + บันทึก `payload.root` และส่งกลับ `sortableOrderOf(..., { root })` ตอนโหลด · หัวกลุ่มครึ่งบน = ก่อนกลุ่ม, ครึ่งล่าง = เข้ากลุ่ม
238
239
  - `sortableOrderToPayload(order)` คืน `{ groups, items }` — `groups` คือลำดับของกลุ่ม, `items` คือรายการในแต่ละกลุ่มเรียงตามนั้น (ของที่ยังไม่มีกลุ่มมาก่อนด้วย `groupId: null`)
239
240
  - `SortableProvider disabled` (เช่นระหว่างกรอง ซึ่งลำดับที่เห็นไม่ใช่ลำดับจริง) ทำให้ `DragHandle` จางลงและกดไม่ได้ — ไม่อยากให้เห็นเลยใส่ `hideWhenDisabled`
240
241
  - ข้อความ 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.38.0",
3
+ "version": "0.39.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 เอง",
@@ -38,8 +38,10 @@ import {
38
38
  gatherItems,
39
39
  groupOfItem,
40
40
  moveItemToGroup,
41
- reorderGroups,
41
+ moveItemToRoot,
42
+ moveRootEntry,
42
43
  reorderItems,
44
+ rootEntries,
43
45
  type SortableOrder,
44
46
  sameOrder,
45
47
  UNGROUPED,
@@ -110,6 +112,11 @@ function createShiftStore() {
110
112
  else shifts.delete(id);
111
113
  for (const listener of listeners) listener();
112
114
  },
115
+ clear() {
116
+ if (shifts.size === 0) return;
117
+ shifts.clear();
118
+ for (const listener of listeners) listener();
119
+ },
113
120
  subscribe(listener: () => void) {
114
121
  listeners.add(listener);
115
122
  return () => {
@@ -129,6 +136,9 @@ interface SortableState {
129
136
  overlay: boolean;
130
137
  /** กลุ่มที่กำลังถูกลาก (แถวรายการของกลุ่มนี้จางตามหัวกลุ่ม) */
131
138
  activeGroup: string | null;
139
+ /** กำลังลากของในชั้นบนสุด (กลุ่ม หรือรายการไม่มีกลุ่มเมื่อ `freeLayout`) — ระยะเลื่อนมาจาก provider */
140
+ rootDrag: boolean;
141
+ freeLayout: boolean;
132
142
  /** รายการอื่นที่ลากไปพร้อมกันแบบ bulk (ไม่รวมตัวที่จับ) — แสดงจาง ๆ ระหว่างลาก */
133
143
  carried: ReadonlySet<string>;
134
144
  multiSelect: boolean;
@@ -146,6 +156,8 @@ const SortableStateContext = createContext<SortableState>({
146
156
  handleLabel: "ลากเพื่อจัดลำดับ",
147
157
  overlay: false,
148
158
  activeGroup: null,
159
+ rootDrag: false,
160
+ freeLayout: false,
149
161
  carried: EMPTY,
150
162
  multiSelect: false,
151
163
  selected: EMPTY,
@@ -240,6 +252,13 @@ export interface SortableProviderProps {
240
252
  * ลากตัวที่เลือกอยู่ = ลากทุกตัวที่เลือกไปรวมที่จุดวาง เรียงตามลำดับเดิม
241
253
  */
242
254
  multiSelect?: boolean;
255
+ /**
256
+ * ชั้นบนสุดจัดได้อิสระ: กลุ่มกับรายการที่ไม่มีกลุ่มสลับที่กันได้ทุกแบบ (กลุ่มขึ้นไปเหนือรายการ /
257
+ * รายการไปอยู่ระหว่างกลุ่ม / เอารายการออกจากกลุ่มไปวางก่อนกลุ่มไหนก็ได้) — แอปต้องเรนเดอร์ตาม
258
+ * `rootEntries(order)` และบันทึก `sortableOrderToPayload(order).root` · ชี้ครึ่งบนของหัวกลุ่ม = วางก่อนกลุ่ม,
259
+ * ครึ่งล่าง = เข้ากลุ่มเป็นตัวแรก · ไม่เปิด = รายการไม่มีกลุ่มอยู่บนสุดเสมอ (แบบเดิม)
260
+ */
261
+ freeLayout?: boolean;
243
262
  /** รายการที่เลือก (ควบคุมจากภายนอก) — ไม่ส่ง = provider เก็บเอง */
244
263
  selected?: string[];
245
264
  onSelectedChange?: (ids: string[]) => void;
@@ -267,6 +286,7 @@ export function SortableProvider({
267
286
  id,
268
287
  renderOverlay,
269
288
  multiSelect = false,
289
+ freeLayout = false,
270
290
  selected: selectedProp,
271
291
  onSelectedChange,
272
292
  children,
@@ -395,40 +415,122 @@ export function SortableProvider({
395
415
  [blockRect],
396
416
  );
397
417
 
398
- // ---------- เลื่อนทั้งก้อนระหว่างลากกลุ่ม (แบบรายการ: กลุ่มอื่นหลบ ช่องว่างของกลุ่มที่ลากเลื่อนไปจุดวาง) ----------
399
- const blocks = useRef(new Map<string, { top: number; bottom: number }>());
400
- const [overGroup, setOverGroup] = useState<string | null>(null);
418
+ // ---------- เลื่อนทั้งก้อนในชั้นบนสุด (แบบรายการ: ตัวอื่นหลบ ช่องว่างของตัวที่ลากเลื่อนไปจุดวาง) ----------
419
+ const freeRef = useRef(freeLayout);
420
+ freeRef.current = freeLayout;
401
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
+
402
459
  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);
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();
407
466
  return;
408
467
  }
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;
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;
416
481
  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]);
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
+
432
534
  const measuring = useMemo(
433
535
  () => ({ droppable: { strategy: MeasuringStrategy.Always, measure: measureDroppable } }),
434
536
  [measureDroppable],
@@ -452,10 +554,16 @@ export function SortableProvider({
452
554
  */
453
555
  const collisionDetection: CollisionDetection = useCallback((args) => {
454
556
  if (kindOf(args.active.id) === "g") {
455
- return closestCenter({
456
- ...args,
457
- droppableContainers: args.droppableContainers.filter((c) => kindOf(c.id) === "g"),
458
- });
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);
459
567
  }
460
568
  const candidates = args.droppableContainers.filter((c) => kindOf(c.id) !== "g");
461
569
  const scoped = { ...args, droppableContainers: candidates };
@@ -463,14 +571,27 @@ export function SortableProvider({
463
571
  const hits = underPointer.length > 0 ? underPointer : rectIntersection(scoped);
464
572
  let overId = getFirstCollision(hits, "id");
465
573
  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)),
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)),
470
592
  );
471
- if (inGroup.length > 0) {
472
- overId = closestCenter({ ...args, droppableContainers: inGroup })[0]?.id ?? overId;
473
- }
593
+ const first = (latest.current.items[rawOf(overId)] ?? []).find((x) => rendered.has(x));
594
+ if (first !== undefined) overId = dndId("item", first);
474
595
  }
475
596
  lastOverId.current = overId;
476
597
  return [{ id: overId }];
@@ -503,21 +624,16 @@ export function SortableProvider({
503
624
  select("", "clear");
504
625
  }
505
626
  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
- }
627
+ setTarget(null);
513
628
  setDragging(kind);
514
629
  setActiveDrag({ kind, id: raw, ids: bulk.current ?? [raw] });
515
630
  }
516
631
 
517
- /** ไม่มี overlay: ก้อนของกลุ่มที่ลากลอยตามเมาส์ทั้งก้อน (หัวกลุ่ม + แถวรายการ) */
518
- function onDragMove({ active, delta }: DragMoveEvent) {
632
+ function onDragMove({ active, over, delta, activatorEvent }: DragMoveEvent) {
633
+ updateTarget(active, over, activatorEvent instanceof KeyboardEvent);
634
+ // ไม่มี overlay: ก้อนของกลุ่มที่ลากลอยตามเมาส์ทั้งก้อน (หัวกลุ่ม + แถวรายการ)
519
635
  if (overlayOn || kindOf(active.id) !== "g") return;
520
- shifts.set(rawOf(active.id), {
636
+ shifts.set(String(active.id), {
521
637
  transform: `translate3d(${delta.x}px, ${delta.y}px, 0)`,
522
638
  transition: undefined,
523
639
  });
@@ -526,23 +642,54 @@ export function SortableProvider({
526
642
  function endDrag() {
527
643
  draggingRef.current = null;
528
644
  bulk.current = null;
529
- setOverGroup(null);
645
+ setTarget(null);
530
646
  // ล้างระยะเลื่อนพร้อมกับที่ลำดับใหม่ render (รอ effect = มีเฟรมที่แถวใหม่ยังติด transform เก่า → กระพริบ)
531
- for (const g of nodes.current.groups.keys()) shifts.set(g, undefined);
647
+ shifts.clear();
532
648
  setDragging(null);
533
649
  setActiveDrag(null);
534
650
  }
535
651
 
536
- function onDragOver({ active, over }: DragOverEvent) {
537
- if (over && kindOf(active.id) === "g" && kindOf(over.id) === "g") {
538
- setOverGroup(rawOf(over.id));
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);
539
656
  return;
540
657
  }
541
- if (!over || kindOf(active.id) !== "i") return;
542
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
+ }
543
674
  const from = containerOf(active.id, current);
544
675
  const to = containerOf(over.id, current);
545
- 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
+ }
546
693
  // วางก่อนหรือหลังแถวเป้าหมาย ตามว่าตัวที่ลากเลยครึ่งแถวนั้นลงมาหรือยัง
547
694
  // (เดิมแทรก "ก่อน" เสมอ — ลากเข้าจากด้านล่างแล้วรายการกระโดดข้ามขึ้นไป)
548
695
  let before: string | null = null;
@@ -552,17 +699,12 @@ export function SortableProvider({
552
699
  const dragged = active.rect.current.translated;
553
700
  const below = dragged != null && dragged.top > over.rect.top + over.rect.height / 2;
554
701
  before = list[at + (below ? 1 : 0)] ?? null;
555
- } else if (to !== UNGROUPED) {
702
+ } else {
556
703
  // วางบนหัวโฟลเดอร์ = ขึ้นบนสุดของโฟลเดอร์ (ตรงกับตำแหน่งที่เห็น และไม่กระโดดตอนโฟลเดอร์ที่ย่อไว้เปิดออก)
557
704
  // ส่วนโซน "ไม่มีกลุ่ม" อยู่ท้ายตาราง จึงต่อท้าย
558
705
  before = current.items[to]?.[0] ?? null;
559
706
  }
560
- const next = moveItemToGroup(current, rawOf(active.id), to, before);
561
- if (next !== current) {
562
- movedAcross.current = true;
563
- latest.current = next;
564
- onOrderChange(next);
565
- }
707
+ apply(moveItemToGroup(current, raw, to, before));
566
708
  }
567
709
 
568
710
  /** ล้มเหลว = คืนลำดับเดิมเสมอ แล้วส่ง error ต่อให้แอป (ไม่มี `onCommitError` = ขึ้น toast ให้) */
@@ -589,12 +731,33 @@ export function SortableProvider({
589
731
  function onDragEnd({ active, over }: DragEndEvent) {
590
732
  const snapshot = beforeDrag.current;
591
733
  const carried = bulk.current;
734
+ // อ่านเป้าหมายก่อน endDrag (endDrag ล้างทิ้ง)
735
+ const finalTarget = targetRef.current;
592
736
  beforeDrag.current = null;
593
737
  endDrag();
594
738
  const current = latest.current;
595
739
  let next = current;
596
- if (over && kindOf(active.id) === "g" && kindOf(over.id) === "g") {
597
- 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
+ }
598
761
  } else if (over && kindOf(active.id) === "i" && kindOf(over.id) === "i") {
599
762
  next = reorderItems(current, rawOf(active.id), rawOf(over.id));
600
763
  }
@@ -742,6 +905,10 @@ export function SortableProvider({
742
905
  const groupIds = useMemo(() => order.groups.map((id) => dndId("group", id)), [order.groups]);
743
906
 
744
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));
745
912
  const carried = useMemo<ReadonlySet<string>>(
746
913
  () =>
747
914
  activeDrag && activeDrag.ids.length > 1
@@ -757,6 +924,8 @@ export function SortableProvider({
757
924
  handleLabel: handleLabel ?? text.sortable?.handle ?? "ลากเพื่อจัดลำดับ",
758
925
  overlay: Boolean(renderOverlay),
759
926
  activeGroup: activeDrag?.kind === "group" ? activeDrag.id : null,
927
+ rootDrag,
928
+ freeLayout,
760
929
  carried,
761
930
  multiSelect,
762
931
  selected,
@@ -772,6 +941,8 @@ export function SortableProvider({
772
941
  text.sortable?.handle,
773
942
  renderOverlay,
774
943
  activeDrag,
944
+ rootDrag,
945
+ freeLayout,
775
946
  carried,
776
947
  multiSelect,
777
948
  selected,
@@ -887,18 +1058,30 @@ export function useSortableRow({
887
1058
  );
888
1059
 
889
1060
  const transform = CSS.Translate.toString(sortable.transform);
890
- const groupDrag = state.dragging === "group";
891
1061
  // ระหว่างลากกลุ่ม provider คำนวณระยะเลื่อนของแต่ละกลุ่มเอง — หัวกลุ่มและแถวรายการของกลุ่มอ่านค่าเดียวกัน
892
1062
  // (หัวกลุ่มมักอยู่ใน `SortableGroup` ซึ่งเป็น context ของรายการ dnd-kit จึงคำนวณให้หัวกลุ่มไม่ได้)
893
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;
894
1068
  const shift = useSyncExternalStore(
895
1069
  shifts.subscribe,
896
- () => (groupId ? shifts.get(groupId) : undefined),
1070
+ () => (shiftKey ? shifts.get(shiftKey) : undefined),
897
1071
  () => undefined,
898
1072
  );
899
1073
 
900
1074
  let style: CSSProperties = { transform, transition: sortable.transition };
901
- if (type === "group" && groupDrag) {
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) {
902
1085
  const own = state.activeGroup === id;
903
1086
  style = {
904
1087
  transform: shift?.transform,
@@ -914,7 +1097,7 @@ export function useSortableRow({
914
1097
  ? // มี overlay ลอยตามเมาส์แล้ว แถวต้นทางเหลือเป็นช่องจาง ๆ บอกตำแหน่งที่จะวาง
915
1098
  { ...style, opacity: 0.4 }
916
1099
  : { ...style, position: "relative", zIndex: 1, opacity: 0.85 };
917
- } else if (type === "item" && groupDrag && shift) {
1100
+ } else if (type === "item" && state.rootDrag && shift) {
918
1101
  const own = state.activeGroup !== null && state.activeGroup === groupId;
919
1102
  style = {
920
1103
  transform: shift.transform,
@@ -1,18 +1,57 @@
1
1
  /**
2
2
  * ลำดับของรายการ 2 ระดับ (กลุ่ม → รายการในกลุ่ม) แบบ pure — ไม่พึ่ง dnd-kit และไม่พึ่ง React
3
3
  * ใช้โดย `@suphark/ui/sortable` และเทสต์ได้ตรง ๆ; แอปเอาไปสร้าง payload ตอนบันทึกได้เลย
4
+ *
5
+ * ชั้นบนสุด (root) มีทั้งกลุ่มและรายการที่ไม่มีกลุ่มปนกันได้ตามใจ — `root` เก็บลำดับนั้น
6
+ * ส่วน `groups` / `items[UNGROUPED]` เป็นมุมมองเดิมที่ได้จาก root (แอปเก่าที่ไม่รู้จัก root ยังอ่านได้)
4
7
  */
5
8
 
6
- /** รายการที่ยังไม่อยู่กลุ่มไหน — เก็บในถังนี้ (แสดงเป็นกลุ่มไม่มีชื่อ) */
9
+ /** รายการที่ยังไม่อยู่กลุ่มไหน — เก็บในถังนี้ */
7
10
  export const UNGROUPED = "__ungrouped__";
8
11
 
12
+ /** สิ่งหนึ่งในชั้นบนสุด: กลุ่ม หรือรายการที่ไม่มีกลุ่ม */
13
+ export interface SortableRootEntry {
14
+ kind: "group" | "item";
15
+ id: string;
16
+ }
17
+
9
18
  export interface SortableOrder {
10
19
  /** ลำดับของกลุ่ม (ไม่รวม `UNGROUPED`) */
11
20
  groups: string[];
12
21
  /** id ของรายการในแต่ละกลุ่ม — key เป็น id ของกลุ่มหรือ `UNGROUPED` */
13
22
  items: Record<string, string[]>;
23
+ /**
24
+ * ลำดับชั้นบนสุดที่กลุ่มกับรายการไม่มีกลุ่มสลับกันได้ — ไม่มี = รายการไม่มีกลุ่มอยู่บนสุดแล้วตามด้วยกลุ่ม
25
+ * (รูปแบบเดิม) · ใช้ `rootEntries(order)` อ่าน แทนการอ่านฟิลด์นี้ตรง ๆ
26
+ */
27
+ root?: SortableRootEntry[];
14
28
  }
15
29
 
30
+ /** ลำดับชั้นบนสุด (กลุ่ม + รายการไม่มีกลุ่ม) — แอปใช้วนเรนเดอร์ตามลำดับนี้ */
31
+ export function rootEntries(order: SortableOrder): SortableRootEntry[] {
32
+ if (order.root) return order.root;
33
+ return [
34
+ ...(order.items[UNGROUPED] ?? []).map((id) => ({ kind: "item" as const, id })),
35
+ ...order.groups.map((id) => ({ kind: "group" as const, id })),
36
+ ];
37
+ }
38
+
39
+ /** ตั้งชั้นบนสุดใหม่ แล้วให้ `groups` / `items[UNGROUPED]` ตรงกับมันเสมอ */
40
+ function withRoot(order: SortableOrder, root: SortableRootEntry[]): SortableOrder {
41
+ return {
42
+ ...order,
43
+ root,
44
+ groups: root.filter((e) => e.kind === "group").map((e) => e.id),
45
+ items: {
46
+ ...order.items,
47
+ [UNGROUPED]: root.filter((e) => e.kind === "item").map((e) => e.id),
48
+ },
49
+ };
50
+ }
51
+
52
+ const sameEntry = (a: SortableRootEntry, b: SortableRootEntry) =>
53
+ a.kind === b.kind && a.id === b.id;
54
+
16
55
  /** สร้างลำดับเริ่มต้นจากข้อมูลที่โหลดมา (กลุ่มเรียงตามที่ส่งมา รายการเรียงตามที่ส่งมา) */
17
56
  export function sortableOrderOf<TGroup, TItem>(
18
57
  groups: ReadonlyArray<TGroup>,
@@ -23,6 +62,13 @@ export function sortableOrderOf<TGroup, TItem>(
23
62
  /** รายการนี้อยู่กลุ่มไหน — `null`/`undefined` = ยังไม่อยู่กลุ่มไหน */
24
63
  itemGroupId: (item: TItem) => string | null | undefined;
25
64
  },
65
+ options?: {
66
+ /**
67
+ * ลำดับชั้นบนสุดที่บันทึกไว้ (จาก `sortableOrderToPayload(order).root`) — ไม่ส่ง = รายการไม่มีกลุ่มก่อน
68
+ * แล้วตามด้วยกลุ่ม · ของที่ไม่มีใน root ต่อท้าย ของใน root ที่ไม่มีอยู่แล้วถูกทิ้ง
69
+ */
70
+ root?: ReadonlyArray<SortableRootEntry>;
71
+ },
26
72
  ): SortableOrder {
27
73
  const order: SortableOrder = { groups: [], items: { [UNGROUPED]: [] } };
28
74
  for (const group of groups) {
@@ -36,10 +82,14 @@ export function sortableOrderOf<TGroup, TItem>(
36
82
  if (list) list.push(select.itemId(item));
37
83
  else order.items[container] = [select.itemId(item)];
38
84
  }
39
- return order;
85
+ if (!options?.root) return order;
86
+ const known = rootEntries(order);
87
+ const kept = options.root.filter((e) => known.some((k) => sameEntry(k, e)));
88
+ const rest = known.filter((k) => !kept.some((e) => sameEntry(k, e)));
89
+ return withRoot(order, [...kept, ...rest]);
40
90
  }
41
91
 
42
- /** กลุ่มที่รายการนี้อยู่ (ไม่เจอ = undefined) */
92
+ /** กลุ่มที่รายการนี้อยู่ (`UNGROUPED` = ชั้นบนสุด; ไม่เจอ = undefined) */
43
93
  export function groupOfItem(order: SortableOrder, itemId: string): string | undefined {
44
94
  return Object.keys(order.items).find((group) => order.items[group]?.includes(itemId));
45
95
  }
@@ -53,19 +103,42 @@ export function moveWithin<T>(list: ReadonlyArray<T>, from: number, to: number):
53
103
  return next;
54
104
  }
55
105
 
56
- /** เรียงกลุ่มใหม่: ย้าย `groupId` ไปอยู่ตำแหน่งของ `overGroupId` */
106
+ /**
107
+ * ย้ายสิ่งหนึ่งในชั้นบนสุดไปที่ตำแหน่ง `index` (นับในรายการที่ "เอาตัวมันออกแล้ว")
108
+ * ใช้ได้ทั้งกลุ่มและรายการไม่มีกลุ่ม — กลุ่มลงไปอยู่ระหว่างรายการไม่มีกลุ่มได้
109
+ */
110
+ export function moveRootEntry(
111
+ order: SortableOrder,
112
+ entry: SortableRootEntry,
113
+ index: number,
114
+ ): SortableOrder {
115
+ const root = rootEntries(order);
116
+ const from = root.findIndex((e) => sameEntry(e, entry));
117
+ if (from < 0) return order;
118
+ const rest = root.filter((_, i) => i !== from);
119
+ const at = Math.max(0, Math.min(index, rest.length));
120
+ if (at === from) return order;
121
+ return withRoot(order, [
122
+ ...rest.slice(0, at),
123
+ root[from] as SortableRootEntry,
124
+ ...rest.slice(at),
125
+ ]);
126
+ }
127
+
128
+ /** เรียงกลุ่มใหม่: ย้าย `groupId` ไปอยู่ตำแหน่งของ `overGroupId` (ในชั้นบนสุด) */
57
129
  export function reorderGroups(
58
130
  order: SortableOrder,
59
131
  groupId: string,
60
132
  overGroupId: string,
61
133
  ): SortableOrder {
62
- const from = order.groups.indexOf(groupId);
63
- const to = order.groups.indexOf(overGroupId);
134
+ const root = rootEntries(order);
135
+ const from = root.findIndex((e) => e.kind === "group" && e.id === groupId);
136
+ const to = root.findIndex((e) => e.kind === "group" && e.id === overGroupId);
64
137
  if (from < 0 || to < 0 || from === to) return order;
65
- return { ...order, groups: moveWithin(order.groups, from, to) };
138
+ return withRoot(order, moveWithin(root, from, to));
66
139
  }
67
140
 
68
- /** เรียงรายการในกลุ่มเดียวกัน: ย้าย `itemId` ไปอยู่ตำแหน่งของ `overItemId` */
141
+ /** เรียงรายการในกลุ่มเดียวกัน (รวมรายการไม่มีกลุ่มในชั้นบนสุด): ย้าย `itemId` ไปอยู่ตำแหน่งของ `overItemId` */
69
142
  export function reorderItems(
70
143
  order: SortableOrder,
71
144
  itemId: string,
@@ -73,6 +146,13 @@ export function reorderItems(
73
146
  ): SortableOrder {
74
147
  const group = groupOfItem(order, itemId);
75
148
  if (!group || group !== groupOfItem(order, overItemId)) return order;
149
+ if (group === UNGROUPED) {
150
+ const root = rootEntries(order);
151
+ const from = root.findIndex((e) => e.kind === "item" && e.id === itemId);
152
+ const to = root.findIndex((e) => e.kind === "item" && e.id === overItemId);
153
+ if (from < 0 || to < 0 || from === to) return order;
154
+ return withRoot(order, moveWithin(root, from, to));
155
+ }
76
156
  const list = order.items[group] ?? [];
77
157
  const from = list.indexOf(itemId);
78
158
  const to = list.indexOf(overItemId);
@@ -80,8 +160,25 @@ export function reorderItems(
80
160
  return { ...order, items: { ...order.items, [group]: moveWithin(list, from, to) } };
81
161
  }
82
162
 
163
+ /** เอารายการออกจากที่อยู่ปัจจุบัน (กลุ่ม หรือชั้นบนสุด) */
164
+ function removeItem(order: SortableOrder, itemId: string): SortableOrder {
165
+ const from = groupOfItem(order, itemId);
166
+ if (!from) return order;
167
+ if (from === UNGROUPED) {
168
+ return withRoot(
169
+ order,
170
+ rootEntries(order).filter((e) => !(e.kind === "item" && e.id === itemId)),
171
+ );
172
+ }
173
+ return {
174
+ ...order,
175
+ items: { ...order.items, [from]: (order.items[from] ?? []).filter((id) => id !== itemId) },
176
+ };
177
+ }
178
+
83
179
  /**
84
180
  * ย้ายรายการไปกลุ่มอื่น — แทรกก่อน `beforeItemId` (ไม่ส่ง = ต่อท้ายกลุ่มปลายทาง)
181
+ * `toGroup = UNGROUPED` = ออกมาอยู่ชั้นบนสุด (ก่อนรายการ `beforeItemId` หรือท้ายสุด) — จะวางก่อนกลุ่มใช้ `moveItemToRoot`
85
182
  * เรียกระหว่างลากได้ (ให้แถวไปโผล่ในกลุ่มใหม่ทันที) เพราะคืน object ใหม่เมื่อมีอะไรเปลี่ยนจริงเท่านั้น
86
183
  */
87
184
  export function moveItemToGroup(
@@ -92,27 +189,72 @@ export function moveItemToGroup(
92
189
  ): SortableOrder {
93
190
  const from = groupOfItem(order, itemId);
94
191
  if (!from || from === toGroup) return order;
95
- const target = order.items[toGroup] ?? [];
192
+ if (toGroup === UNGROUPED) {
193
+ const root = rootEntries(order);
194
+ const at = beforeItemId
195
+ ? root.findIndex((e) => e.kind === "item" && e.id === beforeItemId)
196
+ : -1;
197
+ return moveItemToRoot(order, itemId, at >= 0 ? (root[at] as SortableRootEntry) : null);
198
+ }
199
+ const removed = removeItem(order, itemId);
200
+ const target = removed.items[toGroup] ?? [];
96
201
  const at = beforeItemId ? target.indexOf(beforeItemId) : -1;
97
202
  const index = at >= 0 ? at : target.length;
98
203
  return {
99
- ...order,
204
+ ...removed,
100
205
  items: {
101
- ...order.items,
102
- [from]: (order.items[from] ?? []).filter((id) => id !== itemId),
206
+ ...removed.items,
103
207
  [toGroup]: [...target.slice(0, index), itemId, ...target.slice(index)],
104
208
  },
105
209
  };
106
210
  }
107
211
 
108
- /** รายการทั้งหมดเรียงตามที่แสดง: ของที่ไม่มีกลุ่มก่อน แล้วไล่ตามลำดับกลุ่ม */
212
+ /** ย้ายรายการออกมาอยู่ชั้นบนสุด ก่อน `before` (กลุ่มหรือรายการ; `null` = ท้ายสุด) */
213
+ export function moveItemToRoot(
214
+ order: SortableOrder,
215
+ itemId: string,
216
+ before: SortableRootEntry | null,
217
+ ): SortableOrder {
218
+ const from = groupOfItem(order, itemId);
219
+ if (!from) return order;
220
+ const removed = removeItem(order, itemId);
221
+ const root = rootEntries(removed);
222
+ const at = before ? root.findIndex((e) => sameEntry(e, before)) : -1;
223
+ const index = at >= 0 ? at : root.length;
224
+ const entry: SortableRootEntry = { kind: "item", id: itemId };
225
+ const next = [...root.slice(0, index), entry, ...root.slice(index)];
226
+ if (from === UNGROUPED && sameRoot(next, rootEntries(order))) return order;
227
+ return withRoot(removed, next);
228
+ }
229
+
230
+ function sameRoot(a: ReadonlyArray<SortableRootEntry>, b: ReadonlyArray<SortableRootEntry>) {
231
+ return a.length === b.length && a.every((e, i) => sameEntry(e, b[i] as SortableRootEntry));
232
+ }
233
+
234
+ /** ลำดับเหมือนกันไหม (ใช้ตัดสินว่าต้องบันทึกหรือไม่) */
235
+ export function sameOrder(a: SortableOrder, b: SortableOrder): boolean {
236
+ if (a === b) return true;
237
+ if (!sameRoot(rootEntries(a), rootEntries(b))) return false;
238
+ const keys = new Set([...Object.keys(a.items), ...Object.keys(b.items)]);
239
+ for (const key of keys) {
240
+ const left = a.items[key] ?? [];
241
+ const right = b.items[key] ?? [];
242
+ if (left.length !== right.length) return false;
243
+ if (left.some((id, i) => right[i] !== id)) return false;
244
+ }
245
+ return true;
246
+ }
247
+
248
+ /** รายการทั้งหมดเรียงตามที่แสดง: ไล่ชั้นบนสุด กลุ่มคลี่รายการของมันออกมา */
109
249
  export function flatItems(order: SortableOrder): string[] {
110
- return [UNGROUPED, ...order.groups].flatMap((group) => order.items[group] ?? []);
250
+ return rootEntries(order).flatMap((e) =>
251
+ e.kind === "item" ? [e.id] : (order.items[e.id] ?? []),
252
+ );
111
253
  }
112
254
 
113
255
  /**
114
256
  * ลากหลายรายการพร้อมกัน (bulk): ดึงรายการใน `ids` ทั้งหมดมารวมที่ตำแหน่งของ `activeId`
115
- * (ตัวที่ผู้ใช้จับลาก) เรียงตามลำดับใน `ids` — ตัวอื่นหายจากที่เดิม ไม่ว่าจะอยู่กลุ่มไหน
257
+ * (ตัวที่ผู้ใช้จับลาก) เรียงตามลำดับใน `ids` — ตัวอื่นหายจากที่เดิม ไม่ว่าจะอยู่กลุ่มไหนหรือชั้นบนสุด
116
258
  */
117
259
  export function gatherItems(
118
260
  order: SortableOrder,
@@ -123,26 +265,15 @@ export function gatherItems(
123
265
  const moving = ids.filter((id) => present.has(id));
124
266
  if (moving.length <= 1 || !moving.includes(activeId)) return order;
125
267
  const others = new Set(moving.filter((id) => id !== activeId));
268
+ const expand = (id: string) => (others.has(id) ? [] : id === activeId ? moving : [id]);
126
269
  const items: Record<string, string[]> = {};
127
270
  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
-
133
- /** ลำดับเหมือนกันไหม (ใช้ตัดสินว่าต้องบันทึกหรือไม่) */
134
- export function sameOrder(a: SortableOrder, b: SortableOrder): boolean {
135
- if (a === b) return true;
136
- if (a.groups.length !== b.groups.length) return false;
137
- if (a.groups.some((id, i) => b.groups[i] !== id)) return false;
138
- const keys = new Set([...Object.keys(a.items), ...Object.keys(b.items)]);
139
- for (const key of keys) {
140
- const left = a.items[key] ?? [];
141
- const right = b.items[key] ?? [];
142
- if (left.length !== right.length) return false;
143
- if (left.some((id, i) => right[i] !== id)) return false;
271
+ if (group !== UNGROUPED) items[group] = list.flatMap(expand);
144
272
  }
145
- return true;
273
+ const root = rootEntries(order).flatMap((e) =>
274
+ e.kind === "group" ? [e] : expand(e.id).map((id) => ({ kind: "item" as const, id })),
275
+ );
276
+ return withRoot({ ...order, items: { ...order.items, ...items } }, root);
146
277
  }
147
278
 
148
279
  export interface SortableOrderPayload {
@@ -150,10 +281,15 @@ export interface SortableOrderPayload {
150
281
  groups: string[];
151
282
  /** รายการในแต่ละกลุ่ม เรียงตามลำดับกลุ่ม โดยของที่ไม่มีกลุ่มมาก่อนด้วย `groupId: null` */
152
283
  items: Array<{ groupId: string | null; itemIds: string[] }>;
284
+ /**
285
+ * ลำดับชั้นบนสุด (กลุ่มกับรายการไม่มีกลุ่มปนกัน) — เก็บเป็นตำแหน่งเดียวกันของทั้งสองชนิด
286
+ * แล้วส่งกลับเข้า `sortableOrderOf(..., { root })` ตอนโหลด
287
+ */
288
+ root: SortableRootEntry[];
153
289
  }
154
290
 
155
291
  /**
156
- * แปลงเป็นรูปที่ส่งเข้า server action ได้ — คืนทั้งลำดับกลุ่มและรายการในกลุ่ม
292
+ * แปลงเป็นรูปที่ส่งเข้า server action ได้ — คืนทั้งลำดับกลุ่ม รายการในกลุ่ม และชั้นบนสุด
157
293
  * ชื่อฟิลด์เป็นศัพท์กลางของแพ็กเกจ (`groupId`) แอปที่ใช้ศัพท์อื่น (เช่น `sectionId`) rename ชั้นเดียวที่ปลายทาง
158
294
  */
159
295
  export function sortableOrderToPayload(order: SortableOrder): SortableOrderPayload {
@@ -163,5 +299,6 @@ export function sortableOrderToPayload(order: SortableOrder): SortableOrderPaylo
163
299
  groupId: group === UNGROUPED ? null : group,
164
300
  itemIds: order.items[group] ?? [],
165
301
  })),
302
+ root: rootEntries(order).map((e) => ({ ...e })),
166
303
  };
167
304
  }
package/src/sortable.ts CHANGED
@@ -22,11 +22,15 @@ export {
22
22
  gatherItems,
23
23
  groupOfItem,
24
24
  moveItemToGroup,
25
+ moveItemToRoot,
26
+ moveRootEntry,
25
27
  moveWithin,
26
28
  reorderGroups,
27
29
  reorderItems,
30
+ rootEntries,
28
31
  type SortableOrder,
29
32
  type SortableOrderPayload,
33
+ type SortableRootEntry,
30
34
  sameOrder,
31
35
  sortableOrderOf,
32
36
  sortableOrderToPayload,