@suphark/ui 0.25.0 → 0.25.1
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 +39 -0
- package/package.json +1 -1
- package/src/components/app-shell/command-palette.tsx +8 -1
- package/src/components/data-table/data-table-sortable.tsx +154 -0
- package/src/components/data-table/data-table-toolbar.tsx +33 -4
- package/src/components/data-table/data-table.tsx +30 -142
- package/src/components/data-table/use-data-table.ts +8 -5
- package/src/components/pwa/pull-to-refresh.tsx +21 -1
- package/src/components/shared/action-menu.tsx +25 -6
- package/src/components/shared/auth-parts.tsx +5 -3
- package/src/components/shared/button/confirm-action-button.tsx +17 -3
- package/src/components/shared/chat-thread.tsx +7 -0
- package/src/components/shared/client-redirect.tsx +16 -1
- package/src/components/shared/form/date-time-picker.tsx +150 -0
- package/src/components/shared/form/form-field-autocomplete.tsx +93 -0
- package/src/components/shared/form/form-field-input.tsx +23 -81
- package/src/components/shared/form/number-input.tsx +10 -0
- package/src/components/shared/form/tags-input.tsx +6 -1
- package/src/components/shared/form/time-picker.tsx +23 -123
- package/src/components/shared/month-stepper.tsx +10 -4
- package/src/components/shared/row-actions.tsx +9 -3
- package/src/components/shared/settings-layout.tsx +17 -6
- package/src/components/shared/step-form.tsx +25 -4
- package/src/components/shared/time/client-date-formatter.tsx +2 -1
- package/src/components/shared/tree-view.tsx +6 -2
- package/src/components/shared/ui-text.tsx +3 -0
- package/src/hooks/use-table-search-params.ts +10 -2
- package/src/hooks/use-ticker.ts +11 -5
- package/src/index.ts +1 -1
- package/src/lib/formatters.ts +1 -1
- package/src/lib/month.ts +14 -2
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import { Button } from "@suphark/ui/components/ui/button";
|
|
4
|
-
import { Calendar } from "@suphark/ui/components/ui/calendar";
|
|
5
4
|
import {
|
|
6
5
|
InputGroup,
|
|
7
6
|
InputGroupAddon,
|
|
@@ -10,15 +9,14 @@ import {
|
|
|
10
9
|
} from "@suphark/ui/components/ui/input-group";
|
|
11
10
|
import { Popover, PopoverContent, PopoverTrigger } from "@suphark/ui/components/ui/popover";
|
|
12
11
|
import { cn } from "@suphark/ui/lib/utils";
|
|
13
|
-
import {
|
|
12
|
+
import { ClockIcon } from "lucide-react";
|
|
14
13
|
import { type KeyboardEvent, useEffect, useId, useRef, useState } from "react";
|
|
15
|
-
import { th } from "react-day-picker/locale";
|
|
16
14
|
import { useUiText } from "../ui-text";
|
|
17
15
|
|
|
18
16
|
/** "HH:mm" 24 ชั่วโมง */
|
|
19
17
|
export type TimeValue = string;
|
|
20
18
|
|
|
21
|
-
const pad = (n: number) => String(n).padStart(2, "0");
|
|
19
|
+
export const pad = (n: number) => String(n).padStart(2, "0");
|
|
22
20
|
|
|
23
21
|
/**
|
|
24
22
|
* แปลงข้อความที่พิมพ์เป็น "HH:mm": "14:30" "14.30" "1430" "930" "9" "9:5" → null ถ้าไม่ใช่เวลาที่ถูกต้อง
|
|
@@ -47,7 +45,7 @@ export function parseTimeInput(input: string): TimeValue | null {
|
|
|
47
45
|
return `${pad(h)}:${pad(m)}`;
|
|
48
46
|
}
|
|
49
47
|
|
|
50
|
-
function splitTime(value: TimeValue | null | undefined): [number, number] | null {
|
|
48
|
+
export function splitTime(value: TimeValue | null | undefined): [number, number] | null {
|
|
51
49
|
const parsed = value ? parseTimeInput(value) : null;
|
|
52
50
|
if (!parsed) return null;
|
|
53
51
|
const [h, m] = parsed.split(":").map(Number);
|
|
@@ -70,11 +68,12 @@ function TimeColumn({
|
|
|
70
68
|
const listRef = useRef<HTMLDivElement>(null);
|
|
71
69
|
const optionId = (v: number) => `${baseId}-${v}`;
|
|
72
70
|
|
|
73
|
-
//
|
|
71
|
+
// เลื่อนเฉพาะในคอลัมน์ให้ค่าที่เลือกอยู่กลาง — ไม่ใช้ scrollIntoView เพราะจะเลื่อนทั้งหน้าไปด้วย
|
|
74
72
|
useEffect(() => {
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
el
|
|
73
|
+
const list = listRef.current;
|
|
74
|
+
if (selected == null || !list) return;
|
|
75
|
+
const el = list.querySelector<HTMLElement>(`[data-value="${selected}"]`);
|
|
76
|
+
if (el) list.scrollTop = el.offsetTop - (list.clientHeight - el.offsetHeight) / 2;
|
|
78
77
|
}, [selected]);
|
|
79
78
|
|
|
80
79
|
const onKeyDown = (e: KeyboardEvent) => {
|
|
@@ -98,7 +97,7 @@ function TimeColumn({
|
|
|
98
97
|
aria-label={label}
|
|
99
98
|
aria-activedescendant={selected == null ? undefined : optionId(selected)}
|
|
100
99
|
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"
|
|
100
|
+
className="no-scrollbar relative 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
101
|
>
|
|
103
102
|
{options.map((v) => (
|
|
104
103
|
<div
|
|
@@ -161,7 +160,12 @@ export function TimeColumns({
|
|
|
161
160
|
);
|
|
162
161
|
}
|
|
163
162
|
|
|
164
|
-
|
|
163
|
+
/** state แบบ controlled/uncontrolled (ใช้ร่วมกับ DateTimePicker) */
|
|
164
|
+
export function useControllable<T>(
|
|
165
|
+
value: T | undefined,
|
|
166
|
+
defaultValue: T,
|
|
167
|
+
onChange?: (v: T) => void,
|
|
168
|
+
) {
|
|
165
169
|
const [inner, setInner] = useState(defaultValue);
|
|
166
170
|
const controlled = value !== undefined;
|
|
167
171
|
const current = controlled ? value : inner;
|
|
@@ -219,6 +223,13 @@ export function TimePicker({
|
|
|
219
223
|
const [draft, setDraft] = useState<string | null>(null);
|
|
220
224
|
const [open, setOpen] = useState(false);
|
|
221
225
|
const draftInvalid = draft != null && draft.trim() !== "" && parseTimeInput(draft) == null;
|
|
226
|
+
const invalidMessage = text?.invalid ?? "รูปแบบเวลาไม่ถูกต้อง (เช่น 14:30)";
|
|
227
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
228
|
+
|
|
229
|
+
// ข้อความที่พิมพ์ผิด = ช่องนี้ invalid จริง → ฟอร์มไม่ส่ง (ไม่งั้น input ซ่อนจะส่งค่าเดิมไปเงียบ ๆ)
|
|
230
|
+
useEffect(() => {
|
|
231
|
+
inputRef.current?.setCustomValidity(draftInvalid ? invalidMessage : "");
|
|
232
|
+
}, [draftInvalid, invalidMessage]);
|
|
222
233
|
|
|
223
234
|
const commitDraft = () => {
|
|
224
235
|
if (draft == null) return;
|
|
@@ -237,6 +248,7 @@ export function TimePicker({
|
|
|
237
248
|
return (
|
|
238
249
|
<InputGroup data-slot="time-picker" className={cn("w-32", className)}>
|
|
239
250
|
<InputGroupInput
|
|
251
|
+
ref={inputRef}
|
|
240
252
|
id={id}
|
|
241
253
|
inputMode="numeric"
|
|
242
254
|
autoComplete="off"
|
|
@@ -307,115 +319,3 @@ export function TimePicker({
|
|
|
307
319
|
export function toLocalDateTimeString(date: Date): string {
|
|
308
320
|
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
|
309
321
|
}
|
|
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
|
-
}
|
|
@@ -38,6 +38,9 @@ export interface MonthStepperProps {
|
|
|
38
38
|
* const { month, setMonth } = useMonthCursor();
|
|
39
39
|
* <MonthStepper value={month} onValueChange={setMonth} />
|
|
40
40
|
*/
|
|
41
|
+
/** "เดือนนี้" ของ MonthStepper/useMonthCursor คิดตามเวลาไทย — แอปของเราใช้ในไทย และต้องได้ค่าเดียวกันทั้ง server และ browser */
|
|
42
|
+
const APP_TIME_ZONE = "Asia/Bangkok";
|
|
43
|
+
|
|
41
44
|
export function MonthStepper({
|
|
42
45
|
value,
|
|
43
46
|
onValueChange,
|
|
@@ -54,7 +57,8 @@ export function MonthStepper({
|
|
|
54
57
|
const next = shiftMonth(value, 1);
|
|
55
58
|
const canPrev = !min || compareMonth(prev, min) >= 0;
|
|
56
59
|
const canNext = !max || compareMonth(next, max) <= 0;
|
|
57
|
-
|
|
60
|
+
// โซนเวลาคงที่: server (UTC) กับ browser ได้เดือนเดียวกัน — ไม่งั้นช่วง 00–07 น. วันที่ 1 จะ hydration mismatch
|
|
61
|
+
const now = currentMonthKey(undefined, APP_TIME_ZONE);
|
|
58
62
|
const nowInRange = (!min || compareMonth(now, min) >= 0) && (!max || compareMonth(now, max) <= 0);
|
|
59
63
|
const label = formatLabel ? formatLabel(value) : formatMonthKey(value, locale, format);
|
|
60
64
|
const isCurrent = value === now;
|
|
@@ -112,13 +116,15 @@ export function MonthStepper({
|
|
|
112
116
|
}
|
|
113
117
|
|
|
114
118
|
/**
|
|
115
|
-
* state เดือนปัจจุบัน + ตัวเลื่อน — `initial` ไม่ส่ง = เดือนนี้
|
|
119
|
+
* state เดือนปัจจุบัน + ตัวเลื่อน — `initial` ไม่ส่ง = เดือนนี้ (ตามเวลาไทย เพื่อให้ server/browser ตรงกัน)
|
|
116
120
|
* ต้องการเก็บใน URL ให้ใช้ `useTableSearchParams`/searchParams ของแอปแทน แล้วส่ง value/onValueChange เอง
|
|
117
121
|
*/
|
|
118
122
|
export function useMonthCursor(initial?: MonthKey) {
|
|
119
|
-
const [month, setMonth] = useState<MonthKey>(
|
|
123
|
+
const [month, setMonth] = useState<MonthKey>(
|
|
124
|
+
() => initial ?? currentMonthKey(undefined, APP_TIME_ZONE),
|
|
125
|
+
);
|
|
120
126
|
const previous = useCallback(() => setMonth((m) => shiftMonth(m, -1)), []);
|
|
121
127
|
const next = useCallback(() => setMonth((m) => shiftMonth(m, 1)), []);
|
|
122
|
-
const reset = useCallback(() => setMonth(currentMonthKey()), []);
|
|
128
|
+
const reset = useCallback(() => setMonth(currentMonthKey(undefined, APP_TIME_ZONE)), []);
|
|
123
129
|
return { month, setMonth, previous, next, reset };
|
|
124
130
|
}
|
|
@@ -13,7 +13,7 @@ import Link from "next/link";
|
|
|
13
13
|
import { useRouter } from "next/navigation";
|
|
14
14
|
import { useState, useTransition } from "react";
|
|
15
15
|
import { toast } from "sonner";
|
|
16
|
-
import { errorMessage } from "./button/confirm-action-button";
|
|
16
|
+
import { errorMessage, failedResult } from "./button/confirm-action-button";
|
|
17
17
|
import { ConfirmDialog, type ConfirmSpec, normalizeConfirm } from "./confirm-dialog";
|
|
18
18
|
import { useUiText } from "./ui-text";
|
|
19
19
|
|
|
@@ -58,10 +58,16 @@ export function RowActions({ items, label }: { items: RowAction[]; label?: strin
|
|
|
58
58
|
for (const [k, v] of Object.entries(it.data ?? {})) fd.set(k, v);
|
|
59
59
|
const act = it.action;
|
|
60
60
|
start(async () => {
|
|
61
|
+
const fallback = text.actionFailed ?? "ดำเนินการไม่สำเร็จ";
|
|
61
62
|
try {
|
|
62
|
-
|
|
63
|
+
// คืน { ok: false, error } (FormState) ก็ถือว่าล้มเหลวเหมือน throw
|
|
64
|
+
const failed = failedResult(await act(fd), fallback);
|
|
65
|
+
if (failed) {
|
|
66
|
+
toast.error(failed);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
63
69
|
} catch (err) {
|
|
64
|
-
toast.error(errorMessage(err,
|
|
70
|
+
toast.error(errorMessage(err, fallback));
|
|
65
71
|
return;
|
|
66
72
|
}
|
|
67
73
|
router.refresh();
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { cn } from "@suphark/ui/lib/utils";
|
|
4
4
|
import { ChevronLeft, ChevronRight } from "lucide-react";
|
|
5
|
-
import { type ReactNode,
|
|
5
|
+
import { type ReactNode, useId, useMemo, useRef, useState } from "react";
|
|
6
6
|
import { SearchInput } from "./form/search-input";
|
|
7
7
|
import { useUiText } from "./ui-text";
|
|
8
8
|
|
|
@@ -53,6 +53,7 @@ export function SettingsLayout({
|
|
|
53
53
|
const [mobileDetail, setMobileDetail] = useState(false);
|
|
54
54
|
const [query, setQuery] = useState("");
|
|
55
55
|
const headingRef = useRef<HTMLHeadingElement>(null);
|
|
56
|
+
const navRef = useRef<HTMLElement>(null);
|
|
56
57
|
|
|
57
58
|
const visible = useMemo(() => {
|
|
58
59
|
const q = query.trim().toLowerCase();
|
|
@@ -71,12 +72,20 @@ export function SettingsLayout({
|
|
|
71
72
|
if (valueProp === undefined) setInner(id);
|
|
72
73
|
onValueChange?.(id);
|
|
73
74
|
setMobileDetail(true);
|
|
75
|
+
// มือถือ: รายการหัวข้อถูกซ่อน → ย้ายโฟกัสไปหัวข้อ ให้ screen reader รู้ว่าเนื้อหาเปลี่ยน
|
|
76
|
+
// (จอกว้างเห็นทั้งสองฝั่ง โฟกัสอยู่ที่รายการต่อได้ — ไม่ย้าย)
|
|
77
|
+
if (!window.matchMedia?.("(min-width: 48rem)").matches) {
|
|
78
|
+
requestAnimationFrame(() => headingRef.current?.focus({ preventScroll: true }));
|
|
79
|
+
}
|
|
74
80
|
};
|
|
75
81
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
82
|
+
/** มือถือ: กลับไปรายการ — ปุ่ม "กลับ" ถูกซ่อน จึงคืนโฟกัสให้หัวข้อที่เพิ่งเปิด (ไม่งั้นโฟกัสหลุดไป body) */
|
|
83
|
+
const back = (id: string) => {
|
|
84
|
+
setMobileDetail(false);
|
|
85
|
+
requestAnimationFrame(() =>
|
|
86
|
+
navRef.current?.querySelector<HTMLElement>(`[data-section-id="${CSS.escape(id)}"]`)?.focus(),
|
|
87
|
+
);
|
|
88
|
+
};
|
|
80
89
|
|
|
81
90
|
if (!current) return null;
|
|
82
91
|
|
|
@@ -87,6 +96,7 @@ export function SettingsLayout({
|
|
|
87
96
|
className={cn("grid gap-6 md:grid-cols-[14rem_minmax(0,1fr)] lg:gap-8", className)}
|
|
88
97
|
>
|
|
89
98
|
<nav
|
|
99
|
+
ref={navRef}
|
|
90
100
|
aria-label={text.settings?.label ?? "หัวข้อการตั้งค่า"}
|
|
91
101
|
className={cn(
|
|
92
102
|
"flex flex-col gap-3 md:sticky md:top-20 md:self-start",
|
|
@@ -108,6 +118,7 @@ export function SettingsLayout({
|
|
|
108
118
|
<li key={s.id}>
|
|
109
119
|
<button
|
|
110
120
|
type="button"
|
|
121
|
+
data-section-id={s.id}
|
|
111
122
|
onClick={() => select(s.id)}
|
|
112
123
|
aria-current={selected ? "page" : undefined}
|
|
113
124
|
className={cn(
|
|
@@ -144,7 +155,7 @@ export function SettingsLayout({
|
|
|
144
155
|
>
|
|
145
156
|
<button
|
|
146
157
|
type="button"
|
|
147
|
-
onClick={() =>
|
|
158
|
+
onClick={() => back(current.id)}
|
|
148
159
|
className="-ml-1 flex w-fit items-center gap-1 rounded-md px-1 py-1 text-muted-foreground text-sm outline-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring md:hidden"
|
|
149
160
|
>
|
|
150
161
|
<ChevronLeft className="size-4" aria-hidden />
|
|
@@ -26,12 +26,17 @@ const CONTROLS = "input, select, textarea";
|
|
|
26
26
|
*/
|
|
27
27
|
function reportSection(section: HTMLElement | null): true | false | string {
|
|
28
28
|
if (!section) return true;
|
|
29
|
-
|
|
29
|
+
const controls = [...section.querySelectorAll<HTMLInputElement>(CONTROLS)];
|
|
30
|
+
for (const [i, el] of controls.entries()) {
|
|
30
31
|
if (el.type === "hidden" || el.disabled) continue;
|
|
31
32
|
if (!el.checkValidity()) {
|
|
32
33
|
if (el.getAttribute("aria-hidden") === "true") {
|
|
33
|
-
//
|
|
34
|
-
|
|
34
|
+
// input ซ่อนของ Base UI อยู่ถัดจากช่องที่มองเห็นของมัน → โฟกัสช่องที่มองเห็นตัวก่อนหน้าที่ใกล้ที่สุด
|
|
35
|
+
controls
|
|
36
|
+
.slice(0, i)
|
|
37
|
+
.reverse()
|
|
38
|
+
.find((c) => c.getAttribute("aria-hidden") !== "true" && c.type !== "hidden")
|
|
39
|
+
?.focus();
|
|
35
40
|
return el.validationMessage || "ค่าไม่ถูกต้อง";
|
|
36
41
|
}
|
|
37
42
|
el.reportValidity();
|
|
@@ -83,6 +88,10 @@ export function StepForm({
|
|
|
83
88
|
const sectionRefs = useRef<(HTMLElement | null)[]>([]);
|
|
84
89
|
const headingRefs = useRef<(HTMLHeadingElement | null)[]>([]);
|
|
85
90
|
const moved = useRef(false);
|
|
91
|
+
/** กันส่งซ้ำแบบ synchronous (state pending อัปเดตไม่ทันดับเบิลคลิก) */
|
|
92
|
+
const busy = useRef(false);
|
|
93
|
+
/** เวลาที่เปลี่ยนขั้นล่าสุด — คลิกที่สองของดับเบิลคลิก "ถัดไป" ตกที่ปุ่ม "ส่ง" ตำแหน่งเดิม */
|
|
94
|
+
const movedAt = useRef(0);
|
|
86
95
|
const last = steps.length - 1;
|
|
87
96
|
const step = steps[current];
|
|
88
97
|
|
|
@@ -94,6 +103,7 @@ export function StepForm({
|
|
|
94
103
|
|
|
95
104
|
const go = (index: number) => {
|
|
96
105
|
moved.current = true;
|
|
106
|
+
movedAt.current = Date.now();
|
|
97
107
|
setError(null);
|
|
98
108
|
setCurrent(index);
|
|
99
109
|
setReached((r) => Math.max(r, index));
|
|
@@ -121,7 +131,18 @@ export function StepForm({
|
|
|
121
131
|
|
|
122
132
|
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
|
|
123
133
|
e.preventDefault();
|
|
124
|
-
if (
|
|
134
|
+
if (busy.current) return;
|
|
135
|
+
// ปุ่ม "ถัดไป" กลายเป็น "ส่ง" ที่ตำแหน่งเดิม — คลิกที่มาเร็วเกินหลังเปลี่ยนขั้นไม่นับเป็นการส่ง (ผู้ใช้ยังไม่ได้เห็นหน้าตรวจทาน)
|
|
136
|
+
if (current === last && Date.now() - movedAt.current < 500) return;
|
|
137
|
+
busy.current = true;
|
|
138
|
+
try {
|
|
139
|
+
await submitStep();
|
|
140
|
+
} finally {
|
|
141
|
+
busy.current = false;
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
const submitStep = async () => {
|
|
125
146
|
setError(null);
|
|
126
147
|
if (current < last) {
|
|
127
148
|
if (await check(current)) go(current + 1);
|
|
@@ -4,7 +4,8 @@ import { useMounted } from "@suphark/ui/hooks/use-mounted";
|
|
|
4
4
|
import { useTicker } from "@suphark/ui/hooks/use-ticker";
|
|
5
5
|
import { getDateTimeFormat } from "@suphark/ui/lib/intl-cache";
|
|
6
6
|
import { formatDistance, type Locale } from "date-fns";
|
|
7
|
-
import { enUS
|
|
7
|
+
import { enUS } from "date-fns/locale/en-US";
|
|
8
|
+
import { th } from "date-fns/locale/th";
|
|
8
9
|
import { useMemo } from "react";
|
|
9
10
|
|
|
10
11
|
interface ClientDateFormatterProps {
|
|
@@ -88,8 +88,12 @@ export function TreeView({
|
|
|
88
88
|
className,
|
|
89
89
|
}: TreeViewProps) {
|
|
90
90
|
const [uncontrolledExpanded, setUncontrolledExpanded] = useState(() => toSet(defaultExpandedIds));
|
|
91
|
-
|
|
92
|
-
|
|
91
|
+
// Set ใหม่ทุก render ทำให้ memo ของ rows คำนวณใหม่ทุกครั้ง — สร้างเมื่อ prop เปลี่ยนเท่านั้น
|
|
92
|
+
const controlledSet = useMemo(
|
|
93
|
+
() => (controlledExpanded !== undefined ? toSet(controlledExpanded) : null),
|
|
94
|
+
[controlledExpanded],
|
|
95
|
+
);
|
|
96
|
+
const expanded = controlledSet ?? uncontrolledExpanded;
|
|
93
97
|
const [loaded, setLoaded] = useState<Map<string, TreeNode[]>>(() => new Map());
|
|
94
98
|
const [loading, setLoading] = useState<Set<string>>(() => new Set());
|
|
95
99
|
const [focusId, setFocusId] = useState<string | null>(null);
|
|
@@ -99,6 +99,7 @@ export interface UiText {
|
|
|
99
99
|
now: string;
|
|
100
100
|
clear: string;
|
|
101
101
|
dateTimePlaceholder: string;
|
|
102
|
+
invalid: string;
|
|
102
103
|
}>;
|
|
103
104
|
/** TagsInput (optional) — remove ใช้ {tag}, duplicate ใช้ {tag}, max ใช้ {max} */
|
|
104
105
|
tags?: Partial<{ placeholder: string; remove: string; duplicate: string; max: string }>;
|
|
@@ -185,6 +186,7 @@ export const UI_TEXT_TH: UiText = {
|
|
|
185
186
|
now: "ตอนนี้",
|
|
186
187
|
clear: "ล้าง",
|
|
187
188
|
dateTimePlaceholder: "เลือกวันและเวลา",
|
|
189
|
+
invalid: "รูปแบบเวลาไม่ถูกต้อง (เช่น 14:30)",
|
|
188
190
|
},
|
|
189
191
|
tags: {
|
|
190
192
|
placeholder: "พิมพ์แล้วกด Enter",
|
|
@@ -274,6 +276,7 @@ export const UI_TEXT_EN: UiText = {
|
|
|
274
276
|
now: "Now",
|
|
275
277
|
clear: "Clear",
|
|
276
278
|
dateTimePlaceholder: "Pick date and time",
|
|
279
|
+
invalid: "Invalid time (e.g. 14:30)",
|
|
277
280
|
},
|
|
278
281
|
tags: {
|
|
279
282
|
placeholder: "Type and press Enter",
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
|
4
|
-
import { useCallback, useMemo, useTransition } from "react";
|
|
4
|
+
import { useCallback, useEffect, useMemo, useRef, useTransition } from "react";
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
7
|
* เก็บ state ของตาราง (หน้า, ขนาดหน้า, การเรียง, คำค้น, ตัวกรอง) ไว้ใน URL
|
|
@@ -71,10 +71,18 @@ export function useTableSearchParams(defaults?: { pageSize?: number }) {
|
|
|
71
71
|
};
|
|
72
72
|
}, [searchParams, defaults?.pageSize]);
|
|
73
73
|
|
|
74
|
+
// setter หลายตัวในจังหวะเดียว (เช่น setFilter แล้ว setSort) ต้องต่อจากค่าที่เพิ่งตั้ง ไม่ใช่ searchParams ตอน render
|
|
75
|
+
// (router.replace ยังไม่ทันเปลี่ยน URL → ตัวหลังเขียนทับตัวแรก) — ล้างเมื่อ URL เปลี่ยนจริงแล้ว
|
|
76
|
+
const latest = useRef<string | null>(null);
|
|
77
|
+
useEffect(() => {
|
|
78
|
+
latest.current = null;
|
|
79
|
+
}, [searchParams]);
|
|
80
|
+
|
|
74
81
|
const push = useCallback(
|
|
75
82
|
(mutate: (params: URLSearchParams) => void) => {
|
|
76
|
-
const params = new URLSearchParams(searchParams.toString());
|
|
83
|
+
const params = new URLSearchParams(latest.current ?? searchParams.toString());
|
|
77
84
|
mutate(params);
|
|
85
|
+
latest.current = params.toString();
|
|
78
86
|
startTransition(() => {
|
|
79
87
|
router.replace(`${pathname}?${params.toString()}`, { scroll: false });
|
|
80
88
|
});
|
package/src/hooks/use-ticker.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { useSyncExternalStore } from "react";
|
|
3
|
+
import { useCallback, useSyncExternalStore } from "react";
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* นาฬิกากลางต่อ interval — ทุก component ที่ขอ interval เท่ากันแชร์ `setInterval` ตัวเดียว
|
|
@@ -45,9 +45,15 @@ const zero = () => 0;
|
|
|
45
45
|
*/
|
|
46
46
|
export function useTicker(intervalMs: number | undefined): number {
|
|
47
47
|
const active = typeof intervalMs === "number" && intervalMs > 0;
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
48
|
+
// subscribe ต้องคงที่ข้าม render — ฟังก์ชันใหม่ทุก render ทำให้ unsubscribe/subscribe ใหม่
|
|
49
|
+
// (ผู้ใช้ตัวเดียว = clearInterval แล้วเริ่มนับใหม่ทุกครั้งที่ parent render → ถ้า render บ่อยกว่า interval จะไม่ tick เลย)
|
|
50
|
+
const subscribeActive = useCallback(
|
|
51
|
+
(listener: () => void) => (active ? subscribe(intervalMs, listener) : subscribeNoop()),
|
|
52
|
+
[active, intervalMs],
|
|
52
53
|
);
|
|
54
|
+
const getSnapshot = useCallback(
|
|
55
|
+
() => (active ? getTicker(intervalMs).tick : 0),
|
|
56
|
+
[active, intervalMs],
|
|
57
|
+
);
|
|
58
|
+
return useSyncExternalStore(subscribeActive, getSnapshot, zero);
|
|
53
59
|
}
|
package/src/index.ts
CHANGED
|
@@ -90,6 +90,7 @@ export { EmptyState } from "./components/shared/empty-state";
|
|
|
90
90
|
export { EnvSnippet, randomSecret } from "./components/shared/env-snippet";
|
|
91
91
|
export { type FileKind, FileTypeIcon, getFileKind } from "./components/shared/file-type-icon";
|
|
92
92
|
export { FilterBar, type FilterBarFilter } from "./components/shared/filter-bar";
|
|
93
|
+
export { DateTimePicker } from "./components/shared/form/date-time-picker";
|
|
93
94
|
export { FormFieldBoolean } from "./components/shared/form/form-field-boolean";
|
|
94
95
|
export { FormFieldColorSelect } from "./components/shared/form/form-field-color-select";
|
|
95
96
|
export { FormFieldCombobox } from "./components/shared/form/form-field-combobox";
|
|
@@ -117,7 +118,6 @@ export { SliderField, type SliderFieldProps } from "./components/shared/form/sli
|
|
|
117
118
|
export { TagsInput } from "./components/shared/form/tags-input";
|
|
118
119
|
export { TextareaField } from "./components/shared/form/textarea-field";
|
|
119
120
|
export {
|
|
120
|
-
DateTimePicker,
|
|
121
121
|
parseTimeInput,
|
|
122
122
|
TimeColumns,
|
|
123
123
|
TimePicker,
|
package/src/lib/formatters.ts
CHANGED
package/src/lib/month.ts
CHANGED
|
@@ -29,8 +29,20 @@ export function toMonthKey(date: Date): MonthKey {
|
|
|
29
29
|
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`;
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
-
|
|
33
|
-
|
|
32
|
+
/**
|
|
33
|
+
* เดือนปัจจุบัน — ไม่ส่ง `timeZone` = โซนเวลาของเครื่อง
|
|
34
|
+
* ส่ง `timeZone` (เช่น "Asia/Bangkok") เมื่อค่าถูกคำนวณทั้งบน server (UTC) และ browser แล้วต้องตรงกัน (กัน hydration mismatch ต้นเดือน)
|
|
35
|
+
*/
|
|
36
|
+
export function currentMonthKey(now: Date = new Date(), timeZone?: string): MonthKey {
|
|
37
|
+
if (!timeZone) return toMonthKey(now);
|
|
38
|
+
const parts = new Intl.DateTimeFormat("en-CA", {
|
|
39
|
+
timeZone,
|
|
40
|
+
year: "numeric",
|
|
41
|
+
month: "2-digit",
|
|
42
|
+
}).formatToParts(now);
|
|
43
|
+
const year = parts.find((p) => p.type === "year")?.value ?? String(now.getFullYear());
|
|
44
|
+
const month = parts.find((p) => p.type === "month")?.value ?? "01";
|
|
45
|
+
return `${year}-${month}`;
|
|
34
46
|
}
|
|
35
47
|
|
|
36
48
|
/** เลื่อนเดือน (ลบ = ย้อนหลัง) ข้ามปีให้เอง: shiftMonth("2026-01", -1) → "2025-12" */
|