@suphark/ui 0.40.1 → 0.42.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,40 @@
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.1] — 2026-10-07
6
+
7
+ ### Fixed
8
+
9
+ - **วันที่สัมพัทธ์นับผิด 1 วัน** (เจอตอนตรวจ 0.42.0 บน production: พิมพ์ `t+3` ได้ 10 ต.ค. ถูก แต่วงเล็บบอก "(ใน 2 วัน)") — `formatRelativeThaiDate` ใช้ `formatDistanceToNow` ซึ่งนับเวลาจาก "ตอนนี้" ถึงเที่ยงคืนของวันนั้น (บ่าย/ค่ำเหลือ ~2.4 วัน → 2) และมีคำว่า "ประมาณ 1 เดือน" · ตอนนี้นับเป็นวันปฏิทินด้วย `Intl.RelativeTimeFormat`: "ในอีก 3 วัน", "10 วันที่ผ่านมา", "เดือนหน้า", "ปีที่แล้ว" (< 31 วัน = วัน, < 12 เดือน = เดือน, ที่เหลือ = ปี) — กระทบ `DatePicker` และ `RelativeDate` · `DatePicker locale` ใช้สูตรเดียวกัน (เดิมนับเป็นวันอย่างเดียว "in 45 days")
10
+ - `formatRelativeCalendarDate(date, locale)` ใน `lib/formatters` (ใช้ร่วมกันข้างใน)
11
+
12
+
13
+ ### Added
14
+
15
+ - **`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`)
16
+ - ปุ่ม (i) ท้ายช่องพิมพ์ เปิดตารางวิธีพิมพ์ในกล่องเดียวกัน (ไม่ใช่ tooltip — popup ซ้อน popup และจอสัมผัสเปิด tooltip ไม่ได้) · ข้อความจาก `UiText.datePicker.help*`
17
+
18
+ ### Changed
19
+
20
+ - **เลิกปุ่มทางลัด** (วันนี้/พรุ่งนี้/+1 สัปดาห์ …) ทั้งแบบวันเดียวและช่วง — ใช้การพิมพ์ลัดแทนเพื่อลดพื้นที่ · `showPresets` ยังรับได้แต่ไม่มีผล (deprecated)
21
+ - ช่วงวันที่: **ไม่ใส่ปี = ปีปัจจุบันเสมอ** (0.41.0 ยืมปีจากฝั่งขวา)
22
+ - placeholder ช่องพิมพ์: "เช่น 15/3, t+3, t+1m" / ช่วง "เช่น 1/7-5/7, t-7..t"
23
+
24
+ ## [0.41.0] — 2026-10-07
25
+
26
+ > ประกาศพร้อม publish แล้ว แต่ยังไม่ขึ้น npm — รวมอยู่ใน 0.42.0
27
+
28
+
29
+ ### Added
30
+
31
+ - **`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" ยังเป็นวันเดียวเหมือนเดิม (`-` เป็นตัวคั่นช่วงเมื่อมี `/` หรือ `.`)
32
+ - แสดงด้วย `Intl.DateTimeFormat#formatRange` ตาม `locale`: "1–5 ก.ค. 2569" / en-GB "1–5 Jul 2026"
33
+ - ทางลัดของช่วง: วันนี้, 7/30 วันล่าสุด, เดือนนี้, เดือนที่แล้ว, 7/30 วันข้างหน้า
34
+ - ฟอร์ม: `name={["from", "to"]}` ส่ง `YYYY-MM-DD` สองช่อง (ใช้กับ `<form method="get">` ของตัวกรองได้ตรง ๆ) + `required` · `value`/`defaultValue` = `{ from, to }` (Date หรือ "YYYY-MM-DD") · `onChange(range | null)`
35
+ - `parseSmartDateRange(input)` · types `DatePickerRange`, `DatePickerSingleProps`, `DatePickerRangeProps` (`DatePickerProps` เป็น union — ของเดิมที่ไม่ส่ง `mode` ใช้ได้เหมือนเดิม)
36
+ - `UiText.datePicker`: `rangePlaceholder`, `rangeSmartPlaceholder`, `pickEnd`, ทางลัดของช่วง
37
+ - showcase: `/components/date-picker` เพิ่มตัวอย่างช่วงวันที่
38
+
6
39
 
