@suphark/ui 0.38.0 → 0.40.1

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.
@@ -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,