@suphark/ui 0.53.0 → 0.59.1

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,13 +1,21 @@
1
1
  "use client";
2
2
 
3
3
  import { cn } from "@suphark/ui/lib/utils";
4
- import { Loader2, RefreshCw } from "lucide-react";
4
+ import { ArrowDown, Loader2, RefreshCw } from "lucide-react";
5
5
  import { useRouter } from "next/navigation";
6
6
  import { useEffect, useRef, useState } from "react";
7
7
 
8
- const ACTIVATION_DISTANCE = 72;
9
- const MAX_PULL_DISTANCE = 112;
8
+ /** ระยะเริ่มทำงาน / สูงสุด / แรงต้าน (px) ต่อแบบ — circle ดึงสั้นกว่า หนักมือกว่า */
9
+ const DIMS = {
10
+ pill: { activation: 72, max: 112, resistance: 0.62 },
11
+ circle: { activation: 64, max: 104, resistance: 0.5 },
12
+ } as const;
13
+ /** circle: ขนาดวงและที่พักใต้ขอบบนของ root ตอนกำลังรีเฟรช */
14
+ const CIRCLE_SIZE = 40;
15
+ const CIRCLE_REST = 16;
10
16
  const MOBILE_BREAKPOINT = 768;
17
+ /** หมุนอย่างน้อยเท่านี้ — refresh ที่เสร็จทันทีจะไม่กะพริบหายไปเลย */
18
+ const MIN_SPIN_MS = 500;
11
19
 
