@suphark/ui 0.25.0 → 0.26.0

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 (38) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/package.json +1 -2
  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/design-system/design-system-showcase.tsx +63 -15
  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/base/action-button.tsx +7 -14
  13. package/src/components/shared/button/base/link-button.tsx +5 -14
  14. package/src/components/shared/button/confirm-action-button.tsx +17 -3
  15. package/src/components/shared/chat-thread.tsx +7 -0
  16. package/src/components/shared/client-redirect.tsx +16 -1
  17. package/src/components/shared/form/date-time-picker.tsx +150 -0
  18. package/src/components/shared/form/form-field-autocomplete.tsx +93 -0
  19. package/src/components/shared/form/form-field-input.tsx +23 -81
  20. package/src/components/shared/form/number-input.tsx +19 -2
  21. package/src/components/shared/form/tags-input.tsx +7 -2
  22. package/src/components/shared/form/time-picker.tsx +34 -124
  23. package/src/components/shared/month-stepper.tsx +10 -4
  24. package/src/components/shared/row-actions.tsx +9 -3
  25. package/src/components/shared/settings-layout.tsx +17 -6
  26. package/src/components/shared/step-form.tsx +25 -4
  27. package/src/components/shared/time/client-date-formatter.tsx +2 -1
  28. package/src/components/shared/tree-view.tsx +6 -2
  29. package/src/components/shared/ui-text.tsx +3 -0
  30. package/src/components/ui/input.tsx +1 -1
  31. package/src/components/ui/native-select.tsx +1 -1
  32. package/src/components/ui/select.tsx +1 -1
  33. package/src/components/ui/textarea.tsx +1 -1
  34. package/src/hooks/use-table-search-params.ts +10 -2
  35. package/src/hooks/use-ticker.ts +11 -5
  36. package/src/index.ts +1 -1
  37. package/src/lib/formatters.ts +1 -1
  38. package/src/lib/month.ts +14 -2
@@ -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",
@@ -8,7 +8,7 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
8
8
  type={type}
9
9
  data-slot="input"
10
10
  className={cn(
11
- "h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
11
+ "h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-body transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
12
12
  className,
13
13
  )}
14
14
  {...props}
@@ -19,7 +19,7 @@ function NativeSelect({ className, size = "default", ...props }: NativeSelectPro
19
19
  <select
20
20
  data-slot="native-select"
21
21
  data-size={size}
22
- className="h-8 w-full min-w-0 appearance-none rounded-lg border border-input bg-transparent py-1 pr-8 pl-2.5 text-sm transition-colors outline-none select-none selection:bg-primary selection:text-primary-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] data-[size=sm]:py-0.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40"
22
+ className="h-8 w-full min-w-0 appearance-none rounded-lg border border-input bg-transparent py-1 pr-8 pl-2.5 text-body transition-colors outline-none select-none selection:bg-primary selection:text-primary-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] data-[size=sm]:py-0.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40"
23
23
  {...props}
24
24
  />
25
25
  <ChevronDownIcon
@@ -40,7 +40,7 @@ function SelectTrigger({
40
40
  data-slot="select-trigger"
41
41
  data-size={size}
42
42
  className={cn(
43
- "flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
43
+ "flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-body whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
44
44
  className,
45
45
  )}
46
46
  {...props}
@@ -6,7 +6,7 @@ function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
6
6
  <textarea
7
7
  data-slot="textarea"
8
8
  className={cn(
9
- "flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-base transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
9
+ "flex field-sizing-content min-h-16 w-full rounded-lg border border-input bg-transparent px-2.5 py-2 text-body transition-colors outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
10
10
  className,
11
11
  )}
12
12
  {...props}
@@ -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" */