@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
@@ -12,14 +12,38 @@ import {
12
12
  Zap,
13
13
  } from "lucide-react";
14
14
  import * as React from "react";
15
- import { ShowcaseDataDisplay } from "./sections/showcase-data-display";
16
- import { ShowcaseFeedback } from "./sections/showcase-feedback";
17
- import { ShowcaseForms } from "./sections/showcase-forms";
18
- import { ShowcaseFoundations } from "./sections/showcase-foundations";
19
- import { ShowcaseGuidelinesRecipes } from "./sections/showcase-guidelines-recipes";
20
- import { ShowcaseOverlaysNavigation } from "./sections/showcase-overlays-navigation";
21
15
  import { ShowcaseOverview } from "./sections/showcase-overview";
22
- import { ShowcasePrimitives } from "./sections/showcase-primitives";
16
+
17
+ // แท็บอื่นนอกจากภาพรวมโหลดเมื่อเปิดแท็บนั้น — เดิม import ทุกแท็บ (ปฏิทิน, ตาราง, dnd-kit, cmdk …) ~1 MB ทั้งที่เห็นทีละแท็บ
18
+ const ShowcaseDataDisplay = React.lazy(() =>
19
+ import("./sections/showcase-data-display").then((m) => ({ default: m.ShowcaseDataDisplay })),
20
+ );
21
+ const ShowcaseFeedback = React.lazy(() =>
22
+ import("./sections/showcase-feedback").then((m) => ({ default: m.ShowcaseFeedback })),
23
+ );
24
+ const ShowcaseForms = React.lazy(() =>
25
+ import("./sections/showcase-forms").then((m) => ({ default: m.ShowcaseForms })),
26
+ );
27
+ const ShowcaseFoundations = React.lazy(() =>
28
+ import("./sections/showcase-foundations").then((m) => ({ default: m.ShowcaseFoundations })),
29
+ );
30
+ const ShowcaseGuidelinesRecipes = React.lazy(() =>
31
+ import("./sections/showcase-guidelines-recipes").then((m) => ({
32
+ default: m.ShowcaseGuidelinesRecipes,
33
+ })),
34
+ );
35
+ const ShowcaseOverlaysNavigation = React.lazy(() =>
36
+ import("./sections/showcase-overlays-navigation").then((m) => ({
37
+ default: m.ShowcaseOverlaysNavigation,
38
+ })),
39
+ );
40
+ const ShowcasePrimitives = React.lazy(() =>
41
+ import("./sections/showcase-primitives").then((m) => ({ default: m.ShowcasePrimitives })),
42
+ );
43
+
44
+ function SectionLoading() {
45
+ return <div className="h-96 animate-pulse rounded-xl bg-muted/40" aria-busy="true" />;
46
+ }
23
47
 
