@suphark/ui 0.34.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,49 @@
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
+
5
48
  ## [0.34.0] — 2026-10-06
6
49
 
7
50
  ### 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="ตรวจสอบอีกครั้ง"` |
@@ -218,8 +231,9 @@ const [order, setOrder] = useState(() =>
218
231
  ```
219
232
 
220
233
  - กลุ่มที่ไม่มีหัวกลุ่ม (เช่น "ยังไม่จัดกลุ่ม") ใช้ `useSortableDropZone(groupId)` แปะกับแถว placeholder ของตัวเอง และดูว่าควรแสดงเมื่อไหร่จาก `useSortableState().dragging` — **วางแถวนี้ไว้ใต้แถวที่ลากได้ทั้งหมด (ท้ายตาราง)** ถ้ามันโผล่ขึ้นเหนือแถวที่กำลังลาก layout จะเลื่อนตอนเริ่มลาก แล้วการลากด้วยคีย์บอร์ดจะไม่ขยับ
221
- - ตั้ง `id` ของ `SortableProvider` ได้ (ไม่ตั้ง = `useId()`) — ต้องคงที่ระหว่าง SSR กับ client ไม่งั้น `aria-describedby` ของ dnd-kit จะ hydration mismatch
234
+ - ตั้ง `id` ของ `SortableProvider` ได้ (ไม่ตั้ง = `useId()`) — ต้องคงที่ระหว่าง SSR กับ client ไม่งั้น `aria-describedby` ของ dnd-kit จะ hydration mismatch · ตรวจได้จากที่จับ: `aria-describedby` จะเป็น **id ที่ส่งเข้าไปตรง ๆ** (ไม่มี prefix) และต้องชี้ element ที่มีข้อความวิธีลาก — element นี้สร้างหลัง hydrate จึงต้องรอหน้า mount ก่อนค่อย query
222
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` = ต่อท้าย
223
237
  - `sortableOrderToPayload(order)` คืน `{ groups, items }` — `groups` คือลำดับของกลุ่ม, `items` คือรายการในแต่ละกลุ่มเรียงตามนั้น (ของที่ยังไม่มีกลุ่มมาก่อนด้วย `groupId: null`)
224
238
  - `SortableProvider disabled` (เช่นระหว่างกรอง ซึ่งลำดับที่เห็นไม่ใช่ลำดับจริง) ทำให้ `DragHandle` จางลงและกดไม่ได้ — ไม่อยากให้เห็นเลยใส่ `hideWhenDisabled`
225
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.34.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,11 +49,13 @@ import {
41
49
  type ReactNode,
42
50
  useCallback,
43
51
  useContext,
52
+ useEffect,
44
53
  useId,
45
54
  useMemo,
46
55
  useRef,
47
56
  useState,
48
57
  } from "react";
58
+ import { createPortal } from "react-dom";
49
59
  import { toast } from "sonner";
50
60
 
51
61
  export type SortableKind = "group" | "item";
@@ -56,15 +66,13 @@ const dndId = (kind: SortableKind | "zone", id: string) =>
56
66
  const kindOf = (id: UniqueIdentifier) => String(id).slice(0, 1);
57
67
  const rawOf = (id: UniqueIdentifier) => String(id).slice(2);
58
68
 
59
- /** ลากกลุ่มชนได้แค่กลุ่ม ลากรายการชนได้แค่รายการกับหัวกลุ่ม — ไม่งั้น closestCenter สลับชนิดกันมั่ว */
60
- const collision: CollisionDetection = (args) => {
61
- const draggingGroup = kindOf(args.active.id) === "g";
62
- return closestCenter({
63
- ...args,
64
- droppableContainers: args.droppableContainers.filter(
65
- (container) => (kindOf(container.id) === "g") === draggingGroup,
66
- ),
67
- });
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" } } }),
68
76
  };
69
77
 
70
78
  interface SortableState {
@@ -72,12 +80,15 @@ interface SortableState {
72
80
  dragging: SortableKind | null;
73
81
  disabled: boolean;
74
82
  handleLabel: string;
83
+ /** มี `renderOverlay` = แถวต้นทางเป็นที่ว่างจาง ๆ ส่วนของที่ลอยตามเมาส์คือ overlay */
84
+ overlay: boolean;
75
85
  }
76
86
 
77
87
  const SortableStateContext = createContext<SortableState>({
78
88
  dragging: null,
79
89
  disabled: true,
80
90
  handleLabel: "ลากเพื่อจัดลำดับ",
91
+ overlay: false,
81
92
  });
82
93
 
83
94
  /**
@@ -139,6 +150,12 @@ export interface SortableProviderProps {
139
150
  * (เช่น `values-${environmentId}`) ช่วยตอนไล่ปัญหา
140
151
  */
141
152
  id?: string;
153
+ /**
154
+ * สิ่งที่ลอยตามเมาส์ระหว่างลาก (แบบการ์ดใน Trello/Todoist) — ไม่ส่ง = ลากตัวแถวเองเหมือนเดิม
155
+ * ส่งมา = แถวต้นทางกลายเป็นช่องว่างจาง ๆ บอกตำแหน่งที่จะวาง และมีแอนิเมชันตอนปล่อย
156
+ * วาดใน portal ที่ `body` จึงคืน `<div>` ได้แม้รายการจริงเป็นแถว `<tr>` ของตาราง
157
+ */
158
+ renderOverlay?: (drag: { kind: SortableKind; id: string }) => ReactNode;
142
159
  children: ReactNode;
143
160
  }
144
161
 
@@ -161,16 +178,67 @@ export function SortableProvider({
161
178
  screenReaderInstructions,
162
179
  announcements,
163
180
  id,
181
+ renderOverlay,
164
182
  children,
165
183
  }: SortableProviderProps) {
166
184
  const fallbackId = useId();
167
185
  const text = useUiText();
186
+ const mounted = useMounted();
168
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);
169
192
  const beforeDrag = useRef<SortableOrder | null>(null);
170
193
  // อ่านลำดับล่าสุดตอนปล่อย (onDragOver เปลี่ยนไปหลายรอบแล้ว prop ยังไม่ทัน)
171
194
  const latest = useRef(order);
172
195
  latest.current = order;
173
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
+
174
242
  const sensors = useSensors(
175
243
  useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
176
244
  useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
@@ -183,7 +251,10 @@ export function SortableProvider({
183
251
 
184
252
  function onDragStart({ active }: DragStartEvent) {
185
253
  beforeDrag.current = latest.current;
186
- 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) });
187
258
  }
188
259
 
189
260
  function onDragOver({ active, over }: DragOverEvent) {
@@ -192,13 +263,23 @@ export function SortableProvider({
192
263
  const from = containerOf(active.id, current);
193
264
  const to = containerOf(over.id, current);
194
265
  if (!from || !to || from === to) return;
195
- const next = moveItemToGroup(
196
- current,
197
- rawOf(active.id),
198
- to,
199
- kindOf(over.id) === "i" ? rawOf(over.id) : null,
200
- );
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);
201
281
  if (next !== current) {
282
+ movedAcross.current = true;
202
283
  latest.current = next;
203
284
  onOrderChange(next);
204
285
  }
@@ -229,6 +310,7 @@ export function SortableProvider({
229
310
  const snapshot = beforeDrag.current;
230
311
  beforeDrag.current = null;
231
312
  setDragging(null);
313
+ setActiveDrag(null);
232
314
  const current = latest.current;
233
315
  let next = current;
234
316
  if (over && kindOf(active.id) === "g" && kindOf(over.id) === "g") {
@@ -247,6 +329,7 @@ export function SortableProvider({
247
329
  const snapshot = beforeDrag.current;
248
330
  beforeDrag.current = null;
249
331
  setDragging(null);
332
+ setActiveDrag(null);
250
333
  if (snapshot) {
251
334
  latest.current = snapshot;
252
335
  onOrderChange(snapshot);
@@ -363,8 +446,9 @@ export function SortableProvider({
363
446
  dragging,
364
447
  disabled,
365
448
  handleLabel: handleLabel ?? text.sortable?.handle ?? "ลากเพื่อจัดลำดับ",
449
+ overlay: Boolean(renderOverlay),
366
450
  }),
367
- [dragging, disabled, handleLabel, text.sortable?.handle],
451
+ [dragging, disabled, handleLabel, text.sortable?.handle, renderOverlay],
368
452
  );
369
453
 
370
454
  return (
@@ -372,7 +456,9 @@ export function SortableProvider({
372
456
  <DndContext
373
457
  id={id ?? fallbackId}
374
458
  sensors={sensors}
375
- collisionDetection={collision}
459
+ collisionDetection={collisionDetection}
460
+ // วัดตำแหน่งที่วางใหม่ตลอดระหว่างลาก — ย้ายข้ามกลุ่มแล้ว layout เปลี่ยน ต้องใช้ตำแหน่งปัจจุบัน
461
+ measuring={{ droppable: { strategy: MeasuringStrategy.Always } }}
376
462
  accessibility={{
377
463
  screenReaderInstructions: { draggable: instructions },
378
464
  announcements: dndAnnouncements,
@@ -387,6 +473,16 @@ export function SortableProvider({
387
473
  <SortableContext items={groupIds} strategy={verticalListSortingStrategy}>
388
474
  {children}
389
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
+ )}
390
486
  </DndContext>
391
487
  </SortableStateContext.Provider>
392
488
  );
@@ -435,13 +531,20 @@ export function useSortableRow({
435
531
  const sortable = useSortable({
436
532
  id: dndId(type, id),
437
533
  disabled: disabled || state.disabled,
534
+ transition: SORT_TRANSITION,
438
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 };
439
542
  return {
440
543
  ref: sortable.setNodeRef,
441
544
  style: {
442
545
  transform: CSS.Translate.toString(sortable.transform),
443
546
  transition: sortable.transition,
444
- ...(sortable.isDragging ? { position: "relative", zIndex: 1, opacity: 0.85 } : {}),
547
+ ...dragStyle,
445
548
  },
446
549
  handleProps: { ...sortable.attributes, ...sortable.listeners },
447
550
  isDragging: sortable.isDragging,
@@ -452,7 +555,18 @@ export function useSortableRow({
452
555
  * ทำให้หัวกลุ่ม (หรือพื้นที่ว่างของกลุ่ม) รับการวางรายการได้ — กลุ่มที่ย่ออยู่ก็ยังรับได้
453
556
  * ปิดเองเมื่อกำลังลากกลุ่ม (ไม่ใช่รายการ)
454
557
  */
455
- 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
+ ): {
456
570
  ref: (node: HTMLElement | null) => void;
457
571
  isOver: boolean;
458
572
  } {
@@ -462,6 +576,17 @@ export function useSortableDropZone(groupId: string | null): {
462
576
  id: dndId("zone", groupId ?? UNGROUPED),
463
577
  disabled: state.disabled || state.dragging !== "item",
464
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]);
465
590
  return { ref: droppable.setNodeRef, isOver: droppable.isOver };
466
591
  }
467
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";