@suphark/ui 0.28.0 → 0.30.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 +31 -0
- package/README.md +26 -0
- package/package.json +2 -1
- package/scripts/hardcoded-text.d.mts +3 -0
- package/scripts/hardcoded-text.mjs +101 -0
- package/src/components/app-shell/app-shell.tsx +27 -8
- package/src/components/app-shell/sub-nav.tsx +8 -4
- package/src/components/app-shell/top-tabs.tsx +6 -1
- package/src/components/data-table/data-table-faceted-filter.tsx +3 -1
- package/src/components/data-table/data-table-toolbar.tsx +13 -6
- package/src/components/shared/form/form-field-combobox.tsx +9 -2
- package/src/components/shared/inline-edit.tsx +37 -3
- package/src/components/shared/layout/error-state.tsx +8 -4
- package/src/components/shared/month-stepper.tsx +8 -6
- package/src/components/shared/multi-select-combobox.tsx +11 -4
- package/src/components/shared/resource-property-filter.tsx +33 -16
- package/src/components/shared/segmented-control.tsx +17 -1
- package/src/components/shared/ui-text.tsx +147 -0
- package/src/components/sortable/sortable-list.tsx +450 -0
- package/src/index.ts +5 -1
- package/src/lib/sortable-order.ts +142 -0
- package/src/sortable.ts +31 -0
|
@@ -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 };
|
package/src/index.ts
CHANGED
|
@@ -197,7 +197,11 @@ export {
|
|
|
197
197
|
export { type SaveState, SaveStatus, type SaveStatusText } from "./components/shared/save-status";
|
|
198
198
|
export { SecretReveal } from "./components/shared/secret-reveal";
|
|
199
199
|
export { SectionCard } from "./components/shared/section-card";
|
|
200
|
-
export {
|
|
200
|
+
export {
|
|
201
|
+
SegmentedControl,
|
|
202
|
+
type SegmentedOption,
|
|
203
|
+
type SegmentedTone,
|
|
204
|
+
} from "./components/shared/segmented-control";
|
|
201
205
|
export {
|
|
202
206
|
type SelectionAction,
|
|
203
207
|
SelectionBar,
|
|
@@ -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
|
+
}
|
package/src/sortable.ts
ADDED
|
@@ -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";
|