@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
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
} from "@suphark/ui/components/ui/dropdown-menu";
|
|
14
14
|
import { MoreHorizontal } from "lucide-react";
|
|
15
15
|
import { type ReactElement, type ReactNode, useState } from "react";
|
|
16
|
+
import { toast } from "sonner";
|
|
16
17
|
import { ConfirmDialog, type ConfirmSpec, normalizeConfirm } from "./confirm-dialog";
|
|
17
18
|
import { useUiText } from "./ui-text";
|
|
18
19
|
|
|
@@ -64,16 +65,32 @@ export function ActionMenu({
|
|
|
64
65
|
const [confirming, setConfirming] = useState<ActionMenuCommand | null>(null);
|
|
65
66
|
const [pending, setPending] = useState(false);
|
|
66
67
|
|
|
67
|
-
|
|
68
|
-
|
|
68
|
+
/** คืน true เมื่อสำเร็จ — throw/reject = toast แจ้ง (ไม่ปล่อยเป็น unhandled rejection เงียบ ๆ) */
|
|
69
|
+
const run = async (fn?: () => unknown): Promise<boolean> => {
|
|
70
|
+
if (!fn) return true;
|
|
69
71
|
setPending(true);
|
|
70
72
|
try {
|
|
71
73
|
await fn();
|
|
74
|
+
return true;
|
|
75
|
+
} catch (err) {
|
|
76
|
+
toast.error(
|
|
77
|
+
err instanceof Error && err.message
|
|
78
|
+
? err.message
|
|
79
|
+
: (text.actionFailed ?? "ดำเนินการไม่สำเร็จ"),
|
|
80
|
+
);
|
|
81
|
+
return false;
|
|
72
82
|
} finally {
|
|
73
83
|
setPending(false);
|
|
74
84
|
}
|
|
75
85
|
};
|
|
76
86
|
|
|
87
|
+
/** ลิงก์ที่มี confirm: ไปหลังยืนยันแล้วเท่านั้น */
|
|
88
|
+
const follow = (item: ActionMenuCommand) => {
|
|
89
|
+
if (!item.href) return;
|
|
90
|
+
if (item.external) window.open(item.href, "_blank", "noopener,noreferrer");
|
|
91
|
+
else window.location.assign(item.href);
|
|
92
|
+
};
|
|
93
|
+
|
|
77
94
|
const spec = confirming?.confirm ? normalizeConfirm(confirming.confirm) : null;
|
|
78
95
|
|
|
79
96
|
return (
|
|
@@ -113,7 +130,8 @@ export function ActionMenu({
|
|
|
113
130
|
variant={item.destructive ? "destructive" : "default"}
|
|
114
131
|
disabled={item.disabled}
|
|
115
132
|
render={
|
|
116
|
-
|
|
133
|
+
// มี confirm = ยังไม่เป็นลิงก์จริง (กดแล้วถามก่อน แล้วค่อยไป)
|
|
134
|
+
item.href && !item.confirm ? (
|
|
117
135
|
<a
|
|
118
136
|
href={item.href}
|
|
119
137
|
target={item.external ? "_blank" : undefined}
|
|
@@ -122,9 +140,8 @@ export function ActionMenu({
|
|
|
122
140
|
) : undefined
|
|
123
141
|
}
|
|
124
142
|
onClick={() => {
|
|
125
|
-
if (item.href) return;
|
|
126
143
|
if (item.confirm) setConfirming(item);
|
|
127
|
-
else void run(item.onClick);
|
|
144
|
+
else if (!item.href) void run(item.onClick);
|
|
128
145
|
}}
|
|
129
146
|
>
|
|
130
147
|
{content}
|
|
@@ -147,8 +164,10 @@ export function ActionMenu({
|
|
|
147
164
|
destructive={spec.destructive ?? confirming.destructive}
|
|
148
165
|
pending={pending}
|
|
149
166
|
onConfirm={async () => {
|
|
150
|
-
|
|
167
|
+
// ล้มเหลว = คง dialog ไว้ให้ลองใหม่ (toast แจ้งแล้วใน run)
|
|
168
|
+
if (!(await run(confirming.onClick))) return;
|
|
151
169
|
setConfirming(null);
|
|
170
|
+
follow(confirming);
|
|
152
171
|
}}
|
|
153
172
|
/>
|
|
154
173
|
)}
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
InputGroupButton,
|
|
8
8
|
InputGroupInput,
|
|
9
9
|
} from "@suphark/ui/components/ui/input-group";
|
|
10
|
+
import { Spinner } from "@suphark/ui/components/ui/spinner";
|
|
10
11
|
import { cn } from "@suphark/ui/lib/utils";
|
|
11
12
|
import { EyeIcon, EyeOffIcon } from "lucide-react";
|
|
12
13
|
import { type ComponentProps, type ReactNode, useState } from "react";
|
|
@@ -73,9 +74,10 @@ export function ProviderButtons({
|
|
|
73
74
|
p.label,
|
|
74
75
|
);
|
|
75
76
|
const label = p.text ?? (grid ? p.label : full);
|
|
77
|
+
const busy = pendingId === p.id;
|
|
76
78
|
const content = (
|
|
77
79
|
<>
|
|
78
|
-
{p.icon}
|
|
80
|
+
{busy ? <Spinner /> : p.icon}
|
|
79
81
|
<span className={grid ? "truncate" : "min-w-0 text-balance"}>{label}</span>
|
|
80
82
|
{p.badge &&
|
|
81
83
|
(badgePlacement === "corner" ? (
|
|
@@ -97,14 +99,14 @@ export function ProviderButtons({
|
|
|
97
99
|
p.badge && badgePlacement === "corner" && "relative",
|
|
98
100
|
);
|
|
99
101
|
const a11y = grid ? { "aria-label": full, title: full } : {};
|
|
100
|
-
const busy = pendingId === p.id;
|
|
101
102
|
const disabled = p.disabled || (pendingId != null && !busy);
|
|
102
103
|
return p.href ? (
|
|
103
104
|
<Button
|
|
104
105
|
key={p.id}
|
|
105
106
|
variant="outline"
|
|
106
107
|
className={buttonClass}
|
|
107
|
-
disabled={disabled}
|
|
108
|
+
disabled={disabled || busy}
|
|
109
|
+
aria-busy={busy || undefined}
|
|
108
110
|
nativeButton={false}
|
|
109
111
|
render={<a href={p.href} {...a11y} />}
|
|
110
112
|
>
|
|
@@ -19,6 +19,15 @@ export function errorMessage(err: unknown, fallback: string): string {
|
|
|
19
19
|
return err instanceof Error && err.message ? err.message : fallback;
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
/** action คืน `FormState` แบบ `{ ok: false, error }` (ไม่ได้ throw) = ล้มเหลว → ข้อความ error; อย่างอื่น = null */
|
|
23
|
+
export function failedResult(result: unknown, fallback: string): string | null {
|
|
24
|
+
if (result && typeof result === "object" && "ok" in result && result.ok === false) {
|
|
25
|
+
const error = "error" in result ? result.error : undefined;
|
|
26
|
+
return typeof error === "string" && error ? error : fallback;
|
|
27
|
+
}
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
|
|
22
31
|
/**
|
|
23
32
|
* ปุ่มเดี่ยวที่ถามยืนยัน (ConfirmDialog) ก่อนเรียก server action — ใช้แทน `<form action>` + window.confirm
|
|
24
33
|
* `confirm` เหมือน `RowAction.confirm`; `action` + `data` เหมือน RowAction; refresh หน้าเมื่อเสร็จ
|
|
@@ -74,11 +83,16 @@ export function ConfirmActionButton({
|
|
|
74
83
|
const fd = new FormData();
|
|
75
84
|
for (const [k, v] of Object.entries(data ?? {})) fd.set(k, v);
|
|
76
85
|
start(async () => {
|
|
77
|
-
// action โยน error → แจ้งด้วย toast แล้วคง dialog ไว้ให้ลองใหม่ (ไม่ปล่อยขึ้น error boundary ทั้งหน้า)
|
|
86
|
+
// action โยน error หรือคืน { ok: false } → แจ้งด้วย toast แล้วคง dialog ไว้ให้ลองใหม่ (ไม่ปล่อยขึ้น error boundary ทั้งหน้า)
|
|
87
|
+
const fallback = text.actionFailed ?? "ดำเนินการไม่สำเร็จ";
|
|
78
88
|
try {
|
|
79
|
-
await action(fd);
|
|
89
|
+
const failed = failedResult(await action(fd), fallback);
|
|
90
|
+
if (failed) {
|
|
91
|
+
toast.error(failed);
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
80
94
|
} catch (err) {
|
|
81
|
-
toast.error(errorMessage(err,
|
|
95
|
+
toast.error(errorMessage(err, fallback));
|
|
82
96
|
return;
|
|
83
97
|
}
|
|
84
98
|
onDone?.();
|
|
@@ -27,6 +27,7 @@ import { Spinner } from "@suphark/ui/components/ui/spinner";
|
|
|
27
27
|
import { cn } from "@suphark/ui/lib/utils";
|
|
28
28
|
import { ArrowDown, SendHorizontal } from "lucide-react";
|
|
29
29
|
import { type ReactNode, useState } from "react";
|
|
30
|
+
import { toast } from "sonner";
|
|
30
31
|
import { useUiText } from "./ui-text";
|
|
31
32
|
|
|
32
33
|
export interface ChatMessage {
|
|
@@ -150,6 +151,7 @@ function ChatComposer({
|
|
|
150
151
|
}) {
|
|
151
152
|
const [value, setValue] = useState("");
|
|
152
153
|
const [pending, setPending] = useState(false);
|
|
154
|
+
const failedText = useUiText().actionFailed;
|
|
153
155
|
const canSend = value.trim() !== "" && !disabled && !pending;
|
|
154
156
|
|
|
155
157
|
const send = async () => {
|
|
@@ -158,6 +160,11 @@ function ChatComposer({
|
|
|
158
160
|
try {
|
|
159
161
|
await onSend(value.trim());
|
|
160
162
|
setValue("");
|
|
163
|
+
} catch (err) {
|
|
164
|
+
// ส่งไม่สำเร็จ: คงข้อความไว้ในช่องให้ลองใหม่ + แจ้งผู้ใช้ (เดิมเงียบ — unhandled rejection)
|
|
165
|
+
toast.error(
|
|
166
|
+
err instanceof Error && err.message ? err.message : (failedText ?? "ดำเนินการไม่สำเร็จ"),
|
|
167
|
+
);
|
|
161
168
|
} finally {
|
|
162
169
|
setPending(false);
|
|
163
170
|
}
|
|
@@ -2,9 +2,24 @@
|
|
|
2
2
|
|
|
3
3
|
import { useEffect } from "react";
|
|
4
4
|
|
|
5
|
+
/**
|
|
6
|
+
* redirect ฝั่ง browser (`location.replace`) — รับเฉพาะ http(s) และ path ภายใน
|
|
7
|
+
* `javascript:`/`data:` ถูกปฏิเสธ (location.replace ไม่มีตัวกันแบบ `<a href>`/router ของ Next)
|
|
8
|
+
* ถ้า `to` มาจาก URL/ผู้ใช้ แอปต้องเช็กเองว่าเป็นปลายทางที่อนุญาต (กัน open redirect ไปโดเมนอื่น)
|
|
9
|
+
*/
|
|
5
10
|
export function ClientRedirect({ to }: { to: string }) {
|
|
6
11
|
useEffect(() => {
|
|
7
|
-
|
|
12
|
+
let url: URL;
|
|
13
|
+
try {
|
|
14
|
+
url = new URL(to, window.location.href);
|
|
15
|
+
} catch {
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
19
|
+
console.error(`ClientRedirect: ปฏิเสธปลายทางที่ไม่ใช่ http(s): ${url.protocol}`);
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
window.location.replace(url.href);
|
|
8
23
|
}, [to]);
|
|
9
24
|
|
|
10
25
|
return null;
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Button } from "@suphark/ui/components/ui/button";
|
|
4
|
+
import { Calendar } from "@suphark/ui/components/ui/calendar";
|
|
5
|
+
import { Popover, PopoverContent, PopoverTrigger } from "@suphark/ui/components/ui/popover";
|
|
6
|
+
import { useMounted } from "@suphark/ui/hooks/use-mounted";
|
|
7
|
+
import { cn } from "@suphark/ui/lib/utils";
|
|
8
|
+
import { CalendarClockIcon } from "lucide-react";
|
|
9
|
+
import { useState } from "react";
|
|
10
|
+
import { th } from "react-day-picker/locale/th";
|
|
11
|
+
import { useUiText } from "../ui-text";
|
|
12
|
+
import {
|
|
13
|
+
pad,
|
|
14
|
+
splitTime,
|
|
15
|
+
TimeColumns,
|
|
16
|
+
type TimeValue,
|
|
17
|
+
toLocalDateTimeString,
|
|
18
|
+
useControllable,
|
|
19
|
+
} from "./time-picker";
|
|
20
|
+
|
|
21
|
+
/** "YYYY-MM-DDTHH:mm+07:00" — เวลาท้องถิ่นพร้อม offset (ISO 8601) ให้ server ที่อยู่โซนอื่น parse ได้ถูก */
|
|
22
|
+
function toOffsetDateTimeString(date: Date): string {
|
|
23
|
+
const offset = -date.getTimezoneOffset();
|
|
24
|
+
const sign = offset >= 0 ? "+" : "-";
|
|
25
|
+
const abs = Math.abs(offset);
|
|
26
|
+
return `${toLocalDateTimeString(date)}${sign}${pad(Math.floor(abs / 60))}:${pad(abs % 60)}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* เลือกวัน + เวลาในกล่องเดียว (ปฏิทิน + คอลัมน์ชั่วโมง/นาที) — นัดหมาย, กำหนดส่ง, เวลาเตือน
|
|
31
|
+
* ค่าเป็น `Date | null`; เลือกวันก่อนเวลา = ใช้ `defaultTime`; `name` ส่ง "YYYY-MM-DDTHH:mm+07:00" (เวลาท้องถิ่น + offset)
|
|
32
|
+
* ใน FormData → `new Date(formData.get(name))` บน server (UTC) ได้เวลาที่ถูกต้อง
|
|
33
|
+
* ข้อความวัน/เวลาแสดงหลัง hydrate (server กับ browser อยู่คนละโซนเวลา — กัน hydration mismatch)
|
|
34
|
+
*
|
|
35
|
+
* <DateTimePicker value={dueAt} onValueChange={setDueAt} minuteStep={15} />
|
|
36
|
+
*/
|
|
37
|
+
export function DateTimePicker({
|
|
38
|
+
value,
|
|
39
|
+
defaultValue = null,
|
|
40
|
+
onValueChange,
|
|
41
|
+
minuteStep = 5,
|
|
42
|
+
defaultTime = "09:00",
|
|
43
|
+
name,
|
|
44
|
+
id,
|
|
45
|
+
disabled,
|
|
46
|
+
placeholder,
|
|
47
|
+
className,
|
|
48
|
+
"aria-label": ariaLabel,
|
|
49
|
+
"aria-invalid": ariaInvalid,
|
|
50
|
+
}: {
|
|
51
|
+
value?: Date | null;
|
|
52
|
+
defaultValue?: Date | null;
|
|
53
|
+
onValueChange?: (value: Date | null) => void;
|
|
54
|
+
minuteStep?: number;
|
|
55
|
+
/** เวลาที่ใช้เมื่อเลือกวันครั้งแรก */
|
|
56
|
+
defaultTime?: TimeValue;
|
|
57
|
+
name?: string;
|
|
58
|
+
id?: string;
|
|
59
|
+
disabled?: boolean;
|
|
60
|
+
placeholder?: string;
|
|
61
|
+
className?: string;
|
|
62
|
+
"aria-label"?: string;
|
|
63
|
+
"aria-invalid"?: boolean;
|
|
64
|
+
}) {
|
|
65
|
+
const ui = useUiText();
|
|
66
|
+
const text = ui.time;
|
|
67
|
+
const [date, setDate] = useControllable<Date | null>(value, defaultValue, onValueChange);
|
|
68
|
+
const [open, setOpen] = useState(false);
|
|
69
|
+
const mounted = useMounted();
|
|
70
|
+
const time = date ? `${pad(date.getHours())}:${pad(date.getMinutes())}` : null;
|
|
71
|
+
|
|
72
|
+
const withTime = (day: Date, t: TimeValue) => {
|
|
73
|
+
const [h, m] = splitTime(t) ?? [9, 0];
|
|
74
|
+
const d = new Date(day);
|
|
75
|
+
d.setHours(h, m, 0, 0);
|
|
76
|
+
return d;
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const label = !date
|
|
80
|
+
? (placeholder ?? text?.dateTimePlaceholder ?? "เลือกวันและเวลา")
|
|
81
|
+
: !mounted
|
|
82
|
+
? " " // ก่อน hydrate: เว้นที่ไว้ ไม่แสดงเวลาของ server
|
|
83
|
+
: date.toLocaleString("th-TH", { dateStyle: "medium", timeStyle: "short" });
|
|
84
|
+
|
|
85
|
+
return (
|
|
86
|
+
<Popover open={open} onOpenChange={setOpen}>
|
|
87
|
+
<PopoverTrigger
|
|
88
|
+
render={
|
|
89
|
+
<Button
|
|
90
|
+
id={id}
|
|
91
|
+
type="button"
|
|
92
|
+
variant="outline"
|
|
93
|
+
disabled={disabled}
|
|
94
|
+
aria-label={ariaLabel}
|
|
95
|
+
aria-invalid={ariaInvalid || undefined}
|
|
96
|
+
data-slot="date-time-picker"
|
|
97
|
+
className={cn(
|
|
98
|
+
"w-full justify-start gap-2 font-normal",
|
|
99
|
+
!date && "text-muted-foreground",
|
|
100
|
+
className,
|
|
101
|
+
)}
|
|
102
|
+
/>
|
|
103
|
+
}
|
|
104
|
+
>
|
|
105
|
+
<CalendarClockIcon className="text-muted-foreground" />
|
|
106
|
+
<span className="truncate">{label}</span>
|
|
107
|
+
</PopoverTrigger>
|
|
108
|
+
{name && (
|
|
109
|
+
<input
|
|
110
|
+
type="hidden"
|
|
111
|
+
name={name}
|
|
112
|
+
value={date && mounted ? toOffsetDateTimeString(date) : ""}
|
|
113
|
+
/>
|
|
114
|
+
)}
|
|
115
|
+
<PopoverContent align="start" className="w-auto gap-0 p-0">
|
|
116
|
+
<div className="flex flex-col sm:flex-row">
|
|
117
|
+
<Calendar
|
|
118
|
+
mode="single"
|
|
119
|
+
locale={th}
|
|
120
|
+
selected={date ?? undefined}
|
|
121
|
+
defaultMonth={date ?? undefined}
|
|
122
|
+
onSelect={(day) => day && setDate(withTime(day, time ?? defaultTime))}
|
|
123
|
+
className="p-3"
|
|
124
|
+
/>
|
|
125
|
+
<div className="flex justify-center border-t p-2 sm:border-t-0 sm:border-l">
|
|
126
|
+
<TimeColumns
|
|
127
|
+
value={time}
|
|
128
|
+
minuteStep={minuteStep}
|
|
129
|
+
onValueChange={(t) => setDate(withTime(date ?? new Date(), t))}
|
|
130
|
+
/>
|
|
131
|
+
</div>
|
|
132
|
+
</div>
|
|
133
|
+
<div className="flex justify-between gap-2 border-t p-2">
|
|
134
|
+
<Button
|
|
135
|
+
type="button"
|
|
136
|
+
size="xs"
|
|
137
|
+
variant="ghost"
|
|
138
|
+
disabled={!date}
|
|
139
|
+
onClick={() => setDate(null)}
|
|
140
|
+
>
|
|
141
|
+
{text?.clear ?? "ล้าง"}
|
|
142
|
+
</Button>
|
|
143
|
+
<Button type="button" size="xs" onClick={() => setOpen(false)}>
|
|
144
|
+
{ui.done}
|
|
145
|
+
</Button>
|
|
146
|
+
</div>
|
|
147
|
+
</PopoverContent>
|
|
148
|
+
</Popover>
|
|
149
|
+
);
|
|
150
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/** variant "autocomplete" ของ FormFieldInput — แยกไฟล์เพราะ cmdk หนัก โหลดเฉพาะเมื่อใช้ (React.lazy) */
|
|
4
|
+
import {
|
|
5
|
+
Command,
|
|
6
|
+
CommandEmpty,
|
|
7
|
+
CommandGroup,
|
|
8
|
+
CommandItem,
|
|
9
|
+
CommandList,
|
|
10
|
+
} from "@suphark/ui/components/ui/command";
|
|
11
|
+
import { cn } from "@suphark/ui/lib/utils";
|
|
12
|
+
import { Command as CommandPrimitive } from "cmdk";
|
|
13
|
+
import { Check } from "lucide-react";
|
|
14
|
+
import { useState } from "react";
|
|
15
|
+
import type { ControllerRenderProps, FieldValues, Path, PathValue } from "react-hook-form";
|
|
16
|
+
|
|
17
|
+
export type AutocompleteOption = {
|
|
18
|
+
value: string;
|
|
19
|
+
label: string;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export function AutocompleteInput<T extends FieldValues>({
|
|
23
|
+
field,
|
|
24
|
+
data,
|
|
25
|
+
placeholder,
|
|
26
|
+
disabled,
|
|
27
|
+
emptyMessage = "No results found.",
|
|
28
|
+
required,
|
|
29
|
+
}: {
|
|
30
|
+
field: ControllerRenderProps<T, Path<T>>;
|
|
31
|
+
data: AutocompleteOption[];
|
|
32
|
+
placeholder?: string;
|
|
33
|
+
disabled?: boolean;
|
|
34
|
+
emptyMessage?: string;
|
|
35
|
+
required?: boolean;
|
|
36
|
+
}) {
|
|
37
|
+
const [open, setOpen] = useState(false);
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<Command className="overflow-visible bg-transparent">
|
|
41
|
+
<div
|
|
42
|
+
className={cn(
|
|
43
|
+
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm ring-offset-background focus-within:border-ring focus-within:ring-1 focus-within:ring-ring",
|
|
44
|
+
disabled && "cursor-not-allowed opacity-50",
|
|
45
|
+
required && !field.value && "border-input",
|
|
46
|
+
)}
|
|
47
|
+
>
|
|
48
|
+
<CommandPrimitive.Input
|
|
49
|
+
value={field.value as string}
|
|
50
|
+
onValueChange={field.onChange}
|
|
51
|
+
onFocus={() => setOpen(true)}
|
|
52
|
+
onBlur={() => setOpen(false)}
|
|
53
|
+
placeholder={placeholder}
|
|
54
|
+
disabled={disabled}
|
|
55
|
+
className="w-full bg-transparent outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed"
|
|
56
|
+
/>
|
|
57
|
+
</div>
|
|
58
|
+
<div className="relative mt-1">
|
|
59
|
+
{open && field.value && (
|
|
60
|
+
<CommandList className="fade-in-0 zoom-in-95 absolute top-0 z-50 w-full animate-in rounded-md border bg-popover text-popover-foreground shadow-md outline-none">
|
|
61
|
+
<CommandEmpty className="py-2 text-center text-muted-foreground text-sm">
|
|
62
|
+
{emptyMessage}
|
|
63
|
+
</CommandEmpty>
|
|
64
|
+
<CommandGroup>
|
|
65
|
+
{data.map((option) => (
|
|
66
|
+
<CommandItem
|
|
67
|
+
key={option.value}
|
|
68
|
+
value={`${option.label} ${option.value}`}
|
|
69
|
+
onSelect={() => {
|
|
70
|
+
field.onChange(option.value as PathValue<T, Path<T>>);
|
|
71
|
+
setOpen(false);
|
|
72
|
+
}}
|
|
73
|
+
onMouseDown={(e) => {
|
|
74
|
+
e.preventDefault();
|
|
75
|
+
e.stopPropagation();
|
|
76
|
+
}}
|
|
77
|
+
>
|
|
78
|
+
<Check
|
|
79
|
+
className={cn(
|
|
80
|
+
"mr-2 h-4 w-4",
|
|
81
|
+
field.value === option.value ? "opacity-100" : "opacity-0",
|
|
82
|
+
)}
|
|
83
|
+
/>
|
|
84
|
+
{option.label}
|
|
85
|
+
</CommandItem>
|
|
86
|
+
))}
|
|
87
|
+
</CommandGroup>
|
|
88
|
+
</CommandList>
|
|
89
|
+
)}
|
|
90
|
+
</div>
|
|
91
|
+
</Command>
|
|
92
|
+
);
|
|
93
|
+
}
|
|
@@ -1,12 +1,5 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import {
|
|
4
|
-
Command,
|
|
5
|
-
CommandEmpty,
|
|
6
|
-
CommandGroup,
|
|
7
|
-
CommandItem,
|
|
8
|
-
CommandList,
|
|
9
|
-
} from "@suphark/ui/components/ui/command";
|
|
10
3
|
import {
|
|
11
4
|
FormControl,
|
|
12
5
|
FormField,
|
|
@@ -24,10 +17,9 @@ import {
|
|
|
24
17
|
import { Textarea } from "@suphark/ui/components/ui/textarea";
|
|
25
18
|
import { useCopyToClipboard } from "@suphark/ui/hooks/use-copy-to-clipboard";
|
|
26
19
|
import { cn } from "@suphark/ui/lib/utils";
|
|
27
|
-
import { Command as CommandPrimitive } from "cmdk";
|
|
28
20
|
import { Check, CircleAlert, Copy } from "lucide-react";
|
|
29
21
|
import type { ComponentProps } from "react";
|
|
30
|
-
import {
|
|
22
|
+
import { lazy, Suspense } from "react";
|
|
31
23
|
import type { ControllerRenderProps, FieldValues, Path, PathValue } from "react-hook-form";
|
|
32
24
|
|
|
33
25
|
// --- Types ---
|
|
@@ -49,13 +41,11 @@ type StandardInputProps<T extends FieldValues> = BaseProps<T> & {
|
|
|
49
41
|
} & Omit<ComponentProps<typeof Input>, "required" | "form" | "name" | "value" | "onChange">;
|
|
50
42
|
|
|
51
43
|
// Variant 2: Autocomplete
|
|
52
|
-
export type AutocompleteOption
|
|
53
|
-
|
|
54
|
-
label: string;
|
|
55
|
-
};
|
|
44
|
+
export type { AutocompleteOption } from "./form-field-autocomplete";
|
|
45
|
+
|
|
56
46
|
type AutocompleteProps<T extends FieldValues> = BaseProps<T> & {
|
|
57
47
|
variant: "autocomplete";
|
|
58
|
-
data: AutocompleteOption[];
|
|
48
|
+
data: import("./form-field-autocomplete").AutocompleteOption[];
|
|
59
49
|
emptyMessage?: string;
|
|
60
50
|
placeholder?: string;
|
|
61
51
|
disabled?: boolean;
|
|
@@ -82,76 +72,28 @@ export type FormFieldInputProps<T extends FieldValues> =
|
|
|
82
72
|
|
|
83
73
|
// --- Helper Components ---
|
|
84
74
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
emptyMessage = "No results found.",
|
|
91
|
-
required,
|
|
92
|
-
}: {
|
|
93
|
-
field: ControllerRenderProps<T, Path<T>>;
|
|
94
|
-
data: AutocompleteOption[];
|
|
95
|
-
placeholder?: string;
|
|
96
|
-
disabled?: boolean;
|
|
97
|
-
emptyMessage?: string;
|
|
98
|
-
required?: boolean;
|
|
99
|
-
}) {
|
|
100
|
-
const [open, setOpen] = useState(false);
|
|
75
|
+
// variant autocomplete ใช้ cmdk (+ Radix Dialog ที่ cmdk พ่วงมา) — แยกไฟล์และโหลดแบบ lazy
|
|
76
|
+
// ไม่งั้นทุกฟอร์มที่ใช้ FormFieldInput ธรรมดาต้องพก cmdk ไปด้วย
|
|
77
|
+
const LazyAutocompleteInput = lazy(() =>
|
|
78
|
+
import("./form-field-autocomplete").then((m) => ({ default: m.AutocompleteInput })),
|
|
79
|
+
) as unknown as typeof import("./form-field-autocomplete").AutocompleteInput;
|
|
101
80
|
|
|
81
|
+
function AutocompleteInput<T extends FieldValues>(
|
|
82
|
+
props: ComponentProps<typeof import("./form-field-autocomplete").AutocompleteInput<T>>,
|
|
83
|
+
) {
|
|
102
84
|
return (
|
|
103
|
-
<
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
>
|
|
111
|
-
<CommandPrimitive.Input
|
|
112
|
-
value={field.value as string}
|
|
113
|
-
onValueChange={field.onChange}
|
|
114
|
-
onFocus={() => setOpen(true)}
|
|
115
|
-
onBlur={() => setOpen(false)}
|
|
116
|
-
placeholder={placeholder}
|
|
117
|
-
disabled={disabled}
|
|
118
|
-
className="w-full bg-transparent outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed"
|
|
85
|
+
<Suspense
|
|
86
|
+
fallback={
|
|
87
|
+
<Input
|
|
88
|
+
value={(props.field.value as string) ?? ""}
|
|
89
|
+
placeholder={props.placeholder}
|
|
90
|
+
disabled
|
|
91
|
+
readOnly
|
|
119
92
|
/>
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
<CommandEmpty className="py-2 text-center text-muted-foreground text-sm">
|
|
125
|
-
{emptyMessage}
|
|
126
|
-
</CommandEmpty>
|
|
127
|
-
<CommandGroup>
|
|
128
|
-
{data.map((option) => (
|
|
129
|
-
<CommandItem
|
|
130
|
-
key={option.value}
|
|
131
|
-
value={`${option.label} ${option.value}`}
|
|
132
|
-
onSelect={() => {
|
|
133
|
-
field.onChange(option.value as PathValue<T, Path<T>>);
|
|
134
|
-
setOpen(false);
|
|
135
|
-
}}
|
|
136
|
-
onMouseDown={(e) => {
|
|
137
|
-
e.preventDefault();
|
|
138
|
-
e.stopPropagation();
|
|
139
|
-
}}
|
|
140
|
-
>
|
|
141
|
-
<Check
|
|
142
|
-
className={cn(
|
|
143
|
-
"mr-2 h-4 w-4",
|
|
144
|
-
field.value === option.value ? "opacity-100" : "opacity-0",
|
|
145
|
-
)}
|
|
146
|
-
/>
|
|
147
|
-
{option.label}
|
|
148
|
-
</CommandItem>
|
|
149
|
-
))}
|
|
150
|
-
</CommandGroup>
|
|
151
|
-
</CommandList>
|
|
152
|
-
)}
|
|
153
|
-
</div>
|
|
154
|
-
</Command>
|
|
93
|
+
}
|
|
94
|
+
>
|
|
95
|
+
<LazyAutocompleteInput {...props} />
|
|
96
|
+
</Suspense>
|
|
155
97
|
);
|
|
156
98
|
}
|
|
157
99
|
|