12
20
  export interface PullToRefreshText {
13
21
  pull: string;
@@ -28,20 +36,35 @@ export const PULL_TO_REFRESH_TEXT_EN: PullToRefreshText = {
28
36
  };
29
37
 
30
38
  /**
31
- * ดึงลงเพื่อรีเฟรช (มือถือ, < 768px) — เรียก `router.refresh()` เมื่อดึงเกินระยะ
32
- * ใส่ `data-pull-to-refresh-root` บน element ที่เลื่อนได้ของหน้า (ดึงได้เมื่อเลื่อนอยู่บนสุดเท่านั้น)
39
+ * ดึงลงเพื่อรีเฟรช (มือถือ, < 768px) — เรียก `onRefresh` (ไม่ส่ง = `router.refresh()`) เมื่อดึงเกินระยะ
40
+ * ใส่ `data-pull-to-refresh-root` บน element ที่เลื่อนได้ของหน้า — ดึงได้เมื่อแตะ "ในนั้น" และเลื่อนอยู่บนสุดเท่านั้น
41
+ * (sheet/dialog ที่ portal ออกไปนอก root จึงปัดลงของตัวเองได้ ไม่ถูกแย่งไปรีเฟรชหน้า) · วงโผล่ใต้ขอบบนของ root
42
+ * (ไม่จมใต้ header ที่ sticky) และหมุนจนกว่า `onRefresh` จะเสร็จ (ขั้นต่ำ 500ms)
33
43
  *
34
44
  * <main data-pull-to-refresh-root className="overflow-y-auto">…</main>
35
- * <PullToRefresh />
45
+ * <PullToRefresh /> // รีเฟรช server component
46
+ * <PullToRefresh onRefresh={() => store.sync()} /> // แอป local-first / ดึงข้อมูลเอง
36
47
  */
37
48
  export function PullToRefresh({
38
49
  text = PULL_TO_REFRESH_TEXT_TH,
39
50
  rootSelector = "[data-pull-to-refresh-root]",
51
+ onRefresh,
52
+ variant = "pill",
40
53
  }: {
41
54
  text?: PullToRefreshText;
42
55
  rootSelector?: string;
56
+ /** รีเฟรชเอง (คืน promise = หมุนจนเสร็จ) — ไม่ส่ง = `router.refresh()` แล้วหมุน 700ms */
57
+ onRefresh?: () => Promise<unknown> | void;
58
+ /**
59
+ * `pill` (ค่าเริ่มต้น) = แคปซูลมีข้อความ · `circle` = วงกลม 40px ไอคอนล้วน (ลูกศร → หมุน) อยู่กลาง พักที่ 16px ใต้ขอบบนของ root
60
+ * ข้อความยังอยู่เป็น `sr-only` + `role="status"` สำหรับ screen reader
61
+ */
62
+ variant?: "pill" | "circle";
43
63
  }) {
44
64
  const router = useRouter();
65
+ const onRefreshRef = useRef(onRefresh);
66
+ onRefreshRef.current = onRefresh;
67
+ const [rootTop, setRootTop] = useState(0);
45
68
  const startRef = useRef<{ x: number; y: number } | null>(null);
46
69
  const isPullingRef = useRef(false);
47
70
  const [pullDistance, setPullDistance] = useState(0);
@@ -53,6 +76,7 @@ export function PullToRefresh({
53
76
  isRefreshingRef.current = isRefreshing;
54
77
 
55
78
  useEffect(() => {
79
+ const dims = DIMS[variant];
56
80
  const isRefreshing = () => isRefreshingRef.current;
57
81
  const isMobileViewport = () => window.innerWidth < MOBILE_BREAKPOINT;
58
82
  const getRoot = () => document.querySelector<HTMLElement>(rootSelector);
@@ -81,6 +105,13 @@ export function PullToRefresh({
81
105
  resetPull();
82
106
  return;
83
107
  }
108
+ // root ปกติ = ต้องแตะ "ในนั้น" (portal ของ sheet/dialog อยู่นอก root จึงไม่เริ่มดึง) · root เป็น <html>/<body> ครอบทุกอย่างอยู่แล้ว
109
+ const whole = root === document.documentElement || root === document.body;
110
+ if (!whole && !(event.target instanceof Node && root.contains(event.target))) {
111
+ resetPull();
112
+ return;
113
+ }
114
+ setRootTop(whole ? 0 : Math.max(0, root.getBoundingClientRect().top));
84
115
  startRef.current = { x: touch.clientX, y: touch.clientY };
85
116
  isPullingRef.current = false;
86
117
  };
@@ -107,16 +138,30 @@ export function PullToRefresh({
107
138
  isPullingRef.current = true;
108
139
  }
109
140
  event.preventDefault();
110
- setPullDistance(Math.min(MAX_PULL_DISTANCE, deltaY * 0.62));
141
+ setPullDistance(Math.min(dims.max, deltaY * dims.resistance));
111
142
  };
112
143
 
113
- const handleTouchEnd = () => {
114
- const shouldRefresh = pullDistanceRef.current >= ACTIVATION_DISTANCE;
144
+ const handleTouchEnd = async () => {
145
+ const shouldRefresh = pullDistanceRef.current >= dims.activation;
115
146
  resetPull();
116
147
  if (!shouldRefresh || isRefreshing()) return;
148
+ isRefreshingRef.current = true;
117
149
  setIsRefreshing(true);
118
- router.refresh();
119
- window.setTimeout(() => setIsRefreshing(false), 700);
150
+ const startedAt = Date.now();
151
+ const custom = onRefreshRef.current;
152
+ try {
153
+ if (custom) await custom();
154
+ else {
155
+ router.refresh();
156
+ await new Promise((resolve) => setTimeout(resolve, 700));
157
+ }
158
+ } catch {
159
+ // งาน refresh รายงานข้อผิดพลาดเอง — วงแค่หยุดหมุน
160
+ }
161
+ const wait = MIN_SPIN_MS - (Date.now() - startedAt);
162
+ if (wait > 0) await new Promise((resolve) => setTimeout(resolve, wait));
163
+ isRefreshingRef.current = false;
164
+ setIsRefreshing(false);
120
165
  };
121
166
 
122
167
  // touchmove ต้อง non-passive (preventDefault ระหว่างดึง) ซึ่งทำให้ browser ต้องรอ JS ก่อนเลื่อนหน้าทุกครั้ง
@@ -150,20 +195,56 @@ export function PullToRefresh({
150
195
  window.removeEventListener("touchend", handleTouchEnd);
151
196
  window.removeEventListener("touchcancel", resetPull);
152
197
  };
153
- }, [router, rootSelector]);
198
+ }, [router, rootSelector, variant]);
154
199
 
155
200
  const isVisible = pullDistance > 0 || isRefreshing;
156
- const progress = Math.min(1, pullDistance / ACTIVATION_DISTANCE);
201
+ const progress = Math.min(1, pullDistance / DIMS[variant].activation);
157
202
  const label = isRefreshing ? text.refreshing : progress >= 1 ? text.release : text.pull;
158
203
 
204
+ if (variant === "circle") {
205
+ const armed = !isRefreshing && progress >= 1;
206
+ return (
207
+ <div
208
+ role="status"
209
+ aria-live="polite"
210
+ className={cn(
211
+ "pointer-events-none fixed left-1/2 z-50 flex size-10 items-center justify-center rounded-full border bg-background text-muted-foreground shadow-md md:hidden",
212
+ pullDistance === 0 && "transition-[transform,opacity] duration-200 ease-out",
213
+ armed && "text-primary",
214
+ )}
215
+ style={{
216
+ top: rootTop - CIRCLE_SIZE,
217
+ marginLeft: -CIRCLE_SIZE / 2,
218
+ opacity: isRefreshing ? 1 : pullDistance > 0 ? Math.max(0.35, progress) : 0,
219
+ transform: `translateY(${isRefreshing ? CIRCLE_REST + CIRCLE_SIZE : pullDistance}px) scale(${isVisible ? 0.7 + 0.3 * progress : 0.6})`,
220
+ }}
221
+ >
222
+ {isRefreshing ? (
223
+ <Loader2 className="size-5 animate-spin text-primary" aria-hidden="true" />
224
+ ) : (
225
+ <ArrowDown
226
+ className="size-5 transition-transform duration-150"
227
+ style={{ transform: `rotate(${armed ? 180 : 0}deg)` }}
228
+ aria-hidden="true"
229
+ />
230
+ )}
231
+ <span className="sr-only">{isVisible ? label : ""}</span>
232
+ </div>
233
+ );
234
+ }
235
+
159
236
  return (
160
237
  <div
161
238
  aria-live="polite"
162
239
  className={cn(
163
- "pointer-events-none fixed top-3 left-1/2 flex -translate-x-1/2 items-center gap-2 rounded-full border bg-card/95 px-3.5 py-2 text-xs font-medium text-foreground shadow-lg backdrop-blur-md transition-all duration-150 md:hidden",
240
+ "pointer-events-none fixed left-1/2 z-50 flex -translate-x-1/2 items-center gap-2 rounded-full border bg-card/95 px-3.5 py-2 text-xs font-medium text-foreground shadow-lg backdrop-blur-md transition-all duration-150 md:hidden",
164
241
  isVisible ? "translate-y-0 opacity-100" : "-translate-y-8 opacity-0",
165
242
  )}
166
- style={{ transform: `translate(-50%, ${isRefreshing ? 0 : progress * 18}px)` }}
243
+ style={{
244
+ // วงอยู่ใต้ขอบบนของ root (ไม่ใช่ขอบจอ) จึงไม่โดน header ที่ sticky บัง
245
+ top: rootTop + 12,
246
+ transform: `translate(-50%, ${isRefreshing ? 0 : progress * 18}px)`,
247
+ }}
167
248
  >
168
249
  {isRefreshing ? (
169
250
  <Loader2 className="size-3.5 animate-spin" aria-hidden="true" />
@@ -0,0 +1,40 @@
1
+ "use client";
2
+
3
+ import { Calendar, CalendarDayButton } from "@suphark/ui/components/ui/calendar";
4
+ import { type ComponentProps, useEffect, useMemo, useRef } from "react";
5
+
6
+ /**
7
+ * ปุ่มวันของปฏิทินที่ย้ายโฟกัสจริงตามลูกศรคีย์บอร์ด — `CalendarDayButton` ของ shadcn (ไฟล์ห้ามแก้มือ) มี effect สั่ง
8
+ * `ref.current?.focus()` แต่ไม่เคยผูก `ref` กับปุ่ม ลูกศรจึงเลื่อนได้แค่สถานะภายในของ react-day-picker โฟกัสจริงอยู่ที่เดิม
9
+ * (ใช้เลือกวันด้วยคีย์บอร์ดไม่ได้) — ผูก ref ผ่าน `{...props}` ที่ CalendarDayButton ส่งต่อให้ปุ่ม
10
+ */
11
+ export function FocusableDayButton(props: ComponentProps<typeof CalendarDayButton>) {
12
+ const ref = useRef<HTMLButtonElement>(null);
13
+ const focused = props.modifiers.focused;
14
+ useEffect(() => {
15
+ if (focused) ref.current?.focus();
16
+ }, [focused]);
17
+ // React 19: ref เป็น prop ธรรมดาที่ถูก spread ต่อไปถึง <Button> — แต่ type ของ react-day-picker ไม่ประกาศ ref
18
+ return <CalendarDayButton {...props} {...({ ref } as Record<string, unknown>)} />;
19
+ }
20
+
21
+ /**
22
+ * `Calendar` ที่ใช้ลูกศร/Home/End/PageUp/PageDown เลือกวันด้วยคีย์บอร์ดได้จริง — ใช้แทน `Calendar` ดิบเสมอเมื่อวางปฏิทินเอง
23
+ * (`Calendar` ของ shadcn ย้ายโฟกัสวันไม่ได้ ดูคำอธิบายที่ `FocusableDayButton`) · props เหมือน `Calendar` ทุกอย่าง
24
+ * ส่ง `components.DayButton` เองได้ (ทับตัวนี้)
25
+ */
26
+ export function AccessibleCalendar({
27
+ components,
28
+ locale,
29
+ ...props
30
+ }: ComponentProps<typeof Calendar>) {
31
+ // ผูก locale เข้ากับปุ่มวัน (Calendar ของ shadcn ส่ง locale ให้ปุ่มของมันเอง แต่ปุ่มที่เราแทนต้องส่งต่อเอง)
32
+ const DayButton = useMemo(
33
+ () =>
34
+ function DayButtonWithLocale(buttonProps: ComponentProps<typeof CalendarDayButton>) {
35
+ return <FocusableDayButton locale={locale} {...buttonProps} />;
36
+ },
37
+ [locale],
38
+ );
39
+ return <Calendar locale={locale} components={{ DayButton, ...components }} {...props} />;
40
+ }
@@ -1,8 +1,9 @@
1
1
  "use client";
2
2
 
3
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
- import { Calendar, CalendarDayButton } from "@suphark/ui/components/ui/calendar";
6
+ import { Calendar } from "@suphark/ui/components/ui/calendar";
6
7
  import {
7
8
  InputGroup,
8
9
  InputGroupAddon,
@@ -26,11 +27,11 @@ import {
26
27
  createContext,
27
28
  type ReactNode,
28
29
  useContext,
29
- useEffect,
30
30
  useId,
31
31
  useRef,
32
32
  useState,
33
33
  } from "react";
34
+ import type { Matcher } from "react-day-picker";
34
35
 
35
36
  /**
36
37
  * Smart date parser supporting intuitive Thai & international date inputs:
@@ -196,6 +197,13 @@ interface DatePickerBaseProps {
196
197
  showPresets?: boolean;
197
198
  /** พิมพ์ในช่องได้ (ค่าเริ่มต้น true) · false = ช่องอ่านอย่างเดียว กดแล้วเปิดปฏิทิน */
198
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;
199
207
  }
200
208
 
201
209
  export interface DatePickerSingleProps extends DatePickerBaseProps {
@@ -302,21 +310,6 @@ function calendarFormatters(locale?: string) {
302
310
  };
303
311
  }
304
312
 
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
313
  /** คลิกชื่อเดือนในหัวปฏิทิน → สลับไปตารางเลือกเดือน/ปี (อยู่ใน shell) */
321
314
  const OpenMonthsContext = createContext<(() => void) | null>(null);
322
315
 
@@ -341,10 +334,14 @@ function MonthGrid({
341
334
  month,
342
335
  locale,
343
336
  onPick,
337
+ startMonth,
338
+ endMonth,
344
339
  }: {
345
340
  month: Date;
346
341
  locale: string | undefined;
347
342
  onPick: (month: Date) => void;
343
+ startMonth: Date;
344
+ endMonth: Date;
348
345
  }) {
349
346
  const tag = locale ?? "th";
350
347
  const [year, setYear] = useState(month.getFullYear());
@@ -355,11 +352,23 @@ function MonthGrid({
355
352
  return (
356
353
  <div className="flex flex-col gap-1">
357
354
  <div className="flex h-8 items-center justify-between">
358
- <button type="button" aria-label="-" className={step} onClick={() => setYear(year - 1)}>
355
+ <button
356
+ type="button"
357
+ aria-label="-"
358
+ className={step}
359
+ disabled={year <= startMonth.getFullYear()}
360
+ onClick={() => setYear(year - 1)}
361
+ >
359
362
  <ChevronLeftIcon className="size-4" />
360
363
  </button>
361
364
  <span className="font-semibold text-sm">{yearLabel(year)}</span>
362
- <button type="button" aria-label="+" className={step} onClick={() => setYear(year + 1)}>
365
+ <button
366
+ type="button"
367
+ aria-label="+"
368
+ className={step}
369
+ disabled={year >= endMonth.getFullYear()}
370
+ onClick={() => setYear(year + 1)}
371
+ >
363
372
  <ChevronRightIcon className="size-4" />
364
373
  </button>
365
374
  </div>
@@ -367,14 +376,17 @@ function MonthGrid({
367
376
  {Array.from({ length: 12 }, (_, m) => {
368
377
  const current = year === month.getFullYear() && m === month.getMonth();
369
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();
370
381
  const label = new Date(2000, m, 1).toLocaleString(tag, { month: "short" });
371
382
  return (
372
383
  <button
373
384
  key={label}
374
385
  type="button"
386
+ disabled={outside}
375
387
  onClick={() => onPick(new Date(year, m, 1))}
376
388
  className={cn(
377
- "h-[3.25rem] rounded-md text-sm outline-none focus-visible:ring-3 focus-visible:ring-ring/50",
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",
378
390
  current ? "bg-primary font-semibold text-primary-foreground" : "hover:bg-accent",
379
391
  !current && thisMonth && "bg-muted font-medium",
380
392
  )}
@@ -388,10 +400,55 @@ function MonthGrid({
388
400
  );
389
401
  }
390
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
+
391
446
  /** ผลอ่านจากข้อความที่พิมพ์ — `label` ที่คนอ่านออก + เดือนที่ปฏิทินควรเลื่อนไปแสดง */
392
447
  interface DatePreview {
393
448
  label: string;
394
449
  month: Date;
450
+ /** อ่านออกแต่อยู่นอก `minDate`/`maxDate` — ข้อความเหตุผล (ไม่ยืนยัน ไม่ไฮไลต์ในปฏิทิน) */
451
+ blocked?: string;
395
452
  }
396
453
  interface SinglePreview extends DatePreview {
397
454
  date: Date;
@@ -412,6 +469,10 @@ interface ShellApi<T extends DatePreview> {
412
469
  interface ShellProps<T extends DatePreview> {
413
470
  id?: string;
414
471
  locale?: string;
472
+ /** ค่าที่เลือกอยู่อยู่นอก `minDate`/`maxDate` — ช่องขึ้นสถานะผิด */
473
+ invalid?: boolean;
474
+ startMonth: Date;
475
+ endMonth: Date;
415
476
  disabled: boolean;
416
477
  className?: string;
417
478
  required: boolean;
@@ -437,6 +498,9 @@ interface ShellProps<T extends DatePreview> {
437
498
  function DateFieldShell<T extends DatePreview>({
438
499
  id,
439
500
  locale,
501
+ invalid = false,
502
+ startMonth,
503
+ endMonth,
440
504
  disabled,
441
505
  className,
442
506
  required,
@@ -595,6 +659,7 @@ function DateFieldShell<T extends DatePreview>({
595
659
  aria-haspopup="dialog"
596
660
  aria-expanded={open}
597
661
  aria-required={required || undefined}
662
+ aria-invalid={invalid || undefined}
598
663
  aria-describedby={open && editing && trimmed !== "" ? previewId : undefined}
599
664
  onMouseDown={() => {
600
665
  pointerFocus.current = !editingRef.current;
@@ -675,7 +740,7 @@ function DateFieldShell<T extends DatePreview>({
675
740
  </InputGroupButton>
676
741
  </InputGroupAddon>
677
742
  <InputGroupAddon align="inline-end">
678
- {editing && typing && preview && (
743
+ {editing && typing && preview && !preview.blocked && (
679
744
  <kbd className="rounded border px-1 font-mono text-caption text-muted-foreground leading-4">
680
745
  Enter
681
746
  </kbd>
@@ -729,7 +794,11 @@ function DateFieldShell<T extends DatePreview>({
729
794
  >
730
795
  {typing && trimmed !== "" && editing && (
731
796
  <div id={previewId} role="status">
732
- {preview ? (
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 ? (
733
802
  <button
734
803
  type="button"
735
804
  onClick={apply}
@@ -783,6 +852,8 @@ function DateFieldShell<T extends DatePreview>({
783
852
  <MonthGrid
784
853
  month={month}
785
854
  locale={locale}
855
+ startMonth={startMonth}
856
+ endMonth={endMonth}
786
857
  onPick={(next) => {
787
858
  setMonth(next);
788
859
  setView("days");
@@ -862,8 +933,11 @@ function SingleDatePicker({
862
933
  className,
863
934
  showSmartInput = true,
864
935
  showRelative = true,
936
+ minDate,
937
+ maxDate,
865
938
  }: DatePickerSingleProps) {
866
939
  const text = useUiText().datePicker;
940
+ const bounds = makeBounds(minDate, maxDate, locale, text);
867
941
  const mounted = useMounted();
868
942
  // ไม่ควบคุม = มี defaultValue หรือไม่มีทั้ง value และ onChange (เช่นใช้แค่ name ในฟอร์ม)
869
943
  const controlled = value !== undefined || (defaultValue === undefined && onChange !== undefined);
@@ -882,6 +956,7 @@ function SingleDatePicker({
882
956
  date,
883
957
  month: date,
884
958
  label: `${weekdayShort(date, locale)} ${formatDate(date, locale)} · ${formatRelative(date, locale)}`,
959
+ blocked: bounds.reason(date) ?? undefined,
885
960
  };
886
961
  };
887
962
 
@@ -898,6 +973,9 @@ function SingleDatePicker({
898
973
  <DateFieldShell<SinglePreview>
899
974
  id={id}
900
975
  locale={locale}
976
+ invalid={Boolean(selectedDate && bounds.reason(selectedDate))}
977
+ startMonth={bounds.startMonth}
978
+ endMonth={bounds.endMonth}
901
979
  disabled={disabled}
902
980
  className={className}
903
981
  required={required}
@@ -924,7 +1002,7 @@ function SingleDatePicker({
924
1002
  return true;
925
1003
  }
926
1004
  const next = parse(input);
927
- if (!next) return false;
1005
+ if (!next || next.blocked) return false;
928
1006
  change(next.date);
929
1007
  return true;
930
1008
  }}
@@ -935,17 +1013,19 @@ function SingleDatePicker({
935
1013
  <Calendar
936
1014
  mode="single"
937
1015
  // ข้อความที่พิมพ์อยู่ไฮไลต์ในปฏิทินทันที — คลิกวันที่ไฮไลต์อยู่ = ยืนยันวันนั้น (ไม่ใช่ล้างค่าแบบ toggle)
938
- selected={api.preview?.date ?? selectedDate}
1016
+ selected={
1017
+ (api.preview && !api.preview.blocked ? api.preview.date : undefined) ?? selectedDate
1018
+ }
939
1019
  onSelect={(date) => {
940
1020
  change(date ?? api.preview?.date ?? selectedDate ?? null);
941
1021
  api.close();
942
1022
  }}
943
1023
  month={api.month}
944
1024
  onMonthChange={api.setMonth}
945
- disabled={disabled}
1025
+ disabled={disabled ? true : bounds.matchers}
946
1026
  captionLayout="label"
947
- startMonth={new Date(1990, 0)}
948
- endMonth={new Date(2040, 11)}
1027
+ startMonth={bounds.startMonth}
1028
+ endMonth={bounds.endMonth}
949
1029
  formatters={calendarFormatters(locale)}
950
1030
  components={{ DayButton: FocusableDayButton, CaptionLabel: CaptionButton }}
951
1031
  className="p-0 [--cell-size:--spacing(10)]"
@@ -996,8 +1076,11 @@ function RangeDatePicker({
996
1076
  className,
997
1077
  allowClear = true,
998
1078
  showSmartInput = true,
1079
+ minDate,
1080
+ maxDate,
999
1081
  }: DatePickerRangeProps) {
1000
1082
  const text = useUiText().datePicker;
1083
+ const bounds = makeBounds(minDate, maxDate, locale, text);
1001
1084
  const controlled = value !== undefined || (defaultValue === undefined && onChange !== undefined);
1002
1085
  const [own, setOwn] = useState<DatePickerRange | null>(() => toRange(defaultValue) ?? null);
1003
1086
  // ระหว่างคลิกเลือก: คลิกแรก = วันเริ่ม (ยังไม่ปิด) คลิกที่สอง = วันสุดท้าย แล้วปิด
@@ -1022,6 +1105,7 @@ function RangeDatePicker({
1022
1105
  return {
1023
1106
  range,
1024
1107
  month: range.from,
1108
+ blocked: bounds.reason(range.from) ?? bounds.reason(range.to) ?? undefined,
1025
1109
  label:
1026
1110
  days === 1
1027
1111
  ? `${weekdayShort(range.from, locale)} ${formatDate(range.from, locale)}`
@@ -1042,6 +1126,9 @@ function RangeDatePicker({
1042
1126
  <DateFieldShell<RangePreview>
1043
1127
  id={id}
1044
1128
  locale={locale}
1129
+ invalid={Boolean(selected && (bounds.reason(selected.from) || bounds.reason(selected.to)))}
1130
+ startMonth={bounds.startMonth}
1131
+ endMonth={bounds.endMonth}
1045
1132
  disabled={disabled}
1046
1133
  className={className}
1047
1134
  required={required}
@@ -1065,7 +1152,7 @@ function RangeDatePicker({
1065
1152
  return true;
1066
1153
  }
1067
1154
  const next = parse(input);
1068
- if (!next) return false;
1155
+ if (!next || next.blocked) return false;
1069
1156
  change(next.range);
1070
1157
  return true;
1071
1158
  }}
@@ -1077,7 +1164,12 @@ function RangeDatePicker({
1077
1164
  <>
1078
1165
  <Calendar
1079
1166
  mode="range"
1080
- selected={start ? { from: start, to: undefined } : (api.preview?.range ?? selected)}
1167
+ selected={
1168
+ start
1169
+ ? { from: start, to: undefined }
1170
+ : ((api.preview && !api.preview.blocked ? api.preview.range : undefined) ??
1171
+ selected)
1172
+ }
1081
1173
  onSelect={(_range, day) => {
1082
1174
  // คุมสองคลิกเอง: react-day-picker คลิกแรกให้ from = to (ช่วงหนึ่งวัน) จึงแยกคลิกแรกไม่ออก
1083
1175
  if (!start) {
@@ -1093,10 +1185,10 @@ function RangeDatePicker({
1093
1185
  }}
1094
1186
  month={api.month}
1095
1187
  onMonthChange={api.setMonth}
1096
- disabled={disabled}
1188
+ disabled={disabled ? true : bounds.matchers}
1097
1189
  captionLayout="label"
1098
- startMonth={new Date(1990, 0)}
1099
- endMonth={new Date(2040, 11)}
1190
+ startMonth={bounds.startMonth}
1191
+ endMonth={bounds.endMonth}
1100
1192
  formatters={calendarFormatters(locale)}
1101
1193
  components={{ DayButton: FocusableDayButton, CaptionLabel: CaptionButton }}
1102
1194
  className="p-0 [--cell-size:--spacing(10)]"
@@ -1,8 +1,9 @@
1
1
  "use client";
2
2
 
3
3
  import { Button } from "@suphark/ui/components/shared/button/button";
4
+ import { AccessibleCalendar } from "@suphark/ui/components/shared/date/accessible-calendar";
5
+ import { useUiText } from "@suphark/ui/components/shared/ui-text";
4
6
  import { Badge } from "@suphark/ui/components/ui/badge";
5
- import { Calendar } from "@suphark/ui/components/ui/calendar";
6
7
  import { Popover, PopoverContent, PopoverTrigger } from "@suphark/ui/components/ui/popover";
7
8
  import { Separator } from "@suphark/ui/components/ui/separator";
8
9
  import { cn } from "@suphark/ui/lib/utils";
@@ -25,7 +26,7 @@ interface DateRangePickerProps {
25
26
  export function DateRangePicker({
26
27
  value,
27
28
  onValueChange,
28
- placeholder = "Pick a date range",
29
+ placeholder,
29
30
  className,
30
31
  triggerClassName,
31
32
  align = "start",
@@ -33,6 +34,8 @@ export function DateRangePicker({
33
34
  title,
34
35
  variant = "outline",
35
36
  }: DateRangePickerProps) {
37
+ const text = useUiText().datePicker;
38
+ const hint = placeholder ?? text?.rangePlaceholder ?? "Pick a date range";
36
39
  return (
37
40
  <div className={cn("flex items-center gap-2", className)}>
38
41
  <Popover>
@@ -62,7 +65,7 @@ export function DateRangePicker({
62
65
  format(value.from, "PP")
63
66
  )
64
67
  ) : (
65
- <span>{placeholder}</span>
68
+ <span>{hint}</span>
66
69
  ))}
67
70
  {title && value?.from && (
68
71
  <>
@@ -80,7 +83,7 @@ export function DateRangePicker({
80
83
  )}
81
84
  </PopoverTrigger>
82
85
  <PopoverContent className="w-auto p-0" align={align}>
83
- <Calendar
86
+ <AccessibleCalendar
84
87
  autoFocus
85
88
  mode="range"
86
89
  defaultMonth={value?.from}
@@ -95,7 +98,7 @@ export function DateRangePicker({
95
98
  className="w-full font-normal text-xs"
96
99
  onClick={() => onValueChange(undefined)}
97
100
  >
98
- Clear selection
101
+ {text?.clearRange ?? "Clear selection"}
99
102
  </Button>
100
103
  </div>
101
104
  )}
@@ -1,7 +1,7 @@
1
1
  "use client";
2
2
 
3
3
  import { Button } from "@suphark/ui/components/shared/button/button";
4
- import { Calendar } from "@suphark/ui/components/ui/calendar";
4
+ import { AccessibleCalendar } from "@suphark/ui/components/shared/date/accessible-calendar";
5
5
  import { Popover, PopoverContent, PopoverTrigger } from "@suphark/ui/components/ui/popover";
6
6
  import { useMounted } from "@suphark/ui/hooks/use-mounted";
7
7
  import { cn } from "@suphark/ui/lib/utils";
@@ -114,7 +114,7 @@ export function DateTimePicker({
114
114
  )}
115
115
  <PopoverContent align="start" className="w-auto gap-0 p-0">
116
116
  <div className="flex flex-col sm:flex-row">
117
- <Calendar
117
+ <AccessibleCalendar
118
118
  mode="single"
119
119
  locale={th}
120
120
  selected={date ?? undefined}