@suphark/ui 0.24.0 → 0.25.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 +21 -0
- package/package.json +1 -1
- package/src/components/data-table/index.ts +9 -9
- package/src/components/shared/form/number-input.tsx +144 -0
- package/src/components/shared/form/tags-input.tsx +192 -0
- package/src/components/shared/form/time-picker.tsx +421 -0
- package/src/components/shared/step-form.tsx +283 -0
- package/src/components/shared/ui-text.tsx +65 -0
- package/src/data-table.ts +12 -1
- package/src/index.ts +430 -66
- package/src/next.ts +38 -1
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Button } from "@suphark/ui/components/ui/button";
|
|
4
|
+
import { Calendar } from "@suphark/ui/components/ui/calendar";
|
|
5
|
+
import {
|
|
6
|
+
InputGroup,
|
|
7
|
+
InputGroupAddon,
|
|
8
|
+
InputGroupButton,
|
|
9
|
+
InputGroupInput,
|
|
10
|
+
} from "@suphark/ui/components/ui/input-group";
|
|
11
|
+
import { Popover, PopoverContent, PopoverTrigger } from "@suphark/ui/components/ui/popover";
|
|
12
|
+
import { cn } from "@suphark/ui/lib/utils";
|
|
13
|
+
import { CalendarClockIcon, ClockIcon } from "lucide-react";
|
|
14
|
+
import { type KeyboardEvent, useEffect, useId, useRef, useState } from "react";
|
|
15
|
+
import { th } from "react-day-picker/locale";
|
|
16
|
+
import { useUiText } from "../ui-text";
|
|
17
|
+
|
|
18
|
+
/** "HH:mm" 24 ชั่วโมง */
|
|
19
|
+
export type TimeValue = string;
|
|
20
|
+
|
|
21
|
+
const pad = (n: number) => String(n).padStart(2, "0");
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* แปลงข้อความที่พิมพ์เป็น "HH:mm": "14:30" "14.30" "1430" "930" "9" "9:5" → null ถ้าไม่ใช่เวลาที่ถูกต้อง
|
|
25
|
+
*/
|
|
26
|
+
export function parseTimeInput(input: string): TimeValue | null {
|
|
27
|
+
const s = input.trim().replace(/\s*(น\.|นาฬิกา)$/, "");
|
|
28
|
+
if (!s) return null;
|
|
29
|
+
let h: number;
|
|
30
|
+
let m: number;
|
|
31
|
+
const sep = s.match(/^(\d{1,2})[:.](\d{1,2})$/);
|
|
32
|
+
if (sep) {
|
|
33
|
+
h = Number(sep[1]);
|
|
34
|
+
m = Number(sep[2]);
|
|
35
|
+
} else if (/^\d{1,4}$/.test(s)) {
|
|
36
|
+
if (s.length <= 2) {
|
|
37
|
+
h = Number(s);
|
|
38
|
+
m = 0;
|
|
39
|
+
} else {
|
|
40
|
+
h = Number(s.slice(0, s.length - 2));
|
|
41
|
+
m = Number(s.slice(-2));
|
|
42
|
+
}
|
|
43
|
+
} else {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
if (h > 23 || m > 59) return null;
|
|
47
|
+
return `${pad(h)}:${pad(m)}`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function splitTime(value: TimeValue | null | undefined): [number, number] | null {
|
|
51
|
+
const parsed = value ? parseTimeInput(value) : null;
|
|
52
|
+
if (!parsed) return null;
|
|
53
|
+
const [h, m] = parsed.split(":").map(Number);
|
|
54
|
+
return [h ?? 0, m ?? 0];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** คอลัมน์เลือกค่า (listbox): คลิก หรือโฟกัสแล้วใช้ลูกศรขึ้น/ลง Home/End */
|
|
58
|
+
function TimeColumn({
|
|
59
|
+
label,
|
|
60
|
+
options,
|
|
61
|
+
selected,
|
|
62
|
+
onSelect,
|
|
63
|
+
}: {
|
|
64
|
+
label: string;
|
|
65
|
+
options: number[];
|
|
66
|
+
selected: number | null;
|
|
67
|
+
onSelect: (value: number) => void;
|
|
68
|
+
}) {
|
|
69
|
+
const baseId = useId();
|
|
70
|
+
const listRef = useRef<HTMLDivElement>(null);
|
|
71
|
+
const optionId = (v: number) => `${baseId}-${v}`;
|
|
72
|
+
|
|
73
|
+
// เลื่อนให้ค่าที่เลือกอยู่กลางคอลัมน์ทุกครั้งที่เปลี่ยน
|
|
74
|
+
useEffect(() => {
|
|
75
|
+
if (selected == null) return;
|
|
76
|
+
const el = listRef.current?.querySelector<HTMLElement>(`[data-value="${selected}"]`);
|
|
77
|
+
el?.scrollIntoView?.({ block: "center" });
|
|
78
|
+
}, [selected]);
|
|
79
|
+
|
|
80
|
+
const onKeyDown = (e: KeyboardEvent) => {
|
|
81
|
+
const i = selected == null ? -1 : options.indexOf(selected);
|
|
82
|
+
let next: number | undefined;
|
|
83
|
+
if (e.key === "ArrowDown") next = options[Math.min(i + 1, options.length - 1)];
|
|
84
|
+
else if (e.key === "ArrowUp") next = options[Math.max(i - 1, 0)];
|
|
85
|
+
else if (e.key === "Home") next = options[0];
|
|
86
|
+
else if (e.key === "End") next = options[options.length - 1];
|
|
87
|
+
if (next !== undefined) {
|
|
88
|
+
e.preventDefault();
|
|
89
|
+
onSelect(next);
|
|
90
|
+
}
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
return (
|
|
94
|
+
<div
|
|
95
|
+
ref={listRef}
|
|
96
|
+
role="listbox"
|
|
97
|
+
tabIndex={0}
|
|
98
|
+
aria-label={label}
|
|
99
|
+
aria-activedescendant={selected == null ? undefined : optionId(selected)}
|
|
100
|
+
onKeyDown={onKeyDown}
|
|
101
|
+
className="no-scrollbar flex max-h-56 w-14 flex-col gap-0.5 overflow-y-auto rounded-md p-0.5 outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
|
|
102
|
+
>
|
|
103
|
+
{options.map((v) => (
|
|
104
|
+
<div
|
|
105
|
+
key={v}
|
|
106
|
+
id={optionId(v)}
|
|
107
|
+
role="option"
|
|
108
|
+
aria-selected={v === selected}
|
|
109
|
+
data-value={v}
|
|
110
|
+
tabIndex={-1}
|
|
111
|
+
onClick={() => onSelect(v)}
|
|
112
|
+
className={cn(
|
|
113
|
+
"flex h-8 shrink-0 cursor-default items-center justify-center rounded-md text-sm tabular-nums select-none hover:bg-muted",
|
|
114
|
+
v === selected && "bg-primary text-primary-foreground hover:bg-primary",
|
|
115
|
+
)}
|
|
116
|
+
>
|
|
117
|
+
{pad(v)}
|
|
118
|
+
</div>
|
|
119
|
+
))}
|
|
120
|
+
</div>
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** คอลัมน์ชั่วโมง + นาที (ใช้ใน TimePicker / DateTimePicker) */
|
|
125
|
+
export function TimeColumns({
|
|
126
|
+
value,
|
|
127
|
+
onValueChange,
|
|
128
|
+
minuteStep = 5,
|
|
129
|
+
className,
|
|
130
|
+
}: {
|
|
131
|
+
value: TimeValue | null | undefined;
|
|
132
|
+
onValueChange: (value: TimeValue) => void;
|
|
133
|
+
/** ระยะห่างนาทีในคอลัมน์ (ค่านาทีที่ไม่ลงตัวยังแสดงถ้าเลือกอยู่) */
|
|
134
|
+
minuteStep?: number;
|
|
135
|
+
className?: string;
|
|
136
|
+
}) {
|
|
137
|
+
const text = useUiText().time;
|
|
138
|
+
const current = splitTime(value);
|
|
139
|
+
const hours = Array.from({ length: 24 }, (_, i) => i);
|
|
140
|
+
const minutes = Array.from({ length: Math.ceil(60 / minuteStep) }, (_, i) => i * minuteStep);
|
|
141
|
+
if (current && !minutes.includes(current[1])) {
|
|
142
|
+
minutes.push(current[1]);
|
|
143
|
+
minutes.sort((a, b) => a - b);
|
|
144
|
+
}
|
|
145
|
+
const set = (h: number, m: number) => onValueChange(`${pad(h)}:${pad(m)}`);
|
|
146
|
+
return (
|
|
147
|
+
<div data-slot="time-columns" className={cn("flex gap-1", className)}>
|
|
148
|
+
<TimeColumn
|
|
149
|
+
label={text?.hour ?? "ชั่วโมง"}
|
|
150
|
+
options={hours}
|
|
151
|
+
selected={current?.[0] ?? null}
|
|
152
|
+
onSelect={(h) => set(h, current?.[1] ?? 0)}
|
|
153
|
+
/>
|
|
154
|
+
<TimeColumn
|
|
155
|
+
label={text?.minute ?? "นาที"}
|
|
156
|
+
options={minutes}
|
|
157
|
+
selected={current?.[1] ?? null}
|
|
158
|
+
onSelect={(m) => set(current?.[0] ?? 0, m)}
|
|
159
|
+
/>
|
|
160
|
+
</div>
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function useControllable<T>(value: T | undefined, defaultValue: T, onChange?: (v: T) => void) {
|
|
165
|
+
const [inner, setInner] = useState(defaultValue);
|
|
166
|
+
const controlled = value !== undefined;
|
|
167
|
+
const current = controlled ? value : inner;
|
|
168
|
+
const set = (v: T) => {
|
|
169
|
+
if (!controlled) setInner(v);
|
|
170
|
+
onChange?.(v);
|
|
171
|
+
};
|
|
172
|
+
return [current, set] as const;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const nowTime = (): TimeValue => {
|
|
176
|
+
const d = new Date();
|
|
177
|
+
return `${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* ช่องเวลา 24 ชม. — พิมพ์ได้เลย ("1430", "9.15", "14:30") หรือกดไอคอนนาฬิกาเลือกจากคอลัมน์ชั่วโมง/นาที
|
|
182
|
+
* ค่าเป็น string "HH:mm" (หรือ null) — `name` ส่งใน FormData ได้; พิมพ์ผิดรูปแบบช่องจะเป็นสีแดงจนแก้
|
|
183
|
+
*
|
|
184
|
+
* <TimePicker name="remindAt" defaultValue="09:00" minuteStep={15} aria-label="เวลาเตือน" />
|
|
185
|
+
*/
|
|
186
|
+
export function TimePicker({
|
|
187
|
+
value,
|
|
188
|
+
defaultValue = null,
|
|
189
|
+
onValueChange,
|
|
190
|
+
minuteStep = 5,
|
|
191
|
+
name,
|
|
192
|
+
id,
|
|
193
|
+
required,
|
|
194
|
+
disabled,
|
|
195
|
+
placeholder,
|
|
196
|
+
className,
|
|
197
|
+
"aria-label": ariaLabel,
|
|
198
|
+
"aria-describedby": ariaDescribedBy,
|
|
199
|
+
"aria-invalid": ariaInvalid,
|
|
200
|
+
}: {
|
|
201
|
+
value?: TimeValue | null;
|
|
202
|
+
defaultValue?: TimeValue | null;
|
|
203
|
+
onValueChange?: (value: TimeValue | null) => void;
|
|
204
|
+
minuteStep?: number;
|
|
205
|
+
name?: string;
|
|
206
|
+
id?: string;
|
|
207
|
+
required?: boolean;
|
|
208
|
+
disabled?: boolean;
|
|
209
|
+
/** ค่าเริ่มต้น "--:--" */
|
|
210
|
+
placeholder?: string;
|
|
211
|
+
className?: string;
|
|
212
|
+
"aria-label"?: string;
|
|
213
|
+
"aria-describedby"?: string;
|
|
214
|
+
"aria-invalid"?: boolean;
|
|
215
|
+
}) {
|
|
216
|
+
const ui = useUiText();
|
|
217
|
+
const text = ui.time;
|
|
218
|
+
const [time, setTime] = useControllable<TimeValue | null>(value, defaultValue, onValueChange);
|
|
219
|
+
const [draft, setDraft] = useState<string | null>(null);
|
|
220
|
+
const [open, setOpen] = useState(false);
|
|
221
|
+
const draftInvalid = draft != null && draft.trim() !== "" && parseTimeInput(draft) == null;
|
|
222
|
+
|
|
223
|
+
const commitDraft = () => {
|
|
224
|
+
if (draft == null) return;
|
|
225
|
+
if (draft.trim() === "") {
|
|
226
|
+
setTime(null);
|
|
227
|
+
setDraft(null);
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
const parsed = parseTimeInput(draft);
|
|
231
|
+
if (parsed) {
|
|
232
|
+
setTime(parsed);
|
|
233
|
+
setDraft(null);
|
|
234
|
+
}
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
return (
|
|
238
|
+
<InputGroup data-slot="time-picker" className={cn("w-32", className)}>
|
|
239
|
+
<InputGroupInput
|
|
240
|
+
id={id}
|
|
241
|
+
inputMode="numeric"
|
|
242
|
+
autoComplete="off"
|
|
243
|
+
placeholder={placeholder ?? "--:--"}
|
|
244
|
+
value={draft ?? time ?? ""}
|
|
245
|
+
disabled={disabled}
|
|
246
|
+
required={required}
|
|
247
|
+
aria-label={ariaLabel}
|
|
248
|
+
aria-describedby={ariaDescribedBy}
|
|
249
|
+
aria-invalid={draftInvalid || ariaInvalid || undefined}
|
|
250
|
+
onChange={(e) => setDraft(e.currentTarget.value)}
|
|
251
|
+
onBlur={commitDraft}
|
|
252
|
+
onKeyDown={(e) => {
|
|
253
|
+
if (e.key === "Enter") {
|
|
254
|
+
e.preventDefault();
|
|
255
|
+
commitDraft();
|
|
256
|
+
}
|
|
257
|
+
}}
|
|
258
|
+
className="tabular-nums"
|
|
259
|
+
/>
|
|
260
|
+
{name && <input type="hidden" name={name} value={time ?? ""} />}
|
|
261
|
+
<InputGroupAddon align="inline-end">
|
|
262
|
+
<Popover open={open} onOpenChange={setOpen}>
|
|
263
|
+
<PopoverTrigger
|
|
264
|
+
render={
|
|
265
|
+
<InputGroupButton
|
|
266
|
+
size="icon-xs"
|
|
267
|
+
disabled={disabled}
|
|
268
|
+
aria-label={text?.choose ?? "เลือกเวลา"}
|
|
269
|
+
/>
|
|
270
|
+
}
|
|
271
|
+
>
|
|
272
|
+
<ClockIcon />
|
|
273
|
+
</PopoverTrigger>
|
|
274
|
+
<PopoverContent align="end" className="w-auto gap-2 p-2">
|
|
275
|
+
<TimeColumns
|
|
276
|
+
value={time}
|
|
277
|
+
minuteStep={minuteStep}
|
|
278
|
+
onValueChange={(v) => {
|
|
279
|
+
setTime(v);
|
|
280
|
+
setDraft(null);
|
|
281
|
+
}}
|
|
282
|
+
/>
|
|
283
|
+
<div className="flex justify-between gap-2 border-t pt-2">
|
|
284
|
+
<Button
|
|
285
|
+
type="button"
|
|
286
|
+
size="xs"
|
|
287
|
+
variant="ghost"
|
|
288
|
+
onClick={() => {
|
|
289
|
+
setTime(nowTime());
|
|
290
|
+
setDraft(null);
|
|
291
|
+
}}
|
|
292
|
+
>
|
|
293
|
+
{text?.now ?? "ตอนนี้"}
|
|
294
|
+
</Button>
|
|
295
|
+
<Button type="button" size="xs" onClick={() => setOpen(false)}>
|
|
296
|
+
{ui.done}
|
|
297
|
+
</Button>
|
|
298
|
+
</div>
|
|
299
|
+
</PopoverContent>
|
|
300
|
+
</Popover>
|
|
301
|
+
</InputGroupAddon>
|
|
302
|
+
</InputGroup>
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** "YYYY-MM-DDTHH:mm" ตามเวลาท้องถิ่น (รูปแบบเดียวกับ input datetime-local) — ใช้เป็นค่าใน FormData */
|
|
307
|
+
export function toLocalDateTimeString(date: Date): string {
|
|
308
|
+
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* เลือกวัน + เวลาในกล่องเดียว (ปฏิทิน + คอลัมน์ชั่วโมง/นาที) — นัดหมาย, กำหนดส่ง, เวลาเตือน
|
|
313
|
+
* ค่าเป็น `Date | null`; เลือกวันก่อนเวลา = ใช้ `defaultTime`; `name` ส่ง "YYYY-MM-DDTHH:mm" (เวลาท้องถิ่น) ใน FormData
|
|
314
|
+
*
|
|
315
|
+
* <DateTimePicker value={dueAt} onValueChange={setDueAt} minuteStep={15} />
|
|
316
|
+
*/
|
|
317
|
+
export function DateTimePicker({
|
|
318
|
+
value,
|
|
319
|
+
defaultValue = null,
|
|
320
|
+
onValueChange,
|
|
321
|
+
minuteStep = 5,
|
|
322
|
+
defaultTime = "09:00",
|
|
323
|
+
name,
|
|
324
|
+
id,
|
|
325
|
+
disabled,
|
|
326
|
+
placeholder,
|
|
327
|
+
className,
|
|
328
|
+
"aria-label": ariaLabel,
|
|
329
|
+
"aria-invalid": ariaInvalid,
|
|
330
|
+
}: {
|
|
331
|
+
value?: Date | null;
|
|
332
|
+
defaultValue?: Date | null;
|
|
333
|
+
onValueChange?: (value: Date | null) => void;
|
|
334
|
+
minuteStep?: number;
|
|
335
|
+
/** เวลาที่ใช้เมื่อเลือกวันครั้งแรก */
|
|
336
|
+
defaultTime?: TimeValue;
|
|
337
|
+
name?: string;
|
|
338
|
+
id?: string;
|
|
339
|
+
disabled?: boolean;
|
|
340
|
+
placeholder?: string;
|
|
341
|
+
className?: string;
|
|
342
|
+
"aria-label"?: string;
|
|
343
|
+
"aria-invalid"?: boolean;
|
|
344
|
+
}) {
|
|
345
|
+
const ui = useUiText();
|
|
346
|
+
const text = ui.time;
|
|
347
|
+
const [date, setDate] = useControllable<Date | null>(value, defaultValue, onValueChange);
|
|
348
|
+
const [open, setOpen] = useState(false);
|
|
349
|
+
const time = date ? `${pad(date.getHours())}:${pad(date.getMinutes())}` : null;
|
|
350
|
+
|
|
351
|
+
const withTime = (day: Date, t: TimeValue) => {
|
|
352
|
+
const [h, m] = splitTime(t) ?? [9, 0];
|
|
353
|
+
const d = new Date(day);
|
|
354
|
+
d.setHours(h, m, 0, 0);
|
|
355
|
+
return d;
|
|
356
|
+
};
|
|
357
|
+
|
|
358
|
+
const label = date
|
|
359
|
+
? date.toLocaleString("th-TH", { dateStyle: "medium", timeStyle: "short" })
|
|
360
|
+
: (placeholder ?? text?.dateTimePlaceholder ?? "เลือกวันและเวลา");
|
|
361
|
+
|
|
362
|
+
return (
|
|
363
|
+
<Popover open={open} onOpenChange={setOpen}>
|
|
364
|
+
<PopoverTrigger
|
|
365
|
+
render={
|
|
366
|
+
<Button
|
|
367
|
+
id={id}
|
|
368
|
+
type="button"
|
|
369
|
+
variant="outline"
|
|
370
|
+
disabled={disabled}
|
|
371
|
+
aria-label={ariaLabel}
|
|
372
|
+
aria-invalid={ariaInvalid || undefined}
|
|
373
|
+
data-slot="date-time-picker"
|
|
374
|
+
className={cn(
|
|
375
|
+
"w-full justify-start gap-2 font-normal",
|
|
376
|
+
!date && "text-muted-foreground",
|
|
377
|
+
className,
|
|
378
|
+
)}
|
|
379
|
+
/>
|
|
380
|
+
}
|
|
381
|
+
>
|
|
382
|
+
<CalendarClockIcon className="text-muted-foreground" />
|
|
383
|
+
<span className="truncate">{label}</span>
|
|
384
|
+
</PopoverTrigger>
|
|
385
|
+
{name && <input type="hidden" name={name} value={date ? toLocalDateTimeString(date) : ""} />}
|
|
386
|
+
<PopoverContent align="start" className="w-auto gap-0 p-0">
|
|
387
|
+
<div className="flex flex-col sm:flex-row">
|
|
388
|
+
<Calendar
|
|
389
|
+
mode="single"
|
|
390
|
+
locale={th}
|
|
391
|
+
selected={date ?? undefined}
|
|
392
|
+
defaultMonth={date ?? undefined}
|
|
393
|
+
onSelect={(day) => day && setDate(withTime(day, time ?? defaultTime))}
|
|
394
|
+
className="p-3"
|
|
395
|
+
/>
|
|
396
|
+
<div className="flex justify-center border-t p-2 sm:border-t-0 sm:border-l">
|
|
397
|
+
<TimeColumns
|
|
398
|
+
value={time}
|
|
399
|
+
minuteStep={minuteStep}
|
|
400
|
+
onValueChange={(t) => setDate(withTime(date ?? new Date(), t))}
|
|
401
|
+
/>
|
|
402
|
+
</div>
|
|
403
|
+
</div>
|
|
404
|
+
<div className="flex justify-between gap-2 border-t p-2">
|
|
405
|
+
<Button
|
|
406
|
+
type="button"
|
|
407
|
+
size="xs"
|
|
408
|
+
variant="ghost"
|
|
409
|
+
disabled={!date}
|
|
410
|
+
onClick={() => setDate(null)}
|
|
411
|
+
>
|
|
412
|
+
{text?.clear ?? "ล้าง"}
|
|
413
|
+
</Button>
|
|
414
|
+
<Button type="button" size="xs" onClick={() => setOpen(false)}>
|
|
415
|
+
{ui.done}
|
|
416
|
+
</Button>
|
|
417
|
+
</div>
|
|
418
|
+
</PopoverContent>
|
|
419
|
+
</Popover>
|
|
420
|
+
);
|
|
421
|
+
}
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Button } from "@suphark/ui/components/ui/button";
|
|
4
|
+
import { Spinner } from "@suphark/ui/components/ui/spinner";
|
|
5
|
+
import { cn } from "@suphark/ui/lib/utils";
|
|
6
|
+
import { CheckIcon } from "lucide-react";
|
|
7
|
+
import { type FormEvent, type ReactNode, useEffect, useRef, useState } from "react";
|
|
8
|
+
import { useUiText } from "./ui-text";
|
|
9
|
+
|
|
10
|
+
export interface StepFormStep {
|
|
11
|
+
id: string;
|
|
12
|
+
title: ReactNode;
|
|
13
|
+
description?: ReactNode;
|
|
14
|
+
/** ช่องกรอกของขั้นนี้ — ใส่ `name` ตามปกติ ค่าทุกขั้นรวมใน FormData เดียวตอนส่ง */
|
|
15
|
+
content: ReactNode;
|
|
16
|
+
/** ตรวจเพิ่มจาก required/pattern ของ input — คืน string = ข้อความ error (ไม่ไปต่อ) */
|
|
17
|
+
validate?: (formData: FormData) => string | null | undefined | Promise<string | null | undefined>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const CONTROLS = "input, select, textarea";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* ตรวจ constraint ของ input ในกล่อง (required, pattern, min…) — แสดง bubble ของเบราว์เซอร์ที่ช่องแรกที่ผิด
|
|
24
|
+
* input ที่ซ่อนไว้ (ตัวแทนของ Base UI เช่น NumberField, aria-hidden) แสดง bubble ไม่ได้ → คืนข้อความให้แสดงใต้ฟอร์มแทน
|
|
25
|
+
* คืน true = ผ่าน, string = ข้อความ error, false = แสดง bubble แล้ว
|
|
26
|
+
*/
|
|
27
|
+
function reportSection(section: HTMLElement | null): true | false | string {
|
|
28
|
+
if (!section) return true;
|
|
29
|
+
for (const el of section.querySelectorAll<HTMLInputElement>(CONTROLS)) {
|
|
30
|
+
if (el.type === "hidden" || el.disabled) continue;
|
|
31
|
+
if (!el.checkValidity()) {
|
|
32
|
+
if (el.getAttribute("aria-hidden") === "true") {
|
|
33
|
+
// โฟกัสช่องที่มองเห็นในกลุ่มเดียวกัน (ถ้ามี)
|
|
34
|
+
el.parentElement?.querySelector<HTMLElement>("input:not([aria-hidden=true])")?.focus();
|
|
35
|
+
return el.validationMessage || "ค่าไม่ถูกต้อง";
|
|
36
|
+
}
|
|
37
|
+
el.reportValidity();
|
|
38
|
+
el.focus();
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* ฟอร์มหลายขั้น (wizard) สำเร็จรูป — แถบขั้นตอน + เนื้อหาทีละขั้น + ปุ่มย้อนกลับ/ถัดไป/ส่ง ในตัว
|
|
47
|
+
* ทุกขั้นอยู่ใน `<form>` เดียว (ขั้นที่ไม่แสดงถูกซ่อนไว้ ค่ายังอยู่) → `onSubmit(formData)` ได้ค่าครบทุกขั้น ส่งต่อ server action ได้ตรง ๆ
|
|
48
|
+
* กด "ถัดไป" ตรวจ required/pattern ของขั้นนั้น + `validate` ก่อนไปต่อ; ขั้นที่ผ่านแล้วกดที่แถบเพื่อย้อนกลับได้
|
|
49
|
+
* ขั้นสุดท้ายใช้เป็นหน้าตรวจทานได้ (content อ่านค่าจาก state ของแอปเอง)
|
|
50
|
+
*
|
|
51
|
+
* <StepForm onSubmit={createApp} submitLabel="สร้างแอป" steps={[
|
|
52
|
+
* { id: "basics", title: "ข้อมูลพื้นฐาน", content: <LabeledField label="ชื่อ"><Input name="name" required /></LabeledField> },
|
|
53
|
+
* { id: "oauth", title: "OAuth", content: <TagsInput name="redirectUris" /> ,
|
|
54
|
+
* validate: (fd) => (fd.getAll("redirectUris").length ? null : "ต้องมี redirect URI อย่างน้อย 1 รายการ") },
|
|
55
|
+
* ]} />
|
|
56
|
+
*/
|
|
57
|
+
export function StepForm({
|
|
58
|
+
steps,
|
|
59
|
+
onSubmit,
|
|
60
|
+
submitLabel,
|
|
61
|
+
initialStep = 0,
|
|
62
|
+
onStepChange,
|
|
63
|
+
footer,
|
|
64
|
+
className,
|
|
65
|
+
}: {
|
|
66
|
+
steps: ReadonlyArray<StepFormStep>;
|
|
67
|
+
/** เรียกเมื่อกดส่งที่ขั้นสุดท้าย — throw / reject = แสดงข้อความ error ใต้ฟอร์ม */
|
|
68
|
+
onSubmit: (formData: FormData) => unknown | Promise<unknown>;
|
|
69
|
+
submitLabel?: ReactNode;
|
|
70
|
+
initialStep?: number;
|
|
71
|
+
onStepChange?: (index: number, id: string) => void;
|
|
72
|
+
/** ต่อท้ายแถวปุ่มฝั่งซ้าย เช่น ปุ่มยกเลิก */
|
|
73
|
+
footer?: ReactNode;
|
|
74
|
+
className?: string;
|
|
75
|
+
}) {
|
|
76
|
+
const ui = useUiText();
|
|
77
|
+
const text = ui.stepForm;
|
|
78
|
+
const [current, setCurrent] = useState(initialStep);
|
|
79
|
+
const [reached, setReached] = useState(initialStep);
|
|
80
|
+
const [error, setError] = useState<string | null>(null);
|
|
81
|
+
const [pending, setPending] = useState(false);
|
|
82
|
+
const formRef = useRef<HTMLFormElement>(null);
|
|
83
|
+
const sectionRefs = useRef<(HTMLElement | null)[]>([]);
|
|
84
|
+
const headingRefs = useRef<(HTMLHeadingElement | null)[]>([]);
|
|
85
|
+
const moved = useRef(false);
|
|
86
|
+
const last = steps.length - 1;
|
|
87
|
+
const step = steps[current];
|
|
88
|
+
|
|
89
|
+
// ย้ายโฟกัสไปหัวข้อของขั้นใหม่ (screen reader รู้ว่าเปลี่ยนขั้น) — ไม่ทำตอน mount
|
|
90
|
+
useEffect(() => {
|
|
91
|
+
if (!moved.current) return;
|
|
92
|
+
headingRefs.current[current]?.focus();
|
|
93
|
+
}, [current]);
|
|
94
|
+
|
|
95
|
+
const go = (index: number) => {
|
|
96
|
+
moved.current = true;
|
|
97
|
+
setError(null);
|
|
98
|
+
setCurrent(index);
|
|
99
|
+
setReached((r) => Math.max(r, index));
|
|
100
|
+
const target = steps[index];
|
|
101
|
+
if (target) onStepChange?.(index, target.id);
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
/** ตรวจขั้น index — คืน true ถ้าผ่าน */
|
|
105
|
+
const check = async (index: number): Promise<boolean> => {
|
|
106
|
+
const native = reportSection(sectionRefs.current[index] ?? null);
|
|
107
|
+
if (native !== true) {
|
|
108
|
+
if (typeof native === "string") setError(native);
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
const s = steps[index];
|
|
112
|
+
if (s?.validate && formRef.current) {
|
|
113
|
+
const message = await s.validate(new FormData(formRef.current));
|
|
114
|
+
if (message) {
|
|
115
|
+
setError(message);
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return true;
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
|
|
123
|
+
e.preventDefault();
|
|
124
|
+
if (pending) return;
|
|
125
|
+
setError(null);
|
|
126
|
+
if (current < last) {
|
|
127
|
+
if (await check(current)) go(current + 1);
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
// ขั้นสุดท้าย: ตรวจทุกขั้นอีกครั้งแบบเงียบ ขั้นไหนไม่ผ่านพากลับไปขั้นนั้นแล้วค่อยแสดงจุดที่ผิด
|
|
131
|
+
for (let i = 0; i <= last; i++) {
|
|
132
|
+
const section = sectionRefs.current[i];
|
|
133
|
+
const invalid = section
|
|
134
|
+
? [...section.querySelectorAll<HTMLInputElement>(CONTROLS)].some(
|
|
135
|
+
(el) => el.type !== "hidden" && !el.disabled && !el.checkValidity(),
|
|
136
|
+
)
|
|
137
|
+
: false;
|
|
138
|
+
const s = steps[i];
|
|
139
|
+
const message =
|
|
140
|
+
!invalid && s?.validate && formRef.current
|
|
141
|
+
? await s.validate(new FormData(formRef.current))
|
|
142
|
+
: null;
|
|
143
|
+
if (invalid || message) {
|
|
144
|
+
if (i !== current) go(i);
|
|
145
|
+
if (message) setError(message);
|
|
146
|
+
else
|
|
147
|
+
requestAnimationFrame(() => {
|
|
148
|
+
const native = reportSection(sectionRefs.current[i] ?? null);
|
|
149
|
+
if (typeof native === "string") setError(native);
|
|
150
|
+
});
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
if (!formRef.current) return;
|
|
155
|
+
setPending(true);
|
|
156
|
+
try {
|
|
157
|
+
await onSubmit(new FormData(formRef.current));
|
|
158
|
+
} catch (err) {
|
|
159
|
+
setError(
|
|
160
|
+
err instanceof Error && err.message ? err.message : (ui.actionFailed ?? "ดำเนินการไม่สำเร็จ"),
|
|
161
|
+
);
|
|
162
|
+
} finally {
|
|
163
|
+
setPending(false);
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
return (
|
|
168
|
+
<form
|
|
169
|
+
ref={formRef}
|
|
170
|
+
noValidate
|
|
171
|
+
onSubmit={handleSubmit}
|
|
172
|
+
data-slot="step-form"
|
|
173
|
+
className={cn("flex flex-col gap-6", className)}
|
|
174
|
+
>
|
|
175
|
+
<nav aria-label={text?.label ?? "ขั้นตอน"}>
|
|
176
|
+
<p className="mb-2 text-muted-foreground text-xs sm:hidden">
|
|
177
|
+
{(text?.progress ?? "ขั้นที่ {current} จาก {total}")
|
|
178
|
+
.replace("{current}", String(current + 1))
|
|
179
|
+
.replace("{total}", String(steps.length))}
|
|
180
|
+
</p>
|
|
181
|
+
<ol className="flex items-center gap-2">
|
|
182
|
+
{steps.map((s, i) => {
|
|
183
|
+
const done = i < reached;
|
|
184
|
+
const active = i === current;
|
|
185
|
+
const clickable = i <= reached && !active && !pending;
|
|
186
|
+
return (
|
|
187
|
+
<li
|
|
188
|
+
key={s.id}
|
|
189
|
+
className={cn("flex min-w-0 items-center gap-2", i < last && "flex-1")}
|
|
190
|
+
>
|
|
191
|
+
<button
|
|
192
|
+
type="button"
|
|
193
|
+
disabled={!clickable}
|
|
194
|
+
aria-current={active ? "step" : undefined}
|
|
195
|
+
onClick={() => go(i)}
|
|
196
|
+
className="flex min-w-0 items-center gap-2 rounded-md text-left outline-none focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-default"
|
|
197
|
+
>
|
|
198
|
+
<span
|
|
199
|
+
className={cn(
|
|
200
|
+
"flex size-7 shrink-0 items-center justify-center rounded-full border text-sm tabular-nums transition-colors",
|
|
201
|
+
active && "border-primary bg-primary text-primary-foreground",
|
|
202
|
+
!active && done && "border-primary text-primary",
|
|
203
|
+
!active && !done && "text-muted-foreground",
|
|
204
|
+
)}
|
|
205
|
+
>
|
|
206
|
+
{!active && done ? <CheckIcon className="size-4" /> : i + 1}
|
|
207
|
+
</span>
|
|
208
|
+
<span
|
|
209
|
+
className={cn(
|
|
210
|
+
"hidden truncate text-sm sm:inline",
|
|
211
|
+
active ? "font-medium" : "text-muted-foreground",
|
|
212
|
+
)}
|
|
213
|
+
>
|
|
214
|
+
{s.title}
|
|
215
|
+
</span>
|
|
216
|
+
</button>
|
|
217
|
+
{i < last && (
|
|
218
|
+
<span
|
|
219
|
+
aria-hidden
|
|
220
|
+
className={cn("h-px min-w-4 flex-1", i < reached ? "bg-primary" : "bg-border")}
|
|
221
|
+
/>
|
|
222
|
+
)}
|
|
223
|
+
</li>
|
|
224
|
+
);
|
|
225
|
+
})}
|
|
226
|
+
</ol>
|
|
227
|
+
</nav>
|
|
228
|
+
|
|
229
|
+
{steps.map((s, i) => (
|
|
230
|
+
<section
|
|
231
|
+
key={s.id}
|
|
232
|
+
ref={(el) => {
|
|
233
|
+
sectionRefs.current[i] = el;
|
|
234
|
+
}}
|
|
235
|
+
hidden={i !== current}
|
|
236
|
+
aria-labelledby={`${s.id}-step-title`}
|
|
237
|
+
className="flex flex-col gap-4"
|
|
238
|
+
>
|
|
239
|
+
<div className="flex flex-col gap-1">
|
|
240
|
+
<h2
|
|
241
|
+
id={`${s.id}-step-title`}
|
|
242
|
+
ref={(el) => {
|
|
243
|
+
headingRefs.current[i] = el;
|
|
244
|
+
}}
|
|
245
|
+
tabIndex={-1}
|
|
246
|
+
className="font-semibold text-base outline-none"
|
|
247
|
+
>
|
|
248
|
+
{s.title}
|
|
249
|
+
</h2>
|
|
250
|
+
{s.description && <p className="text-muted-foreground text-sm">{s.description}</p>}
|
|
251
|
+
</div>
|
|
252
|
+
{s.content}
|
|
253
|
+
</section>
|
|
254
|
+
))}
|
|
255
|
+
|
|
256
|
+
{error && (
|
|
257
|
+
<p role="alert" className="text-destructive text-sm">
|
|
258
|
+
{error}
|
|
259
|
+
</p>
|
|
260
|
+
)}
|
|
261
|
+
|
|
262
|
+
<div className="flex flex-wrap items-center gap-2 border-t pt-4">
|
|
263
|
+
{footer}
|
|
264
|
+
<div className="ml-auto flex gap-2">
|
|
265
|
+
{current > 0 && (
|
|
266
|
+
<Button
|
|
267
|
+
type="button"
|
|
268
|
+
variant="outline"
|
|
269
|
+
disabled={pending}
|
|
270
|
+
onClick={() => go(current - 1)}
|
|
271
|
+
>
|
|
272
|
+
{text?.previous ?? "ย้อนกลับ"}
|
|
273
|
+
</Button>
|
|
274
|
+
)}
|
|
275
|
+
<Button type="submit" disabled={pending || !step}>
|
|
276
|
+
{pending && <Spinner />}
|
|
277
|
+
{current < last ? (text?.next ?? "ถัดไป") : (submitLabel ?? text?.submit ?? "ส่ง")}
|
|
278
|
+
</Button>
|
|
279
|
+
</div>
|
|
280
|
+
</div>
|
|
281
|
+
</form>
|
|
282
|
+
);
|
|
283
|
+
}
|