@suphark/ui 0.39.0 → 0.40.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.
package/CHANGELOG.md CHANGED
@@ -2,7 +2,37 @@
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.40.1] — 2026-10-07
6
+
7
+ ### Fixed
8
+
9
+ - `DatePicker`: ปฏิทินใน popup มีที่ว่างซ้ายขวามาก (เจ้าของแจ้ง) — กล่องกว้างตายตัว 320–340px แต่ช่องวันที่ 28px ปฏิทินจึงกว้างแค่ ~200px · ตอนนี้ช่องวันที่ 40px (กดง่ายขึ้นด้วย) และกล่องกว้างพอดีปฏิทิน 306px (commit แรกหลุด format ของ biome — แก้ในรุ่นเดียวกัน)
10
+
11
+ ## [0.40.0] — 2026-10-07
12
+
13
+ > ประกาศพร้อม publish แล้ว แต่ยังไม่ขึ้น npm — รวมอยู่ใน 0.40.1
14
+
15
+
16
+ ### Added
17
+
18
+ - **`DatePicker` ใช้ในฟอร์มและหลายภาษาได้** (ขอจาก Pi Auth — `<input type="date">` แสดง "10/02/2027" อ่านได้ทั้ง 2 ต.ค. และ 10 ก.พ.):
19
+ - `locale` (BCP 47): แสดงผ่าน Intl `{ day, month: "short", year }` ทั้งในช่อง ปฏิทิน และวันที่สัมพัทธ์ — `"en-GB"` = "2 Oct 2027", `"en"` = "Oct 2, 2027", `"th"` = "2 ต.ค. 2570" · ไม่ส่ง = รูปแบบไทยเดิม
20
+ - `name` (+ `defaultValue` แบบไม่ควบคุม, `value`/`onChange` ไม่บังคับแล้ว): ส่ง `YYYY-MM-DD` ตามวันที่ local ไปกับ FormData ของ `<form action>` · `required` ทำงานกับการตรวจของเบราว์เซอร์และตาข่ายฟอร์ม (ชื่อช่องจาก `LabeledField`)
21
+ - `showRelative` (ค่าเริ่มต้น true) · `toDateInputValue(date)` export ให้ใช้เอง · type `DatePickerProps`
22
+ - `UiText.datePicker` (placeholder, ทางลัด, ปุ่ม) ไทย/อังกฤษในตัว
23
+
24
+ ### Changed
25
+
26
+ - `DatePicker` ย้ายไป `components/shared/date/date-picker.tsx` — ไม่ได้มาจาก shadcn CLI (registry ของ base-nova ไม่มี date-picker) แต่ยกมาจาก supplychain; `components/ui/date-picker` ยัง re-export ให้ import path เดิมใช้ได้
27
+ - ข้อความเดิมที่ฝังไว้ย้ายไป UiText: placeholder ค่าเริ่มต้นจาก "เลือกวันที่อ้างอิงราคา..." (ของ supplychain) เป็น "เลือกวันที่", ทางลัดเป็นภาษาไทย ("วันนี้", "+1 สัปดาห์" …) แทนอังกฤษปนไทย, หัวข้อ "Quick Preset" → "ทางลัด"
28
+ - สีดิบ (slate/emerald) → semantic token · ปุ่ม "ตกลง" ของช่องพิมพ์วันที่เป็น `type="button"` (เดิม submit — ส่งฟอร์มแม่ได้ถ้าไม่ได้กด Enter) · ปุ่มล้างมี `aria-label` และกดด้วยคีย์บอร์ดได้
29
+
30
+ ### Fixed
31
+
32
+ - `value="YYYY-MM-DD"` อ่านเป็น UTC แล้วเลื่อนวันในโซนเวลาตะวันตก → อ่านเป็นวันที่ local
33
+ - วันที่สัมพัทธ์ ("(พรุ่งนี้)") แสดงหลัง mount — ขึ้นกับวันของเครื่องผู้ใช้ จึง hydration mismatch ได้ข้ามวัน/โซนเวลา (ค้างใน todo)
34
+ - showcase: `/components/date-picker` เพิ่มตัวอย่างในฟอร์ม server action (`name` + `locale="en-GB"` + `required`)
35
+
6
36
 
