@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.
- package/CHANGELOG.md +51 -1
- package/README.md +1 -0
- package/package.json +1 -1
- package/src/components/design-system/sections/showcase-forms.tsx +1 -1
- package/src/components/shared/date/date-picker.tsx +377 -0
- package/src/components/shared/form/form-field-date.tsx +1 -1
- package/src/components/shared/ui-text.tsx +43 -0
- package/src/components/sortable/sortable-list.tsx +254 -71
- package/src/components/ui/date-picker.tsx +8 -306
- package/src/index.ts +6 -1
- package/src/lib/sortable-order.ts +170 -33
- package/src/sortable.ts +4 -0
|
@@ -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
|
-
*
|
|
20
|
-
* -
|
|
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
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
-
/**
|
|
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
|
|
63
|
-
const
|
|
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
|
|
138
|
+
return withRoot(order, moveWithin(root, from, to));
|
|
66
139
|
}
|
|
67
140
|
|
|
68
|
-
/**
|
|
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
|
-
|
|
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
|
-
...
|
|
204
|
+
...removed,
|
|
100
205
|
items: {
|
|
101
|
-
...
|
|
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
|
|
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(
|
|
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
|
-
|
|
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,
|