@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
@@ -0,0 +1,154 @@
1
+ "use client";
2
+
3
+ import { NumberField } from "@base-ui/react/number-field";
4
+ import { cn } from "@suphark/ui/lib/utils";
5
+ import { MinusIcon, PlusIcon } from "lucide-react";
6
+ import type { ReactNode } from "react";
7
+ import { useUiText } from "../ui-text";
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
+
18
+ const SIZE = {
19
+ sm: { group: "h-7", button: "w-7 [&_svg]:size-3.5", text: "text-sm" },
20
+ default: { group: "h-8", button: "w-8 [&_svg]:size-4", text: "text-base md:text-sm" },
21
+ } as const;
22
+
23
+ /**
24
+ * ช่องตัวเลขพร้อมปุ่ม −/+ (Base UI NumberField) — แทน `<Input type="number">` + InputGroup + state เอง
25
+ * ลูกศรขึ้น/ลง (Shift = `largeStep`), จัดรูปแบบตาม locale (`format` เช่น ทศนิยม/สกุลเงิน), `prefix`/`suffix` เช่น ฿ หรือ %
26
+ * `name` ส่งค่าใน FormData ของ server action ได้ (ค่าดิบไม่มีจุลภาค)
27
+ *
28
+ * <NumberInput name="amount" prefix="฿" min={0} step={100} format={{ maximumFractionDigits: 2 }} />
29
+ * <NumberInput value={qty} onValueChange={setQty} min={1} max={99} aria-label="จำนวน" />
30
+ */
31
+ export function NumberInput({
32
+ value,
33
+ defaultValue,
34
+ onValueChange,
35
+ min,
36
+ max,
37
+ step = 1,
38
+ largeStep,
39
+ format,
40
+ locale,
41
+ prefix,
42
+ suffix,
43
+ hideButtons = false,
44
+ size = "default",
45
+ name,
46
+ id,
47
+ required,
48
+ disabled,
49
+ readOnly,
50
+ placeholder,
51
+ className,
52
+ inputClassName,
53
+ "aria-label": ariaLabel,
54
+ "aria-describedby": ariaDescribedBy,
55
+ "aria-invalid": ariaInvalid,
56
+ }: {
57
+ value?: number | null;
58
+ defaultValue?: number;
59
+ onValueChange?: (value: number | null) => void;
60
+ min?: number;
61
+ max?: number;
62
+ step?: number;
63
+ /** ก้าวเมื่อกด Shift + ลูกศร / PageUp-PageDown (ค่าเริ่มต้น step × 10) */
64
+ largeStep?: number;
65
+ /** Intl.NumberFormatOptions เช่น `{ maximumFractionDigits: 2 }` หรือ `{ style: "percent" }` */
66
+ format?: Intl.NumberFormatOptions;
67
+ /** ค่าเริ่มต้น th-TH */
68
+ locale?: Intl.LocalesArgument;
69
+ /** ข้อความ/ไอคอนหน้าตัวเลข เช่น "฿" */
70
+ prefix?: ReactNode;
71
+ /** ข้อความ/ไอคอนหลังตัวเลข เช่น "%", "ชิ้น" */
72
+ suffix?: ReactNode;
73
+ /** ซ่อนปุ่ม −/+ (ยังใช้ลูกศรคีย์บอร์ดได้) */
74
+ hideButtons?: boolean;
75
+ size?: "sm" | "default";
76
+ name?: string;
77
+ id?: string;
78
+ required?: boolean;
79
+ disabled?: boolean;
80
+ readOnly?: boolean;
81
+ placeholder?: string;
82
+ className?: string;
83
+ inputClassName?: string;
84
+ "aria-label"?: string;
85
+ "aria-describedby"?: string;
86
+ "aria-invalid"?: boolean | "true" | "false";
87
+ }) {
88
+ const text = useUiText().number;
89
+ const s = SIZE[size];
90
+ const buttonClass = cn(
91
+ "flex shrink-0 items-center justify-center text-muted-foreground transition-colors outline-none hover:bg-muted hover:text-foreground focus-visible:bg-muted disabled:pointer-events-none disabled:opacity-50",
92
+ s.button,
93
+ );
94
+ const affix = "flex shrink-0 items-center px-2 text-muted-foreground text-sm select-none";
95
+ return (
96
+ <NumberField.Root
97
+ id={id}
98
+ name={name}
99
+ value={value}
100
+ defaultValue={defaultValue}
101
+ onValueChange={(v) => onValueChange?.(v)}
102
+ min={min}
103
+ max={max}
104
+ step={step}
105
+ largeStep={largeStep ?? step * 10}
106
+ format={format}
107
+ locale={locale ?? "th-TH"}
108
+ required={required}
109
+ disabled={disabled}
110
+ readOnly={readOnly}
111
+ inputRef={relaxHiddenStep}
112
+ className={cn("w-full", className)}
113
+ >
114
+ <NumberField.Group
115
+ data-slot="number-input"
116
+ className={cn(
117
+ "flex w-full min-w-0 items-stretch overflow-hidden rounded-lg border border-input bg-transparent transition-colors focus-within:border-ring focus-within:ring-3 focus-within:ring-ring/50 has-aria-invalid:border-destructive has-aria-invalid:ring-3 has-aria-invalid:ring-destructive/20 has-disabled:opacity-50 dark:bg-input/30",
118
+ s.group,
119
+ )}
120
+ >
121
+ {!hideButtons && (
122
+ <NumberField.Decrement
123
+ aria-label={text?.decrement ?? "ลด"}
124
+ className={cn(buttonClass, "border-r border-input")}
125
+ >
126
+ <MinusIcon />
127
+ </NumberField.Decrement>
128
+ )}
129
+ {prefix && <span className={cn(affix, !hideButtons && "pr-0")}>{prefix}</span>}
130
+ <NumberField.Input
131
+ placeholder={placeholder}
132
+ aria-label={ariaLabel}
133
+ aria-describedby={ariaDescribedBy}
134
+ aria-invalid={ariaInvalid}
135
+ className={cn(
136
+ "min-w-0 flex-1 bg-transparent px-2.5 tabular-nums outline-none placeholder:text-muted-foreground",
137
+ hideButtons ? "text-left" : "text-center",
138
+ s.text,
139
+ inputClassName,
140
+ )}
141
+ />
142
+ {suffix && <span className={cn(affix, !hideButtons && "pl-0")}>{suffix}</span>}
143
+ {!hideButtons && (
144
+ <NumberField.Increment
145
+ aria-label={text?.increment ?? "เพิ่ม"}
146
+ className={cn(buttonClass, "border-l border-input")}
147
+ >
148
+ <PlusIcon />
149
+ </NumberField.Increment>
150
+ )}
151
+ </NumberField.Group>
152
+ </NumberField.Root>
153
+ );
154
+ }
@@ -0,0 +1,197 @@
1
+ "use client";
2
+
3
+ import { cn } from "@suphark/ui/lib/utils";
4
+ import { XIcon } from "lucide-react";
5
+ import { type ClipboardEvent, type KeyboardEvent, useId, useRef, useState } from "react";
6
+ import { useUiText } from "../ui-text";
7
+
8
+ /**
9
+ * ช่องพิมพ์หลายค่าเป็นป้าย (chip) — พิมพ์แล้วกด Enter หรือจุลภาค, วางหลายค่าพร้อมกันได้ (แยกด้วย , ; เว้นวรรค หรือขึ้นบรรทัด)
10
+ * Backspace ในช่องว่างลบป้ายสุดท้าย; ไม่รับค่าซ้ำ; `validate` คืนข้อความ error เพื่อปฏิเสธค่า (แสดงใต้ช่อง)
11
+ * `name` ส่งทุกค่าใน FormData (`formData.getAll(name)`) — แทน input + state array + map ป้าย + ปุ่มลบเอง
12
+ *
13
+ * <TagsInput name="redirectUris" placeholder="https://…"
14
+ * validate={(v) => (URL.canParse(v) ? null : "ต้องเป็น URL")} />
15
+ */
16
+ export function TagsInput({
17
+ value,
18
+ defaultValue = [],
19
+ onValueChange,
20
+ validate,
21
+ transform = (v) => v.trim(),
22
+ max,
23
+ allowDuplicates = false,
24
+ splitOnSpace = false,
25
+ name,
26
+ id,
27
+ disabled,
28
+ placeholder,
29
+ className,
30
+ "aria-label": ariaLabel,
31
+ "aria-describedby": ariaDescribedBy,
32
+ "aria-invalid": ariaInvalid,
33
+ }: {
34
+ value?: string[];
35
+ defaultValue?: string[];
36
+ onValueChange?: (value: string[]) => void;
37
+ /** คืน string = ข้อความ error (ไม่เพิ่มค่า), null/undefined = ผ่าน */
38
+ validate?: (tag: string, current: string[]) => string | null | undefined;
39
+ /** แปลงค่าก่อนเพิ่ม (ค่าเริ่มต้น trim) เช่น `(v) => v.trim().toLowerCase()` */
40
+ transform?: (raw: string) => string;
41
+ /** จำนวนป้ายสูงสุด */
42
+ max?: number;
43
+ allowDuplicates?: boolean;
44
+ /** เว้นวรรคก็ตัดเป็นป้ายใหม่ (เหมาะกับอีเมล/โดเมน) */
45
+ splitOnSpace?: boolean;
46
+ name?: string;
47
+ id?: string;
48
+ disabled?: boolean;
49
+ placeholder?: string;
50
+ className?: string;
51
+ "aria-label"?: string;
52
+ "aria-describedby"?: string;
53
+ "aria-invalid"?: boolean;
54
+ }) {
55
+ const text = useUiText().tags;
56
+ const [inner, setInner] = useState(defaultValue);
57
+ const tags = value ?? inner;
58
+ const [draft, setDraft] = useState("");
59
+ const [error, setError] = useState<string | null>(null);
60
+ const inputRef = useRef<HTMLInputElement>(null);
61
+ const errorId = useId();
62
+ const full = max != null && tags.length >= max;
63
+
64
+ const commit = (next: string[]) => {
65
+ if (value === undefined) setInner(next);
66
+ onValueChange?.(next);
67
+ };
68
+
69
+ /** เพิ่มหลายค่า — คืนค่าที่ไม่ผ่าน (ให้คงไว้ในช่องพิมพ์) */
70
+ const add = (raws: string[]): string => {
71
+ const next = [...tags];
72
+ let firstError: string | null = null;
73
+ const rejected: string[] = [];
74
+ for (const raw of raws) {
75
+ const tag = transform(raw);
76
+ if (!tag) continue;
77
+ let message: string | null | undefined = null;
78
+ if (max != null && next.length >= max) {
79
+ message = (text?.max ?? "เพิ่มได้สูงสุด {max} รายการ").replace("{max}", String(max));
80
+ } else if (!allowDuplicates && next.includes(tag)) {
81
+ message = (text?.duplicate ?? "มี “{tag}” แล้ว").replace("{tag}", tag);
82
+ } else {
83
+ message = validate?.(tag, next);
84
+ }
85
+ if (message) {
86
+ firstError ??= message;
87
+ rejected.push(raw.trim());
88
+ } else {
89
+ next.push(tag);
90
+ }
91
+ }
92
+ if (next.length !== tags.length) commit(next);
93
+ setError(firstError);
94
+ return rejected.join(", ");
95
+ };
96
+
97
+ const separator = splitOnSpace ? /[,;\n\s]+/ : /[,;\n]+/;
98
+
99
+ const onKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
100
+ if (e.nativeEvent.isComposing) return; // กำลังประกอบคำด้วย IME
101
+ if (e.key === "Enter" || e.key === "," || (splitOnSpace && e.key === " ")) {
102
+ if (!draft.trim()) {
103
+ if (e.key !== "Enter") e.preventDefault();
104
+ return;
105
+ }
106
+ e.preventDefault();
107
+ setDraft(add([draft]));
108
+ } else if (e.key === "Backspace" && draft === "" && tags.length > 0) {
109
+ e.preventDefault();
110
+ commit(tags.slice(0, -1));
111
+ setError(null);
112
+ }
113
+ };
114
+
115
+ const onPaste = (e: ClipboardEvent<HTMLInputElement>) => {
116
+ const pasted = e.clipboardData.getData("text");
117
+ if (!separator.test(pasted)) return;
118
+ e.preventDefault();
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)));
125
+ };
126
+
127
+ const remove = (index: number) => {
128
+ commit(tags.filter((_, i) => i !== index));
129
+ setError(null);
130
+ inputRef.current?.focus();
131
+ };
132
+
133
+ const describedBy =
134
+ [ariaDescribedBy, error ? errorId : null].filter(Boolean).join(" ") || undefined;
135
+
136
+ return (
137
+ <div data-slot="tags-input" className={cn("flex w-full flex-col gap-1.5", className)}>
138
+ <div
139
+ onClick={() => inputRef.current?.focus()}
140
+ className={cn(
141
+ "flex min-h-8 w-full flex-wrap items-center gap-1 rounded-lg border border-input bg-transparent px-1.5 py-1 transition-colors focus-within:border-ring focus-within:ring-3 focus-within:ring-ring/50 dark:bg-input/30",
142
+ (error || ariaInvalid) &&
143
+ "border-destructive ring-3 ring-destructive/20 focus-within:border-destructive focus-within:ring-destructive/20",
144
+ disabled && "pointer-events-none opacity-50",
145
+ )}
146
+ >
147
+ {tags.map((tag, i) => (
148
+ <span
149
+ // biome-ignore lint/suspicious/noArrayIndexKey: allowDuplicates ทำให้ค่าซ้ำได้ ใช้ตำแหน่งร่วม
150
+ key={`${tag}-${i}`}
151
+ className="inline-flex h-6 max-w-full items-center gap-0.5 rounded-md bg-secondary pr-0.5 pl-2 text-secondary-foreground text-sm"
152
+ >
153
+ <span className="truncate">{tag}</span>
154
+ <button
155
+ type="button"
156
+ disabled={disabled}
157
+ aria-label={(text?.remove ?? "ลบ {tag}").replace("{tag}", tag)}
158
+ onClick={(e) => {
159
+ e.stopPropagation();
160
+ remove(i);
161
+ }}
162
+ className="flex size-5 shrink-0 items-center justify-center rounded-sm text-muted-foreground outline-none hover:bg-background/60 hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/50 [&_svg]:size-3.5"
163
+ >
164
+ <XIcon />
165
+ </button>
166
+ {name && <input type="hidden" name={name} value={tag} />}
167
+ </span>
168
+ ))}
169
+ <input
170
+ ref={inputRef}
171
+ id={id}
172
+ value={draft}
173
+ disabled={disabled || full}
174
+ placeholder={full ? undefined : (placeholder ?? text?.placeholder ?? "พิมพ์แล้วกด Enter")}
175
+ aria-label={ariaLabel}
176
+ aria-describedby={describedBy}
177
+ aria-invalid={error || ariaInvalid ? true : undefined}
178
+ onChange={(e) => {
179
+ setDraft(e.currentTarget.value);
180
+ if (error) setError(null);
181
+ }}
182
+ onKeyDown={onKeyDown}
183
+ onPaste={onPaste}
184
+ onBlur={() => {
185
+ if (draft.trim()) setDraft(add([draft]));
186
+ }}
187
+ className="h-6 min-w-24 flex-1 bg-transparent px-1 text-base outline-none placeholder:text-muted-foreground md:text-sm"
188
+ />
189
+ </div>
190
+ {error && (
191
+ <p id={errorId} role="alert" className="text-destructive text-xs">
192
+ {error}
193
+ </p>
194
+ )}
195
+ </div>
196
+ );
197
+ }
@@ -0,0 +1,321 @@
1
+ "use client";
2
+
3
+ import { Button } from "@suphark/ui/components/ui/button";
4
+ import {
5
+ InputGroup,
6
+ InputGroupAddon,
7
+ InputGroupButton,
8
+ InputGroupInput,
9
+ } from "@suphark/ui/components/ui/input-group";
10
+ import { Popover, PopoverContent, PopoverTrigger } from "@suphark/ui/components/ui/popover";
11
+ import { cn } from "@suphark/ui/lib/utils";
12
+ import { ClockIcon } from "lucide-react";
13
+ import { type KeyboardEvent, useEffect, useId, useRef, useState } from "react";
14
+ import { useUiText } from "../ui-text";
15
+
16
+ /** "HH:mm" 24 ชั่วโมง */
17
+ export type TimeValue = string;
18
+
19
+ export const pad = (n: number) => String(n).padStart(2, "0");
20
+
21
+ /**
22
+ * แปลงข้อความที่พิมพ์เป็น "HH:mm": "14:30" "14.30" "1430" "930" "9" "9:5" → null ถ้าไม่ใช่เวลาที่ถูกต้อง
23
+ */
24
+ export function parseTimeInput(input: string): TimeValue | null {
25
+ const s = input.trim().replace(/\s*(น\.|นาฬิกา)$/, "");
26
+ if (!s) return null;
27
+ let h: number;
28
+ let m: number;
29
+ const sep = s.match(/^(\d{1,2})[:.](\d{1,2})$/);
30
+ if (sep) {
31
+ h = Number(sep[1]);
32
+ m = Number(sep[2]);
33
+ } else if (/^\d{1,4}$/.test(s)) {
34
+ if (s.length <= 2) {
35
+ h = Number(s);
36
+ m = 0;
37
+ } else {
38
+ h = Number(s.slice(0, s.length - 2));
39
+ m = Number(s.slice(-2));
40
+ }
41
+ } else {
42
+ return null;
43
+ }
44
+ if (h > 23 || m > 59) return null;
45
+ return `${pad(h)}:${pad(m)}`;
46
+ }
47
+
48
+ export function splitTime(value: TimeValue | null | undefined): [number, number] | null {
49
+ const parsed = value ? parseTimeInput(value) : null;
50
+ if (!parsed) return null;
51
+ const [h, m] = parsed.split(":").map(Number);
52
+ return [h ?? 0, m ?? 0];
53
+ }
54
+
55
+ /** คอลัมน์เลือกค่า (listbox): คลิก หรือโฟกัสแล้วใช้ลูกศรขึ้น/ลง Home/End */
56
+ function TimeColumn({
57
+ label,
58
+ options,
59
+ selected,
60
+ onSelect,
61
+ }: {
62
+ label: string;
63
+ options: number[];
64
+ selected: number | null;
65
+ onSelect: (value: number) => void;
66
+ }) {
67
+ const baseId = useId();
68
+ const listRef = useRef<HTMLDivElement>(null);
69
+ const optionId = (v: number) => `${baseId}-${v}`;
70
+
71
+ // เลื่อนเฉพาะในคอลัมน์ให้ค่าที่เลือกอยู่กลาง — ไม่ใช้ scrollIntoView เพราะจะเลื่อนทั้งหน้าไปด้วย
72
+ useEffect(() => {
73
+ const list = listRef.current;
74
+ if (selected == null || !list) return;
75
+ const el = list.querySelector<HTMLElement>(`[data-value="${selected}"]`);
76
+ if (el) list.scrollTop = el.offsetTop - (list.clientHeight - el.offsetHeight) / 2;
77
+ }, [selected]);
78
+
79
+ const onKeyDown = (e: KeyboardEvent) => {
80
+ const i = selected == null ? -1 : options.indexOf(selected);
81
+ let next: number | undefined;
82
+ if (e.key === "ArrowDown") next = options[Math.min(i + 1, options.length - 1)];
83
+ else if (e.key === "ArrowUp") next = options[Math.max(i - 1, 0)];
84
+ else if (e.key === "Home") next = options[0];
85
+ else if (e.key === "End") next = options[options.length - 1];
86
+ if (next !== undefined) {
87
+ e.preventDefault();
88
+ onSelect(next);
89
+ }
90
+ };
91
+
92
+ return (
93
+ <div
94
+ ref={listRef}
95
+ role="listbox"
96
+ tabIndex={0}
97
+ aria-label={label}
98
+ aria-activedescendant={selected == null ? undefined : optionId(selected)}
99
+ onKeyDown={onKeyDown}
100
+ className="no-scrollbar relative flex max-h-56 w-14 flex-col gap-0.5 overflow-y-auto rounded-md p-0.5 outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
101
+ >
102
+ {options.map((v) => (
103
+ <div
104
+ key={v}
105
+ id={optionId(v)}
106
+ role="option"
107
+ aria-selected={v === selected}
108
+ data-value={v}
109
+ tabIndex={-1}
110
+ onClick={() => onSelect(v)}
111
+ className={cn(
112
+ "flex h-8 shrink-0 cursor-default items-center justify-center rounded-md text-sm tabular-nums select-none hover:bg-muted",
113
+ v === selected && "bg-primary text-primary-foreground hover:bg-primary",
114
+ )}
115
+ >
116
+ {pad(v)}
117
+ </div>
118
+ ))}
119
+ </div>
120
+ );
121
+ }
122
+
123
+ /** คอลัมน์ชั่วโมง + นาที (ใช้ใน TimePicker / DateTimePicker) */
124
+ export function TimeColumns({
125
+ value,
126
+ onValueChange,
127
+ minuteStep = 5,
128
+ className,
129
+ }: {
130
+ value: TimeValue | null | undefined;
131
+ onValueChange: (value: TimeValue) => void;
132
+ /** ระยะห่างนาทีในคอลัมน์ (ค่านาทีที่ไม่ลงตัวยังแสดงถ้าเลือกอยู่) */
133
+ minuteStep?: number;
134
+ className?: string;
135
+ }) {
136
+ const text = useUiText().time;
137
+ const current = splitTime(value);
138
+ const hours = Array.from({ length: 24 }, (_, i) => i);
139
+ const minutes = Array.from({ length: Math.ceil(60 / minuteStep) }, (_, i) => i * minuteStep);
140
+ if (current && !minutes.includes(current[1])) {
141
+ minutes.push(current[1]);
142
+ minutes.sort((a, b) => a - b);
143
+ }
144
+ const set = (h: number, m: number) => onValueChange(`${pad(h)}:${pad(m)}`);
145
+ return (
146
+ <div data-slot="time-columns" className={cn("flex gap-1", className)}>
147
+ <TimeColumn
148
+ label={text?.hour ?? "ชั่วโมง"}
149
+ options={hours}
150
+ selected={current?.[0] ?? null}
151
+ onSelect={(h) => set(h, current?.[1] ?? 0)}
152
+ />
153
+ <TimeColumn
154
+ label={text?.minute ?? "นาที"}
155
+ options={minutes}
156
+ selected={current?.[1] ?? null}
157
+ onSelect={(m) => set(current?.[0] ?? 0, m)}
158
+ />
159
+ </div>
160
+ );
161
+ }
162
+
163
+ /** state แบบ controlled/uncontrolled (ใช้ร่วมกับ DateTimePicker) */
164
+ export function useControllable<T>(
165
+ value: T | undefined,
166
+ defaultValue: T,
167
+ onChange?: (v: T) => void,
168
+ ) {
169
+ const [inner, setInner] = useState(defaultValue);
170
+ const controlled = value !== undefined;
171
+ const current = controlled ? value : inner;
172
+ const set = (v: T) => {
173
+ if (!controlled) setInner(v);
174
+ onChange?.(v);
175
+ };
176
+ return [current, set] as const;
177
+ }
178
+
179
+ const nowTime = (): TimeValue => {
180
+ const d = new Date();
181
+ return `${pad(d.getHours())}:${pad(d.getMinutes())}`;
182
+ };
183
+
184
+ /**
185
+ * ช่องเวลา 24 ชม. — พิมพ์ได้เลย ("1430", "9.15", "14:30") หรือกดไอคอนนาฬิกาเลือกจากคอลัมน์ชั่วโมง/นาที
186
+ * ค่าเป็น string "HH:mm" (หรือ null) — `name` ส่งใน FormData ได้; พิมพ์ผิดรูปแบบช่องจะเป็นสีแดงจนแก้
187
+ *
188
+ * <TimePicker name="remindAt" defaultValue="09:00" minuteStep={15} aria-label="เวลาเตือน" />
189
+ */
190
+ export function TimePicker({
191
+ value,
192
+ defaultValue = null,
193
+ onValueChange,
194
+ minuteStep = 5,
195
+ name,
196
+ id,
197
+ required,
198
+ disabled,
199
+ placeholder,
200
+ className,
201
+ "aria-label": ariaLabel,
202
+ "aria-describedby": ariaDescribedBy,
203
+ "aria-invalid": ariaInvalid,
204
+ }: {
205
+ value?: TimeValue | null;
206
+ defaultValue?: TimeValue | null;
207
+ onValueChange?: (value: TimeValue | null) => void;
208
+ minuteStep?: number;
209
+ name?: string;
210
+ id?: string;
211
+ required?: boolean;
212
+ disabled?: boolean;
213
+ /** ค่าเริ่มต้น "--:--" */
214
+ placeholder?: string;
215
+ className?: string;
216
+ "aria-label"?: string;
217
+ "aria-describedby"?: string;
218
+ "aria-invalid"?: boolean;
219
+ }) {
220
+ const ui = useUiText();
221
+ const text = ui.time;
222
+ const [time, setTime] = useControllable<TimeValue | null>(value, defaultValue, onValueChange);
223
+ const [draft, setDraft] = useState<string | null>(null);
224
+ const [open, setOpen] = useState(false);
225
+ const draftInvalid = draft != null && draft.trim() !== "" && parseTimeInput(draft) == null;
226
+ const invalidMessage = text?.invalid ?? "รูปแบบเวลาไม่ถูกต้อง (เช่น 14:30)";
227
+ const inputRef = useRef<HTMLInputElement>(null);
228
+
229
+ // ข้อความที่พิมพ์ผิด = ช่องนี้ invalid จริง → ฟอร์มไม่ส่ง (ไม่งั้น input ซ่อนจะส่งค่าเดิมไปเงียบ ๆ)
230
+ useEffect(() => {
231
+ inputRef.current?.setCustomValidity(draftInvalid ? invalidMessage : "");
232
+ }, [draftInvalid, invalidMessage]);
233
+
234
+ const commitDraft = () => {
235
+ if (draft == null) return;
236
+ if (draft.trim() === "") {
237
+ setTime(null);
238
+ setDraft(null);
239
+ return;
240
+ }
241
+ const parsed = parseTimeInput(draft);
242
+ if (parsed) {
243
+ setTime(parsed);
244
+ setDraft(null);
245
+ }
246
+ };
247
+
248
+ return (
249
+ <InputGroup data-slot="time-picker" className={cn("w-32", className)}>
250
+ <InputGroupInput
251
+ ref={inputRef}
252
+ id={id}
253
+ inputMode="numeric"
254
+ autoComplete="off"
255
+ placeholder={placeholder ?? "--:--"}
256
+ value={draft ?? time ?? ""}
257
+ disabled={disabled}
258
+ required={required}
259
+ aria-label={ariaLabel}
260
+ aria-describedby={ariaDescribedBy}
261
+ aria-invalid={draftInvalid || ariaInvalid || undefined}
262
+ onChange={(e) => setDraft(e.currentTarget.value)}
263
+ onBlur={commitDraft}
264
+ onKeyDown={(e) => {
265
+ if (e.key === "Enter") {
266
+ e.preventDefault();
267
+ commitDraft();
268
+ }
269
+ }}
270
+ className="tabular-nums"
271
+ />
272
+ {name && <input type="hidden" name={name} value={time ?? ""} />}
273
+ <InputGroupAddon align="inline-end">
274
+ <Popover open={open} onOpenChange={setOpen}>
275
+ <PopoverTrigger
276
+ render={
277
+ <InputGroupButton
278
+ size="icon-xs"
279
+ disabled={disabled}
280
+ aria-label={text?.choose ?? "เลือกเวลา"}
281
+ />
282
+ }
283
+ >
284
+ <ClockIcon />
285
+ </PopoverTrigger>
286
+ <PopoverContent align="end" className="w-auto gap-2 p-2">
287
+ <TimeColumns
288
+ value={time}
289
+ minuteStep={minuteStep}
290
+ onValueChange={(v) => {
291
+ setTime(v);
292
+ setDraft(null);
293
+ }}
294
+ />
295
+ <div className="flex justify-between gap-2 border-t pt-2">
296
+ <Button
297
+ type="button"
298
+ size="xs"
299
+ variant="ghost"
300
+ onClick={() => {
301
+ setTime(nowTime());
302
+ setDraft(null);
303
+ }}
304
+ >
305
+ {text?.now ?? "ตอนนี้"}
306
+ </Button>
307
+ <Button type="button" size="xs" onClick={() => setOpen(false)}>
308
+ {ui.done}
309
+ </Button>
310
+ </div>
311
+ </PopoverContent>
312
+ </Popover>
313
+ </InputGroupAddon>
314
+ </InputGroup>
315
+ );
316
+ }
317
+
318
+ /** "YYYY-MM-DDTHH:mm" ตามเวลาท้องถิ่น (รูปแบบเดียวกับ input datetime-local) — ใช้เป็นค่าใน FormData */
319
+ export function toLocalDateTimeString(date: Date): string {
320
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
321
+ }