7
37
  ### Added
8
38
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@suphark/ui",
3
- "version": "0.39.0",
3
+ "version": "0.40.1",
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,377 @@
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, subDays, subMonths, subWeeks } from "date-fns";
17
+ import { CalendarIcon, Check, Clock, 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
+
31
+ const parts = clean.split(/[/\-.]/);
32
+ if (parts.length < 2 || parts.length > 3) return null;
33
+
34
+ const day = parseInt(parts[0] ?? "", 10);
35
+ const month = parseInt(parts[1] ?? "", 10);
36
+ if (Number.isNaN(day) || Number.isNaN(month) || day < 1 || day > 31 || month < 1 || month > 12) {
37
+ return null;
38
+ }
39
+
40
+ const currentYear = new Date().getFullYear();
41
+ let year = currentYear;
42
+
43
+ if (parts.length === 3 && parts[2]) {
44
+ const rawYear = parseInt(parts[2], 10);
45
+ if (Number.isNaN(rawYear)) return null;
46
+
47
+ if (rawYear > 2400) {
48
+ // Full Buddhist Era year (e.g. 2568 -> 2025 CE)
49
+ year = rawYear - 543;
50
+ } else if (rawYear >= 50 && rawYear < 100) {
51
+ // Short BE year (e.g. 68 -> 2568 -> 2025 CE)
52
+ year = 2500 + rawYear - 543;
53
+ } else if (rawYear < 50) {
54
+ // Short AD year (e.g. 25 -> 2025 CE)
55
+ year = 2000 + rawYear;
56
+ } else {
57
+ year = rawYear;
58
+ }
59
+ }
60
+
61
+ const d = new Date(year, month - 1, day);
62
+ if (d.getFullYear() === year && d.getMonth() === month - 1 && d.getDate() === day) {
63
+ return d;
64
+ }
65
+
66
+ return null;
67
+ }
68
+
69
+ type DateInput = Date | string | number | null | undefined;
70
+
71
+ /**
72
+ * แปลงค่าที่รับมาเป็น Date — `"YYYY-MM-DD"` อ่านเป็นวันที่ local
73
+ * (`new Date("2027-10-02")` อ่านเป็น UTC เที่ยงคืน → ฝั่งตะวันตกของ UTC กลายเป็นวันก่อนหน้า)
74
+ */
75
+ function toDate(value: DateInput): Date | undefined {
76
+ if (value === null || value === undefined || value === "") return undefined;
77
+ if (typeof value === "string") {
78
+ const ymd = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
79
+ if (ymd) return new Date(Number(ymd[1]), Number(ymd[2]) - 1, Number(ymd[3]));
80
+ }
81
+ const date = value instanceof Date ? value : new Date(value);
82
+ return Number.isNaN(date.getTime()) ? undefined : date;
83
+ }
84
+
85
+ /** `YYYY-MM-DD` ตามวันที่ local (ไม่ใช่ toISOString ที่เลื่อนวันตาม timezone) — ค่าที่ส่งในฟอร์ม */
86
+ export function toDateInputValue(date: Date | null | undefined): string {
87
+ if (!date) return "";
88
+ const pad = (n: number) => String(n).padStart(2, "0");
89
+ return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
90
+ }
91
+
92
+ /** "2 Oct 2027" / "2 ต.ค. 2570" ตาม locale — ไม่ส่ง locale = รูปแบบไทยเดิม (เดือนย่อ + พ.ศ.) */
93
+ function formatDate(date: Date, locale: string | undefined): string {
94
+ if (!locale) return formatThaiFullDate(date);
95
+ return new Intl.DateTimeFormat(locale, {
96
+ day: "numeric",
97
+ month: "short",
98
+ year: "numeric",
99
+ }).format(date);
100
+ }
101
+
102
+ /** "พรุ่งนี้" / "in 3 days" — นับเป็นวันตามปฏิทิน */
103
+ function formatRelative(date: Date, locale: string | undefined): string {
104
+ if (!locale) return formatRelativeThaiDate(date);
105
+ const startOf = (d: Date) => new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
106
+ const days = Math.round((startOf(date) - startOf(new Date())) / 86_400_000);
107
+ return new Intl.RelativeTimeFormat(locale, { numeric: "auto" }).format(days, "day");
108
+ }
109
+
110
+ export interface DatePickerProps {
111
+ id?: string;
112
+ /** ควบคุมจากภายนอก (`"YYYY-MM-DD"` อ่านเป็นวันที่ local) — ใช้แบบไม่ควบคุมให้ส่ง `defaultValue` แทน */
113
+ value?: DateInput;
114
+ /** ค่าเริ่มต้นแบบไม่ควบคุม (ใช้คู่ `name` ใน `<form action>`) — `null` = เริ่มว่าง */
115
+ defaultValue?: DateInput;
116
+ onChange?: (date: Date | null) => void;
117
+ /**
118
+ * ชื่อฟิลด์ในฟอร์ม — ส่งค่า `YYYY-MM-DD` (วันที่ local) ไปกับ FormData ของ `<form action>`/server action
119
+ * ว่าง = ส่ง `""`
120
+ */
121
+ name?: string;
122
+ /** ต้องเลือกวันที่ก่อนส่งฟอร์ม — ไม่เลือกแล้วกดส่ง เบราว์เซอร์บล็อกและตาข่ายฟอร์มบอกชื่อช่องให้ */
123
+ required?: boolean;
124
+ /**
125
+ * ภาษาของวันที่ (BCP 47 เช่น "en", "th") — แสดงแบบ "2 Oct 2027" / "2 ต.ค. 2570" ทั้งในช่อง ปฏิทิน และวันที่สัมพัทธ์
126
+ * ไม่ส่ง = รูปแบบไทยเดิม · ข้อความอื่นมาจาก `UiText.datePicker`
127
+ */
128
+ locale?: string;
129
+ /** ไม่ส่ง = `UiText.datePicker.placeholder` */
130
+ placeholder?: string;
131
+ disabled?: boolean;
132
+ className?: string;
133
+ allowClear?: boolean;
134
+ showPresets?: boolean;
135
+ showSmartInput?: boolean;
136
+ /** แสดงวันที่สัมพัทธ์ต่อท้าย เช่น "(พรุ่งนี้)" — ค่าเริ่มต้น true */
137
+ showRelative?: boolean;
138
+ }
139
+
140
+ export function DatePicker({
141
+ id,
142
+ value,
143
+ defaultValue,
144
+ onChange,
145
+ name,
146
+ required = false,
147
+ locale,
148
+ placeholder,
149
+ disabled = false,
150
+ className,
151
+ allowClear = true,
152
+ showPresets = true,
153
+ showSmartInput = true,
154
+ showRelative = true,
155
+ }: DatePickerProps) {
156
+ const text = useUiText().datePicker;
157
+ const mounted = useMounted();
158
+ const [open, setOpen] = useState(false);
159
+ const [smartText, setSmartText] = useState("");
160
+ // ไม่ควบคุม = มี defaultValue หรือไม่มีทั้ง value และ onChange (เช่นใช้แค่ name ในฟอร์ม)
161
+ const controlled = value !== undefined || (defaultValue === undefined && onChange !== undefined);
162
+ const [own, setOwn] = useState<Date | null>(() => toDate(defaultValue) ?? null);
163
+
164
+ const selectedDate = controlled ? toDate(value) : (own ?? undefined);
165
+ const isValidDate = selectedDate !== undefined;
166
+
167
+ const parsedSmartDate = useMemo(() => parseSmartDateInput(smartText), [smartText]);
168
+
169
+ const change = (date: Date | null) => {
170
+ if (!controlled) setOwn(date);
171
+ onChange?.(date);
172
+ };
173
+
174
+ const handleSelectDate = (date: Date | undefined) => {
175
+ change(date ?? null);
176
+ setOpen(false);
177
+ setSmartText("");
178
+ };
179
+
180
+ const applyPreset = (date: Date | null) => {
181
+ change(date);
182
+ setOpen(false);
183
+ setSmartText("");
184
+ };
185
+
186
+ const handleSmartSubmit = () => {
187
+ if (parsedSmartDate) {
188
+ change(parsedSmartDate);
189
+ setOpen(false);
190
+ setSmartText("");
191
+ }
192
+ };
193
+
194
+ const today = new Date();
195
+ const displayMonth = parsedSmartDate || selectedDate;
196
+ const presets: Array<[string, Date]> = [
197
+ [text?.today ?? "วันนี้", today],
198
+ [text?.yesterday ?? "เมื่อวาน", subDays(today, 1)],
199
+ [text?.tomorrow ?? "พรุ่งนี้", addDays(today, 1)],
200
+ [text?.nextWeek ?? "+1 สัปดาห์", addWeeks(today, 1)],
201
+ [text?.lastWeek ?? "-1 สัปดาห์", subWeeks(today, 1)],
202
+ [text?.nextMonth ?? "+1 เดือน", addMonths(today, 1)],
203
+ [text?.lastMonth ?? "-1 เดือน", subMonths(today, 1)],
204
+ ];
205
+ // ปฏิทินใช้ locale เดียวกับช่อง (ชื่อเดือน/วัน/ปี) — ไม่ส่ง locale = ค่าเดิมของ Calendar
206
+ const calendarFormatters = locale
207
+ ? {
208
+ formatMonthDropdown: (d: Date) => d.toLocaleString(locale, { month: "short" }),
209
+ formatYearDropdown: (d: Date) => d.toLocaleString(locale, { year: "numeric" }),
210
+ formatWeekdayName: (d: Date) => d.toLocaleString(locale, { weekday: "narrow" }),
211
+ formatCaption: (d: Date) => d.toLocaleString(locale, { month: "long", year: "numeric" }),
212
+ }
213
+ : undefined;
214
+
215
+ return (
216
+ <>
217
+ <Popover
218
+ open={open}
219
+ onOpenChange={(o) => {
220
+ setOpen(o);
221
+ if (!o) setSmartText("");
222
+ }}
223
+ >
224
+ <PopoverTrigger
225
+ render={
226
+ <Button
227
+ id={id}
228
+ variant="outline"
229
+ disabled={disabled}
230
+ aria-required={required || undefined}
231
+ className={cn(
232
+ "h-8 w-full justify-between bg-background pr-2 pl-3 text-left font-normal text-xs",
233
+ !isValidDate && "text-muted-foreground",
234
+ className,
235
+ )}
236
+ />
237
+ }
238
+ >
239
+ <div className="flex items-center gap-2 truncate">
240
+ <CalendarIcon className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
241
+ <span className="truncate">
242
+ {isValidDate ? (
243
+ <>
244
+ <span className="font-medium text-foreground">
245
+ {formatDate(selectedDate, locale)}
246
+ </span>
247
+ {/* วันที่สัมพัทธ์ขึ้นกับ "วันนี้" ของเครื่องผู้ใช้ — แสดงหลัง mount กัน hydration mismatch */}
248
+ {showRelative && mounted && (
249
+ <span className="ml-1 text-muted-foreground text-xs">
250
+ ({formatRelative(selectedDate, locale)})
251
+ </span>
252
+ )}
253
+ </>
254
+ ) : (
255
+ <span>{placeholder ?? text?.placeholder ?? "เลือกวันที่"}</span>
256
+ )}
257
+ </span>
258
+ </div>
259
+ {allowClear && isValidDate && !disabled && (
260
+ <span
261
+ role="button"
262
+ tabIndex={0}
263
+ aria-label={text?.clear ?? "ล้างวันที่"}
264
+ className="ml-1 cursor-pointer rounded p-0.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
265
+ onClick={(e) => {
266
+ e.stopPropagation();
267
+ change(null);
268
+ }}
269
+ onKeyDown={(e) => {
270
+ if (e.key !== "Enter" && e.key !== " ") return;
271
+ e.preventDefault();
272
+ e.stopPropagation();
273
+ change(null);
274
+ }}
275
+ >
276
+ <X className="h-3.5 w-3.5" />
277
+ </span>
278
+ )}
279
+ </PopoverTrigger>
280
+ {/* กว้างพอดีปฏิทิน: ช่องวันที่ 40px × 7 + p-3 + ขอบ (เดิมกล่อง 320–340px แต่ช่อง 28px → ว่างซ้ายขวามาก) */}
281
+ <PopoverContent className="w-[306px] border bg-background p-3 shadow-xl" align="start">
282
+ {/* Smart Date Text Input */}
283
+ {showSmartInput && (
284
+ <div className="mb-3 flex flex-col gap-1.5 border-b pb-2.5">
285
+ <InputGroup className="h-8">
286
+ <InputGroupInput
287
+ value={smartText}
288
+ onChange={(e) => setSmartText(e.target.value)}
289
+ onKeyDown={(event) => {
290
+ if (event.key === "Enter") {
291
+ event.preventDefault();
292
+ event.stopPropagation();
293
+ handleSmartSubmit();
294
+ }
295
+ }}
296
+ placeholder={text?.smartPlaceholder ?? "เช่น 1/7, 1/7/25, 1-7-2568..."}
297
+ className="h-8 text-xs"
298
+ />
299
+ <InputGroupAddon align="inline-end">
300
+ <InputGroupButton
301
+ type="button"
302
+ variant="default"
303
+ size="xs"
304
+ onClick={handleSmartSubmit}
305
+ disabled={!parsedSmartDate}
306
+ >
307
+ {text?.apply ?? "ตกลง"}
308
+ </InputGroupButton>
309
+ </InputGroupAddon>
310
+ </InputGroup>
311
+ {parsedSmartDate && (
312
+ <span className="flex items-center gap-1 font-medium text-success text-xs">
313
+ <Check className="size-3" />
314
+ {formatDate(parsedSmartDate, locale)}
315
+ </span>
316
+ )}
317
+ </div>
318
+ )}
319
+
320
+ {/* Quick Presets Section (Flex Wrap & Clean Spacing) */}
321
+ {showPresets && (
322
+ <div className="mb-3 border-b pb-2.5">
323
+ <div className="mb-1.5 flex items-center gap-1 font-semibold text-muted-foreground text-xs">
324
+ <Clock className="h-3 w-3" />
325
+ {text?.presets ?? "ทางลัด"}
326
+ </div>
327
+ <div className="flex flex-wrap items-center gap-1.5">
328
+ {presets.map(([label, date]) => (
329
+ <Button
330
+ key={label}
331
+ type="button"
332
+ variant="outline"
333
+ size="sm"
334
+ className="h-6 px-2 font-normal text-xs hover:bg-muted"
335
+ onClick={() => applyPreset(date)}
336
+ >
337
+ {label}
338
+ </Button>
339
+ ))}
340
+ </div>
341
+ </div>
342
+ )}
343
+
344
+ {/* Visual DayPicker Calendar */}
345
+ <div className="flex w-full justify-center">
346
+ <Calendar
347
+ mode="single"
348
+ selected={selectedDate}
349
+ onSelect={handleSelectDate}
350
+ disabled={disabled}
351
+ captionLayout="dropdown"
352
+ startMonth={new Date(1990, 0)}
353
+ endMonth={new Date(2040, 11)}
354
+ defaultMonth={displayMonth}
355
+ formatters={calendarFormatters}
356
+ className="p-0 [--cell-size:--spacing(10)]"
357
+ />
358
+ </div>
359
+ </PopoverContent>
360
+ </Popover>
361
+ {name !== undefined && (
362
+ // ค่าที่ส่งไปกับฟอร์ม — ใช้ input ข้อความที่มองไม่เห็นแทน type="hidden" เพราะ hidden ไม่ถูกตรวจ `required`
363
+ // อยู่หลังปุ่มเสมอ: label ที่ครอบ (LabeledField) คลิกแล้วไปที่ปุ่ม ไม่ใช่ช่องนี้ · ตาข่ายฟอร์มอ่านชื่อจาก label เดียวกัน
364
+ <input
365
+ name={name}
366
+ value={toDateInputValue(selectedDate)}
367
+ onChange={() => {}}
368
+ required={required}
369
+ disabled={disabled}
370
+ tabIndex={-1}
371
+ aria-hidden
372
+ className="sr-only"
373
+ />
374
+ )}
375
+ </>
376
+ );
377
+ }
@@ -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,21 @@ 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
+ }>;
178
193
  /** TimePicker / DateTimePicker (optional) */
