@suphark/ui 0.37.0 → 0.39.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 +35 -1
- package/README.md +2 -0
- package/package.json +1 -1
- package/src/components/shared/ui-text.tsx +4 -0
- package/src/components/sortable/sortable-list.tsx +663 -52
- package/src/lib/sortable-order.ts +177 -15
- package/src/sortable.ts +7 -0
|
@@ -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[];
|
|
28
|
+
}
|
|
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
|
+
};
|
|
14
50
|
}
|
|
15
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,24 +189,52 @@ 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
|
|
|
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
|
+
|
|
108
234
|
/** ลำดับเหมือนกันไหม (ใช้ตัดสินว่าต้องบันทึกหรือไม่) */
|
|
109
235
|
export function sameOrder(a: SortableOrder, b: SortableOrder): boolean {
|
|
110
236
|
if (a === b) return true;
|
|
111
|
-
if (a
|
|
112
|
-
if (a.groups.some((id, i) => b.groups[i] !== id)) return false;
|
|
237
|
+
if (!sameRoot(rootEntries(a), rootEntries(b))) return false;
|
|
113
238
|
const keys = new Set([...Object.keys(a.items), ...Object.keys(b.items)]);
|
|
114
239
|
for (const key of keys) {
|
|
115
240
|
const left = a.items[key] ?? [];
|
|
@@ -120,15 +245,51 @@ export function sameOrder(a: SortableOrder, b: SortableOrder): boolean {
|
|
|
120
245
|
return true;
|
|
121
246
|
}
|
|
122
247
|
|
|
248
|
+
/** รายการทั้งหมดเรียงตามที่แสดง: ไล่ชั้นบนสุด กลุ่มคลี่รายการของมันออกมา */
|
|
249
|
+
export function flatItems(order: SortableOrder): string[] {
|
|
250
|
+
return rootEntries(order).flatMap((e) =>
|
|
251
|
+
e.kind === "item" ? [e.id] : (order.items[e.id] ?? []),
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* ลากหลายรายการพร้อมกัน (bulk): ดึงรายการใน `ids` ทั้งหมดมารวมที่ตำแหน่งของ `activeId`
|
|
257
|
+
* (ตัวที่ผู้ใช้จับลาก) เรียงตามลำดับใน `ids` — ตัวอื่นหายจากที่เดิม ไม่ว่าจะอยู่กลุ่มไหนหรือชั้นบนสุด
|
|
258
|
+
*/
|
|
259
|
+
export function gatherItems(
|
|
260
|
+
order: SortableOrder,
|
|
261
|
+
activeId: string,
|
|
262
|
+
ids: ReadonlyArray<string>,
|
|
263
|
+
): SortableOrder {
|
|
264
|
+
const present = new Set(flatItems(order));
|
|
265
|
+
const moving = ids.filter((id) => present.has(id));
|
|
266
|
+
if (moving.length <= 1 || !moving.includes(activeId)) return order;
|
|
267
|
+
const others = new Set(moving.filter((id) => id !== activeId));
|
|
268
|
+
const expand = (id: string) => (others.has(id) ? [] : id === activeId ? moving : [id]);
|
|
269
|
+
const items: Record<string, string[]> = {};
|
|
270
|
+
for (const [group, list] of Object.entries(order.items)) {
|
|
271
|
+
if (group !== UNGROUPED) items[group] = list.flatMap(expand);
|
|
272
|
+
}
|
|
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);
|
|
277
|
+
}
|
|
278
|
+
|
|
123
279
|
export interface SortableOrderPayload {
|
|
124
280
|
/** ลำดับของกลุ่ม (ไม่มี `UNGROUPED` — ของที่ไม่มีกลุ่มไม่ใช่กลุ่มจริง) */
|
|
125
281
|
groups: string[];
|
|
126
282
|
/** รายการในแต่ละกลุ่ม เรียงตามลำดับกลุ่ม โดยของที่ไม่มีกลุ่มมาก่อนด้วย `groupId: null` */
|
|
127
283
|
items: Array<{ groupId: string | null; itemIds: string[] }>;
|
|
284
|
+
/**
|
|
285
|
+
* ลำดับชั้นบนสุด (กลุ่มกับรายการไม่มีกลุ่มปนกัน) — เก็บเป็นตำแหน่งเดียวกันของทั้งสองชนิด
|
|
286
|
+
* แล้วส่งกลับเข้า `sortableOrderOf(..., { root })` ตอนโหลด
|
|
287
|
+
*/
|
|
288
|
+
root: SortableRootEntry[];
|
|
128
289
|
}
|
|
129
290
|
|
|
130
291
|
/**
|
|
131
|
-
* แปลงเป็นรูปที่ส่งเข้า server action ได้ —
|
|
292
|
+
* แปลงเป็นรูปที่ส่งเข้า server action ได้ — คืนทั้งลำดับกลุ่ม รายการในกลุ่ม และชั้นบนสุด
|
|
132
293
|
* ชื่อฟิลด์เป็นศัพท์กลางของแพ็กเกจ (`groupId`) แอปที่ใช้ศัพท์อื่น (เช่น `sectionId`) rename ชั้นเดียวที่ปลายทาง
|
|
133
294
|
*/
|
|
134
295
|
export function sortableOrderToPayload(order: SortableOrder): SortableOrderPayload {
|
|
@@ -138,5 +299,6 @@ export function sortableOrderToPayload(order: SortableOrder): SortableOrderPaylo
|
|
|
138
299
|
groupId: group === UNGROUPED ? null : group,
|
|
139
300
|
itemIds: order.items[group] ?? [],
|
|
140
301
|
})),
|
|
302
|
+
root: rootEntries(order).map((e) => ({ ...e })),
|
|
141
303
|
};
|
|
142
304
|
}
|
package/src/sortable.ts
CHANGED
|
@@ -7,6 +7,7 @@ export {
|
|
|
7
7
|
DragHandle,
|
|
8
8
|
type SortableAnnouncementContext,
|
|
9
9
|
type SortableAnnouncements,
|
|
10
|
+
type SortableDrag,
|
|
10
11
|
SortableGroup,
|
|
11
12
|
type SortableKind,
|
|
12
13
|
SortableProvider,
|
|
@@ -17,13 +18,19 @@ export {
|
|
|
17
18
|
useSortableState,
|
|
18
19
|
} from "./components/sortable/sortable-list";
|
|
19
20
|
export {
|
|
21
|
+
flatItems,
|
|
22
|
+
gatherItems,
|
|
20
23
|
groupOfItem,
|
|
21
24
|
moveItemToGroup,
|
|
25
|
+
moveItemToRoot,
|
|
26
|
+
moveRootEntry,
|
|
22
27
|
moveWithin,
|
|
23
28
|
reorderGroups,
|
|
24
29
|
reorderItems,
|
|
30
|
+
rootEntries,
|
|
25
31
|
type SortableOrder,
|
|
26
32
|
type SortableOrderPayload,
|
|
33
|
+
type SortableRootEntry,
|
|
27
34
|
sameOrder,
|
|
28
35
|
sortableOrderOf,
|
|
29
36
|
sortableOrderToPayload,
|