@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 +34 -1
- package/package.json +1 -1
- package/src/components/shared/date/date-picker.tsx +426 -104
- package/src/components/shared/ui-text.tsx +55 -2
- package/src/components/ui/date-picker.tsx +4 -0
- package/src/index.ts +4 -0
- package/src/lib/formatters.ts +22 -10
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.
|
|
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.
|
|
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 {
|
|
14
|
+
import { formatRelativeCalendarDate, formatThaiFullDate } from "@suphark/ui/lib/formatters";
|
|
15
15
|
import { cn } from "@suphark/ui/lib/utils";
|
|
16
|
-
import { addDays, addMonths, addWeeks,
|
|
17
|
-
import { CalendarIcon, Check,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
}:
|
|
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
|
-
<
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
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
|
-
|
|
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: "เช่น
|
|
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.
|
|
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",
|
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";
|
package/src/lib/formatters.ts
CHANGED
|
@@ -1,24 +1,36 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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 = [
|