@suphark/ui 0.47.0 → 0.57.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 +107 -0
- package/README.md +1 -0
- package/package.json +2 -2
- package/src/components/app-shell/header-breadcrumbs.tsx +24 -0
- package/src/components/pwa/pull-to-refresh.tsx +44 -8
- package/src/components/pwa/web-push-row.tsx +126 -0
- package/src/components/shared/date/accessible-calendar.tsx +40 -0
- package/src/components/shared/date/date-picker.tsx +844 -321
- package/src/components/shared/date/date-range-picker.tsx +8 -5
- package/src/components/shared/form/date-time-picker.tsx +2 -2
- package/src/components/shared/form/labeled-field.tsx +34 -0
- package/src/components/shared/list-section.tsx +85 -0
- package/src/components/shared/notification-center.tsx +99 -11
- package/src/components/shared/progress-bar.tsx +9 -2
- package/src/components/shared/record-card.tsx +158 -0
- package/src/components/shared/responsive-popover.tsx +174 -0
- package/src/components/shared/row-table.tsx +133 -0
- package/src/components/shared/selection-bar.tsx +20 -6
- package/src/components/shared/sidebar-nav-item.tsx +62 -0
- package/src/components/shared/status-badge.tsx +16 -2
- package/src/components/shared/ui-text.tsx +36 -0
- package/src/components/sortable/sortable-list.tsx +17 -1
- package/src/index.ts +26 -1
- package/src/pwa.ts +2 -1
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import {
|
|
3
|
+
import { Popover as PopoverPrimitive } from "@base-ui/react/popover";
|
|
4
|
+
import { FocusableDayButton } from "@suphark/ui/components/shared/date/accessible-calendar";
|
|
4
5
|
import { useUiText } from "@suphark/ui/components/shared/ui-text";
|
|
5
6
|
import { Calendar } from "@suphark/ui/components/ui/calendar";
|
|
6
7
|
import {
|
|
@@ -9,13 +10,28 @@ import {
|
|
|
9
10
|
InputGroupButton,
|
|
10
11
|
InputGroupInput,
|
|
11
12
|
} from "@suphark/ui/components/ui/input-group";
|
|
12
|
-
import { Popover, PopoverContent, PopoverTrigger } from "@suphark/ui/components/ui/popover";
|
|
13
13
|
import { useMounted } from "@suphark/ui/hooks/use-mounted";
|
|
14
14
|
import { formatRelativeCalendarDate, formatThaiFullDate } from "@suphark/ui/lib/formatters";
|
|
15
15
|
import { cn } from "@suphark/ui/lib/utils";
|
|
16
|
-
import { addDays, addMonths, addWeeks, addYears } from "date-fns";
|
|
17
|
-
import {
|
|
18
|
-
|
|
16
|
+
import { addDays, addMonths, addWeeks, addYears, differenceInCalendarDays } from "date-fns";
|
|
17
|
+
import {
|
|
18
|
+
CalendarIcon,
|
|
19
|
+
CheckIcon,
|
|
20
|
+
ChevronDownIcon,
|
|
21
|
+
ChevronLeftIcon,
|
|
22
|
+
ChevronRightIcon,
|
|
23
|
+
X,
|
|
24
|
+
} from "lucide-react";
|
|
25
|
+
import {
|
|
26
|
+
type ComponentProps,
|
|
27
|
+
createContext,
|
|
28
|
+
type ReactNode,
|
|
29
|
+
useContext,
|
|
30
|
+
useId,
|
|
31
|
+
useRef,
|
|
32
|
+
useState,
|
|
33
|
+
} from "react";
|
|
34
|
+
import type { Matcher } from "react-day-picker";
|
|
19
35
|
|
|
20
36
|
/**
|
|
21
37
|
* Smart date parser supporting intuitive Thai & international date inputs:
|
|
@@ -179,7 +195,15 @@ interface DatePickerBaseProps {
|
|
|
179
195
|
allowClear?: boolean;
|
|
180
196
|
/** @deprecated 0.42.0 — ปุ่มทางลัดถูกแทนด้วยการพิมพ์ลัด (`t`, `t+3`, `t+1m` …) ใส่ได้แต่ไม่มีผล */
|
|
181
197
|
showPresets?: boolean;
|
|
198
|
+
/** พิมพ์ในช่องได้ (ค่าเริ่มต้น true) · false = ช่องอ่านอย่างเดียว กดแล้วเปิดปฏิทิน */
|
|
182
199
|
showSmartInput?: boolean;
|
|
200
|
+
/**
|
|
201
|
+
* วันต่ำสุด/สูงสุดที่เลือกได้ (`"YYYY-MM-DD"` | Date, รวมวันนั้นด้วย) — ใช้ได้ทั้งวันเดียวและ `mode="range"` (ทั้งสองฝั่งต้องอยู่ในช่วง)
|
|
202
|
+
* ปฏิทินกดวันนอกช่วงไม่ได้ · ค่าที่พิมพ์แล้วอยู่นอกช่วง = ไม่ยืนยัน + บอกเหตุผลใต้ช่อง (`UiText.datePicker.beforeMin`/`afterMax`)
|
|
203
|
+
* ค่าที่ส่งเข้ามาเองนอกช่วงไม่ถูกแก้ แค่ช่องขึ้นสถานะผิด (`aria-invalid`)
|
|
204
|
+
*/
|
|
205
|
+
minDate?: DateInput;
|
|
206
|
+
maxDate?: DateInput;
|
|
183
207
|
}
|
|
184
208
|
|
|
185
209
|
export interface DatePickerSingleProps extends DatePickerBaseProps {
|
|
@@ -222,8 +246,10 @@ export interface DatePickerRangeProps extends DatePickerBaseProps {
|
|
|
222
246
|
export type DatePickerProps = DatePickerSingleProps | DatePickerRangeProps;
|
|
223
247
|
|
|
224
248
|
/**
|
|
225
|
-
* เลือกวันที่ — วันเดียว (ค่าเริ่มต้น) หรือ `mode="range"` เป็นช่วง
|
|
226
|
-
*
|
|
249
|
+
* เลือกวันที่ — วันเดียว (ค่าเริ่มต้น) หรือ `mode="range"` เป็นช่วง · **พิมพ์ในช่องได้เลย**
|
|
250
|
+
* ("15/3", "15/3/69", "t+3", "t+1m", ช่วง "1/7-5/7", "t-7..t") เห็นผลที่อ่านออกทันทีใต้ช่อง
|
|
251
|
+
* กด Enter หรือกดผลนั้นเพื่อยืนยัน · ปฏิทินเป็นตัวช่วย (คลิกวัน / คลิกวันเริ่มแล้ววันสุดท้าย)
|
|
252
|
+
* ใช้ในฟอร์มด้วย `name` · `showSmartInput={false}` = ช่องอ่านอย่างเดียว เปิดปฏิทินอย่างเดียว (แบบเดิม)
|
|
227
253
|
*/
|
|
228
254
|
export function DatePicker(props: DatePickerProps) {
|
|
229
255
|
return props.mode === "range" ? <RangeDatePicker {...props} /> : <SingleDatePicker {...props} />;
|
|
@@ -241,7 +267,7 @@ function FormValue({
|
|
|
241
267
|
required: boolean;
|
|
242
268
|
disabled: boolean;
|
|
243
269
|
}) {
|
|
244
|
-
//
|
|
270
|
+
// อยู่หลังช่องพิมพ์เสมอ: label ที่ครอบ (LabeledField) คลิกแล้วไปที่ช่องพิมพ์ ไม่ใช่ช่องนี้ · ตาข่ายฟอร์มอ่านชื่อจาก label เดียวกัน
|
|
245
271
|
return (
|
|
246
272
|
<input
|
|
247
273
|
name={name}
|
|
@@ -256,6 +282,643 @@ function FormValue({
|
|
|
256
282
|
);
|
|
257
283
|
}
|
|
258
284
|
|
|
285
|
+
// ---------- ช่องพิมพ์ + ป๊อปอัปปฏิทิน (ใช้ร่วมกันทั้งวันเดียวและช่วง) ----------
|
|
286
|
+
|
|
287
|
+
const WEEKDAY_TH = ["อา", "จ", "อ", "พ", "พฤ", "ศ", "ส"];
|
|
288
|
+
const WEEKDAY_TH_DOT = ["อา.", "จ.", "อ.", "พ.", "พฤ.", "ศ.", "ส."];
|
|
289
|
+
const isThai = (locale?: string) => !locale || locale.toLowerCase().startsWith("th");
|
|
290
|
+
|
|
291
|
+
/** "จ." / "Mon" ตาม locale — ไม่ส่ง locale = ไทย */
|
|
292
|
+
function weekdayShort(date: Date, locale?: string): string {
|
|
293
|
+
return isThai(locale)
|
|
294
|
+
? (WEEKDAY_TH_DOT[date.getDay()] ?? "")
|
|
295
|
+
: date.toLocaleString(locale, { weekday: "short" });
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* ปฏิทินใช้ locale เดียวกับช่อง (ชื่อเดือน/ปี/วัน) — เดิมไม่ส่ง locale แล้วปฏิทินขึ้น "Oct 2026" อังกฤษ
|
|
300
|
+
* ทั้งที่ช่องแสดง พ.ศ. ไทย · หัวคอลัมน์วันไทยใช้ 2 ตัว (อา จ อ พ พฤ ศ ส) เพราะตัวเดียว "อ"/"พ" กำกวม
|
|
301
|
+
*/
|
|
302
|
+
function calendarFormatters(locale?: string) {
|
|
303
|
+
const tag = locale ?? "th";
|
|
304
|
+
return {
|
|
305
|
+
formatMonthDropdown: (d: Date) => d.toLocaleString(tag, { month: "short" }),
|
|
306
|
+
formatYearDropdown: (d: Date) => d.toLocaleString(tag, { year: "numeric" }),
|
|
307
|
+
formatWeekdayName: (d: Date) =>
|
|
308
|
+
isThai(locale) ? (WEEKDAY_TH[d.getDay()] ?? "") : d.toLocaleString(tag, { weekday: "short" }),
|
|
309
|
+
formatCaption: (d: Date) => d.toLocaleString(tag, { month: "long", year: "numeric" }),
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** คลิกชื่อเดือนในหัวปฏิทิน → สลับไปตารางเลือกเดือน/ปี (อยู่ใน shell) */
|
|
314
|
+
const OpenMonthsContext = createContext<(() => void) | null>(null);
|
|
315
|
+
|
|
316
|
+
/** หัวปฏิทินแบบ "ตุลาคม 2569 ▾" เป็นปุ่มเดียว (แทนเมนูเลือกเดือน/ปีสองอัน) */
|
|
317
|
+
function CaptionButton({ children }: ComponentProps<"span">) {
|
|
318
|
+
const open = useContext(OpenMonthsContext);
|
|
319
|
+
if (!open) return <span>{children}</span>;
|
|
320
|
+
return (
|
|
321
|
+
<button
|
|
322
|
+
type="button"
|
|
323
|
+
onClick={open}
|
|
324
|
+
className="inline-flex items-center gap-1 rounded-md px-2 py-1 font-semibold text-sm outline-none hover:bg-accent focus-visible:ring-3 focus-visible:ring-ring/50"
|
|
325
|
+
>
|
|
326
|
+
{children}
|
|
327
|
+
<ChevronDownIcon className="size-3.5 text-muted-foreground" aria-hidden />
|
|
328
|
+
</button>
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** ตารางเลือกเดือน (12 ช่อง) + ปีเลื่อนซ้าย/ขวา — ใช้แทนปฏิทินชั่วคราวเมื่อกดหัวปฏิทิน */
|
|
333
|
+
function MonthGrid({
|
|
334
|
+
month,
|
|
335
|
+
locale,
|
|
336
|
+
onPick,
|
|
337
|
+
startMonth,
|
|
338
|
+
endMonth,
|
|
339
|
+
}: {
|
|
340
|
+
month: Date;
|
|
341
|
+
locale: string | undefined;
|
|
342
|
+
onPick: (month: Date) => void;
|
|
343
|
+
startMonth: Date;
|
|
344
|
+
endMonth: Date;
|
|
345
|
+
}) {
|
|
346
|
+
const tag = locale ?? "th";
|
|
347
|
+
const [year, setYear] = useState(month.getFullYear());
|
|
348
|
+
const today = new Date();
|
|
349
|
+
const yearLabel = (y: number) => new Date(y, 0, 1).toLocaleString(tag, { year: "numeric" });
|
|
350
|
+
const step =
|
|
351
|
+
"flex size-7 items-center justify-center rounded-md text-muted-foreground outline-none hover:bg-accent focus-visible:ring-3 focus-visible:ring-ring/50";
|
|
352
|
+
return (
|
|
353
|
+
<div className="flex flex-col gap-1">
|
|
354
|
+
<div className="flex h-8 items-center justify-between">
|
|
355
|
+
<button
|
|
356
|
+
type="button"
|
|
357
|
+
aria-label="-"
|
|
358
|
+
className={step}
|
|
359
|
+
disabled={year <= startMonth.getFullYear()}
|
|
360
|
+
onClick={() => setYear(year - 1)}
|
|
361
|
+
>
|
|
362
|
+
<ChevronLeftIcon className="size-4" />
|
|
363
|
+
</button>
|
|
364
|
+
<span className="font-semibold text-sm">{yearLabel(year)}</span>
|
|
365
|
+
<button
|
|
366
|
+
type="button"
|
|
367
|
+
aria-label="+"
|
|
368
|
+
className={step}
|
|
369
|
+
disabled={year >= endMonth.getFullYear()}
|
|
370
|
+
onClick={() => setYear(year + 1)}
|
|
371
|
+
>
|
|
372
|
+
<ChevronRightIcon className="size-4" />
|
|
373
|
+
</button>
|
|
374
|
+
</div>
|
|
375
|
+
<div className="grid grid-cols-3 gap-1">
|
|
376
|
+
{Array.from({ length: 12 }, (_, m) => {
|
|
377
|
+
const current = year === month.getFullYear() && m === month.getMonth();
|
|
378
|
+
const thisMonth = year === today.getFullYear() && m === today.getMonth();
|
|
379
|
+
const at = new Date(year, m, 1).getTime();
|
|
380
|
+
const outside = at < startMonth.getTime() || at > endMonth.getTime();
|
|
381
|
+
const label = new Date(2000, m, 1).toLocaleString(tag, { month: "short" });
|
|
382
|
+
return (
|
|
383
|
+
<button
|
|
384
|
+
key={label}
|
|
385
|
+
type="button"
|
|
386
|
+
disabled={outside}
|
|
387
|
+
onClick={() => onPick(new Date(year, m, 1))}
|
|
388
|
+
className={cn(
|
|
389
|
+
"h-[3.25rem] rounded-md text-sm outline-none focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-40",
|
|
390
|
+
current ? "bg-primary font-semibold text-primary-foreground" : "hover:bg-accent",
|
|
391
|
+
!current && thisMonth && "bg-muted font-medium",
|
|
392
|
+
)}
|
|
393
|
+
>
|
|
394
|
+
{label}
|
|
395
|
+
</button>
|
|
396
|
+
);
|
|
397
|
+
})}
|
|
398
|
+
</div>
|
|
399
|
+
</div>
|
|
400
|
+
);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/** ช่วงวันที่ที่เลือกได้ (`minDate`/`maxDate`) — ใช้กับ disabled ของปฏิทิน เดือนที่เลื่อนไปได้ และเหตุผลของค่าที่พิมพ์ */
|
|
404
|
+
interface Bounds {
|
|
405
|
+
lo: Date | undefined;
|
|
406
|
+
hi: Date | undefined;
|
|
407
|
+
/** ข้อความเหตุผลที่วันนี้เลือกไม่ได้ (null = เลือกได้) */
|
|
408
|
+
reason: (date: Date) => string | null;
|
|
409
|
+
matchers: Matcher[];
|
|
410
|
+
startMonth: Date;
|
|
411
|
+
endMonth: Date;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function makeBounds(
|
|
415
|
+
minDate: DateInput,
|
|
416
|
+
maxDate: DateInput,
|
|
417
|
+
locale: string | undefined,
|
|
418
|
+
text: { beforeMin?: string; afterMax?: string } | undefined,
|
|
419
|
+
): Bounds {
|
|
420
|
+
const day = (d: Date) => new Date(d.getFullYear(), d.getMonth(), d.getDate());
|
|
421
|
+
const min = toDate(minDate);
|
|
422
|
+
const max = toDate(maxDate);
|
|
423
|
+
const lo = min && day(min);
|
|
424
|
+
const hi = max && day(max);
|
|
425
|
+
const reason = (date: Date) => {
|
|
426
|
+
const d = day(date);
|
|
427
|
+
if (lo && d.getTime() < lo.getTime())
|
|
428
|
+
return (text?.beforeMin ?? "เลือกได้ตั้งแต่วันที่ {date}").replace("{date}", formatDate(lo, locale));
|
|
429
|
+
if (hi && d.getTime() > hi.getTime())
|
|
430
|
+
return (text?.afterMax ?? "เลือกได้ถึงวันที่ {date}").replace("{date}", formatDate(hi, locale));
|
|
431
|
+
return null;
|
|
432
|
+
};
|
|
433
|
+
const matchers: Matcher[] = [];
|
|
434
|
+
if (lo) matchers.push({ before: lo });
|
|
435
|
+
if (hi) matchers.push({ after: hi });
|
|
436
|
+
return {
|
|
437
|
+
lo,
|
|
438
|
+
hi,
|
|
439
|
+
reason,
|
|
440
|
+
matchers,
|
|
441
|
+
startMonth: lo ? new Date(lo.getFullYear(), lo.getMonth(), 1) : new Date(1990, 0),
|
|
442
|
+
endMonth: hi ? new Date(hi.getFullYear(), hi.getMonth(), 1) : new Date(2040, 11),
|
|
443
|
+
};
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/** ผลอ่านจากข้อความที่พิมพ์ — `label` ที่คนอ่านออก + เดือนที่ปฏิทินควรเลื่อนไปแสดง */
|
|
447
|
+
interface DatePreview {
|
|
448
|
+
label: string;
|
|
449
|
+
month: Date;
|
|
450
|
+
/** อ่านออกแต่อยู่นอก `minDate`/`maxDate` — ข้อความเหตุผล (ไม่ยืนยัน ไม่ไฮไลต์ในปฏิทิน) */
|
|
451
|
+
blocked?: string;
|
|
452
|
+
}
|
|
453
|
+
interface SinglePreview extends DatePreview {
|
|
454
|
+
date: Date;
|
|
455
|
+
}
|
|
456
|
+
interface RangePreview extends DatePreview {
|
|
457
|
+
range: DatePickerRange;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
interface ShellApi<T extends DatePreview> {
|
|
461
|
+
month: Date;
|
|
462
|
+
setMonth: (month: Date) => void;
|
|
463
|
+
/** ผลอ่านของข้อความที่กำลังพิมพ์ (null = ไม่ได้พิมพ์/อ่านไม่ออก) */
|
|
464
|
+
preview: T | null;
|
|
465
|
+
/** ปิดป๊อปอัปและจบการพิมพ์ (เรียกหลังเลือกจากปฏิทิน) */
|
|
466
|
+
close: () => void;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
interface ShellProps<T extends DatePreview> {
|
|
470
|
+
id?: string;
|
|
471
|
+
locale?: string;
|
|
472
|
+
/** ค่าที่เลือกอยู่อยู่นอก `minDate`/`maxDate` — ช่องขึ้นสถานะผิด */
|
|
473
|
+
invalid?: boolean;
|
|
474
|
+
startMonth: Date;
|
|
475
|
+
endMonth: Date;
|
|
476
|
+
disabled: boolean;
|
|
477
|
+
className?: string;
|
|
478
|
+
required: boolean;
|
|
479
|
+
placeholder: string;
|
|
480
|
+
/** false = ช่องอ่านอย่างเดียว กดแล้วเปิดปฏิทิน (`showSmartInput={false}`) */
|
|
481
|
+
typing: boolean;
|
|
482
|
+
/** ข้อความของค่าที่เลือกอยู่ ("" = ว่าง) */
|
|
483
|
+
display: string;
|
|
484
|
+
relative: string | null;
|
|
485
|
+
allowClear: boolean;
|
|
486
|
+
onClear: () => void;
|
|
487
|
+
/** เดือนที่ปฏิทินเปิดเมื่อไม่มีอะไรให้โฟกัส (ค่าที่เลือกอยู่ หรือวันนี้) */
|
|
488
|
+
initialMonth: Date;
|
|
489
|
+
parse: (text: string) => T | null;
|
|
490
|
+
/** ยืนยันข้อความ — คืน false = ใช้ไม่ได้ (อ่านไม่ออก) · ข้อความว่าง = ล้างค่า */
|
|
491
|
+
commit: (text: string) => boolean;
|
|
492
|
+
chips: string[];
|
|
493
|
+
sheet: Array<{ code: string; text: string }>;
|
|
494
|
+
onClose?: () => void;
|
|
495
|
+
children: (api: ShellApi<T>) => ReactNode;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
function DateFieldShell<T extends DatePreview>({
|
|
499
|
+
id,
|
|
500
|
+
locale,
|
|
501
|
+
invalid = false,
|
|
502
|
+
startMonth,
|
|
503
|
+
endMonth,
|
|
504
|
+
disabled,
|
|
505
|
+
className,
|
|
506
|
+
required,
|
|
507
|
+
placeholder,
|
|
508
|
+
typing,
|
|
509
|
+
display,
|
|
510
|
+
relative,
|
|
511
|
+
allowClear,
|
|
512
|
+
onClear,
|
|
513
|
+
initialMonth,
|
|
514
|
+
parse,
|
|
515
|
+
commit,
|
|
516
|
+
chips,
|
|
517
|
+
sheet,
|
|
518
|
+
onClose,
|
|
519
|
+
children,
|
|
520
|
+
}: ShellProps<T>) {
|
|
521
|
+
const text = useUiText().datePicker;
|
|
522
|
+
const previewId = useId();
|
|
523
|
+
const [open, setOpen] = useState(false);
|
|
524
|
+
const [editing, setEditing] = useState(false);
|
|
525
|
+
// ผูกกับ state แบบทันที — onFocus ที่ถูกเรียกในจังหวะเดียวกับ fill()/onChange ต้องเห็นว่ากำลังพิมพ์อยู่แล้ว
|
|
526
|
+
// (อ่านจาก state จะได้ค่าเก่า แล้วเขียนทับข้อความที่เพิ่งเติมด้วยค่าเดิม)
|
|
527
|
+
const editingRef = useRef(false);
|
|
528
|
+
const markEditing = (value: boolean) => {
|
|
529
|
+
editingRef.current = value;
|
|
530
|
+
setEditing(value);
|
|
531
|
+
};
|
|
532
|
+
const [draft, setDraft] = useState("");
|
|
533
|
+
const [showSheet, setShowSheet] = useState(false);
|
|
534
|
+
const [view, setView] = useState<"days" | "months">("days");
|
|
535
|
+
const [month, setMonth] = useState<Date>(initialMonth);
|
|
536
|
+
const wrapperRef = useRef<HTMLDivElement>(null);
|
|
537
|
+
const inputRef = useRef<HTMLInputElement>(null);
|
|
538
|
+
const popupRef = useRef<HTMLDivElement>(null);
|
|
539
|
+
// กดเมาส์ในช่องตอน "ยังไม่ได้อยู่ในโหมดพิมพ์" (โฟกัสใหม่ หรือโฟกัสค้างอยู่หลังกด Enter) — เลือกทั้งข้อความตอน
|
|
540
|
+
// mouseup ให้พิมพ์ทับได้ (เลือกตอน mousedown/focus ไม่ได้ผล: เบราว์เซอร์เคลียร์ทิ้งเมื่อปล่อยเมาส์ แล้วพิมพ์ต่อท้ายค่าเดิม)
|
|
541
|
+
// โฟกัสด้วย Tab ไม่มี mouseup เลือกได้ทันที
|
|
542
|
+
const pointerFocus = useRef(false);
|
|
543
|
+
// โฟกัสที่เราคืนให้ช่องเองหลังเลือกจากปฏิทิน — ห้ามเริ่ม "โหมดพิมพ์" ใหม่ (draft จะเป็นค่าเก่าก่อนยืนยัน)
|
|
544
|
+
const returningFocus = useRef(false);
|
|
545
|
+
|
|
546
|
+
const shown = editing ? draft : display;
|
|
547
|
+
const trimmed = draft.trim();
|
|
548
|
+
const preview = typing && editing && trimmed !== "" ? parse(draft) : null;
|
|
549
|
+
const unreadable = typing && editing && trimmed !== "" && preview === null;
|
|
550
|
+
const example = chips[1] ?? chips[0] ?? "15/3";
|
|
551
|
+
|
|
552
|
+
const shut = () => {
|
|
553
|
+
setOpen(false);
|
|
554
|
+
setShowSheet(false);
|
|
555
|
+
setView("days");
|
|
556
|
+
onClose?.();
|
|
557
|
+
};
|
|
558
|
+
const openCalendar = () => {
|
|
559
|
+
if (disabled) return;
|
|
560
|
+
if (!open) setMonth(preview?.month ?? initialMonth);
|
|
561
|
+
setOpen(true);
|
|
562
|
+
};
|
|
563
|
+
/** จบการพิมพ์: ข้อความใช้ได้และต่างจากเดิม = ยืนยัน · อ่านไม่ออก = คืนค่าเดิม */
|
|
564
|
+
const settle = () => {
|
|
565
|
+
if (editing && trimmed !== display.trim()) commit(draft);
|
|
566
|
+
markEditing(false);
|
|
567
|
+
};
|
|
568
|
+
const apply = () => {
|
|
569
|
+
if (!editing || trimmed === display.trim() || commit(draft)) {
|
|
570
|
+
markEditing(false);
|
|
571
|
+
shut();
|
|
572
|
+
selectSoon();
|
|
573
|
+
}
|
|
574
|
+
};
|
|
575
|
+
const refocusInput = () => {
|
|
576
|
+
returningFocus.current = true;
|
|
577
|
+
inputRef.current?.focus();
|
|
578
|
+
returningFocus.current = false;
|
|
579
|
+
};
|
|
580
|
+
/**
|
|
581
|
+
* ยืนยัน/ยกเลิกแล้วช่องยังโฟกัสอยู่ — เลือกข้อความรอไว้ ให้พิมพ์ครั้งต่อไปทับค่าเดิมเลย (ไม่ใช่ต่อท้าย)
|
|
582
|
+
* รอเฟรมถัดไปให้ช่องแสดงค่าใหม่ก่อน · ข้ามถ้าผู้ใช้เริ่มพิมพ์ไปแล้ว
|
|
583
|
+
*/
|
|
584
|
+
const selectSoon = () => {
|
|
585
|
+
requestAnimationFrame(() => {
|
|
586
|
+
const el = inputRef.current;
|
|
587
|
+
if (el && document.activeElement === el && !editingRef.current) el.select();
|
|
588
|
+
});
|
|
589
|
+
};
|
|
590
|
+
/** คีย์บอร์ด: ลูกศรลงตอนปฏิทินเปิดอยู่ = ย้ายโฟกัสเข้าปฏิทิน (ลูกศร/Enter เลือกวันได้ตามปกติของ DayPicker) */
|
|
591
|
+
const focusCalendar = () => {
|
|
592
|
+
const popup = popupRef.current;
|
|
593
|
+
const day =
|
|
594
|
+
popup?.querySelector<HTMLElement>("button[data-day][tabindex='0']") ??
|
|
595
|
+
popup?.querySelector<HTMLElement>("button[data-day]:not([disabled])");
|
|
596
|
+
day?.focus();
|
|
597
|
+
};
|
|
598
|
+
const fill = (value: string) => {
|
|
599
|
+
markEditing(true);
|
|
600
|
+
setDraft(value);
|
|
601
|
+
const next = parse(value);
|
|
602
|
+
if (next) setMonth(next.month);
|
|
603
|
+
inputRef.current?.focus();
|
|
604
|
+
};
|
|
605
|
+
|
|
606
|
+
return (
|
|
607
|
+
<PopoverPrimitive.Root
|
|
608
|
+
open={open}
|
|
609
|
+
onOpenChange={(next, details) => {
|
|
610
|
+
if (next) return;
|
|
611
|
+
// คลิกในช่อง/ปุ่มปฏิทินของเราเองนับเป็น "outside press" ของ popover — ไม่ปิด (ไม่งั้นปิดแล้วเปิดกระพริบ)
|
|
612
|
+
const target = details.event?.target;
|
|
613
|
+
if (
|
|
614
|
+
details.reason === "outside-press" &&
|
|
615
|
+
target instanceof Node &&
|
|
616
|
+
wrapperRef.current?.contains(target)
|
|
617
|
+
) {
|
|
618
|
+
details.cancel();
|
|
619
|
+
return;
|
|
620
|
+
}
|
|
621
|
+
settle();
|
|
622
|
+
shut();
|
|
623
|
+
// Escape ตอนโฟกัสอยู่ในปฏิทิน: คืนโฟกัสให้ช่อง ไม่ให้หลุดไป body
|
|
624
|
+
if (details.reason === "escape-key") refocusInput();
|
|
625
|
+
}}
|
|
626
|
+
>
|
|
627
|
+
<InputGroup
|
|
628
|
+
ref={wrapperRef}
|
|
629
|
+
className={className}
|
|
630
|
+
onKeyDownCapture={(event) => {
|
|
631
|
+
// Tab ออกจากช่อง = ปิดป๊อปอัป "ก่อน" เบราว์เซอร์ย้ายโฟกัส: ระหว่างเปิด Base UI วางตัวดักโฟกัส
|
|
632
|
+
// (<span tabindex=0>) ต่อท้ายช่อง ถ้ายังอยู่ Tab จะตกไปที่มันแล้วโฟกัสหลุดไป body ไม่ไปช่องถัดไป
|
|
633
|
+
// (ป๊อปอัปไม่มี animation ตอนปิดโดยตั้งใจ — Base UI รอ animation จบก่อนถอด ตัวดักจึงค้างอยู่ระหว่างนั้น)
|
|
634
|
+
if (event.key === "Tab" && !event.shiftKey && open) {
|
|
635
|
+
settle();
|
|
636
|
+
shut();
|
|
637
|
+
}
|
|
638
|
+
}}
|
|
639
|
+
onBlur={(event) => {
|
|
640
|
+
const next = event.relatedTarget;
|
|
641
|
+
if (
|
|
642
|
+
next instanceof Node &&
|
|
643
|
+
(wrapperRef.current?.contains(next) || popupRef.current?.contains(next))
|
|
644
|
+
)
|
|
645
|
+
return;
|
|
646
|
+
settle();
|
|
647
|
+
shut();
|
|
648
|
+
}}
|
|
649
|
+
>
|
|
650
|
+
<InputGroupInput
|
|
651
|
+
id={id}
|
|
652
|
+
ref={inputRef}
|
|
653
|
+
value={shown}
|
|
654
|
+
readOnly={!typing}
|
|
655
|
+
disabled={disabled}
|
|
656
|
+
placeholder={placeholder}
|
|
657
|
+
autoComplete="off"
|
|
658
|
+
role="combobox"
|
|
659
|
+
aria-haspopup="dialog"
|
|
660
|
+
aria-expanded={open}
|
|
661
|
+
aria-required={required || undefined}
|
|
662
|
+
aria-invalid={invalid || undefined}
|
|
663
|
+
aria-describedby={open && editing && trimmed !== "" ? previewId : undefined}
|
|
664
|
+
onMouseDown={() => {
|
|
665
|
+
pointerFocus.current = !editingRef.current;
|
|
666
|
+
}}
|
|
667
|
+
onMouseUp={(event) => {
|
|
668
|
+
if (!pointerFocus.current) return;
|
|
669
|
+
pointerFocus.current = false;
|
|
670
|
+
if (typing && !disabled) {
|
|
671
|
+
if (!editingRef.current) {
|
|
672
|
+
markEditing(true);
|
|
673
|
+
setDraft(display);
|
|
674
|
+
}
|
|
675
|
+
event.preventDefault();
|
|
676
|
+
inputRef.current?.select();
|
|
677
|
+
}
|
|
678
|
+
}}
|
|
679
|
+
onFocus={() => {
|
|
680
|
+
if (!typing || disabled || returningFocus.current) return;
|
|
681
|
+
if (!editingRef.current) {
|
|
682
|
+
markEditing(true);
|
|
683
|
+
setDraft(display);
|
|
684
|
+
}
|
|
685
|
+
// โฟกัสด้วย Tab = เลือกทั้งข้อความทันที (พิมพ์ทับได้เลย) · ด้วยเมาส์ เลือกตอน mouseup (ด้านบน)
|
|
686
|
+
// ไม่เปิดปฏิทินตอนโฟกัส — เปิดเมื่อพิมพ์/คลิก/กดลูกศรลง
|
|
687
|
+
if (!pointerFocus.current) inputRef.current?.select();
|
|
688
|
+
}}
|
|
689
|
+
onClick={openCalendar}
|
|
690
|
+
onChange={(event) => {
|
|
691
|
+
const value = event.target.value;
|
|
692
|
+
markEditing(true);
|
|
693
|
+
setDraft(value);
|
|
694
|
+
const next = parse(value);
|
|
695
|
+
if (next) setMonth(next.month);
|
|
696
|
+
setShowSheet(false);
|
|
697
|
+
setOpen(true);
|
|
698
|
+
}}
|
|
699
|
+
onKeyDown={(event) => {
|
|
700
|
+
if (event.key === "ArrowDown") {
|
|
701
|
+
event.preventDefault();
|
|
702
|
+
if (open) focusCalendar();
|
|
703
|
+
else openCalendar();
|
|
704
|
+
} else if (event.key === "Enter") {
|
|
705
|
+
// ยืนยันค่าที่พิมพ์ — ไม่ปล่อยให้ Enter ส่งฟอร์มแม่ระหว่างพิมพ์
|
|
706
|
+
if (editing && typing) {
|
|
707
|
+
event.preventDefault();
|
|
708
|
+
apply();
|
|
709
|
+
} else if (open) {
|
|
710
|
+
event.preventDefault();
|
|
711
|
+
shut();
|
|
712
|
+
}
|
|
713
|
+
} else if (event.key === "Escape" && (open || editing)) {
|
|
714
|
+
event.preventDefault();
|
|
715
|
+
event.stopPropagation();
|
|
716
|
+
markEditing(false);
|
|
717
|
+
shut();
|
|
718
|
+
selectSoon();
|
|
719
|
+
}
|
|
720
|
+
}}
|
|
721
|
+
/>
|
|
722
|
+
<InputGroupAddon align="inline-start">
|
|
723
|
+
<InputGroupButton
|
|
724
|
+
type="button"
|
|
725
|
+
size="icon-xs"
|
|
726
|
+
disabled={disabled}
|
|
727
|
+
aria-label={text?.openCalendar ?? "เปิดปฏิทิน"}
|
|
728
|
+
aria-expanded={open}
|
|
729
|
+
onClick={() => {
|
|
730
|
+
if (open) {
|
|
731
|
+
settle();
|
|
732
|
+
shut();
|
|
733
|
+
} else {
|
|
734
|
+
openCalendar();
|
|
735
|
+
inputRef.current?.focus();
|
|
736
|
+
}
|
|
737
|
+
}}
|
|
738
|
+
>
|
|
739
|
+
<CalendarIcon />
|
|
740
|
+
</InputGroupButton>
|
|
741
|
+
</InputGroupAddon>
|
|
742
|
+
<InputGroupAddon align="inline-end">
|
|
743
|
+
{editing && typing && preview && !preview.blocked && (
|
|
744
|
+
<kbd className="rounded border px-1 font-mono text-caption text-muted-foreground leading-4">
|
|
745
|
+
Enter
|
|
746
|
+
</kbd>
|
|
747
|
+
)}
|
|
748
|
+
{relative && !editing && (
|
|
749
|
+
<span className="whitespace-nowrap text-caption text-muted-foreground">
|
|
750
|
+
({relative})
|
|
751
|
+
</span>
|
|
752
|
+
)}
|
|
753
|
+
{allowClear && display !== "" && !disabled && (
|
|
754
|
+
<InputGroupButton
|
|
755
|
+
type="button"
|
|
756
|
+
size="icon-xs"
|
|
757
|
+
aria-label={text?.clear ?? "ล้างวันที่"}
|
|
758
|
+
onClick={() => {
|
|
759
|
+
markEditing(false);
|
|
760
|
+
onClear();
|
|
761
|
+
}}
|
|
762
|
+
>
|
|
763
|
+
<X />
|
|
764
|
+
</InputGroupButton>
|
|
765
|
+
)}
|
|
766
|
+
</InputGroupAddon>
|
|
767
|
+
</InputGroup>
|
|
768
|
+
|
|
769
|
+
<PopoverPrimitive.Portal>
|
|
770
|
+
<PopoverPrimitive.Positioner
|
|
771
|
+
anchor={wrapperRef}
|
|
772
|
+
side="bottom"
|
|
773
|
+
align="start"
|
|
774
|
+
sideOffset={4}
|
|
775
|
+
// พลิกได้เฉพาะบน↔ล่าง ห้ามข้ามไปวางข้างช่อง: ป๊อปอัปสูง ~400px ถ้าที่ว่างไม่พอทั้งบนและล่าง
|
|
776
|
+
// ค่าเริ่มต้นของ Base UI จะย้ายไปทางซ้าย ซึ่งไปทับเนื้อหาข้างช่อง (หน้าเลื่อนได้ ไม่ต้องบีบให้พอดีจอ)
|
|
777
|
+
collisionAvoidance={{ side: "flip", align: "shift", fallbackAxisSide: "none" }}
|
|
778
|
+
className="isolate z-50"
|
|
779
|
+
>
|
|
780
|
+
{/* ใช้ primitive ตรง ๆ ไม่ผ่าน PopoverContent ของ shadcn (ห้ามแก้ไฟล์นั้น): ต้องผูกกับช่องพิมพ์ด้วย `anchor`
|
|
781
|
+
และ `initialFocus={false}` ให้โฟกัสอยู่ที่ช่อง พิมพ์ต่อได้ตอนปฏิทินเปิด */}
|
|
782
|
+
<PopoverPrimitive.Popup
|
|
783
|
+
ref={popupRef}
|
|
784
|
+
data-slot="popover-content"
|
|
785
|
+
initialFocus={false}
|
|
786
|
+
finalFocus={false}
|
|
787
|
+
// กดในป๊อปอัปไม่ดึงโฟกัสออกจากช่องพิมพ์ (ยกเว้น <select> เดือน/ปี ที่ต้องรับโฟกัสถึงจะเปิดรายการ)
|
|
788
|
+
onMouseDown={(event) => {
|
|
789
|
+
if (!(event.target as Element).closest("select, option, input, textarea")) {
|
|
790
|
+
event.preventDefault();
|
|
791
|
+
}
|
|
792
|
+
}}
|
|
793
|
+
className="z-50 flex w-[306px] origin-(--transform-origin) flex-col gap-2 text-sm text-popover-foreground outline-hidden duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95"
|
|
794
|
+
>
|
|
795
|
+
{typing && trimmed !== "" && editing && (
|
|
796
|
+
<div id={previewId} role="status">
|
|
797
|
+
{preview?.blocked ? (
|
|
798
|
+
<p className="rounded-lg border border-destructive-border bg-destructive-subtle px-2.5 py-1.5 text-caption text-destructive-subtle-foreground shadow-sm">
|
|
799
|
+
{preview.blocked}
|
|
800
|
+
</p>
|
|
801
|
+
) : preview ? (
|
|
802
|
+
<button
|
|
803
|
+
type="button"
|
|
804
|
+
onClick={apply}
|
|
805
|
+
className="flex w-full items-center gap-2 rounded-lg border border-success-border bg-success-subtle px-2.5 py-1.5 text-left text-body text-success-subtle-foreground shadow-sm outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
|
|
806
|
+
>
|
|
807
|
+
<CheckIcon className="size-3.5 shrink-0" aria-hidden />
|
|
808
|
+
<span className="min-w-0 flex-1 truncate font-semibold">{preview.label}</span>
|
|
809
|
+
</button>
|
|
810
|
+
) : (
|
|
811
|
+
unreadable && (
|
|
812
|
+
<p className="rounded-lg border border-destructive-border bg-destructive-subtle px-2.5 py-1.5 text-caption text-destructive-subtle-foreground shadow-sm">
|
|
813
|
+
{(text?.cannotRead ?? "อ่านไม่ออก — ลองพิมพ์ {example}").replace(
|
|
814
|
+
"{example}",
|
|
815
|
+
example,
|
|
816
|
+
)}
|
|
817
|
+
</p>
|
|
818
|
+
)
|
|
819
|
+
)}
|
|
820
|
+
</div>
|
|
821
|
+
)}
|
|
822
|
+
|
|
823
|
+
<div className="flex flex-col gap-2 rounded-xl bg-popover p-3 shadow-md ring-1 ring-foreground/10">
|
|
824
|
+
{showSheet ? (
|
|
825
|
+
<div className="flex flex-col gap-1.5">
|
|
826
|
+
<div className="flex items-center justify-between font-semibold text-caption">
|
|
827
|
+
{text?.allShortcuts ?? "พิมพ์ลัดทั้งหมด"}
|
|
828
|
+
<span className="font-normal text-muted-foreground">
|
|
829
|
+
{text?.tapToFill ?? "กดเพื่อเติมในช่อง"}
|
|
830
|
+
</span>
|
|
831
|
+
</div>
|
|
832
|
+
{sheet.map((row) => (
|
|
833
|
+
<div
|
|
834
|
+
key={row.code}
|
|
835
|
+
className="grid grid-cols-[auto_minmax(0,1fr)] items-center gap-x-3 text-caption"
|
|
836
|
+
>
|
|
837
|
+
<button
|
|
838
|
+
type="button"
|
|
839
|
+
onClick={() => {
|
|
840
|
+
fill(row.code.split(" · ")[0] ?? row.code);
|
|
841
|
+
setShowSheet(false);
|
|
842
|
+
}}
|
|
843
|
+
className="justify-self-start whitespace-nowrap rounded-md border bg-muted px-1.5 py-0.5 font-mono outline-none hover:bg-accent focus-visible:ring-3 focus-visible:ring-ring/50"
|
|
844
|
+
>
|
|
845
|
+
{row.code}
|
|
846
|
+
</button>
|
|
847
|
+
<span className="text-muted-foreground">{row.text}</span>
|
|
848
|
+
</div>
|
|
849
|
+
))}
|
|
850
|
+
</div>
|
|
851
|
+
) : view === "months" ? (
|
|
852
|
+
<MonthGrid
|
|
853
|
+
month={month}
|
|
854
|
+
locale={locale}
|
|
855
|
+
startMonth={startMonth}
|
|
856
|
+
endMonth={endMonth}
|
|
857
|
+
onPick={(next) => {
|
|
858
|
+
setMonth(next);
|
|
859
|
+
setView("days");
|
|
860
|
+
}}
|
|
861
|
+
/>
|
|
862
|
+
) : (
|
|
863
|
+
<OpenMonthsContext.Provider value={() => setView("months")}>
|
|
864
|
+
{children({
|
|
865
|
+
month,
|
|
866
|
+
setMonth,
|
|
867
|
+
preview,
|
|
868
|
+
close: () => {
|
|
869
|
+
markEditing(false);
|
|
870
|
+
shut();
|
|
871
|
+
refocusInput();
|
|
872
|
+
selectSoon();
|
|
873
|
+
},
|
|
874
|
+
})}
|
|
875
|
+
</OpenMonthsContext.Provider>
|
|
876
|
+
)}
|
|
877
|
+
|
|
878
|
+
{typing && (
|
|
879
|
+
<div className="flex flex-wrap items-center gap-1.5 border-t pt-2 text-caption text-muted-foreground">
|
|
880
|
+
{showSheet ? (
|
|
881
|
+
<button
|
|
882
|
+
type="button"
|
|
883
|
+
onClick={() => setShowSheet(false)}
|
|
884
|
+
className="font-medium text-foreground outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
|
|
885
|
+
>
|
|
886
|
+
‹ {text?.backToCalendar ?? "ปฏิทิน"}
|
|
887
|
+
</button>
|
|
888
|
+
) : (
|
|
889
|
+
<>
|
|
890
|
+
<span className="whitespace-nowrap">{text?.shortcutsLabel ?? "พิมพ์ลัด"}</span>
|
|
891
|
+
{chips.map((chip) => (
|
|
892
|
+
<button
|
|
893
|
+
key={chip}
|
|
894
|
+
type="button"
|
|
895
|
+
onClick={() => fill(chip)}
|
|
896
|
+
className="whitespace-nowrap rounded-full border bg-background px-2 font-mono text-foreground outline-none hover:bg-accent focus-visible:ring-3 focus-visible:ring-ring/50"
|
|
897
|
+
>
|
|
898
|
+
{chip}
|
|
899
|
+
</button>
|
|
900
|
+
))}
|
|
901
|
+
<button
|
|
902
|
+
type="button"
|
|
903
|
+
onClick={() => setShowSheet(true)}
|
|
904
|
+
className="ms-auto whitespace-nowrap font-medium text-foreground outline-none focus-visible:ring-3 focus-visible:ring-ring/50"
|
|
905
|
+
>
|
|
906
|
+
{text?.showAll ?? "ทั้งหมด"} ›
|
|
907
|
+
</button>
|
|
908
|
+
</>
|
|
909
|
+
)}
|
|
910
|
+
</div>
|
|
911
|
+
)}
|
|
912
|
+
</div>
|
|
913
|
+
</PopoverPrimitive.Popup>
|
|
914
|
+
</PopoverPrimitive.Positioner>
|
|
915
|
+
</PopoverPrimitive.Portal>
|
|
916
|
+
</PopoverPrimitive.Root>
|
|
917
|
+
);
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
// ---------- วันเดียว ----------
|
|
921
|
+
|
|
259
922
|
function SingleDatePicker({
|
|
260
923
|
id,
|
|
261
924
|
value,
|
|
@@ -266,149 +929,109 @@ function SingleDatePicker({
|
|
|
266
929
|
locale,
|
|
267
930
|
placeholder,
|
|
268
931
|
disabled = false,
|
|
269
|
-
className,
|
|
270
932
|
allowClear = true,
|
|
933
|
+
className,
|
|
271
934
|
showSmartInput = true,
|
|
272
935
|
showRelative = true,
|
|
936
|
+
minDate,
|
|
937
|
+
maxDate,
|
|
273
938
|
}: DatePickerSingleProps) {
|
|
274
939
|
const text = useUiText().datePicker;
|
|
940
|
+
const bounds = makeBounds(minDate, maxDate, locale, text);
|
|
275
941
|
const mounted = useMounted();
|
|
276
|
-
const [open, setOpen] = useState(false);
|
|
277
|
-
const [smartText, setSmartText] = useState("");
|
|
278
942
|
// ไม่ควบคุม = มี defaultValue หรือไม่มีทั้ง value และ onChange (เช่นใช้แค่ name ในฟอร์ม)
|
|
279
943
|
const controlled = value !== undefined || (defaultValue === undefined && onChange !== undefined);
|
|
280
944
|
const [own, setOwn] = useState<Date | null>(() => toDate(defaultValue) ?? null);
|
|
281
|
-
|
|
282
945
|
const selectedDate = controlled ? toDate(value) : (own ?? undefined);
|
|
283
|
-
const isValidDate = selectedDate !== undefined;
|
|
284
|
-
|
|
285
|
-
const parsedSmartDate = useMemo(() => parseSmartDateInput(smartText), [smartText]);
|
|
286
946
|
|
|
287
947
|
const change = (date: Date | null) => {
|
|
288
948
|
if (!controlled) setOwn(date);
|
|
289
949
|
onChange?.(date);
|
|
290
950
|
};
|
|
291
951
|
|
|
292
|
-
const
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
setOpen(false);
|
|
302
|
-
setSmartText("");
|
|
303
|
-
}
|
|
952
|
+
const parse = (input: string): SinglePreview | null => {
|
|
953
|
+
const date = parseSmartDateInput(input);
|
|
954
|
+
if (!date) return null;
|
|
955
|
+
return {
|
|
956
|
+
date,
|
|
957
|
+
month: date,
|
|
958
|
+
label: `${weekdayShort(date, locale)} ${formatDate(date, locale)} · ${formatRelative(date, locale)}`,
|
|
959
|
+
blocked: bounds.reason(date) ?? undefined,
|
|
960
|
+
};
|
|
304
961
|
};
|
|
305
962
|
|
|
306
|
-
const
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
formatCaption: (d: Date) => d.toLocaleString(locale, { month: "long", year: "numeric" }),
|
|
314
|
-
}
|
|
315
|
-
: undefined;
|
|
963
|
+
const sheet = [
|
|
964
|
+
{ code: "15/3", text: text?.helpDate ?? "15 มี.ค. ปีนี้ (ไม่ใส่ปี = ปีปัจจุบัน)" },
|
|
965
|
+
{ code: "15/3/69 · 15/3/2026", text: text?.helpYear ?? "ใส่ปี พ.ศ. หรือ ค.ศ. ได้ (2 หรือ 4 หลัก)" },
|
|
966
|
+
{ code: "t", text: text?.helpToday ?? "วันนี้" },
|
|
967
|
+
{ code: "t+3 · t-2", text: text?.helpDays ?? "อีก 3 วัน · 2 วันก่อน (d = วัน)" },
|
|
968
|
+
{ code: "t+2w · t+1m · t+1y", text: text?.helpUnits ?? "สัปดาห์ · เดือน · ปี" },
|
|
969
|
+
];
|
|
316
970
|
|
|
317
971
|
return (
|
|
318
972
|
<>
|
|
319
|
-
<
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
973
|
+
<DateFieldShell<SinglePreview>
|
|
974
|
+
id={id}
|
|
975
|
+
locale={locale}
|
|
976
|
+
invalid={Boolean(selectedDate && bounds.reason(selectedDate))}
|
|
977
|
+
startMonth={bounds.startMonth}
|
|
978
|
+
endMonth={bounds.endMonth}
|
|
979
|
+
disabled={disabled}
|
|
980
|
+
className={className}
|
|
981
|
+
required={required}
|
|
982
|
+
typing={showSmartInput}
|
|
983
|
+
placeholder={
|
|
984
|
+
placeholder ??
|
|
985
|
+
(showSmartInput
|
|
986
|
+
? (text?.smartPlaceholder ?? "เช่น 15/3, t+3, t+1m")
|
|
987
|
+
: (text?.placeholder ?? "เลือกวันที่"))
|
|
988
|
+
}
|
|
989
|
+
display={selectedDate ? formatDate(selectedDate, locale) : ""}
|
|
990
|
+
// วันที่สัมพัทธ์ขึ้นกับ "วันนี้" ของเครื่องผู้ใช้ — แสดงหลัง mount กัน hydration mismatch
|
|
991
|
+
relative={
|
|
992
|
+
showRelative && mounted && selectedDate ? formatRelative(selectedDate, locale) : null
|
|
993
|
+
}
|
|
994
|
+
allowClear={allowClear}
|
|
995
|
+
onClear={() => change(null)}
|
|
996
|
+
initialMonth={selectedDate ?? new Date()}
|
|
997
|
+
parse={parse}
|
|
998
|
+
commit={(input) => {
|
|
999
|
+
if (input.trim() === "") {
|
|
1000
|
+
if (!allowClear) return false;
|
|
1001
|
+
change(null);
|
|
1002
|
+
return true;
|
|
1003
|
+
}
|
|
1004
|
+
const next = parse(input);
|
|
1005
|
+
if (!next || next.blocked) return false;
|
|
1006
|
+
change(next.date);
|
|
1007
|
+
return true;
|
|
324
1008
|
}}
|
|
1009
|
+
chips={["t", "t+3", "t+1m", "15/3"]}
|
|
1010
|
+
sheet={sheet}
|
|
325
1011
|
>
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
{/* วันที่สัมพัทธ์ขึ้นกับ "วันนี้" ของเครื่องผู้ใช้ — แสดงหลัง mount กัน hydration mismatch */}
|
|
350
|
-
{showRelative && mounted && (
|
|
351
|
-
<span className="ml-1 text-muted-foreground text-xs">
|
|
352
|
-
({formatRelative(selectedDate, locale)})
|
|
353
|
-
</span>
|
|
354
|
-
)}
|
|
355
|
-
</>
|
|
356
|
-
) : (
|
|
357
|
-
<span>{placeholder ?? text?.placeholder ?? "เลือกวันที่"}</span>
|
|
358
|
-
)}
|
|
359
|
-
</span>
|
|
360
|
-
</div>
|
|
361
|
-
{allowClear && isValidDate && !disabled && (
|
|
362
|
-
<span
|
|
363
|
-
role="button"
|
|
364
|
-
tabIndex={0}
|
|
365
|
-
aria-label={text?.clear ?? "ล้างวันที่"}
|
|
366
|
-
className="ml-1 cursor-pointer rounded p-0.5 text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
|
|
367
|
-
onClick={(e) => {
|
|
368
|
-
e.stopPropagation();
|
|
369
|
-
change(null);
|
|
370
|
-
}}
|
|
371
|
-
onKeyDown={(e) => {
|
|
372
|
-
if (e.key !== "Enter" && e.key !== " ") return;
|
|
373
|
-
e.preventDefault();
|
|
374
|
-
e.stopPropagation();
|
|
375
|
-
change(null);
|
|
376
|
-
}}
|
|
377
|
-
>
|
|
378
|
-
<X className="h-3.5 w-3.5" />
|
|
379
|
-
</span>
|
|
380
|
-
)}
|
|
381
|
-
</PopoverTrigger>
|
|
382
|
-
{/* กว้างพอดีปฏิทิน: ช่องวันที่ 40px × 7 + p-3 + ขอบ (เดิมกล่อง 320–340px แต่ช่อง 28px → ว่างซ้ายขวามาก) */}
|
|
383
|
-
<PopoverContent className="w-[306px] border bg-background p-3 shadow-xl" align="start">
|
|
384
|
-
{showSmartInput && (
|
|
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
|
-
/>
|
|
393
|
-
)}
|
|
394
|
-
|
|
395
|
-
{/* Visual DayPicker Calendar */}
|
|
396
|
-
<div className="flex w-full justify-center">
|
|
397
|
-
<Calendar
|
|
398
|
-
mode="single"
|
|
399
|
-
selected={selectedDate}
|
|
400
|
-
onSelect={handleSelectDate}
|
|
401
|
-
disabled={disabled}
|
|
402
|
-
captionLayout="dropdown"
|
|
403
|
-
startMonth={new Date(1990, 0)}
|
|
404
|
-
endMonth={new Date(2040, 11)}
|
|
405
|
-
defaultMonth={displayMonth}
|
|
406
|
-
formatters={calendarFormatters}
|
|
407
|
-
className="p-0 [--cell-size:--spacing(10)]"
|
|
408
|
-
/>
|
|
409
|
-
</div>
|
|
410
|
-
</PopoverContent>
|
|
411
|
-
</Popover>
|
|
1012
|
+
{(api) => (
|
|
1013
|
+
<Calendar
|
|
1014
|
+
mode="single"
|
|
1015
|
+
// ข้อความที่พิมพ์อยู่ไฮไลต์ในปฏิทินทันที — คลิกวันที่ไฮไลต์อยู่ = ยืนยันวันนั้น (ไม่ใช่ล้างค่าแบบ toggle)
|
|
1016
|
+
selected={
|
|
1017
|
+
(api.preview && !api.preview.blocked ? api.preview.date : undefined) ?? selectedDate
|
|
1018
|
+
}
|
|
1019
|
+
onSelect={(date) => {
|
|
1020
|
+
change(date ?? api.preview?.date ?? selectedDate ?? null);
|
|
1021
|
+
api.close();
|
|
1022
|
+
}}
|
|
1023
|
+
month={api.month}
|
|
1024
|
+
onMonthChange={api.setMonth}
|
|
1025
|
+
disabled={disabled ? true : bounds.matchers}
|
|
1026
|
+
captionLayout="label"
|
|
1027
|
+
startMonth={bounds.startMonth}
|
|
1028
|
+
endMonth={bounds.endMonth}
|
|
1029
|
+
formatters={calendarFormatters(locale)}
|
|
1030
|
+
components={{ DayButton: FocusableDayButton, CaptionLabel: CaptionButton }}
|
|
1031
|
+
className="p-0 [--cell-size:--spacing(10)]"
|
|
1032
|
+
/>
|
|
1033
|
+
)}
|
|
1034
|
+
</DateFieldShell>
|
|
412
1035
|
{name !== undefined && (
|
|
413
1036
|
<FormValue
|
|
414
1037
|
name={name}
|
|
@@ -421,6 +1044,8 @@ function SingleDatePicker({
|
|
|
421
1044
|
);
|
|
422
1045
|
}
|
|
423
1046
|
|
|
1047
|
+
// ---------- ช่วงวันที่ ----------
|
|
1048
|
+
|
|
424
1049
|
/** "1 – 5 ก.ค. 2569" / "1–5 Jul 2026" — ใช้ formatRange ของ Intl (ย่อเดือน/ปีที่ซ้ำให้เอง) */
|
|
425
1050
|
function formatRange(range: DatePickerRange, locale: string | undefined): string {
|
|
426
1051
|
const fmt = new Intl.DateTimeFormat(locale ?? "th-TH", {
|
|
@@ -451,141 +1076,131 @@ function RangeDatePicker({
|
|
|
451
1076
|
className,
|
|
452
1077
|
allowClear = true,
|
|
453
1078
|
showSmartInput = true,
|
|
1079
|
+
minDate,
|
|
1080
|
+
maxDate,
|
|
454
1081
|
}: DatePickerRangeProps) {
|
|
455
1082
|
const text = useUiText().datePicker;
|
|
456
|
-
const
|
|
457
|
-
const [smartText, setSmartText] = useState("");
|
|
1083
|
+
const bounds = makeBounds(minDate, maxDate, locale, text);
|
|
458
1084
|
const controlled = value !== undefined || (defaultValue === undefined && onChange !== undefined);
|
|
459
1085
|
const [own, setOwn] = useState<DatePickerRange | null>(() => toRange(defaultValue) ?? null);
|
|
460
1086
|
// ระหว่างคลิกเลือก: คลิกแรก = วันเริ่ม (ยังไม่ปิด) คลิกที่สอง = วันสุดท้าย แล้วปิด
|
|
461
1087
|
const [start, setStart] = useState<Date | null>(null);
|
|
462
|
-
|
|
463
1088
|
const selected = controlled ? toRange(value) : (own ?? undefined);
|
|
464
|
-
const parsed = useMemo(() => parseSmartDateRange(smartText), [smartText]);
|
|
465
1089
|
|
|
466
|
-
const close = () => {
|
|
467
|
-
setOpen(false);
|
|
468
|
-
setSmartText("");
|
|
469
|
-
setStart(null);
|
|
470
|
-
};
|
|
471
1090
|
const change = (range: DatePickerRange | null) => {
|
|
472
1091
|
if (!controlled) setOwn(range);
|
|
473
1092
|
onChange?.(range);
|
|
474
1093
|
};
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
1094
|
+
|
|
1095
|
+
const parse = (input: string): RangePreview | null => {
|
|
1096
|
+
// พิมพ์วันเดียว ("t", "15/3") ในช่องช่วง = ช่วงหนึ่งวัน
|
|
1097
|
+
const range =
|
|
1098
|
+
parseSmartDateRange(input) ??
|
|
1099
|
+
(() => {
|
|
1100
|
+
const day = parseSmartDateInput(input);
|
|
1101
|
+
return day ? { from: day, to: day } : null;
|
|
1102
|
+
})();
|
|
1103
|
+
if (!range) return null;
|
|
1104
|
+
const days = differenceInCalendarDays(range.to, range.from) + 1;
|
|
1105
|
+
return {
|
|
1106
|
+
range,
|
|
1107
|
+
month: range.from,
|
|
1108
|
+
blocked: bounds.reason(range.from) ?? bounds.reason(range.to) ?? undefined,
|
|
1109
|
+
label:
|
|
1110
|
+
days === 1
|
|
1111
|
+
? `${weekdayShort(range.from, locale)} ${formatDate(range.from, locale)}`
|
|
1112
|
+
: `${formatRange(range, locale)} · ${(text?.dayCount ?? "{count} วัน").replace("{count}", String(days))}`,
|
|
1113
|
+
};
|
|
478
1114
|
};
|
|
479
1115
|
|
|
480
|
-
const
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
: undefined;
|
|
488
|
-
const calendarSelected = start ? { from: start, to: undefined } : selected;
|
|
1116
|
+
const sheet = [
|
|
1117
|
+
{ code: "t-7..t", text: text?.helpRangeShortcut ?? "ช่วงจากตัวย่อ (คั่นด้วย .. ~ ถึง to)" },
|
|
1118
|
+
{ code: "1/7-5/7 · 1-5/7", text: text?.helpRange ?? "1–5 ก.ค." },
|
|
1119
|
+
{ code: "15/3", text: text?.helpDate ?? "15 มี.ค. ปีนี้ (ไม่ใส่ปี = ปีปัจจุบัน)" },
|
|
1120
|
+
{ code: "t+3 · t-2", text: text?.helpDays ?? "อีก 3 วัน · 2 วันก่อน (d = วัน)" },
|
|
1121
|
+
{ code: "t+2w · t+1m · t+1y", text: text?.helpUnits ?? "สัปดาห์ · เดือน · ปี" },
|
|
1122
|
+
];
|
|
489
1123
|
|
|
490
1124
|
return (
|
|
491
1125
|
<>
|
|
492
|
-
<
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
1126
|
+
<DateFieldShell<RangePreview>
|
|
1127
|
+
id={id}
|
|
1128
|
+
locale={locale}
|
|
1129
|
+
invalid={Boolean(selected && (bounds.reason(selected.from) || bounds.reason(selected.to)))}
|
|
1130
|
+
startMonth={bounds.startMonth}
|
|
1131
|
+
endMonth={bounds.endMonth}
|
|
1132
|
+
disabled={disabled}
|
|
1133
|
+
className={className}
|
|
1134
|
+
required={required}
|
|
1135
|
+
typing={showSmartInput}
|
|
1136
|
+
placeholder={
|
|
1137
|
+
placeholder ??
|
|
1138
|
+
(showSmartInput
|
|
1139
|
+
? (text?.rangeSmartPlaceholder ?? "เช่น 1/7-5/7, t-7..t")
|
|
1140
|
+
: (text?.rangePlaceholder ?? "เลือกช่วงวันที่"))
|
|
1141
|
+
}
|
|
1142
|
+
display={selected ? formatRange(selected, locale) : ""}
|
|
1143
|
+
relative={null}
|
|
1144
|
+
allowClear={allowClear}
|
|
1145
|
+
onClear={() => change(null)}
|
|
1146
|
+
initialMonth={selected?.from ?? new Date()}
|
|
1147
|
+
parse={parse}
|
|
1148
|
+
commit={(input) => {
|
|
1149
|
+
if (input.trim() === "") {
|
|
1150
|
+
if (!allowClear) return false;
|
|
1151
|
+
change(null);
|
|
1152
|
+
return true;
|
|
1153
|
+
}
|
|
1154
|
+
const next = parse(input);
|
|
1155
|
+
if (!next || next.blocked) return false;
|
|
1156
|
+
change(next.range);
|
|
1157
|
+
return true;
|
|
497
1158
|
}}
|
|
1159
|
+
chips={["t-7..t", "1/7-5/7", "t ~ t+1m"]}
|
|
1160
|
+
sheet={sheet}
|
|
1161
|
+
onClose={() => setStart(null)}
|
|
498
1162
|
>
|
|
499
|
-
|
|
500
|
-
|
|
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">
|
|
1163
|
+
{(api) => (
|
|
1164
|
+
<>
|
|
558
1165
|
<Calendar
|
|
559
1166
|
mode="range"
|
|
560
|
-
selected={
|
|
1167
|
+
selected={
|
|
1168
|
+
start
|
|
1169
|
+
? { from: start, to: undefined }
|
|
1170
|
+
: ((api.preview && !api.preview.blocked ? api.preview.range : undefined) ??
|
|
1171
|
+
selected)
|
|
1172
|
+
}
|
|
561
1173
|
onSelect={(_range, day) => {
|
|
562
1174
|
// คุมสองคลิกเอง: react-day-picker คลิกแรกให้ from = to (ช่วงหนึ่งวัน) จึงแยกคลิกแรกไม่ออก
|
|
563
1175
|
if (!start) {
|
|
564
1176
|
setStart(day);
|
|
565
1177
|
return;
|
|
566
1178
|
}
|
|
567
|
-
|
|
1179
|
+
change(
|
|
568
1180
|
day.getTime() < start.getTime()
|
|
569
1181
|
? { from: day, to: start }
|
|
570
1182
|
: { from: start, to: day },
|
|
571
1183
|
);
|
|
1184
|
+
api.close();
|
|
572
1185
|
}}
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
1186
|
+
month={api.month}
|
|
1187
|
+
onMonthChange={api.setMonth}
|
|
1188
|
+
disabled={disabled ? true : bounds.matchers}
|
|
1189
|
+
captionLayout="label"
|
|
1190
|
+
startMonth={bounds.startMonth}
|
|
1191
|
+
endMonth={bounds.endMonth}
|
|
1192
|
+
formatters={calendarFormatters(locale)}
|
|
1193
|
+
components={{ DayButton: FocusableDayButton, CaptionLabel: CaptionButton }}
|
|
579
1194
|
className="p-0 [--cell-size:--spacing(10)]"
|
|
580
1195
|
/>
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
</
|
|
1196
|
+
{start && (
|
|
1197
|
+
<p className="text-center text-caption text-muted-foreground">
|
|
1198
|
+
{text?.pickEnd ?? "เลือกวันสุดท้ายของช่วง"}
|
|
1199
|
+
</p>
|
|
1200
|
+
)}
|
|
1201
|
+
</>
|
|
1202
|
+
)}
|
|
1203
|
+
</DateFieldShell>
|
|
589
1204
|
{name !== undefined && (
|
|
590
1205
|
<>
|
|
591
1206
|
<FormValue
|
|
@@ -605,95 +1220,3 @@ function RangeDatePicker({
|
|
|
605
1220
|
</>
|
|
606
1221
|
);
|
|
607
1222
|
}
|
|
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
|
-
}
|