@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.
Files changed (34) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/package.json +1 -1
  3. package/src/components/app-shell/command-palette.tsx +8 -1
  4. package/src/components/data-table/data-table-sortable.tsx +154 -0
  5. package/src/components/data-table/data-table-toolbar.tsx +33 -4
  6. package/src/components/data-table/data-table.tsx +30 -142
  7. package/src/components/data-table/index.ts +9 -9
  8. package/src/components/data-table/use-data-table.ts +8 -5
  9. package/src/components/pwa/pull-to-refresh.tsx +21 -1
  10. package/src/components/shared/action-menu.tsx +25 -6
  11. package/src/components/shared/auth-parts.tsx +5 -3
  12. package/src/components/shared/button/confirm-action-button.tsx +17 -3
  13. package/src/components/shared/chat-thread.tsx +7 -0
  14. package/src/components/shared/client-redirect.tsx +16 -1
  15. package/src/components/shared/form/date-time-picker.tsx +150 -0
  16. package/src/components/shared/form/form-field-autocomplete.tsx +93 -0
  17. package/src/components/shared/form/form-field-input.tsx +23 -81
  18. package/src/components/shared/form/number-input.tsx +154 -0
  19. package/src/components/shared/form/tags-input.tsx +197 -0
  20. package/src/components/shared/form/time-picker.tsx +321 -0
  21. package/src/components/shared/month-stepper.tsx +10 -4
  22. package/src/components/shared/row-actions.tsx +9 -3
  23. package/src/components/shared/settings-layout.tsx +17 -6
  24. package/src/components/shared/step-form.tsx +304 -0
  25. package/src/components/shared/time/client-date-formatter.tsx +2 -1
  26. package/src/components/shared/tree-view.tsx +6 -2
  27. package/src/components/shared/ui-text.tsx +68 -0
  28. package/src/data-table.ts +12 -1
  29. package/src/hooks/use-table-search-params.ts +10 -2
  30. package/src/hooks/use-ticker.ts +11 -5
  31. package/src/index.ts +430 -66
  32. package/src/lib/formatters.ts +1 -1
  33. package/src/lib/month.ts +14 -2
  34. 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
- const run = async (fn?: () => unknown) => {
68
- if (!fn) return;
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
- item.href ? (
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
- await run(confirming.onClick);
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, text.actionFailed ?? "ดำเนินการไม่สำเร็จ"));
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
- window.location.replace(to);
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 { useState } from "react";
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
- value: string;
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
- function AutocompleteInput<T extends FieldValues>({
86
- field,
87
- data,
88
- placeholder,
89
- disabled,
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
- <Command className="overflow-visible bg-transparent">
104
- <div
105
- className={cn(
106
- "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",
107
- disabled && "cursor-not-allowed opacity-50",
108
- required && !field.value && "border-input",
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
- </div>
121
- <div className="relative mt-1">
122
- {open && field.value && (
123
- <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">
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