@suphark/ui 0.33.0 → 0.37.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,57 @@
2
2
 
3
3
  รูปแบบตาม [Keep a Changelog](https://keepachangelog.com/) · semver: patch = หน้าตา/บั๊ก, minor = component/prop ใหม่, major = เปลี่ยน/ลบ props
4
4
 
5
+ ## [0.37.0] — 2026-10-07
6
+
7
+ ### Added
8
+
9
+ - **`FormInvalidNet`** (ข้อเสนอจาก Pi Auth — มี `<form action>` เขียนเอง ~60 จุด): ตาข่ายฟอร์ม invalid ระดับแอป วางครั้งเดียว ฟัง `invalid` ทั้ง document แล้วแจ้งเป็น toast ข้อความเดียวกับ `UiText.invalidForm` + พาไปช่องแรกที่โฟกัสไม่ได้ · ข้ามฟอร์มที่มีตาข่ายของตัวเอง (`FormDialog`/`ActionForm`/`useFormInvalidNotice` ใส่ `data-invalid-notice` ให้; ใส่เองที่ฟอร์มเพื่อปิดได้) · วางซ้ำหลายที่ก็ไม่ขึ้น toast ซ้อน
10
+ - `AppShell` วาง `FormInvalidNet` ให้เอง — หน้านอก AppShell (เช่น /login) วางใน root layout ใต้ `Toaster`
11
+ - `useFormInvalidNotice().formProps` มี `data-invalid-notice` แล้ว
12
+ - showcase: root layout วาง `FormInvalidNet`; `/components/action-form` มีตัวอย่าง `<form>` ดิบ
13
+
14
+ ## [0.36.0] — 2026-10-07
15
+
16
+ > ประกาศพร้อม publish แล้ว แต่ยังไม่ขึ้น npm — รวมอยู่ใน 0.37.0
17
+
18
+ มาตรฐานใหม่ (ขอจาก Pi Auth): **ปุ่มที่มีงานค้างต้องหมุน และฟอร์มที่ส่งไม่ได้ต้องบอกผู้ใช้เสมอ — ห้ามเงียบ**
19
+
20
+ ### Added
21
+
22
+ - `Button loading`: ไอคอนนำหน้าเปลี่ยนเป็น spinner (ไม่มีไอคอน = เติมหน้าข้อความ ความกว้างปุ่มไม่กระตุก), กดซ้ำไม่ได้, `aria-busy="true"`, ปุ่มยังรับโฟกัส (`focusableWhenDisabled` — โฟกัสไม่หลุดไป body ตอนกด) · ไม่ใส่ `loading` + `type="submit"` ภายใน `<form action={…}>` = หมุนเองจาก `useFormStatus()` (ทุกปุ่ม submit ในฟอร์มนั้น) · `loading={false}` ปิดการตรวจอัตโนมัติ
23
+ - `useFormInvalidNotice()` → `{ formProps, message, notice }`: จับ event `invalid` ของฟอร์ม รวมชื่อช่องที่ไม่ผ่านเป็นข้อความเดียว "กรุณากรอกหรือแก้ไข: ชื่อ, อีเมล" (`role="alert"`) — รวมช่องที่เบราว์เซอร์โฟกัสไม่ได้ (ซ่อนในแท็บ/ขั้นที่ปิด, input ซ่อนของ Select) ซึ่งปกติไม่มีอะไรขึ้นเลยนอกจาก console · เลื่อนไปที่ Field ของช่องแรกและโฟกัส control ที่กดได้ในนั้น · หายเองเมื่อแก้ครบ · ชื่อช่องจาก `<label>` (รวมแบบครอบของ `LabeledField`), `aria-label`, `aria-labelledby`, label ของ `Field`, placeholder, `name`
24
+ - `UiText.invalidForm` (`fields` ใช้ `{fields}`, `generic`) — ไทย/อังกฤษในตัว
25
+
26
+ ### Changed
27
+
28
+ - `FormDialog` / `ActionForm`: ปุ่มส่งใช้ `loading` ระหว่างรอ action (เดิมแค่ disabled จาง ๆ) และใส่ตาข่าย `useFormInvalidNotice` ให้เอง — แอปไม่ต้องแก้อะไร
29
+
30
+ ## [0.35.0] — 2026-10-07
31
+
32
+ > ประกาศพร้อม publish แล้ว แต่ยังไม่ขึ้น npm — publish 0.36.0 ทีเดียวได้ทั้งสองรุ่น
33
+
34
+ ### Added
35
+
36
+ - `SortableProvider renderOverlay={(drag) => …}`: วาดการ์ดลอยตามเมาส์แบบ Trello/Todoist (portal ไป `document.body` + drop animation 200ms) แถวต้นทางเหลือเป็นช่องจาง ๆ บอกตำแหน่งที่จะวาง · `useSortableState().overlay` บอกว่ากำลังใช้ overlay
37
+ - `useSortableDropZone(groupId, { onDwell, dwellMs = 600 })`: ลากค้างเหนือโซนครบเวลาแล้วเรียก `onDwell` — ใช้เปิดโฟลเดอร์ที่ย่ออยู่ให้เอง
38
+
39
+ ### Changed
40
+
41
+ - **`@suphark/ui/sortable` ลากลื่นขึ้น** (ผู้ใช้แจ้งว่าสะดุดตอนเอาเข้าโฟลเดอร์): หาเป้าหมายจากตำแหน่งเมาส์ (`pointerWithin` → `rectIntersection`) แทน `closestCenter` ทั้งตาราง, จำเป้าหมายล่าสุดกันสั่นตอนรายการย้ายข้ามกลุ่ม, ย้ายข้ามกลุ่มแทรก **ก่อนหรือหลัง** แถวเป้าหมายตามครึ่งแถว (เดิมแทรกก่อนเสมอ → ลากเข้าจากด้านล่างแล้วกระโดดข้าม), วางบนหัวโฟลเดอร์ = ขึ้นบนสุดของโฟลเดอร์ (เดิมไปท้ายกลุ่ม/ท้ายตาราง), วัดตำแหน่งใหม่ทุกครั้งที่ layout เปลี่ยน (`MeasuringStrategy.Always`) และ transition 200ms แบบ ease-out · วัดด้วย probe ลาก APP_URL ลง 222px: เดิมกระโดดไปท้ายกลุ่มแล้วไปท้ายตาราง, ตอนนี้ลงตรงใต้เมาส์และไม่เปลี่ยนเลยระหว่างค้างนิ่ง
42
+ - showcase: `/components/sortable-list` ใช้การ์ดลอย + ลากค้างเหนือโฟลเดอร์ที่ย่ออยู่แล้วเปิดให้เอง
43
+
44
+ ### Docs
45
+
46
+ - README (`@suphark/ui/sortable`): วิธีตรวจว่า `id` ทำงาน — `aria-describedby` ของที่จับเป็น id ที่ส่งเข้าไปตรง ๆ ไม่มี prefix `DndDescribedBy-` (prefix มีเฉพาะตอน dnd-kit สร้างเอง) และ element คำอธิบายถูกสร้างหลัง hydrate ต้องรอ mount ก่อน query (Pi Config ยืนยันบนของจริงหลัง bump 0.34.0)
47
+
48
+ ## [0.34.0] — 2026-10-06
49
+
50
+ ### Fixed
51
+
52
+ - **`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
53
+ - `@suphark/ui/sortable`: เสียงประกาศระหว่างลากในกลุ่มเดียวกันบอกตำแหน่ง **เดิม** ซ้ำ ("ย้ายไปตำแหน่งที่ 1 จาก 2" ทั้งที่เลื่อนลงแล้ว) เพราะลำดับจริงเปลี่ยนตอนปล่อย — ตอนนี้ระหว่างลากประกาศตำแหน่งของเป้าหมาย และตอนปล่อยประกาศตำแหน่งจริงหลังย้าย (เจอระหว่างทดสอบลากด้วยคีย์บอร์ดตามที่ Pi Config ถาม; ลากด้วยคีย์บอร์ดเองทำงานถูกอยู่แล้ว)
54
+ - showcase: `/components/sortable-list` ย้ายแถว "ลากมาที่นี่เพื่อเอาออกจากกลุ่ม" ไปท้ายตาราง — เดิมโผล่เหนือกลุ่มตอนเริ่มลาก ดันแถวที่กำลังลากลง จนลากคีย์ในกลุ่มด้วยคีย์บอร์ดไม่ขยับ (README เพิ่มคำเตือนนี้ + เรื่อง `id`)
55
+
5
56
  ## [0.33.0] — 2026-10-06
6
57
 
7
58
  ### Fixed
package/README.md CHANGED
@@ -166,6 +166,19 @@ export default async function Layout({ children }) {
166
166
  // → ได้ tooltip ด้วย (ไม่ต้องมี prop ใหม่ที่ FormDialog/RowActions)
167
167
  ```
168
168
 
169
+ **งานค้างต้องหมุน ห้ามเงียบ** (0.36.0): `<Button loading={saving}><SaveIcon /> บันทึก</Button>` — ไอคอนกลายเป็น spinner, กดซ้ำไม่ได้, `aria-busy` · `type="submit"` ใน `<form action={…}>` หมุนเองจาก `useFormStatus` · `FormDialog`/`ActionForm` ทำให้ทั้งปุ่มหมุนและตาข่ายฟอร์ม invalid · ฟอร์มเขียนเอง:
170
+
171
+ ```tsx
172
+ const invalid = useFormInvalidNotice();
173
+ <form action={save} {...invalid.formProps}>
174
+ …
175
+ {invalid.notice /* "กรุณากรอกหรือแก้ไข: ชื่อ, รหัสสาขา" — รวมช่องที่ซ่อน/โฟกัสไม่ได้ */}
176
+ <Button type="submit">บันทึก</Button>
177
+ </form>
178
+ ```
179
+
180
+ ไม่อยากใส่ทีละฟอร์ม: `<FormInvalidNet />` (0.37.0) แจ้งเป็น toast ให้ทุก `<form>` ที่ไม่มีตาข่ายของตัวเอง — `AppShell` ใส่ให้แล้ว หน้านอก AppShell วางใน root layout ใต้ `Toaster`
181
+
169
182
  | ต้องการ | เขียน |
170
183
  | --- | --- |
171
184
  | ข้อความอื่นที่ไม่ใช่ `aria-label` (หรือปุ่มที่มีข้อความแล้วแต่อยากอธิบายเพิ่ม) | `tooltip="ตรวจสอบอีกครั้ง"` |
@@ -217,8 +230,10 @@ const [order, setOrder] = useState(() =>
217
230
  </SortableProvider>
218
231
  ```
219
232
 
220
- - กลุ่มที่ไม่มีหัวกลุ่ม (เช่น "ยังไม่จัดกลุ่ม") ใช้ `useSortableDropZone(groupId)` แปะกับแถว placeholder ของตัวเอง และดูว่าควรแสดงเมื่อไหร่จาก `useSortableState().dragging`
233
+ - กลุ่มที่ไม่มีหัวกลุ่ม (เช่น "ยังไม่จัดกลุ่ม") ใช้ `useSortableDropZone(groupId)` แปะกับแถว placeholder ของตัวเอง และดูว่าควรแสดงเมื่อไหร่จาก `useSortableState().dragging` — **วางแถวนี้ไว้ใต้แถวที่ลากได้ทั้งหมด (ท้ายตาราง)** ถ้ามันโผล่ขึ้นเหนือแถวที่กำลังลาก layout จะเลื่อนตอนเริ่มลาก แล้วการลากด้วยคีย์บอร์ดจะไม่ขยับ
234
+ - ตั้ง `id` ของ `SortableProvider` ได้ (ไม่ตั้ง = `useId()`) — ต้องคงที่ระหว่าง SSR กับ client ไม่งั้น `aria-describedby` ของ dnd-kit จะ hydration mismatch · ตรวจได้จากที่จับ: `aria-describedby` จะเป็น **id ที่ส่งเข้าไปตรง ๆ** (ไม่มี prefix) และต้องชี้ element ที่มีข้อความวิธีลาก — element นี้สร้างหลัง hydrate จึงต้องรอหน้า mount ก่อนค่อย query
221
235
  - `onCommit` ล้มเหลว (throw หรือ `{ ok: false, error }`) = คืนลำดับเดิม + toast; ถ้าอยากจัดการ error เองใส่ `onCommitError`
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` = ต่อท้าย
222
237
  - `sortableOrderToPayload(order)` คืน `{ groups, items }` — `groups` คือลำดับของกลุ่ม, `items` คือรายการในแต่ละกลุ่มเรียงตามนั้น (ของที่ยังไม่มีกลุ่มมาก่อนด้วย `groupId: null`)
223
238
  - `SortableProvider disabled` (เช่นระหว่างกรอง ซึ่งลำดับที่เห็นไม่ใช่ลำดับจริง) ทำให้ `DragHandle` จางลงและกดไม่ได้ — ไม่อยากให้เห็นเลยใส่ `hideWhenDisabled`
224
239
  - ข้อความ 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.33.0",
3
+ "version": "0.37.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 เอง",
@@ -1,6 +1,7 @@
1
1
  "use client";
2
2
 
3
3
  import { Button } from "@suphark/ui/components/shared/button/button";
4
+ import { FormInvalidNet } from "@suphark/ui/components/shared/form/form-invalid-notice";
4
5
  import { SkipLink } from "@suphark/ui/components/shared/skip-link";
5
6
  import { useUiText } from "@suphark/ui/components/shared/ui-text";
6
7
  import {
@@ -133,7 +134,13 @@ export const APP_MAIN_ID = "app-main";
133
134
 
134
135
  /** ลิงก์ "ข้ามไปเนื้อหา" ของ AppShell — ใช้ `SkipLink` ตัวเดียวกับที่ export ให้แอปใช้นอก AppShell */
135
136
  function SkipToMain() {
136
- return <SkipLink href={`#${APP_MAIN_ID}`} />;
137
+ return (
138
+ <>
139
+ <SkipLink href={`#${APP_MAIN_ID}`} />
140
+ {/* ตาข่ายฟอร์ม invalid ทั้งแอป — วางคู่กับ skip link เพราะทั้งสองแบบของ shell เรียกตัวนี้ */}
141
+ <FormInvalidNet />
142
+ </>
143
+ );
137
144
  }
138
145
 
139
146
  function SidebarShell({
@@ -4,6 +4,7 @@ import { Button } from "@suphark/ui/components/shared/button/button";
4
4
  import { Card, CardContent } from "@suphark/ui/components/ui/card";
5
5
  import { cn } from "@suphark/ui/lib/utils";
6
6
  import { type ComponentProps, type ReactNode, startTransition, useActionState } from "react";
7
+ import { useFormInvalidNotice } from "./form/form-invalid-notice";
7
8
  import {
8
9
  type FormAction,
9
10
  FormMessage,
@@ -56,6 +57,7 @@ export function ActionForm<TData = undefined>({
56
57
  submitProps?: Omit<ComponentProps<typeof Button>, "type" | "disabled" | "children">;
57
58
  }) {
58
59
  const text = useUiText();
60
+ const invalid = useFormInvalidNotice();
59
61
  const [state, formAction, pending] = useActionState<FormState<TData>, FormData>(
60
62
  async (prev, fd) => {
61
63
  const next = await action(prev, fd);
@@ -67,6 +69,7 @@ export function ActionForm<TData = undefined>({
67
69
 
68
70
  const form = (
69
71
  <form
72
+ {...invalid.formProps}
70
73
  onSubmit={(e) => {
71
74
  e.preventDefault();
72
75
  const fd = new FormData(e.currentTarget);
@@ -79,6 +82,7 @@ export function ActionForm<TData = undefined>({
79
82
  >
80
83
  {title && !compact && <h2 className="font-semibold text-sm">{title}</h2>}
81
84
  {typeof children === "function" ? children(formRenderContext(state, pending)) : children}
85
+ {invalid.notice && <div className={compact ? "basis-full" : undefined}>{invalid.notice}</div>}
82
86
  {!(hideSuccessMessage && state?.ok) && (
83
87
  <FormMessage state={state} className={compact ? "basis-full" : undefined} />
84
88
  )}
@@ -87,7 +91,8 @@ export function ActionForm<TData = undefined>({
87
91
  className={compact ? undefined : "self-start"}
88
92
  {...submitProps}
89
93
  type="submit"
90
- disabled={pending || submitDisabled}
94
+ disabled={submitDisabled}
95
+ loading={pending}
91
96
  >
92
97
  {submitLabel ?? text.save}
93
98
  </Button>
@@ -2,6 +2,8 @@
2
2
 
3
3
  import { Button as ButtonBase } from "@suphark/ui/components/ui/button";
4
4
  import { Tooltip, TooltipContent, TooltipTrigger } from "@suphark/ui/components/ui/tooltip";
5
+ import { cn } from "@suphark/ui/lib/utils";
6
+ import { Loader2Icon } from "lucide-react";
5
7
  import {
6
8
  Children,
7
9
  type ComponentProps,
@@ -11,6 +13,7 @@ import {
11
13
  type ReactNode,
12
14
  useContext,
13
15
  } from "react";
16
+ import { useFormStatus } from "react-dom";
14
17
 
15
18
  export type ButtonTooltipSide = "top" | "bottom" | "left" | "right";
16
19
 
@@ -24,6 +27,12 @@ export interface ButtonProps extends ComponentProps<typeof ButtonBase> {
24
27
  tooltipSide?: ButtonTooltipSide;
25
28
  /** หน่วงก่อนโชว์ (ms) — ไม่ใส่ = ตาม `TooltipProvider` ของแอป หรือ 600 ตามค่าของ Base UI */
26
29
  tooltipDelay?: number;
30
+ /**
31
+ * กำลังทำงาน — ไอคอนนำหน้าเปลี่ยนเป็น spinner (ไม่มีไอคอน = เติม spinner หน้าข้อความ), กดซ้ำไม่ได้, `aria-busy`
32
+ * ปุ่มยังรับโฟกัสอยู่ (ไม่หลุดไป body ตอนกด) · ไม่ใส่ + `type="submit"` = ใช้ `useFormStatus()` ของ `<form action={…}>` ให้เอง
33
+ * (ทุกปุ่ม submit ในฟอร์มนั้นหมุนพร้อมกัน) · `false` = ปิดการตรวจอัตโนมัติ
34
+ */
35
+ loading?: boolean;
27
36
  }
28
37
 
29
38
  const NoAutoTooltip = createContext(false);
@@ -61,6 +70,37 @@ function srOnlyText(children: ReactNode): string | null {
61
70
  return found;
62
71
  }
63
72
 
73
+ function isSrOnly(node: ReactNode): boolean {
74
+ if (!isValidElement(node)) return false;
75
+ const className = (node.props as { className?: unknown }).className;
76
+ return typeof className === "string" && className.split(/\s+/).includes("sr-only");
77
+ }
78
+
79
+ /** แทนไอคอนนำหน้า (child ตัวแรกที่เป็น element) ด้วย spinner — ความกว้างปุ่มจึงไม่กระตุก; ไม่มีไอคอน = เติมหน้าข้อความ */
80
+ function withSpinner(children: ReactNode): ReactNode {
81
+ const list = Children.toArray(children);
82
+ const first = list[0];
83
+ const leadingIcon =
84
+ isValidElement(first) && first.type !== Fragment && !isSrOnly(first) ? first : null;
85
+ // คง data-icon เดิมของไอคอน (padding ของปุ่มขึ้นกับมัน) — เติมใหม่ = inline-start
86
+ const dataIcon = leadingIcon
87
+ ? (leadingIcon.props as { "data-icon"?: string })["data-icon"]
88
+ : "inline-start";
89
+ const iconClass = leadingIcon
90
+ ? (leadingIcon.props as { className?: unknown }).className
91
+ : undefined;
92
+ const spinner = (
93
+ <Loader2Icon
94
+ key="loading"
95
+ aria-hidden
96
+ data-icon={dataIcon}
97
+ data-slot="button-spinner"
98
+ className={cn(typeof iconClass === "string" && iconClass, "animate-spin")}
99
+ />
100
+ );
101
+ return leadingIcon ? [spinner, ...list.slice(1)] : [spinner, ...list];
102
+ }
103
+
64
104
  /** มี children ที่เป็นข้อความจริง ๆ ไหม (ไอคอน/`<span className="sr-only">` ไม่นับ) */
65
105
  function hasTextChild(children: ReactNode): boolean {
66
106
  let found = false;
@@ -91,8 +131,26 @@ function hasTextChild(children: ReactNode): boolean {
91
131
  * ปิดเป็นรายปุ่มด้วย `tooltip={false}`; ทับข้อความด้วย `tooltip="…"`
92
132
  * ไฟล์ shadcn ดิบอยู่ที่ `components/ui/button.tsx` (ห้ามแก้มือ) — barrel export ตัวนี้
93
133
  */
94
- export function Button({ tooltip, tooltipSide = "top", tooltipDelay, ...props }: ButtonProps) {
134
+ export function Button({
135
+ tooltip,
136
+ tooltipSide = "top",
137
+ tooltipDelay,
138
+ loading,
139
+ ...rest
140
+ }: ButtonProps) {
95
141
  const suppressed = useContext(NoAutoTooltip);
142
+ // นอก <form> หรือฟอร์มที่ไม่ได้ใช้ `action=` → pending เป็น false เสมอ
143
+ const form = useFormStatus();
144
+ const busy = loading ?? (rest.type === "submit" && form.pending);
145
+ const props: ComponentProps<typeof ButtonBase> = busy
146
+ ? {
147
+ ...rest,
148
+ disabled: true,
149
+ focusableWhenDisabled: true,
150
+ "aria-busy": true,
151
+ children: withSpinner(rest.children),
152
+ }
153
+ : rest;
96
154
  const label =
97
155
  typeof props["aria-label"] === "string" && props["aria-label"].trim() !== ""
98
156
  ? props["aria-label"]
@@ -0,0 +1,226 @@
1
+ "use client";
2
+
3
+ import { type FormEvent, type ReactNode, useEffect, useRef, useState } from "react";
4
+ import { toast } from "sonner";
5
+ import { UI_TEXT_TH, type UiText, useUiText } from "../ui-text";
6
+
7
+ type Control = HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement;
8
+
9
+ const CONTROL_SELECTOR =
10
+ "input:not([type=hidden]):not([aria-hidden=true]),select:not([aria-hidden=true]),textarea,button,[role=combobox],[tabindex]:not([tabindex='-1'])";
11
+
12
+ export interface FormInvalidNotice {
13
+ /** กระจายลง `<form>` — จับ event `invalid` (ไม่ bubble จึงต้อง capture) + ล้างเมื่อแก้ครบหรือส่งผ่าน */
14
+ formProps: {
15
+ /** เครื่องหมายให้ `FormInvalidNet` ข้ามฟอร์มนี้ (มีตาข่ายของตัวเองแล้ว) */
16
+ "data-invalid-notice": "";
17
+ onInvalidCapture: (event: FormEvent<HTMLFormElement>) => void;
18
+ onInputCapture: () => void;
19
+ onChangeCapture: () => void;
20
+ onSubmitCapture: () => void;
21
+ };
22
+ /** ข้อความที่จะแสดง (null = ไม่มีช่องผิด) */
23
+ message: string | null;
24
+ /** `<p role="alert">` สำเร็จรูป — วางไว้ใกล้ปุ่มส่ง */
25
+ notice: ReactNode;
26
+ }
27
+
28
+ /**
29
+ * ตาข่ายกัน "กดส่งแล้วเงียบ": เบราว์เซอร์บล็อกการส่งเมื่อมีช่อง `required`/`pattern`/… ไม่ผ่าน แต่ถ้าช่องนั้น
30
+ * โฟกัสไม่ได้ (ซ่อนอยู่ในแท็บ/ขั้นที่ปิด, input ซ่อนของ Select/Combobox) จะไม่มีอะไรขึ้นให้ผู้ใช้เห็นเลย มีแค่ใน console
31
+ * hook นี้รวมชื่อช่องที่ผิดเป็นข้อความเดียว ("กรุณากรอกหรือแก้ไข: ชื่อ, อีเมล") และเลื่อนไปที่ช่องแรกให้ —
32
+ * `FormDialog`/`ActionForm` ใส่ให้แล้ว ฟอร์มเขียนเองใช้:
33
+ *
34
+ * const invalid = useFormInvalidNotice();
35
+ * <form action={save} {...invalid.formProps}>… {invalid.notice} <Button type="submit">บันทึก</Button></form>
36
+ *
37
+ * ชื่อช่องหาจาก `<label>` (รวมแบบที่ครอบ control ของ `LabeledField`), `aria-label`, `aria-labelledby`,
38
+ * label ของ `Field` ที่ครอบอยู่, placeholder แล้วค่อย `name` · ข้อความจาก `UiText.invalidForm`
39
+ */
40
+ export function useFormInvalidNotice(): FormInvalidNotice {
41
+ const text = useUiText();
42
+ const [labels, setLabels] = useState<string[] | null>(null);
43
+ const batch = useRef<Control[]>([]);
44
+ const invalid = useRef<Control[]>([]);
45
+
46
+ function onInvalidCapture(event: FormEvent<HTMLFormElement>) {
47
+ const target = event.target as Control;
48
+ // เบราว์เซอร์ยิง invalid ทีละช่องแบบ synchronous ในรอบตรวจเดียว → รวบแล้วค่อยแสดงทีเดียว
49
+ if (batch.current.length === 0) queueMicrotask(flush);
50
+ batch.current.push(target);
51
+ }
52
+
53
+ function flush() {
54
+ const controls = batch.current;
55
+ batch.current = [];
56
+ invalid.current = controls;
57
+ setLabels(unique(controls.map(labelOf).filter((l): l is string => l !== null)));
58
+ const first = controls[0];
59
+ if (first && !canShowNativeBubble(first)) reveal(first);
60
+ }
61
+
62
+ function recheck() {
63
+ if (invalid.current.length === 0) return;
64
+ const still = invalid.current.filter((c) => c.isConnected && !c.validity.valid);
65
+ invalid.current = still;
66
+ setLabels(
67
+ still.length === 0 ? null : unique(still.map(labelOf).filter((l): l is string => l !== null)),
68
+ );
69
+ }
70
+
71
+ function clear() {
72
+ invalid.current = [];
73
+ setLabels(null);
74
+ }
75
+
76
+ const message = labels === null ? null : invalidMessage(text, labels);
77
+
78
+ return {
79
+ formProps: {
80
+ "data-invalid-notice": "",
81
+ onInvalidCapture,
82
+ onInputCapture: recheck,
83
+ onChangeCapture: recheck,
84
+ onSubmitCapture: clear,
85
+ },
86
+ message,
87
+ notice: message ? (
88
+ <p role="alert" data-slot="form-invalid-notice" className="text-destructive text-xs">
89
+ {message}
90
+ </p>
91
+ ) : null,
92
+ };
93
+ }
94
+
95
+ function invalidMessage(text: UiText, labels: string[]): string {
96
+ const t = { ...UI_TEXT_TH.invalidForm, ...text.invalidForm };
97
+ return labels.length > 0
98
+ ? (t.fields ?? "").replace("{fields}", labels.join(", "))
99
+ : (t.generic ?? "");
100
+ }
101
+
102
+ /** event ที่ net ตัวไหนจัดการไปแล้ว — วาง net ซ้ำ (root layout + AppShell) ก็ไม่ขึ้น toast ซ้อน */
103
+ const handled = new WeakSet<Event>();
104
+
105
+ /**
106
+ * ตาข่ายระดับแอป วางครั้งเดียว (AppShell ใส่ให้แล้ว; หน้านอก AppShell เช่น /login วางใน root layout ใต้ `Toaster`):
107
+ * ฟัง `invalid` ทั้ง document แล้วแจ้งเป็น toast "กรุณากรอกหรือแก้ไข: …" + พาไปช่องแรกที่โฟกัสไม่ได้
108
+ * ข้ามฟอร์มที่มีตาข่ายของตัวเอง (`FormDialog`/`ActionForm`/`useFormInvalidNotice` — มี `data-invalid-notice`)
109
+ * และฟอร์มที่ใส่ `data-invalid-notice` เองเพื่อปิด · ต้องมี `Toaster` ของ sonner ในหน้า
110
+ */
111
+ export function FormInvalidNet() {
112
+ const text = useUiText();
113
+ const textRef = useRef(text);
114
+ textRef.current = text;
115
+
116
+ useEffect(() => {
117
+ let batch: Control[] = [];
118
+ function flush() {
119
+ const controls = batch;
120
+ batch = [];
121
+ const labels = unique(controls.map(labelOf).filter((l): l is string => l !== null));
122
+ toast.error(invalidMessage(textRef.current, labels), { id: "suphark-form-invalid" });
123
+ const first = controls[0];
124
+ if (first && !canShowNativeBubble(first)) reveal(first);
125
+ }
126
+ function onInvalid(event: Event) {
127
+ const target = event.target;
128
+ if (handled.has(event)) return;
129
+ if (
130
+ !(target instanceof HTMLInputElement) &&
131
+ !(target instanceof HTMLSelectElement) &&
132
+ !(target instanceof HTMLTextAreaElement)
133
+ )
134
+ return;
135
+ handled.add(event);
136
+ // ฟอร์มที่มีตาข่ายของตัวเอง (หรือ control ที่ไม่อยู่ในฟอร์ม แต่อยู่ใต้ element ที่ทำเครื่องหมาย)
137
+ if (
138
+ target.form?.hasAttribute("data-invalid-notice") ||
139
+ target.closest("[data-invalid-notice]")
140
+ )
141
+ return;
142
+ if (batch.length === 0) queueMicrotask(flush);
143
+ batch.push(target);
144
+ }
145
+ document.addEventListener("invalid", onInvalid, true);
146
+ return () => document.removeEventListener("invalid", onInvalid, true);
147
+ }, []);
148
+
149
+ return null;
150
+ }
151
+
152
+ function unique(list: string[]): string[] {
153
+ return [...new Set(list)];
154
+ }
155
+
156
+ function isShown(el: Element): boolean {
157
+ return el.getClientRects().length > 0;
158
+ }
159
+
160
+ /** เบราว์เซอร์จะโฟกัสช่องนี้และขึ้น bubble เองไหม — ถ้าไม่ เราต้องพาผู้ใช้ไปเอง */
161
+ function canShowNativeBubble(el: Control): boolean {
162
+ return isShown(el) && el.getAttribute("aria-hidden") !== "true" && el.tabIndex >= 0;
163
+ }
164
+
165
+ /** เลื่อนไปที่ช่อง (หรือ Field ที่ครอบ / บรรพบุรุษตัวแรกที่มองเห็น) แล้วโฟกัส control ที่กดได้ในนั้น */
166
+ function reveal(el: Control) {
167
+ const field = el.closest<HTMLElement>("[data-slot=field],[data-slot=form-item]");
168
+ let anchor: HTMLElement | null = field && isShown(field) ? field : el;
169
+ while (anchor && !isShown(anchor)) anchor = anchor.parentElement;
170
+ if (!anchor) return;
171
+ anchor.scrollIntoView?.({ block: "center" });
172
+ const focusable = [...anchor.querySelectorAll<HTMLElement>(CONTROL_SELECTOR)].find(
173
+ (c) => c !== el && isShown(c) && !(c as HTMLButtonElement).disabled,
174
+ );
175
+ focusable?.focus({ preventScroll: true });
176
+ }
177
+
178
+ function clean(value: string | null | undefined): string | null {
179
+ const text = value
180
+ ?.replace(/\s+/g, " ")
181
+ .replace(/\s*\*$/, "")
182
+ .trim();
183
+ return text ? text : null;
184
+ }
185
+
186
+ /** ข้อความของ label โดยไม่เอาข้อความของ control ที่อยู่ข้างใน (label ที่ครอบ select จะมีข้อความ option ปนมา) */
187
+ function labelText(label: Element): string | null {
188
+ const parts: string[] = [];
189
+ for (const node of label.childNodes) {
190
+ if (node.nodeType === Node.TEXT_NODE) parts.push(node.textContent ?? "");
191
+ else if (
192
+ node instanceof Element &&
193
+ !node.matches(CONTROL_SELECTOR) &&
194
+ !node.querySelector(CONTROL_SELECTOR)
195
+ )
196
+ parts.push(node.textContent ?? "");
197
+ }
198
+ return clean(parts.join(" "));
199
+ }
200
+
201
+ function labelOf(el: Control): string | null {
202
+ const own = el.labels?.[0];
203
+ if (own) {
204
+ const t = labelText(own);
205
+ if (t) return t;
206
+ }
207
+ const aria = clean(el.getAttribute("aria-label"));
208
+ if (aria) return aria;
209
+ const labelledBy = el.getAttribute("aria-labelledby");
210
+ if (labelledBy) {
211
+ const t = clean(
212
+ labelledBy
213
+ .split(/\s+/)
214
+ .map((id) => el.ownerDocument.getElementById(id)?.textContent ?? "")
215
+ .join(" "),
216
+ );
217
+ if (t) return t;
218
+ }
219
+ const field = el.closest("[data-slot=field],[data-slot=form-item]");
220
+ const fieldLabel = field?.querySelector("label,[data-slot=field-label],[data-slot=form-label]");
221
+ if (fieldLabel) {
222
+ const t = labelText(fieldLabel);
223
+ if (t) return t;
224
+ }
225
+ return clean(el.getAttribute("placeholder")) ?? clean(el.getAttribute("name"));
226
+ }
@@ -21,6 +21,7 @@ import {
21
21
  useEffect,
22
22
  useState,
23
23
  } from "react";
24
+ import { useFormInvalidNotice } from "./form/form-invalid-notice";
24
25
  import {
25
26
  type FormAction,
26
27
  FormMessage,
@@ -140,6 +141,7 @@ function FormDialogBody<TData>({
140
141
  }) {
141
142
  const router = useRouter();
142
143
  const text = useUiText();
144
+ const invalid = useFormInvalidNotice();
143
145
  const [state, formAction, pending] = useActionState<FormState<TData>, FormData>(action, null);
144
146
  const [submitted, setSubmitted] = useState(0);
145
147
 
@@ -178,6 +180,7 @@ function FormDialogBody<TData>({
178
180
 
179
181
  return (
180
182
  <form
183
+ {...invalid.formProps}
181
184
  // ไม่ใช้ `action=` — React 19 รีเซ็ตฟอร์ม uncontrolled หลัง action จบทุกครั้ง (แม้คืน error)
182
185
  // ทำให้ค่าที่พิมพ์หาย; เรียก action ใน transition เองแทน ฟอร์มจึงคงค่าไว้ให้แก้ต่อได้
183
186
  onSubmit={(e) => {
@@ -195,12 +198,13 @@ function FormDialogBody<TData>({
195
198
  <div className="-mx-1 flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto px-1">
196
199
  {typeof children === "function" ? children(formRenderContext(state, pending)) : children}
197
200
  </div>
201
+ {invalid.notice}
198
202
  <FormMessage state={state} />
199
203
  <DialogFooter>
200
204
  <Button type="button" variant="ghost" onClick={close}>
201
205
  {text.cancel}
202
206
  </Button>
203
- <Button type="submit" disabled={pending}>
207
+ <Button type="submit" loading={pending}>
204
208
  {submitLabel ?? text.save}
205
209
  </Button>
206
210
  </DialogFooter>
@@ -71,6 +71,8 @@ export interface UiText {
71
71
  notifications?: Partial<{ title: string; empty: string; viewAll: string }>;
72
72
  /** SelectionBar (optional) — `selected` ใช้ `{count}` */
73
73
  selection?: Partial<{ selected: string; clear: string }>;
74
+ /** ตาข่ายฟอร์มที่ไม่ผ่าน validation (`useFormInvalidNotice`, FormDialog, ActionForm) — `fields` ใช้ `{fields}` */
75
+ invalidForm?: Partial<{ fields: string; generic: string }>;
74
76
  /** DataTable (optional) — `rowsSelected` ใช้ `{selected}`/`{total}`, `pageOf` ใช้ `{page}`/`{pages}` */
75
77
  table?: Partial<{
76
78
  rowsPerPage: string;
@@ -248,6 +250,7 @@ export const UI_TEXT_TH: UiText = {
248
250
  viewAll: "ดูทั้งหมด",
249
251
  },
250
252
  selection: { selected: "เลือก {count} รายการ", clear: "ล้างการเลือก" },
253
+ invalidForm: { fields: "กรุณากรอกหรือแก้ไข: {fields}", generic: "กรุณากรอกข้อมูลให้ครบถ้วน" },
251
254
  sortable: {
252
255
  handle: "ลากเพื่อจัดลำดับ",
253
256
  instructions: "กด Space เพื่อเริ่มลาก ใช้ปุ่มลูกศรเลื่อน แล้วกด Space อีกครั้งเพื่อวาง (Esc = ยกเลิก)",
@@ -413,6 +416,10 @@ export const UI_TEXT_EN: UiText = {
413
416
  viewAll: "View all",
414
417
  },
415
418
  selection: { selected: "{count} selected", clear: "Clear selection" },
419
+ invalidForm: {
420
+ fields: "Please fill in or fix: {fields}",
421
+ generic: "Please complete the required fields",
422
+ },
416
423
  sortable: {
417
424
  handle: "Drag to reorder",
418
425
  instructions:
@@ -6,9 +6,16 @@ import {
6
6
  DndContext,
7
7
  type DragEndEvent,
8
8
  type DragOverEvent,
9
+ DragOverlay,
9
10
  type DragStartEvent,
11
+ type DropAnimation,
12
+ defaultDropAnimationSideEffects,
13
+ getFirstCollision,
10
14
  KeyboardSensor,
15
+ MeasuringStrategy,
11
16
  PointerSensor,
17
+ pointerWithin,
18
+ rectIntersection,
12
19
  type UniqueIdentifier,
13
20
  useDroppable,
14
21
  useSensor,
@@ -23,6 +30,7 @@ import {
23
30
  import { CSS } from "@dnd-kit/utilities";
24
31
  import { Button } from "@suphark/ui/components/shared/button/button";
25
32
  import { useUiText } from "@suphark/ui/components/shared/ui-text";
33
+ import { useMounted } from "@suphark/ui/hooks/use-mounted";
26
34
  import { errorMessage } from "@suphark/ui/lib/action-result";
27
35
  import {
28
36
  groupOfItem,
@@ -41,10 +49,13 @@ import {
41
49
  type ReactNode,
42
50
  useCallback,
43
51
  useContext,
52
+ useEffect,
53
+ useId,
44
54
  useMemo,
45
55
  useRef,
46
56
  useState,
47
57
  } from "react";
58
+ import { createPortal } from "react-dom";
48
59
  import { toast } from "sonner";
49
60
 
50
61
  export type SortableKind = "group" | "item";
@@ -55,15 +66,13 @@ const dndId = (kind: SortableKind | "zone", id: string) =>
55
66
  const kindOf = (id: UniqueIdentifier) => String(id).slice(0, 1);
56
67
  const rawOf = (id: UniqueIdentifier) => String(id).slice(2);
57
68
 
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
- });
69
+ /** จังหวะเดียวกันทั้งแถวที่เลื่อนหลบและตอนปล่อย — ease-out สั้น ๆ แบบบอร์ดงาน (Trello/Todoist) */
70
+ const EASING = "cubic-bezier(0.25, 1, 0.5, 1)";
71
+ const SORT_TRANSITION = { duration: 200, easing: EASING };
72
+ const DROP_ANIMATION: DropAnimation = {
73
+ duration: 200,
74
+ easing: EASING,
75
+ sideEffects: defaultDropAnimationSideEffects({ styles: { active: { opacity: "0.4" } } }),
67
76
  };
68
77
 
69
78
  interface SortableState {
@@ -71,12 +80,15 @@ interface SortableState {
71
80
  dragging: SortableKind | null;
72
81
  disabled: boolean;
73
82
  handleLabel: string;
83
+ /** มี `renderOverlay` = แถวต้นทางเป็นที่ว่างจาง ๆ ส่วนของที่ลอยตามเมาส์คือ overlay */
84
+ overlay: boolean;
74
85
  }
75
86
 
76
87
  const SortableStateContext = createContext<SortableState>({
77
88
  dragging: null,
78
89
  disabled: true,
79
90
  handleLabel: "ลากเพื่อจัดลำดับ",
91
+ overlay: false,
80
92
  });
81
93
 
82
94
  /**
@@ -131,6 +143,19 @@ export interface SortableProviderProps {
131
143
  * ไม่ส่ง = แม่แบบจาก `UiText.sortable` (`{index}` / `{total}`)
132
144
  */
133
145
  announcements?: SortableAnnouncements;
146
+ /**
147
+ * id ของ `DndContext` — ต้องคงที่ระหว่าง SSR กับ client ไม่งั้น `aria-describedby="DndDescribedBy-N"`
148
+ * ที่ dnd-kit สร้างจากตัวนับจะไม่ตรงกัน (hydration mismatch ที่ React ไม่ซ่อมให้ → screen reader
149
+ * ไม่ได้ยินคำอธิบายวิธีลาก) · ไม่ส่ง = `useId()` ซึ่งตรงกันทั้งสองฝั่งอยู่แล้ว · ส่ง id ที่สื่อความหมาย
150
+ * (เช่น `values-${environmentId}`) ช่วยตอนไล่ปัญหา
151
+ */
152
+ id?: string;
153
+ /**
154
+ * สิ่งที่ลอยตามเมาส์ระหว่างลาก (แบบการ์ดใน Trello/Todoist) — ไม่ส่ง = ลากตัวแถวเองเหมือนเดิม
155
+ * ส่งมา = แถวต้นทางกลายเป็นช่องว่างจาง ๆ บอกตำแหน่งที่จะวาง และมีแอนิเมชันตอนปล่อย
156
+ * วาดใน portal ที่ `body` จึงคืน `<div>` ได้แม้รายการจริงเป็นแถว `<tr>` ของตาราง
157
+ */
158
+ renderOverlay?: (drag: { kind: SortableKind; id: string }) => ReactNode;
134
159
  children: ReactNode;
135
160
  }
136
161
 
@@ -152,15 +177,68 @@ export function SortableProvider({
152
177
  onCommitError,
153
178
  screenReaderInstructions,
154
179
  announcements,
180
+ id,
181
+ renderOverlay,
155
182
  children,
156
183
  }: SortableProviderProps) {
184
+ const fallbackId = useId();
157
185
  const text = useUiText();
186
+ const mounted = useMounted();
158
187
  const [dragging, setDragging] = useState<SortableKind | null>(null);
188
+ const [activeDrag, setActiveDrag] = useState<{ kind: SortableKind; id: string } | null>(null);
189
+ // กันกระพริบตอนย้ายข้ามกลุ่ม: จำเป้าหมายล่าสุดไว้ใช้ตอนที่ layout กำลังขยับแล้วหาเป้าหมายไม่เจอ
190
+ const lastOverId = useRef<UniqueIdentifier | null>(null);
191
+ const movedAcross = useRef(false);
159
192
  const beforeDrag = useRef<SortableOrder | null>(null);
160
193
  // อ่านลำดับล่าสุดตอนปล่อย (onDragOver เปลี่ยนไปหลายรอบแล้ว prop ยังไม่ทัน)
161
194
  const latest = useRef(order);
162
195
  latest.current = order;
163
196
 
197
+ // ย้ายข้ามกลุ่มเสร็จแล้ว (render ลำดับใหม่ไปแล้ว) ค่อยปลดธงในเฟรมถัดไป
198
+ useEffect(() => {
199
+ const frame = requestAnimationFrame(() => {
200
+ movedAcross.current = false;
201
+ });
202
+ return () => cancelAnimationFrame(frame);
203
+ }, [order]);
204
+
205
+ /**
206
+ * เลือกเป้าหมายแบบบอร์ดงาน (สูตรเดียวกับตัวอย่าง multiple containers ของ dnd-kit):
207
+ * - ลากกลุ่ม → เทียบกับกลุ่มด้วยกันเท่านั้น
208
+ * - ลากรายการ → ใช้ "สิ่งที่อยู่ใต้เมาส์จริง" ก่อน (pointerWithin) ไม่ใช่ตัวที่ใกล้ที่สุดทั้งตาราง
209
+ * (เดิมใช้ closestCenter ทั้งตาราง ทำให้แถวรับวางท้ายตารางแย่งเป้าหมาย รายการกระโดดไปไกลจากเมาส์)
210
+ * - ชี้หัวกลุ่มที่เปิดอยู่ → เลือกรายการในกลุ่มที่ใกล้ที่สุดแทน (วางลงในกลุ่มตรงตำแหน่ง)
211
+ * - หาไม่เจอชั่วขณะตอน layout ขยับ → ใช้เป้าหมายล่าสุด ไม่ให้แถวสั่นไปมา
212
+ */
213
+ const collisionDetection: CollisionDetection = useCallback((args) => {
214
+ if (kindOf(args.active.id) === "g") {
215
+ return closestCenter({
216
+ ...args,
217
+ droppableContainers: args.droppableContainers.filter((c) => kindOf(c.id) === "g"),
218
+ });
219
+ }
220
+ const candidates = args.droppableContainers.filter((c) => kindOf(c.id) !== "g");
221
+ const scoped = { ...args, droppableContainers: candidates };
222
+ const underPointer = pointerWithin(scoped);
223
+ const hits = underPointer.length > 0 ? underPointer : rectIntersection(scoped);
224
+ let overId = getFirstCollision(hits, "id");
225
+ if (overId != null) {
226
+ if (kindOf(overId) === "z") {
227
+ const ids = new Set(latest.current.items[rawOf(overId)] ?? []);
228
+ const inGroup = candidates.filter(
229
+ (c) => kindOf(c.id) === "i" && c.id !== args.active.id && ids.has(rawOf(c.id)),
230
+ );
231
+ if (inGroup.length > 0) {
232
+ overId = closestCenter({ ...args, droppableContainers: inGroup })[0]?.id ?? overId;
233
+ }
234
+ }
235
+ lastOverId.current = overId;
236
+ return [{ id: overId }];
237
+ }
238
+ if (movedAcross.current) lastOverId.current = args.active.id;
239
+ return lastOverId.current != null ? [{ id: lastOverId.current }] : [];
240
+ }, []);
241
+
164
242
  const sensors = useSensors(
165
243
  useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
166
244
  useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
@@ -173,7 +251,10 @@ export function SortableProvider({
173
251
 
174
252
  function onDragStart({ active }: DragStartEvent) {
175
253
  beforeDrag.current = latest.current;
176
- setDragging(kindOf(active.id) === "g" ? "group" : "item");
254
+ lastOverId.current = null;
255
+ const kind: SortableKind = kindOf(active.id) === "g" ? "group" : "item";
256
+ setDragging(kind);
257
+ setActiveDrag({ kind, id: rawOf(active.id) });
177
258
  }
178
259
 
179
260
  function onDragOver({ active, over }: DragOverEvent) {
@@ -182,13 +263,23 @@ export function SortableProvider({
182
263
  const from = containerOf(active.id, current);
183
264
  const to = containerOf(over.id, current);
184
265
  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
- );
266
+ // วางก่อนหรือหลังแถวเป้าหมาย ตามว่าตัวที่ลากเลยครึ่งแถวนั้นลงมาหรือยัง
267
+ // (เดิมแทรก "ก่อน" เสมอ — ลากเข้าจากด้านล่างแล้วรายการกระโดดข้ามขึ้นไป)
268
+ let before: string | null = null;
269
+ if (kindOf(over.id) === "i") {
270
+ const list = current.items[to] ?? [];
271
+ const at = list.indexOf(rawOf(over.id));
272
+ const dragged = active.rect.current.translated;
273
+ const below = dragged != null && dragged.top > over.rect.top + over.rect.height / 2;
274
+ before = list[at + (below ? 1 : 0)] ?? null;
275
+ } else if (to !== UNGROUPED) {
276
+ // วางบนหัวโฟลเดอร์ = ขึ้นบนสุดของโฟลเดอร์ (ตรงกับตำแหน่งที่เห็น และไม่กระโดดตอนโฟลเดอร์ที่ย่อไว้เปิดออก)
277
+ // ส่วนโซน "ไม่มีกลุ่ม" อยู่ท้ายตาราง จึงต่อท้าย
278
+ before = current.items[to]?.[0] ?? null;
279
+ }
280
+ const next = moveItemToGroup(current, rawOf(active.id), to, before);
191
281
  if (next !== current) {
282
+ movedAcross.current = true;
192
283
  latest.current = next;
193
284
  onOrderChange(next);
194
285
  }
@@ -219,6 +310,7 @@ export function SortableProvider({
219
310
  const snapshot = beforeDrag.current;
220
311
  beforeDrag.current = null;
221
312
  setDragging(null);
313
+ setActiveDrag(null);
222
314
  const current = latest.current;
223
315
  let next = current;
224
316
  if (over && kindOf(active.id) === "g" && kindOf(over.id) === "g") {
@@ -237,6 +329,7 @@ export function SortableProvider({
237
329
  const snapshot = beforeDrag.current;
238
330
  beforeDrag.current = null;
239
331
  setDragging(null);
332
+ setActiveDrag(null);
240
333
  if (snapshot) {
241
334
  latest.current = snapshot;
242
335
  onOrderChange(snapshot);
@@ -271,6 +364,31 @@ export function SortableProvider({
271
364
  };
272
365
  }, []);
273
366
 
367
+ /**
368
+ * ตำแหน่งที่ "จะไปวาง" ระหว่างลาก — ในกลุ่มเดียวกันลำดับจริงยังไม่เปลี่ยนจนกว่าจะปล่อย
369
+ * (เดิมประกาศตำแหน่งเดิมซ้ำ เช่น "ตำแหน่งที่ 1" ทั้งที่เลื่อนลงมาแล้ว) จึงใช้ตำแหน่งของเป้าหมายแทน
370
+ * ข้ามกลุ่มไม่ต้องทำอะไร — onDragOver ย้ายเข้ากลุ่มใหม่ใน order ไปแล้ว
371
+ */
372
+ const describeTarget = useCallback(
373
+ (activeId: UniqueIdentifier, overId: UniqueIdentifier | null): SortableAnnouncementContext => {
374
+ const base = describe(activeId);
375
+ if (overId === null || overId === activeId) return base;
376
+ const current = latest.current;
377
+ if (base.kind === "group" && kindOf(overId) === "g") {
378
+ const at = current.groups.indexOf(rawOf(overId));
379
+ return at >= 0 ? { ...base, index: at + 1 } : base;
380
+ }
381
+ if (base.kind === "item" && kindOf(overId) === "i") {
382
+ const group = groupOfItem(current, rawOf(overId));
383
+ if (!group || group !== base.groupId) return base;
384
+ const at = (current.items[group] ?? []).indexOf(rawOf(overId));
385
+ return at >= 0 ? { ...base, index: at + 1 } : base;
386
+ }
387
+ return base;
388
+ },
389
+ [describe],
390
+ );
391
+
274
392
  const dndAnnouncements = useMemo(() => {
275
393
  const fill = (
276
394
  template: string | undefined,
@@ -285,9 +403,19 @@ export function SortableProvider({
285
403
  own: ((context: SortableAnnouncementContext) => string | undefined) | undefined,
286
404
  template: string | undefined,
287
405
  fallback: string,
406
+ // ระหว่างลาก = ตำแหน่งของเป้าหมาย; ตอนปล่อย order อัปเดตแล้ว จึงใช้ตำแหน่งจริงของตัวที่ลาก
407
+ useTarget = false,
288
408
  ) =>
289
- ({ active }: { active: { id: UniqueIdentifier } }) => {
290
- const context = describe(active.id);
409
+ ({
410
+ active,
411
+ over,
412
+ }: {
413
+ active: { id: UniqueIdentifier };
414
+ over?: { id: UniqueIdentifier } | null;
415
+ }) => {
416
+ const context = useTarget
417
+ ? describeTarget(active.id, over?.id ?? null)
418
+ : describe(active.id);
291
419
  return own?.(context) ?? fill(template, fallback, context);
292
420
  };
293
421
  return {
@@ -300,6 +428,7 @@ export function SortableProvider({
300
428
  announcements?.onDragOver,
301
429
  text.sortable?.moved,
302
430
  "ย้ายไปตำแหน่งที่ {index} จาก {total}",
431
+ true,
303
432
  ),
304
433
  onDragEnd: announce(
305
434
  announcements?.onDragEnd,
@@ -308,7 +437,7 @@ export function SortableProvider({
308
437
  ),
309
438
  onDragCancel: announce(announcements?.onDragCancel, text.sortable?.cancelled, "ยกเลิกการลาก"),
310
439
  };
311
- }, [announcements, describe, text.sortable]);
440
+ }, [announcements, describeTarget, text.sortable]);
312
441
 
313
442
  const groupIds = useMemo(() => order.groups.map((id) => dndId("group", id)), [order.groups]);
314
443
 
@@ -317,15 +446,19 @@ export function SortableProvider({
317
446
  dragging,
318
447
  disabled,
319
448
  handleLabel: handleLabel ?? text.sortable?.handle ?? "ลากเพื่อจัดลำดับ",
449
+ overlay: Boolean(renderOverlay),
320
450
  }),
321
- [dragging, disabled, handleLabel, text.sortable?.handle],
451
+ [dragging, disabled, handleLabel, text.sortable?.handle, renderOverlay],
322
452
  );
323
453
 
324
454
  return (
325
455
  <SortableStateContext.Provider value={state}>
326
456
  <DndContext
457
+ id={id ?? fallbackId}
327
458
  sensors={sensors}
328
- collisionDetection={collision}
459
+ collisionDetection={collisionDetection}
460
+ // วัดตำแหน่งที่วางใหม่ตลอดระหว่างลาก — ย้ายข้ามกลุ่มแล้ว layout เปลี่ยน ต้องใช้ตำแหน่งปัจจุบัน
461
+ measuring={{ droppable: { strategy: MeasuringStrategy.Always } }}
329
462
  accessibility={{
330
463
  screenReaderInstructions: { draggable: instructions },
331
464
  announcements: dndAnnouncements,
@@ -340,6 +473,16 @@ export function SortableProvider({
340
473
  <SortableContext items={groupIds} strategy={verticalListSortingStrategy}>
341
474
  {children}
342
475
  </SortableContext>
476
+ {renderOverlay &&
477
+ mounted &&
478
+ createPortal(
479
+ <DragOverlay dropAnimation={DROP_ANIMATION}>
480
+ {activeDrag ? (
481
+ <div className="cursor-grabbing">{renderOverlay(activeDrag)}</div>
482
+ ) : null}
483
+ </DragOverlay>,
484
+ document.body,
485
+ )}
343
486
  </DndContext>
344
487
  </SortableStateContext.Provider>
345
488
  );
@@ -388,13 +531,20 @@ export function useSortableRow({
388
531
  const sortable = useSortable({
389
532
  id: dndId(type, id),
390
533
  disabled: disabled || state.disabled,
534
+ transition: SORT_TRANSITION,
391
535
  });
536
+ const dragStyle: CSSProperties = !sortable.isDragging
537
+ ? {}
538
+ : state.overlay
539
+ ? // มี overlay ลอยตามเมาส์แล้ว แถวต้นทางเหลือเป็นช่องจาง ๆ บอกตำแหน่งที่จะวาง
540
+ { opacity: 0.4 }
541
+ : { position: "relative", zIndex: 1, opacity: 0.85 };
392
542
  return {
393
543
  ref: sortable.setNodeRef,
394
544
  style: {
395
545
  transform: CSS.Translate.toString(sortable.transform),
396
546
  transition: sortable.transition,
397
- ...(sortable.isDragging ? { position: "relative", zIndex: 1, opacity: 0.85 } : {}),
547
+ ...dragStyle,
398
548
  },
399
549
  handleProps: { ...sortable.attributes, ...sortable.listeners },
400
550
  isDragging: sortable.isDragging,
@@ -405,7 +555,18 @@ export function useSortableRow({
405
555
  * ทำให้หัวกลุ่ม (หรือพื้นที่ว่างของกลุ่ม) รับการวางรายการได้ — กลุ่มที่ย่ออยู่ก็ยังรับได้
406
556
  * ปิดเองเมื่อกำลังลากกลุ่ม (ไม่ใช่รายการ)
407
557
  */
408
- export function useSortableDropZone(groupId: string | null): {
558
+ export function useSortableDropZone(
559
+ groupId: string | null,
560
+ options?: {
561
+ /**
562
+ * ลากรายการค้างไว้เหนือที่วางนี้นานพอ — ใช้เปิดกลุ่มที่ย่ออยู่ให้เองแบบโฟลเดอร์ใน Finder/Todoist
563
+ * (ไม่ส่ง = ไม่ทำอะไร)
564
+ */
565
+ onDwell?: () => void;
566
+ /** นานเท่าไรถึงนับว่าค้าง (ms) — ค่าเริ่มต้น 600 */
567
+ dwellMs?: number;
568
+ },
569
+ ): {
409
570
  ref: (node: HTMLElement | null) => void;
410
571
  isOver: boolean;
411
572
  } {
@@ -415,6 +576,17 @@ export function useSortableDropZone(groupId: string | null): {
415
576
  id: dndId("zone", groupId ?? UNGROUPED),
416
577
  disabled: state.disabled || state.dragging !== "item",
417
578
  });
579
+ const onDwell = options?.onDwell;
580
+ const dwellMs = options?.dwellMs ?? 600;
581
+ const hovering = droppable.isOver && state.dragging === "item";
582
+ // เก็บ callback ล่าสุดไว้ใน ref — แอปส่งฟังก์ชันใหม่ทุก render ก็ไม่ทำให้นับเวลาใหม่
583
+ const dwellRef = useRef(onDwell);
584
+ dwellRef.current = onDwell;
585
+ useEffect(() => {
586
+ if (!hovering || !dwellRef.current) return;
587
+ const timer = setTimeout(() => dwellRef.current?.(), dwellMs);
588
+ return () => clearTimeout(timer);
589
+ }, [hovering, dwellMs]);
418
590
  return { ref: droppable.setNodeRef, isOver: droppable.isOver };
419
591
  }
420
592
 
package/src/index.ts CHANGED
@@ -115,6 +115,11 @@ export {
115
115
  export { FormFieldRadioCards } from "./components/shared/form/form-field-radio-cards";
116
116
  export { FormFieldRadioGroup } from "./components/shared/form/form-field-radio-group";
117
117
  export { FormFieldSelect, type SelectOption } from "./components/shared/form/form-field-select";
118
+ export {
119
+ FormInvalidNet,
120
+ type FormInvalidNotice,
121
+ useFormInvalidNotice,
122
+ } from "./components/shared/form/form-invalid-notice";
118
123
  export { CheckboxField, LabeledField } from "./components/shared/form/labeled-field";
119
124
  export { NumberInput } from "./components/shared/form/number-input";
120
125
  export { SearchInput, type SearchInputProps } from "./components/shared/form/search-input";