@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.
- package/CHANGELOG.md +79 -1
- package/README.md +2 -0
- package/package.json +2 -2
- package/src/components/app-shell/header-breadcrumbs.tsx +24 -0
- package/src/components/pwa/web-push-row.tsx +126 -0
- package/src/components/shared/date/date-picker.tsx +750 -319
- package/src/components/shared/notification-center.tsx +99 -11
- package/src/components/shared/record-card.tsx +158 -0
- package/src/components/shared/selection-bar.tsx +20 -6
- package/src/components/shared/status-badge.tsx +16 -2
- package/src/components/shared/ui-text.tsx +25 -0
- package/src/components/sortable/sortable-list.tsx +24 -21
- package/src/index.ts +1 -0
- package/src/lib/types.ts +18 -0
- package/src/pwa.ts +2 -1
|
@@ -1,21 +1,36 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import {
|
|
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 {
|
|
18
|
-
|
|
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
|
-
*
|
|
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
|
-
//
|
|
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
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
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
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
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
|
-
<
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
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
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
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
|
-
|
|
476
|
-
|
|
477
|
-
|
|
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
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
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
|
-
<
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
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
|
-
|
|
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">
|
|
1076
|
+
{(api) => (
|
|
1077
|
+
<>
|
|
558
1078
|
<Calendar
|
|
559
1079
|
mode="range"
|
|
560
|
-
selected={
|
|
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
|
-
|
|
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="
|
|
1097
|
+
captionLayout="label"
|
|
575
1098
|
startMonth={new Date(1990, 0)}
|
|
576
1099
|
endMonth={new Date(2040, 11)}
|
|
577
|
-
|
|
578
|
-
|
|
1100
|
+
formatters={calendarFormatters(locale)}
|
|
1101
|
+
components={{ DayButton: FocusableDayButton, CaptionLabel: CaptionButton }}
|
|
579
1102
|
className="p-0 [--cell-size:--spacing(10)]"
|
|
580
1103
|
/>
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
</
|
|
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
|
-
}
|