@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.
@@ -1,308 +1,10 @@
1
- "use client";
2
-
3
- import { Button } from "@suphark/ui/components/ui/button";
4
- import { Calendar } from "@suphark/ui/components/ui/calendar";
5
- import {
6
- InputGroup,
7
- InputGroupAddon,
8
- InputGroupButton,
9
- InputGroupInput,
10
- } from "@suphark/ui/components/ui/input-group";
11
- import { Popover, PopoverContent, PopoverTrigger } from "@suphark/ui/components/ui/popover";
12
- import { formatRelativeThaiDate, formatThaiFullDate } from "@suphark/ui/lib/formatters";
13
- import { cn } from "@suphark/ui/lib/utils";
14
- import { addDays, addMonths, addWeeks, subDays, subMonths, subWeeks } from "date-fns";
15
- import { CalendarIcon, Check, Clock, X } from "lucide-react";
16
- import { useMemo, useState } from "react";
17
-
18
1
  /**
19
- * Smart date parser supporting intuitive Thai & international date inputs:
20
- * - "1/7" -> 1 July of current year
21
- * - "1/7/25" -> 1 July 2025 CE
22
- * - "1/7/68" -> 1 July 2025 CE (BE 2568)
23
- * - "1/7/2568" -> 1 July 2025 CE (BE 2568)
2
+ * ย้ายไป `components/shared/date/date-picker.tsx` แล้ว (0.40.0) — ไฟล์นี้ไม่ได้มาจาก shadcn CLI
3
+ * (registry ของ base-nova ไม่มี date-picker) แต่ยกมาจาก supplychain · คงไว้ให้ import path เดิมใช้ได้
24
4
  */
