@suphark/ui 0.40.1 → 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,34 @@
2
2
 
3
3
  รูปแบบตาม [Keep a Changelog](https://keepachangelog.com/) · semver: patch = หน้าตา/บั๊ก, minor = component/prop ใหม่, major = เปลี่ยน/ลบ props
4
4
 
5
- ## [0.40.1] — 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
+
6
33
 
7
34
  ### Fixed
8
35
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@suphark/ui",
3
- "version": "0.40.1",
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 เอง",
@@ -13,8 +13,8 @@ import { Popover, PopoverContent, PopoverTrigger } from "@suphark/ui/components/
13
13
  import { useMounted } from "@suphark/ui/hooks/use-mounted";
14
14
  import { formatRelativeThaiDate, formatThaiFullDate } from "@suphark/ui/lib/formatters";
15
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";
16
+ import { addDays, addMonths, addWeeks, addYears } from "date-fns";
17
+ import { CalendarIcon, Check, InfoIcon, X } from "lucide-react";
18
18
  import { useMemo, useState } from "react";
19
19
 
20
20
  /**
@@ -27,6 +27,8 @@ import { useMemo, useState } from "react";
27
27
  export function parseSmartDateInput(input: string): Date | null {
28
28
  if (!input?.trim()) return null;
29
29
  const clean = input.trim().replace(/\s+/g, "");
30
+ const shortcut = parseDateShortcut(clean);
31
+ if (shortcut) return shortcut;
30
32
 
31
33
  const parts = clean.split(/[/\-.]/);
32
34
  if (parts.length < 2 || parts.length > 3) return null;
@@ -66,6 +68,63 @@ export function parseSmartDateInput(input: string): Date | null {
66
68
  return null;
67
69
  }
68
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
+
69
128
  type DateInput = Date | string | number | null | undefined;
70
129
 
71
130
  /**
@@ -107,18 +166,8 @@ function formatRelative(date: Date, locale: string | undefined): string {
107
166
  return new Intl.RelativeTimeFormat(locale, { numeric: "auto" }).format(days, "day");
108
167
  }
109
168
 
110
- export interface DatePickerProps {
169
+ interface DatePickerBaseProps {
111
170
  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
171
  /** ต้องเลือกวันที่ก่อนส่งฟอร์ม — ไม่เลือกแล้วกดส่ง เบราว์เซอร์บล็อกและตาข่ายฟอร์มบอกชื่อช่องให้ */
123
172
  required?: boolean;
124
173
  /**
@@ -131,13 +180,86 @@ export interface DatePickerProps {
131
180
  disabled?: boolean;
132
181
  className?: string;
133
182
  allowClear?: boolean;
183
+ /** @deprecated 0.42.0 — ปุ่มทางลัดถูกแทนด้วยการพิมพ์ลัด (`t`, `t+3`, `t+1m` …) ใส่ได้แต่ไม่มีผล */
134
184
  showPresets?: boolean;
135
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;
136
201
  /** แสดงวันที่สัมพัทธ์ต่อท้าย เช่น "(พรุ่งนี้)" — ค่าเริ่มต้น true */
137
202
  showRelative?: boolean;
138
203
  }
139
204
 
