@suphark/ui 0.24.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 +60 -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/index.ts +9 -9
- 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 +154 -0
- package/src/components/shared/form/tags-input.tsx +197 -0
- package/src/components/shared/form/time-picker.tsx +321 -0
- 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 +304 -0
- 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 +68 -0
- package/src/data-table.ts +12 -1
- package/src/hooks/use-table-search-params.ts +10 -2
- package/src/hooks/use-ticker.ts +11 -5
- package/src/index.ts +430 -66
- package/src/lib/formatters.ts +1 -1
- package/src/lib/month.ts +14 -2
- package/src/next.ts +38 -1
|
@@ -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 />
|
|
@@ -0,0 +1,304 @@
|
|
|
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
|
+
const controls = [...section.querySelectorAll<HTMLInputElement>(CONTROLS)];
|
|
30
|
+
for (const [i, el] of controls.entries()) {
|
|
31
|
+
if (el.type === "hidden" || el.disabled) continue;
|
|
32
|
+
if (!el.checkValidity()) {
|
|
33
|
+
if (el.getAttribute("aria-hidden") === "true") {
|
|
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();
|
|
40
|
+
return el.validationMessage || "ค่าไม่ถูกต้อง";
|
|
41
|
+
}
|
|
42
|
+
el.reportValidity();
|
|
43
|
+
el.focus();
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* ฟอร์มหลายขั้น (wizard) สำเร็จรูป — แถบขั้นตอน + เนื้อหาทีละขั้น + ปุ่มย้อนกลับ/ถัดไป/ส่ง ในตัว
|
|
52
|
+
* ทุกขั้นอยู่ใน `<form>` เดียว (ขั้นที่ไม่แสดงถูกซ่อนไว้ ค่ายังอยู่) → `onSubmit(formData)` ได้ค่าครบทุกขั้น ส่งต่อ server action ได้ตรง ๆ
|
|
53
|
+
* กด "ถัดไป" ตรวจ required/pattern ของขั้นนั้น + `validate` ก่อนไปต่อ; ขั้นที่ผ่านแล้วกดที่แถบเพื่อย้อนกลับได้
|
|
54
|
+
* ขั้นสุดท้ายใช้เป็นหน้าตรวจทานได้ (content อ่านค่าจาก state ของแอปเอง)
|
|
55
|
+
*
|
|
56
|
+
* <StepForm onSubmit={createApp} submitLabel="สร้างแอป" steps={[
|
|
57
|
+
* { id: "basics", title: "ข้อมูลพื้นฐาน", content: <LabeledField label="ชื่อ"><Input name="name" required /></LabeledField> },
|
|
58
|
+
* { id: "oauth", title: "OAuth", content: <TagsInput name="redirectUris" /> ,
|
|
59
|
+
* validate: (fd) => (fd.getAll("redirectUris").length ? null : "ต้องมี redirect URI อย่างน้อย 1 รายการ") },
|
|
60
|
+
* ]} />
|
|
61
|
+
*/
|
|
62
|
+
export function StepForm({
|
|
63
|
+
steps,
|
|
64
|
+
onSubmit,
|
|
65
|
+
submitLabel,
|
|
66
|
+
initialStep = 0,
|
|
67
|
+
onStepChange,
|
|
68
|
+
footer,
|
|
69
|
+
className,
|
|
70
|
+
}: {
|
|
71
|
+
steps: ReadonlyArray<StepFormStep>;
|
|
72
|
+
/** เรียกเมื่อกดส่งที่ขั้นสุดท้าย — throw / reject = แสดงข้อความ error ใต้ฟอร์ม */
|
|
73
|
+
onSubmit: (formData: FormData) => unknown | Promise<unknown>;
|
|
74
|
+
submitLabel?: ReactNode;
|
|
75
|
+
initialStep?: number;
|
|
76
|
+
onStepChange?: (index: number, id: string) => void;
|
|
77
|
+
/** ต่อท้ายแถวปุ่มฝั่งซ้าย เช่น ปุ่มยกเลิก */
|
|
78
|
+
footer?: ReactNode;
|
|
79
|
+
className?: string;
|
|
80
|
+
}) {
|
|
81
|
+
const ui = useUiText();
|
|
82
|
+
const text = ui.stepForm;
|
|
83
|
+
const [current, setCurrent] = useState(initialStep);
|
|
84
|
+
const [reached, setReached] = useState(initialStep);
|
|
85
|
+
const [error, setError] = useState<string | null>(null);
|
|
86
|
+
const [pending, setPending] = useState(false);
|
|
87
|
+
const formRef = useRef<HTMLFormElement>(null);
|
|
88
|
+
const sectionRefs = useRef<(HTMLElement | null)[]>([]);
|
|
89
|
+
const headingRefs = useRef<(HTMLHeadingElement | null)[]>([]);
|
|
90
|
+
const moved = useRef(false);
|
|
91
|
+
/** กันส่งซ้ำแบบ synchronous (state pending อัปเดตไม่ทันดับเบิลคลิก) */
|
|
92
|
+
const busy = useRef(false);
|
|
93
|
+
/** เวลาที่เปลี่ยนขั้นล่าสุด — คลิกที่สองของดับเบิลคลิก "ถัดไป" ตกที่ปุ่ม "ส่ง" ตำแหน่งเดิม */
|
|
94
|
+
const movedAt = useRef(0);
|
|
95
|
+
const last = steps.length - 1;
|
|
96
|
+
const step = steps[current];
|
|
97
|
+
|
|
98
|
+
// ย้ายโฟกัสไปหัวข้อของขั้นใหม่ (screen reader รู้ว่าเปลี่ยนขั้น) — ไม่ทำตอน mount
|
|
99
|
+
useEffect(() => {
|
|
100
|
+
if (!moved.current) return;
|
|
101
|
+
headingRefs.current[current]?.focus();
|
|
102
|
+
}, [current]);
|
|
103
|
+
|
|
104
|
+
const go = (index: number) => {
|
|
105
|
+
moved.current = true;
|
|
106
|
+
movedAt.current = Date.now();
|
|
107
|
+
setError(null);
|
|
108
|
+
setCurrent(index);
|
|
109
|
+
setReached((r) => Math.max(r, index));
|
|
110
|
+
const target = steps[index];
|
|
111
|
+
if (target) onStepChange?.(index, target.id);
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
/** ตรวจขั้น index — คืน true ถ้าผ่าน */
|
|
115
|
+
const check = async (index: number): Promise<boolean> => {
|
|
116
|
+
const native = reportSection(sectionRefs.current[index] ?? null);
|
|
117
|
+
if (native !== true) {
|
|
118
|
+
if (typeof native === "string") setError(native);
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
const s = steps[index];
|
|
122
|
+
if (s?.validate && formRef.current) {
|
|
123
|
+
const message = await s.validate(new FormData(formRef.current));
|
|
124
|
+
if (message) {
|
|
125
|
+
setError(message);
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return true;
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
|
|
133
|
+
e.preventDefault();
|
|
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 () => {
|
|
146
|
+
setError(null);
|
|
147
|
+
if (current < last) {
|
|
148
|
+
if (await check(current)) go(current + 1);
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
// ขั้นสุดท้าย: ตรวจทุกขั้นอีกครั้งแบบเงียบ ขั้นไหนไม่ผ่านพากลับไปขั้นนั้นแล้วค่อยแสดงจุดที่ผิด
|
|
152
|
+
for (let i = 0; i <= last; i++) {
|
|
153
|
+
const section = sectionRefs.current[i];
|
|
154
|
+
const invalid = section
|
|
155
|
+
? [...section.querySelectorAll<HTMLInputElement>(CONTROLS)].some(
|
|
156
|
+
(el) => el.type !== "hidden" && !el.disabled && !el.checkValidity(),
|
|
157
|
+
)
|
|
158
|
+
: false;
|
|
159
|
+
const s = steps[i];
|
|
160
|
+
const message =
|
|
161
|
+
!invalid && s?.validate && formRef.current
|
|
162
|
+
? await s.validate(new FormData(formRef.current))
|
|
163
|
+
: null;
|
|
164
|
+
if (invalid || message) {
|
|
165
|
+
if (i !== current) go(i);
|
|
166
|
+
if (message) setError(message);
|
|
167
|
+
else
|
|
168
|
+
requestAnimationFrame(() => {
|
|
169
|
+
const native = reportSection(sectionRefs.current[i] ?? null);
|
|
170
|
+
if (typeof native === "string") setError(native);
|
|
171
|
+
});
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
if (!formRef.current) return;
|
|
176
|
+
setPending(true);
|
|
177
|
+
try {
|
|
178
|
+
await onSubmit(new FormData(formRef.current));
|
|
179
|
+
} catch (err) {
|
|
180
|
+
setError(
|
|
181
|
+
err instanceof Error && err.message ? err.message : (ui.actionFailed ?? "ดำเนินการไม่สำเร็จ"),
|
|
182
|
+
);
|
|
183
|
+
} finally {
|
|
184
|
+
setPending(false);
|
|
185
|
+
}
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
return (
|
|
189
|
+
<form
|
|
190
|
+
ref={formRef}
|
|
191
|
+
noValidate
|
|
192
|
+
onSubmit={handleSubmit}
|
|
193
|
+
data-slot="step-form"
|
|
194
|
+
className={cn("flex flex-col gap-6", className)}
|
|
195
|
+
>
|
|
196
|
+
<nav aria-label={text?.label ?? "ขั้นตอน"}>
|
|
197
|
+
<p className="mb-2 text-muted-foreground text-xs sm:hidden">
|
|
198
|
+
{(text?.progress ?? "ขั้นที่ {current} จาก {total}")
|
|
199
|
+
.replace("{current}", String(current + 1))
|
|
200
|
+
.replace("{total}", String(steps.length))}
|
|
201
|
+
</p>
|
|
202
|
+
<ol className="flex items-center gap-2">
|
|
203
|
+
{steps.map((s, i) => {
|
|
204
|
+
const done = i < reached;
|
|
205
|
+
const active = i === current;
|
|
206
|
+
const clickable = i <= reached && !active && !pending;
|
|
207
|
+
return (
|
|
208
|
+
<li
|
|
209
|
+
key={s.id}
|
|
210
|
+
className={cn("flex min-w-0 items-center gap-2", i < last && "flex-1")}
|
|
211
|
+
>
|
|
212
|
+
<button
|
|
213
|
+
type="button"
|
|
214
|
+
disabled={!clickable}
|
|
215
|
+
aria-current={active ? "step" : undefined}
|
|
216
|
+
onClick={() => go(i)}
|
|
217
|
+
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"
|
|
218
|
+
>
|
|
219
|
+
<span
|
|
220
|
+
className={cn(
|
|
221
|
+
"flex size-7 shrink-0 items-center justify-center rounded-full border text-sm tabular-nums transition-colors",
|
|
222
|
+
active && "border-primary bg-primary text-primary-foreground",
|
|
223
|
+
!active && done && "border-primary text-primary",
|
|
224
|
+
!active && !done && "text-muted-foreground",
|
|
225
|
+
)}
|
|
226
|
+
>
|
|
227
|
+
{!active && done ? <CheckIcon className="size-4" /> : i + 1}
|
|
228
|
+
</span>
|
|
229
|
+
<span
|
|
230
|
+
className={cn(
|
|
231
|
+
"hidden truncate text-sm sm:inline",
|
|
232
|
+
active ? "font-medium" : "text-muted-foreground",
|
|
233
|
+
)}
|
|
234
|
+
>
|
|
235
|
+
{s.title}
|
|
236
|
+
</span>
|
|
237
|
+
</button>
|
|
238
|
+
{i < last && (
|
|
239
|
+
<span
|
|
240
|
+
aria-hidden
|
|
241
|
+
className={cn("h-px min-w-4 flex-1", i < reached ? "bg-primary" : "bg-border")}
|
|
242
|
+
/>
|
|
243
|
+
)}
|
|
244
|
+
</li>
|
|
245
|
+
);
|
|
246
|
+
})}
|
|
247
|
+
</ol>
|
|
248
|
+
</nav>
|
|
249
|
+
|
|
250
|
+
{steps.map((s, i) => (
|
|
251
|
+
<section
|
|
252
|
+
key={s.id}
|
|
253
|
+
ref={(el) => {
|
|
254
|
+
sectionRefs.current[i] = el;
|
|
255
|
+
}}
|
|
256
|
+
hidden={i !== current}
|
|
257
|
+
aria-labelledby={`${s.id}-step-title`}
|
|
258
|
+
className="flex flex-col gap-4"
|
|
259
|
+
>
|
|
260
|
+
<div className="flex flex-col gap-1">
|
|
261
|
+
<h2
|
|
262
|
+
id={`${s.id}-step-title`}
|
|
263
|
+
ref={(el) => {
|
|
264
|
+
headingRefs.current[i] = el;
|
|
265
|
+
}}
|
|
266
|
+
tabIndex={-1}
|
|
267
|
+
className="font-semibold text-base outline-none"
|
|
268
|
+
>
|
|
269
|
+
{s.title}
|
|
270
|
+
</h2>
|
|
271
|
+
{s.description && <p className="text-muted-foreground text-sm">{s.description}</p>}
|
|
272
|
+
</div>
|
|
273
|
+
{s.content}
|
|
274
|
+
</section>
|
|
275
|
+
))}
|
|
276
|
+
|
|
277
|
+
{error && (
|
|
278
|
+
<p role="alert" className="text-destructive text-sm">
|
|
279
|
+
{error}
|
|
280
|
+
</p>
|
|
281
|
+
)}
|
|
282
|
+
|
|
283
|
+
<div className="flex flex-wrap items-center gap-2 border-t pt-4">
|
|
284
|
+
{footer}
|
|
285
|
+
<div className="ml-auto flex gap-2">
|
|
286
|
+
{current > 0 && (
|
|
287
|
+
<Button
|
|
288
|
+
type="button"
|
|
289
|
+
variant="outline"
|
|
290
|
+
disabled={pending}
|
|
291
|
+
onClick={() => go(current - 1)}
|
|
292
|
+
>
|
|
293
|
+
{text?.previous ?? "ย้อนกลับ"}
|
|
294
|
+
</Button>
|
|
295
|
+
)}
|
|
296
|
+
<Button type="submit" disabled={pending || !step}>
|
|
297
|
+
{pending && <Spinner />}
|
|
298
|
+
{current < last ? (text?.next ?? "ถัดไป") : (submitLabel ?? text?.submit ?? "ส่ง")}
|
|
299
|
+
</Button>
|
|
300
|
+
</div>
|
|
301
|
+
</div>
|
|
302
|
+
</form>
|
|
303
|
+
);
|
|
304
|
+
}
|
|
@@ -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);
|
|
@@ -89,6 +89,28 @@ export interface UiText {
|
|
|
89
89
|
presence?: Partial<{ online: string; away: string; busy: string; offline: string }>;
|
|
90
90
|
/** TextareaField (optional) — ข้อความ screen reader ของตัวนับ ใช้ {count} {max} */
|
|
91
91
|
characterCount?: string;
|
|
92
|
+
/** NumberInput (optional) */
|
|
93
|
+
number?: Partial<{ increment: string; decrement: string }>;
|
|
94
|
+
/** TimePicker / DateTimePicker (optional) */
|
|
95
|
+
time?: Partial<{
|
|
96
|
+
choose: string;
|
|
97
|
+
hour: string;
|
|
98
|
+
minute: string;
|
|
99
|
+
now: string;
|
|
100
|
+
clear: string;
|
|
101
|
+
dateTimePlaceholder: string;
|
|
102
|
+
invalid: string;
|
|
103
|
+
}>;
|
|
104
|
+
/** TagsInput (optional) — remove ใช้ {tag}, duplicate ใช้ {tag}, max ใช้ {max} */
|
|
105
|
+
tags?: Partial<{ placeholder: string; remove: string; duplicate: string; max: string }>;
|
|
106
|
+
/** StepForm (optional) — progress ใช้ {current} {total} */
|
|
107
|
+
stepForm?: Partial<{
|
|
108
|
+
label: string;
|
|
109
|
+
previous: string;
|
|
110
|
+
next: string;
|
|
111
|
+
submit: string;
|
|
112
|
+
progress: string;
|
|
113
|
+
}>;
|
|
92
114
|
}
|
|
93
115
|
|
|
94
116
|
export const UI_TEXT_TH: UiText = {
|
|
@@ -156,6 +178,29 @@ export const UI_TEXT_TH: UiText = {
|
|
|
156
178
|
loading: "กำลังโหลด…",
|
|
157
179
|
presence: { online: "ออนไลน์", away: "ไม่อยู่", busy: "ไม่ว่าง", offline: "ออฟไลน์" },
|
|
158
180
|
characterCount: "พิมพ์แล้ว {count} จาก {max} ตัวอักษร",
|
|
181
|
+
number: { increment: "เพิ่ม", decrement: "ลด" },
|
|
182
|
+
time: {
|
|
183
|
+
choose: "เลือกเวลา",
|
|
184
|
+
hour: "ชั่วโมง",
|
|
185
|
+
minute: "นาที",
|
|
186
|
+
now: "ตอนนี้",
|
|
187
|
+
clear: "ล้าง",
|
|
188
|
+
dateTimePlaceholder: "เลือกวันและเวลา",
|
|
189
|
+
invalid: "รูปแบบเวลาไม่ถูกต้อง (เช่น 14:30)",
|
|
190
|
+
},
|
|
191
|
+
tags: {
|
|
192
|
+
placeholder: "พิมพ์แล้วกด Enter",
|
|
193
|
+
remove: "ลบ {tag}",
|
|
194
|
+
duplicate: "มี “{tag}” แล้ว",
|
|
195
|
+
max: "เพิ่มได้สูงสุด {max} รายการ",
|
|
196
|
+
},
|
|
197
|
+
stepForm: {
|
|
198
|
+
label: "ขั้นตอน",
|
|
199
|
+
previous: "ย้อนกลับ",
|
|
200
|
+
next: "ถัดไป",
|
|
201
|
+
submit: "ส่ง",
|
|
202
|
+
progress: "ขั้นที่ {current} จาก {total}",
|
|
203
|
+
},
|
|
159
204
|
};
|
|
160
205
|
|
|
161
206
|
export const UI_TEXT_EN: UiText = {
|
|
@@ -223,6 +268,29 @@ export const UI_TEXT_EN: UiText = {
|
|
|
223
268
|
loading: "Loading…",
|
|
224
269
|
presence: { online: "Online", away: "Away", busy: "Busy", offline: "Offline" },
|
|
225
270
|
characterCount: "{count} of {max} characters",
|
|
271
|
+
number: { increment: "Increase", decrement: "Decrease" },
|
|
272
|
+
time: {
|
|
273
|
+
choose: "Choose time",
|
|
274
|
+
hour: "Hour",
|
|
275
|
+
minute: "Minute",
|
|
276
|
+
now: "Now",
|
|
277
|
+
clear: "Clear",
|
|
278
|
+
dateTimePlaceholder: "Pick date and time",
|
|
279
|
+
invalid: "Invalid time (e.g. 14:30)",
|
|
280
|
+
},
|
|
281
|
+
tags: {
|
|
282
|
+
placeholder: "Type and press Enter",
|
|
283
|
+
remove: "Remove {tag}",
|
|
284
|
+
duplicate: "“{tag}” is already added",
|
|
285
|
+
max: "Up to {max} items",
|
|
286
|
+
},
|
|
287
|
+
stepForm: {
|
|
288
|
+
label: "Steps",
|
|
289
|
+
previous: "Back",
|
|
290
|
+
next: "Next",
|
|
291
|
+
submit: "Submit",
|
|
292
|
+
progress: "Step {current} of {total}",
|
|
293
|
+
},
|
|
226
294
|
};
|
|
227
295
|
|
|
228
296
|
const UiTextContext = createContext<UiText>(UI_TEXT_TH);
|
package/src/data-table.ts
CHANGED
|
@@ -7,7 +7,18 @@
|
|
|
7
7
|
* ```
|
|
8
8
|
* state ของตารางใน URL (`useTableSearchParams`) ต้องใช้ next/navigation → อยู่ที่ `@suphark/ui/next`
|
|
9
9
|
*/
|
|
10
|
-
export
|
|
10
|
+
export {
|
|
11
|
+
DataTable,
|
|
12
|
+
DataTableColumnHeader,
|
|
13
|
+
DataTableDateRangeFilter,
|
|
14
|
+
DataTableFacetedFilter,
|
|
15
|
+
DataTablePagination,
|
|
16
|
+
type DataTableProps,
|
|
17
|
+
DataTableToolbar,
|
|
18
|
+
DataTableViewOptions,
|
|
19
|
+
type FacetedFilterConfig,
|
|
20
|
+
useDataTable,
|
|
21
|
+
} from "./components/data-table";
|
|
11
22
|
export { DataTableRowActions } from "./components/data-table/data-table-row-actions";
|
|
12
23
|
export { DeleteManyButton } from "./components/shared/button/delete-many-button";
|
|
13
24
|
export {
|
|
@@ -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
|
}
|