@suphark/ui 0.33.0 → 0.34.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 CHANGED
@@ -2,6 +2,14 @@
2
2
 
3
3
  รูปแบบตาม [Keep a Changelog](https://keepachangelog.com/) · semver: patch = หน้าตา/บั๊ก, minor = component/prop ใหม่, major = เปลี่ยน/ลบ props
4
4
 
5
+ ## [0.34.0] — 2026-10-06
6
+
7
+ ### Fixed
8
+
9
+ - **`SortableProvider` ทำ hydration mismatch ทุกหน้าที่ SSR** (รายงานจาก Pi Config ตอนลากบนของจริง): ไม่ได้ส่ง `id` ให้ `DndContext` → dnd-kit สร้าง `aria-describedby="DndDescribedBy-N"` จากตัวนับที่เดินไม่ตรงกันระหว่าง server กับ client, React 19 ขึ้น error และ **ไม่ซ่อมให้** — attribute ค้างชี้ element ที่ไม่มีอยู่ ผู้ใช้ screen reader จึงไม่ได้ยินคำอธิบายวิธีลาก · ตอนนี้ค่าเริ่มต้นเป็น `useId()` (ตรงกันทั้งสองฝั่ง) และรับ prop **`id`** สำหรับ id ที่สื่อความหมาย — กระทบทุกแอปที่ใช้ `@suphark/ui/sortable` ใน Next.js
10
+ - `@suphark/ui/sortable`: เสียงประกาศระหว่างลากในกลุ่มเดียวกันบอกตำแหน่ง **เดิม** ซ้ำ ("ย้ายไปตำแหน่งที่ 1 จาก 2" ทั้งที่เลื่อนลงแล้ว) เพราะลำดับจริงเปลี่ยนตอนปล่อย — ตอนนี้ระหว่างลากประกาศตำแหน่งของเป้าหมาย และตอนปล่อยประกาศตำแหน่งจริงหลังย้าย (เจอระหว่างทดสอบลากด้วยคีย์บอร์ดตามที่ Pi Config ถาม; ลากด้วยคีย์บอร์ดเองทำงานถูกอยู่แล้ว)
11
+ - showcase: `/components/sortable-list` ย้ายแถว "ลากมาที่นี่เพื่อเอาออกจากกลุ่ม" ไปท้ายตาราง — เดิมโผล่เหนือกลุ่มตอนเริ่มลาก ดันแถวที่กำลังลากลง จนลากคีย์ในกลุ่มด้วยคีย์บอร์ดไม่ขยับ (README เพิ่มคำเตือนนี้ + เรื่อง `id`)
12
+
5
13
  ## [0.33.0] — 2026-10-06
6
14
 
7
15
  ### Fixed
package/README.md CHANGED
@@ -217,7 +217,8 @@ const [order, setOrder] = useState(() =>
217
217
  </SortableProvider>
218
218
  ```
219
219
 
220
- - กลุ่มที่ไม่มีหัวกลุ่ม (เช่น "ยังไม่จัดกลุ่ม") ใช้ `useSortableDropZone(groupId)` แปะกับแถว placeholder ของตัวเอง และดูว่าควรแสดงเมื่อไหร่จาก `useSortableState().dragging`
220
+ - กลุ่มที่ไม่มีหัวกลุ่ม (เช่น "ยังไม่จัดกลุ่ม") ใช้ `useSortableDropZone(groupId)` แปะกับแถว placeholder ของตัวเอง และดูว่าควรแสดงเมื่อไหร่จาก `useSortableState().dragging` — **วางแถวนี้ไว้ใต้แถวที่ลากได้ทั้งหมด (ท้ายตาราง)** ถ้ามันโผล่ขึ้นเหนือแถวที่กำลังลาก layout จะเลื่อนตอนเริ่มลาก แล้วการลากด้วยคีย์บอร์ดจะไม่ขยับ
221
+ - ตั้ง `id` ของ `SortableProvider` ได้ (ไม่ตั้ง = `useId()`) — ต้องคงที่ระหว่าง SSR กับ client ไม่งั้น `aria-describedby` ของ dnd-kit จะ hydration mismatch
221
222
  - `onCommit` ล้มเหลว (throw หรือ `{ ok: false, error }`) = คืนลำดับเดิม + toast; ถ้าอยากจัดการ error เองใส่ `onCommitError`
222
223
  - `sortableOrderToPayload(order)` คืน `{ groups, items }` — `groups` คือลำดับของกลุ่ม, `items` คือรายการในแต่ละกลุ่มเรียงตามนั้น (ของที่ยังไม่มีกลุ่มมาก่อนด้วย `groupId: null`)
223
224
  - `SortableProvider disabled` (เช่นระหว่างกรอง ซึ่งลำดับที่เห็นไม่ใช่ลำดับจริง) ทำให้ `DragHandle` จางลงและกดไม่ได้ — ไม่อยากให้เห็นเลยใส่ `hideWhenDisabled`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@suphark/ui",
3
- "version": "0.33.0",
3
+ "version": "0.34.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 เอง",
@@ -41,6 +41,7 @@ import {
41
41
  type ReactNode,
42
42
  useCallback,
43
43
  useContext,
44
+ useId,
44
45
  useMemo,
45
46
  useRef,
46
47
  useState,
@@ -131,6 +132,13 @@ export interface SortableProviderProps {
131
132
  * ไม่ส่ง = แม่แบบจาก `UiText.sortable` (`{index}` / `{total}`)
132
133
  */
133
134
  announcements?: SortableAnnouncements;
135
+ /**
136
+ * id ของ `DndContext` — ต้องคงที่ระหว่าง SSR กับ client ไม่งั้น `aria-describedby="DndDescribedBy-N"`
137
+ * ที่ dnd-kit สร้างจากตัวนับจะไม่ตรงกัน (hydration mismatch ที่ React ไม่ซ่อมให้ → screen reader
138
+ * ไม่ได้ยินคำอธิบายวิธีลาก) · ไม่ส่ง = `useId()` ซึ่งตรงกันทั้งสองฝั่งอยู่แล้ว · ส่ง id ที่สื่อความหมาย
139
+ * (เช่น `values-${environmentId}`) ช่วยตอนไล่ปัญหา
140
+ */
141
+ id?: string;
134
142
  children: ReactNode;
135
143
  }
136
144
 
@@ -152,8 +160,10 @@ export function SortableProvider({
152
160
  onCommitError,
153
161
  screenReaderInstructions,
154
162
  announcements,
163
+ id,
155
164
  children,
156
165
  }: SortableProviderProps) {
166
+ const fallbackId = useId();
157
167
  const text = useUiText();
158
168
  const [dragging, setDragging] = useState<SortableKind | null>(null);
159
169
  const beforeDrag = useRef<SortableOrder | null>(null);
@@ -271,6 +281,31 @@ export function SortableProvider({
271
281
  };
272
282
  }, []);
273
283
 
284
+ /**
285
+ * ตำแหน่งที่ "จะไปวาง" ระหว่างลาก — ในกลุ่มเดียวกันลำดับจริงยังไม่เปลี่ยนจนกว่าจะปล่อย
286
+ * (เดิมประกาศตำแหน่งเดิมซ้ำ เช่น "ตำแหน่งที่ 1" ทั้งที่เลื่อนลงมาแล้ว) จึงใช้ตำแหน่งของเป้าหมายแทน
287
+ * ข้ามกลุ่มไม่ต้องทำอะไร — onDragOver ย้ายเข้ากลุ่มใหม่ใน order ไปแล้ว
288
+ */
289
+ const describeTarget = useCallback(
290
+ (activeId: UniqueIdentifier, overId: UniqueIdentifier | null): SortableAnnouncementContext => {
291
+ const base = describe(activeId);
292
+ if (overId === null || overId === activeId) return base;
293
+ const current = latest.current;
294
+ if (base.kind === "group" && kindOf(overId) === "g") {
295
+ const at = current.groups.indexOf(rawOf(overId));
296
+ return at >= 0 ? { ...base, index: at + 1 } : base;
297
+ }
298
+ if (base.kind === "item" && kindOf(overId) === "i") {
299
+ const group = groupOfItem(current, rawOf(overId));
300
+ if (!group || group !== base.groupId) return base;
301
+ const at = (current.items[group] ?? []).indexOf(rawOf(overId));
302
+ return at >= 0 ? { ...base, index: at + 1 } : base;
303
+ }
304
+ return base;
305
+ },
306
+ [describe],
307
+ );
308
+
274
309
  const dndAnnouncements = useMemo(() => {
275
310
  const fill = (
276
311
  template: string | undefined,
@@ -285,9 +320,19 @@ export function SortableProvider({
285
320
  own: ((context: SortableAnnouncementContext) => string | undefined) | undefined,
286
321
  template: string | undefined,
287
322
  fallback: string,
323
+ // ระหว่างลาก = ตำแหน่งของเป้าหมาย; ตอนปล่อย order อัปเดตแล้ว จึงใช้ตำแหน่งจริงของตัวที่ลาก
324
+ useTarget = false,
288
325
  ) =>
289
- ({ active }: { active: { id: UniqueIdentifier } }) => {
290
- const context = describe(active.id);
326
+ ({
327
+ active,
328
+ over,
329
+ }: {
330
+ active: { id: UniqueIdentifier };
331
+ over?: { id: UniqueIdentifier } | null;
332
+ }) => {
333
+ const context = useTarget
334
+ ? describeTarget(active.id, over?.id ?? null)
335
+ : describe(active.id);
291
336
  return own?.(context) ?? fill(template, fallback, context);
292
337
  };
293
338
  return {
@@ -300,6 +345,7 @@ export function SortableProvider({
300
345
  announcements?.onDragOver,
301
346
  text.sortable?.moved,
302
347
  "ย้ายไปตำแหน่งที่ {index} จาก {total}",
348
+ true,
303
349
  ),
304
350
  onDragEnd: announce(
305
351
  announcements?.onDragEnd,
@@ -308,7 +354,7 @@ export function SortableProvider({
308
354
  ),
309
355
  onDragCancel: announce(announcements?.onDragCancel, text.sortable?.cancelled, "ยกเลิกการลาก"),
310
356
  };
311
- }, [announcements, describe, text.sortable]);
357
+ }, [announcements, describeTarget, text.sortable]);
312
358
 
313
359
  const groupIds = useMemo(() => order.groups.map((id) => dndId("group", id)), [order.groups]);
314
360
 
@@ -324,6 +370,7 @@ export function SortableProvider({
324
370
  return (
325
371
  <SortableStateContext.Provider value={state}>
326
372
  <DndContext
373
+ id={id ?? fallbackId}
327
374
  sensors={sensors}
328
375
  collisionDetection={collision}
329
376
  accessibility={{