@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.
Files changed (31) hide show
  1. package/CHANGELOG.md +39 -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/use-data-table.ts +8 -5
  8. package/src/components/pwa/pull-to-refresh.tsx +21 -1
  9. package/src/components/shared/action-menu.tsx +25 -6
  10. package/src/components/shared/auth-parts.tsx +5 -3
  11. package/src/components/shared/button/confirm-action-button.tsx +17 -3
  12. package/src/components/shared/chat-thread.tsx +7 -0
  13. package/src/components/shared/client-redirect.tsx +16 -1
  14. package/src/components/shared/form/date-time-picker.tsx +150 -0
  15. package/src/components/shared/form/form-field-autocomplete.tsx +93 -0
  16. package/src/components/shared/form/form-field-input.tsx +23 -81
  17. package/src/components/shared/form/number-input.tsx +10 -0
  18. package/src/components/shared/form/tags-input.tsx +6 -1
  19. package/src/components/shared/form/time-picker.tsx +23 -123
  20. package/src/components/shared/month-stepper.tsx +10 -4
  21. package/src/components/shared/row-actions.tsx +9 -3
  22. package/src/components/shared/settings-layout.tsx +17 -6
  23. package/src/components/shared/step-form.tsx +25 -4
  24. package/src/components/shared/time/client-date-formatter.tsx +2 -1
  25. package/src/components/shared/tree-view.tsx +6 -2
  26. package/src/components/shared/ui-text.tsx +3 -0
  27. package/src/hooks/use-table-search-params.ts +10 -2
  28. package/src/hooks/use-ticker.ts +11 -5
  29. package/src/index.ts +1 -1
  30. package/src/lib/formatters.ts +1 -1
  31. package/src/lib/month.ts +14 -2
@@ -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
 
@@ -6,6 +6,15 @@ import { MinusIcon, PlusIcon } from "lucide-react";
6
6
  import type { ReactNode } from "react";
7
7
  import { useUiText } from "../ui-text";
8
8
 
9
+ /**
10
+ * input ซ่อนของ Base UI (ที่ส่งค่าใน FormData) ได้ attribute step ไปด้วย → browser ปฏิเสธค่าที่ไม่ลง step นับจาก min
11
+ * (เช่น 12.50 เมื่อ step=1, 150 เมื่อ min=0 step=100) ทั้งที่ step มีไว้แค่ให้ปุ่ม −/+ เลื่อน — ตั้ง step="any" ให้ input ซ่อน
12
+ * (React ไม่เขียน attribute ทับเพราะ prop step ไม่เปลี่ยน) ส่วน min/max ยังตรวจตามเดิม
13
+ */
14
+ function relaxHiddenStep(el: HTMLInputElement | null) {
15
+ if (el) el.step = "any";
16
+ }
17
+
9
18
  const SIZE = {
10
19
  sm: { group: "h-7", button: "w-7 [&_svg]:size-3.5", text: "text-sm" },
11
20
  default: { group: "h-8", button: "w-8 [&_svg]:size-4", text: "text-base md:text-sm" },
@@ -99,6 +108,7 @@ export function NumberInput({
99
108
  required={required}
100
109
  disabled={disabled}
101
110
  readOnly={readOnly}
111
+ inputRef={relaxHiddenStep}
102
112
  className={cn("w-full", className)}
103
113
  >
104
114
  <NumberField.Group
@@ -116,7 +116,12 @@ export function TagsInput({
116
116
  const pasted = e.clipboardData.getData("text");
117
117
  if (!separator.test(pasted)) return;
118
118
  e.preventDefault();
119
- setDraft(add(pasted.split(separator)));
119
+ // แทรกที่ตำแหน่ง cursor ของข้อความที่พิมพ์ค้างไว้ก่อนแยก (เช่น พิมพ์ "https://" แล้ววาง "a.com,b.com")
120
+ const el = e.currentTarget;
121
+ const start = el.selectionStart ?? draft.length;
122
+ const end = el.selectionEnd ?? draft.length;
123
+ const combined = draft.slice(0, start) + pasted + draft.slice(end);
124
+ setDraft(add(combined.split(separator)));
120
125
  };
121
126
 
122
127
  const remove = (index: number) => {