@suphark/ui 0.39.0 → 0.42.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.
package/CHANGELOG.md CHANGED
@@ -2,7 +2,64 @@
2
2
 
3
3
  รูปแบบตาม [Keep a Changelog](https://keepachangelog.com/) · semver: patch = หน้าตา/บั๊ก, minor = component/prop ใหม่, major = เปลี่ยน/ลบ props
4
4
 
5
- ## [0.39.0] — 2026-10-07
5
+ ## [0.42.0] — 2026-10-07
6
+
7
+ ### Added
8
+
9
+ - **`DatePicker` พิมพ์ลัด** (เจ้าของขอ): `t` = วันนี้, `t+3` / `t+3d` = อีก 3 วัน, `t-2` = 2 วันก่อน, `t+2w` สัปดาห์, `t+1m` เดือน, `t+1y` ปี (`today`/`วันนี้` และหน่วยไทย วัน/สัปดาห์/เดือน/ปี ก็ได้; ปลายเดือนตัดให้อยู่ในเดือนถัดไปแบบ date-fns) · ช่วง: `t-7..t`, `t ~ t+1m`, `t ถึง t+3` — ใช้ได้กับ `parseSmartDateInput` / `parseSmartDateRange` ทุกที่ (รวม `FormFieldDate`)
10
+ - ปุ่ม (i) ท้ายช่องพิมพ์ เปิดตารางวิธีพิมพ์ในกล่องเดียวกัน (ไม่ใช่ tooltip — popup ซ้อน popup และจอสัมผัสเปิด tooltip ไม่ได้) · ข้อความจาก `UiText.datePicker.help*`
11
+
12
+ ### Changed
13
+
14
+ - **เลิกปุ่มทางลัด** (วันนี้/พรุ่งนี้/+1 สัปดาห์ …) ทั้งแบบวันเดียวและช่วง — ใช้การพิมพ์ลัดแทนเพื่อลดพื้นที่ · `showPresets` ยังรับได้แต่ไม่มีผล (deprecated)
15
+ - ช่วงวันที่: **ไม่ใส่ปี = ปีปัจจุบันเสมอ** (0.41.0 ยืมปีจากฝั่งขวา)
16
+ - placeholder ช่องพิมพ์: "เช่น 15/3, t+3, t+1m" / ช่วง "เช่น 1/7-5/7, t-7..t"
17
+
18
+ ## [0.41.0] — 2026-10-07
19
+
20
+ > ประกาศพร้อม publish แล้ว แต่ยังไม่ขึ้น npm — รวมอยู่ใน 0.42.0
21
+
22
+
23
+ ### Added
24
+
25
+ - **`DatePicker mode="range"` — เลือกช่วงวันที่** (เจ้าของขอ): คลิกวันเริ่มแล้ววันสุดท้าย (มีบรรทัดบอก "เลือกวันสุดท้ายของช่วง" ระหว่างเลือก) หรือพิมพ์ **"1/7-5/7" = 1–5 ก.ค.** · รองรับ "1-5/7", "1/7 - 5/8/68", "1/7 ถึง 5/7", "1/7 to 5/7", "1/7–5/7" · ปีที่ไม่ได้พิมพ์ฝั่งซ้ายใช้ปีฝั่งขวา · ใส่กลับด้านแล้วสลับให้ · "1-7-2568" ยังเป็นวันเดียวเหมือนเดิม (`-` เป็นตัวคั่นช่วงเมื่อมี `/` หรือ `.`)
26
+ - แสดงด้วย `Intl.DateTimeFormat#formatRange` ตาม `locale`: "1–5 ก.ค. 2569" / en-GB "1–5 Jul 2026"
27
+ - ทางลัดของช่วง: วันนี้, 7/30 วันล่าสุด, เดือนนี้, เดือนที่แล้ว, 7/30 วันข้างหน้า
28
+ - ฟอร์ม: `name={["from", "to"]}` ส่ง `YYYY-MM-DD` สองช่อง (ใช้กับ `<form method="get">` ของตัวกรองได้ตรง ๆ) + `required` · `value`/`defaultValue` = `{ from, to }` (Date หรือ "YYYY-MM-DD") · `onChange(range | null)`
29
+ - `parseSmartDateRange(input)` · types `DatePickerRange`, `DatePickerSingleProps`, `DatePickerRangeProps` (`DatePickerProps` เป็น union — ของเดิมที่ไม่ส่ง `mode` ใช้ได้เหมือนเดิม)
30
+ - `UiText.datePicker`: `rangePlaceholder`, `rangeSmartPlaceholder`, `pickEnd`, ทางลัดของช่วง
31
+ - showcase: `/components/date-picker` เพิ่มตัวอย่างช่วงวันที่
32
+
33
+
34
+ ### Fixed
35
+
36
+ - `DatePicker`: ปฏิทินใน popup มีที่ว่างซ้ายขวามาก (เจ้าของแจ้ง) — กล่องกว้างตายตัว 320–340px แต่ช่องวันที่ 28px ปฏิทินจึงกว้างแค่ ~200px · ตอนนี้ช่องวันที่ 40px (กดง่ายขึ้นด้วย) และกล่องกว้างพอดีปฏิทิน 306px (commit แรกหลุด format ของ biome — แก้ในรุ่นเดียวกัน)
37
+
38
+ ## [0.40.0] — 2026-10-07
39
+
40
+ > ประกาศพร้อม publish แล้ว แต่ยังไม่ขึ้น npm — รวมอยู่ใน 0.40.1
41
+
42
+
43
+ ### Added
44
+
45
+ - **`DatePicker` ใช้ในฟอร์มและหลายภาษาได้** (ขอจาก Pi Auth — `<input type="date">` แสดง "10/02/2027" อ่านได้ทั้ง 2 ต.ค. และ 10 ก.พ.):
46
+ - `locale` (BCP 47): แสดงผ่าน Intl `{ day, month: "short", year }` ทั้งในช่อง ปฏิทิน และวันที่สัมพัทธ์ — `"en-GB"` = "2 Oct 2027", `"en"` = "Oct 2, 2027", `"th"` = "2 ต.ค. 2570" · ไม่ส่ง = รูปแบบไทยเดิม
47
+ - `name` (+ `defaultValue` แบบไม่ควบคุม, `value`/`onChange` ไม่บังคับแล้ว): ส่ง `YYYY-MM-DD` ตามวันที่ local ไปกับ FormData ของ `<form action>` · `required` ทำงานกับการตรวจของเบราว์เซอร์และตาข่ายฟอร์ม (ชื่อช่องจาก `LabeledField`)
48
+ - `showRelative` (ค่าเริ่มต้น true) · `toDateInputValue(date)` export ให้ใช้เอง · type `DatePickerProps`
49
+ - `UiText.datePicker` (placeholder, ทางลัด, ปุ่ม) ไทย/อังกฤษในตัว
50
+
51
+ ### Changed
52
+
53
+ - `DatePicker` ย้ายไป `components/shared/date/date-picker.tsx` — ไม่ได้มาจาก shadcn CLI (registry ของ base-nova ไม่มี date-picker) แต่ยกมาจาก supplychain; `components/ui/date-picker` ยัง re-export ให้ import path เดิมใช้ได้
54
+ - ข้อความเดิมที่ฝังไว้ย้ายไป UiText: placeholder ค่าเริ่มต้นจาก "เลือกวันที่อ้างอิงราคา..." (ของ supplychain) เป็น "เลือกวันที่", ทางลัดเป็นภาษาไทย ("วันนี้", "+1 สัปดาห์" …) แทนอังกฤษปนไทย, หัวข้อ "Quick Preset" → "ทางลัด"
55
+ - สีดิบ (slate/emerald) → semantic token · ปุ่ม "ตกลง" ของช่องพิมพ์วันที่เป็น `type="button"` (เดิม submit — ส่งฟอร์มแม่ได้ถ้าไม่ได้กด Enter) · ปุ่มล้างมี `aria-label` และกดด้วยคีย์บอร์ดได้
56
+
57
+ ### Fixed
58
+
59
+ - `value="YYYY-MM-DD"` อ่านเป็น UTC แล้วเลื่อนวันในโซนเวลาตะวันตก → อ่านเป็นวันที่ local
60
+ - วันที่สัมพัทธ์ ("(พรุ่งนี้)") แสดงหลัง mount — ขึ้นกับวันของเครื่องผู้ใช้ จึง hydration mismatch ได้ข้ามวัน/โซนเวลา (ค้างใน todo)
61
+ - showcase: `/components/date-picker` เพิ่มตัวอย่างในฟอร์ม server action (`name` + `locale="en-GB"` + `required`)
62
+
6
63
 
7
64
  ### Added
8
65
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@suphark/ui",
3
- "version": "0.39.0",
3
+ "version": "0.42.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Design system กลางของ Suphark apps: shadcn/ui (base-nova, Base UI, Tailwind v4) + shared components + token/ฟอนต์ Noto Sans Thai Looped — ส่งเป็น source ให้แอป Next transpile เอง",
@@ -2,6 +2,7 @@
2
2
 
3
3
  import { Button } from "@suphark/ui/components/shared/button/button";
4
4
  import { Combobox } from "@suphark/ui/components/shared/combobox";
5
+ import { DatePicker } from "@suphark/ui/components/shared/date/date-picker";
5
6
  import { MultiSelectCombobox } from "@suphark/ui/components/shared/multi-select-combobox";
6
7
  import { Badge } from "@suphark/ui/components/ui/badge";
7
8
  import {
@@ -11,7 +12,6 @@ import {
11
12
  CardHeader,
12
13
  CardTitle,
13
14
  } from "@suphark/ui/components/ui/card";
14
- import { DatePicker } from "@suphark/ui/components/ui/date-picker";
15
15
  import {
16
16
  Field,
17
17
  FieldDescription,
@@ -0,0 +1,702 @@
1
+ "use client";
2
+
3
+ import { Button } from "@suphark/ui/components/shared/button/button";
4
+ import { useUiText } from "@suphark/ui/components/shared/ui-text";
5
+ import { Calendar } from "@suphark/ui/components/ui/calendar";
6
+ import {
7
+ InputGroup,
8
+ InputGroupAddon,
9
+ InputGroupButton,
10
+ InputGroupInput,
11
+ } from "@suphark/ui/components/ui/input-group";
12
+ import { Popover, PopoverContent, PopoverTrigger } from "@suphark/ui/components/ui/popover";
13
+ import { useMounted } from "@suphark/ui/hooks/use-mounted";
14
+ import { formatRelativeThaiDate, formatThaiFullDate } from "@suphark/ui/lib/formatters";
15
+ import { cn } from "@suphark/ui/lib/utils";
16
+ import { addDays, addMonths, addWeeks, addYears } from "date-fns";
17
+ import { CalendarIcon, Check, InfoIcon, X } from "lucide-react";
18
+ import { useMemo, useState } from "react";
19
+
20
+ /**
21
+ * Smart date parser supporting intuitive Thai & international date inputs:
22
+ * - "1/7" -> 1 July of current year
23
+ * - "1/7/25" -> 1 July 2025 CE
24
+ * - "1/7/68" -> 1 July 2025 CE (BE 2568)
25
+ * - "1/7/2568" -> 1 July 2025 CE (BE 2568)
26
+ */
27
+ export function parseSmartDateInput(input: string): Date | null {
28
+ if (!input?.trim()) return null;
29
+ const clean = input.trim().replace(/\s+/g, "");
30
+ const shortcut = parseDateShortcut(clean);
31
+ if (shortcut) return shortcut;
32
+
33
+ const parts = clean.split(/[/\-.]/);
34
+ if (parts.length < 2 || parts.length > 3) return null;
35
+
36
+ const day = parseInt(parts[0] ?? "", 10);
37
+ const month = parseInt(parts[1] ?? "", 10);
38
+ if (Number.isNaN(day) || Number.isNaN(month) || day < 1 || day > 31 || month < 1 || month > 12) {
39
+ return null;
40
+ }
41
+
42
+ const currentYear = new Date().getFullYear();
43
+ let year = currentYear;
44
+
45
+ if (parts.length === 3 && parts[2]) {
46
+ const rawYear = parseInt(parts[2], 10);
47
+ if (Number.isNaN(rawYear)) return null;
48
+
49
+ if (rawYear > 2400) {
50
+ // Full Buddhist Era year (e.g. 2568 -> 2025 CE)
51
+ year = rawYear - 543;
52
+ } else if (rawYear >= 50 && rawYear < 100) {
53
+ // Short BE year (e.g. 68 -> 2568 -> 2025 CE)
54
+ year = 2500 + rawYear - 543;
55
+ } else if (rawYear < 50) {
56
+ // Short AD year (e.g. 25 -> 2025 CE)
57
+ year = 2000 + rawYear;
58
+ } else {
59
+ year = rawYear;
60
+ }
61
+ }
62
+
63
+ const d = new Date(year, month - 1, day);
64
+ if (d.getFullYear() === year && d.getMonth() === month - 1 && d.getDate() === day) {
65
+ return d;
66
+ }
67
+
68
+ return null;
69
+ }
70
+
71
+ /**
72
+ * ตัวย่อวันที่: `t` = วันนี้ · `t+3` / `t+3d` = อีก 3 วัน · `t-2` = 2 วันก่อน · `t+2w` สัปดาห์ · `t+1m` เดือน · `t+1y` ปี
73
+ * (`today` / `วันนี้` แทน `t` ได้, หน่วยไทย วัน/สัปดาห์/เดือน/ปี ก็ได้)
74
+ */
75
+ function parseDateShortcut(clean: string): Date | null {
76
+ const match = /^(?:t|today|วันนี้)(?:([+-])(\d{1,4})(d|w|m|y|วัน|สัปดาห์|อาทิตย์|เดือน|ปี)?)?$/i.exec(
77
+ clean,
78
+ );
79
+ if (!match) return null;
80
+ const now = new Date();
81
+ const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
82
+ if (!match[1]) return today;
83
+ const amount = Number(match[2]) * (match[1] === "-" ? -1 : 1);
84
+ switch ((match[3] ?? "d").toLowerCase()) {
85
+ case "w":
86
+ case "สัปดาห์":
87
+ case "อาทิตย์":
88
+ return addWeeks(today, amount);
89
+ case "m":
90
+ case "เดือน":
91
+ return addMonths(today, amount);
92
+ case "y":
93
+ case "ปี":
94
+ return addYears(today, amount);
95
+ default:
96
+ return addDays(today, amount);
97
+ }
98
+ }
99
+
100
+ /**
101
+ * พิมพ์ช่วงวันที่: "1/7-5/7" (1–5 ก.ค.), "1-5/7", "1/7 - 5/8/68", "1/7 ถึง 5/7", "1/7 to 5/7", "1/7–5/7",
102
+ * ตัวย่อ "t-7..t", "t ~ t+1m" · ไม่ได้พิมพ์ปี = ปีปัจจุบัน · ใส่กลับด้านได้ (สลับให้)
103
+ * `-` เป็นตัวคั่นช่วงเมื่อมี `/` หรือ `.` อยู่ด้วยเท่านั้น ("1-7-2568" และ "t-3" ยังเป็นวันเดียว) — ตัวย่อใช้ `..`/`~`/`ถึง`/`to`
104
+ */
105
+ export function parseSmartDateRange(input: string): { from: Date; to: Date } | null {
106
+ const raw = input?.trim();
107
+ if (!raw) return null;
108
+ let parts = raw.split(/\s*(?:–|—|~|\.\.|ถึง|\bto\b)\s*|\s+-\s+/i).filter((p) => p.trim() !== "");
109
+ if (parts.length !== 2) {
110
+ const dashes = raw.split("-");
111
+ if (dashes.length !== 2 || !/[/.]/.test(raw)) return null;
112
+ parts = dashes;
113
+ }
114
+ const left = (parts[0] ?? "").trim();
115
+ const right = (parts[1] ?? "").trim();
116
+ const to = parseSmartDateInput(right);
117
+ if (!to) return null;
118
+ let from = parseSmartDateInput(left);
119
+ if (!from) {
120
+ // "1-5/7" → ฝั่งซ้ายมีแค่วัน ใช้เดือน/ปีของฝั่งขวา
121
+ if (!/^\d{1,2}$/.test(left)) return null;
122
+ from = new Date(to.getFullYear(), to.getMonth(), Number(left));
123
+ if (from.getMonth() !== to.getMonth()) return null;
124
+ }
125
+ return from.getTime() <= to.getTime() ? { from, to } : { from: to, to: from };
126
+ }
127
+
128
+ type DateInput = Date | string | number | null | undefined;
129
+
130
+ /**
131
+ * แปลงค่าที่รับมาเป็น Date — `"YYYY-MM-DD"` อ่านเป็นวันที่ local
132
+ * (`new Date("2027-10-02")` อ่านเป็น UTC เที่ยงคืน → ฝั่งตะวันตกของ UTC กลายเป็นวันก่อนหน้า)
133
+ */
134
+ function toDate(value: DateInput): Date | undefined {
135
+ if (value === null || value === undefined || value === "") return undefined;
136
+ if (typeof value === "string") {
137
+ const ymd = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
138
+ if (ymd) return new Date(Number(ymd[1]), Number(ymd[2]) - 1, Number(ymd[3]));
139
+ }
140
+ const date = value instanceof Date ? value : new Date(value);
141
+ return Number.isNaN(date.getTime()) ? undefined : date;
142
+ }
143
+
144
+ /** `YYYY-MM-DD` ตามวันที่ local (ไม่ใช่ toISOString ที่เลื่อนวันตาม timezone) — ค่าที่ส่งในฟอร์ม */
145
+ export function toDateInputValue(date: Date | null | undefined): string {
146
+ if (!date) return "";
147
+ const pad = (n: number) => String(n).padStart(2, "0");
148
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
149
+ }
150
+
151
+ /** "2 Oct 2027" / "2 ต.ค. 2570" ตาม locale — ไม่ส่ง locale = รูปแบบไทยเดิม (เดือนย่อ + พ.ศ.) */
152
+ function formatDate(date: Date, locale: string | undefined): string {
153
+ if (!locale) return formatThaiFullDate(date);
154
+ return new Intl.DateTimeFormat(locale, {
155
+ day: "numeric",
156
+ month: "short",
157
+ year: "numeric",
158
+ }).format(date);
159
+ }
160
+
161
+ /** "พรุ่งนี้" / "in 3 days" — นับเป็นวันตามปฏิทิน */
162
+ function formatRelative(date: Date, locale: string | undefined): string {
163
+ if (!locale) return formatRelativeThaiDate(date);
164
+ const startOf = (d: Date) => new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
165
+ const days = Math.round((startOf(date) - startOf(new Date())) / 86_400_000);
166
+ return new Intl.RelativeTimeFormat(locale, { numeric: "auto" }).format(days, "day");
167
+ }
168
+
169
+ interface DatePickerBaseProps {
170
+ id?: string;
171
+ /** ต้องเลือกวันที่ก่อนส่งฟอร์ม — ไม่เลือกแล้วกดส่ง เบราว์เซอร์บล็อกและตาข่ายฟอร์มบอกชื่อช่องให้ */
172
+ required?: boolean;
173
+ /**
174
+ * ภาษาของวันที่ (BCP 47 เช่น "en", "th") — แสดงแบบ "2 Oct 2027" / "2 ต.ค. 2570" ทั้งในช่อง ปฏิทิน และวันที่สัมพัทธ์
175
+ * ไม่ส่ง = รูปแบบไทยเดิม · ข้อความอื่นมาจาก `UiText.datePicker`
176
+ */
177
+ locale?: string;
178
+ /** ไม่ส่ง = `UiText.datePicker.placeholder` */
179
+ placeholder?: string;
180
+ disabled?: boolean;
181
+ className?: string;
182
+ allowClear?: boolean;
183
+ /** @deprecated 0.42.0 — ปุ่มทางลัดถูกแทนด้วยการพิมพ์ลัด (`t`, `t+3`, `t+1m` …) ใส่ได้แต่ไม่มีผล */
184
+ showPresets?: boolean;
185
+ showSmartInput?: boolean;
186
+ }
187
+
188
+ export interface DatePickerSingleProps extends DatePickerBaseProps {
189
+ /** `"single"` (ค่าเริ่มต้น) = วันเดียว · `"range"` = ช่วงวันที่ */
190
+ mode?: "single";
191
+ /** ควบคุมจากภายนอก (`"YYYY-MM-DD"` อ่านเป็นวันที่ local) — ใช้แบบไม่ควบคุมให้ส่ง `defaultValue` แทน */
192
+ value?: DateInput;
193
+ /** ค่าเริ่มต้นแบบไม่ควบคุม (ใช้คู่ `name` ใน `<form action>`) — `null` / `""` = เริ่มว่าง */
194
+ defaultValue?: DateInput;
195
+ onChange?: (date: Date | null) => void;
196
+ /**
197
+ * ชื่อฟิลด์ในฟอร์ม — ส่งค่า `YYYY-MM-DD` (วันที่ local) ไปกับ FormData ของ `<form action>`/server action
198
+ * ว่าง = ส่ง `""`
199
+ */
200
+ name?: string;
201
+ /** แสดงวันที่สัมพัทธ์ต่อท้าย เช่น "(พรุ่งนี้)" — ค่าเริ่มต้น true */
202
+ showRelative?: boolean;
203
+ }
204
+
205
+ /** ช่วงวันที่ — ทั้งสองฝั่งรวมวันนั้นด้วย (`to` = วันสุดท้ายของช่วง) */
206
+ export interface DatePickerRange {
207
+ from: Date;
208
+ to: Date;
209
+ }
210
+
211
+ export interface DatePickerRangeProps extends DatePickerBaseProps {
212
+ mode: "range";
213
+ /** ควบคุมจากภายนอก — แต่ละฝั่งรับ Date หรือ `"YYYY-MM-DD"` · `null` = ว่าง */
214
+ value?: { from: DateInput; to: DateInput } | null;
215
+ /** ค่าเริ่มต้นแบบไม่ควบคุม (ใช้คู่ `name`) */
216
+ defaultValue?: { from: DateInput; to: DateInput } | null;
217
+ onChange?: (range: DatePickerRange | null) => void;
218
+ /**
219
+ * ชื่อฟิลด์สองช่อง `[ชื่อวันเริ่ม, ชื่อวันสุดท้าย]` เช่น `["from", "to"]` — ส่ง `YYYY-MM-DD` แยกกัน
220
+ * (ใช้กับ `<form method="get">` ของตัวกรองได้ตรง ๆ) · ว่าง = `""` ทั้งคู่
221
+ */
222
+ name?: [string, string];
223
+ }
224
+
225
+ export type DatePickerProps = DatePickerSingleProps | DatePickerRangeProps;
226
+
227
+ /**
228
+ * เลือกวันที่ — วันเดียว (ค่าเริ่มต้น) หรือ `mode="range"` เป็นช่วง (คลิกวันเริ่มแล้ววันสุดท้าย
229
+ * หรือพิมพ์ "1/7-5/7") · พิมพ์ "15/3", "15/3/69" ได้ · มีทางลัด · ใช้ในฟอร์มด้วย `name`
230
+ */
231
+ export function DatePicker(props: DatePickerProps) {
232
+ return props.mode === "range" ? <RangeDatePicker {...props} /> : <SingleDatePicker {...props} />;
233
+ }
234
+
235
+ /** ช่องที่มองไม่เห็นสำหรับส่งค่าไปกับฟอร์ม — ไม่ใช้ type="hidden" เพราะ hidden ไม่ถูกตรวจ `required` */
236
+ function FormValue({
237
+ name,
238
+ value,
239
+ required,
240
+ disabled,
241
+ }: {
242
+ name: string;
243
+ value: string;
244
+ required: boolean;
245
+ disabled: boolean;
246
+ }) {
247
+ // อยู่หลังปุ่มเสมอ: label ที่ครอบ (LabeledField) คลิกแล้วไปที่ปุ่ม ไม่ใช่ช่องนี้ · ตาข่ายฟอร์มอ่านชื่อจาก label เดียวกัน
248
+ return (
249
+ <input
250
+ name={name}
251
+ value={value}
252
+ onChange={() => {}}
253
+ required={required}
254
+ disabled={disabled}
255
+ tabIndex={-1}
256
+ aria-hidden
257
+ className="sr-only"
258
+ />
259
+ );
260
+ }
261
+
262
+ function SingleDatePicker({
263
+ id,
264
+ value,
265
+ defaultValue,
266
+ onChange,
267
+ name,
268
+ required = false,
269
+ locale,
270
+ placeholder,
271
+ disabled = false,
272
+ className,
273
+ allowClear = true,
274
+ showSmartInput = true,
275
+ showRelative = true,
276
+ }: DatePickerSingleProps) {
277
+ const text = useUiText().datePicker;
278
+ const mounted = useMounted();
279
+ const [open, setOpen] = useState(false);
280
+ const [smartText, setSmartText] = useState("");
281
+ // ไม่ควบคุม = มี defaultValue หรือไม่มีทั้ง value และ onChange (เช่นใช้แค่ name ในฟอร์ม)
282
+ const controlled = value !== undefined || (defaultValue === undefined && onChange !== undefined);
283
+ const [own, setOwn] = useState<Date | null>(() => toDate(defaultValue) ?? null);
284
+
285
+ const selectedDate = controlled ? toDate(value) : (own ?? undefined);
286
+ const isValidDate = selectedDate !== undefined;
287
+
288
+ const parsedSmartDate = useMemo(() => parseSmartDateInput(smartText), [smartText]);
289
+
290
+ const change = (date: Date | null) => {
291
+ if (!controlled) setOwn(date);
292
+ onChange?.(date);
293
+ };
294
+
295
+ const handleSelectDate = (date: Date | undefined) => {
296
+ change(date ?? null);
297
+ setOpen(false);
298
+ setSmartText("");
299
+ };
300
+
301
+ const handleSmartSubmit = () => {
302
+ if (parsedSmartDate) {
303
+ change(parsedSmartDate);
304
+ setOpen(false);
305
+ setSmartText("");
306
+ }
307
+ };
308
+
309
+ const displayMonth = parsedSmartDate || selectedDate;
310
+ // ปฏิทินใช้ locale เดียวกับช่อง (ชื่อเดือน/วัน/ปี) — ไม่ส่ง locale = ค่าเดิมของ Calendar
311
+ const calendarFormatters = locale
312
+ ? {
313
+ formatMonthDropdown: (d: Date) => d.toLocaleString(locale, { month: "short" }),
314
+ formatYearDropdown: (d: Date) => d.toLocaleString(locale, { year: "numeric" }),
315
+ formatWeekdayName: (d: Date) => d.toLocaleString(locale, { weekday: "narrow" }),
316
+ formatCaption: (d: Date) => d.toLocaleString(locale, { month: "long", year: "numeric" }),
317
+ }
318
+ : undefined;
319
+
320
+ return (
321
+ <>
322
+ <Popover
323
+ open={open}
324
+ onOpenChange={(o) => {
325
+ setOpen(o);
326
+ if (!o) setSmartText("");
327
+ }}
328
+ >
329
+ <PopoverTrigger
330
+ render={
331
+ <Button
332
+ id={id}
333
+ variant="outline"
334
+ disabled={disabled}
335
+ aria-required={required || undefined}
336
+ className={cn(
337
+ "h-8 w-full justify-between bg-background pr-2 pl-3 text-left font-normal text-xs",
338
+ !isValidDate && "text-muted-foreground",
339
+ className,
340
+ )}
341
+ />
342
+ }
343
+ >
344
+ <div className="flex items-center gap-2 truncate">
345
+ <CalendarIcon className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
346
+ <span className="truncate">
347
+ {isValidDate ? (
348
+ <>
349
+ <span className="font-medium text-foreground">
350
+ {formatDate(selectedDate, locale)}
351
+ </span>
352
+ {/* วันที่สัมพัทธ์ขึ้นกับ "วันนี้" ของเครื่องผู้ใช้ — แสดงหลัง mount กัน hydration mismatch */}
353
+ {showRelative && mounted && (
354
+ <span className="ml-1 text-muted-foreground text-xs">
355
+ ({formatRelative(selectedDate, locale)})
356
+ </span>
357
+ )}
358
+ </>
359
+ ) : (
360
+ <span>{placeholder ?? text?.placeholder ?? "เลือกวันที่"}</span>
361
+ )}
362
+ </span>
363
+ </div>
364
+ {allowClear && isValidDate && !disabled && (
365
+ <span
366
+ role="button"
367
+ tabIndex={0}
368
+ aria-label={text?.clear ?? "ล้างวันที่"}
369
+ className="ml-1 cursor-pointer rounded p-0.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
370
+ onClick={(e) => {
371
+ e.stopPropagation();
372
+ change(null);
373
+ }}
374
+ onKeyDown={(e) => {
375
+ if (e.key !== "Enter" && e.key !== " ") return;
376
+ e.preventDefault();
377
+ e.stopPropagation();
378
+ change(null);
379
+ }}
380
+ >
381
+ <X className="h-3.5 w-3.5" />
382
+ </span>
383
+ )}
384
+ </PopoverTrigger>
385
+ {/* กว้างพอดีปฏิทิน: ช่องวันที่ 40px × 7 + p-3 + ขอบ (เดิมกล่อง 320–340px แต่ช่อง 28px → ว่างซ้ายขวามาก) */}
386
+ <PopoverContent className="w-[306px] border bg-background p-3 shadow-xl" align="start">
387
+ {showSmartInput && (
388
+ <SmartInput
389
+ value={smartText}
390
+ onChange={setSmartText}
391
+ onSubmit={handleSmartSubmit}
392
+ preview={parsedSmartDate ? formatDate(parsedSmartDate, locale) : null}
393
+ placeholder={text?.smartPlaceholder ?? "เช่น 15/3, t+3, t+1m"}
394
+ range={false}
395
+ />
396
+ )}
397
+
398
+ {/* Visual DayPicker Calendar */}
399
+ <div className="flex w-full justify-center">
400
+ <Calendar
401
+ mode="single"
402
+ selected={selectedDate}
403
+ onSelect={handleSelectDate}
404
+ disabled={disabled}
405
+ captionLayout="dropdown"
406
+ startMonth={new Date(1990, 0)}
407
+ endMonth={new Date(2040, 11)}
408
+ defaultMonth={displayMonth}
409
+ formatters={calendarFormatters}
410
+ className="p-0 [--cell-size:--spacing(10)]"
411
+ />
412
+ </div>
413
+ </PopoverContent>
414
+ </Popover>
415
+ {name !== undefined && (
416
+ <FormValue
417
+ name={name}
418
+ value={toDateInputValue(selectedDate)}
419
+ required={required}
420
+ disabled={disabled}
421
+ />
422
+ )}
423
+ </>
424
+ );
425
+ }
426
+
427
+ /** "1 – 5 ก.ค. 2569" / "1–5 Jul 2026" — ใช้ formatRange ของ Intl (ย่อเดือน/ปีที่ซ้ำให้เอง) */
428
+ function formatRange(range: DatePickerRange, locale: string | undefined): string {
429
+ const fmt = new Intl.DateTimeFormat(locale ?? "th-TH", {
430
+ day: "numeric",
431
+ month: "short",
432
+ year: "numeric",
433
+ });
434
+ return fmt.formatRange(range.from, range.to);
435
+ }
436
+
437
+ function toRange(value: { from: DateInput; to: DateInput } | null | undefined) {
438
+ const from = toDate(value?.from);
439
+ const to = toDate(value?.to);
440
+ if (!from || !to) return undefined;
441
+ return from.getTime() <= to.getTime() ? { from, to } : { from: to, to: from };
442
+ }
443
+
444
+ function RangeDatePicker({
445
+ id,
446
+ value,
447
+ defaultValue,
448
+ onChange,
449
+ name,
450
+ required = false,
451
+ locale,
452
+ placeholder,
453
+ disabled = false,
454
+ className,
455
+ allowClear = true,
456
+ showSmartInput = true,
457
+ }: DatePickerRangeProps) {
458
+ const text = useUiText().datePicker;
459
+ const [open, setOpen] = useState(false);
460
+ const [smartText, setSmartText] = useState("");
461
+ const controlled = value !== undefined || (defaultValue === undefined && onChange !== undefined);
462
+ const [own, setOwn] = useState<DatePickerRange | null>(() => toRange(defaultValue) ?? null);
463
+ // ระหว่างคลิกเลือก: คลิกแรก = วันเริ่ม (ยังไม่ปิด) คลิกที่สอง = วันสุดท้าย แล้วปิด
464
+ const [start, setStart] = useState<Date | null>(null);
465
+
466
+ const selected = controlled ? toRange(value) : (own ?? undefined);
467
+ const parsed = useMemo(() => parseSmartDateRange(smartText), [smartText]);
468
+
469
+ const close = () => {
470
+ setOpen(false);
471
+ setSmartText("");
472
+ setStart(null);
473
+ };
474
+ const change = (range: DatePickerRange | null) => {
475
+ if (!controlled) setOwn(range);
476
+ onChange?.(range);
477
+ };
478
+ const commit = (range: DatePickerRange | null) => {
479
+ change(range);
480
+ close();
481
+ };
482
+
483
+ const calendarFormatters = locale
484
+ ? {
485
+ formatMonthDropdown: (d: Date) => d.toLocaleString(locale, { month: "short" }),
486
+ formatYearDropdown: (d: Date) => d.toLocaleString(locale, { year: "numeric" }),
487
+ formatWeekdayName: (d: Date) => d.toLocaleString(locale, { weekday: "narrow" }),
488
+ formatCaption: (d: Date) => d.toLocaleString(locale, { month: "long", year: "numeric" }),
489
+ }
490
+ : undefined;
491
+ const calendarSelected = start ? { from: start, to: undefined } : selected;
492
+
493
+ return (
494
+ <>
495
+ <Popover
496
+ open={open}
497
+ onOpenChange={(o) => {
498
+ if (o) setOpen(true);
499
+ else close();
500
+ }}
501
+ >
502
+ <PopoverTrigger
503
+ render={
504
+ <Button
505
+ id={id}
506
+ variant="outline"
507
+ disabled={disabled}
508
+ aria-required={required || undefined}
509
+ className={cn(
510
+ "h-8 w-full justify-between bg-background pr-2 pl-3 text-left font-normal text-xs",
511
+ !selected && "text-muted-foreground",
512
+ className,
513
+ )}
514
+ />
515
+ }
516
+ >
517
+ <div className="flex items-center gap-2 truncate">
518
+ <CalendarIcon className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
519
+ <span className="truncate">
520
+ {selected ? (
521
+ <span className="font-medium text-foreground">{formatRange(selected, locale)}</span>
522
+ ) : (
523
+ <span>{placeholder ?? text?.rangePlaceholder ?? "เลือกช่วงวันที่"}</span>
524
+ )}
525
+ </span>
526
+ </div>
527
+ {allowClear && selected && !disabled && (
528
+ <span
529
+ role="button"
530
+ tabIndex={0}
531
+ aria-label={text?.clear ?? "ล้างวันที่"}
532
+ className="ml-1 cursor-pointer rounded p-0.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
533
+ onClick={(e) => {
534
+ e.stopPropagation();
535
+ change(null);
536
+ }}
537
+ onKeyDown={(e) => {
538
+ if (e.key !== "Enter" && e.key !== " ") return;
539
+ e.preventDefault();
540
+ e.stopPropagation();
541
+ change(null);
542
+ }}
543
+ >
544
+ <X className="h-3.5 w-3.5" />
545
+ </span>
546
+ )}
547
+ </PopoverTrigger>
548
+ <PopoverContent className="w-[306px] border bg-background p-3 shadow-xl" align="start">
549
+ {showSmartInput && (
550
+ <SmartInput
551
+ value={smartText}
552
+ onChange={setSmartText}
553
+ onSubmit={() => parsed && commit(parsed)}
554
+ preview={parsed ? formatRange(parsed, locale) : null}
555
+ placeholder={text?.rangeSmartPlaceholder ?? "เช่น 1/7-5/7, t-7..t"}
556
+ range
557
+ />
558
+ )}
559
+
560
+ <div className="flex w-full justify-center">
561
+ <Calendar
562
+ mode="range"
563
+ selected={calendarSelected}
564
+ onSelect={(_range, day) => {
565
+ // คุมสองคลิกเอง: react-day-picker คลิกแรกให้ from = to (ช่วงหนึ่งวัน) จึงแยกคลิกแรกไม่ออก
566
+ if (!start) {
567
+ setStart(day);
568
+ return;
569
+ }
570
+ commit(
571
+ day.getTime() < start.getTime()
572
+ ? { from: day, to: start }
573
+ : { from: start, to: day },
574
+ );
575
+ }}
576
+ disabled={disabled}
577
+ captionLayout="dropdown"
578
+ startMonth={new Date(1990, 0)}
579
+ endMonth={new Date(2040, 11)}
580
+ defaultMonth={parsed?.from ?? start ?? selected?.from}
581
+ formatters={calendarFormatters}
582
+ className="p-0 [--cell-size:--spacing(10)]"
583
+ />
584
+ </div>
585
+ {start && (
586
+ <p className="mt-2 text-center text-muted-foreground text-xs">
587
+ {text?.pickEnd ?? "เลือกวันสุดท้ายของช่วง"}
588
+ </p>
589
+ )}
590
+ </PopoverContent>
591
+ </Popover>
592
+ {name !== undefined && (
593
+ <>
594
+ <FormValue
595
+ name={name[0]}
596
+ value={toDateInputValue(selected?.from)}
597
+ required={required}
598
+ disabled={disabled}
599
+ />
600
+ <FormValue
601
+ name={name[1]}
602
+ value={toDateInputValue(selected?.to)}
603
+ required={required}
604
+ disabled={disabled}
605
+ />
606
+ </>
607
+ )}
608
+ </>
609
+ );
610
+ }
611
+
612
+ /**
613
+ * ช่องพิมพ์วันที่ + ปุ่ม (i) เปิดวิธีพิมพ์ — คำอธิบายแสดงในกล่องเดียวกัน (ไม่ใช่ tooltip: popup ซ้อน popup
614
+ * และจอสัมผัสเปิด tooltip ไม่ได้)
615
+ */
616
+ function SmartInput({
617
+ value,
618
+ onChange,
619
+ onSubmit,
620
+ preview,
621
+ placeholder,
622
+ range,
623
+ }: {
624
+ value: string;
625
+ onChange: (value: string) => void;
626
+ onSubmit: () => void;
627
+ preview: string | null;
628
+ placeholder: string;
629
+ range: boolean;
630
+ }) {
631
+ const text = useUiText().datePicker;
632
+ const [help, setHelp] = useState(false);
633
+ const rows: Array<[string, string]> = [
634
+ ["15/3", text?.helpDate ?? "15 มี.ค. ปีนี้ (ไม่ใส่ปี = ปีปัจจุบัน)"],
635
+ ["15/3/69 · 15/3/2026", text?.helpYear ?? "ใส่ปี พ.ศ. หรือ ค.ศ. ได้ (2 หรือ 4 หลัก)"],
636
+ ["t", text?.helpToday ?? "วันนี้"],
637
+ ["t+3 · t-2", text?.helpDays ?? "อีก 3 วัน · 2 วันก่อน (d = วัน)"],
638
+ ["t+2w · t+1m · t+1y", text?.helpUnits ?? "สัปดาห์ · เดือน · ปี"],
639
+ ...(range
640
+ ? ([
641
+ ["1/7-5/7 · 1-5/7", text?.helpRange ?? "1–5 ก.ค."],
642
+ ["t-7..t · t ~ t+1m", text?.helpRangeShortcut ?? "ช่วงจากตัวย่อ (คั่นด้วย .. ~ ถึง to)"],
643
+ ] as Array<[string, string]>)
644
+ : []),
645
+ ];
646
+ return (
647
+ <div className="mb-3 flex flex-col gap-1.5 border-b pb-2.5">
648
+ <InputGroup className="h-8">
649
+ <InputGroupInput
650
+ value={value}
651
+ onChange={(e) => onChange(e.target.value)}
652
+ onKeyDown={(event) => {
653
+ if (event.key === "Enter") {
654
+ event.preventDefault();
655
+ event.stopPropagation();
656
+ onSubmit();
657
+ }
658
+ }}
659
+ placeholder={placeholder}
660
+ className="h-8 text-xs"
661
+ />
662
+ <InputGroupAddon align="inline-end">
663
+ <InputGroupButton
664
+ type="button"
665
+ size="icon-xs"
666
+ aria-label={text?.help ?? "วิธีพิมพ์วันที่"}
667
+ aria-expanded={help}
668
+ onClick={() => setHelp((v) => !v)}
669
+ className={help ? "text-foreground" : "text-muted-foreground"}
670
+ >
671
+ <InfoIcon />
672
+ </InputGroupButton>
673
+ <InputGroupButton
674
+ type="button"
675
+ variant="default"
676
+ size="xs"
677
+ onClick={onSubmit}
678
+ disabled={!preview}
679
+ >
680
+ {text?.apply ?? "ตกลง"}
681
+ </InputGroupButton>
682
+ </InputGroupAddon>
683
+ </InputGroup>
684
+ {preview && (
685
+ <span className="flex items-center gap-1 font-medium text-success text-xs">
686
+ <Check className="size-3" />
687
+ {preview}
688
+ </span>
689
+ )}
690
+ {help && (
691
+ <dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 rounded-md bg-muted/60 p-2 text-xs">
692
+ {rows.map(([example, meaning]) => (
693
+ <div key={example} className="contents">
694
+ <dt className="font-mono text-foreground">{example}</dt>
695
+ <dd className="text-muted-foreground">{meaning}</dd>
696
+ </div>
697
+ ))}
698
+ </dl>
699
+ )}
700
+ </div>
701
+ );
702
+ }
@@ -1,6 +1,6 @@
1
1
  "use client";
2
2
 
3
- import { DatePicker } from "@suphark/ui/components/ui/date-picker";
3
+ import { DatePicker } from "@suphark/ui/components/shared/date/date-picker";
4
4
  import {
5
5
  FormControl,
6
6
  FormField,
@@ -175,6 +175,40 @@ export interface UiText {
175
175
  characterCount?: string;
176
176
  /** NumberInput (optional) */
177
177
  number?: Partial<{ increment: string; decrement: string }>;
178
+ /** DatePicker (optional) — ทางลัดวันที่ + ช่องพิมพ์วันที่ */
179
+ datePicker?: Partial<{
180
+ placeholder: string;
181
+ smartPlaceholder: string;
182
+ apply: string;
183
+ presets: string;
184
+ today: string;
185
+ yesterday: string;
186
+ tomorrow: string;
187
+ nextWeek: string;
188
+ lastWeek: string;
189
+ nextMonth: string;
190
+ lastMonth: string;
191
+ clear: string;
192
+ /** โหมดช่วงวันที่ (`mode="range"`) */
193
+ rangePlaceholder: string;
194
+ rangeSmartPlaceholder: string;
195
+ /** ปุ่ม (i) และคำอธิบายวิธีพิมพ์ (0.42.0) */
196
+ help: string;
197
+ helpDate: string;
198
+ helpYear: string;
199
+ helpToday: string;
200
+ helpDays: string;
201
+ helpUnits: string;
202
+ helpRange: string;
203
+ helpRangeShortcut: string;
204
+ pickEnd: string;
205
+ last7Days: string;
206
+ last30Days: string;
207
+ thisMonth: string;
208
+ previousMonth: string;
209
+ next7Days: string;
210
+ next30Days: string;
211
+ }>;
178
212
  /** TimePicker / DateTimePicker (optional) */
179
213
  time?: Partial<{
180
214
  choose: string;
@@ -340,6 +374,37 @@ export const UI_TEXT_TH: UiText = {
340
374
  presence: { online: "ออนไลน์", away: "ไม่อยู่", busy: "ไม่ว่าง", offline: "ออฟไลน์" },
341
375
  characterCount: "พิมพ์แล้ว {count} จาก {max} ตัวอักษร",
342
376
  number: { increment: "เพิ่ม", decrement: "ลด" },
377
+ datePicker: {
378
+ placeholder: "เลือกวันที่",
379
+ smartPlaceholder: "เช่น 15/3, t+3, t+1m",
380
+ apply: "ตกลง",
381
+ presets: "ทางลัด",
382
+ today: "วันนี้",
383
+ yesterday: "เมื่อวาน",
384
+ tomorrow: "พรุ่งนี้",
385
+ nextWeek: "+1 สัปดาห์",
386
+ lastWeek: "-1 สัปดาห์",
387
+ nextMonth: "+1 เดือน",
388
+ lastMonth: "-1 เดือน",
389
+ clear: "ล้างวันที่",
390
+ rangePlaceholder: "เลือกช่วงวันที่",
391
+ rangeSmartPlaceholder: "เช่น 1/7-5/7, t-7..t",
392
+ help: "วิธีพิมพ์วันที่",
393
+ helpDate: "15 มี.ค. ปีนี้ (ไม่ใส่ปี = ปีปัจจุบัน)",
394
+ helpYear: "ใส่ปี พ.ศ. หรือ ค.ศ. ได้ (2 หรือ 4 หลัก)",
395
+ helpToday: "วันนี้",
396
+ helpDays: "อีก 3 วัน · 2 วันก่อน (d = วัน)",
397
+ helpUnits: "สัปดาห์ · เดือน · ปี",
398
+ helpRange: "1–5 ก.ค.",
399
+ helpRangeShortcut: "ช่วงจากตัวย่อ (คั่นด้วย .. ~ ถึง to)",
400
+ pickEnd: "เลือกวันสุดท้ายของช่วง",
401
+ last7Days: "7 วันล่าสุด",
402
+ last30Days: "30 วันล่าสุด",
403
+ thisMonth: "เดือนนี้",
404
+ previousMonth: "เดือนที่แล้ว",
405
+ next7Days: "7 วันข้างหน้า",
406
+ next30Days: "30 วันข้างหน้า",
407
+ },
343
408
  time: {
344
409
  choose: "เลือกเวลา",
345
410
  hour: "ชั่วโมง",
@@ -511,6 +576,37 @@ export const UI_TEXT_EN: UiText = {
511
576
  presence: { online: "Online", away: "Away", busy: "Busy", offline: "Offline" },
512
577
  characterCount: "{count} of {max} characters",
513
578
  number: { increment: "Increase", decrement: "Decrease" },
579
+ datePicker: {
580
+ placeholder: "Pick a date",
581
+ smartPlaceholder: "e.g. 15/3, t+3, t+1m",
582
+ apply: "Apply",
583
+ presets: "Shortcuts",
584
+ today: "Today",
585
+ yesterday: "Yesterday",
586
+ tomorrow: "Tomorrow",
587
+ nextWeek: "+1 week",
588
+ lastWeek: "-1 week",
589
+ nextMonth: "+1 month",
590
+ lastMonth: "-1 month",
591
+ clear: "Clear date",
592
+ rangePlaceholder: "Pick a date range",
593
+ rangeSmartPlaceholder: "e.g. 1/7-5/7, t-7..t",
594
+ help: "How to type a date",
595
+ helpDate: "15 Mar this year (no year = current year)",
596
+ helpYear: "Year in CE or BE (2 or 4 digits)",
597
+ helpToday: "Today",
598
+ helpDays: "In 3 days · 2 days ago (d = days)",
599
+ helpUnits: "Weeks · months · years",
600
+ helpRange: "1–5 Jul",
601
+ helpRangeShortcut: "Range from shortcuts (separate with .. ~ to)",
602
+ pickEnd: "Pick the last day of the range",
603
+ last7Days: "Last 7 days",
604
+ last30Days: "Last 30 days",
605
+ thisMonth: "This month",
606
+ previousMonth: "Last month",
607
+ next7Days: "Next 7 days",
608
+ next30Days: "Next 30 days",
609
+ },
514
610
  time: {
515
611
  choose: "Choose time",
516
612
  hour: "Hour",
@@ -1,308 +1,14 @@
1
- "use client";
2
-
3
- import { Button } from "@suphark/ui/components/ui/button";
4
- import { Calendar } from "@suphark/ui/components/ui/calendar";
5
- import {
6
- InputGroup,
7
- InputGroupAddon,
8
- InputGroupButton,
9
- InputGroupInput,
10
- } from "@suphark/ui/components/ui/input-group";
11
- import { Popover, PopoverContent, PopoverTrigger } from "@suphark/ui/components/ui/popover";
12
- import { formatRelativeThaiDate, formatThaiFullDate } from "@suphark/ui/lib/formatters";
13
- import { cn } from "@suphark/ui/lib/utils";
14
- import { addDays, addMonths, addWeeks, subDays, subMonths, subWeeks } from "date-fns";
15
- import { CalendarIcon, Check, Clock, X } from "lucide-react";
16
- import { useMemo, useState } from "react";
17
-
18
1
  /**
19
- * Smart date parser supporting intuitive Thai & international date inputs:
20
- * - "1/7" -> 1 July of current year
21
- * - "1/7/25" -> 1 July 2025 CE
22
- * - "1/7/68" -> 1 July 2025 CE (BE 2568)
23
- * - "1/7/2568" -> 1 July 2025 CE (BE 2568)
2
+ * ย้ายไป `components/shared/date/date-picker.tsx` แล้ว (0.40.0) — ไฟล์นี้ไม่ได้มาจาก shadcn CLI
3
+ * (registry ของ base-nova ไม่มี date-picker) แต่ยกมาจาก supplychain · คงไว้ให้ import path เดิมใช้ได้
24
4
  */
25
- export function parseSmartDateInput(input: string): Date | null {
26
- if (!input?.trim()) return null;
27
- const clean = input.trim().replace(/\s+/g, "");
28
-
29
- const parts = clean.split(/[/\-.]/);
30
- if (parts.length < 2 || parts.length > 3) return null;
31
-
32
- const day = parseInt(parts[0] ?? "", 10);
33
- const month = parseInt(parts[1] ?? "", 10);
34
- if (Number.isNaN(day) || Number.isNaN(month) || day < 1 || day > 31 || month < 1 || month > 12) {
35
- return null;
36
- }
37
-
38
- const currentYear = new Date().getFullYear();
39
- let year = currentYear;
40
-
41
- if (parts.length === 3 && parts[2]) {
42
- const rawYear = parseInt(parts[2], 10);
43
- if (Number.isNaN(rawYear)) return null;
44
-
45
- if (rawYear > 2400) {
46
- // Full Buddhist Era year (e.g. 2568 -> 2025 CE)
47
- year = rawYear - 543;
48
- } else if (rawYear >= 50 && rawYear < 100) {
49
- // Short BE year (e.g. 68 -> 2568 -> 2025 CE)
50
- year = 2500 + rawYear - 543;
51
- } else if (rawYear < 50) {
52
- // Short AD year (e.g. 25 -> 2025 CE)
53
- year = 2000 + rawYear;
54
- } else {
55
- year = rawYear;
56
- }
57
- }
58
-
59
- const d = new Date(year, month - 1, day);
60
- if (d.getFullYear() === year && d.getMonth() === month - 1 && d.getDate() === day) {
61
- return d;
62
- }
63
-
64
- return null;
65
- }
66
-
67
- interface DatePickerProps {
68
- id?: string;
69
- value: Date | string | number | null | undefined;
70
- onChange: (date: Date | null) => void;
71
- placeholder?: string;
72
- disabled?: boolean;
73
- className?: string;
74
- allowClear?: boolean;
75
- showPresets?: boolean;
76
- showSmartInput?: boolean;
77
- }
78
-
79
- export function DatePicker({
80
- id,
81
- value,
82
- onChange,
83
- placeholder = "เลือกวันที่อ้างอิงราคา...",
84
- disabled = false,
85
- className,
86
- allowClear = true,
87
- showPresets = true,
88
- showSmartInput = true,
89
- }: DatePickerProps) {
90
- const [open, setOpen] = useState(false);
91
- const [smartText, setSmartText] = useState("");
92
-
93
- const selectedDate = value ? new Date(value) : undefined;
94
- const isValidDate = selectedDate && !Number.isNaN(selectedDate.getTime());
95
-
96
- const parsedSmartDate = useMemo(() => parseSmartDateInput(smartText), [smartText]);
97
-
98
- const handleSelectDate = (date: Date | undefined) => {
99
- onChange(date || null);
100
- setOpen(false);
101
- setSmartText("");
102
- };
103
-
104
- const applyPreset = (date: Date | null) => {
105
- onChange(date);
106
- setOpen(false);
107
- setSmartText("");
108
- };
109
-
110
- const handleSmartSubmit = () => {
111
- if (parsedSmartDate) {
112
- onChange(parsedSmartDate);
113
- setOpen(false);
114
- setSmartText("");
115
- }
116
- };
117
-
118
- const today = new Date();
119
- const displayMonth = parsedSmartDate || (isValidDate ? selectedDate : undefined);
120
-
121
- return (
122
- <Popover
123
- open={open}
124
- onOpenChange={(o) => {
125
- setOpen(o);
126
- if (!o) setSmartText("");
127
- }}
128
- >
129
- <PopoverTrigger
130
- render={
131
- <Button
132
- id={id}
133
- variant="outline"
134
- disabled={disabled}
135
- className={cn(
136
- "h-8 w-full justify-between bg-background pr-2 pl-3 text-left font-normal text-xs",
137
- !isValidDate && "text-muted-foreground",
138
- className,
139
- )}
140
- />
141
- }
142
- >
143
- <div className="flex items-center gap-2 truncate">
144
- <CalendarIcon className="h-3.5 w-3.5 shrink-0 text-slate-500" />
145
- <span className="truncate">
146
- {isValidDate ? (
147
- <>
148
- <span className="font-medium text-foreground">
149
- {formatThaiFullDate(selectedDate)}
150
- </span>
151
- <span className="ml-1 text-muted-foreground text-xs">
152
- ({formatRelativeThaiDate(selectedDate)})
153
- </span>
154
- </>
155
- ) : (
156
- <span>{placeholder}</span>
157
- )}
158
- </span>
159
- </div>
160
- {allowClear && isValidDate && !disabled && (
161
- <span
162
- role="button"
163
- tabIndex={0}
164
- className="ml-1 cursor-pointer rounded p-0.5 text-slate-400 transition-colors hover:bg-slate-100 hover:text-slate-600"
165
- onClick={(e) => {
166
- e.stopPropagation();
167
- onChange(null);
168
- }}
169
- >
170
- <X className="h-3.5 w-3.5" />
171
- </span>
172
- )}
173
- </PopoverTrigger>
174
- <PopoverContent
175
- className="w-[320px] border bg-background p-3 shadow-xl sm:w-[340px]"
176
- align="start"
177
- >
178
- {/* Smart Date Text Input */}
179
- {showSmartInput && (
180
- <div className="mb-3 flex flex-col gap-1.5 border-b pb-2.5">
181
- <InputGroup className="h-8">
182
- <InputGroupInput
183
- value={smartText}
184
- onChange={(e) => setSmartText(e.target.value)}
185
- onKeyDown={(event) => {
186
- if (event.key === "Enter") {
187
- event.preventDefault();
188
- event.stopPropagation();
189
- handleSmartSubmit();
190
- }
191
- }}
192
- placeholder="เช่น 1/7, 1/7/25, 1-7-2568..."
193
- className="h-8 text-xs"
194
- />
195
- <InputGroupAddon align="inline-end">
196
- <InputGroupButton
197
- type="submit"
198
- variant="default"
199
- size="xs"
200
- onClick={handleSmartSubmit}
201
- disabled={!parsedSmartDate}
202
- >
203
- ตกลง
204
- </InputGroupButton>
205
- </InputGroupAddon>
206
- </InputGroup>
207
- {parsedSmartDate && (
208
- <span className="flex items-center gap-1 font-medium text-emerald-600 text-xs">
209
- <Check className="size-3" />
210
- {formatThaiFullDate(parsedSmartDate)}
211
- </span>
212
- )}
213
- </div>
214
- )}
215
-
216
- {/* Quick Presets Section (Flex Wrap & Clean Spacing) */}
217
- {showPresets && (
218
- <div className="mb-3 border-b pb-2.5">
219
- <div className="mb-1.5 flex items-center gap-1 font-semibold text-muted-foreground text-xs">
220
- <Clock className="h-3 w-3" />
221
- Quick Preset
222
- </div>
223
- <div className="flex flex-wrap items-center gap-1.5">
224
- <Button
225
- type="button"
226
- variant="outline"
227
- size="sm"
228
- className="h-6 px-2 font-normal text-xs hover:bg-slate-100"
229
- onClick={() => applyPreset(today)}
230
- >
231
- Today
232
- </Button>
233
- <Button
234
- type="button"
235
- variant="outline"
236
- size="sm"
237
- className="h-6 px-2 font-normal text-xs hover:bg-slate-100"
238
- onClick={() => applyPreset(subDays(today, 1))}
239
- >
240
- Yesterday
241
- </Button>
242
- <Button
243
- type="button"
244
- variant="outline"
245
- size="sm"
246
- className="h-6 px-2 font-normal text-xs hover:bg-slate-100"
247
- onClick={() => applyPreset(addDays(today, 1))}
248
- >
249
- Tomorrow
250
- </Button>
251
- <Button
252
- type="button"
253
- variant="outline"
254
- size="sm"
255
- className="h-6 px-2 font-normal text-xs hover:bg-slate-100"
256
- onClick={() => applyPreset(addWeeks(today, 1))}
257
- >
258
- +1 Week
259
- </Button>
260
- <Button
261
- type="button"
262
- variant="outline"
263
- size="sm"
264
- className="h-6 px-2 font-normal text-xs hover:bg-slate-100"
265
- onClick={() => applyPreset(subWeeks(today, 1))}
266
- >
267
- -1 Week
268
- </Button>
269
- <Button
270
- type="button"
271
- variant="outline"
272
- size="sm"
273
- className="h-6 px-2 font-normal text-xs hover:bg-slate-100"
274
- onClick={() => applyPreset(addMonths(today, 1))}
275
- >
276
- +1 Month
277
- </Button>
278
- <Button
279
- type="button"
280
- variant="outline"
281
- size="sm"
282
- className="h-6 px-2 font-normal text-xs hover:bg-slate-100"
283
- onClick={() => applyPreset(subMonths(today, 1))}
284
- >
285
- -1 Month
286
- </Button>
287
- </div>
288
- </div>
289
- )}
290
-
291
- {/* Visual DayPicker Calendar */}
292
- <div className="flex w-full justify-center">
293
- <Calendar
294
- mode="single"
295
- selected={isValidDate ? selectedDate : undefined}
296
- onSelect={handleSelectDate}
297
- disabled={disabled}
298
- captionLayout="dropdown"
299
- startMonth={new Date(1990, 0)}
300
- endMonth={new Date(2040, 11)}
301
- defaultMonth={displayMonth}
302
- className="p-0"
303
- />
304
- </div>
305
- </PopoverContent>
306
- </Popover>
307
- );
308
- }
5
+ export {
6
+ DatePicker,
7
+ type DatePickerProps,
8
+ type DatePickerRange,
9
+ type DatePickerRangeProps,
10
+ type DatePickerSingleProps,
11
+ parseSmartDateInput,
12
+ parseSmartDateRange,
13
+ toDateInputValue,
14
+ } from "@suphark/ui/components/shared/date/date-picker";
package/src/index.ts CHANGED
@@ -76,6 +76,16 @@ export {
76
76
  } from "./components/shared/confirm-dialog";
77
77
  export { CountBadge } from "./components/shared/count-badge";
78
78
  export { DataList, DataRow } from "./components/shared/data-list";
79
+ export {
80
+ DatePicker,
81
+ type DatePickerProps,
82
+ type DatePickerRange,
83
+ type DatePickerRangeProps,
84
+ type DatePickerSingleProps,
85
+ parseSmartDateInput,
86
+ parseSmartDateRange,
87
+ toDateInputValue,
88
+ } from "./components/shared/date/date-picker";
79
89
  export { DateRangePicker } from "./components/shared/date/date-range-picker";
80
90
  export { DescriptionList } from "./components/shared/description-list";
81
91
  export {
@@ -403,7 +413,6 @@ export {
403
413
  ContextMenuSubTrigger,
404
414
  ContextMenuTrigger,
405
415
  } from "./components/ui/context-menu";
406
- export { DatePicker, parseSmartDateInput } from "./components/ui/date-picker";
407
416
  export {
408
417
  Dialog,
409
418
  DialogClose,