@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.
@@ -1,6 +1,7 @@
1
1
  "use client";
2
2
 
3
- import { Button } from "@suphark/ui/components/shared/button/button";
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 { CalendarIcon, Check, InfoIcon, X } from "lucide-react";
18
- import { useMemo, useState } from "react";
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
- * หรือพิมพ์ "1/7-5/7") · พิมพ์ "15/3", "15/3/69" ได้ · มีทางลัด · ใช้ในฟอร์มด้วย `name`
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
- // อยู่หลังปุ่มเสมอ: label ที่ครอบ (LabeledField) คลิกแล้วไปที่ปุ่ม ไม่ใช่ช่องนี้ · ตาข่ายฟอร์มอ่านชื่อจาก label เดียวกัน
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 handleSelectDate = (date: Date | undefined) => {
293
- change(date ?? null);
294
- setOpen(false);
295
- setSmartText("");
296
- };
297
-
298
- const handleSmartSubmit = () => {
299
- if (parsedSmartDate) {
300
- change(parsedSmartDate);
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 displayMonth = parsedSmartDate || selectedDate;
307
- // ปฏิทินใช้ locale เดียวกับช่อง (ชื่อเดือน/วัน/ปี) — ไม่ส่ง locale = ค่าเดิมของ Calendar
308
- const calendarFormatters = locale
309
- ? {
310
- formatMonthDropdown: (d: Date) => d.toLocaleString(locale, { month: "short" }),
311
- formatYearDropdown: (d: Date) => d.toLocaleString(locale, { year: "numeric" }),
312
- formatWeekdayName: (d: Date) => d.toLocaleString(locale, { weekday: "narrow" }),
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
- <Popover
320
- open={open}
321
- onOpenChange={(o) => {
322
- setOpen(o);
323
- if (!o) setSmartText("");
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
- <PopoverTrigger
327
- render={
328
- <Button
329
- id={id}
330
- variant="outline"
331
- disabled={disabled}
332
- aria-required={required || undefined}
333
- className={cn(
334
- "h-8 w-full justify-between bg-background pr-2 pl-3 text-left font-normal text-xs",
335
- !isValidDate && "text-muted-foreground",
336
- className,
337
- )}
338
- />
339
- }
340
- >
341
- <div className="flex items-center gap-2 truncate">
342
- <CalendarIcon className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
343
- <span className="truncate">
344
- {isValidDate ? (
345
- <>
346
- <span className="font-medium text-foreground">
347
- {formatDate(selectedDate, locale)}
348
- </span>
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 [open, setOpen] = useState(false);
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
- const commit = (range: DatePickerRange | null) => {
476
- change(range);
477
- close();
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 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;
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
- <Popover
493
- open={open}
494
- onOpenChange={(o) => {
495
- if (o) setOpen(true);
496
- else close();
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
- <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">
1163
+ {(api) => (
1164
+ <>
558
1165
  <Calendar
559
1166
  mode="range"
560
- selected={calendarSelected}
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
- commit(
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
- 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}
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
- </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>
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
- }