7
40
  ### Fixed
8
41
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@suphark/ui",
3
- "version": "0.40.1",
3
+ "version": "0.42.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 เอง",
@@ -11,10 +11,10 @@ import {
11
11
  } from "@suphark/ui/components/ui/input-group";
12
12
  import { Popover, PopoverContent, PopoverTrigger } from "@suphark/ui/components/ui/popover";
13
13
  import { useMounted } from "@suphark/ui/hooks/use-mounted";
14
- import { formatRelativeThaiDate, formatThaiFullDate } from "@suphark/ui/lib/formatters";
14
+ import { formatRelativeCalendarDate, 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
  /**
@@ -99,26 +158,13 @@ function formatDate(date: Date, locale: string | undefined): string {
99
158
  }).format(date);
100
159
  }
101
160
 
102
- /** "พรุ่งนี้" / "in 3 days" — นับเป็นวันตามปฏิทิน */
161
+ /** "พรุ่งนี้" / "in 3 days" — นับเป็นวันตามปฏิทิน (ไม่ส่ง locale = ไทย) */
103
162
  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");
163
+ return formatRelativeCalendarDate(date, locale ?? "th");
108
164
  }
109
165
 
110
- export interface DatePickerProps {
166
+ interface DatePickerBaseProps {
111
167
  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
168
  /** ต้องเลือกวันที่ก่อนส่งฟอร์ม — ไม่เลือกแล้วกดส่ง เบราว์เซอร์บล็อกและตาข่ายฟอร์มบอกชื่อช่องให้ */
123
169
  required?: boolean;
124
170
  /**
@@ -131,13 +177,86 @@ export interface DatePickerProps {
131
177
  disabled?: boolean;
132
178
  className?: string;
133
179
  allowClear?: boolean;
180
+ /** @deprecated 0.42.0 — ปุ่มทางลัดถูกแทนด้วยการพิมพ์ลัด (`t`, `t+3`, `t+1m` …) ใส่ได้แต่ไม่มีผล */
134
181
  showPresets?: boolean;
135
182
  showSmartInput?: boolean;
183
+ }
184
+
185
+ export interface DatePickerSingleProps extends DatePickerBaseProps {
186
+ /** `"single"` (ค่าเริ่มต้น) = วันเดียว · `"range"` = ช่วงวันที่ */
187
+ mode?: "single";
188
+ /** ควบคุมจากภายนอก (`"YYYY-MM-DD"` อ่านเป็นวันที่ local) — ใช้แบบไม่ควบคุมให้ส่ง `defaultValue` แทน */
189
+ value?: DateInput;
190
+ /** ค่าเริ่มต้นแบบไม่ควบคุม (ใช้คู่ `name` ใน `<form action>`) — `null` / `""` = เริ่มว่าง */
191
+ defaultValue?: DateInput;
192
+ onChange?: (date: Date | null) => void;
193
+ /**
194
+ * ชื่อฟิลด์ในฟอร์ม — ส่งค่า `YYYY-MM-DD` (วันที่ local) ไปกับ FormData ของ `<form action>`/server action
195
+ * ว่าง = ส่ง `""`
196
+ */
197
+ name?: string;
136
198
  /** แสดงวันที่สัมพัทธ์ต่อท้าย เช่น "(พรุ่งนี้)" — ค่าเริ่มต้น true */
137
199
  showRelative?: boolean;
138
200
  }
139
201
 
140
- export function DatePicker({
202
+ /** ช่วงวันที่ — ทั้งสองฝั่งรวมวันนั้นด้วย (`to` = วันสุดท้ายของช่วง) */
203
+ export interface DatePickerRange {
204
+ from: Date;
205
+ to: Date;
206
+ }
207
+
208
+ export interface DatePickerRangeProps extends DatePickerBaseProps {
209
+ mode: "range";
210
+ /** ควบคุมจากภายนอก — แต่ละฝั่งรับ Date หรือ `"YYYY-MM-DD"` · `null` = ว่าง */
211
+ value?: { from: DateInput; to: DateInput } | null;
212
+ /** ค่าเริ่มต้นแบบไม่ควบคุม (ใช้คู่ `name`) */
213
+ defaultValue?: { from: DateInput; to: DateInput } | null;
214
+ onChange?: (range: DatePickerRange | null) => void;
215
+ /**
216
+ * ชื่อฟิลด์สองช่อง `[ชื่อวันเริ่ม, ชื่อวันสุดท้าย]` เช่น `["from", "to"]` — ส่ง `YYYY-MM-DD` แยกกัน
217
+ * (ใช้กับ `<form method="get">` ของตัวกรองได้ตรง ๆ) · ว่าง = `""` ทั้งคู่
218
+ */
219
+ name?: [string, string];
220
+ }
221
+
222
+ export type DatePickerProps = DatePickerSingleProps | DatePickerRangeProps;
223
+
224
+ /**
225
+ * เลือกวันที่ — วันเดียว (ค่าเริ่มต้น) หรือ `mode="range"` เป็นช่วง (คลิกวันเริ่มแล้ววันสุดท้าย
226
+ * หรือพิมพ์ "1/7-5/7") · พิมพ์ "15/3", "15/3/69" ได้ · มีทางลัด · ใช้ในฟอร์มด้วย `name`
227
+ */
228
+ export function DatePicker(props: DatePickerProps) {
229
+ return props.mode === "range" ? <RangeDatePicker {...props} /> : <SingleDatePicker {...props} />;
230
+ }
231
+
232
+ /** ช่องที่มองไม่เห็นสำหรับส่งค่าไปกับฟอร์ม — ไม่ใช้ type="hidden" เพราะ hidden ไม่ถูกตรวจ `required` */
233
+ function FormValue({
234
+ name,
235
+ value,
236
+ required,
237
+ disabled,
238
+ }: {
239
+ name: string;
240
+ value: string;
241
+ required: boolean;
242
+ disabled: boolean;
243
+ }) {
244
+ // อยู่หลังปุ่มเสมอ: label ที่ครอบ (LabeledField) คลิกแล้วไปที่ปุ่ม ไม่ใช่ช่องนี้ · ตาข่ายฟอร์มอ่านชื่อจาก label เดียวกัน
245
+ return (
246
+ <input
247
+ name={name}
248
+ value={value}
249
+ onChange={() => {}}
250
+ required={required}
251
+ disabled={disabled}
252
+ tabIndex={-1}
253
+ aria-hidden
254
+ className="sr-only"
255
+ />
256
+ );
257
+ }
258
+
259
+ function SingleDatePicker({
141
260
  id,
142
261
  value,
143
262
  defaultValue,
@@ -149,10 +268,9 @@ export function DatePicker({
149
268
  disabled = false,
150
269
  className,
151
270
  allowClear = true,
152
- showPresets = true,
153
271
  showSmartInput = true,
154
272
  showRelative = true,
155
- }: DatePickerProps) {
273
+ }: DatePickerSingleProps) {
156
274
  const text = useUiText().datePicker;
157
275
  const mounted = useMounted();
158
276
  const [open, setOpen] = useState(false);
@@ -177,12 +295,6 @@ export function DatePicker({
177
295
  setSmartText("");
178
296
  };
179
297
 
180
- const applyPreset = (date: Date | null) => {
181
- change(date);
182
- setOpen(false);
183
- setSmartText("");
184
- };
185
-
186
298
  const handleSmartSubmit = () => {
187
299
  if (parsedSmartDate) {
188
300
  change(parsedSmartDate);
@@ -191,17 +303,7 @@ export function DatePicker({
191
303
  }
192
304
  };
193
305
 
194
- const today = new Date();
195
306
  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
307
  // ปฏิทินใช้ locale เดียวกับช่อง (ชื่อเดือน/วัน/ปี) — ไม่ส่ง locale = ค่าเดิมของ Calendar
206
308
  const calendarFormatters = locale
207
309
  ? {
@@ -279,66 +381,15 @@ export function DatePicker({
279
381
  </PopoverTrigger>
280
382
  {/* กว้างพอดีปฏิทิน: ช่องวันที่ 40px × 7 + p-3 + ขอบ (เดิมกล่อง 320–340px แต่ช่อง 28px → ว่างซ้ายขวามาก) */}
281
383
  <PopoverContent className="w-[306px] border bg-background p-3 shadow-xl" align="start">
282
- {/* Smart Date Text Input */}
283
384
  {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>
385
+ <SmartInput
386
+ value={smartText}
387
+ onChange={setSmartText}
388
+ onSubmit={handleSmartSubmit}
389
+ preview={parsedSmartDate ? formatDate(parsedSmartDate, locale) : null}
390
+ placeholder={text?.smartPlaceholder ?? "เช่น 15/3, t+3, t+1m"}
391
+ range={false}
392
+ />
342
393
  )}
343
394
 
344
395
  {/* Visual DayPicker Calendar */}
@@ -359,19 +410,290 @@ export function DatePicker({
359
410
  </PopoverContent>
360
411
  </Popover>
361
412
  {name !== undefined && (
362
- // ค่าที่ส่งไปกับฟอร์ม — ใช้ input ข้อความที่มองไม่เห็นแทน type="hidden" เพราะ hidden ไม่ถูกตรวจ `required`
363
- // อยู่หลังปุ่มเสมอ: label ที่ครอบ (LabeledField) คลิกแล้วไปที่ปุ่ม ไม่ใช่ช่องนี้ · ตาข่ายฟอร์มอ่านชื่อจาก label เดียวกัน
364
- <input
413
+ <FormValue
365
414
  name={name}
366
415
  value={toDateInputValue(selectedDate)}
367
- onChange={() => {}}
368
416
  required={required}
369
417
  disabled={disabled}
370
- tabIndex={-1}
371
- aria-hidden
372
- className="sr-only"
373
418
  />
374
419
  )}
375
420
  </>
376
421
  );
377
422
  }
423
+
424
+ /** "1 – 5 ก.ค. 2569" / "1–5 Jul 2026" — ใช้ formatRange ของ Intl (ย่อเดือน/ปีที่ซ้ำให้เอง) */
425
+ function formatRange(range: DatePickerRange, locale: string | undefined): string {
426
+ const fmt = new Intl.DateTimeFormat(locale ?? "th-TH", {
427
+ day: "numeric",
428
+ month: "short",
429
+ year: "numeric",
430
+ });
431
+ return fmt.formatRange(range.from, range.to);
432
+ }
433
+
434
+ function toRange(value: { from: DateInput; to: DateInput } | null | undefined) {
435
+ const from = toDate(value?.from);
436
+ const to = toDate(value?.to);
437
+ if (!from || !to) return undefined;
438
+ return from.getTime() <= to.getTime() ? { from, to } : { from: to, to: from };
439
+ }
440
+
441
+ function RangeDatePicker({
442
+ id,
443
+ value,
444
+ defaultValue,
445
+ onChange,
446
+ name,
447
+ required = false,
448
+ locale,
449
+ placeholder,
450
+ disabled = false,
451
+ className,
452
+ allowClear = true,
453
+ showSmartInput = true,
454
+ }: DatePickerRangeProps) {
455
+ const text = useUiText().datePicker;
456
+ const [open, setOpen] = useState(false);
457
+ const [smartText, setSmartText] = useState("");
458
+ const controlled = value !== undefined || (defaultValue === undefined && onChange !== undefined);
459
+ const [own, setOwn] = useState<DatePickerRange | null>(() => toRange(defaultValue) ?? null);
460
+ // ระหว่างคลิกเลือก: คลิกแรก = วันเริ่ม (ยังไม่ปิด) คลิกที่สอง = วันสุดท้าย แล้วปิด
461
+ const [start, setStart] = useState<Date | null>(null);
462
+
463
+ const selected = controlled ? toRange(value) : (own ?? undefined);
464
+ const parsed = useMemo(() => parseSmartDateRange(smartText), [smartText]);
465
+
466
+ const close = () => {
467
+ setOpen(false);
468
+ setSmartText("");
469
+ setStart(null);
470
+ };
471
+ const change = (range: DatePickerRange | null) => {
472
+ if (!controlled) setOwn(range);
473
+ onChange?.(range);
474
+ };
475
+ const commit = (range: DatePickerRange | null) => {
476
+ change(range);
477
+ close();
478
+ };
479
+
480
+ const calendarFormatters = locale
481
+ ? {
482
+ formatMonthDropdown: (d: Date) => d.toLocaleString(locale, { month: "short" }),
483
+ formatYearDropdown: (d: Date) => d.toLocaleString(locale, { year: "numeric" }),
484
+ formatWeekdayName: (d: Date) => d.toLocaleString(locale, { weekday: "narrow" }),
485
+ formatCaption: (d: Date) => d.toLocaleString(locale, { month: "long", year: "numeric" }),
486
+ }
487
+ : undefined;
488
+ const calendarSelected = start ? { from: start, to: undefined } : selected;
489
+
490
+ return (
491
+ <>
492
+ <Popover
493
+ open={open}
494
+ onOpenChange={(o) => {
495
+ if (o) setOpen(true);
496
+ else close();
497
+ }}
498
+ >
499
+ <PopoverTrigger
500
+ render={
501
+ <Button
502
+ id={id}
503
+ variant="outline"
504
+ disabled={disabled}
505
+ aria-required={required || undefined}
506
+ className={cn(
507
+ "h-8 w-full justify-between bg-background pr-2 pl-3 text-left font-normal text-xs",
508
+ !selected && "text-muted-foreground",
509
+ className,
510
+ )}
511
+ />
512
+ }
513
+ >
514
+ <div className="flex items-center gap-2 truncate">
515
+ <CalendarIcon className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
516
+ <span className="truncate">
517
+ {selected ? (
518
+ <span className="font-medium text-foreground">{formatRange(selected, locale)}</span>
519
+ ) : (
520
+ <span>{placeholder ?? text?.rangePlaceholder ?? "เลือกช่วงวันที่"}</span>
521
+ )}
522
+ </span>
523
+ </div>
524
+ {allowClear && selected && !disabled && (
525
+ <span
526
+ role="button"
527
+ tabIndex={0}
528
+ aria-label={text?.clear ?? "ล้างวันที่"}
529
+ className="ml-1 cursor-pointer rounded p-0.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
530
+ onClick={(e) => {
531
+ e.stopPropagation();
532
+ change(null);
533
+ }}
534
+ onKeyDown={(e) => {
535
+ if (e.key !== "Enter" && e.key !== " ") return;
536
+ e.preventDefault();
537
+ e.stopPropagation();
538
+ change(null);
539
+ }}
540
+ >
541
+ <X className="h-3.5 w-3.5" />
542
+ </span>
543
+ )}
544
+ </PopoverTrigger>
545
+ <PopoverContent className="w-[306px] border bg-background p-3 shadow-xl" align="start">
546
+ {showSmartInput && (
547
+ <SmartInput
548
+ value={smartText}
549
+ onChange={setSmartText}
550
+ onSubmit={() => parsed && commit(parsed)}
551
+ preview={parsed ? formatRange(parsed, locale) : null}
552
+ placeholder={text?.rangeSmartPlaceholder ?? "เช่น 1/7-5/7, t-7..t"}
553
+ range
554
+ />
555
+ )}
556
+
557
+ <div className="flex w-full justify-center">
558
+ <Calendar
559
+ mode="range"
560
+ selected={calendarSelected}
561
+ onSelect={(_range, day) => {
562
+ // คุมสองคลิกเอง: react-day-picker คลิกแรกให้ from = to (ช่วงหนึ่งวัน) จึงแยกคลิกแรกไม่ออก
563
+ if (!start) {
564
+ setStart(day);
565
+ return;
566
+ }
567
+ commit(
568
+ day.getTime() < start.getTime()
569
+ ? { from: day, to: start }
570
+ : { from: start, to: day },
571
+ );
572
+ }}
573
+ disabled={disabled}
574
+ captionLayout="dropdown"
575
+ startMonth={new Date(1990, 0)}
576
+ endMonth={new Date(2040, 11)}
577
+ defaultMonth={parsed?.from ?? start ?? selected?.from}
578
+ formatters={calendarFormatters}
579
+ className="p-0 [--cell-size:--spacing(10)]"
580
+ />
581
+ </div>
582
+ {start && (
583
+ <p className="mt-2 text-center text-muted-foreground text-xs">
584
+ {text?.pickEnd ?? "เลือกวันสุดท้ายของช่วง"}
585
+ </p>
586
+ )}
587
+ </PopoverContent>
588
+ </Popover>
589
+ {name !== undefined && (
590
+ <>
591
+ <FormValue
592
+ name={name[0]}
593
+ value={toDateInputValue(selected?.from)}
594
+ required={required}
595
+ disabled={disabled}
596
+ />
597
+ <FormValue
598
+ name={name[1]}
599
+ value={toDateInputValue(selected?.to)}
600
+ required={required}
601
+ disabled={disabled}
602
+ />
603
+ </>
604
+ )}
605
+ </>
606
+ );
607
+ }
608
+
609
+ /**
610
+ * ช่องพิมพ์วันที่ + ปุ่ม (i) เปิดวิธีพิมพ์ — คำอธิบายแสดงในกล่องเดียวกัน (ไม่ใช่ tooltip: popup ซ้อน popup
611
+ * และจอสัมผัสเปิด tooltip ไม่ได้)
612
+ */
613
+ function SmartInput({
614
+ value,
615
+ onChange,
616
+ onSubmit,
617
+ preview,
618
+ placeholder,
619
+ range,
620
+ }: {
621
+ value: string;
622
+ onChange: (value: string) => void;
623
+ onSubmit: () => void;
624
+ preview: string | null;
625
+ placeholder: string;
626
+ range: boolean;
627
+ }) {
628
+ const text = useUiText().datePicker;
629
+ const [help, setHelp] = useState(false);
630
+ const rows: Array<[string, string]> = [
631
+ ["15/3", text?.helpDate ?? "15 มี.ค. ปีนี้ (ไม่ใส่ปี = ปีปัจจุบัน)"],
632
+ ["15/3/69 · 15/3/2026", text?.helpYear ?? "ใส่ปี พ.ศ. หรือ ค.ศ. ได้ (2 หรือ 4 หลัก)"],
633
+ ["t", text?.helpToday ?? "วันนี้"],
634
+ ["t+3 · t-2", text?.helpDays ?? "อีก 3 วัน · 2 วันก่อน (d = วัน)"],
635
+ ["t+2w · t+1m · t+1y", text?.helpUnits ?? "สัปดาห์ · เดือน · ปี"],
636
+ ...(range
637
+ ? ([
638
+ ["1/7-5/7 · 1-5/7", text?.helpRange ?? "1–5 ก.ค."],
639
+ ["t-7..t · t ~ t+1m", text?.helpRangeShortcut ?? "ช่วงจากตัวย่อ (คั่นด้วย .. ~ ถึง to)"],
640
+ ] as Array<[string, string]>)
641
+ : []),
642
+ ];
643
+ return (
644
+ <div className="mb-3 flex flex-col gap-1.5 border-b pb-2.5">
645
+ <InputGroup className="h-8">
646
+ <InputGroupInput
647
+ value={value}
648
+ onChange={(e) => onChange(e.target.value)}
649
+ onKeyDown={(event) => {
650
+ if (event.key === "Enter") {
651
+ event.preventDefault();
652
+ event.stopPropagation();
653
+ onSubmit();
654
+ }
655
+ }}
656
+ placeholder={placeholder}
657
+ className="h-8 text-xs"
658
+ />
659
+ <InputGroupAddon align="inline-end">
660
+ <InputGroupButton
661
+ type="button"
662
+ size="icon-xs"
663
+ aria-label={text?.help ?? "วิธีพิมพ์วันที่"}
664
+ aria-expanded={help}
665
+ onClick={() => setHelp((v) => !v)}
666
+ className={help ? "text-foreground" : "text-muted-foreground"}
667
+ >
668
+ <InfoIcon />
669
+ </InputGroupButton>
670
+ <InputGroupButton
671
+ type="button"
672
+ variant="default"
673
+ size="xs"
674
+ onClick={onSubmit}
675
+ disabled={!preview}
676
+ >
677
+ {text?.apply ?? "ตกลง"}
678
+ </InputGroupButton>
679
+ </InputGroupAddon>
680
+ </InputGroup>
681
+ {preview && (
682
+ <span className="flex items-center gap-1 font-medium text-success text-xs">
683
+ <Check className="size-3" />
684
+ {preview}
685
+ </span>
686
+ )}
687
+ {help && (
688
+ <dl className="grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 rounded-md bg-muted/60 p-2 text-xs">
689
+ {rows.map(([example, meaning]) => (
690
+ <div key={example} className="contents">
691
+ <dt className="font-mono text-foreground">{example}</dt>
692
+ <dd className="text-muted-foreground">{meaning}</dd>
693
+ </div>
694
+ ))}
695
+ </dl>
696
+ )}
697
+ </div>
698
+ );
699
+ }
@@ -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";
@@ -1,24 +1,36 @@
1
- import { differenceInCalendarDays, formatDistanceToNow } from "date-fns";
2
- import { th } from "date-fns/locale/th";
1
+ import {
2
+ differenceInCalendarDays,
3
+ differenceInCalendarMonths,
4
+ differenceInCalendarYears,
5
+ } from "date-fns";
3
6
 
4
7
  /**
5
8
  * ฟอร์แมตวันที่แบบไทยที่ primitives ใช้ร่วมกัน (DatePicker, RelativeDate)
6
9
  * ยกมาจาก supplychain `lib/utils/formatters.ts` เฉพาะส่วนที่ไม่ผูก domain
7
10
  */
8
11
 
9
- /** วันที่แบบสัมพัทธ์ภาษาไทย เช่น "วันนี้", "เมื่อวาน", "พรุ่งนี้", "10 วันที่ผ่านมา", "ใน 2 เดือน" */
12
+ /**
13
+ * วันที่สัมพัทธ์นับตาม "วันปฏิทิน" ไม่ใช่ระยะเวลาจากตอนนี้ — "ในอีก 3 วัน" ทั้งวันไม่ว่าตอนนี้กี่โมง
14
+ * (เดิมใช้ formatDistanceToNow: 3 วันข้างหน้าตอนบ่ายเหลือ ~2.4 วัน → "ใน 2 วัน", และมี "ประมาณ 1 เดือน")
15
+ * น้อยกว่า 31 วัน = วัน, น้อยกว่า 12 เดือน = เดือน, ที่เหลือ = ปี
16
+ */
17
+ export function formatRelativeCalendarDate(date: Date, locale = "th"): string {
18
+ const now = new Date();
19
+ const fmt = new Intl.RelativeTimeFormat(locale, { numeric: "auto" });
20
+ const days = differenceInCalendarDays(date, now);
21
+ if (Math.abs(days) < 31) return fmt.format(days, "day");
22
+ const months = differenceInCalendarMonths(date, now);
23
+ if (Math.abs(months) < 12) return fmt.format(months, "month");
24
+ return fmt.format(differenceInCalendarYears(date, now), "year");
25
+ }
26
+
27
+ /** วันที่แบบสัมพัทธ์ภาษาไทย เช่น "วันนี้", "เมื่อวาน", "พรุ่งนี้", "10 วันที่ผ่านมา", "ในอีก 2 เดือน" */
10
28
  export function formatRelativeThaiDate(date: Date | string | number | null | undefined): string {
11
29
  if (!date) return "ไม่ได้ระบุ";
12
30
  const d = new Date(date);
13
31
  if (Number.isNaN(d.getTime())) return "ไม่ได้ระบุ";
14
32
 
15
- // เทียบเป็นวันปฏิทิน (ไม่ใช่ 24 ชม.) และคงเครื่องหมาย — อนาคต 1 วันคือ "พรุ่งนี้" ไม่ใช่ "เมื่อวาน"
16
- const diffDays = differenceInCalendarDays(new Date(), d);
17
- if (diffDays === 0) return "วันนี้";
18
- if (diffDays === 1) return "เมื่อวาน";
19
- if (diffDays === -1) return "พรุ่งนี้";
20
-
21
- return formatDistanceToNow(d, { addSuffix: true, locale: th });
33
+ return formatRelativeCalendarDate(d, "th");
22
34
  }
23
35
 
24
36
  const THAI_MONTH_SHORT = [