25
- export function parseSmartDateInput(input: string): Date | null {
26
- if (!input?.trim()) return null;
27
- const clean = input.trim().replace(/\s+/g, "");
28
-
29
- const parts = clean.split(/[/\-.]/);
30
- if (parts.length < 2 || parts.length > 3) return null;
31
-
32
- const day = parseInt(parts[0] ?? "", 10);
33
- const month = parseInt(parts[1] ?? "", 10);
34
- if (Number.isNaN(day) || Number.isNaN(month) || day < 1 || day > 31 || month < 1 || month > 12) {
35
- return null;
36
- }
37
-
38
- const currentYear = new Date().getFullYear();
39
- let year = currentYear;
40
-
41
- if (parts.length === 3 && parts[2]) {
42
- const rawYear = parseInt(parts[2], 10);
43
- if (Number.isNaN(rawYear)) return null;
44
-
45
- if (rawYear > 2400) {
46
- // Full Buddhist Era year (e.g. 2568 -> 2025 CE)
47
- year = rawYear - 543;
48
- } else if (rawYear >= 50 && rawYear < 100) {
49
- // Short BE year (e.g. 68 -> 2568 -> 2025 CE)
50
- year = 2500 + rawYear - 543;
51
- } else if (rawYear < 50) {
52
- // Short AD year (e.g. 25 -> 2025 CE)
53
- year = 2000 + rawYear;
54
- } else {
55
- year = rawYear;
56
- }
57
- }
58
-
59
- const d = new Date(year, month - 1, day);
60
- if (d.getFullYear() === year && d.getMonth() === month - 1 && d.getDate() === day) {
61
- return d;
62
- }
63
-
64
- return null;
65
- }
66
-
67
- interface DatePickerProps {
68
- id?: string;
69
- value: Date | string | number | null | undefined;
70
- onChange: (date: Date | null) => void;
71
- placeholder?: string;
72
- disabled?: boolean;
73
- className?: string;
74
- allowClear?: boolean;
75
- showPresets?: boolean;
76
- showSmartInput?: boolean;
77
- }
78
-
79
- export function DatePicker({
80
- id,
81
- value,
82
- onChange,
83
- placeholder = "เลือกวันที่อ้างอิงราคา...",
84
- disabled = false,
85
- className,
86
- allowClear = true,
87
- showPresets = true,
88
- showSmartInput = true,
89
- }: DatePickerProps) {
90
- const [open, setOpen] = useState(false);
91
- const [smartText, setSmartText] = useState("");
92
-
93
- const selectedDate = value ? new Date(value) : undefined;
94
- const isValidDate = selectedDate && !Number.isNaN(selectedDate.getTime());
95
-
96
- const parsedSmartDate = useMemo(() => parseSmartDateInput(smartText), [smartText]);
97
-
98
- const handleSelectDate = (date: Date | undefined) => {
99
- onChange(date || null);
100
- setOpen(false);
101
- setSmartText("");
102
- };
103
-
104
- const applyPreset = (date: Date | null) => {
105
- onChange(date);
106
- setOpen(false);
107
- setSmartText("");
108
- };
109
-
110
- const handleSmartSubmit = () => {
111
- if (parsedSmartDate) {
112
- onChange(parsedSmartDate);
113
- setOpen(false);
114
- setSmartText("");
115
- }
116
- };
117
-
118
- const today = new Date();
119
- const displayMonth = parsedSmartDate || (isValidDate ? selectedDate : undefined);
120
-
121
- return (
122
- <Popover
123
- open={open}
124
- onOpenChange={(o) => {
125
- setOpen(o);
126
- if (!o) setSmartText("");
127
- }}
128
- >
129
- <PopoverTrigger
130
- render={
131
- <Button
132
- id={id}
133
- variant="outline"
134
- disabled={disabled}
135
- className={cn(
136
- "h-8 w-full justify-between bg-background pr-2 pl-3 text-left font-normal text-xs",
137
- !isValidDate && "text-muted-foreground",
138
- className,
139
- )}
140
- />
141
- }
142
- >
143
- <div className="flex items-center gap-2 truncate">
144
- <CalendarIcon className="h-3.5 w-3.5 shrink-0 text-slate-500" />
145
- <span className="truncate">
146
- {isValidDate ? (
147
- <>
148
- <span className="font-medium text-foreground">
149
- {formatThaiFullDate(selectedDate)}
150
- </span>
151
- <span className="ml-1 text-muted-foreground text-xs">
152
- ({formatRelativeThaiDate(selectedDate)})
153
- </span>
154
- </>
155
- ) : (
156
- <span>{placeholder}</span>
157
- )}
158
- </span>
159
- </div>
160
- {allowClear && isValidDate && !disabled && (
161
- <span
162
- role="button"
163
- tabIndex={0}
164
- className="ml-1 cursor-pointer rounded p-0.5 text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-600"
165
- onClick={(e) => {
166
- e.stopPropagation();
167
- onChange(null);
168
- }}
169
- >
170
- <X className="h-3.5 w-3.5" />
171
- </span>
172
- )}
173
- </PopoverTrigger>
174
- <PopoverContent
175
- className="w-[320px] border bg-background p-3 shadow-xl sm:w-[340px]"
176
- align="start"
177
- >
178
- {/* Smart Date Text Input */}
179
- {showSmartInput && (
180
- <div className="mb-3 flex flex-col gap-1.5 border-b pb-2.5">
181
- <InputGroup className="h-8">
182
- <InputGroupInput
183
- value={smartText}
184
- onChange={(e) => setSmartText(e.target.value)}
185
- onKeyDown={(event) => {
186
- if (event.key === "Enter") {
187
- event.preventDefault();
188
- event.stopPropagation();
189
- handleSmartSubmit();
190
- }
191
- }}
192
- placeholder="เช่น 1/7, 1/7/25, 1-7-2568..."
193
- className="h-8 text-xs"
194
- />
195
- <InputGroupAddon align="inline-end">
196
- <InputGroupButton
197
- type="submit"
198
- variant="default"
199
- size="xs"
200
- onClick={handleSmartSubmit}
201
- disabled={!parsedSmartDate}
202
- >
203
- ตกลง
204
- </InputGroupButton>
205
- </InputGroupAddon>
206
- </InputGroup>
207
- {parsedSmartDate && (
208
- <span className="flex items-center gap-1 font-medium text-emerald-600 text-xs">
209
- <Check className="size-3" />
210
- {formatThaiFullDate(parsedSmartDate)}
211
- </span>
212
- )}
213
- </div>
214
- )}
215
-
216
- {/* Quick Presets Section (Flex Wrap & Clean Spacing) */}
217
- {showPresets && (
218
- <div className="mb-3 border-b pb-2.5">
219
- <div className="mb-1.5 flex items-center gap-1 font-semibold text-muted-foreground text-xs">
220
- <Clock className="h-3 w-3" />
221
- Quick Preset
222
- </div>
223
- <div className="flex flex-wrap items-center gap-1.5">
224
- <Button
225
- type="button"
226
- variant="outline"
227
- size="sm"
228
- className="h-6 px-2 font-normal text-xs hover:bg-slate-100"
229
- onClick={() => applyPreset(today)}
230
- >
231
- Today
232
- </Button>
233
- <Button
234
- type="button"
235
- variant="outline"
236
- size="sm"
237
- className="h-6 px-2 font-normal text-xs hover:bg-slate-100"
238
- onClick={() => applyPreset(subDays(today, 1))}
239
- >
240
- Yesterday
241
- </Button>
242
- <Button
243
- type="button"
244
- variant="outline"
245
- size="sm"
246
- className="h-6 px-2 font-normal text-xs hover:bg-slate-100"
247
- onClick={() => applyPreset(addDays(today, 1))}
248
- >
249
- Tomorrow
250
- </Button>
251
- <Button
252
- type="button"
253
- variant="outline"
254
- size="sm"
255
- className="h-6 px-2 font-normal text-xs hover:bg-slate-100"
256
- onClick={() => applyPreset(addWeeks(today, 1))}
257
- >
258
- +1 Week
259
- </Button>
260
- <Button
261
- type="button"
262
- variant="outline"
263
- size="sm"
264
- className="h-6 px-2 font-normal text-xs hover:bg-slate-100"
265
- onClick={() => applyPreset(subWeeks(today, 1))}
266
- >
267
- -1 Week
268
- </Button>
269
- <Button
270
- type="button"
271
- variant="outline"
272
- size="sm"
273
- className="h-6 px-2 font-normal text-xs hover:bg-slate-100"
274
- onClick={() => applyPreset(addMonths(today, 1))}
275
- >
276
- +1 Month
277
- </Button>
278
- <Button
279
- type="button"
280
- variant="outline"
281
- size="sm"
282
- className="h-6 px-2 font-normal text-xs hover:bg-slate-100"
283
- onClick={() => applyPreset(subMonths(today, 1))}
284
- >
285
- -1 Month
286
- </Button>
287
- </div>
288
- </div>
289
- )}
290
-
291
- {/* Visual DayPicker Calendar */}
292
- <div className="flex w-full justify-center">
293
- <Calendar
294
- mode="single"
295
- selected={isValidDate ? selectedDate : undefined}
296
- onSelect={handleSelectDate}
297
- disabled={disabled}
298
- captionLayout="dropdown"
299
- startMonth={new Date(1990, 0)}
300
- endMonth={new Date(2040, 11)}
301
- defaultMonth={displayMonth}
302
- className="p-0"
303
- />
304
- </div>
305
- </PopoverContent>
306
- </Popover>
307
- );
308
- }
5
+ export {
6
+ DatePicker,
7
+ type DatePickerProps,
8
+ parseSmartDateInput,
9
+ toDateInputValue,
10
+ } from "@suphark/ui/components/shared/date/date-picker";
package/src/index.ts CHANGED
@@ -76,6 +76,12 @@ export {
76
76
  } from "./components/shared/confirm-dialog";
77
77
  export { CountBadge } from "./components/shared/count-badge";
78
78
  export { DataList, DataRow } from "./components/shared/data-list";
79
+ export {
80
+ DatePicker,
81
+ type DatePickerProps,
82
+ parseSmartDateInput,
83
+ toDateInputValue,
84
+ } from "./components/shared/date/date-picker";
79
85
  export { DateRangePicker } from "./components/shared/date/date-range-picker";
80
86
  export { DescriptionList } from "./components/shared/description-list";
81
87
  export {
@@ -403,7 +409,6 @@ export {
403
409
  ContextMenuSubTrigger,
404
410
  ContextMenuTrigger,
405
411
  } from "./components/ui/context-menu";
406
- export { DatePicker, parseSmartDateInput } from "./components/ui/date-picker";
407
412
  export {
408
413
  Dialog,
409
414
  DialogClose,
@@ -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,