@suphark/ui 0.34.0 → 0.38.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,63 @@
2
2
 
3
3
  รูปแบบตาม [Keep a Changelog](https://keepachangelog.com/) · semver: patch = หน้าตา/บั๊ก, minor = component/prop ใหม่, major = เปลี่ยน/ลบ props
4
4
 
5
+ ## [0.38.0] — 2026-10-07
6
+
7
+ ### Fixed
8
+
9
+ - **`@suphark/ui/sortable` ลากกลุ่มแล้วไม่เห็นอะไรขยับ** (เจ้าของแจ้ง): หัวกลุ่มมักอยู่ใน `SortableGroup` ซึ่งเป็น context ของรายการ dnd-kit จึงหากลุ่มในลำดับไม่เจอ ไม่มีแถวไหนได้ transform เลยจนปล่อย · ตอนนี้ provider คำนวณระยะเลื่อนของกลุ่มเอง และเลื่อน **ทั้งก้อน** (หัวกลุ่ม + แถวรายการ) — กลุ่มอื่นหลบเท่าความสูงทั้งก้อน, ช่องจาง ๆ ของกลุ่มที่ลากเลื่อนไปจุดวาง (ไม่มี overlay = ทั้งก้อนลอยตามเมาส์) · ที่วางของกลุ่มวัดรวมแถวรายการ ใช้ได้ทุกโครงสร้างโดยไม่ต้องแก้ call site · วัดบนหน้า demo: ลาก "ฐานข้อมูล" ลงเลย "อีเมล" → ฐานข้อมูลทั้งก้อน +134px, อีเมลทั้งก้อน −135px, ปล่อยแล้วไม่มี style ค้าง
10
+ - แถวต้นทางจางค้างหลังปล่อย (เจอกับหัวกลุ่ม): side effect ของ drop animation ใน dnd-kit จำ opacity ที่เราตั้งไว้เป็นค่าเดิมแล้วเขียนคืน — ปิด side effect นั้น (opacity คุมผ่าน style ของ React อยู่แล้ว)
11
+
12
+ ### Added
13
+
14
+ - `SortableProvider multiSelect` (+ `selected`/`onSelectedChange` ถ้าจะคุมเอง): เลือกหลายรายการแล้วลากไปพร้อมกัน — ⌘/Ctrl+คลิก ทีละตัว, Shift+คลิก เป็นช่วง (เฉพาะแถวที่แสดง), คลิกเฉย ๆ ล้าง (คลิกปุ่ม/ช่องกรอกในแถวไม่นับ) · ที่จับ: `x` เลือก/ไม่เลือก, ⌘/Ctrl+A ทั้งหมด, Esc ล้าง · ลากตัวที่เลือก = ทุกตัวที่เลือกจางลงตรงที่เดิมแล้วไปรวมที่จุดวาง เรียงตามลำดับเดิม (ข้ามกลุ่มได้) · ลากตัวที่ไม่ได้เลือก = ลากตัวเดียวและล้างการเลือก
15
+ - `useSortableRow` คืน `selected` + `selectProps` (กระจายลงแถว มี `data-selected` ไว้ทำสี) · `useSortableState` คืน `selected`/`clearSelection` (ใช้คู่ `SelectionBar`)
16
+ - `renderOverlay` ได้ `SortableDrag` = `{ kind, id, ids }` (`ids` = ทุกตัวที่ลากไปด้วยกัน — ทำการ์ดซ้อน + จำนวน) · `SortableAnnouncementContext.count` + `UiText.sortable.startedMany` ("เริ่มลาก {count} รายการที่เลือกไว้")
17
+ - helper pure: `flatItems(order)`, `gatherItems(order, activeId, ids)`
18
+ - showcase: `/components/sortable-list` เปิด `multiSelect` + `SelectionBar` + การ์ดซ้อนตอนลากหลายคีย์
19
+
20
+
21
+ ### Added
22
+
23
+ - **`FormInvalidNet`** (ข้อเสนอจาก Pi Auth — มี `<form action>` เขียนเอง ~60 จุด): ตาข่ายฟอร์ม invalid ระดับแอป วางครั้งเดียว ฟัง `invalid` ทั้ง document แล้วแจ้งเป็น toast ข้อความเดียวกับ `UiText.invalidForm` + พาไปช่องแรกที่โฟกัสไม่ได้ · ข้ามฟอร์มที่มีตาข่ายของตัวเอง (`FormDialog`/`ActionForm`/`useFormInvalidNotice` ใส่ `data-invalid-notice` ให้; ใส่เองที่ฟอร์มเพื่อปิดได้) · วางซ้ำหลายที่ก็ไม่ขึ้น toast ซ้อน
24
+ - `AppShell` วาง `FormInvalidNet` ให้เอง — หน้านอก AppShell (เช่น /login) วางใน root layout ใต้ `Toaster`
25
+ - `useFormInvalidNotice().formProps` มี `data-invalid-notice` แล้ว
26
+ - showcase: root layout วาง `FormInvalidNet`; `/components/action-form` มีตัวอย่าง `<form>` ดิบ
27
+
28
+ ## [0.36.0] — 2026-10-07
29
+
30
+ > ประกาศพร้อม publish แล้ว แต่ยังไม่ขึ้น npm — รวมอยู่ใน 0.37.0
31
+
32
+ มาตรฐานใหม่ (ขอจาก Pi Auth): **ปุ่มที่มีงานค้างต้องหมุน และฟอร์มที่ส่งไม่ได้ต้องบอกผู้ใช้เสมอ — ห้ามเงียบ**
33
+
34
+ ### Added
35
+
36
+ - `Button loading`: ไอคอนนำหน้าเปลี่ยนเป็น spinner (ไม่มีไอคอน = เติมหน้าข้อความ ความกว้างปุ่มไม่กระตุก), กดซ้ำไม่ได้, `aria-busy="true"`, ปุ่มยังรับโฟกัส (`focusableWhenDisabled` — โฟกัสไม่หลุดไป body ตอนกด) · ไม่ใส่ `loading` + `type="submit"` ภายใน `<form action={…}>` = หมุนเองจาก `useFormStatus()` (ทุกปุ่ม submit ในฟอร์มนั้น) · `loading={false}` ปิดการตรวจอัตโนมัติ
37
+ - `useFormInvalidNotice()` → `{ formProps, message, notice }`: จับ event `invalid` ของฟอร์ม รวมชื่อช่องที่ไม่ผ่านเป็นข้อความเดียว "กรุณากรอกหรือแก้ไข: ชื่อ, อีเมล" (`role="alert"`) — รวมช่องที่เบราว์เซอร์โฟกัสไม่ได้ (ซ่อนในแท็บ/ขั้นที่ปิด, input ซ่อนของ Select) ซึ่งปกติไม่มีอะไรขึ้นเลยนอกจาก console · เลื่อนไปที่ Field ของช่องแรกและโฟกัส control ที่กดได้ในนั้น · หายเองเมื่อแก้ครบ · ชื่อช่องจาก `<label>` (รวมแบบครอบของ `LabeledField`), `aria-label`, `aria-labelledby`, label ของ `Field`, placeholder, `name`
38
+ - `UiText.invalidForm` (`fields` ใช้ `{fields}`, `generic`) — ไทย/อังกฤษในตัว
39
+
40
+ ### Changed
41
+
42
+ - `FormDialog` / `ActionForm`: ปุ่มส่งใช้ `loading` ระหว่างรอ action (เดิมแค่ disabled จาง ๆ) และใส่ตาข่าย `useFormInvalidNotice` ให้เอง — แอปไม่ต้องแก้อะไร
43
+
44
+ ## [0.35.0] — 2026-10-07
45
+
46
+ > ประกาศพร้อม publish แล้ว แต่ยังไม่ขึ้น npm — publish 0.36.0 ทีเดียวได้ทั้งสองรุ่น
47
+
48
+ ### Added
49
+
50
+ - `SortableProvider renderOverlay={(drag) => …}`: วาดการ์ดลอยตามเมาส์แบบ Trello/Todoist (portal ไป `document.body` + drop animation 200ms) แถวต้นทางเหลือเป็นช่องจาง ๆ บอกตำแหน่งที่จะวาง · `useSortableState().overlay` บอกว่ากำลังใช้ overlay
51
+ - `useSortableDropZone(groupId, { onDwell, dwellMs = 600 })`: ลากค้างเหนือโซนครบเวลาแล้วเรียก `onDwell` — ใช้เปิดโฟลเดอร์ที่ย่ออยู่ให้เอง
52
+
53
+ ### Changed
54
+
55
+ - **`@suphark/ui/sortable` ลากลื่นขึ้น** (ผู้ใช้แจ้งว่าสะดุดตอนเอาเข้าโฟลเดอร์): หาเป้าหมายจากตำแหน่งเมาส์ (`pointerWithin` → `rectIntersection`) แทน `closestCenter` ทั้งตาราง, จำเป้าหมายล่าสุดกันสั่นตอนรายการย้ายข้ามกลุ่ม, ย้ายข้ามกลุ่มแทรก **ก่อนหรือหลัง** แถวเป้าหมายตามครึ่งแถว (เดิมแทรกก่อนเสมอ → ลากเข้าจากด้านล่างแล้วกระโดดข้าม), วางบนหัวโฟลเดอร์ = ขึ้นบนสุดของโฟลเดอร์ (เดิมไปท้ายกลุ่ม/ท้ายตาราง), วัดตำแหน่งใหม่ทุกครั้งที่ layout เปลี่ยน (`MeasuringStrategy.Always`) และ transition 200ms แบบ ease-out · วัดด้วย probe ลาก APP_URL ลง 222px: เดิมกระโดดไปท้ายกลุ่มแล้วไปท้ายตาราง, ตอนนี้ลงตรงใต้เมาส์และไม่เปลี่ยนเลยระหว่างค้างนิ่ง
56
+ - showcase: `/components/sortable-list` ใช้การ์ดลอย + ลากค้างเหนือโฟลเดอร์ที่ย่ออยู่แล้วเปิดให้เอง
57
+
58
+ ### Docs
59
+
60
+ - README (`@suphark/ui/sortable`): วิธีตรวจว่า `id` ทำงาน — `aria-describedby` ของที่จับเป็น id ที่ส่งเข้าไปตรง ๆ ไม่มี prefix `DndDescribedBy-` (prefix มีเฉพาะตอน dnd-kit สร้างเอง) และ element คำอธิบายถูกสร้างหลัง hydrate ต้องรอ mount ก่อน query (Pi Config ยืนยันบนของจริงหลัง bump 0.34.0)
61
+
5
62
  ## [0.34.0] — 2026-10-06
6
63
 
7
64
  ### 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,10 @@ 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` = ต่อท้าย
237
+ - เลือกหลายรายการแล้วลากพร้อมกัน: `SortableProvider multiSelect` แล้วกระจาย `row.selectProps` ลงแถว (ทำสีด้วย `data-[selected]:bg-primary/10`) — ⌘/Ctrl+คลิก, Shift+คลิก, ที่จับกด `x` / ⌘/Ctrl+A, Esc ล้าง · แถบนับใช้ `SelectionBar` + `useSortableState().selected/clearSelection` · `renderOverlay` ได้ `drag.ids` ไว้ทำการ์ดซ้อน
223
238
  - `sortableOrderToPayload(order)` คืน `{ groups, items }` — `groups` คือลำดับของกลุ่ม, `items` คือรายการในแต่ละกลุ่มเรียงตามนั้น (ของที่ยังไม่มีกลุ่มมาก่อนด้วย `groupId: null`)
224
239
  - `SortableProvider disabled` (เช่นระหว่างกรอง ซึ่งลำดับที่เห็นไม่ใช่ลำดับจริง) ทำให้ `DragHandle` จางลงและกดไม่ได้ — ไม่อยากให้เห็นเลยใส่ `hideWhenDisabled`
225
240
  - ข้อความ screen reader: ใส่ `announcements`/`screenReaderInstructions` เป็น prop (callback ได้ `{ id, kind, groupId, index, total }`) หรือปล่อยให้ใช้แม่แบบจาก `UiText.sortable`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@suphark/ui",
3
- "version": "0.34.0",
3
+ "version": "0.38.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Design system กลางของ Suphark apps: shadcn/ui (base-nova, Base UI, Tailwind v4) + shared components + token/ฟอนต์ Noto Sans Thai Looped — ส่งเป็น source ให้แอป Next transpile เอง",
@@ -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>
@@ -28,6 +28,8 @@ export interface UiText {
28
28
  instructions: string;
29
29
  /** ใช้ `{index}` / `{total}` */
30
30
  started: string;
31
+ /** เริ่มลากหลายรายการที่เลือกไว้ — ใช้ `{count}` */
32
+ startedMany: string;
31
33
  moved: string;
32
34
  dropped: string;
33
35
  cancelled: string;
@@ -71,6 +73,8 @@ export interface UiText {
71
73
  notifications?: Partial<{ title: string; empty: string; viewAll: string }>;
72
74
  /** SelectionBar (optional) — `selected` ใช้ `{count}` */
73
75
  selection?: Partial<{ selected: string; clear: string }>;
76
+ /** ตาข่ายฟอร์มที่ไม่ผ่าน validation (`useFormInvalidNotice`, FormDialog, ActionForm) — `fields` ใช้ `{fields}` */
77
+ invalidForm?: Partial<{ fields: string; generic: string }>;
74
78
  /** DataTable (optional) — `rowsSelected` ใช้ `{selected}`/`{total}`, `pageOf` ใช้ `{page}`/`{pages}` */
75
79
  table?: Partial<{
76
80
  rowsPerPage: string;
@@ -248,10 +252,12 @@ export const UI_TEXT_TH: UiText = {
248
252
  viewAll: "ดูทั้งหมด",
249
253
  },
250
254
  selection: { selected: "เลือก {count} รายการ", clear: "ล้างการเลือก" },
255
+ invalidForm: { fields: "กรุณากรอกหรือแก้ไข: {fields}", generic: "กรุณากรอกข้อมูลให้ครบถ้วน" },
251
256
  sortable: {
252
257
  handle: "ลากเพื่อจัดลำดับ",
253
258
  instructions: "กด Space เพื่อเริ่มลาก ใช้ปุ่มลูกศรเลื่อน แล้วกด Space อีกครั้งเพื่อวาง (Esc = ยกเลิก)",
254
259
  started: "เริ่มลากรายการที่ {index} จาก {total}",
260
+ startedMany: "เริ่มลาก {count} รายการที่เลือกไว้",
255
261
  moved: "ย้ายไปตำแหน่งที่ {index} จาก {total}",
256
262
  dropped: "วางที่ตำแหน่งที่ {index} จาก {total}",
257
263
  cancelled: "ยกเลิกการลาก",
@@ -413,11 +419,16 @@ export const UI_TEXT_EN: UiText = {
413
419
  viewAll: "View all",
414
420
  },
415
421
  selection: { selected: "{count} selected", clear: "Clear selection" },
422
+ invalidForm: {
423
+ fields: "Please fill in or fix: {fields}",
424
+ generic: "Please complete the required fields",
425
+ },
416
426
  sortable: {
417
427
  handle: "Drag to reorder",
418
428
  instructions:
419
429
  "Press Space to start dragging, use arrow keys to move, Space again to drop (Esc to cancel)",
420
430
  started: "Picked up item {index} of {total}",
431
+ startedMany: "Picked up {count} selected items",
421
432
  moved: "Moved to position {index} of {total}",
422
433
  dropped: "Dropped at position {index} of {total}",
423
434
  cancelled: "Dragging cancelled",