24
48
  export function DesignSystemShowcase({
25
49
  links,
@@ -32,10 +56,13 @@ export function DesignSystemShowcase({
32
56
 
33
57
  /** สลับแท็บแล้วเลื่อนไปหัวข้อ — เลื่อนใน effect หลังแท็บใหม่ render แล้ว (ตำแหน่งจึงถูก) ชดเชย header ที่ sticky */
34
58
  const [pendingAnchor, setPendingAnchor] = React.useState<string | null>(null);
59
+ const tries = React.useRef(0);
60
+ const [retry, setRetry] = React.useState(0);
35
61
  const jumpTo = React.useCallback((tab: string, anchor?: string) => {
36
62
  setActiveTab(tab);
37
63
  setPendingAnchor(anchor ?? "__top__");
38
64
  }, []);
65
+ // biome-ignore lint/correctness/useExhaustiveDependencies: retry ใช้เป็นตัวกระตุ้นให้ลองหาหัวข้อซ้ำระหว่างรอแท็บ lazy
39
66
  React.useEffect(() => {
40
67
  if (!pendingAnchor) return;
41
68
  // หน่วงเล็กน้อยให้ Tabs โฟกัส trigger ใหม่เสร็จก่อน (การโฟกัสเลื่อนหน้าไปที่แถบแท็บ) แล้วค่อยเลื่อนไปหัวข้อ
@@ -47,15 +74,22 @@ export function DesignSystemShowcase({
47
74
  }
48
75
  const el = document.getElementById(pendingAnchor);
49
76
  if (!el) {
77
+ // แท็บที่โหลดแบบ lazy อาจยัง render ไม่เสร็จ — ลองใหม่ได้ถึง ~2 วินาที
78
+ if (tries.current++ < 25) {
79
+ setRetry((r) => r + 1);
80
+ return;
81
+ }
82
+ tries.current = 0;
50
83
  setPendingAnchor(null);
51
84
  return;
52
85
  }
86
+ tries.current = 0;
53
87
  el.style.scrollMarginTop = "88px"; // ชดเชย header ที่ sticky
54
88
  el.scrollIntoView({ behavior: "smooth", block: "start" });
55
89
  setPendingAnchor(null);
56
90
  }, 80);
57
91
  return () => window.clearTimeout(timer);
58
- }, [pendingAnchor]);
92
+ }, [pendingAnchor, retry]);
59
93
 
60
94
  const tabItems = [
61
95
  { value: "overview", label: "ภาพรวม", icon: LayoutGrid },
@@ -109,31 +143,45 @@ export function DesignSystemShowcase({
109
143
  </TabsContent>
110
144
 
111
145
  <TabsContent value="foundations" className="mt-0 outline-none">
112
- <ShowcaseFoundations />
146
+ <React.Suspense fallback={<SectionLoading />}>
147
+ <ShowcaseFoundations />
148
+ </React.Suspense>
113
149
  </TabsContent>
114
150
 
115
151
  <TabsContent value="primitives" className="mt-0 outline-none">
116
- <ShowcasePrimitives />
152
+ <React.Suspense fallback={<SectionLoading />}>
153
+ <ShowcasePrimitives />
154
+ </React.Suspense>
117
155
  </TabsContent>
118
156
 
119
157
  <TabsContent value="forms" className="mt-0 outline-none">
120
- <ShowcaseForms />
158
+ <React.Suspense fallback={<SectionLoading />}>
159
+ <ShowcaseForms />
160
+ </React.Suspense>
121
161
  </TabsContent>
122
162
 
123
163
  <TabsContent value="data-display" className="mt-0 outline-none">
124
- <ShowcaseDataDisplay />
164
+ <React.Suspense fallback={<SectionLoading />}>
165
+ <ShowcaseDataDisplay />
166
+ </React.Suspense>
125
167
  </TabsContent>
126
168
 
127
169
  <TabsContent value="overlays" className="mt-0 outline-none">
128
- <ShowcaseOverlaysNavigation />
170
+ <React.Suspense fallback={<SectionLoading />}>
171
+ <ShowcaseOverlaysNavigation />
172
+ </React.Suspense>
129
173
  </TabsContent>
130
174
 
131
175
  <TabsContent value="feedback" className="mt-0 outline-none">
132
- <ShowcaseFeedback />
176
+ <React.Suspense fallback={<SectionLoading />}>
177
+ <ShowcaseFeedback />
178
+ </React.Suspense>
133
179
  </TabsContent>
134
180
 
135
181
  <TabsContent value="guidelines-recipes" className="mt-0 outline-none">
136
- <ShowcaseGuidelinesRecipes />
182
+ <React.Suspense fallback={<SectionLoading />}>
183
+ <ShowcaseGuidelinesRecipes />
184
+ </React.Suspense>
137
185
  </TabsContent>
138
186
  </Tabs>
139
187
  </div>
@@ -113,11 +113,31 @@ export function PullToRefresh({
113
113
  window.setTimeout(() => setIsRefreshing(false), 700);
114
114
  };
115
115
 
116
+ // touchmove ต้อง non-passive (preventDefault ระหว่างดึง) ซึ่งทำให้ browser ต้องรอ JS ก่อนเลื่อนหน้าทุกครั้ง
117
+ // → ติดตั้งเฉพาะตอนที่ดึงได้จริง (จอมือถือ + หน้าอยู่บนสุด) ถอดออกเมื่อเลื่อนลงไปแล้ว
118
+ let moveAttached = false;
119
+ const syncMoveListener = () => {
120
+ const root = getRoot();
121
+ const want = isMobileViewport() && !!root && isRootAtTop(root);
122
+ if (want && !moveAttached) {
123
+ window.addEventListener("touchmove", handleTouchMove, { passive: false });
124
+ moveAttached = true;
125
+ } else if (!want && moveAttached && !isPullingRef.current) {
126
+ window.removeEventListener("touchmove", handleTouchMove);
127
+ moveAttached = false;
128
+ }
129
+ };
130
+ const root = getRoot();
131
+ syncMoveListener();
132
+ root?.addEventListener("scroll", syncMoveListener, { passive: true });
133
+ window.addEventListener("resize", syncMoveListener, { passive: true });
134
+
116
135
  window.addEventListener("touchstart", handleTouchStart, { passive: true });
117
- window.addEventListener("touchmove", handleTouchMove, { passive: false });
118
136
  window.addEventListener("touchend", handleTouchEnd, { passive: true });
119
137
  window.addEventListener("touchcancel", resetPull, { passive: true });
120
138
  return () => {
139
+ root?.removeEventListener("scroll", syncMoveListener);
140
+ window.removeEventListener("resize", syncMoveListener);
121
141
  window.removeEventListener("touchstart", handleTouchStart);
122
142
  window.removeEventListener("touchmove", handleTouchMove);
123
143
  window.removeEventListener("touchend", handleTouchEnd);
@@ -13,6 +13,7 @@ import {
13
13
  } from "@suphark/ui/components/ui/dropdown-menu";
14
14
  import { MoreHorizontal } from "lucide-react";
15
15
  import { type ReactElement, type ReactNode, useState } from "react";
16
+ import { toast } from "sonner";
16
17
  import { ConfirmDialog, type ConfirmSpec, normalizeConfirm } from "./confirm-dialog";
17
18
  import { useUiText } from "./ui-text";
18
19
 
@@ -64,16 +65,32 @@ export function ActionMenu({
64
65
  const [confirming, setConfirming] = useState<ActionMenuCommand | null>(null);
65
66
  const [pending, setPending] = useState(false);
66
67
 
67
- const run = async (fn?: () => unknown) => {
68
- if (!fn) return;
68
+ /** คืน true เมื่อสำเร็จ — throw/reject = toast แจ้ง (ไม่ปล่อยเป็น unhandled rejection เงียบ ๆ) */
69
+ const run = async (fn?: () => unknown): Promise<boolean> => {
70
+ if (!fn) return true;
69
71
  setPending(true);
70
72
  try {
71
73
  await fn();
74
+ return true;
75
+ } catch (err) {
76
+ toast.error(
77
+ err instanceof Error && err.message
78
+ ? err.message
79
+ : (text.actionFailed ?? "ดำเนินการไม่สำเร็จ"),
80
+ );
81
+ return false;
72
82
  } finally {
73
83
  setPending(false);
74
84
  }
75
85
  };
76
86
 
87
+ /** ลิงก์ที่มี confirm: ไปหลังยืนยันแล้วเท่านั้น */
88
+ const follow = (item: ActionMenuCommand) => {
89
+ if (!item.href) return;
90
+ if (item.external) window.open(item.href, "_blank", "noopener,noreferrer");
91
+ else window.location.assign(item.href);
92
+ };
93
+
77
94
  const spec = confirming?.confirm ? normalizeConfirm(confirming.confirm) : null;
78
95
 
79
96
  return (
@@ -113,7 +130,8 @@ export function ActionMenu({
113
130
  variant={item.destructive ? "destructive" : "default"}
114
131
  disabled={item.disabled}
115
132
  render={
116
- item.href ? (
133
+ // มี confirm = ยังไม่เป็นลิงก์จริง (กดแล้วถามก่อน แล้วค่อยไป)
134
+ item.href && !item.confirm ? (
117
135
  <a
118
136
  href={item.href}
119
137
  target={item.external ? "_blank" : undefined}
@@ -122,9 +140,8 @@ export function ActionMenu({
122
140
  ) : undefined
123
141
  }
124
142
  onClick={() => {
125
- if (item.href) return;
126
143
  if (item.confirm) setConfirming(item);
127
- else void run(item.onClick);
144
+ else if (!item.href) void run(item.onClick);
128
145
  }}
129
146
  >
130
147
  {content}
@@ -147,8 +164,10 @@ export function ActionMenu({
147
164
  destructive={spec.destructive ?? confirming.destructive}
148
165
  pending={pending}
149
166
  onConfirm={async () => {
150
- await run(confirming.onClick);
167
+ // ล้มเหลว = คง dialog ไว้ให้ลองใหม่ (toast แจ้งแล้วใน run)
168
+ if (!(await run(confirming.onClick))) return;
151
169
  setConfirming(null);
170
+ follow(confirming);
152
171
  }}
153
172
  />
154
173
  )}
@@ -7,6 +7,7 @@ import {
7
7
  InputGroupButton,
8
8
  InputGroupInput,
9
9
  } from "@suphark/ui/components/ui/input-group";
10
+ import { Spinner } from "@suphark/ui/components/ui/spinner";
10
11
  import { cn } from "@suphark/ui/lib/utils";
11
12
  import { EyeIcon, EyeOffIcon } from "lucide-react";
12
13
  import { type ComponentProps, type ReactNode, useState } from "react";
@@ -73,9 +74,10 @@ export function ProviderButtons({
73
74
  p.label,
74
75
  );
75
76
  const label = p.text ?? (grid ? p.label : full);
77
+ const busy = pendingId === p.id;
76
78
  const content = (
77
79
  <>
78
- {p.icon}
80
+ {busy ? <Spinner /> : p.icon}
79
81
  <span className={grid ? "truncate" : "min-w-0 text-balance"}>{label}</span>
80
82
  {p.badge &&
81
83
  (badgePlacement === "corner" ? (
@@ -97,14 +99,14 @@ export function ProviderButtons({
97
99
  p.badge && badgePlacement === "corner" && "relative",
98
100
  );
99
101
  const a11y = grid ? { "aria-label": full, title: full } : {};
100
- const busy = pendingId === p.id;
101
102
  const disabled = p.disabled || (pendingId != null && !busy);
102
103
  return p.href ? (
103
104
  <Button
104
105
  key={p.id}
105
106
  variant="outline"
106
107
  className={buttonClass}
107
- disabled={disabled}
108
+ disabled={disabled || busy}
109
+ aria-busy={busy || undefined}
108
110
  nativeButton={false}
109
111
  render={<a href={p.href} {...a11y} />}
110
112
  >
@@ -1,12 +1,7 @@
1
1
  "use client";
2
2
 
3
3
  import { Button } from "@suphark/ui/components/ui/button";
4
- import {
5
- Tooltip,
6
- TooltipContent,
7
- TooltipProvider,
8
- TooltipTrigger,
9
- } from "@suphark/ui/components/ui/tooltip";
4
+ import { Tooltip, TooltipContent, TooltipTrigger } from "@suphark/ui/components/ui/tooltip";
10
5
  import {
11
6
  type CheckPermissionOptions,
12
7
  type CheckPermissionRequirement,
@@ -118,14 +113,12 @@ export function ActionButton({
118
113
  // 5. Wrap with Tooltip if provided or auto-generated
119
114
  if (finalTooltip) {
120
115
  return (
121
- <TooltipProvider>
122
- <Tooltip>
123
- <TooltipTrigger render={buttonContent as React.ReactElement} />
124
- <TooltipContent side={tooltipSide}>
125
- {typeof finalTooltip === "string" ? <p>{finalTooltip}</p> : finalTooltip}
126
- </TooltipContent>
127
- </Tooltip>
128
- </TooltipProvider>
116
+ <Tooltip>
117
+ <TooltipTrigger delay={0} render={buttonContent as React.ReactElement} />
118
+ <TooltipContent side={tooltipSide}>
119
+ {typeof finalTooltip === "string" ? <p>{finalTooltip}</p> : finalTooltip}
120
+ </TooltipContent>
121
+ </Tooltip>
129
122
  );
130
123
  }
131
124
 
@@ -2,12 +2,7 @@
2
2
 
3
3
  // [FIX 1.1] นำเข้าเฉพาะ Button
4
4
  import { Button } from "@suphark/ui/components/ui/button";
5
- import {
6
- Tooltip,
7
- TooltipContent,
8
- TooltipProvider,
9
- TooltipTrigger,
10
- } from "@suphark/ui/components/ui/tooltip";
5
+ import { Tooltip, TooltipContent, TooltipTrigger } from "@suphark/ui/components/ui/tooltip";
11
6
  import {
12
7
  type CheckPermissionOptions,
13
8
  type CheckPermissionRequirement,
@@ -114,14 +109,10 @@ export function LinkButton({
114
109
  // 3. Wrap with Tooltip if provided
115
110
  if (tooltip) {
116
111
  return (
117
- <TooltipProvider>
118
- <Tooltip>
119
- <TooltipTrigger render={buttonContent as React.ReactElement} />
120
- <TooltipContent>
121
- {typeof tooltip === "string" ? <p>{tooltip}</p> : tooltip}
122
- </TooltipContent>
123
- </Tooltip>
124
- </TooltipProvider>
112
+ <Tooltip>
113
+ <TooltipTrigger delay={0} render={buttonContent as React.ReactElement} />
114
+ <TooltipContent>{typeof tooltip === "string" ? <p>{tooltip}</p> : tooltip}</TooltipContent>
115
+ </Tooltip>
125
116
  );
126
117
  }
127
118
 
@@ -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
+ }