@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.
|
|
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.
|
|
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,
|
|
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
|
/**
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
}:
|
|
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
|
-
<
|
|
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>
|
|
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
|
-
|
|
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: "เช่น
|
|
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";
|