@suphark/ui 0.37.0 → 0.38.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 +15 -1
- package/README.md +1 -0
- package/package.json +1 -1
- package/src/components/shared/ui-text.tsx +4 -0
- package/src/components/sortable/sortable-list.tsx +456 -28
- package/src/lib/sortable-order.ts +25 -0
- package/src/sortable.ts +3 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,7 +2,21 @@
|
|
|
2
2
|
|
|
3
3
|
รูปแบบตาม [Keep a Changelog](https://keepachangelog.com/) · semver: patch = หน้าตา/บั๊ก, minor = component/prop ใหม่, major = เปลี่ยน/ลบ props
|
|
4
4
|
|
|
5
|
-
## [0.
|
|
5
|
+
## [0.38.0] — 2026-10-07
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- **`@suphark/ui/sortable` ลากกลุ่มแล้วไม่เห็นอะไรขยับ** (เจ้าของแจ้ง): หัวกลุ่มมักอยู่ใน `SortableGroup` ซึ่งเป็น context ของรายการ dnd-kit จึงหากลุ่มในลำดับไม่เจอ ไม่มีแถวไหนได้ transform เลยจนปล่อย · ตอนนี้ provider คำนวณระยะเลื่อนของกลุ่มเอง และเลื่อน **ทั้งก้อน** (หัวกลุ่ม + แถวรายการ) — กลุ่มอื่นหลบเท่าความสูงทั้งก้อน, ช่องจาง ๆ ของกลุ่มที่ลากเลื่อนไปจุดวาง (ไม่มี overlay = ทั้งก้อนลอยตามเมาส์) · ที่วางของกลุ่มวัดรวมแถวรายการ ใช้ได้ทุกโครงสร้างโดยไม่ต้องแก้ call site · วัดบนหน้า demo: ลาก "ฐานข้อมูล" ลงเลย "อีเมล" → ฐานข้อมูลทั้งก้อน +134px, อีเมลทั้งก้อน −135px, ปล่อยแล้วไม่มี style ค้าง
|
|
10
|
+
- แถวต้นทางจางค้างหลังปล่อย (เจอกับหัวกลุ่ม): side effect ของ drop animation ใน dnd-kit จำ opacity ที่เราตั้งไว้เป็นค่าเดิมแล้วเขียนคืน — ปิด side effect นั้น (opacity คุมผ่าน style ของ React อยู่แล้ว)
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
|
|
14
|
+
- `SortableProvider multiSelect` (+ `selected`/`onSelectedChange` ถ้าจะคุมเอง): เลือกหลายรายการแล้วลากไปพร้อมกัน — ⌘/Ctrl+คลิก ทีละตัว, Shift+คลิก เป็นช่วง (เฉพาะแถวที่แสดง), คลิกเฉย ๆ ล้าง (คลิกปุ่ม/ช่องกรอกในแถวไม่นับ) · ที่จับ: `x` เลือก/ไม่เลือก, ⌘/Ctrl+A ทั้งหมด, Esc ล้าง · ลากตัวที่เลือก = ทุกตัวที่เลือกจางลงตรงที่เดิมแล้วไปรวมที่จุดวาง เรียงตามลำดับเดิม (ข้ามกลุ่มได้) · ลากตัวที่ไม่ได้เลือก = ลากตัวเดียวและล้างการเลือก
|
|
15
|
+
- `useSortableRow` คืน `selected` + `selectProps` (กระจายลงแถว มี `data-selected` ไว้ทำสี) · `useSortableState` คืน `selected`/`clearSelection` (ใช้คู่ `SelectionBar`)
|
|
16
|
+
- `renderOverlay` ได้ `SortableDrag` = `{ kind, id, ids }` (`ids` = ทุกตัวที่ลากไปด้วยกัน — ทำการ์ดซ้อน + จำนวน) · `SortableAnnouncementContext.count` + `UiText.sortable.startedMany` ("เริ่มลาก {count} รายการที่เลือกไว้")
|
|
17
|
+
- helper pure: `flatItems(order)`, `gatherItems(order, activeId, ids)`
|
|
18
|
+
- showcase: `/components/sortable-list` เปิด `multiSelect` + `SelectionBar` + การ์ดซ้อนตอนลากหลายคีย์
|
|
19
|
+
|
|
6
20
|
|
|
7
21
|
### Added
|
|
8
22
|
|
package/README.md
CHANGED
|
@@ -234,6 +234,7 @@ const [order, setOrder] = useState(() =>
|
|
|
234
234
|
- ตั้ง `id` ของ `SortableProvider` ได้ (ไม่ตั้ง = `useId()`) — ต้องคงที่ระหว่าง SSR กับ client ไม่งั้น `aria-describedby` ของ dnd-kit จะ hydration mismatch · ตรวจได้จากที่จับ: `aria-describedby` จะเป็น **id ที่ส่งเข้าไปตรง ๆ** (ไม่มี prefix) และต้องชี้ element ที่มีข้อความวิธีลาก — element นี้สร้างหลัง hydrate จึงต้องรอหน้า mount ก่อนค่อย query
|
|
235
235
|
- `onCommit` ล้มเหลว (throw หรือ `{ ok: false, error }`) = คืนลำดับเดิม + toast; ถ้าอยากจัดการ error เองใส่ `onCommitError`
|
|
236
236
|
- การ์ดลอยตามเมาส์: `renderOverlay={(drag) => drag.kind === "item" ? <ItemCard id={drag.id} /> : <GroupCard id={drag.id} />}` (วาดใน portal จึงเป็น `div` ได้ ไม่ต้องเป็น `<tr>`) — ไม่ใส่ = แถวขยับเองแบบเดิม · โฟลเดอร์ที่ย่ออยู่: `useSortableDropZone(id, { onDwell: collapsed ? open : undefined })` ลากค้าง 600ms แล้วเปิดให้ · วางบนหัวกลุ่ม = ขึ้นบนสุดของกลุ่ม, วางบนโซนของ `UNGROUPED` = ต่อท้าย
|
|
237
|
+
- เลือกหลายรายการแล้วลากพร้อมกัน: `SortableProvider multiSelect` แล้วกระจาย `row.selectProps` ลงแถว (ทำสีด้วย `data-[selected]:bg-primary/10`) — ⌘/Ctrl+คลิก, Shift+คลิก, ที่จับกด `x` / ⌘/Ctrl+A, Esc ล้าง · แถบนับใช้ `SelectionBar` + `useSortableState().selected/clearSelection` · `renderOverlay` ได้ `drag.ids` ไว้ทำการ์ดซ้อน
|
|
237
238
|
- `sortableOrderToPayload(order)` คืน `{ groups, items }` — `groups` คือลำดับของกลุ่ม, `items` คือรายการในแต่ละกลุ่มเรียงตามนั้น (ของที่ยังไม่มีกลุ่มมาก่อนด้วย `groupId: null`)
|
|
238
239
|
- `SortableProvider disabled` (เช่นระหว่างกรอง ซึ่งลำดับที่เห็นไม่ใช่ลำดับจริง) ทำให้ `DragHandle` จางลงและกดไม่ได้ — ไม่อยากให้เห็นเลยใส่ `hideWhenDisabled`
|
|
239
240
|
- ข้อความ screen reader: ใส่ `announcements`/`screenReaderInstructions` เป็น prop (callback ได้ `{ id, kind, groupId, index, total }`) หรือปล่อยให้ใช้แม่แบบจาก `UiText.sortable`
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@suphark/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.38.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "Design system กลางของ Suphark apps: shadcn/ui (base-nova, Base UI, Tailwind v4) + shared components + token/ฟอนต์ Noto Sans Thai Looped — ส่งเป็น source ให้แอป Next transpile เอง",
|
|
@@ -28,6 +28,8 @@ export interface UiText {
|
|
|
28
28
|
instructions: string;
|
|
29
29
|
/** ใช้ `{index}` / `{total}` */
|
|
30
30
|
started: string;
|
|
31
|
+
/** เริ่มลากหลายรายการที่เลือกไว้ — ใช้ `{count}` */
|
|
32
|
+
startedMany: string;
|
|
31
33
|
moved: string;
|
|
32
34
|
dropped: string;
|
|
33
35
|
cancelled: string;
|
|
@@ -255,6 +257,7 @@ export const UI_TEXT_TH: UiText = {
|
|
|
255
257
|
handle: "ลากเพื่อจัดลำดับ",
|
|
256
258
|
instructions: "กด Space เพื่อเริ่มลาก ใช้ปุ่มลูกศรเลื่อน แล้วกด Space อีกครั้งเพื่อวาง (Esc = ยกเลิก)",
|
|
257
259
|
started: "เริ่มลากรายการที่ {index} จาก {total}",
|
|
260
|
+
startedMany: "เริ่มลาก {count} รายการที่เลือกไว้",
|
|
258
261
|
moved: "ย้ายไปตำแหน่งที่ {index} จาก {total}",
|
|
259
262
|
dropped: "วางที่ตำแหน่งที่ {index} จาก {total}",
|
|
260
263
|
cancelled: "ยกเลิกการลาก",
|
|
@@ -425,6 +428,7 @@ export const UI_TEXT_EN: UiText = {
|
|
|
425
428
|
instructions:
|
|
426
429
|
"Press Space to start dragging, use arrow keys to move, Space again to drop (Esc to cancel)",
|
|
427
430
|
started: "Picked up item {index} of {total}",
|
|
431
|
+
startedMany: "Picked up {count} selected items",
|
|
428
432
|
moved: "Moved to position {index} of {total}",
|
|
429
433
|
dropped: "Dropped at position {index} of {total}",
|
|
430
434
|
cancelled: "Dragging cancelled",
|
|
@@ -5,11 +5,12 @@ import {
|
|
|
5
5
|
closestCenter,
|
|
6
6
|
DndContext,
|
|
7
7
|
type DragEndEvent,
|
|
8
|
+
type DragMoveEvent,
|
|
8
9
|
type DragOverEvent,
|
|
9
10
|
DragOverlay,
|
|
10
11
|
type DragStartEvent,
|
|
11
12
|
type DropAnimation,
|
|
12
|
-
|
|
13
|
+
getClientRect,
|
|
13
14
|
getFirstCollision,
|
|
14
15
|
KeyboardSensor,
|
|
15
16
|
MeasuringStrategy,
|
|
@@ -33,6 +34,8 @@ import { useUiText } from "@suphark/ui/components/shared/ui-text";
|
|
|
33
34
|
import { useMounted } from "@suphark/ui/hooks/use-mounted";
|
|
34
35
|
import { errorMessage } from "@suphark/ui/lib/action-result";
|
|
35
36
|
import {
|
|
37
|
+
flatItems,
|
|
38
|
+
gatherItems,
|
|
36
39
|
groupOfItem,
|
|
37
40
|
moveItemToGroup,
|
|
38
41
|
reorderGroups,
|
|
@@ -46,6 +49,8 @@ import { GripVerticalIcon } from "lucide-react";
|
|
|
46
49
|
import {
|
|
47
50
|
type CSSProperties,
|
|
48
51
|
createContext,
|
|
52
|
+
type KeyboardEvent,
|
|
53
|
+
type MouseEvent,
|
|
49
54
|
type ReactNode,
|
|
50
55
|
useCallback,
|
|
51
56
|
useContext,
|
|
@@ -54,6 +59,7 @@ import {
|
|
|
54
59
|
useMemo,
|
|
55
60
|
useRef,
|
|
56
61
|
useState,
|
|
62
|
+
useSyncExternalStore,
|
|
57
63
|
} from "react";
|
|
58
64
|
import { createPortal } from "react-dom";
|
|
59
65
|
import { toast } from "sonner";
|
|
@@ -72,9 +78,48 @@ const SORT_TRANSITION = { duration: 200, easing: EASING };
|
|
|
72
78
|
const DROP_ANIMATION: DropAnimation = {
|
|
73
79
|
duration: 200,
|
|
74
80
|
easing: EASING,
|
|
75
|
-
|
|
81
|
+
// ช่องจาง ๆ ของแถวต้นทางเราใส่ผ่าน style ของ React เองแล้ว — side effect ของ dnd-kit จะจำค่า 0.4 นั้น
|
|
82
|
+
// ไว้เป็น "ค่าเดิม" แล้วเขียนคืนหลังปล่อย ทำให้แถวจางค้าง (เจอกับหัวกลุ่ม)
|
|
83
|
+
sideEffects: null,
|
|
76
84
|
};
|
|
77
85
|
|
|
86
|
+
/** สิ่งที่กำลังลาก — `ids` = ทุกรายการที่ลากไปด้วยกัน (ลากแบบ bulk; ลากตัวเดียว = `[id]`) */
|
|
87
|
+
export interface SortableDrag {
|
|
88
|
+
kind: SortableKind;
|
|
89
|
+
id: string;
|
|
90
|
+
ids: string[];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
type SelectMode = "toggle" | "range" | "all" | "clear";
|
|
94
|
+
|
|
95
|
+
/** ระยะเลื่อนของหัวกลุ่มแต่ละกลุ่มระหว่างลากกลุ่ม — แถวรายการในกลุ่มอ่านไปเลื่อนตาม (ทั้งก้อนขยับพร้อมกัน) */
|
|
96
|
+
interface GroupShift {
|
|
97
|
+
transform: string | undefined;
|
|
98
|
+
transition: string | undefined;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function createShiftStore() {
|
|
102
|
+
const shifts = new Map<string, GroupShift>();
|
|
103
|
+
const listeners = new Set<() => void>();
|
|
104
|
+
return {
|
|
105
|
+
get: (id: string) => shifts.get(id),
|
|
106
|
+
set(id: string, shift: GroupShift | undefined) {
|
|
107
|
+
const prev = shifts.get(id);
|
|
108
|
+
if (prev?.transform === shift?.transform && prev?.transition === shift?.transition) return;
|
|
109
|
+
if (shift) shifts.set(id, shift);
|
|
110
|
+
else shifts.delete(id);
|
|
111
|
+
for (const listener of listeners) listener();
|
|
112
|
+
},
|
|
113
|
+
subscribe(listener: () => void) {
|
|
114
|
+
listeners.add(listener);
|
|
115
|
+
return () => {
|
|
116
|
+
listeners.delete(listener);
|
|
117
|
+
};
|
|
118
|
+
},
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
type ShiftStore = ReturnType<typeof createShiftStore>;
|
|
122
|
+
|
|
78
123
|
interface SortableState {
|
|
79
124
|
/** กำลังลากชนิดไหนอยู่ (null = ไม่ได้ลาก) — ใช้ปิดที่วางของอีกชนิด */
|
|
80
125
|
dragging: SortableKind | null;
|
|
@@ -82,22 +127,52 @@ interface SortableState {
|
|
|
82
127
|
handleLabel: string;
|
|
83
128
|
/** มี `renderOverlay` = แถวต้นทางเป็นที่ว่างจาง ๆ ส่วนของที่ลอยตามเมาส์คือ overlay */
|
|
84
129
|
overlay: boolean;
|
|
130
|
+
/** กลุ่มที่กำลังถูกลาก (แถวรายการของกลุ่มนี้จางตามหัวกลุ่ม) */
|
|
131
|
+
activeGroup: string | null;
|
|
132
|
+
/** รายการอื่นที่ลากไปพร้อมกันแบบ bulk (ไม่รวมตัวที่จับ) — แสดงจาง ๆ ระหว่างลาก */
|
|
133
|
+
carried: ReadonlySet<string>;
|
|
134
|
+
multiSelect: boolean;
|
|
135
|
+
selected: ReadonlySet<string>;
|
|
136
|
+
select: (id: string, mode: SelectMode) => void;
|
|
137
|
+
groupOf: (itemId: string) => string | undefined;
|
|
138
|
+
register: (kind: SortableKind, id: string, node: HTMLElement | null) => void;
|
|
139
|
+
shifts: ShiftStore;
|
|
85
140
|
}
|
|
86
141
|
|
|
142
|
+
const EMPTY = new Set<string>();
|
|
87
143
|
const SortableStateContext = createContext<SortableState>({
|
|
88
144
|
dragging: null,
|
|
89
145
|
disabled: true,
|
|
90
146
|
handleLabel: "ลากเพื่อจัดลำดับ",
|
|
91
147
|
overlay: false,
|
|
148
|
+
activeGroup: null,
|
|
149
|
+
carried: EMPTY,
|
|
150
|
+
multiSelect: false,
|
|
151
|
+
selected: EMPTY,
|
|
152
|
+
select: () => {},
|
|
153
|
+
groupOf: () => undefined,
|
|
154
|
+
register: () => {},
|
|
155
|
+
shifts: createShiftStore(),
|
|
92
156
|
});
|
|
93
157
|
|
|
94
158
|
/**
|
|
95
159
|
* สถานะการลากตอนนี้ — ใช้ตัดสินใจเรนเดอร์ที่วางเอง เช่นแถว "ลากมาที่นี่เพื่อเอาออกจากกลุ่ม"
|
|
96
160
|
* ที่ควรโผล่เฉพาะตอน `dragging === "item"` (กลุ่มที่ไม่มีหัวกลุ่มต้องมีที่วางของตัวเอง)
|
|
161
|
+
* `selected`/`clearSelection` ใช้ทำแถบ "เลือก N รายการ" เมื่อเปิด `multiSelect`
|
|
97
162
|
*/
|
|
98
|
-
export function useSortableState(): {
|
|
163
|
+
export function useSortableState(): {
|
|
164
|
+
dragging: SortableKind | null;
|
|
165
|
+
disabled: boolean;
|
|
166
|
+
selected: string[];
|
|
167
|
+
clearSelection: () => void;
|
|
168
|
+
} {
|
|
99
169
|
const state = useContext(SortableStateContext);
|
|
100
|
-
return {
|
|
170
|
+
return {
|
|
171
|
+
dragging: state.dragging,
|
|
172
|
+
disabled: state.disabled,
|
|
173
|
+
selected: [...state.selected],
|
|
174
|
+
clearSelection: () => state.select("", "clear"),
|
|
175
|
+
};
|
|
101
176
|
}
|
|
102
177
|
|
|
103
178
|
/** ข้อมูลของสิ่งที่กำลังลาก ส่งให้แอปประกอบข้อความเอง (แอปรู้ชื่อคีย์/ชื่อกลุ่ม แพ็กเกจไม่รู้) */
|
|
@@ -109,6 +184,8 @@ export interface SortableAnnouncementContext {
|
|
|
109
184
|
/** ตำแหน่งในกลุ่ม/ในลำดับกลุ่ม เริ่มที่ 1 */
|
|
110
185
|
index: number;
|
|
111
186
|
total: number;
|
|
187
|
+
/** จำนวนรายการที่ลากไปด้วยกัน (ลากแบบ bulk > 1) */
|
|
188
|
+
count: number;
|
|
112
189
|
}
|
|
113
190
|
|
|
114
191
|
export interface SortableAnnouncements {
|
|
@@ -155,7 +232,17 @@ export interface SortableProviderProps {
|
|
|
155
232
|
* ส่งมา = แถวต้นทางกลายเป็นช่องว่างจาง ๆ บอกตำแหน่งที่จะวาง และมีแอนิเมชันตอนปล่อย
|
|
156
233
|
* วาดใน portal ที่ `body` จึงคืน `<div>` ได้แม้รายการจริงเป็นแถว `<tr>` ของตาราง
|
|
157
234
|
*/
|
|
158
|
-
renderOverlay?: (drag:
|
|
235
|
+
renderOverlay?: (drag: SortableDrag) => ReactNode;
|
|
236
|
+
/**
|
|
237
|
+
* เลือกหลายรายการแล้วลากไปพร้อมกัน: ⌘/Ctrl+คลิก = เลือกทีละตัว, Shift+คลิก = เลือกเป็นช่วง,
|
|
238
|
+
* คลิกเฉย ๆ = ล้าง · ที่จับ (คีย์บอร์ด): `x` = เลือก/ไม่เลือก, ⌘/Ctrl+A = ทั้งหมด, Esc = ล้าง
|
|
239
|
+
* แถวต้องกระจาย `selectProps` จาก `useSortableRow` (มี `data-selected` ไว้ทำสีแถวที่เลือก)
|
|
240
|
+
* ลากตัวที่เลือกอยู่ = ลากทุกตัวที่เลือกไปรวมที่จุดวาง เรียงตามลำดับเดิม
|
|
241
|
+
*/
|
|
242
|
+
multiSelect?: boolean;
|
|
243
|
+
/** รายการที่เลือก (ควบคุมจากภายนอก) — ไม่ส่ง = provider เก็บเอง */
|
|
244
|
+
selected?: string[];
|
|
245
|
+
onSelectedChange?: (ids: string[]) => void;
|
|
159
246
|
children: ReactNode;
|
|
160
247
|
}
|
|
161
248
|
|
|
@@ -179,13 +266,25 @@ export function SortableProvider({
|
|
|
179
266
|
announcements,
|
|
180
267
|
id,
|
|
181
268
|
renderOverlay,
|
|
269
|
+
multiSelect = false,
|
|
270
|
+
selected: selectedProp,
|
|
271
|
+
onSelectedChange,
|
|
182
272
|
children,
|
|
183
273
|
}: SortableProviderProps) {
|
|
184
274
|
const fallbackId = useId();
|
|
185
275
|
const text = useUiText();
|
|
186
276
|
const mounted = useMounted();
|
|
187
277
|
const [dragging, setDragging] = useState<SortableKind | null>(null);
|
|
188
|
-
const [activeDrag, setActiveDrag] = useState<
|
|
278
|
+
const [activeDrag, setActiveDrag] = useState<SortableDrag | null>(null);
|
|
279
|
+
const draggingRef = useRef<SortableKind | null>(null);
|
|
280
|
+
// รายการที่ลากไปด้วยกันแบบ bulk (รวมตัวที่จับ เรียงตามลำดับที่แสดง) — null = ลากตัวเดียว
|
|
281
|
+
const bulk = useRef<string[] | null>(null);
|
|
282
|
+
const nodes = useRef({
|
|
283
|
+
groups: new Map<string, HTMLElement>(),
|
|
284
|
+
items: new Map<string, HTMLElement>(),
|
|
285
|
+
groupOfNode: new WeakMap<HTMLElement, string>(),
|
|
286
|
+
});
|
|
287
|
+
const shifts = useMemo(createShiftStore, []);
|
|
189
288
|
// กันกระพริบตอนย้ายข้ามกลุ่ม: จำเป้าหมายล่าสุดไว้ใช้ตอนที่ layout กำลังขยับแล้วหาเป้าหมายไม่เจอ
|
|
190
289
|
const lastOverId = useRef<UniqueIdentifier | null>(null);
|
|
191
290
|
const movedAcross = useRef(false);
|
|
@@ -194,6 +293,147 @@ export function SortableProvider({
|
|
|
194
293
|
const latest = useRef(order);
|
|
195
294
|
latest.current = order;
|
|
196
295
|
|
|
296
|
+
// ---------- เลือกหลายรายการ ----------
|
|
297
|
+
const [ownSelected, setOwnSelected] = useState<string[]>([]);
|
|
298
|
+
const selectedList = selectedProp ?? ownSelected;
|
|
299
|
+
const selected = useMemo<ReadonlySet<string>>(() => {
|
|
300
|
+
if (!multiSelect || selectedList.length === 0) return EMPTY;
|
|
301
|
+
const present = new Set(flatItems(order));
|
|
302
|
+
return new Set(selectedList.filter((x) => present.has(x)));
|
|
303
|
+
}, [multiSelect, selectedList, order]);
|
|
304
|
+
const selectedRef = useRef(selected);
|
|
305
|
+
selectedRef.current = selected;
|
|
306
|
+
const anchor = useRef<string | null>(null);
|
|
307
|
+
const onSelectedChangeRef = useRef(onSelectedChange);
|
|
308
|
+
onSelectedChangeRef.current = onSelectedChange;
|
|
309
|
+
const controlled = selectedProp !== undefined;
|
|
310
|
+
|
|
311
|
+
const setSelected = useCallback(
|
|
312
|
+
(ids: string[]) => {
|
|
313
|
+
if (!controlled) setOwnSelected(ids);
|
|
314
|
+
onSelectedChangeRef.current?.(ids);
|
|
315
|
+
},
|
|
316
|
+
[controlled],
|
|
317
|
+
);
|
|
318
|
+
|
|
319
|
+
const select = useCallback(
|
|
320
|
+
(itemId: string, mode: SelectMode) => {
|
|
321
|
+
const all = flatItems(latest.current);
|
|
322
|
+
// เลือกเป็นช่วง/ทั้งหมด = เฉพาะแถวที่แสดงอยู่ (ไม่หยิบของในกลุ่มที่ย่อไว้โดยไม่รู้ตัว)
|
|
323
|
+
const visible = all.filter((x) => nodes.current.items.get(x)?.isConnected);
|
|
324
|
+
const current = selectedRef.current;
|
|
325
|
+
let next: Set<string>;
|
|
326
|
+
if (mode === "clear") next = new Set();
|
|
327
|
+
else if (mode === "all") next = new Set(visible);
|
|
328
|
+
else if (mode === "toggle") {
|
|
329
|
+
next = new Set(current);
|
|
330
|
+
if (next.has(itemId)) next.delete(itemId);
|
|
331
|
+
else next.add(itemId);
|
|
332
|
+
anchor.current = itemId;
|
|
333
|
+
} else {
|
|
334
|
+
const from = visible.indexOf(anchor.current ?? itemId);
|
|
335
|
+
const to = visible.indexOf(itemId);
|
|
336
|
+
next = new Set(current);
|
|
337
|
+
if (from >= 0 && to >= 0) {
|
|
338
|
+
for (const x of visible.slice(Math.min(from, to), Math.max(from, to) + 1)) next.add(x);
|
|
339
|
+
} else next.add(itemId);
|
|
340
|
+
if (anchor.current === null) anchor.current = itemId;
|
|
341
|
+
}
|
|
342
|
+
if (mode === "clear" || mode === "all") anchor.current = null;
|
|
343
|
+
if (next.size === current.size && [...next].every((x) => current.has(x))) return;
|
|
344
|
+
setSelected(all.filter((x) => next.has(x)));
|
|
345
|
+
},
|
|
346
|
+
[setSelected],
|
|
347
|
+
);
|
|
348
|
+
|
|
349
|
+
// Esc ที่ไหนก็ได้ = ล้างการเลือก (ระหว่างลาก Esc เป็นของการยกเลิกลาก)
|
|
350
|
+
const hasSelection = selected.size > 0;
|
|
351
|
+
useEffect(() => {
|
|
352
|
+
if (!hasSelection) return;
|
|
353
|
+
function onKey(event: globalThis.KeyboardEvent) {
|
|
354
|
+
if (event.key === "Escape" && draggingRef.current === null) select("", "clear");
|
|
355
|
+
}
|
|
356
|
+
document.addEventListener("keydown", onKey);
|
|
357
|
+
return () => document.removeEventListener("keydown", onKey);
|
|
358
|
+
}, [hasSelection, select]);
|
|
359
|
+
|
|
360
|
+
const register = useCallback((kind: SortableKind, rowId: string, node: HTMLElement | null) => {
|
|
361
|
+
const map = kind === "group" ? nodes.current.groups : nodes.current.items;
|
|
362
|
+
if (node) {
|
|
363
|
+
map.set(rowId, node);
|
|
364
|
+
if (kind === "group") nodes.current.groupOfNode.set(node, rowId);
|
|
365
|
+
} else map.delete(rowId);
|
|
366
|
+
}, []);
|
|
367
|
+
|
|
368
|
+
/** กรอบของทั้งก้อน: หัวกลุ่ม + แถวรายการที่แสดงอยู่ (ไม่สนใจ transform ระหว่างลาก) */
|
|
369
|
+
const blockRect = useCallback((groupId: string, header: HTMLElement) => {
|
|
370
|
+
let { top, bottom, left, right } = getClientRect(header, { ignoreTransform: true });
|
|
371
|
+
for (const itemId of latest.current.items[groupId] ?? []) {
|
|
372
|
+
const node = nodes.current.items.get(itemId);
|
|
373
|
+
if (!node?.isConnected) continue;
|
|
374
|
+
const r = getClientRect(node, { ignoreTransform: true });
|
|
375
|
+
top = Math.min(top, r.top);
|
|
376
|
+
bottom = Math.max(bottom, r.bottom);
|
|
377
|
+
left = Math.min(left, r.left);
|
|
378
|
+
right = Math.max(right, r.right);
|
|
379
|
+
}
|
|
380
|
+
return { top, bottom, left, right, width: right - left, height: bottom - top };
|
|
381
|
+
}, []);
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* ขนาดของที่วางตอนลากกลุ่ม = หัวกลุ่ม "รวมแถวรายการของกลุ่มนั้น" ทั้งก้อน
|
|
385
|
+
* (เดิมวัดแค่หัวกลุ่ม กลุ่มอื่นจึงเลื่อนหลบแค่ความสูงหัวกลุ่มเดียว แถวรายการไม่ขยับ)
|
|
386
|
+
*/
|
|
387
|
+
const measureDroppable = useCallback(
|
|
388
|
+
(element: HTMLElement) => {
|
|
389
|
+
const groupId =
|
|
390
|
+
draggingRef.current === "group" ? nodes.current.groupOfNode.get(element) : undefined;
|
|
391
|
+
return groupId
|
|
392
|
+
? blockRect(groupId, element)
|
|
393
|
+
: getClientRect(element, { ignoreTransform: true });
|
|
394
|
+
},
|
|
395
|
+
[blockRect],
|
|
396
|
+
);
|
|
397
|
+
|
|
398
|
+
// ---------- เลื่อนทั้งก้อนระหว่างลากกลุ่ม (แบบรายการ: กลุ่มอื่นหลบ ช่องว่างของกลุ่มที่ลากเลื่อนไปจุดวาง) ----------
|
|
399
|
+
const blocks = useRef(new Map<string, { top: number; bottom: number }>());
|
|
400
|
+
const [overGroup, setOverGroup] = useState<string | null>(null);
|
|
401
|
+
const overlayOn = Boolean(renderOverlay);
|
|
402
|
+
useEffect(() => {
|
|
403
|
+
const active = activeDrag?.kind === "group" ? activeDrag.id : null;
|
|
404
|
+
const groups = order.groups;
|
|
405
|
+
if (!active) {
|
|
406
|
+
for (const g of groups) shifts.set(g, undefined);
|
|
407
|
+
return;
|
|
408
|
+
}
|
|
409
|
+
const a = groups.indexOf(active);
|
|
410
|
+
const o = overGroup ? groups.indexOf(overGroup) : a;
|
|
411
|
+
const at = (i: number) => blocks.current.get(groups[i] ?? "");
|
|
412
|
+
const self = at(a);
|
|
413
|
+
if (!self || o < 0) return;
|
|
414
|
+
// ระยะที่กลุ่มอื่นหลบ = จากหัวของกลุ่มที่ลากไปถึงหัวของกลุ่มถัดไป (รวมช่องไฟ)
|
|
415
|
+
const step = (at(a + 1)?.top ?? self.bottom) - self.top;
|
|
416
|
+
const transition = `transform ${SORT_TRANSITION.duration}ms ${EASING}`;
|
|
417
|
+
groups.forEach((g, i) => {
|
|
418
|
+
let dy = 0;
|
|
419
|
+
if (i === a) {
|
|
420
|
+
if (!overlayOn) return; // ไม่มี overlay = หัวกลุ่มลอยตามเมาส์ (onDragMove)
|
|
421
|
+
dy =
|
|
422
|
+
o > a
|
|
423
|
+
? (at(o)?.bottom ?? self.bottom) - self.bottom
|
|
424
|
+
: o < a
|
|
425
|
+
? (at(o)?.top ?? self.top) - self.top
|
|
426
|
+
: 0;
|
|
427
|
+
} else if (a < o && i > a && i <= o) dy = -step;
|
|
428
|
+
else if (o < a && i >= o && i < a) dy = step;
|
|
429
|
+
shifts.set(g, { transform: `translate3d(0px, ${dy}px, 0)`, transition });
|
|
430
|
+
});
|
|
431
|
+
}, [activeDrag, overGroup, order.groups, overlayOn, shifts]);
|
|
432
|
+
const measuring = useMemo(
|
|
433
|
+
() => ({ droppable: { strategy: MeasuringStrategy.Always, measure: measureDroppable } }),
|
|
434
|
+
[measureDroppable],
|
|
435
|
+
);
|
|
436
|
+
|
|
197
437
|
// ย้ายข้ามกลุ่มเสร็จแล้ว (render ลำดับใหม่ไปแล้ว) ค่อยปลดธงในเฟรมถัดไป
|
|
198
438
|
useEffect(() => {
|
|
199
439
|
const frame = requestAnimationFrame(() => {
|
|
@@ -253,11 +493,51 @@ export function SortableProvider({
|
|
|
253
493
|
beforeDrag.current = latest.current;
|
|
254
494
|
lastOverId.current = null;
|
|
255
495
|
const kind: SortableKind = kindOf(active.id) === "g" ? "group" : "item";
|
|
496
|
+
const raw = rawOf(active.id);
|
|
497
|
+
const chosen = selectedRef.current;
|
|
498
|
+
bulk.current = null;
|
|
499
|
+
if (kind === "item" && chosen.has(raw) && chosen.size > 1) {
|
|
500
|
+
bulk.current = flatItems(latest.current).filter((x) => chosen.has(x));
|
|
501
|
+
} else if (kind === "item" && chosen.size > 0 && !chosen.has(raw)) {
|
|
502
|
+
// ลากตัวที่ไม่ได้เลือก = ลากตัวนั้นตัวเดียว และล้างการเลือกเดิม (แบบ Finder)
|
|
503
|
+
select("", "clear");
|
|
504
|
+
}
|
|
505
|
+
draggingRef.current = kind;
|
|
506
|
+
if (kind === "group") {
|
|
507
|
+
blocks.current.clear();
|
|
508
|
+
for (const [groupId, header] of nodes.current.groups) {
|
|
509
|
+
if (header.isConnected) blocks.current.set(groupId, blockRect(groupId, header));
|
|
510
|
+
}
|
|
511
|
+
setOverGroup(raw);
|
|
512
|
+
}
|
|
256
513
|
setDragging(kind);
|
|
257
|
-
setActiveDrag({ kind, id:
|
|
514
|
+
setActiveDrag({ kind, id: raw, ids: bulk.current ?? [raw] });
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/** ไม่มี overlay: ก้อนของกลุ่มที่ลากลอยตามเมาส์ทั้งก้อน (หัวกลุ่ม + แถวรายการ) */
|
|
518
|
+
function onDragMove({ active, delta }: DragMoveEvent) {
|
|
519
|
+
if (overlayOn || kindOf(active.id) !== "g") return;
|
|
520
|
+
shifts.set(rawOf(active.id), {
|
|
521
|
+
transform: `translate3d(${delta.x}px, ${delta.y}px, 0)`,
|
|
522
|
+
transition: undefined,
|
|
523
|
+
});
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
function endDrag() {
|
|
527
|
+
draggingRef.current = null;
|
|
528
|
+
bulk.current = null;
|
|
529
|
+
setOverGroup(null);
|
|
530
|
+
// ล้างระยะเลื่อนพร้อมกับที่ลำดับใหม่ render (รอ effect = มีเฟรมที่แถวใหม่ยังติด transform เก่า → กระพริบ)
|
|
531
|
+
for (const g of nodes.current.groups.keys()) shifts.set(g, undefined);
|
|
532
|
+
setDragging(null);
|
|
533
|
+
setActiveDrag(null);
|
|
258
534
|
}
|
|
259
535
|
|
|
260
536
|
function onDragOver({ active, over }: DragOverEvent) {
|
|
537
|
+
if (over && kindOf(active.id) === "g" && kindOf(over.id) === "g") {
|
|
538
|
+
setOverGroup(rawOf(over.id));
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
261
541
|
if (!over || kindOf(active.id) !== "i") return;
|
|
262
542
|
const current = latest.current;
|
|
263
543
|
const from = containerOf(active.id, current);
|
|
@@ -308,9 +588,9 @@ export function SortableProvider({
|
|
|
308
588
|
|
|
309
589
|
function onDragEnd({ active, over }: DragEndEvent) {
|
|
310
590
|
const snapshot = beforeDrag.current;
|
|
591
|
+
const carried = bulk.current;
|
|
311
592
|
beforeDrag.current = null;
|
|
312
|
-
|
|
313
|
-
setActiveDrag(null);
|
|
593
|
+
endDrag();
|
|
314
594
|
const current = latest.current;
|
|
315
595
|
let next = current;
|
|
316
596
|
if (over && kindOf(active.id) === "g" && kindOf(over.id) === "g") {
|
|
@@ -318,6 +598,8 @@ export function SortableProvider({
|
|
|
318
598
|
} else if (over && kindOf(active.id) === "i" && kindOf(over.id) === "i") {
|
|
319
599
|
next = reorderItems(current, rawOf(active.id), rawOf(over.id));
|
|
320
600
|
}
|
|
601
|
+
// bulk: ตัวที่เลือกไว้ตัวอื่นตามมารวมที่จุดวางของตัวที่จับ (เรียงตามลำดับเดิม)
|
|
602
|
+
if (carried) next = gatherItems(next, rawOf(active.id), carried);
|
|
321
603
|
if (next !== current) {
|
|
322
604
|
latest.current = next;
|
|
323
605
|
onOrderChange(next);
|
|
@@ -328,8 +610,7 @@ export function SortableProvider({
|
|
|
328
610
|
function onDragCancel() {
|
|
329
611
|
const snapshot = beforeDrag.current;
|
|
330
612
|
beforeDrag.current = null;
|
|
331
|
-
|
|
332
|
-
setActiveDrag(null);
|
|
613
|
+
endDrag();
|
|
333
614
|
if (snapshot) {
|
|
334
615
|
latest.current = snapshot;
|
|
335
616
|
onOrderChange(snapshot);
|
|
@@ -351,6 +632,7 @@ export function SortableProvider({
|
|
|
351
632
|
kind: "group",
|
|
352
633
|
index: current.groups.indexOf(raw) + 1,
|
|
353
634
|
total: current.groups.length,
|
|
635
|
+
count: 1,
|
|
354
636
|
};
|
|
355
637
|
}
|
|
356
638
|
const group = groupOfItem(current, raw);
|
|
@@ -361,6 +643,7 @@ export function SortableProvider({
|
|
|
361
643
|
groupId: group,
|
|
362
644
|
index: list.indexOf(raw) + 1,
|
|
363
645
|
total: list.length,
|
|
646
|
+
count: bulk.current?.length ?? 1,
|
|
364
647
|
};
|
|
365
648
|
}, []);
|
|
366
649
|
|
|
@@ -397,12 +680,15 @@ export function SortableProvider({
|
|
|
397
680
|
) =>
|
|
398
681
|
(template ?? fallback)
|
|
399
682
|
.replace("{index}", String(ctx.index))
|
|
400
|
-
.replace("{total}", String(ctx.total))
|
|
683
|
+
.replace("{total}", String(ctx.total))
|
|
684
|
+
.replace("{count}", String(ctx.count));
|
|
401
685
|
const announce =
|
|
402
686
|
(
|
|
403
687
|
own: ((context: SortableAnnouncementContext) => string | undefined) | undefined,
|
|
404
688
|
template: string | undefined,
|
|
405
689
|
fallback: string,
|
|
690
|
+
// ประโยคแทนเมื่อลากหลายรายการพร้อมกัน
|
|
691
|
+
many: { template: string | undefined; fallback: string } | null,
|
|
406
692
|
// ระหว่างลาก = ตำแหน่งของเป้าหมาย; ตอนปล่อย order อัปเดตแล้ว จึงใช้ตำแหน่งจริงของตัวที่ลาก
|
|
407
693
|
useTarget = false,
|
|
408
694
|
) =>
|
|
@@ -416,39 +702,84 @@ export function SortableProvider({
|
|
|
416
702
|
const context = useTarget
|
|
417
703
|
? describeTarget(active.id, over?.id ?? null)
|
|
418
704
|
: describe(active.id);
|
|
419
|
-
|
|
705
|
+
if (own) {
|
|
706
|
+
const said = own(context);
|
|
707
|
+
if (said !== undefined) return said;
|
|
708
|
+
}
|
|
709
|
+
return many && context.count > 1
|
|
710
|
+
? fill(many.template, many.fallback, context)
|
|
711
|
+
: fill(template, fallback, context);
|
|
420
712
|
};
|
|
421
713
|
return {
|
|
422
714
|
onDragStart: announce(
|
|
423
715
|
announcements?.onDragStart,
|
|
424
716
|
text.sortable?.started,
|
|
425
717
|
"เริ่มลากรายการที่ {index} จาก {total}",
|
|
718
|
+
{ template: text.sortable?.startedMany, fallback: "เริ่มลาก {count} รายการที่เลือกไว้" },
|
|
426
719
|
),
|
|
427
720
|
onDragOver: announce(
|
|
428
721
|
announcements?.onDragOver,
|
|
429
722
|
text.sortable?.moved,
|
|
430
723
|
"ย้ายไปตำแหน่งที่ {index} จาก {total}",
|
|
724
|
+
null,
|
|
431
725
|
true,
|
|
432
726
|
),
|
|
433
727
|
onDragEnd: announce(
|
|
434
728
|
announcements?.onDragEnd,
|
|
435
729
|
text.sortable?.dropped,
|
|
436
730
|
"วางที่ตำแหน่งที่ {index} จาก {total}",
|
|
731
|
+
null,
|
|
732
|
+
),
|
|
733
|
+
onDragCancel: announce(
|
|
734
|
+
announcements?.onDragCancel,
|
|
735
|
+
text.sortable?.cancelled,
|
|
736
|
+
"ยกเลิกการลาก",
|
|
737
|
+
null,
|
|
437
738
|
),
|
|
438
|
-
onDragCancel: announce(announcements?.onDragCancel, text.sortable?.cancelled, "ยกเลิกการลาก"),
|
|
439
739
|
};
|
|
440
740
|
}, [announcements, describeTarget, text.sortable]);
|
|
441
741
|
|
|
442
742
|
const groupIds = useMemo(() => order.groups.map((id) => dndId("group", id)), [order.groups]);
|
|
443
743
|
|
|
744
|
+
const groupOf = useCallback((itemId: string) => groupOfItem(order, itemId), [order]);
|
|
745
|
+
const carried = useMemo<ReadonlySet<string>>(
|
|
746
|
+
() =>
|
|
747
|
+
activeDrag && activeDrag.ids.length > 1
|
|
748
|
+
? new Set(activeDrag.ids.filter((x) => x !== activeDrag.id))
|
|
749
|
+
: EMPTY,
|
|
750
|
+
[activeDrag],
|
|
751
|
+
);
|
|
752
|
+
|
|
444
753
|
const state = useMemo<SortableState>(
|
|
445
754
|
() => ({
|
|
446
755
|
dragging,
|
|
447
756
|
disabled,
|
|
448
757
|
handleLabel: handleLabel ?? text.sortable?.handle ?? "ลากเพื่อจัดลำดับ",
|
|
449
758
|
overlay: Boolean(renderOverlay),
|
|
759
|
+
activeGroup: activeDrag?.kind === "group" ? activeDrag.id : null,
|
|
760
|
+
carried,
|
|
761
|
+
multiSelect,
|
|
762
|
+
selected,
|
|
763
|
+
select,
|
|
764
|
+
groupOf,
|
|
765
|
+
register,
|
|
766
|
+
shifts,
|
|
450
767
|
}),
|
|
451
|
-
[
|
|
768
|
+
[
|
|
769
|
+
dragging,
|
|
770
|
+
disabled,
|
|
771
|
+
handleLabel,
|
|
772
|
+
text.sortable?.handle,
|
|
773
|
+
renderOverlay,
|
|
774
|
+
activeDrag,
|
|
775
|
+
carried,
|
|
776
|
+
multiSelect,
|
|
777
|
+
selected,
|
|
778
|
+
select,
|
|
779
|
+
groupOf,
|
|
780
|
+
register,
|
|
781
|
+
shifts,
|
|
782
|
+
],
|
|
452
783
|
);
|
|
453
784
|
|
|
454
785
|
return (
|
|
@@ -458,13 +789,15 @@ export function SortableProvider({
|
|
|
458
789
|
sensors={sensors}
|
|
459
790
|
collisionDetection={collisionDetection}
|
|
460
791
|
// วัดตำแหน่งที่วางใหม่ตลอดระหว่างลาก — ย้ายข้ามกลุ่มแล้ว layout เปลี่ยน ต้องใช้ตำแหน่งปัจจุบัน
|
|
461
|
-
|
|
792
|
+
// (ตอนลากกลุ่ม วัดหัวกลุ่มรวมแถวรายการทั้งก้อน — ดู measureDroppable)
|
|
793
|
+
measuring={measuring}
|
|
462
794
|
accessibility={{
|
|
463
795
|
screenReaderInstructions: { draggable: instructions },
|
|
464
796
|
announcements: dndAnnouncements,
|
|
465
797
|
}}
|
|
466
798
|
onDragStart={onDragStart}
|
|
467
799
|
onDragOver={onDragOver}
|
|
800
|
+
onDragMove={onDragMove}
|
|
468
801
|
onDragEnd={onDragEnd}
|
|
469
802
|
onDragCancel={onDragCancel}
|
|
470
803
|
>
|
|
@@ -511,8 +844,18 @@ export interface SortableRow {
|
|
|
511
844
|
/** props ที่ต้องแปะที่ "ที่จับ" — ส่งต่อให้ `<DragHandle {...handleProps} />` */
|
|
512
845
|
handleProps: Record<string, unknown>;
|
|
513
846
|
isDragging: boolean;
|
|
847
|
+
/** รายการนี้ถูกเลือกอยู่ (`multiSelect`) */
|
|
848
|
+
selected: boolean;
|
|
849
|
+
/**
|
|
850
|
+
* กระจายลงแถวเมื่อเปิด `multiSelect`: คลิกพร้อม ⌘/Ctrl/Shift เพื่อเลือก + `data-selected`
|
|
851
|
+
* สำหรับทำสี (`data-[selected]:bg-primary/10`) — ปิด `multiSelect` หรือเป็นแถวกลุ่ม = object ว่าง
|
|
852
|
+
*/
|
|
853
|
+
selectProps: Record<string, unknown>;
|
|
514
854
|
}
|
|
515
855
|
|
|
856
|
+
const INTERACTIVE =
|
|
857
|
+
"a,button,input,select,textarea,label,[contenteditable=''],[contenteditable='true'],[role=button],[role=checkbox],[role=switch],[role=combobox]";
|
|
858
|
+
|
|
516
859
|
/**
|
|
517
860
|
* ทำให้แถวหนึ่งลากได้ — แอปเอา `ref`/`style` ไปแปะที่ `<TableRow>` และ `handleProps` ที่ `<DragHandle>`
|
|
518
861
|
* `type: "group"` = ลากทั้งกลุ่ม, `"item"` = ลากรายการในกลุ่ม
|
|
@@ -533,21 +876,105 @@ export function useSortableRow({
|
|
|
533
876
|
disabled: disabled || state.disabled,
|
|
534
877
|
transition: SORT_TRANSITION,
|
|
535
878
|
});
|
|
536
|
-
const
|
|
537
|
-
|
|
538
|
-
|
|
879
|
+
const { register, shifts, select } = state;
|
|
880
|
+
const { setNodeRef } = sortable;
|
|
881
|
+
const ref = useCallback(
|
|
882
|
+
(node: HTMLElement | null) => {
|
|
883
|
+
setNodeRef(node);
|
|
884
|
+
register(type, id, node);
|
|
885
|
+
},
|
|
886
|
+
[setNodeRef, register, type, id],
|
|
887
|
+
);
|
|
888
|
+
|
|
889
|
+
const transform = CSS.Translate.toString(sortable.transform);
|
|
890
|
+
const groupDrag = state.dragging === "group";
|
|
891
|
+
// ระหว่างลากกลุ่ม provider คำนวณระยะเลื่อนของแต่ละกลุ่มเอง — หัวกลุ่มและแถวรายการของกลุ่มอ่านค่าเดียวกัน
|
|
892
|
+
// (หัวกลุ่มมักอยู่ใน `SortableGroup` ซึ่งเป็น context ของรายการ dnd-kit จึงคำนวณให้หัวกลุ่มไม่ได้)
|
|
893
|
+
const groupId = type === "item" ? state.groupOf(id) : id;
|
|
894
|
+
const shift = useSyncExternalStore(
|
|
895
|
+
shifts.subscribe,
|
|
896
|
+
() => (groupId ? shifts.get(groupId) : undefined),
|
|
897
|
+
() => undefined,
|
|
898
|
+
);
|
|
899
|
+
|
|
900
|
+
let style: CSSProperties = { transform, transition: sortable.transition };
|
|
901
|
+
if (type === "group" && groupDrag) {
|
|
902
|
+
const own = state.activeGroup === id;
|
|
903
|
+
style = {
|
|
904
|
+
transform: shift?.transform,
|
|
905
|
+
transition: shift?.transition,
|
|
906
|
+
...(own
|
|
907
|
+
? state.overlay
|
|
908
|
+
? { opacity: 0.4 }
|
|
909
|
+
: { position: "relative", zIndex: 1, opacity: 0.85 }
|
|
910
|
+
: {}),
|
|
911
|
+
};
|
|
912
|
+
} else if (sortable.isDragging) {
|
|
913
|
+
style = state.overlay
|
|
539
914
|
? // มี overlay ลอยตามเมาส์แล้ว แถวต้นทางเหลือเป็นช่องจาง ๆ บอกตำแหน่งที่จะวาง
|
|
540
|
-
{ opacity: 0.4 }
|
|
541
|
-
: { position: "relative", zIndex: 1, opacity: 0.85 };
|
|
915
|
+
{ ...style, opacity: 0.4 }
|
|
916
|
+
: { ...style, position: "relative", zIndex: 1, opacity: 0.85 };
|
|
917
|
+
} else if (type === "item" && groupDrag && shift) {
|
|
918
|
+
const own = state.activeGroup !== null && state.activeGroup === groupId;
|
|
919
|
+
style = {
|
|
920
|
+
transform: shift.transform,
|
|
921
|
+
transition: shift.transition,
|
|
922
|
+
...(own
|
|
923
|
+
? state.overlay
|
|
924
|
+
? { opacity: 0.4 }
|
|
925
|
+
: { position: "relative", zIndex: 1, opacity: 0.85 }
|
|
926
|
+
: {}),
|
|
927
|
+
};
|
|
928
|
+
} else if (type === "item" && state.carried.has(id)) {
|
|
929
|
+
// ลากแบบ bulk: ตัวที่ตามไปด้วยจางลงตรงที่เดิม แล้วไปรวมที่จุดวางตอนปล่อย
|
|
930
|
+
style = { ...style, opacity: 0.4 };
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
const selectable = type === "item" && state.multiSelect;
|
|
934
|
+
const isSelected = selectable && state.selected.has(id);
|
|
935
|
+
const listeners = sortable.listeners;
|
|
936
|
+
const handleProps: Record<string, unknown> = { ...sortable.attributes, ...listeners };
|
|
937
|
+
if (selectable) {
|
|
938
|
+
handleProps.onKeyDown = (event: KeyboardEvent<HTMLElement>) => {
|
|
939
|
+
const mod = event.metaKey || event.ctrlKey;
|
|
940
|
+
if (!mod && !event.altKey && event.key.toLowerCase() === "x") {
|
|
941
|
+
event.preventDefault();
|
|
942
|
+
select(id, "toggle");
|
|
943
|
+
return;
|
|
944
|
+
}
|
|
945
|
+
if (mod && event.key.toLowerCase() === "a") {
|
|
946
|
+
event.preventDefault();
|
|
947
|
+
select(id, "all");
|
|
948
|
+
return;
|
|
949
|
+
}
|
|
950
|
+
listeners?.onKeyDown?.(event);
|
|
951
|
+
};
|
|
952
|
+
}
|
|
953
|
+
const selectProps: Record<string, unknown> = selectable
|
|
954
|
+
? {
|
|
955
|
+
"data-selected": isSelected ? "" : undefined,
|
|
956
|
+
onMouseDown: (event: MouseEvent<HTMLElement>) => {
|
|
957
|
+
// Shift+คลิก ไม่ให้เบราว์เซอร์ลากคลุมข้อความทั้งแถว
|
|
958
|
+
if (event.shiftKey) event.preventDefault();
|
|
959
|
+
},
|
|
960
|
+
onClick: (event: MouseEvent<HTMLElement>) => {
|
|
961
|
+
const target = event.target as Element;
|
|
962
|
+
const control = target.closest(INTERACTIVE);
|
|
963
|
+
if (control && !control.closest("[data-sortable-handle]")) return;
|
|
964
|
+
if (event.metaKey || event.ctrlKey) select(id, "toggle");
|
|
965
|
+
else if (event.shiftKey) select(id, "range");
|
|
966
|
+
else if (state.selected.size > 0) select(id, "clear");
|
|
967
|
+
},
|
|
968
|
+
}
|
|
969
|
+
: {};
|
|
970
|
+
|
|
542
971
|
return {
|
|
543
|
-
ref
|
|
544
|
-
style
|
|
545
|
-
|
|
546
|
-
transition: sortable.transition,
|
|
547
|
-
...dragStyle,
|
|
548
|
-
},
|
|
549
|
-
handleProps: { ...sortable.attributes, ...sortable.listeners },
|
|
972
|
+
ref,
|
|
973
|
+
style,
|
|
974
|
+
handleProps,
|
|
550
975
|
isDragging: sortable.isDragging,
|
|
976
|
+
selected: isSelected,
|
|
977
|
+
selectProps,
|
|
551
978
|
};
|
|
552
979
|
}
|
|
553
980
|
|
|
@@ -617,6 +1044,7 @@ export function DragHandle({
|
|
|
617
1044
|
aria-label={label ?? state.handleLabel}
|
|
618
1045
|
disabled={off}
|
|
619
1046
|
className={cn("cursor-grab touch-none active:cursor-grabbing", className)}
|
|
1047
|
+
data-sortable-handle=""
|
|
620
1048
|
{...handleProps}
|
|
621
1049
|
>
|
|
622
1050
|
<GripVerticalIcon />
|
|
@@ -105,6 +105,31 @@ export function moveItemToGroup(
|
|
|
105
105
|
};
|
|
106
106
|
}
|
|
107
107
|
|
|
108
|
+
/** รายการทั้งหมดเรียงตามที่แสดง: ของที่ไม่มีกลุ่มก่อน แล้วไล่ตามลำดับกลุ่ม */
|
|
109
|
+
export function flatItems(order: SortableOrder): string[] {
|
|
110
|
+
return [UNGROUPED, ...order.groups].flatMap((group) => order.items[group] ?? []);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* ลากหลายรายการพร้อมกัน (bulk): ดึงรายการใน `ids` ทั้งหมดมารวมที่ตำแหน่งของ `activeId`
|
|
115
|
+
* (ตัวที่ผู้ใช้จับลาก) เรียงตามลำดับใน `ids` — ตัวอื่นหายจากที่เดิม ไม่ว่าจะอยู่กลุ่มไหน
|
|
116
|
+
*/
|
|
117
|
+
export function gatherItems(
|
|
118
|
+
order: SortableOrder,
|
|
119
|
+
activeId: string,
|
|
120
|
+
ids: ReadonlyArray<string>,
|
|
121
|
+
): SortableOrder {
|
|
122
|
+
const present = new Set(flatItems(order));
|
|
123
|
+
const moving = ids.filter((id) => present.has(id));
|
|
124
|
+
if (moving.length <= 1 || !moving.includes(activeId)) return order;
|
|
125
|
+
const others = new Set(moving.filter((id) => id !== activeId));
|
|
126
|
+
const items: Record<string, string[]> = {};
|
|
127
|
+
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
|
+
|
|
108
133
|
/** ลำดับเหมือนกันไหม (ใช้ตัดสินว่าต้องบันทึกหรือไม่) */
|
|
109
134
|
export function sameOrder(a: SortableOrder, b: SortableOrder): boolean {
|
|
110
135
|
if (a === b) return true;
|
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,6 +18,8 @@ export {
|
|
|
17
18
|
useSortableState,
|
|
18
19
|
} from "./components/sortable/sortable-list";
|
|
19
20
|
export {
|
|
21
|
+
flatItems,
|
|
22
|
+
gatherItems,
|
|
20
23
|
groupOfItem,
|
|
21
24
|
moveItemToGroup,
|
|
22
25
|
moveWithin,
|