179
194
  time?: Partial<{
180
195
  choose: string;
@@ -340,6 +355,20 @@ export const UI_TEXT_TH: UiText = {
340
355
  presence: { online: "ออนไลน์", away: "ไม่อยู่", busy: "ไม่ว่าง", offline: "ออฟไลน์" },
341
356
  characterCount: "พิมพ์แล้ว {count} จาก {max} ตัวอักษร",
342
357
  number: { increment: "เพิ่ม", decrement: "ลด" },
358
+ datePicker: {
359
+ placeholder: "เลือกวันที่",
360
+ smartPlaceholder: "เช่น 1/7, 1/7/25, 1-7-2568...",
361
+ apply: "ตกลง",
362
+ presets: "ทางลัด",
363
+ today: "วันนี้",
364
+ yesterday: "เมื่อวาน",
365
+ tomorrow: "พรุ่งนี้",
366
+ nextWeek: "+1 สัปดาห์",
367
+ lastWeek: "-1 สัปดาห์",
368
+ nextMonth: "+1 เดือน",
369
+ lastMonth: "-1 เดือน",
370
+ clear: "ล้างวันที่",
371
+ },
343
372
  time: {
344
373
  choose: "เลือกเวลา",
345
374
  hour: "ชั่วโมง",
@@ -511,6 +540,20 @@ export const UI_TEXT_EN: UiText = {
511
540
  presence: { online: "Online", away: "Away", busy: "Busy", offline: "Offline" },
512
541
  characterCount: "{count} of {max} characters",
513
542
  number: { increment: "Increase", decrement: "Decrease" },
543
+ datePicker: {
544
+ placeholder: "Pick a date",
545
+ smartPlaceholder: "e.g. 1/7, 1/7/25, 1-7-2025…",
546
+ apply: "Apply",
547
+ presets: "Shortcuts",
548
+ today: "Today",
549
+ yesterday: "Yesterday",
550
+ tomorrow: "Tomorrow",
551
+ nextWeek: "+1 week",
552
+ lastWeek: "-1 week",
553
+ nextMonth: "+1 month",
554
+ lastMonth: "-1 month",
555
+ clear: "Clear date",
556
+ },
514
557
  time: {
515
558
  choose: "Choose time",
516
559
  hour: "Hour",
@@ -1,308 +1,10 @@
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
+ parseSmartDateInput,
9
+ toDateInputValue,
10
+ } from "@suphark/ui/components/shared/date/date-picker";
package/src/index.ts CHANGED
@@ -76,6 +76,12 @@ 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
+ parseSmartDateInput,
83
+ toDateInputValue,
84
+ } from "./components/shared/date/date-picker";
79
85
  export { DateRangePicker } from "./components/shared/date/date-range-picker";
80
86
  export { DescriptionList } from "./components/shared/description-list";
81
87
  export {
@@ -403,7 +409,6 @@ export {
403
409
  ContextMenuSubTrigger,
404
410
  ContextMenuTrigger,
405
411
  } from "./components/ui/context-menu";
406
- export { DatePicker, parseSmartDateInput } from "./components/ui/date-picker";
407
412
  export {
408
413
  Dialog,
409
414
  DialogClose,