@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
@@ -1,7 +1,6 @@
1
1
  "use client";
2
2
 
3
3
  import { Button } from "@suphark/ui/components/ui/button";
4
- import { Calendar } from "@suphark/ui/components/ui/calendar";
5
4
  import {
6
5
  InputGroup,
7
6
  InputGroupAddon,
@@ -10,15 +9,14 @@ import {
10
9
  } from "@suphark/ui/components/ui/input-group";
11
10
  import { Popover, PopoverContent, PopoverTrigger } from "@suphark/ui/components/ui/popover";
12
11
  import { cn } from "@suphark/ui/lib/utils";
13
- import { CalendarClockIcon, ClockIcon } from "lucide-react";
12
+ import { ClockIcon } from "lucide-react";
14
13
  import { type KeyboardEvent, useEffect, useId, useRef, useState } from "react";
15
- import { th } from "react-day-picker/locale";
16
14
  import { useUiText } from "../ui-text";
17
15
 
18
16
  /** "HH:mm" 24 ชั่วโมง */
19
17
  export type TimeValue = string;
20
18
 
21
- const pad = (n: number) => String(n).padStart(2, "0");
19
+ export const pad = (n: number) => String(n).padStart(2, "0");
22
20
 
23
21
  /**
24
22
  * แปลงข้อความที่พิมพ์เป็น "HH:mm": "14:30" "14.30" "1430" "930" "9" "9:5" → null ถ้าไม่ใช่เวลาที่ถูกต้อง
@@ -47,7 +45,7 @@ export function parseTimeInput(input: string): TimeValue | null {
47
45
  return `${pad(h)}:${pad(m)}`;
48
46
  }
49
47
 
50
- function splitTime(value: TimeValue | null | undefined): [number, number] | null {
48
+ export function splitTime(value: TimeValue | null | undefined): [number, number] | null {
51
49
  const parsed = value ? parseTimeInput(value) : null;
52
50
  if (!parsed) return null;
53
51
  const [h, m] = parsed.split(":").map(Number);
@@ -70,11 +68,12 @@ function TimeColumn({
70
68
  const listRef = useRef<HTMLDivElement>(null);
71
69
  const optionId = (v: number) => `${baseId}-${v}`;
72
70
 
73
- // เลื่อนให้ค่าที่เลือกอยู่กลางคอลัมน์ทุกครั้งที่เปลี่ยน
71
+ // เลื่อนเฉพาะในคอลัมน์ให้ค่าที่เลือกอยู่กลาง — ไม่ใช้ scrollIntoView เพราะจะเลื่อนทั้งหน้าไปด้วย
74
72
  useEffect(() => {
75
- if (selected == null) return;
76
- const el = listRef.current?.querySelector<HTMLElement>(`[data-value="${selected}"]`);
77
- el?.scrollIntoView?.({ block: "center" });
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;
78
77
  }, [selected]);
79
78
 
80
79
  const onKeyDown = (e: KeyboardEvent) => {
@@ -98,7 +97,7 @@ function TimeColumn({
98
97
  aria-label={label}
99
98
  aria-activedescendant={selected == null ? undefined : optionId(selected)}
100
99
  onKeyDown={onKeyDown}
101
- className="no-scrollbar 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"
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"
102
101
  >
103
102
  {options.map((v) => (
104
103
  <div
@@ -161,7 +160,12 @@ export function TimeColumns({
161
160
  );
162
161
  }
163
162
 
164
- function useControllable<T>(value: T | undefined, defaultValue: T, onChange?: (v: T) => void) {
163
+ /** state แบบ controlled/uncontrolled (ใช้ร่วมกับ DateTimePicker) */
164
+ export function useControllable<T>(
165
+ value: T | undefined,
166
+ defaultValue: T,
167
+ onChange?: (v: T) => void,
168
+ ) {
165
169
  const [inner, setInner] = useState(defaultValue);
166
170
  const controlled = value !== undefined;
167
171
  const current = controlled ? value : inner;
@@ -219,6 +223,13 @@ export function TimePicker({
219
223
  const [draft, setDraft] = useState<string | null>(null);
220
224
  const [open, setOpen] = useState(false);
221
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]);
222
233
 
223
234
  const commitDraft = () => {
224
235
  if (draft == null) return;
@@ -237,6 +248,7 @@ export function TimePicker({
237
248
  return (
238
249
  <InputGroup data-slot="time-picker" className={cn("w-32", className)}>
239
250
  <InputGroupInput
251
+ ref={inputRef}
240
252
  id={id}
241
253
  inputMode="numeric"
242
254
  autoComplete="off"
@@ -307,115 +319,3 @@ export function TimePicker({
307
319
  export function toLocalDateTimeString(date: Date): string {
308
320
  return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}`;
309
321
  }
310
-
311
- /**
312
- * เลือกวัน + เวลาในกล่องเดียว (ปฏิทิน + คอลัมน์ชั่วโมง/นาที) — นัดหมาย, กำหนดส่ง, เวลาเตือน
313
- * ค่าเป็น `Date | null`; เลือกวันก่อนเวลา = ใช้ `defaultTime`; `name` ส่ง "YYYY-MM-DDTHH:mm" (เวลาท้องถิ่น) ใน FormData
314
- *
315
- * <DateTimePicker value={dueAt} onValueChange={setDueAt} minuteStep={15} />
316
- */
317
- export function DateTimePicker({
318
- value,
319
- defaultValue = null,
320
- onValueChange,
321
- minuteStep = 5,
322
- defaultTime = "09:00",
323
- name,
324
- id,
325
- disabled,
326
- placeholder,
327
- className,
328
- "aria-label": ariaLabel,
329
- "aria-invalid": ariaInvalid,
330
- }: {
331
- value?: Date | null;
332
- defaultValue?: Date | null;
333
- onValueChange?: (value: Date | null) => void;
334
- minuteStep?: number;
335
- /** เวลาที่ใช้เมื่อเลือกวันครั้งแรก */
336
- defaultTime?: TimeValue;
337
- name?: string;
338
- id?: string;
339
- disabled?: boolean;
340
- placeholder?: string;
341
- className?: string;
342
- "aria-label"?: string;
343
- "aria-invalid"?: boolean;
344
- }) {
345
- const ui = useUiText();
346
- const text = ui.time;
347
- const [date, setDate] = useControllable<Date | null>(value, defaultValue, onValueChange);
348
- const [open, setOpen] = useState(false);
349
- const time = date ? `${pad(date.getHours())}:${pad(date.getMinutes())}` : null;
350
-
351
- const withTime = (day: Date, t: TimeValue) => {
352
- const [h, m] = splitTime(t) ?? [9, 0];
353
- const d = new Date(day);
354
- d.setHours(h, m, 0, 0);
355
- return d;
356
- };
357
-
358
- const label = date
359
- ? date.toLocaleString("th-TH", { dateStyle: "medium", timeStyle: "short" })
360
- : (placeholder ?? text?.dateTimePlaceholder ?? "เลือกวันและเวลา");
361
-
362
- return (
363
- <Popover open={open} onOpenChange={setOpen}>
364
- <PopoverTrigger
365
- render={
366
- <Button
367
- id={id}
368
- type="button"
369
- variant="outline"
370
- disabled={disabled}
371
- aria-label={ariaLabel}
372
- aria-invalid={ariaInvalid || undefined}
373
- data-slot="date-time-picker"
374
- className={cn(
375
- "w-full justify-start gap-2 font-normal",
376
- !date && "text-muted-foreground",
377
- className,
378
- )}
379
- />
380
- }
381
- >
382
- <CalendarClockIcon className="text-muted-foreground" />
383
- <span className="truncate">{label}</span>
384
- </PopoverTrigger>
385
- {name && <input type="hidden" name={name} value={date ? toLocalDateTimeString(date) : ""} />}
386
- <PopoverContent align="start" className="w-auto gap-0 p-0">
387
- <div className="flex flex-col sm:flex-row">
388
- <Calendar
389
- mode="single"
390
- locale={th}
391
- selected={date ?? undefined}
392
- defaultMonth={date ?? undefined}
393
- onSelect={(day) => day && setDate(withTime(day, time ?? defaultTime))}
394
- className="p-3"
395
- />
396
- <div className="flex justify-center border-t p-2 sm:border-t-0 sm:border-l">
397
- <TimeColumns
398
- value={time}
399
- minuteStep={minuteStep}
400
- onValueChange={(t) => setDate(withTime(date ?? new Date(), t))}
401
- />
402
- </div>
403
- </div>
404
- <div className="flex justify-between gap-2 border-t p-2">
405
- <Button
406
- type="button"
407
- size="xs"
408
- variant="ghost"
409
- disabled={!date}
410
- onClick={() => setDate(null)}
411
- >
412
- {text?.clear ?? "ล้าง"}
413
- </Button>
414
- <Button type="button" size="xs" onClick={() => setOpen(false)}>
415
- {ui.done}
416
- </Button>
417
- </div>
418
- </PopoverContent>
419
- </Popover>
420
- );
421
- }
@@ -38,6 +38,9 @@ export interface MonthStepperProps {
38
38
  * const { month, setMonth } = useMonthCursor();
39
39
  * <MonthStepper value={month} onValueChange={setMonth} />
40
40
  */
41
+ /** "เดือนนี้" ของ MonthStepper/useMonthCursor คิดตามเวลาไทย — แอปของเราใช้ในไทย และต้องได้ค่าเดียวกันทั้ง server และ browser */
42
+ const APP_TIME_ZONE = "Asia/Bangkok";
43
+
41
44
  export function MonthStepper({
42
45
  value,
43
46
  onValueChange,
@@ -54,7 +57,8 @@ export function MonthStepper({
54
57
  const next = shiftMonth(value, 1);
55
58
  const canPrev = !min || compareMonth(prev, min) >= 0;
56
59
  const canNext = !max || compareMonth(next, max) <= 0;
57
- const now = currentMonthKey();
60
+ // โซนเวลาคงที่: server (UTC) กับ browser ได้เดือนเดียวกัน — ไม่งั้นช่วง 00–07 น. วันที่ 1 จะ hydration mismatch
61
+ const now = currentMonthKey(undefined, APP_TIME_ZONE);
58
62
  const nowInRange = (!min || compareMonth(now, min) >= 0) && (!max || compareMonth(now, max) <= 0);
59
63
  const label = formatLabel ? formatLabel(value) : formatMonthKey(value, locale, format);
60
64
  const isCurrent = value === now;
@@ -112,13 +116,15 @@ export function MonthStepper({
112
116
  }
113
117
 
114
118
  /**
115
- * state เดือนปัจจุบัน + ตัวเลื่อน — `initial` ไม่ส่ง = เดือนนี้
119
+ * state เดือนปัจจุบัน + ตัวเลื่อน — `initial` ไม่ส่ง = เดือนนี้ (ตามเวลาไทย เพื่อให้ server/browser ตรงกัน)
116
120
  * ต้องการเก็บใน URL ให้ใช้ `useTableSearchParams`/searchParams ของแอปแทน แล้วส่ง value/onValueChange เอง
117
121
  */
118
122
  export function useMonthCursor(initial?: MonthKey) {
119
- const [month, setMonth] = useState<MonthKey>(() => initial ?? currentMonthKey());
123
+ const [month, setMonth] = useState<MonthKey>(
124
+ () => initial ?? currentMonthKey(undefined, APP_TIME_ZONE),
125
+ );
120
126
  const previous = useCallback(() => setMonth((m) => shiftMonth(m, -1)), []);
121
127
  const next = useCallback(() => setMonth((m) => shiftMonth(m, 1)), []);
122
- const reset = useCallback(() => setMonth(currentMonthKey()), []);
128
+ const reset = useCallback(() => setMonth(currentMonthKey(undefined, APP_TIME_ZONE)), []);
123
129
  return { month, setMonth, previous, next, reset };
124
130
  }
@@ -13,7 +13,7 @@ import Link from "next/link";
13
13
  import { useRouter } from "next/navigation";
14
14
  import { useState, useTransition } from "react";
15
15
  import { toast } from "sonner";
16
- import { errorMessage } from "./button/confirm-action-button";
16
+ import { errorMessage, failedResult } from "./button/confirm-action-button";
17
17
  import { ConfirmDialog, type ConfirmSpec, normalizeConfirm } from "./confirm-dialog";
18
18
  import { useUiText } from "./ui-text";
19
19
 
@@ -58,10 +58,16 @@ export function RowActions({ items, label }: { items: RowAction[]; label?: strin
58
58
  for (const [k, v] of Object.entries(it.data ?? {})) fd.set(k, v);
59
59
  const act = it.action;
60
60
  start(async () => {
61
+ const fallback = text.actionFailed ?? "ดำเนินการไม่สำเร็จ";
61
62
  try {
62
- await act(fd);
63
+ // คืน { ok: false, error } (FormState) ก็ถือว่าล้มเหลวเหมือน throw
64
+ const failed = failedResult(await act(fd), fallback);
65
+ if (failed) {
66
+ toast.error(failed);
67
+ return;
68
+ }
63
69
  } catch (err) {
64
- toast.error(errorMessage(err, text.actionFailed ?? "ดำเนินการไม่สำเร็จ"));
70
+ toast.error(errorMessage(err, fallback));
65
71
  return;
66
72
  }
67
73
  router.refresh();
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { cn } from "@suphark/ui/lib/utils";
4
4
  import { ChevronLeft, ChevronRight } from "lucide-react";
5
- import { type ReactNode, useEffect, useId, useMemo, useRef, useState } from "react";
5
+ import { type ReactNode, useId, useMemo, useRef, useState } from "react";
6
6
  import { SearchInput } from "./form/search-input";
7
7
  import { useUiText } from "./ui-text";
8
8
 
@@ -53,6 +53,7 @@ export function SettingsLayout({
53
53
  const [mobileDetail, setMobileDetail] = useState(false);
54
54
  const [query, setQuery] = useState("");
55
55
  const headingRef = useRef<HTMLHeadingElement>(null);
56
+ const navRef = useRef<HTMLElement>(null);
56
57
 
57
58
  const visible = useMemo(() => {
58
59
  const q = query.trim().toLowerCase();
@@ -71,12 +72,20 @@ export function SettingsLayout({
71
72
  if (valueProp === undefined) setInner(id);
72
73
  onValueChange?.(id);
73
74
  setMobileDetail(true);
75
+ // มือถือ: รายการหัวข้อถูกซ่อน → ย้ายโฟกัสไปหัวข้อ ให้ screen reader รู้ว่าเนื้อหาเปลี่ยน
76
+ // (จอกว้างเห็นทั้งสองฝั่ง โฟกัสอยู่ที่รายการต่อได้ — ไม่ย้าย)
77
+ if (!window.matchMedia?.("(min-width: 48rem)").matches) {
78
+ requestAnimationFrame(() => headingRef.current?.focus({ preventScroll: true }));
79
+ }
74
80
  };
75
81
 
76
- // เข้าหน้าหัวข้อบนมือถือ → ย้ายโฟกัสไปหัวข้อ ให้ screen reader รู้ว่าเนื้อหาเปลี่ยน
77
- useEffect(() => {
78
- if (mobileDetail) headingRef.current?.focus({ preventScroll: true });
79
- }, [mobileDetail]);
82
+ /** มือถือ: กลับไปรายการ — ปุ่ม "กลับ" ถูกซ่อน จึงคืนโฟกัสให้หัวข้อที่เพิ่งเปิด (ไม่งั้นโฟกัสหลุดไป body) */
83
+ const back = (id: string) => {
84
+ setMobileDetail(false);
85
+ requestAnimationFrame(() =>
86
+ navRef.current?.querySelector<HTMLElement>(`[data-section-id="${CSS.escape(id)}"]`)?.focus(),
87
+ );
88
+ };
80
89
 
81
90
  if (!current) return null;
82
91
 
@@ -87,6 +96,7 @@ export function SettingsLayout({
87
96
  className={cn("grid gap-6 md:grid-cols-[14rem_minmax(0,1fr)] lg:gap-8", className)}
88
97
  >
89
98
  <nav
99
+ ref={navRef}
90
100
  aria-label={text.settings?.label ?? "หัวข้อการตั้งค่า"}
91
101
  className={cn(
92
102
  "flex flex-col gap-3 md:sticky md:top-20 md:self-start",
@@ -108,6 +118,7 @@ export function SettingsLayout({
108
118
  <li key={s.id}>
109
119
  <button
110
120
  type="button"
121
+ data-section-id={s.id}
111
122
  onClick={() => select(s.id)}
112
123
  aria-current={selected ? "page" : undefined}
113
124
  className={cn(
@@ -144,7 +155,7 @@ export function SettingsLayout({
144
155
  >
145
156
  <button
146
157
  type="button"
147
- onClick={() => setMobileDetail(false)}
158
+ onClick={() => back(current.id)}
148
159
  className="-ml-1 flex w-fit items-center gap-1 rounded-md px-1 py-1 text-muted-foreground text-sm outline-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring md:hidden"
149
160
  >
150
161
  <ChevronLeft className="size-4" aria-hidden />
@@ -26,12 +26,17 @@ const CONTROLS = "input, select, textarea";
26
26
  */
27
27
  function reportSection(section: HTMLElement | null): true | false | string {
28
28
  if (!section) return true;
29
- for (const el of section.querySelectorAll<HTMLInputElement>(CONTROLS)) {
29
+ const controls = [...section.querySelectorAll<HTMLInputElement>(CONTROLS)];
30
+ for (const [i, el] of controls.entries()) {
30
31
  if (el.type === "hidden" || el.disabled) continue;
31
32
  if (!el.checkValidity()) {
32
33
  if (el.getAttribute("aria-hidden") === "true") {
33
- // โฟกัสช่องที่มองเห็นในกลุ่มเดียวกัน (ถ้ามี)
34
- el.parentElement?.querySelector<HTMLElement>("input:not([aria-hidden=true])")?.focus();
34
+ // input ซ่อนของ Base UI อยู่ถัดจากช่องที่มองเห็นของมัน → โฟกัสช่องที่มองเห็นตัวก่อนหน้าที่ใกล้ที่สุด
35
+ controls
36
+ .slice(0, i)
37
+ .reverse()
38
+ .find((c) => c.getAttribute("aria-hidden") !== "true" && c.type !== "hidden")
39
+ ?.focus();
35
40
  return el.validationMessage || "ค่าไม่ถูกต้อง";
36
41
  }
37
42
  el.reportValidity();
@@ -83,6 +88,10 @@ export function StepForm({
83
88
  const sectionRefs = useRef<(HTMLElement | null)[]>([]);
84
89
  const headingRefs = useRef<(HTMLHeadingElement | null)[]>([]);
85
90
  const moved = useRef(false);
91
+ /** กันส่งซ้ำแบบ synchronous (state pending อัปเดตไม่ทันดับเบิลคลิก) */
92
+ const busy = useRef(false);
93
+ /** เวลาที่เปลี่ยนขั้นล่าสุด — คลิกที่สองของดับเบิลคลิก "ถัดไป" ตกที่ปุ่ม "ส่ง" ตำแหน่งเดิม */
94
+ const movedAt = useRef(0);
86
95
  const last = steps.length - 1;
87
96
  const step = steps[current];
88
97
 
@@ -94,6 +103,7 @@ export function StepForm({
94
103
 
95
104
  const go = (index: number) => {
96
105
  moved.current = true;
106
+ movedAt.current = Date.now();
97
107
  setError(null);
98
108
  setCurrent(index);
99
109
  setReached((r) => Math.max(r, index));
@@ -121,7 +131,18 @@ export function StepForm({
121
131
 
122
132
  const handleSubmit = async (e: FormEvent<HTMLFormElement>) => {
123
133
  e.preventDefault();
124
- if (pending) return;
134
+ if (busy.current) return;
135
+ // ปุ่ม "ถัดไป" กลายเป็น "ส่ง" ที่ตำแหน่งเดิม — คลิกที่มาเร็วเกินหลังเปลี่ยนขั้นไม่นับเป็นการส่ง (ผู้ใช้ยังไม่ได้เห็นหน้าตรวจทาน)
136
+ if (current === last && Date.now() - movedAt.current < 500) return;
137
+ busy.current = true;
138
+ try {
139
+ await submitStep();
140
+ } finally {
141
+ busy.current = false;
142
+ }
143
+ };
144
+
145
+ const submitStep = async () => {
125
146
  setError(null);
126
147
  if (current < last) {
127
148
  if (await check(current)) go(current + 1);
@@ -4,7 +4,8 @@ import { useMounted } from "@suphark/ui/hooks/use-mounted";
4
4
  import { useTicker } from "@suphark/ui/hooks/use-ticker";
5
5
  import { getDateTimeFormat } from "@suphark/ui/lib/intl-cache";
6
6
  import { formatDistance, type Locale } from "date-fns";
7
- import { enUS, th } from "date-fns/locale";
7
+ import { enUS } from "date-fns/locale/en-US";
8
+ import { th } from "date-fns/locale/th";
8
9
  import { useMemo } from "react";
9
10
 
10
11
  interface ClientDateFormatterProps {
@@ -88,8 +88,12 @@ export function TreeView({
88
88
  className,
89
89
  }: TreeViewProps) {
90
90
  const [uncontrolledExpanded, setUncontrolledExpanded] = useState(() => toSet(defaultExpandedIds));
91
- const expanded =
92
- controlledExpanded !== undefined ? toSet(controlledExpanded) : uncontrolledExpanded;
91
+ // Set ใหม่ทุก render ทำให้ memo ของ rows คำนวณใหม่ทุกครั้ง — สร้างเมื่อ prop เปลี่ยนเท่านั้น
92
+ const controlledSet = useMemo(
93
+ () => (controlledExpanded !== undefined ? toSet(controlledExpanded) : null),
94
+ [controlledExpanded],
95
+ );
96
+ const expanded = controlledSet ?? uncontrolledExpanded;
93
97
  const [loaded, setLoaded] = useState<Map<string, TreeNode[]>>(() => new Map());
94
98
  const [loading, setLoading] = useState<Set<string>>(() => new Set());
95
99
  const [focusId, setFocusId] = useState<string | null>(null);
@@ -99,6 +99,7 @@ export interface UiText {
99
99
  now: string;
100
100
  clear: string;
101
101
  dateTimePlaceholder: string;
102
+ invalid: string;
102
103
  }>;
103
104
  /** TagsInput (optional) — remove ใช้ {tag}, duplicate ใช้ {tag}, max ใช้ {max} */
104
105
  tags?: Partial<{ placeholder: string; remove: string; duplicate: string; max: string }>;
@@ -185,6 +186,7 @@ export const UI_TEXT_TH: UiText = {
185
186
  now: "ตอนนี้",
186
187
  clear: "ล้าง",
187
188
  dateTimePlaceholder: "เลือกวันและเวลา",
189
+ invalid: "รูปแบบเวลาไม่ถูกต้อง (เช่น 14:30)",
188
190
  },
189
191
  tags: {
190
192
  placeholder: "พิมพ์แล้วกด Enter",
@@ -274,6 +276,7 @@ export const UI_TEXT_EN: UiText = {
274
276
  now: "Now",
275
277
  clear: "Clear",
276
278
  dateTimePlaceholder: "Pick date and time",
279
+ invalid: "Invalid time (e.g. 14:30)",
277
280
  },
278
281
  tags: {
279
282
  placeholder: "Type and press Enter",
@@ -1,7 +1,7 @@
1
1
  "use client";
2
2
 
3
3
  import { usePathname, useRouter, useSearchParams } from "next/navigation";
4
- import { useCallback, useMemo, useTransition } from "react";
4
+ import { useCallback, useEffect, useMemo, useRef, useTransition } from "react";
5
5
 
6
6
  /**
7
7
  * เก็บ state ของตาราง (หน้า, ขนาดหน้า, การเรียง, คำค้น, ตัวกรอง) ไว้ใน URL
@@ -71,10 +71,18 @@ export function useTableSearchParams(defaults?: { pageSize?: number }) {
71
71
  };
72
72
  }, [searchParams, defaults?.pageSize]);
73
73
 
74
+ // setter หลายตัวในจังหวะเดียว (เช่น setFilter แล้ว setSort) ต้องต่อจากค่าที่เพิ่งตั้ง ไม่ใช่ searchParams ตอน render
75
+ // (router.replace ยังไม่ทันเปลี่ยน URL → ตัวหลังเขียนทับตัวแรก) — ล้างเมื่อ URL เปลี่ยนจริงแล้ว
76
+ const latest = useRef<string | null>(null);
77
+ useEffect(() => {
78
+ latest.current = null;
79
+ }, [searchParams]);
80
+
74
81
  const push = useCallback(
75
82
  (mutate: (params: URLSearchParams) => void) => {
76
- const params = new URLSearchParams(searchParams.toString());
83
+ const params = new URLSearchParams(latest.current ?? searchParams.toString());
77
84
  mutate(params);
85
+ latest.current = params.toString();
78
86
  startTransition(() => {
79
87
  router.replace(`${pathname}?${params.toString()}`, { scroll: false });
80
88
  });
@@ -1,6 +1,6 @@
1
1
  "use client";
2
2
 
3
- import { useSyncExternalStore } from "react";
3
+ import { useCallback, useSyncExternalStore } from "react";
4
4
 
5
5
  /**
6
6
  * นาฬิกากลางต่อ interval — ทุก component ที่ขอ interval เท่ากันแชร์ `setInterval` ตัวเดียว
@@ -45,9 +45,15 @@ const zero = () => 0;
45
45
  */
46
46
  export function useTicker(intervalMs: number | undefined): number {
47
47
  const active = typeof intervalMs === "number" && intervalMs > 0;
48
- return useSyncExternalStore(
49
- active ? (l) => subscribe(intervalMs, l) : subscribeNoop,
50
- active ? () => getTicker(intervalMs).tick : zero,
51
- zero,
48
+ // subscribe ต้องคงที่ข้าม render — ฟังก์ชันใหม่ทุก render ทำให้ unsubscribe/subscribe ใหม่
49
+ // (ผู้ใช้ตัวเดียว = clearInterval แล้วเริ่มนับใหม่ทุกครั้งที่ parent render → ถ้า render บ่อยกว่า interval จะไม่ tick เลย)
50
+ const subscribeActive = useCallback(
51
+ (listener: () => void) => (active ? subscribe(intervalMs, listener) : subscribeNoop()),
52
+ [active, intervalMs],
52
53
  );
54
+ const getSnapshot = useCallback(
55
+ () => (active ? getTicker(intervalMs).tick : 0),
56
+ [active, intervalMs],
57
+ );
58
+ return useSyncExternalStore(subscribeActive, getSnapshot, zero);
53
59
  }
package/src/index.ts CHANGED
@@ -90,6 +90,7 @@ export { EmptyState } from "./components/shared/empty-state";
90
90
  export { EnvSnippet, randomSecret } from "./components/shared/env-snippet";
91
91
  export { type FileKind, FileTypeIcon, getFileKind } from "./components/shared/file-type-icon";
92
92
  export { FilterBar, type FilterBarFilter } from "./components/shared/filter-bar";
93
+ export { DateTimePicker } from "./components/shared/form/date-time-picker";
93
94
  export { FormFieldBoolean } from "./components/shared/form/form-field-boolean";
94
95
  export { FormFieldColorSelect } from "./components/shared/form/form-field-color-select";
95
96
  export { FormFieldCombobox } from "./components/shared/form/form-field-combobox";
@@ -117,7 +118,6 @@ export { SliderField, type SliderFieldProps } from "./components/shared/form/sli
117
118
  export { TagsInput } from "./components/shared/form/tags-input";
118
119
  export { TextareaField } from "./components/shared/form/textarea-field";
119
120
  export {
120
- DateTimePicker,
121
121
  parseTimeInput,
122
122
  TimeColumns,
123
123
  TimePicker,
@@ -1,5 +1,5 @@
1
1
  import { differenceInCalendarDays, formatDistanceToNow } from "date-fns";
2
- import { th } from "date-fns/locale";
2
+ import { th } from "date-fns/locale/th";
3
3
 
4
4
  /**
5
5
  * ฟอร์แมตวันที่แบบไทยที่ primitives ใช้ร่วมกัน (DatePicker, RelativeDate)
package/src/lib/month.ts CHANGED
@@ -29,8 +29,20 @@ export function toMonthKey(date: Date): MonthKey {
29
29
  return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`;
30
30
  }
31
31
 
32
- export function currentMonthKey(now: Date = new Date()): MonthKey {
33
- return toMonthKey(now);
32
+ /**
33
+ * เดือนปัจจุบัน — ไม่ส่ง `timeZone` = โซนเวลาของเครื่อง
34
+ * ส่ง `timeZone` (เช่น "Asia/Bangkok") เมื่อค่าถูกคำนวณทั้งบน server (UTC) และ browser แล้วต้องตรงกัน (กัน hydration mismatch ต้นเดือน)
35
+ */
36
+ export function currentMonthKey(now: Date = new Date(), timeZone?: string): MonthKey {
37
+ if (!timeZone) return toMonthKey(now);
38
+ const parts = new Intl.DateTimeFormat("en-CA", {
39
+ timeZone,
40
+ year: "numeric",
41
+ month: "2-digit",
42
+ }).formatToParts(now);
43
+ const year = parts.find((p) => p.type === "year")?.value ?? String(now.getFullYear());
44
+ const month = parts.find((p) => p.type === "month")?.value ?? "01";
45
+ return `${year}-${month}`;
34
46
  }
35
47
 
36
48
  /** เลื่อนเดือน (ลบ = ย้อนหลัง) ข้ามปีให้เอง: shiftMonth("2026-01", -1) → "2025-12" */