@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.
@@ -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
- /** เรียงกลุ่มใหม่: ย้าย `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,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
- 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
 
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.groups.length !== b.groups.length) return false;
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,