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