@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 +43 -0
- package/README.md +15 -1
- package/package.json +1 -1
- package/src/components/app-shell/app-shell.tsx +8 -1
- package/src/components/shared/action-form.tsx +6 -1
- package/src/components/shared/button/button.tsx +59 -1
- package/src/components/shared/form/form-invalid-notice.tsx +226 -0
- package/src/components/shared/form-dialog.tsx +5 -1
- package/src/components/shared/ui-text.tsx +7 -0
- package/src/components/sortable/sortable-list.tsx +145 -20
- package/src/index.ts +5 -0
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.
|
|
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
|
|
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={
|
|
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({
|
|
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"
|
|
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
|
-
/**
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
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
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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={
|
|
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
|
-
...
|
|
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(
|
|
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";
|