@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.
- package/CHANGELOG.md +1125 -1063
- package/package.json +1 -1
- package/src/components/data-table/data-table-column-header.tsx +1 -1
- package/src/components/data-table/data-table-pagination.tsx +70 -0
- package/src/components/data-table/data-table-row-actions.tsx +1 -1
- package/src/components/data-table/data-table-toolbar.tsx +166 -44
- package/src/components/data-table/data-table-view-option.tsx +15 -4
- package/src/components/data-table/data-table.tsx +153 -3
- package/src/components/data-table/index.ts +1 -1
- package/src/components/data-table/types.ts +19 -0
- package/src/components/pwa/pull-to-refresh.tsx +96 -15
- package/src/components/shared/date/accessible-calendar.tsx +40 -0
- package/src/components/shared/date/date-picker.tsx +124 -32
- package/src/components/shared/date/date-range-picker.tsx +8 -5
- package/src/components/shared/form/date-time-picker.tsx +2 -2
- package/src/components/shared/form/labeled-field.tsx +37 -0
- package/src/components/shared/list-section.tsx +85 -0
- package/src/components/shared/notification-center.tsx +13 -5
- package/src/components/shared/progress-bar.tsx +9 -2
- package/src/components/shared/responsive-popover.tsx +174 -0
- package/src/components/shared/row-table.tsx +133 -0
- package/src/components/shared/sidebar-nav-item.tsx +62 -0
- package/src/components/shared/ui-text.tsx +21 -0
- package/src/data-table.ts +1 -0
- package/src/hooks/use-mobile.ts +3 -1
- package/src/index.ts +26 -1
|
@@ -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
|
-
|
|
9
|
-
const
|
|
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(
|
|
141
|
+
setPullDistance(Math.min(dims.max, deltaY * dims.resistance));
|
|
111
142
|
};
|
|
112
143
|
|
|
113
|
-
const handleTouchEnd = () => {
|
|
114
|
-
const shouldRefresh = pullDistanceRef.current >=
|
|
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
|
-
|
|
119
|
-
|
|
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 /
|
|
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
|
|
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={{
|
|
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
|
|
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
|
|
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
|
|
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={
|
|
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={
|
|
948
|
-
endMonth={
|
|
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={
|
|
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={
|
|
1099
|
-
endMonth={
|
|
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
|
|
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>{
|
|
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
|
-
<
|
|
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 {
|
|
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
|
-
<
|
|
117
|
+
<AccessibleCalendar
|
|
118
118
|
mode="single"
|
|
119
119
|
locale={th}
|
|
120
120
|
selected={date ?? undefined}
|