@suphark/ui 0.28.0 → 0.29.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,6 +2,25 @@
2
2
 
3
3
  รูปแบบตาม [Keep a Changelog](https://keepachangelog.com/) · semver: patch = หน้าตา/บั๊ก, minor = component/prop ใหม่, major = เปลี่ยน/ลบ props
4
4
 
5
+ ## [0.29.0] — 2026-10-05
6
+
7
+ ### Added
8
+
9
+ - `InlineEdit`: คุมโหมดแก้ไขจากข้างนอกได้ — `editing` + `onEditingChange` (controlled), `defaultEditing` และ `onCancel` (กด Esc/✕) สำหรับแอปที่เข้าโหมดแก้ไขด้วย F2 หรือเมนูคลิกขวา "เปลี่ยนชื่อ" (คำขอจาก Pi Files — เดิมต้อง `.click()` ปุ่มดินสอผ่าน DOM)
10
+ - **`@suphark/ui/sortable` — ลากจัดลำดับ 2 ระดับ (กลุ่ม + รายการในกลุ่ม)** entry ใหม่บน dnd-kit (แยกจาก barrel เหมือน `data-table`/`kanban` เพราะ dnd-kit สร้าง context ตอน import) — ยกสมองส่วนลากออกจากแอป ส่วนการเรนเดอร์แถวยังเป็นของแอป (ตาราง/การ์ด/รายการ) ตามสเปกของ Pi Config (`values-table.tsx`)
11
+ - `SortableProvider` — sensors (เมาส์ + คีย์บอร์ด), collision ที่กันลากข้ามชนิด (กลุ่มชนได้แค่กลุ่ม, รายการชนได้แค่รายการกับที่วางของกลุ่ม), ย้ายข้ามกลุ่มระหว่างลาก, commit ตอนปล่อย และ **คืนลำดับเดิมอัตโนมัติเมื่อบันทึกล้มเหลว** (throw หรือ `{ ok: false, error }`) — ไม่มี `onCommitError` = ขึ้น toast ให้; ส่งมา = ส่ง error ดิบให้แอปจัดการเอง ไม่กลืนเงียบ
12
+ - `SortableGroup` (ขอบเขตการเรียงของกลุ่ม), `useSortableRow({ type, id })` → `{ ref, style, handleProps, isDragging }`, `useSortableDropZone(groupId)` (แปะที่ไหนก็ได้ — หัวกลุ่ม หรือแถว placeholder ของกลุ่มที่ไม่มีหัว; กลุ่มที่ย่ออยู่ก็ยังรับการวางได้), `useSortableState()` → `{ dragging, disabled }`, `DragHandle` (ใช้ `Button` ของแพ็กเกจ จึงได้ tooltip จาก `aria-label`; ปิดการลากแล้วที่จับจางลงเอง หรือซ่อนไปเลยด้วย `hideWhenDisabled`)
13
+ - `announcements` + `screenReaderInstructions` ส่งเป็น prop ได้ (callback ได้ `{ id, kind, groupId, index, total }` ให้แอปประกอบข้อความจากชื่อจริง) ไม่ส่ง = แม่แบบจาก `UiText.sortable`
14
+ - helper แบบ pure ใน `lib/sortable-order`: `sortableOrderOf`, `reorderGroups`, `reorderItems`, `moveItemToGroup`, `groupOfItem`, `sameOrder`, `moveWithin`, `sortableOrderToPayload` (คืน `{ groups: string[], items: [{ groupId: string | null, itemIds }] }` — ลำดับกลุ่มกับรายการในกลุ่มครบในก้อนเดียว, `UNGROUPED` → `null`)
15
+ - `UiText.sortable` — `handle`, `instructions`, `started`, `moved`, `dropped`, `cancelled` (`{index}` / `{total}`)
16
+ - showcase: หน้า `/components/sortable-list` — ตาราง 2 ระดับ ลากเรียงกลุ่ม/คีย์/ย้ายข้ามกลุ่ม พร้อมแถว "ลากมาที่นี่เพื่อเอาออกจากกลุ่ม" ที่โผล่เฉพาะตอนลากคีย์
17
+
18
+ ## [0.28.1] — 2026-10-05
19
+
20
+ ### Fixed
21
+
22
+ - **`AppShell` ปุ่มย่อ/ขยายเมนูยังมี "Toggle Sidebar" อังกฤษค้างอยู่ใน `sr-only`** (รายงานจาก DueBell): `SidebarTrigger` ของ shadcn ฝังข้อความนั้นเป็น children ซึ่งแทนด้วย props ไม่ได้ (JSX children ชนะ) — 0.27.1 เปลี่ยนได้แค่ `aria-label` ตอนนี้ `AppShell` เรนเดอร์ปุ่มเองจาก `Button` ของแพ็กเกจ + `useSidebar()` จึงใช้ `UiText.toggleSidebar` ทั้งชื่อและ tooltip และไม่มีข้อความอังกฤษเหลือในปุ่ม (ยังคง `data-sidebar="trigger"` ไว้ให้ selector เดิมใช้ได้)
23
+
5
24
  ## [0.28.0] — 2026-10-05
6
25
 
7
26
  ### Added
package/README.md CHANGED
@@ -197,6 +197,32 @@ webview/automation บางตัวปิด `window.confirm` ทิ้ง (
197
197
 
198
198
  ต้องการ `alert` / `prompt` / `choose` (หลายปุ่ม) / `checklist` ด้วย หรือไม่อยาก render `confirmDialog` ทุกที่: ครอบ `<DialogsProvider>` ที่ root แล้ว `const dialogs = useDialogs()` — `await dialogs.prompt({ description: "ชื่อใหม่", defaultValue })` คืน string หรือ null; หัว/ปุ่มค่าเริ่มต้นจาก `UiText.ok/alertTitle/promptTitle/chooseTitle/checklistTitle`
199
199
 
200
+ ### ลากจัดลำดับ (`@suphark/ui/sortable`)
201
+
202
+ ลากเรียงกลุ่ม เรียงรายการในกลุ่ม และย้ายรายการข้ามกลุ่ม ในตาราง/รายการเดียวกัน — แพ็กเกจดูแล sensors, collision, การย้ายระหว่างลาก และ **คืนลำดับเดิมให้เองเมื่อบันทึกไม่สำเร็จ** ส่วนการเรนเดอร์แถวยังเป็นของแอป
203
+
204
+ ```tsx
205
+ const [order, setOrder] = useState(() =>
206
+ sortableOrderOf(groups, items, { groupId: (g) => g.id, itemId: (i) => i.id, itemGroupId: (i) => i.groupId }),
207
+ );
208
+
209
+ <SortableProvider order={order} onOrderChange={setOrder} onCommit={(next) => saveOrder(sortableOrderToPayload(next))}>
210
+ <SortableGroup itemIds={order.items[groupId] ?? []}>
211
+ {/* แต่ละแถว: const row = useSortableRow({ type: "item", id }); */}
212
+ <TableRow ref={row.ref} style={row.style}>
213
+ <TableCell><DragHandle label={`ลาก ${item.name}`} {...row.handleProps} /></TableCell>
214
+ …
215
+ </TableRow>
216
+ </SortableGroup>
217
+ </SortableProvider>
218
+ ```
219
+
220
+ - กลุ่มที่ไม่มีหัวกลุ่ม (เช่น "ยังไม่จัดกลุ่ม") ใช้ `useSortableDropZone(groupId)` แปะกับแถว placeholder ของตัวเอง และดูว่าควรแสดงเมื่อไหร่จาก `useSortableState().dragging`
221
+ - `onCommit` ล้มเหลว (throw หรือ `{ ok: false, error }`) = คืนลำดับเดิม + toast; ถ้าอยากจัดการ error เองใส่ `onCommitError`
222
+ - `sortableOrderToPayload(order)` คืน `{ groups, items }` — `groups` คือลำดับของกลุ่ม, `items` คือรายการในแต่ละกลุ่มเรียงตามนั้น (ของที่ยังไม่มีกลุ่มมาก่อนด้วย `groupId: null`)
223
+ - `SortableProvider disabled` (เช่นระหว่างกรอง ซึ่งลำดับที่เห็นไม่ใช่ลำดับจริง) ทำให้ `DragHandle` จางลงและกดไม่ได้ — ไม่อยากให้เห็นเลยใส่ `hideWhenDisabled`
224
+ - ข้อความ screen reader: ใส่ `announcements`/`screenReaderInstructions` เป็น prop (callback ได้ `{ id, kind, groupId, index, total }`) หรือปล่อยให้ใช้แม่แบบจาก `UiText.sortable`
225
+
200
226
  ### PWA (`@suphark/ui/pwa`)
201
227
 
202
228
  ```tsx
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@suphark/ui",
3
- "version": "0.28.0",
3
+ "version": "0.29.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 เอง",
@@ -44,6 +44,7 @@
44
44
  "./data-table": "./src/data-table.ts",
45
45
  "./pwa": "./src/pwa.ts",
46
46
  "./kanban": "./src/kanban.ts",
47
+ "./sortable": "./src/sortable.ts",
47
48
  "./styles.css": "./src/styles.css",
48
49
  "./components/shared/button": "./src/components/shared/button/index.ts",
49
50
  "./components/shared/theme": "./src/components/shared/theme/index.ts",
@@ -1,6 +1,6 @@
1
1
  "use client";
2
2
 
3
- import { SimpleTooltip } from "@suphark/ui/components/shared/simple-tooltip";
3
+ import { Button } from "@suphark/ui/components/shared/button/button";
4
4
  import { SkipLink } from "@suphark/ui/components/shared/skip-link";
5
5
  import { useUiText } from "@suphark/ui/components/shared/ui-text";
6
6
  import {
@@ -18,11 +18,10 @@ import {
18
18
  SidebarInset,
19
19
  SidebarProvider,
20
20
  SidebarRail,
21
- SidebarTrigger,
22
21
  useSidebar,
23
22
  } from "@suphark/ui/components/ui/sidebar";
24
23
  import { cn } from "@suphark/ui/lib/utils";
25
- import { Search, X } from "lucide-react";
24
+ import { PanelLeftIcon, Search, X } from "lucide-react";
26
25
  import Link from "next/link";
27
26
  import { usePathname } from "next/navigation";
28
27
  import {
@@ -78,7 +77,7 @@ export interface AppShellProps {
78
77
  /** ท้าย sidebar — ปกติคือ `<UserMenu variant="sidebar" />` */
79
78
  sidebarFooter?: ReactNode;
80
79
  /**
81
- * header ของเนื้อหา: `false` = ไม่มี header, `true`/ไม่ส่ง = SidebarTrigger + breadcrumb อัตโนมัติ
80
+ * header ของเนื้อหา: `false` = ไม่มี header, `true`/ไม่ส่ง = ปุ่มย่อ/ขยายเมนู + breadcrumb อัตโนมัติ
82
81
  * ส่ง object เพื่อกำหนดเอง
83
82
  */
84
83
  header?:
@@ -207,9 +206,7 @@ function SidebarShell({
207
206
  )}
208
207
  >
209
208
  <div className="flex h-12 min-w-0 flex-1 items-center gap-2 md:h-full">
210
- <SimpleTooltip content={toggleSidebarLabel}>
211
- <SidebarTrigger className="-ml-1" aria-label={toggleSidebarLabel} />
212
- </SimpleTooltip>
209
+ <SidebarToggle label={toggleSidebarLabel} />
213
210
  {breadcrumbs && (
214
211
  <>
215
212
  <Separator orientation="vertical" className="mr-2 hidden h-4 md:block" />
@@ -357,6 +354,28 @@ function TopShell({
357
354
  );
358
355
  }
359
356
 
357
+ /**
358
+ * ปุ่มย่อ/ขยายเมนูในหัวเรื่อง — เขียนเองแทน `SidebarTrigger` ของ shadcn เพราะตัวนั้นฝัง
359
+ * `<span className="sr-only">Toggle Sidebar</span>` เป็นภาษาอังกฤษไว้ใน children (แทนไม่ได้
360
+ * เพราะ JSX children ชนะ props) ตัวนี้ใช้ `UiText` และได้ tooltip จาก `Button` ของแพ็กเกจ
361
+ * คง `data-sidebar="trigger"` ไว้ให้แอปที่ยิง selector เดิมยังใช้ได้
362
+ */
363
+ function SidebarToggle({ label }: { label: string }) {
364
+ const { toggleSidebar } = useSidebar();
365
+ return (
366
+ <Button
367
+ data-sidebar="trigger"
368
+ variant="ghost"
369
+ size="icon-sm"
370
+ aria-label={label}
371
+ className="-ml-1"
372
+ onClick={toggleSidebar}
373
+ >
374
+ <PanelLeftIcon />
375
+ </Button>
376
+ );
377
+ }
378
+
360
379
  /** แผง sidebar — แยกเพื่อใช้ useSidebar (ล้างคำค้นเมื่อยุบ ไม่ให้เมนูถูกกรองทั้งที่มองไม่เห็นช่องค้นหา) */
361
380
  function ShellSidebar({
362
381
  brand,
@@ -20,6 +20,16 @@ export interface InlineEditProps {
20
20
  emptyLabel?: ReactNode;
21
21
  /** ตรวจก่อนบันทึก — คืนข้อความ = ไม่ผ่าน */
22
22
  validate?: (value: string) => string | null | undefined;
23
+ /**
24
+ * คุมโหมดแก้ไขจากข้างนอก (เช่นเข้าโหมดแก้ไขด้วย F2 หรือเมนูคลิกขวา "เปลี่ยนชื่อ")
25
+ * ส่ง `editing` คู่กับ `onEditingChange` = controlled; ไม่ส่ง = component คุมเอง
26
+ */
27
+ editing?: boolean;
28
+ onEditingChange?: (editing: boolean) => void;
29
+ /** เริ่มต้นอยู่ในโหมดแก้ไขเลย (uncontrolled — เช่นแถวที่เพิ่งสร้าง) */
30
+ defaultEditing?: boolean;
31
+ /** กด Esc / ปุ่ม ✕ (ไม่ถูกเรียกเมื่อบันทึกสำเร็จ) */
32
+ onCancel?: () => void;
23
33
  /** ค่าเดิมกับค่าใหม่เท่ากัน = ปิดโหมดแก้ไขโดยไม่เรียก `onSave` (ค่าเริ่มต้น true) */
24
34
  skipWhenUnchanged?: boolean;
25
35
  disabled?: boolean;
@@ -42,25 +52,49 @@ export function InlineEdit({
42
52
  placeholder,
43
53
  emptyLabel = "—",
44
54
  validate,
55
+ editing: editingProp,
56
+ onEditingChange,
57
+ defaultEditing = false,
58
+ onCancel,
45
59
  skipWhenUnchanged = true,
46
60
  disabled = false,
47
61
  size = "default",
48
62
  className,
49
63
  }: InlineEditProps) {
50
64
  const text = useUiText();
51
- const [editing, setEditing] = useState(false);
65
+ const [uncontrolledEditing, setUncontrolledEditing] = useState(defaultEditing);
66
+ const editing = editingProp ?? uncontrolledEditing;
52
67
  const [draft, setDraft] = useState(value);
53
68
  const [pending, setPending] = useState(false);
54
69
  const inputRef = useRef<HTMLInputElement>(null);
55
70
  // เลือกข้อความทั้งหมดครั้งแรกที่เข้าโหมดแก้ไข — พิมพ์ทับได้ทันที (โฟกัสรอบหลังไม่เลือกซ้ำ)
56
71
  const selectedOnOpen = useRef(false);
57
72
 
73
+ // เข้าโหมดแก้ไขจากข้างนอก (F2/เมนู) ก็ต้องเริ่มจากค่าปัจจุบันเหมือนกดปุ่มดินสอ
74
+ const wasEditing = useRef(editing);
75
+ if (editing && !wasEditing.current) {
76
+ setDraft(value);
77
+ selectedOnOpen.current = false;
78
+ }
79
+ wasEditing.current = editing;
80
+
81
+ function setEditing(next: boolean) {
82
+ if (editingProp === undefined) setUncontrolledEditing(next);
83
+ onEditingChange?.(next);
84
+ }
85
+
58
86
  function start() {
59
87
  setDraft(value);
60
88
  selectedOnOpen.current = false;
61
89
  setEditing(true);
62
90
  }
63
91
 
92
+ /** ยกเลิก (Esc / ปุ่ม ✕) — ต่างจากบันทึกสำเร็จตรงที่บอกแอปด้วย */
93
+ function cancel() {
94
+ setEditing(false);
95
+ onCancel?.();
96
+ }
97
+
64
98
  async function commit() {
65
99
  const next = draft.trim();
66
100
  if (skipWhenUnchanged && next === value.trim()) {
@@ -148,7 +182,7 @@ export function InlineEdit({
148
182
  void commit();
149
183
  } else if (e.key === "Escape") {
150
184
  e.preventDefault();
151
- setEditing(false);
185
+ cancel();
152
186
  }
153
187
  }}
154
188
  />
@@ -166,7 +200,7 @@ export function InlineEdit({
166
200
  size={compact ? "icon-xs" : "icon-sm"}
167
201
  aria-label={text.cancel}
168
202
  disabled={pending}
169
- onClick={() => setEditing(false)}
203
+ onClick={cancel}
170
204
  >
171
205
  <XIcon />
172
206
  </Button>
@@ -22,6 +22,16 @@ export interface UiText {
22
22
  actionFailed?: string;
23
23
  /** InlineEdit: ปุ่มดินสอ (optional — "แก้ไข") */
24
24
  edit?: string;
25
+ /** SortableList (optional) — ที่จับลาก + คำอธิบายสำหรับ screen reader */
26
+ sortable?: Partial<{
27
+ handle: string;
28
+ instructions: string;
29
+ /** ใช้ `{index}` / `{total}` */
30
+ started: string;
31
+ moved: string;
32
+ dropped: string;
33
+ cancelled: string;
34
+ }>;
25
35
  /** SelectionBar (optional) — `selected` ใช้ `{count}` */
26
36
  selection?: Partial<{ selected: string; clear: string }>;
27
37
  /** DataTable (optional) — `rowsSelected` ใช้ `{selected}`/`{total}`, `pageOf` ใช้ `{page}`/`{pages}` */
@@ -154,6 +164,14 @@ export const UI_TEXT_TH: UiText = {
154
164
  actionFailed: "ดำเนินการไม่สำเร็จ",
155
165
  edit: "แก้ไข",
156
166
  selection: { selected: "เลือก {count} รายการ", clear: "ล้างการเลือก" },
167
+ sortable: {
168
+ handle: "ลากเพื่อจัดลำดับ",
169
+ instructions: "กด Space เพื่อเริ่มลาก ใช้ปุ่มลูกศรเลื่อน แล้วกด Space อีกครั้งเพื่อวาง (Esc = ยกเลิก)",
170
+ started: "เริ่มลากรายการที่ {index} จาก {total}",
171
+ moved: "ย้ายไปตำแหน่งที่ {index} จาก {total}",
172
+ dropped: "วางที่ตำแหน่งที่ {index} จาก {total}",
173
+ cancelled: "ยกเลิกการลาก",
174
+ },
157
175
  table: {
158
176
  rowsPerPage: "แถวต่อหน้า",
159
177
  pageOf: "หน้า {page} จาก {pages}",
@@ -265,6 +283,15 @@ export const UI_TEXT_EN: UiText = {
265
283
  actionFailed: "Action failed",
266
284
  edit: "Edit",
267
285
  selection: { selected: "{count} selected", clear: "Clear selection" },
286
+ sortable: {
287
+ handle: "Drag to reorder",
288
+ instructions:
289
+ "Press Space to start dragging, use arrow keys to move, Space again to drop (Esc to cancel)",
290
+ started: "Picked up item {index} of {total}",
291
+ moved: "Moved to position {index} of {total}",
292
+ dropped: "Dropped at position {index} of {total}",
293
+ cancelled: "Dragging cancelled",
294
+ },
268
295
  table: {
269
296
  rowsPerPage: "Rows per page",
270
297
  pageOf: "Page {page} of {pages}",
@@ -0,0 +1,450 @@
1
+ "use client";
2
+
3
+ import {
4
+ type CollisionDetection,
5
+ closestCenter,
6
+ DndContext,
7
+ type DragEndEvent,
8
+ type DragOverEvent,
9
+ type DragStartEvent,
10
+ KeyboardSensor,
11
+ PointerSensor,
12
+ type UniqueIdentifier,
13
+ useDroppable,
14
+ useSensor,
15
+ useSensors,
16
+ } from "@dnd-kit/core";
17
+ import {
18
+ SortableContext,
19
+ sortableKeyboardCoordinates,
20
+ useSortable,
21
+ verticalListSortingStrategy,
22
+ } from "@dnd-kit/sortable";
23
+ import { CSS } from "@dnd-kit/utilities";
24
+ import { Button } from "@suphark/ui/components/shared/button/button";
25
+ import { useUiText } from "@suphark/ui/components/shared/ui-text";
26
+ import { errorMessage } from "@suphark/ui/lib/action-result";
27
+ import {
28
+ groupOfItem,
29
+ moveItemToGroup,
30
+ reorderGroups,
31
+ reorderItems,
32
+ type SortableOrder,
33
+ sameOrder,
34
+ UNGROUPED,
35
+ } from "@suphark/ui/lib/sortable-order";
36
+ import { cn } from "@suphark/ui/lib/utils";
37
+ import { GripVerticalIcon } from "lucide-react";
38
+ import {
39
+ type CSSProperties,
40
+ createContext,
41
+ type ReactNode,
42
+ useCallback,
43
+ useContext,
44
+ useMemo,
45
+ useRef,
46
+ useState,
47
+ } from "react";
48
+ import { toast } from "sonner";
49
+
50
+ export type SortableKind = "group" | "item";
51
+
52
+ // id ของ dnd-kit ต้องไม่ชนกันข้ามชนิด: กลุ่ม / รายการ / หัวกลุ่มที่ใช้เป็นที่วาง
53
+ const dndId = (kind: SortableKind | "zone", id: string) =>
54
+ `${kind === "group" ? "g" : kind === "item" ? "i" : "z"}:${id}`;
55
+ const kindOf = (id: UniqueIdentifier) => String(id).slice(0, 1);
56
+ const rawOf = (id: UniqueIdentifier) => String(id).slice(2);
57
+
58
+ /** ลากกลุ่มชนได้แค่กลุ่ม ลากรายการชนได้แค่รายการกับหัวกลุ่ม — ไม่งั้น closestCenter สลับชนิดกันมั่ว */
59
+ const collision: CollisionDetection = (args) => {
60
+ const draggingGroup = kindOf(args.active.id) === "g";
61
+ return closestCenter({
62
+ ...args,
63
+ droppableContainers: args.droppableContainers.filter(
64
+ (container) => (kindOf(container.id) === "g") === draggingGroup,
65
+ ),
66
+ });
67
+ };
68
+
69
+ interface SortableState {
70
+ /** กำลังลากชนิดไหนอยู่ (null = ไม่ได้ลาก) — ใช้ปิดที่วางของอีกชนิด */
71
+ dragging: SortableKind | null;
72
+ disabled: boolean;
73
+ handleLabel: string;
74
+ }
75
+
76
+ const SortableStateContext = createContext<SortableState>({
77
+ dragging: null,
78
+ disabled: true,
79
+ handleLabel: "ลากเพื่อจัดลำดับ",
80
+ });
81
+
82
+ /**
83
+ * สถานะการลากตอนนี้ — ใช้ตัดสินใจเรนเดอร์ที่วางเอง เช่นแถว "ลากมาที่นี่เพื่อเอาออกจากกลุ่ม"
84
+ * ที่ควรโผล่เฉพาะตอน `dragging === "item"` (กลุ่มที่ไม่มีหัวกลุ่มต้องมีที่วางของตัวเอง)
85
+ */
86
+ export function useSortableState(): { dragging: SortableKind | null; disabled: boolean } {
87
+ const state = useContext(SortableStateContext);
88
+ return { dragging: state.dragging, disabled: state.disabled };
89
+ }
90
+
91
+ /** ข้อมูลของสิ่งที่กำลังลาก ส่งให้แอปประกอบข้อความเอง (แอปรู้ชื่อคีย์/ชื่อกลุ่ม แพ็กเกจไม่รู้) */
92
+ export interface SortableAnnouncementContext {
93
+ id: string;
94
+ kind: SortableKind;
95
+ /** กลุ่มที่อยู่ตอนนี้ (เฉพาะ kind = "item") */
96
+ groupId?: string;
97
+ /** ตำแหน่งในกลุ่ม/ในลำดับกลุ่ม เริ่มที่ 1 */
98
+ index: number;
99
+ total: number;
100
+ }
101
+
102
+ export interface SortableAnnouncements {
103
+ onDragStart?: (context: SortableAnnouncementContext) => string | undefined;
104
+ onDragOver?: (context: SortableAnnouncementContext) => string | undefined;
105
+ onDragEnd?: (context: SortableAnnouncementContext) => string | undefined;
106
+ onDragCancel?: (context: SortableAnnouncementContext) => string | undefined;
107
+ }
108
+
109
+ export interface SortableProviderProps {
110
+ order: SortableOrder;
111
+ /** ลำดับเปลี่ยนระหว่างลาก (ย้ายข้ามกลุ่ม) และตอนปล่อย — แอปเก็บไว้ใน state ของตัวเอง */
112
+ onOrderChange: (order: SortableOrder) => void;
113
+ /**
114
+ * บันทึกลำดับใหม่ตอนปล่อย — throw หรือคืน `{ ok: false, error }` = ล้มเหลว
115
+ * ล้มเหลวแล้ว provider คืนลำดับเดิมให้เองและขึ้น toast (ไม่ต้องจัดการใน call site)
116
+ */
117
+ onCommit?: (order: SortableOrder) => void | Promise<unknown>;
118
+ /** ปิดการลากทั้งชุด (เช่นระหว่างค้นหา หรือผู้ใช้ไม่มีสิทธิ์แก้) */
119
+ disabled?: boolean;
120
+ /** aria-label ของที่จับ (ค่าเริ่มต้นจาก `UiText.sortable.handle`) */
121
+ handleLabel?: string;
122
+ /**
123
+ * `onCommit` ล้มเหลวแล้วทำอะไรต่อ — ไม่ส่ง = ขึ้น toast ข้อความของ error ให้เอง
124
+ * ส่งมา = แพ็กเกจไม่ขึ้น toast แต่ส่ง error ดิบให้แอปจัดการ (คืนลำดับเดิมให้เสมอทั้งสองทาง)
125
+ */
126
+ onCommitError?: (error: unknown) => void;
127
+ /** คำอธิบายวิธีลากด้วยคีย์บอร์ด (ไม่ส่ง = `UiText.sortable.instructions`) */
128
+ screenReaderInstructions?: string;
129
+ /**
130
+ * ข้อความประกาศระหว่างลากสำหรับ screen reader — แอปประกอบเองจากชื่อจริงของข้อมูล
131
+ * ไม่ส่ง = แม่แบบจาก `UiText.sortable` (`{index}` / `{total}`)
132
+ */
133
+ announcements?: SortableAnnouncements;
134
+ children: ReactNode;
135
+ }
136
+
137
+ /**
138
+ * บริบทการลากจัดลำดับ 2 ระดับ (กลุ่ม + รายการในกลุ่ม) — ยกสมองส่วนลากออกมาจากแอป
139
+ * การเรนเดอร์ยังเป็นของแอป (แถวตาราง/การ์ด/รายการ) ผ่าน `useSortableRow`
140
+ * ย้ายข้ามกลุ่มได้ระหว่างลาก และกลุ่มที่ย่ออยู่ก็ยังรับการวางได้ด้วย `useSortableDropZone`
141
+ *
142
+ * <SortableProvider order={order} onOrderChange={setOrder} onCommit={save}>
143
+ * <SortableGroup id={UNGROUPED} itemIds={order.items[UNGROUPED]}>…</SortableGroup>
144
+ * </SortableProvider>
145
+ */
146
+ export function SortableProvider({
147
+ order,
148
+ onOrderChange,
149
+ onCommit,
150
+ disabled = false,
151
+ handleLabel,
152
+ onCommitError,
153
+ screenReaderInstructions,
154
+ announcements,
155
+ children,
156
+ }: SortableProviderProps) {
157
+ const text = useUiText();
158
+ const [dragging, setDragging] = useState<SortableKind | null>(null);
159
+ const beforeDrag = useRef<SortableOrder | null>(null);
160
+ // อ่านลำดับล่าสุดตอนปล่อย (onDragOver เปลี่ยนไปหลายรอบแล้ว prop ยังไม่ทัน)
161
+ const latest = useRef(order);
162
+ latest.current = order;
163
+
164
+ const sensors = useSensors(
165
+ useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
166
+ useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
167
+ );
168
+
169
+ const containerOf = useCallback((id: UniqueIdentifier, current: SortableOrder) => {
170
+ if (kindOf(id) === "z") return rawOf(id);
171
+ return groupOfItem(current, rawOf(id));
172
+ }, []);
173
+
174
+ function onDragStart({ active }: DragStartEvent) {
175
+ beforeDrag.current = latest.current;
176
+ setDragging(kindOf(active.id) === "g" ? "group" : "item");
177
+ }
178
+
179
+ function onDragOver({ active, over }: DragOverEvent) {
180
+ if (!over || kindOf(active.id) !== "i") return;
181
+ const current = latest.current;
182
+ const from = containerOf(active.id, current);
183
+ const to = containerOf(over.id, current);
184
+ if (!from || !to || from === to) return;
185
+ const next = moveItemToGroup(
186
+ current,
187
+ rawOf(active.id),
188
+ to,
189
+ kindOf(over.id) === "i" ? rawOf(over.id) : null,
190
+ );
191
+ if (next !== current) {
192
+ latest.current = next;
193
+ onOrderChange(next);
194
+ }
195
+ }
196
+
197
+ /** ล้มเหลว = คืนลำดับเดิมเสมอ แล้วส่ง error ต่อให้แอป (ไม่มี `onCommitError` = ขึ้น toast ให้) */
198
+ function failed(error: unknown, snapshot: SortableOrder) {
199
+ onOrderChange(snapshot);
200
+ latest.current = snapshot;
201
+ if (onCommitError) onCommitError(error);
202
+ else toast.error(errorMessage(error, text.actionFailed ?? "ดำเนินการไม่สำเร็จ"));
203
+ }
204
+
205
+ async function commit(next: SortableOrder, snapshot: SortableOrder) {
206
+ if (!onCommit) return;
207
+ try {
208
+ const result = await onCommit(next);
209
+ // server action ที่คืน { ok: false, error } ถือว่าล้มเหลวเหมือน throw
210
+ if (result && typeof result === "object" && "ok" in result && result.ok === false) {
211
+ failed("error" in result ? result.error : undefined, snapshot);
212
+ }
213
+ } catch (err) {
214
+ failed(err, snapshot);
215
+ }
216
+ }
217
+
218
+ function onDragEnd({ active, over }: DragEndEvent) {
219
+ const snapshot = beforeDrag.current;
220
+ beforeDrag.current = null;
221
+ setDragging(null);
222
+ const current = latest.current;
223
+ let next = current;
224
+ if (over && kindOf(active.id) === "g" && kindOf(over.id) === "g") {
225
+ next = reorderGroups(current, rawOf(active.id), rawOf(over.id));
226
+ } else if (over && kindOf(active.id) === "i" && kindOf(over.id) === "i") {
227
+ next = reorderItems(current, rawOf(active.id), rawOf(over.id));
228
+ }
229
+ if (next !== current) {
230
+ latest.current = next;
231
+ onOrderChange(next);
232
+ }
233
+ if (snapshot && !sameOrder(snapshot, next)) void commit(next, snapshot);
234
+ }
235
+
236
+ function onDragCancel() {
237
+ const snapshot = beforeDrag.current;
238
+ beforeDrag.current = null;
239
+ setDragging(null);
240
+ if (snapshot) {
241
+ latest.current = snapshot;
242
+ onOrderChange(snapshot);
243
+ }
244
+ }
245
+
246
+ const instructions =
247
+ screenReaderInstructions ??
248
+ text.sortable?.instructions ??
249
+ "กด Space เพื่อเริ่มลาก ใช้ปุ่มลูกศรเลื่อน แล้วกด Space อีกครั้งเพื่อวาง (Esc = ยกเลิก)";
250
+
251
+ /** เหตุการณ์ของ dnd-kit → บริบทที่แอปอ่านง่าย (ตำแหน่งเริ่มที่ 1) */
252
+ const describe = useCallback((id: UniqueIdentifier): SortableAnnouncementContext => {
253
+ const current = latest.current;
254
+ const raw = rawOf(id);
255
+ if (kindOf(id) === "g") {
256
+ return {
257
+ id: raw,
258
+ kind: "group",
259
+ index: current.groups.indexOf(raw) + 1,
260
+ total: current.groups.length,
261
+ };
262
+ }
263
+ const group = groupOfItem(current, raw);
264
+ const list = group ? (current.items[group] ?? []) : [];
265
+ return {
266
+ id: raw,
267
+ kind: "item",
268
+ groupId: group,
269
+ index: list.indexOf(raw) + 1,
270
+ total: list.length,
271
+ };
272
+ }, []);
273
+
274
+ const dndAnnouncements = useMemo(() => {
275
+ const fill = (
276
+ template: string | undefined,
277
+ fallback: string,
278
+ ctx: SortableAnnouncementContext,
279
+ ) =>
280
+ (template ?? fallback)
281
+ .replace("{index}", String(ctx.index))
282
+ .replace("{total}", String(ctx.total));
283
+ const announce =
284
+ (
285
+ own: ((context: SortableAnnouncementContext) => string | undefined) | undefined,
286
+ template: string | undefined,
287
+ fallback: string,
288
+ ) =>
289
+ ({ active }: { active: { id: UniqueIdentifier } }) => {
290
+ const context = describe(active.id);
291
+ return own?.(context) ?? fill(template, fallback, context);
292
+ };
293
+ return {
294
+ onDragStart: announce(
295
+ announcements?.onDragStart,
296
+ text.sortable?.started,
297
+ "เริ่มลากรายการที่ {index} จาก {total}",
298
+ ),
299
+ onDragOver: announce(
300
+ announcements?.onDragOver,
301
+ text.sortable?.moved,
302
+ "ย้ายไปตำแหน่งที่ {index} จาก {total}",
303
+ ),
304
+ onDragEnd: announce(
305
+ announcements?.onDragEnd,
306
+ text.sortable?.dropped,
307
+ "วางที่ตำแหน่งที่ {index} จาก {total}",
308
+ ),
309
+ onDragCancel: announce(announcements?.onDragCancel, text.sortable?.cancelled, "ยกเลิกการลาก"),
310
+ };
311
+ }, [announcements, describe, text.sortable]);
312
+
313
+ const state = useMemo<SortableState>(
314
+ () => ({
315
+ dragging,
316
+ disabled,
317
+ handleLabel: handleLabel ?? text.sortable?.handle ?? "ลากเพื่อจัดลำดับ",
318
+ }),
319
+ [dragging, disabled, handleLabel, text.sortable?.handle],
320
+ );
321
+
322
+ return (
323
+ <SortableStateContext.Provider value={state}>
324
+ <DndContext
325
+ sensors={sensors}
326
+ collisionDetection={collision}
327
+ accessibility={{
328
+ screenReaderInstructions: { draggable: instructions },
329
+ announcements: dndAnnouncements,
330
+ }}
331
+ onDragStart={onDragStart}
332
+ onDragOver={onDragOver}
333
+ onDragEnd={onDragEnd}
334
+ onDragCancel={onDragCancel}
335
+ >
336
+ {children}
337
+ </DndContext>
338
+ </SortableStateContext.Provider>
339
+ );
340
+ }
341
+
342
+ /** ขอบเขตการเรียงของกลุ่มหนึ่ง — ครอบแถวของกลุ่มนั้น (ลำดับ id ต้องตรงกับที่เรนเดอร์) */
343
+ export function SortableGroup({
344
+ itemIds,
345
+ children,
346
+ }: {
347
+ itemIds: ReadonlyArray<string>;
348
+ children: ReactNode;
349
+ }) {
350
+ const ids = useMemo(() => itemIds.map((id) => dndId("item", id)), [itemIds]);
351
+ return (
352
+ <SortableContext items={ids} strategy={verticalListSortingStrategy}>
353
+ {children}
354
+ </SortableContext>
355
+ );
356
+ }
357
+
358
+ export interface SortableRow {
359
+ /** แปะที่ element ของแถว */
360
+ ref: (node: HTMLElement | null) => void;
361
+ style: CSSProperties;
362
+ /** props ที่ต้องแปะที่ "ที่จับ" — ส่งต่อให้ `<DragHandle {...handleProps} />` */
363
+ handleProps: Record<string, unknown>;
364
+ isDragging: boolean;
365
+ }
366
+
367
+ /**
368
+ * ทำให้แถวหนึ่งลากได้ — แอปเอา `ref`/`style` ไปแปะที่ `<TableRow>` และ `handleProps` ที่ `<DragHandle>`
369
+ * `type: "group"` = ลากทั้งกลุ่ม, `"item"` = ลากรายการในกลุ่ม
370
+ */
371
+ export function useSortableRow({
372
+ type,
373
+ id,
374
+ disabled,
375
+ }: {
376
+ type: SortableKind;
377
+ id: string;
378
+ /** ปิดเฉพาะแถวนี้ (ทั้งชุดปิดที่ `SortableProvider disabled`) */
379
+ disabled?: boolean;
380
+ }): SortableRow {
381
+ const state = useContext(SortableStateContext);
382
+ const sortable = useSortable({
383
+ id: dndId(type, id),
384
+ disabled: disabled || state.disabled,
385
+ });
386
+ return {
387
+ ref: sortable.setNodeRef,
388
+ style: {
389
+ transform: CSS.Translate.toString(sortable.transform),
390
+ transition: sortable.transition,
391
+ ...(sortable.isDragging ? { position: "relative", zIndex: 1, opacity: 0.85 } : {}),
392
+ },
393
+ handleProps: { ...sortable.attributes, ...sortable.listeners },
394
+ isDragging: sortable.isDragging,
395
+ };
396
+ }
397
+
398
+ /**
399
+ * ทำให้หัวกลุ่ม (หรือพื้นที่ว่างของกลุ่ม) รับการวางรายการได้ — กลุ่มที่ย่ออยู่ก็ยังรับได้
400
+ * ปิดเองเมื่อกำลังลากกลุ่ม (ไม่ใช่รายการ)
401
+ */
402
+ export function useSortableDropZone(groupId: string | null): {
403
+ ref: (node: HTMLElement | null) => void;
404
+ isOver: boolean;
405
+ } {
406
+ const state = useContext(SortableStateContext);
407
+ const droppable = useDroppable({
408
+ // `null` = กลุ่มของรายการที่ยังไม่มีกลุ่ม (เขียน `useSortableDropZone(null)` ได้เลย)
409
+ id: dndId("zone", groupId ?? UNGROUPED),
410
+ disabled: state.disabled || state.dragging !== "item",
411
+ });
412
+ return { ref: droppable.setNodeRef, isOver: droppable.isOver };
413
+ }
414
+
415
+ /**
416
+ * ปุ่มที่จับสำหรับลาก — ใช้ `Button` ของแพ็กเกจ จึงได้ tooltip จาก `aria-label` อัตโนมัติ
417
+ * ต้องกระจาย `handleProps` จาก `useSortableRow` ลงไปด้วยเสมอ
418
+ */
419
+ export function DragHandle({
420
+ label,
421
+ className,
422
+ disabled,
423
+ hideWhenDisabled = false,
424
+ ...handleProps
425
+ }: {
426
+ label?: string;
427
+ className?: string;
428
+ disabled?: boolean;
429
+ /** ปิดการลากแล้วไม่ต้องแสดงที่จับเลย (ค่าเริ่มต้นคือแสดงแบบจาง ๆ ให้รู้ว่ามีแต่ตอนนี้ลากไม่ได้) */
430
+ hideWhenDisabled?: boolean;
431
+ } & Record<string, unknown>) {
432
+ const state = useContext(SortableStateContext);
433
+ const off = disabled ?? state.disabled;
434
+ if (off && hideWhenDisabled) return null;
435
+ return (
436
+ <Button
437
+ variant="ghost"
438
+ size="icon-sm"
439
+ aria-label={label ?? state.handleLabel}
440
+ disabled={off}
441
+ className={cn("cursor-grab touch-none active:cursor-grabbing", className)}
442
+ {...handleProps}
443
+ >
444
+ <GripVerticalIcon />
445
+ </Button>
446
+ );
447
+ }
448
+
449
+ export type { SortableOrder };
450
+ export { UNGROUPED };
@@ -0,0 +1,142 @@
1
+ /**
2
+ * ลำดับของรายการ 2 ระดับ (กลุ่ม → รายการในกลุ่ม) แบบ pure — ไม่พึ่ง dnd-kit และไม่พึ่ง React
3
+ * ใช้โดย `@suphark/ui/sortable` และเทสต์ได้ตรง ๆ; แอปเอาไปสร้าง payload ตอนบันทึกได้เลย
4
+ */
5
+
6
+ /** รายการที่ยังไม่อยู่กลุ่มไหน — เก็บในถังนี้ (แสดงเป็นกลุ่มไม่มีชื่อ) */
7
+ export const UNGROUPED = "__ungrouped__";
8
+
9
+ export interface SortableOrder {
10
+ /** ลำดับของกลุ่ม (ไม่รวม `UNGROUPED`) */
11
+ groups: string[];
12
+ /** id ของรายการในแต่ละกลุ่ม — key เป็น id ของกลุ่มหรือ `UNGROUPED` */
13
+ items: Record<string, string[]>;
14
+ }
15
+
16
+ /** สร้างลำดับเริ่มต้นจากข้อมูลที่โหลดมา (กลุ่มเรียงตามที่ส่งมา รายการเรียงตามที่ส่งมา) */
17
+ export function sortableOrderOf<TGroup, TItem>(
18
+ groups: ReadonlyArray<TGroup>,
19
+ items: ReadonlyArray<TItem>,
20
+ select: {
21
+ groupId: (group: TGroup) => string;
22
+ itemId: (item: TItem) => string;
23
+ /** รายการนี้อยู่กลุ่มไหน — `null`/`undefined` = ยังไม่อยู่กลุ่มไหน */
24
+ itemGroupId: (item: TItem) => string | null | undefined;
25
+ },
26
+ ): SortableOrder {
27
+ const order: SortableOrder = { groups: [], items: { [UNGROUPED]: [] } };
28
+ for (const group of groups) {
29
+ const id = select.groupId(group);
30
+ order.groups.push(id);
31
+ order.items[id] = [];
32
+ }
33
+ for (const item of items) {
34
+ const container = select.itemGroupId(item) ?? UNGROUPED;
35
+ const list = order.items[container];
36
+ if (list) list.push(select.itemId(item));
37
+ else order.items[container] = [select.itemId(item)];
38
+ }
39
+ return order;
40
+ }
41
+
42
+ /** กลุ่มที่รายการนี้อยู่ (ไม่เจอ = undefined) */
43
+ export function groupOfItem(order: SortableOrder, itemId: string): string | undefined {
44
+ return Object.keys(order.items).find((group) => order.items[group]?.includes(itemId));
45
+ }
46
+
47
+ /** ย้ายของในอาเรย์เดียว (เหมือน `arrayMove` ของ dnd-kit แต่ไม่ต้อง import) */
48
+ export function moveWithin<T>(list: ReadonlyArray<T>, from: number, to: number): T[] {
49
+ const next = [...list];
50
+ const [moved] = next.splice(from, 1);
51
+ if (moved === undefined) return next;
52
+ next.splice(to, 0, moved);
53
+ return next;
54
+ }
55
+
56
+ /** เรียงกลุ่มใหม่: ย้าย `groupId` ไปอยู่ตำแหน่งของ `overGroupId` */
57
+ export function reorderGroups(
58
+ order: SortableOrder,
59
+ groupId: string,
60
+ overGroupId: string,
61
+ ): SortableOrder {
62
+ const from = order.groups.indexOf(groupId);
63
+ const to = order.groups.indexOf(overGroupId);
64
+ if (from < 0 || to < 0 || from === to) return order;
65
+ return { ...order, groups: moveWithin(order.groups, from, to) };
66
+ }
67
+
68
+ /** เรียงรายการในกลุ่มเดียวกัน: ย้าย `itemId` ไปอยู่ตำแหน่งของ `overItemId` */
69
+ export function reorderItems(
70
+ order: SortableOrder,
71
+ itemId: string,
72
+ overItemId: string,
73
+ ): SortableOrder {
74
+ const group = groupOfItem(order, itemId);
75
+ if (!group || group !== groupOfItem(order, overItemId)) return order;
76
+ const list = order.items[group] ?? [];
77
+ const from = list.indexOf(itemId);
78
+ const to = list.indexOf(overItemId);
79
+ if (from < 0 || to < 0 || from === to) return order;
80
+ return { ...order, items: { ...order.items, [group]: moveWithin(list, from, to) } };
81
+ }
82
+
83
+ /**
84
+ * ย้ายรายการไปกลุ่มอื่น — แทรกก่อน `beforeItemId` (ไม่ส่ง = ต่อท้ายกลุ่มปลายทาง)
85
+ * เรียกระหว่างลากได้ (ให้แถวไปโผล่ในกลุ่มใหม่ทันที) เพราะคืน object ใหม่เมื่อมีอะไรเปลี่ยนจริงเท่านั้น
86
+ */
87
+ export function moveItemToGroup(
88
+ order: SortableOrder,
89
+ itemId: string,
90
+ toGroup: string,
91
+ beforeItemId?: string | null,
92
+ ): SortableOrder {
93
+ const from = groupOfItem(order, itemId);
94
+ if (!from || from === toGroup) return order;
95
+ const target = order.items[toGroup] ?? [];
96
+ const at = beforeItemId ? target.indexOf(beforeItemId) : -1;
97
+ const index = at >= 0 ? at : target.length;
98
+ return {
99
+ ...order,
100
+ items: {
101
+ ...order.items,
102
+ [from]: (order.items[from] ?? []).filter((id) => id !== itemId),
103
+ [toGroup]: [...target.slice(0, index), itemId, ...target.slice(index)],
104
+ },
105
+ };
106
+ }
107
+
108
+ /** ลำดับเหมือนกันไหม (ใช้ตัดสินว่าต้องบันทึกหรือไม่) */
109
+ export function sameOrder(a: SortableOrder, b: SortableOrder): boolean {
110
+ if (a === b) return true;
111
+ if (a.groups.length !== b.groups.length) return false;
112
+ if (a.groups.some((id, i) => b.groups[i] !== id)) return false;
113
+ const keys = new Set([...Object.keys(a.items), ...Object.keys(b.items)]);
114
+ for (const key of keys) {
115
+ const left = a.items[key] ?? [];
116
+ const right = b.items[key] ?? [];
117
+ if (left.length !== right.length) return false;
118
+ if (left.some((id, i) => right[i] !== id)) return false;
119
+ }
120
+ return true;
121
+ }
122
+
123
+ export interface SortableOrderPayload {
124
+ /** ลำดับของกลุ่ม (ไม่มี `UNGROUPED` — ของที่ไม่มีกลุ่มไม่ใช่กลุ่มจริง) */
125
+ groups: string[];
126
+ /** รายการในแต่ละกลุ่ม เรียงตามลำดับกลุ่ม โดยของที่ไม่มีกลุ่มมาก่อนด้วย `groupId: null` */
127
+ items: Array<{ groupId: string | null; itemIds: string[] }>;
128
+ }
129
+
130
+ /**
131
+ * แปลงเป็นรูปที่ส่งเข้า server action ได้ — คืนทั้งลำดับกลุ่มและรายการในกลุ่ม
132
+ * ชื่อฟิลด์เป็นศัพท์กลางของแพ็กเกจ (`groupId`) แอปที่ใช้ศัพท์อื่น (เช่น `sectionId`) rename ชั้นเดียวที่ปลายทาง
133
+ */
134
+ export function sortableOrderToPayload(order: SortableOrder): SortableOrderPayload {
135
+ return {
136
+ groups: [...order.groups],
137
+ items: [UNGROUPED, ...order.groups].map((group) => ({
138
+ groupId: group === UNGROUPED ? null : group,
139
+ itemIds: order.items[group] ?? [],
140
+ })),
141
+ };
142
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * @suphark/ui/sortable — ลากจัดลำดับ 2 ระดับ (กลุ่ม + รายการในกลุ่ม) บน dnd-kit
3
+ * แยกจาก barrel หลักเพราะ @dnd-kit/core สร้าง React context ตอน import (ใช้จาก server component ไม่ได้)
4
+ * เช่นเดียวกับ data-table/kanban — การเรนเดอร์แถวยังเป็นของแอป (ตาราง/การ์ด/รายการ)
5
+ */
6
+ export {
7
+ DragHandle,
8
+ type SortableAnnouncementContext,
9
+ type SortableAnnouncements,
10
+ SortableGroup,
11
+ type SortableKind,
12
+ SortableProvider,
13
+ type SortableProviderProps,
14
+ type SortableRow,
15
+ useSortableDropZone,
16
+ useSortableRow,
17
+ useSortableState,
18
+ } from "./components/sortable/sortable-list";
19
+ export {
20
+ groupOfItem,
21
+ moveItemToGroup,
22
+ moveWithin,
23
+ reorderGroups,
24
+ reorderItems,
25
+ type SortableOrder,
26
+ type SortableOrderPayload,
27
+ sameOrder,
28
+ sortableOrderOf,
29
+ sortableOrderToPayload,
30
+ UNGROUPED,
31
+ } from "./lib/sortable-order";