140
- export function DatePicker({
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({
141
263
  id,
142
264
  value,
143
265
  defaultValue,
@@ -149,10 +271,9 @@ export function DatePicker({
149
271
  disabled = false,
150
272
  className,
151
273
  allowClear = true,
152
- showPresets = true,
153
274
  showSmartInput = true,
154
275
  showRelative = true,
155
- }: DatePickerProps) {
276
+ }: DatePickerSingleProps) {
156
277
  const text = useUiText().datePicker;
157
278
  const mounted = useMounted();
158
279
  const [open, setOpen] = useState(false);
@@ -177,12 +298,6 @@ export function DatePicker({
177
298
  setSmartText("");
178
299
  };
179
300
 
180
- const applyPreset = (date: Date | null) => {
181
- change(date);
182
- setOpen(false);
183
- setSmartText("");
184
- };
185
-
186
301
  const handleSmartSubmit = () => {
187
302
  if (parsedSmartDate) {
188
303
  change(parsedSmartDate);
@@ -191,17 +306,7 @@ export function DatePicker({
191
306
  }
192
307
  };
193
308
 
194
- const today = new Date();
195
309
  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
310
  // ปฏิทินใช้ locale เดียวกับช่อง (ชื่อเดือน/วัน/ปี) — ไม่ส่ง locale = ค่าเดิมของ Calendar
206
311
  const calendarFormatters = locale
207
312
  ? {
@@ -279,66 +384,15 @@ export function DatePicker({
279
384
  </PopoverTrigger>
280
385
  {/* กว้างพอดีปฏิทิน: ช่องวันที่ 40px × 7 + p-3 + ขอบ (เดิมกล่อง 320–340px แต่ช่อง 28px → ว่างซ้ายขวามาก) */}
281
386
  <PopoverContent className="w-[306px] border bg-background p-3 shadow-xl" align="start">
282
- {/* Smart Date Text Input */}
283
387
  {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>
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
+ />
342
396
  )}
343
397
 
344
398
  {/* Visual DayPicker Calendar */}
@@ -359,19 +413,290 @@ export function DatePicker({
359
413
  </PopoverContent>
360
414
  </Popover>
361
415
  {name !== undefined && (
362
- // ค่าที่ส่งไปกับฟอร์ม — ใช้ input ข้อความที่มองไม่เห็นแทน type="hidden" เพราะ hidden ไม่ถูกตรวจ `required`
363
- // อยู่หลังปุ่มเสมอ: label ที่ครอบ (LabeledField) คลิกแล้วไปที่ปุ่ม ไม่ใช่ช่องนี้ · ตาข่ายฟอร์มอ่านชื่อจาก label เดียวกัน
364
- <input
416
+ <FormValue
365
417
  name={name}
366
418
  value={toDateInputValue(selectedDate)}
367
- onChange={() => {}}
368
419
  required={required}
369
420
  disabled={disabled}
370
- tabIndex={-1}
371
- aria-hidden
372
- className="sr-only"
373
421
  />
374
422
  )}
375
423
  </>
376
424
  );
377
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
+ }
@@ -189,6 +189,25 @@ export interface UiText {
189
189
  nextMonth: string;
190
190
  lastMonth: string;
191
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;
192
211
  }>;
193
212
  /** TimePicker / DateTimePicker (optional) */
194
213
  time?: Partial<{
@@ -357,7 +376,7 @@ export const UI_TEXT_TH: UiText = {
357
376
  number: { increment: "เพิ่ม", decrement: "ลด" },
358
377
  datePicker: {
359
378
  placeholder: "เลือกวันที่",
360
- smartPlaceholder: "เช่น 1/7, 1/7/25, 1-7-2568...",
379
+ smartPlaceholder: "เช่น 15/3, t+3, t+1m",
361
380
  apply: "ตกลง",
362
381
  presets: "ทางลัด",
363
382
  today: "วันนี้",
@@ -368,6 +387,23 @@ export const UI_TEXT_TH: UiText = {
368
387
  nextMonth: "+1 เดือน",
369
388
  lastMonth: "-1 เดือน",
370
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 วันข้างหน้า",
371
407
  },
372
408
  time: {
373
409
  choose: "เลือกเวลา",
@@ -542,7 +578,7 @@ export const UI_TEXT_EN: UiText = {
542
578
  number: { increment: "Increase", decrement: "Decrease" },
543
579
  datePicker: {
544
580
  placeholder: "Pick a date",
545
- smartPlaceholder: "e.g. 1/7, 1/7/25, 1-7-2025…",
581
+ smartPlaceholder: "e.g. 15/3, t+3, t+1m",
546
582
  apply: "Apply",
547
583
  presets: "Shortcuts",
548
584
  today: "Today",
@@ -553,6 +589,23 @@ export const UI_TEXT_EN: UiText = {
553
589
  nextMonth: "+1 month",
554
590
  lastMonth: "-1 month",
555
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",
556
609
  },
557
610
  time: {
558
611
  choose: "Choose time",
@@ -5,6 +5,10 @@
5
5
  export {
6
6
  DatePicker,
7
7
  type DatePickerProps,
8
+ type DatePickerRange,
9
+ type DatePickerRangeProps,
10
+ type DatePickerSingleProps,
8
11
  parseSmartDateInput,
12
+ parseSmartDateRange,
9
13
  toDateInputValue,
10
14
  } from "@suphark/ui/components/shared/date/date-picker";
package/src/index.ts CHANGED
@@ -79,7 +79,11 @@ export { DataList, DataRow } from "./components/shared/data-list";
79
79
  export {
80
80
  DatePicker,
81
81
  type DatePickerProps,
82
+ type DatePickerRange,
83
+ type DatePickerRangeProps,
84
+ type DatePickerSingleProps,
82
85
  parseSmartDateInput,
86
+ parseSmartDateRange,
83
87
  toDateInputValue,
84
88
  } from "./components/shared/date/date-picker";
85
89
  export { DateRangePicker } from "./components/shared/date/date-range-picker";