@suphark/ui 0.33.0 → 0.37.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +51 -0
- package/README.md +16 -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 +195 -23
- package/src/index.ts +5 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,57 @@
|
|
|
2
2
|
|
|
3
3
|
รูปแบบตาม [Keep a Changelog](https://keepachangelog.com/) · semver: patch = หน้าตา/บั๊ก, minor = component/prop ใหม่, major = เปลี่ยน/ลบ props
|
|
4
4
|
|
|
5
|
+
## [0.37.0] — 2026-10-07
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- **`FormInvalidNet`** (ข้อเสนอจาก Pi Auth — มี `<form action>` เขียนเอง ~60 จุด): ตาข่ายฟอร์ม invalid ระดับแอป วางครั้งเดียว ฟัง `invalid` ทั้ง document แล้วแจ้งเป็น toast ข้อความเดียวกับ `UiText.invalidForm` + พาไปช่องแรกที่โฟกัสไม่ได้ · ข้ามฟอร์มที่มีตาข่ายของตัวเอง (`FormDialog`/`ActionForm`/`useFormInvalidNotice` ใส่ `data-invalid-notice` ให้; ใส่เองที่ฟอร์มเพื่อปิดได้) · วางซ้ำหลายที่ก็ไม่ขึ้น toast ซ้อน
|
|
10
|
+
- `AppShell` วาง `FormInvalidNet` ให้เอง — หน้านอก AppShell (เช่น /login) วางใน root layout ใต้ `Toaster`
|
|
11
|
+
- `useFormInvalidNotice().formProps` มี `data-invalid-notice` แล้ว
|
|
12
|
+
- showcase: root layout วาง `FormInvalidNet`; `/components/action-form` มีตัวอย่าง `<form>` ดิบ
|
|
13
|
+
|
|
14
|
+
## [0.36.0] — 2026-10-07
|
|
15
|
+
|
|
16
|
+
> ประกาศพร้อม publish แล้ว แต่ยังไม่ขึ้น npm — รวมอยู่ใน 0.37.0
|
|
17
|
+
|
|
18
|
+
มาตรฐานใหม่ (ขอจาก Pi Auth): **ปุ่มที่มีงานค้างต้องหมุน และฟอร์มที่ส่งไม่ได้ต้องบอกผู้ใช้เสมอ — ห้ามเงียบ**
|
|
19
|
+
|
|
20
|
+
### Added
|
|
21
|
+
|
|
22
|
+
- `Button loading`: ไอคอนนำหน้าเปลี่ยนเป็น spinner (ไม่มีไอคอน = เติมหน้าข้อความ ความกว้างปุ่มไม่กระตุก), กดซ้ำไม่ได้, `aria-busy="true"`, ปุ่มยังรับโฟกัส (`focusableWhenDisabled` — โฟกัสไม่หลุดไป body ตอนกด) · ไม่ใส่ `loading` + `type="submit"` ภายใน `<form action={…}>` = หมุนเองจาก `useFormStatus()` (ทุกปุ่ม submit ในฟอร์มนั้น) · `loading={false}` ปิดการตรวจอัตโนมัติ
|
|
23
|
+
- `useFormInvalidNotice()` → `{ formProps, message, notice }`: จับ event `invalid` ของฟอร์ม รวมชื่อช่องที่ไม่ผ่านเป็นข้อความเดียว "กรุณากรอกหรือแก้ไข: ชื่อ, อีเมล" (`role="alert"`) — รวมช่องที่เบราว์เซอร์โฟกัสไม่ได้ (ซ่อนในแท็บ/ขั้นที่ปิด, input ซ่อนของ Select) ซึ่งปกติไม่มีอะไรขึ้นเลยนอกจาก console · เลื่อนไปที่ Field ของช่องแรกและโฟกัส control ที่กดได้ในนั้น · หายเองเมื่อแก้ครบ · ชื่อช่องจาก `<label>` (รวมแบบครอบของ `LabeledField`), `aria-label`, `aria-labelledby`, label ของ `Field`, placeholder, `name`
|
|
24
|
+
- `UiText.invalidForm` (`fields` ใช้ `{fields}`, `generic`) — ไทย/อังกฤษในตัว
|
|
25
|
+
|
|
26
|
+
### Changed
|
|
27
|
+
|
|
28
|
+
- `FormDialog` / `ActionForm`: ปุ่มส่งใช้ `loading` ระหว่างรอ action (เดิมแค่ disabled จาง ๆ) และใส่ตาข่าย `useFormInvalidNotice` ให้เอง — แอปไม่ต้องแก้อะไร
|
|
29
|
+
|
|
30
|
+
## [0.35.0] — 2026-10-07
|
|
31
|
+
|
|
32
|
+
> ประกาศพร้อม publish แล้ว แต่ยังไม่ขึ้น npm — publish 0.36.0 ทีเดียวได้ทั้งสองรุ่น
|
|
33
|
+
|
|
34
|
+
### Added
|
|
35
|
+
|
|
36
|
+
- `SortableProvider renderOverlay={(drag) => …}`: วาดการ์ดลอยตามเมาส์แบบ Trello/Todoist (portal ไป `document.body` + drop animation 200ms) แถวต้นทางเหลือเป็นช่องจาง ๆ บอกตำแหน่งที่จะวาง · `useSortableState().overlay` บอกว่ากำลังใช้ overlay
|
|
37
|
+
- `useSortableDropZone(groupId, { onDwell, dwellMs = 600 })`: ลากค้างเหนือโซนครบเวลาแล้วเรียก `onDwell` — ใช้เปิดโฟลเดอร์ที่ย่ออยู่ให้เอง
|
|
38
|
+
|
|
39
|
+
### Changed
|
|
40
|
+
|
|
41
|
+
- **`@suphark/ui/sortable` ลากลื่นขึ้น** (ผู้ใช้แจ้งว่าสะดุดตอนเอาเข้าโฟลเดอร์): หาเป้าหมายจากตำแหน่งเมาส์ (`pointerWithin` → `rectIntersection`) แทน `closestCenter` ทั้งตาราง, จำเป้าหมายล่าสุดกันสั่นตอนรายการย้ายข้ามกลุ่ม, ย้ายข้ามกลุ่มแทรก **ก่อนหรือหลัง** แถวเป้าหมายตามครึ่งแถว (เดิมแทรกก่อนเสมอ → ลากเข้าจากด้านล่างแล้วกระโดดข้าม), วางบนหัวโฟลเดอร์ = ขึ้นบนสุดของโฟลเดอร์ (เดิมไปท้ายกลุ่ม/ท้ายตาราง), วัดตำแหน่งใหม่ทุกครั้งที่ layout เปลี่ยน (`MeasuringStrategy.Always`) และ transition 200ms แบบ ease-out · วัดด้วย probe ลาก APP_URL ลง 222px: เดิมกระโดดไปท้ายกลุ่มแล้วไปท้ายตาราง, ตอนนี้ลงตรงใต้เมาส์และไม่เปลี่ยนเลยระหว่างค้างนิ่ง
|
|
42
|
+
- showcase: `/components/sortable-list` ใช้การ์ดลอย + ลากค้างเหนือโฟลเดอร์ที่ย่ออยู่แล้วเปิดให้เอง
|
|
43
|
+
|
|
44
|
+
### Docs
|
|
45
|
+
|
|
46
|
+
- README (`@suphark/ui/sortable`): วิธีตรวจว่า `id` ทำงาน — `aria-describedby` ของที่จับเป็น id ที่ส่งเข้าไปตรง ๆ ไม่มี prefix `DndDescribedBy-` (prefix มีเฉพาะตอน dnd-kit สร้างเอง) และ element คำอธิบายถูกสร้างหลัง hydrate ต้องรอ mount ก่อน query (Pi Config ยืนยันบนของจริงหลัง bump 0.34.0)
|
|
47
|
+
|
|
48
|
+
## [0.34.0] — 2026-10-06
|
|
49
|
+
|
|
50
|
+
### Fixed
|
|
51
|
+
|
|
52
|
+
- **`SortableProvider` ทำ hydration mismatch ทุกหน้าที่ SSR** (รายงานจาก Pi Config ตอนลากบนของจริง): ไม่ได้ส่ง `id` ให้ `DndContext` → dnd-kit สร้าง `aria-describedby="DndDescribedBy-N"` จากตัวนับที่เดินไม่ตรงกันระหว่าง server กับ client, React 19 ขึ้น error และ **ไม่ซ่อมให้** — attribute ค้างชี้ element ที่ไม่มีอยู่ ผู้ใช้ screen reader จึงไม่ได้ยินคำอธิบายวิธีลาก · ตอนนี้ค่าเริ่มต้นเป็น `useId()` (ตรงกันทั้งสองฝั่ง) และรับ prop **`id`** สำหรับ id ที่สื่อความหมาย — กระทบทุกแอปที่ใช้ `@suphark/ui/sortable` ใน Next.js
|
|
53
|
+
- `@suphark/ui/sortable`: เสียงประกาศระหว่างลากในกลุ่มเดียวกันบอกตำแหน่ง **เดิม** ซ้ำ ("ย้ายไปตำแหน่งที่ 1 จาก 2" ทั้งที่เลื่อนลงแล้ว) เพราะลำดับจริงเปลี่ยนตอนปล่อย — ตอนนี้ระหว่างลากประกาศตำแหน่งของเป้าหมาย และตอนปล่อยประกาศตำแหน่งจริงหลังย้าย (เจอระหว่างทดสอบลากด้วยคีย์บอร์ดตามที่ Pi Config ถาม; ลากด้วยคีย์บอร์ดเองทำงานถูกอยู่แล้ว)
|
|
54
|
+
- showcase: `/components/sortable-list` ย้ายแถว "ลากมาที่นี่เพื่อเอาออกจากกลุ่ม" ไปท้ายตาราง — เดิมโผล่เหนือกลุ่มตอนเริ่มลาก ดันแถวที่กำลังลากลง จนลากคีย์ในกลุ่มด้วยคีย์บอร์ดไม่ขยับ (README เพิ่มคำเตือนนี้ + เรื่อง `id`)
|
|
55
|
+
|
|
5
56
|
## [0.33.0] — 2026-10-06
|
|
6
57
|
|
|
7
58
|
### Fixed
|
package/README.md
CHANGED
|
@@ -166,6 +166,19 @@ export default async function Layout({ children }) {
|
|
|
166
166
|
// → ได้ tooltip ด้วย (ไม่ต้องมี prop ใหม่ที่ FormDialog/RowActions)
|
|
167
167
|
```
|
|
168
168
|
|
|
169
|
+
**งานค้างต้องหมุน ห้ามเงียบ** (0.36.0): `<Button loading={saving}><SaveIcon /> บันทึก</Button>` — ไอคอนกลายเป็น spinner, กดซ้ำไม่ได้, `aria-busy` · `type="submit"` ใน `<form action={…}>` หมุนเองจาก `useFormStatus` · `FormDialog`/`ActionForm` ทำให้ทั้งปุ่มหมุนและตาข่ายฟอร์ม invalid · ฟอร์มเขียนเอง:
|
|
170
|
+
|
|
171
|
+
```tsx
|
|
172
|
+
const invalid = useFormInvalidNotice();
|
|
173
|
+
<form action={save} {...invalid.formProps}>
|
|
174
|
+
…
|
|
175
|
+
{invalid.notice /* "กรุณากรอกหรือแก้ไข: ชื่อ, รหัสสาขา" — รวมช่องที่ซ่อน/โฟกัสไม่ได้ */}
|
|
176
|
+
<Button type="submit">บันทึก</Button>
|
|
177
|
+
</form>
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
ไม่อยากใส่ทีละฟอร์ม: `<FormInvalidNet />` (0.37.0) แจ้งเป็น toast ให้ทุก `<form>` ที่ไม่มีตาข่ายของตัวเอง — `AppShell` ใส่ให้แล้ว หน้านอก AppShell วางใน root layout ใต้ `Toaster`
|
|
181
|
+
|
|
169
182
|
| ต้องการ | เขียน |
|
|
170
183
|
| --- | --- |
|
|
171
184
|
| ข้อความอื่นที่ไม่ใช่ `aria-label` (หรือปุ่มที่มีข้อความแล้วแต่อยากอธิบายเพิ่ม) | `tooltip="ตรวจสอบอีกครั้ง"` |
|
|
@@ -217,8 +230,10 @@ const [order, setOrder] = useState(() =>
|
|
|
217
230
|
</SortableProvider>
|
|
218
231
|
```
|
|
219
232
|
|
|
220
|
-
- กลุ่มที่ไม่มีหัวกลุ่ม (เช่น "ยังไม่จัดกลุ่ม") ใช้ `useSortableDropZone(groupId)` แปะกับแถว placeholder ของตัวเอง และดูว่าควรแสดงเมื่อไหร่จาก `useSortableState().dragging`
|
|
233
|
+
- กลุ่มที่ไม่มีหัวกลุ่ม (เช่น "ยังไม่จัดกลุ่ม") ใช้ `useSortableDropZone(groupId)` แปะกับแถว placeholder ของตัวเอง และดูว่าควรแสดงเมื่อไหร่จาก `useSortableState().dragging` — **วางแถวนี้ไว้ใต้แถวที่ลากได้ทั้งหมด (ท้ายตาราง)** ถ้ามันโผล่ขึ้นเหนือแถวที่กำลังลาก layout จะเลื่อนตอนเริ่มลาก แล้วการลากด้วยคีย์บอร์ดจะไม่ขยับ
|
|
234
|
+
- ตั้ง `id` ของ `SortableProvider` ได้ (ไม่ตั้ง = `useId()`) — ต้องคงที่ระหว่าง SSR กับ client ไม่งั้น `aria-describedby` ของ dnd-kit จะ hydration mismatch · ตรวจได้จากที่จับ: `aria-describedby` จะเป็น **id ที่ส่งเข้าไปตรง ๆ** (ไม่มี prefix) และต้องชี้ element ที่มีข้อความวิธีลาก — element นี้สร้างหลัง hydrate จึงต้องรอหน้า mount ก่อนค่อย query
|
|
221
235
|
- `onCommit` ล้มเหลว (throw หรือ `{ ok: false, error }`) = คืนลำดับเดิม + toast; ถ้าอยากจัดการ error เองใส่ `onCommitError`
|
|
236
|
+
- การ์ดลอยตามเมาส์: `renderOverlay={(drag) => drag.kind === "item" ? <ItemCard id={drag.id} /> : <GroupCard id={drag.id} />}` (วาดใน portal จึงเป็น `div` ได้ ไม่ต้องเป็น `<tr>`) — ไม่ใส่ = แถวขยับเองแบบเดิม · โฟลเดอร์ที่ย่ออยู่: `useSortableDropZone(id, { onDwell: collapsed ? open : undefined })` ลากค้าง 600ms แล้วเปิดให้ · วางบนหัวกลุ่ม = ขึ้นบนสุดของกลุ่ม, วางบนโซนของ `UNGROUPED` = ต่อท้าย
|
|
222
237
|
- `sortableOrderToPayload(order)` คืน `{ groups, items }` — `groups` คือลำดับของกลุ่ม, `items` คือรายการในแต่ละกลุ่มเรียงตามนั้น (ของที่ยังไม่มีกลุ่มมาก่อนด้วย `groupId: null`)
|
|
223
238
|
- `SortableProvider disabled` (เช่นระหว่างกรอง ซึ่งลำดับที่เห็นไม่ใช่ลำดับจริง) ทำให้ `DragHandle` จางลงและกดไม่ได้ — ไม่อยากให้เห็นเลยใส่ `hideWhenDisabled`
|
|
224
239
|
- ข้อความ screen reader: ใส่ `announcements`/`screenReaderInstructions` เป็น prop (callback ได้ `{ id, kind, groupId, index, total }`) หรือปล่อยให้ใช้แม่แบบจาก `UiText.sortable`
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@suphark/ui",
|
|
3
|
-
"version": "0.
|
|
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,10 +49,13 @@ import {
|
|
|
41
49
|
type ReactNode,
|
|
42
50
|
useCallback,
|
|
43
51
|
useContext,
|
|
52
|
+
useEffect,
|
|
53
|
+
useId,
|
|
44
54
|
useMemo,
|
|
45
55
|
useRef,
|
|
46
56
|
useState,
|
|
47
57
|
} from "react";
|
|
58
|
+
import { createPortal } from "react-dom";
|
|
48
59
|
import { toast } from "sonner";
|
|
49
60
|
|
|
50
61
|
export type SortableKind = "group" | "item";
|
|
@@ -55,15 +66,13 @@ const dndId = (kind: SortableKind | "zone", id: string) =>
|
|
|
55
66
|
const kindOf = (id: UniqueIdentifier) => String(id).slice(0, 1);
|
|
56
67
|
const rawOf = (id: UniqueIdentifier) => String(id).slice(2);
|
|
57
68
|
|
|
58
|
-
/**
|
|
59
|
-
const
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
),
|
|
66
|
-
});
|
|
69
|
+
/** จังหวะเดียวกันทั้งแถวที่เลื่อนหลบและตอนปล่อย — ease-out สั้น ๆ แบบบอร์ดงาน (Trello/Todoist) */
|
|
70
|
+
const EASING = "cubic-bezier(0.25, 1, 0.5, 1)";
|
|
71
|
+
const SORT_TRANSITION = { duration: 200, easing: EASING };
|
|
72
|
+
const DROP_ANIMATION: DropAnimation = {
|
|
73
|
+
duration: 200,
|
|
74
|
+
easing: EASING,
|
|
75
|
+
sideEffects: defaultDropAnimationSideEffects({ styles: { active: { opacity: "0.4" } } }),
|
|
67
76
|
};
|
|
68
77
|
|
|
69
78
|
interface SortableState {
|
|
@@ -71,12 +80,15 @@ interface SortableState {
|
|
|
71
80
|
dragging: SortableKind | null;
|
|
72
81
|
disabled: boolean;
|
|
73
82
|
handleLabel: string;
|
|
83
|
+
/** มี `renderOverlay` = แถวต้นทางเป็นที่ว่างจาง ๆ ส่วนของที่ลอยตามเมาส์คือ overlay */
|
|
84
|
+
overlay: boolean;
|
|
74
85
|
}
|
|
75
86
|
|
|
76
87
|
const SortableStateContext = createContext<SortableState>({
|
|
77
88
|
dragging: null,
|
|
78
89
|
disabled: true,
|
|
79
90
|
handleLabel: "ลากเพื่อจัดลำดับ",
|
|
91
|
+
overlay: false,
|
|
80
92
|
});
|
|
81
93
|
|
|
82
94
|
/**
|
|
@@ -131,6 +143,19 @@ export interface SortableProviderProps {
|
|
|
131
143
|
* ไม่ส่ง = แม่แบบจาก `UiText.sortable` (`{index}` / `{total}`)
|
|
132
144
|
*/
|
|
133
145
|
announcements?: SortableAnnouncements;
|
|
146
|
+
/**
|
|
147
|
+
* id ของ `DndContext` — ต้องคงที่ระหว่าง SSR กับ client ไม่งั้น `aria-describedby="DndDescribedBy-N"`
|
|
148
|
+
* ที่ dnd-kit สร้างจากตัวนับจะไม่ตรงกัน (hydration mismatch ที่ React ไม่ซ่อมให้ → screen reader
|
|
149
|
+
* ไม่ได้ยินคำอธิบายวิธีลาก) · ไม่ส่ง = `useId()` ซึ่งตรงกันทั้งสองฝั่งอยู่แล้ว · ส่ง id ที่สื่อความหมาย
|
|
150
|
+
* (เช่น `values-${environmentId}`) ช่วยตอนไล่ปัญหา
|
|
151
|
+
*/
|
|
152
|
+
id?: string;
|
|
153
|
+
/**
|
|
154
|
+
* สิ่งที่ลอยตามเมาส์ระหว่างลาก (แบบการ์ดใน Trello/Todoist) — ไม่ส่ง = ลากตัวแถวเองเหมือนเดิม
|
|
155
|
+
* ส่งมา = แถวต้นทางกลายเป็นช่องว่างจาง ๆ บอกตำแหน่งที่จะวาง และมีแอนิเมชันตอนปล่อย
|
|
156
|
+
* วาดใน portal ที่ `body` จึงคืน `<div>` ได้แม้รายการจริงเป็นแถว `<tr>` ของตาราง
|
|
157
|
+
*/
|
|
158
|
+
renderOverlay?: (drag: { kind: SortableKind; id: string }) => ReactNode;
|
|
134
159
|
children: ReactNode;
|
|
135
160
|
}
|
|
136
161
|
|
|
@@ -152,15 +177,68 @@ export function SortableProvider({
|
|
|
152
177
|
onCommitError,
|
|
153
178
|
screenReaderInstructions,
|
|
154
179
|
announcements,
|
|
180
|
+
id,
|
|
181
|
+
renderOverlay,
|
|
155
182
|
children,
|
|
156
183
|
}: SortableProviderProps) {
|
|
184
|
+
const fallbackId = useId();
|
|
157
185
|
const text = useUiText();
|
|
186
|
+
const mounted = useMounted();
|
|
158
187
|
const [dragging, setDragging] = useState<SortableKind | null>(null);
|
|
188
|
+
const [activeDrag, setActiveDrag] = useState<{ kind: SortableKind; id: string } | null>(null);
|
|
189
|
+
// กันกระพริบตอนย้ายข้ามกลุ่ม: จำเป้าหมายล่าสุดไว้ใช้ตอนที่ layout กำลังขยับแล้วหาเป้าหมายไม่เจอ
|
|
190
|
+
const lastOverId = useRef<UniqueIdentifier | null>(null);
|
|
191
|
+
const movedAcross = useRef(false);
|
|
159
192
|
const beforeDrag = useRef<SortableOrder | null>(null);
|
|
160
193
|
// อ่านลำดับล่าสุดตอนปล่อย (onDragOver เปลี่ยนไปหลายรอบแล้ว prop ยังไม่ทัน)
|
|
161
194
|
const latest = useRef(order);
|
|
162
195
|
latest.current = order;
|
|
163
196
|
|
|
197
|
+
// ย้ายข้ามกลุ่มเสร็จแล้ว (render ลำดับใหม่ไปแล้ว) ค่อยปลดธงในเฟรมถัดไป
|
|
198
|
+
useEffect(() => {
|
|
199
|
+
const frame = requestAnimationFrame(() => {
|
|
200
|
+
movedAcross.current = false;
|
|
201
|
+
});
|
|
202
|
+
return () => cancelAnimationFrame(frame);
|
|
203
|
+
}, [order]);
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* เลือกเป้าหมายแบบบอร์ดงาน (สูตรเดียวกับตัวอย่าง multiple containers ของ dnd-kit):
|
|
207
|
+
* - ลากกลุ่ม → เทียบกับกลุ่มด้วยกันเท่านั้น
|
|
208
|
+
* - ลากรายการ → ใช้ "สิ่งที่อยู่ใต้เมาส์จริง" ก่อน (pointerWithin) ไม่ใช่ตัวที่ใกล้ที่สุดทั้งตาราง
|
|
209
|
+
* (เดิมใช้ closestCenter ทั้งตาราง ทำให้แถวรับวางท้ายตารางแย่งเป้าหมาย รายการกระโดดไปไกลจากเมาส์)
|
|
210
|
+
* - ชี้หัวกลุ่มที่เปิดอยู่ → เลือกรายการในกลุ่มที่ใกล้ที่สุดแทน (วางลงในกลุ่มตรงตำแหน่ง)
|
|
211
|
+
* - หาไม่เจอชั่วขณะตอน layout ขยับ → ใช้เป้าหมายล่าสุด ไม่ให้แถวสั่นไปมา
|
|
212
|
+
*/
|
|
213
|
+
const collisionDetection: CollisionDetection = useCallback((args) => {
|
|
214
|
+
if (kindOf(args.active.id) === "g") {
|
|
215
|
+
return closestCenter({
|
|
216
|
+
...args,
|
|
217
|
+
droppableContainers: args.droppableContainers.filter((c) => kindOf(c.id) === "g"),
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
const candidates = args.droppableContainers.filter((c) => kindOf(c.id) !== "g");
|
|
221
|
+
const scoped = { ...args, droppableContainers: candidates };
|
|
222
|
+
const underPointer = pointerWithin(scoped);
|
|
223
|
+
const hits = underPointer.length > 0 ? underPointer : rectIntersection(scoped);
|
|
224
|
+
let overId = getFirstCollision(hits, "id");
|
|
225
|
+
if (overId != null) {
|
|
226
|
+
if (kindOf(overId) === "z") {
|
|
227
|
+
const ids = new Set(latest.current.items[rawOf(overId)] ?? []);
|
|
228
|
+
const inGroup = candidates.filter(
|
|
229
|
+
(c) => kindOf(c.id) === "i" && c.id !== args.active.id && ids.has(rawOf(c.id)),
|
|
230
|
+
);
|
|
231
|
+
if (inGroup.length > 0) {
|
|
232
|
+
overId = closestCenter({ ...args, droppableContainers: inGroup })[0]?.id ?? overId;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
lastOverId.current = overId;
|
|
236
|
+
return [{ id: overId }];
|
|
237
|
+
}
|
|
238
|
+
if (movedAcross.current) lastOverId.current = args.active.id;
|
|
239
|
+
return lastOverId.current != null ? [{ id: lastOverId.current }] : [];
|
|
240
|
+
}, []);
|
|
241
|
+
|
|
164
242
|
const sensors = useSensors(
|
|
165
243
|
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
|
166
244
|
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
|
|
@@ -173,7 +251,10 @@ export function SortableProvider({
|
|
|
173
251
|
|
|
174
252
|
function onDragStart({ active }: DragStartEvent) {
|
|
175
253
|
beforeDrag.current = latest.current;
|
|
176
|
-
|
|
254
|
+
lastOverId.current = null;
|
|
255
|
+
const kind: SortableKind = kindOf(active.id) === "g" ? "group" : "item";
|
|
256
|
+
setDragging(kind);
|
|
257
|
+
setActiveDrag({ kind, id: rawOf(active.id) });
|
|
177
258
|
}
|
|
178
259
|
|
|
179
260
|
function onDragOver({ active, over }: DragOverEvent) {
|
|
@@ -182,13 +263,23 @@ export function SortableProvider({
|
|
|
182
263
|
const from = containerOf(active.id, current);
|
|
183
264
|
const to = containerOf(over.id, current);
|
|
184
265
|
if (!from || !to || from === to) return;
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
266
|
+
// วางก่อนหรือหลังแถวเป้าหมาย ตามว่าตัวที่ลากเลยครึ่งแถวนั้นลงมาหรือยัง
|
|
267
|
+
// (เดิมแทรก "ก่อน" เสมอ — ลากเข้าจากด้านล่างแล้วรายการกระโดดข้ามขึ้นไป)
|
|
268
|
+
let before: string | null = null;
|
|
269
|
+
if (kindOf(over.id) === "i") {
|
|
270
|
+
const list = current.items[to] ?? [];
|
|
271
|
+
const at = list.indexOf(rawOf(over.id));
|
|
272
|
+
const dragged = active.rect.current.translated;
|
|
273
|
+
const below = dragged != null && dragged.top > over.rect.top + over.rect.height / 2;
|
|
274
|
+
before = list[at + (below ? 1 : 0)] ?? null;
|
|
275
|
+
} else if (to !== UNGROUPED) {
|
|
276
|
+
// วางบนหัวโฟลเดอร์ = ขึ้นบนสุดของโฟลเดอร์ (ตรงกับตำแหน่งที่เห็น และไม่กระโดดตอนโฟลเดอร์ที่ย่อไว้เปิดออก)
|
|
277
|
+
// ส่วนโซน "ไม่มีกลุ่ม" อยู่ท้ายตาราง จึงต่อท้าย
|
|
278
|
+
before = current.items[to]?.[0] ?? null;
|
|
279
|
+
}
|
|
280
|
+
const next = moveItemToGroup(current, rawOf(active.id), to, before);
|
|
191
281
|
if (next !== current) {
|
|
282
|
+
movedAcross.current = true;
|
|
192
283
|
latest.current = next;
|
|
193
284
|
onOrderChange(next);
|
|
194
285
|
}
|
|
@@ -219,6 +310,7 @@ export function SortableProvider({
|
|
|
219
310
|
const snapshot = beforeDrag.current;
|
|
220
311
|
beforeDrag.current = null;
|
|
221
312
|
setDragging(null);
|
|
313
|
+
setActiveDrag(null);
|
|
222
314
|
const current = latest.current;
|
|
223
315
|
let next = current;
|
|
224
316
|
if (over && kindOf(active.id) === "g" && kindOf(over.id) === "g") {
|
|
@@ -237,6 +329,7 @@ export function SortableProvider({
|
|
|
237
329
|
const snapshot = beforeDrag.current;
|
|
238
330
|
beforeDrag.current = null;
|
|
239
331
|
setDragging(null);
|
|
332
|
+
setActiveDrag(null);
|
|
240
333
|
if (snapshot) {
|
|
241
334
|
latest.current = snapshot;
|
|
242
335
|
onOrderChange(snapshot);
|
|
@@ -271,6 +364,31 @@ export function SortableProvider({
|
|
|
271
364
|
};
|
|
272
365
|
}, []);
|
|
273
366
|
|
|
367
|
+
/**
|
|
368
|
+
* ตำแหน่งที่ "จะไปวาง" ระหว่างลาก — ในกลุ่มเดียวกันลำดับจริงยังไม่เปลี่ยนจนกว่าจะปล่อย
|
|
369
|
+
* (เดิมประกาศตำแหน่งเดิมซ้ำ เช่น "ตำแหน่งที่ 1" ทั้งที่เลื่อนลงมาแล้ว) จึงใช้ตำแหน่งของเป้าหมายแทน
|
|
370
|
+
* ข้ามกลุ่มไม่ต้องทำอะไร — onDragOver ย้ายเข้ากลุ่มใหม่ใน order ไปแล้ว
|
|
371
|
+
*/
|
|
372
|
+
const describeTarget = useCallback(
|
|
373
|
+
(activeId: UniqueIdentifier, overId: UniqueIdentifier | null): SortableAnnouncementContext => {
|
|
374
|
+
const base = describe(activeId);
|
|
375
|
+
if (overId === null || overId === activeId) return base;
|
|
376
|
+
const current = latest.current;
|
|
377
|
+
if (base.kind === "group" && kindOf(overId) === "g") {
|
|
378
|
+
const at = current.groups.indexOf(rawOf(overId));
|
|
379
|
+
return at >= 0 ? { ...base, index: at + 1 } : base;
|
|
380
|
+
}
|
|
381
|
+
if (base.kind === "item" && kindOf(overId) === "i") {
|
|
382
|
+
const group = groupOfItem(current, rawOf(overId));
|
|
383
|
+
if (!group || group !== base.groupId) return base;
|
|
384
|
+
const at = (current.items[group] ?? []).indexOf(rawOf(overId));
|
|
385
|
+
return at >= 0 ? { ...base, index: at + 1 } : base;
|
|
386
|
+
}
|
|
387
|
+
return base;
|
|
388
|
+
},
|
|
389
|
+
[describe],
|
|
390
|
+
);
|
|
391
|
+
|
|
274
392
|
const dndAnnouncements = useMemo(() => {
|
|
275
393
|
const fill = (
|
|
276
394
|
template: string | undefined,
|
|
@@ -285,9 +403,19 @@ export function SortableProvider({
|
|
|
285
403
|
own: ((context: SortableAnnouncementContext) => string | undefined) | undefined,
|
|
286
404
|
template: string | undefined,
|
|
287
405
|
fallback: string,
|
|
406
|
+
// ระหว่างลาก = ตำแหน่งของเป้าหมาย; ตอนปล่อย order อัปเดตแล้ว จึงใช้ตำแหน่งจริงของตัวที่ลาก
|
|
407
|
+
useTarget = false,
|
|
288
408
|
) =>
|
|
289
|
-
({
|
|
290
|
-
|
|
409
|
+
({
|
|
410
|
+
active,
|
|
411
|
+
over,
|
|
412
|
+
}: {
|
|
413
|
+
active: { id: UniqueIdentifier };
|
|
414
|
+
over?: { id: UniqueIdentifier } | null;
|
|
415
|
+
}) => {
|
|
416
|
+
const context = useTarget
|
|
417
|
+
? describeTarget(active.id, over?.id ?? null)
|
|
418
|
+
: describe(active.id);
|
|
291
419
|
return own?.(context) ?? fill(template, fallback, context);
|
|
292
420
|
};
|
|
293
421
|
return {
|
|
@@ -300,6 +428,7 @@ export function SortableProvider({
|
|
|
300
428
|
announcements?.onDragOver,
|
|
301
429
|
text.sortable?.moved,
|
|
302
430
|
"ย้ายไปตำแหน่งที่ {index} จาก {total}",
|
|
431
|
+
true,
|
|
303
432
|
),
|
|
304
433
|
onDragEnd: announce(
|
|
305
434
|
announcements?.onDragEnd,
|
|
@@ -308,7 +437,7 @@ export function SortableProvider({
|
|
|
308
437
|
),
|
|
309
438
|
onDragCancel: announce(announcements?.onDragCancel, text.sortable?.cancelled, "ยกเลิกการลาก"),
|
|
310
439
|
};
|
|
311
|
-
}, [announcements,
|
|
440
|
+
}, [announcements, describeTarget, text.sortable]);
|
|
312
441
|
|
|
313
442
|
const groupIds = useMemo(() => order.groups.map((id) => dndId("group", id)), [order.groups]);
|
|
314
443
|
|
|
@@ -317,15 +446,19 @@ export function SortableProvider({
|
|
|
317
446
|
dragging,
|
|
318
447
|
disabled,
|
|
319
448
|
handleLabel: handleLabel ?? text.sortable?.handle ?? "ลากเพื่อจัดลำดับ",
|
|
449
|
+
overlay: Boolean(renderOverlay),
|
|
320
450
|
}),
|
|
321
|
-
[dragging, disabled, handleLabel, text.sortable?.handle],
|
|
451
|
+
[dragging, disabled, handleLabel, text.sortable?.handle, renderOverlay],
|
|
322
452
|
);
|
|
323
453
|
|
|
324
454
|
return (
|
|
325
455
|
<SortableStateContext.Provider value={state}>
|
|
326
456
|
<DndContext
|
|
457
|
+
id={id ?? fallbackId}
|
|
327
458
|
sensors={sensors}
|
|
328
|
-
collisionDetection={
|
|
459
|
+
collisionDetection={collisionDetection}
|
|
460
|
+
// วัดตำแหน่งที่วางใหม่ตลอดระหว่างลาก — ย้ายข้ามกลุ่มแล้ว layout เปลี่ยน ต้องใช้ตำแหน่งปัจจุบัน
|
|
461
|
+
measuring={{ droppable: { strategy: MeasuringStrategy.Always } }}
|
|
329
462
|
accessibility={{
|
|
330
463
|
screenReaderInstructions: { draggable: instructions },
|
|
331
464
|
announcements: dndAnnouncements,
|
|
@@ -340,6 +473,16 @@ export function SortableProvider({
|
|
|
340
473
|
<SortableContext items={groupIds} strategy={verticalListSortingStrategy}>
|
|
341
474
|
{children}
|
|
342
475
|
</SortableContext>
|
|
476
|
+
{renderOverlay &&
|
|
477
|
+
mounted &&
|
|
478
|
+
createPortal(
|
|
479
|
+
<DragOverlay dropAnimation={DROP_ANIMATION}>
|
|
480
|
+
{activeDrag ? (
|
|
481
|
+
<div className="cursor-grabbing">{renderOverlay(activeDrag)}</div>
|
|
482
|
+
) : null}
|
|
483
|
+
</DragOverlay>,
|
|
484
|
+
document.body,
|
|
485
|
+
)}
|
|
343
486
|
</DndContext>
|
|
344
487
|
</SortableStateContext.Provider>
|
|
345
488
|
);
|
|
@@ -388,13 +531,20 @@ export function useSortableRow({
|
|
|
388
531
|
const sortable = useSortable({
|
|
389
532
|
id: dndId(type, id),
|
|
390
533
|
disabled: disabled || state.disabled,
|
|
534
|
+
transition: SORT_TRANSITION,
|
|
391
535
|
});
|
|
536
|
+
const dragStyle: CSSProperties = !sortable.isDragging
|
|
537
|
+
? {}
|
|
538
|
+
: state.overlay
|
|
539
|
+
? // มี overlay ลอยตามเมาส์แล้ว แถวต้นทางเหลือเป็นช่องจาง ๆ บอกตำแหน่งที่จะวาง
|
|
540
|
+
{ opacity: 0.4 }
|
|
541
|
+
: { position: "relative", zIndex: 1, opacity: 0.85 };
|
|
392
542
|
return {
|
|
393
543
|
ref: sortable.setNodeRef,
|
|
394
544
|
style: {
|
|
395
545
|
transform: CSS.Translate.toString(sortable.transform),
|
|
396
546
|
transition: sortable.transition,
|
|
397
|
-
...
|
|
547
|
+
...dragStyle,
|
|
398
548
|
},
|
|
399
549
|
handleProps: { ...sortable.attributes, ...sortable.listeners },
|
|
400
550
|
isDragging: sortable.isDragging,
|
|
@@ -405,7 +555,18 @@ export function useSortableRow({
|
|
|
405
555
|
* ทำให้หัวกลุ่ม (หรือพื้นที่ว่างของกลุ่ม) รับการวางรายการได้ — กลุ่มที่ย่ออยู่ก็ยังรับได้
|
|
406
556
|
* ปิดเองเมื่อกำลังลากกลุ่ม (ไม่ใช่รายการ)
|
|
407
557
|
*/
|
|
408
|
-
export function useSortableDropZone(
|
|
558
|
+
export function useSortableDropZone(
|
|
559
|
+
groupId: string | null,
|
|
560
|
+
options?: {
|
|
561
|
+
/**
|
|
562
|
+
* ลากรายการค้างไว้เหนือที่วางนี้นานพอ — ใช้เปิดกลุ่มที่ย่ออยู่ให้เองแบบโฟลเดอร์ใน Finder/Todoist
|
|
563
|
+
* (ไม่ส่ง = ไม่ทำอะไร)
|
|
564
|
+
*/
|
|
565
|
+
onDwell?: () => void;
|
|
566
|
+
/** นานเท่าไรถึงนับว่าค้าง (ms) — ค่าเริ่มต้น 600 */
|
|
567
|
+
dwellMs?: number;
|
|
568
|
+
},
|
|
569
|
+
): {
|
|
409
570
|
ref: (node: HTMLElement | null) => void;
|
|
410
571
|
isOver: boolean;
|
|
411
572
|
} {
|
|
@@ -415,6 +576,17 @@ export function useSortableDropZone(groupId: string | null): {
|
|
|
415
576
|
id: dndId("zone", groupId ?? UNGROUPED),
|
|
416
577
|
disabled: state.disabled || state.dragging !== "item",
|
|
417
578
|
});
|
|
579
|
+
const onDwell = options?.onDwell;
|
|
580
|
+
const dwellMs = options?.dwellMs ?? 600;
|
|
581
|
+
const hovering = droppable.isOver && state.dragging === "item";
|
|
582
|
+
// เก็บ callback ล่าสุดไว้ใน ref — แอปส่งฟังก์ชันใหม่ทุก render ก็ไม่ทำให้นับเวลาใหม่
|
|
583
|
+
const dwellRef = useRef(onDwell);
|
|
584
|
+
dwellRef.current = onDwell;
|
|
585
|
+
useEffect(() => {
|
|
586
|
+
if (!hovering || !dwellRef.current) return;
|
|
587
|
+
const timer = setTimeout(() => dwellRef.current?.(), dwellMs);
|
|
588
|
+
return () => clearTimeout(timer);
|
|
589
|
+
}, [hovering, dwellMs]);
|
|
418
590
|
return { ref: droppable.setNodeRef, isOver: droppable.isOver };
|
|
419
591
|
}
|
|
420
592
|
|
package/src/index.ts
CHANGED
|
@@ -115,6 +115,11 @@ export {
|
|
|
115
115
|
export { FormFieldRadioCards } from "./components/shared/form/form-field-radio-cards";
|
|
116
116
|
export { FormFieldRadioGroup } from "./components/shared/form/form-field-radio-group";
|
|
117
117
|
export { FormFieldSelect, type SelectOption } from "./components/shared/form/form-field-select";
|
|
118
|
+
export {
|
|
119
|
+
FormInvalidNet,
|
|
120
|
+
type FormInvalidNotice,
|
|
121
|
+
useFormInvalidNotice,
|
|
122
|
+
} from "./components/shared/form/form-invalid-notice";
|
|
118
123
|
export { CheckboxField, LabeledField } from "./components/shared/form/labeled-field";
|
|
119
124
|
export { NumberInput } from "./components/shared/form/number-input";
|
|
120
125
|
export { SearchInput, type SearchInputProps } from "./components/shared/form/search-input";
|