@suphark/ui 0.53.0 → 0.57.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +39 -0
- package/package.json +1 -1
- package/src/components/pwa/pull-to-refresh.tsx +44 -8
- 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 +34 -0
- package/src/components/shared/list-section.tsx +85 -0
- 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 +11 -0
- package/src/index.ts +26 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,45 @@
|
|
|
2
2
|
|
|
3
3
|
รูปแบบตาม [Keep a Changelog](https://keepachangelog.com/) · semver: patch = หน้าตา/บั๊ก, minor = component/prop ใหม่, major = เปลี่ยน/ลบ props
|
|
4
4
|
|
|
5
|
+
## [0.57.0] — 2026-10-24
|
|
6
|
+
|
|
7
|
+
ยกจาก pi-done (รอบสองของคำขอย้าย component ที่แอปทำเอง)
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
- `RowTable` / `RowTableHeader` / `RowTableRow` / `RowTableCell` / `RowTableActions` / `RowTableGroup` — ตาราง grid ที่หัว/แถว/แถวกลุ่มใช้คอลัมน์ชุดเดียว (`columns` + `mobileColumns` เป็น `grid-template-columns` ผ่านตัวแปร CSS) หัวซ่อนบนมือถือ ช่อง `hideOnMobile` ซ่อนบนมือถือ ปุ่มท้ายแถวโผล่ตอนชี้/โฟกัส (เห็นตลอดบนจอสัมผัส) — แทนที่ pi-done เขียนซ้ำ 4 ที่
|
|
11
|
+
- `ListSection` / `ListSectionHeader` — กลุ่มรายการแบบไม่มีกล่อง (หัว + จำนวน + ปุ่มขวา + เส้นบาง + แถวคั่นเส้น) = `SectionPanel plain` + `TaskGroupHeader`
|
|
12
|
+
- `SidebarNavItem` / `SidebarCountBadge` — แถวเมนูข้างแบบเรียบ (active = `bg-primary/10 text-primary`, ตัวเลขเป็นข้อความเทา ไม่ใช่ pill, 44px บนจอสัมผัส, `draggingOver`)
|
|
13
|
+
- `ProgressBar tone="auto"` — เขียวเมื่อครบ (value ≥ max) นอกนั้นสีหลัก (ครอบคลุม `ProgressBar` ของ pi-done: ใช้ `<ProgressBar value={percent} tone="auto" className="w-10" />`)
|
|
14
|
+
- showcase: หน้า `/components/row-table`, `/components/list-section`, `/components/sidebar-nav-item`
|
|
15
|
+
|
|
16
|
+
## [0.56.0] — 2026-10-24
|
|
17
|
+
|
|
18
|
+
### Added
|
|
19
|
+
- `DatePicker` `minDate` / `maxDate` (`"YYYY-MM-DD"` | Date, รวมวันนั้น) ทั้งวันเดียวและ `mode="range"` (ขอโดย DueBell แทนการกันที่ `onChange`): ปฏิทินกดวันนอกช่วงไม่ได้ · เดือน/ปีเลื่อนไปนอกช่วงไม่ได้ (ลูกศรเดือน + ตารางเลือกเดือน) · ค่าที่พิมพ์แล้วอยู่นอกช่วง = ไม่ยืนยัน (Enter/Tab/ปุ่มผล) และกล่องผลแสดงเหตุผลสีแดงแทน · ค่าที่ส่งเข้ามาเองนอกช่วงไม่ถูกแก้ ช่องขึ้น `aria-invalid` · ข้อความ `UiText.datePicker.beforeMin` / `afterMax` (`{date}` ตาม `locale` ของช่อง) แอปทับได้
|
|
20
|
+
|
|
21
|
+
## [0.55.0] — 2026-10-24
|
|
22
|
+
|
|
23
|
+
ยกจาก pi-done (รอบแรกของคำขอย้าย component ที่แอปทำเอง)
|
|
24
|
+
|
|
25
|
+
### Added
|
|
26
|
+
- `ResponsivePopover` / `ResponsivePopoverTrigger` / `ResponsivePopoverContent` / `ResponsivePopoverItem` / `useIsResponsiveSheet()` — Popover บนจอ ≥ 768px · แผ่นล่าง (Drawer) บนมือถือ: สูงตามเนื้อหา, `footer` ค้างล่าง, ปัดลงปิด; `useIsResponsiveSheet()` ให้ picker ขยายพื้นที่แตะ (`ResponsivePopoverItem` = 44px ในแผ่น) · `sheetContentClassName` ทับสไตล์ตัวแผ่น
|
|
27
|
+
- `SwitchField` — สวิตช์ + ป้ายในตัว (คลิกป้ายสลับ, `hint` ได้, ป้ายสูง 44px บนจอสัมผัส) แทน `<label><Switch/>…</label>` ที่ต้อง biome-ignore เพราะ Switch เป็น span
|
|
28
|
+
- `PullToRefresh` (`@suphark/ui/pwa`) prop `onRefresh` — รีเฟรชเองได้ (แอป local-first ที่ `router.refresh()` ไปไม่ถึง) · คืน promise = หมุนจนเสร็จ ขั้นต่ำ 500ms (ไม่ส่ง = `router.refresh()` แล้วหมุน 700ms เหมือนเดิม)
|
|
29
|
+
|
|
30
|
+
### Fixed
|
|
31
|
+
- `PullToRefresh`: วงโผล่ใต้ขอบบนของ root (เดิมติดขอบจอ จมใต้ header ที่ sticky) และ `z-50` · เริ่มดึงได้เฉพาะเมื่อแตะ "ในส่วน root" ที่เลื่อนอยู่บนสุด — sheet/dialog ที่ portal ออกไปนอก root จึงปัดลงของตัวเองได้ ไม่ถูกแย่งไปรีเฟรชหน้า (root เป็น `<html>`/`<body>` ทำงานเหมือนเดิม)
|
|
32
|
+
- showcase: หน้า `/components/responsive-popover` ใหม่ · labeled-field มีตัวอย่าง `SwitchField`
|
|
33
|
+
|
|
34
|
+
## [0.54.0] — 2026-10-24
|
|
35
|
+
|
|
36
|
+
### Added
|
|
37
|
+
- `AccessibleCalendar` — `Calendar` ที่ลูกศร/Home/End/PageUp/PageDown เลือกวันด้วยคีย์บอร์ดได้จริง (props เหมือน `Calendar` ทุกอย่าง) · ใช้แทน `Calendar` ดิบเมื่อวางปฏิทินเอง (ปุ่มวันของ shadcn ไม่ผูก `ref` ลูกศรจึงเลื่อนได้แค่สถานะภายใน) · `FocusableDayButton` ย้ายจาก `date-picker.tsx` มาอยู่ `shared/date/accessible-calendar.tsx`
|
|
38
|
+
|
|
39
|
+
### Fixed
|
|
40
|
+
- `DateRangePicker` (ตัวกรอง) และ `DateTimePicker`: ปฏิทินใช้ `AccessibleCalendar` — เลือกวันด้วยคีย์บอร์ดได้จริง (เดิมโฟกัสค้างที่เดิม)
|
|
41
|
+
- `DateRangePicker`: ข้อความ placeholder/"ล้างช่วงวันที่" มาจาก `UiText.datePicker` (`rangePlaceholder`, ใหม่ `clearRange`) — แอปที่ไม่ส่ง `placeholder` และใช้ UiText ไทยจะเห็นข้อความไทยแทนอังกฤษ; ส่ง `placeholder` เองยังชนะเสมอ
|
|
42
|
+
- showcase: `/components/calendar` ใช้ `AccessibleCalendar`
|
|
43
|
+
|
|
5
44
|
## [0.53.0] — 2026-10-23
|
|
6
45
|
|
|
7
46
|
### Changed
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@suphark/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.57.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "Design system กลางของ Suphark apps: shadcn/ui (base-nova, Base UI, Tailwind v4) + shared components + token/ฟอนต์ Noto Sans Thai Looped — ส่งเป็น source ให้แอป Next transpile เอง",
|
|
@@ -8,6 +8,8 @@ import { useEffect, useRef, useState } from "react";
|
|
|
8
8
|
const ACTIVATION_DISTANCE = 72;
|
|
9
9
|
const MAX_PULL_DISTANCE = 112;
|
|
10
10
|
const MOBILE_BREAKPOINT = 768;
|
|
11
|
+
/** หมุนอย่างน้อยเท่านี้ — refresh ที่เสร็จทันทีจะไม่กะพริบหายไปเลย */
|
|
12
|
+
const MIN_SPIN_MS = 500;
|
|
11
13
|
|
|
12
14
|
export interface PullToRefreshText {
|
|
13
15
|
pull: string;
|
|
@@ -28,20 +30,29 @@ export const PULL_TO_REFRESH_TEXT_EN: PullToRefreshText = {
|
|
|
28
30
|
};
|
|
29
31
|
|
|
30
32
|
/**
|
|
31
|
-
* ดึงลงเพื่อรีเฟรช (มือถือ, < 768px) — เรียก `router.refresh()` เมื่อดึงเกินระยะ
|
|
32
|
-
* ใส่ `data-pull-to-refresh-root` บน element ที่เลื่อนได้ของหน้า
|
|
33
|
+
* ดึงลงเพื่อรีเฟรช (มือถือ, < 768px) — เรียก `onRefresh` (ไม่ส่ง = `router.refresh()`) เมื่อดึงเกินระยะ
|
|
34
|
+
* ใส่ `data-pull-to-refresh-root` บน element ที่เลื่อนได้ของหน้า — ดึงได้เมื่อแตะ "ในนั้น" และเลื่อนอยู่บนสุดเท่านั้น
|
|
35
|
+
* (sheet/dialog ที่ portal ออกไปนอก root จึงปัดลงของตัวเองได้ ไม่ถูกแย่งไปรีเฟรชหน้า) · วงโผล่ใต้ขอบบนของ root
|
|
36
|
+
* (ไม่จมใต้ header ที่ sticky) และหมุนจนกว่า `onRefresh` จะเสร็จ (ขั้นต่ำ 500ms)
|
|
33
37
|
*
|
|
34
38
|
* <main data-pull-to-refresh-root className="overflow-y-auto">…</main>
|
|
35
|
-
* <PullToRefresh />
|
|
39
|
+
* <PullToRefresh /> // รีเฟรช server component
|
|
40
|
+
* <PullToRefresh onRefresh={() => store.sync()} /> // แอป local-first / ดึงข้อมูลเอง
|
|
36
41
|
*/
|
|
37
42
|
export function PullToRefresh({
|
|
38
43
|
text = PULL_TO_REFRESH_TEXT_TH,
|
|
39
44
|
rootSelector = "[data-pull-to-refresh-root]",
|
|
45
|
+
onRefresh,
|
|
40
46
|
}: {
|
|
41
47
|
text?: PullToRefreshText;
|
|
42
48
|
rootSelector?: string;
|
|
49
|
+
/** รีเฟรชเอง (คืน promise = หมุนจนเสร็จ) — ไม่ส่ง = `router.refresh()` แล้วหมุน 700ms */
|
|
50
|
+
onRefresh?: () => Promise<unknown> | void;
|
|
43
51
|
}) {
|
|
44
52
|
const router = useRouter();
|
|
53
|
+
const onRefreshRef = useRef(onRefresh);
|
|
54
|
+
onRefreshRef.current = onRefresh;
|
|
55
|
+
const [rootTop, setRootTop] = useState(0);
|
|
45
56
|
const startRef = useRef<{ x: number; y: number } | null>(null);
|
|
46
57
|
const isPullingRef = useRef(false);
|
|
47
58
|
const [pullDistance, setPullDistance] = useState(0);
|
|
@@ -81,6 +92,13 @@ export function PullToRefresh({
|
|
|
81
92
|
resetPull();
|
|
82
93
|
return;
|
|
83
94
|
}
|
|
95
|
+
// root ปกติ = ต้องแตะ "ในนั้น" (portal ของ sheet/dialog อยู่นอก root จึงไม่เริ่มดึง) · root เป็น <html>/<body> ครอบทุกอย่างอยู่แล้ว
|
|
96
|
+
const whole = root === document.documentElement || root === document.body;
|
|
97
|
+
if (!whole && !(event.target instanceof Node && root.contains(event.target))) {
|
|
98
|
+
resetPull();
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
setRootTop(whole ? 0 : Math.max(0, root.getBoundingClientRect().top));
|
|
84
102
|
startRef.current = { x: touch.clientX, y: touch.clientY };
|
|
85
103
|
isPullingRef.current = false;
|
|
86
104
|
};
|
|
@@ -110,13 +128,27 @@ export function PullToRefresh({
|
|
|
110
128
|
setPullDistance(Math.min(MAX_PULL_DISTANCE, deltaY * 0.62));
|
|
111
129
|
};
|
|
112
130
|
|
|
113
|
-
const handleTouchEnd = () => {
|
|
131
|
+
const handleTouchEnd = async () => {
|
|
114
132
|
const shouldRefresh = pullDistanceRef.current >= ACTIVATION_DISTANCE;
|
|
115
133
|
resetPull();
|
|
116
134
|
if (!shouldRefresh || isRefreshing()) return;
|
|
135
|
+
isRefreshingRef.current = true;
|
|
117
136
|
setIsRefreshing(true);
|
|
118
|
-
|
|
119
|
-
|
|
137
|
+
const startedAt = Date.now();
|
|
138
|
+
const custom = onRefreshRef.current;
|
|
139
|
+
try {
|
|
140
|
+
if (custom) await custom();
|
|
141
|
+
else {
|
|
142
|
+
router.refresh();
|
|
143
|
+
await new Promise((resolve) => setTimeout(resolve, 700));
|
|
144
|
+
}
|
|
145
|
+
} catch {
|
|
146
|
+
// งาน refresh รายงานข้อผิดพลาดเอง — วงแค่หยุดหมุน
|
|
147
|
+
}
|
|
148
|
+
const wait = MIN_SPIN_MS - (Date.now() - startedAt);
|
|
149
|
+
if (wait > 0) await new Promise((resolve) => setTimeout(resolve, wait));
|
|
150
|
+
isRefreshingRef.current = false;
|
|
151
|
+
setIsRefreshing(false);
|
|
120
152
|
};
|
|
121
153
|
|
|
122
154
|
// touchmove ต้อง non-passive (preventDefault ระหว่างดึง) ซึ่งทำให้ browser ต้องรอ JS ก่อนเลื่อนหน้าทุกครั้ง
|
|
@@ -160,10 +192,14 @@ export function PullToRefresh({
|
|
|
160
192
|
<div
|
|
161
193
|
aria-live="polite"
|
|
162
194
|
className={cn(
|
|
163
|
-
"pointer-events-none fixed
|
|
195
|
+
"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
196
|
isVisible ? "translate-y-0 opacity-100" : "-translate-y-8 opacity-0",
|
|
165
197
|
)}
|
|
166
|
-
style={{
|
|
198
|
+
style={{
|
|
199
|
+
// วงอยู่ใต้ขอบบนของ root (ไม่ใช่ขอบจอ) จึงไม่โดน header ที่ sticky บัง
|
|
200
|
+
top: rootTop + 12,
|
|
201
|
+
transform: `translate(-50%, ${isRefreshing ? 0 : progress * 18}px)`,
|
|
202
|
+
}}
|
|
167
203
|
>
|
|
168
204
|
{isRefreshing ? (
|
|
169
205
|
<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}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Checkbox } from "@suphark/ui/components/ui/checkbox";
|
|
2
2
|
import { Field, FieldDescription, FieldError, FieldLabel } from "@suphark/ui/components/ui/field";
|
|
3
3
|
import { Label } from "@suphark/ui/components/ui/label";
|
|
4
|
+
import { Switch } from "@suphark/ui/components/ui/switch";
|
|
4
5
|
import { cn } from "@suphark/ui/lib/utils";
|
|
5
6
|
import type { ComponentProps, ReactNode } from "react";
|
|
6
7
|
|
|
@@ -97,3 +98,36 @@ export function CheckboxField({
|
|
|
97
98
|
</Label>
|
|
98
99
|
);
|
|
99
100
|
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* สวิตช์ + ป้ายในตัว (คลิกป้ายก็สลับ) — ใช้แทน `<label><Switch />…</label>` ที่ต้อง biome-ignore เพราะ Switch เป็น span
|
|
104
|
+
* `size` ส่งต่อให้ Switch · `className` ทับที่ป้ายทั้งก้อน (ใส่สไตล์ hover/ตัวหนังสือได้) · ป้ายใหญ่ขึ้นเป็น 44px บนจอสัมผัส
|
|
105
|
+
*
|
|
106
|
+
* <SwitchField size="sm" checked={showDone} onCheckedChange={setShowDone} label="แสดงที่เสร็จแล้ว" />
|
|
107
|
+
*/
|
|
108
|
+
export function SwitchField({
|
|
109
|
+
label,
|
|
110
|
+
hint,
|
|
111
|
+
className,
|
|
112
|
+
...props
|
|
113
|
+
}: ComponentProps<typeof Switch> & {
|
|
114
|
+
label: ReactNode;
|
|
115
|
+
/** คำอธิบายใต้ label (เยื้องตรงกับ label) */
|
|
116
|
+
hint?: ReactNode;
|
|
117
|
+
}) {
|
|
118
|
+
return (
|
|
119
|
+
<Label
|
|
120
|
+
className={cn(
|
|
121
|
+
"flex cursor-pointer items-center gap-2 font-normal pointer-coarse:min-h-11",
|
|
122
|
+
hint && "items-start",
|
|
123
|
+
className,
|
|
124
|
+
)}
|
|
125
|
+
>
|
|
126
|
+
<Switch className={cn(hint && "mt-0.5")} {...props} />
|
|
127
|
+
<span className="flex flex-col gap-0.5">
|
|
128
|
+
<span className="text-sm">{label}</span>
|
|
129
|
+
{hint && <span className="text-muted-foreground text-xs">{hint}</span>}
|
|
130
|
+
</span>
|
|
131
|
+
</Label>
|
|
132
|
+
);
|
|
133
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { cn } from "@suphark/ui/lib/utils";
|
|
2
|
+
import type { ComponentProps, ReactNode } from "react";
|
|
3
|
+
|
|
4
|
+
export type ListSectionTone = "default" | "danger" | "success" | "accent" | "muted";
|
|
5
|
+
|
|
6
|
+
const TONE: Record<ListSectionTone, string> = {
|
|
7
|
+
default: "text-foreground/90",
|
|
8
|
+
danger: "text-destructive",
|
|
9
|
+
success: "text-success",
|
|
10
|
+
accent: "text-primary",
|
|
11
|
+
muted: "text-muted-foreground",
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
/** หัวกลุ่มของรายการ: ชื่อ (สีตาม tone) + จำนวน + ปุ่มขวา + เส้นบางใต้หัว — ใช้เดี่ยว ๆ หรือผ่าน `ListSection` */
|
|
15
|
+
export function ListSectionHeader({
|
|
16
|
+
title,
|
|
17
|
+
count,
|
|
18
|
+
action,
|
|
19
|
+
tone = "default",
|
|
20
|
+
as: Heading = "h2",
|
|
21
|
+
className,
|
|
22
|
+
}: {
|
|
23
|
+
title: ReactNode;
|
|
24
|
+
count?: number;
|
|
25
|
+
action?: ReactNode;
|
|
26
|
+
tone?: ListSectionTone;
|
|
27
|
+
as?: "h2" | "h3" | "h4" | "p";
|
|
28
|
+
className?: string;
|
|
29
|
+
}) {
|
|
30
|
+
return (
|
|
31
|
+
<div
|
|
32
|
+
data-slot="list-section-header"
|
|
33
|
+
className={cn("mb-1 flex min-h-9 items-center justify-between border-b pb-1", className)}
|
|
34
|
+
>
|
|
35
|
+
<div className="flex min-w-0 items-center gap-2">
|
|
36
|
+
<Heading
|
|
37
|
+
className={cn(
|
|
38
|
+
"flex min-w-0 items-center gap-2 truncate font-semibold text-caption",
|
|
39
|
+
TONE[tone],
|
|
40
|
+
)}
|
|
41
|
+
>
|
|
42
|
+
{title}
|
|
43
|
+
</Heading>
|
|
44
|
+
{typeof count === "number" && (
|
|
45
|
+
<span className="rounded-md bg-muted px-1.5 py-0.5 font-semibold text-caption text-muted-foreground tabular-nums">
|
|
46
|
+
{count}
|
|
47
|
+
</span>
|
|
48
|
+
)}
|
|
49
|
+
</div>
|
|
50
|
+
{action}
|
|
51
|
+
</div>
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* กลุ่มของรายการแบบ "ไม่มีกล่อง" (รูปแบบ List): หัวกลุ่ม + เส้นบาง + แถวคั่นด้วยเส้น — ยกจาก `SectionPanel plain` ของ pi-done
|
|
57
|
+
* แถวข้างในคั่นเส้นให้เอง (`divide-y`) · อยากได้กล่องใช้ `SectionCard`
|
|
58
|
+
*
|
|
59
|
+
* <ListSection title="เกินกำหนด" tone="danger" count={3} action={<Button variant="link" size="xs">ทำทั้งหมด</Button>}>
|
|
60
|
+
* {tasks.map((t) => <TaskRow key={t.id} … />)}
|
|
61
|
+
* </ListSection>
|
|
62
|
+
*/
|
|
63
|
+
export function ListSection({
|
|
64
|
+
title,
|
|
65
|
+
count,
|
|
66
|
+
action,
|
|
67
|
+
tone,
|
|
68
|
+
headingAs,
|
|
69
|
+
children,
|
|
70
|
+
className,
|
|
71
|
+
...props
|
|
72
|
+
}: Omit<ComponentProps<"section">, "title"> & {
|
|
73
|
+
title: ReactNode;
|
|
74
|
+
count?: number;
|
|
75
|
+
action?: ReactNode;
|
|
76
|
+
tone?: ListSectionTone;
|
|
77
|
+
headingAs?: "h2" | "h3" | "h4" | "p";
|
|
78
|
+
}) {
|
|
79
|
+
return (
|
|
80
|
+
<section data-slot="list-section" className={cn("py-1", className)} {...props}>
|
|
81
|
+
<ListSectionHeader title={title} count={count} action={action} tone={tone} as={headingAs} />
|
|
82
|
+
<div className="divide-y divide-border/50">{children}</div>
|
|
83
|
+
</section>
|
|
84
|
+
);
|
|
85
|
+
}
|
|
@@ -44,7 +44,8 @@ export function ProgressBar({
|
|
|
44
44
|
showValue?: boolean;
|
|
45
45
|
/** ปรับข้อความตัวเลข เช่น (v) => `${v} / ${max}` */
|
|
46
46
|
format?: (value: number | null, max: number) => ReactNode;
|
|
47
|
-
|
|
47
|
+
/** `auto` = เขียวเมื่อครบ (value ≥ max) นอกนั้นสีหลัก — แถบงานย่อยที่เต็มแล้วเปลี่ยนสีเอง */
|
|
48
|
+
tone?: ProgressTone | "auto";
|
|
48
49
|
/** sm = h-1 (ค่าเริ่มต้น), md = h-2, lg = h-3 */
|
|
49
50
|
size?: "sm" | "md" | "lg";
|
|
50
51
|
trackClassName?: string;
|
|
@@ -76,7 +77,13 @@ export function ProgressBar({
|
|
|
76
77
|
<ProgressTrack
|
|
77
78
|
className={cn(size === "md" ? "h-2" : size === "lg" ? "h-3" : "h-1", trackClassName)}
|
|
78
79
|
>
|
|
79
|
-
<ProgressIndicator
|
|
80
|
+
<ProgressIndicator
|
|
81
|
+
className={cn(
|
|
82
|
+
TONE[tone === "auto" ? (value != null && value >= max ? "success" : "default") : tone],
|
|
83
|
+
indicatorClassName,
|
|
84
|
+
)}
|
|
85
|
+
style={indicatorStyle}
|
|
86
|
+
/>
|
|
80
87
|
</ProgressTrack>
|
|
81
88
|
</ProgressPrimitive.Root>
|
|
82
89
|
);
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
Drawer,
|
|
5
|
+
DrawerContent,
|
|
6
|
+
DrawerDescription,
|
|
7
|
+
DrawerTitle,
|
|
8
|
+
DrawerTrigger,
|
|
9
|
+
} from "@suphark/ui/components/ui/drawer";
|
|
10
|
+
import { Popover, PopoverContent, PopoverTrigger } from "@suphark/ui/components/ui/popover";
|
|
11
|
+
import { useIsMobile } from "@suphark/ui/hooks/use-mobile";
|
|
12
|
+
import { cn } from "@suphark/ui/lib/utils";
|
|
13
|
+
import {
|
|
14
|
+
type ComponentProps,
|
|
15
|
+
createContext,
|
|
16
|
+
type KeyboardEventHandler,
|
|
17
|
+
type MouseEventHandler,
|
|
18
|
+
type PointerEventHandler,
|
|
19
|
+
type ReactElement,
|
|
20
|
+
type ReactNode,
|
|
21
|
+
useContext,
|
|
22
|
+
} from "react";
|
|
23
|
+
|
|
24
|
+
const ResponsivePopoverContext = createContext(false);
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* อยู่ในโหมดแผ่นล่าง (จอ < 768px) หรือไม่ — ใช้ขยายพื้นที่แตะของ picker ใน `ResponsivePopover`
|
|
28
|
+
* (นอก `ResponsivePopover` = false)
|
|
29
|
+
*/
|
|
30
|
+
export function useIsResponsiveSheet() {
|
|
31
|
+
return useContext(ResponsivePopoverContext);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface ResponsivePopoverProps {
|
|
35
|
+
open?: boolean;
|
|
36
|
+
onOpenChange?: (open: boolean) => void;
|
|
37
|
+
children: ReactNode;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Popover บนจอกว้าง (≥ 768px) · แผ่นล่าง (Drawer) บนมือถือ: สูงตามเนื้อหา, footer ค้างล่าง, ปัดลงเพื่อปิด
|
|
42
|
+
* ใช้กับ picker ที่เปิดจากปุ่ม (สถานะ/ลำดับความสำคัญ/วันที่ …) — ในแผ่นใช้ `useIsResponsiveSheet()` ขยายแถวเป็น 44px
|
|
43
|
+
*
|
|
44
|
+
* <ResponsivePopover open={open} onOpenChange={setOpen}>
|
|
45
|
+
* <ResponsivePopoverTrigger render={<Button variant="outline">สถานะ</Button>} />
|
|
46
|
+
* <ResponsivePopoverContent title="เลือกสถานะ" className="w-56 p-1">
|
|
47
|
+
* <ResponsivePopoverItem onClick={() => pick("todo")}>ต้องทำ</ResponsivePopoverItem>
|
|
48
|
+
* </ResponsivePopoverContent>
|
|
49
|
+
* </ResponsivePopover>
|
|
50
|
+
*/
|
|
51
|
+
export function ResponsivePopover({ open, onOpenChange, children }: ResponsivePopoverProps) {
|
|
52
|
+
const isMobile = useIsMobile();
|
|
53
|
+
const handleOpenChange = onOpenChange ? (next: boolean) => onOpenChange(next) : undefined;
|
|
54
|
+
|
|
55
|
+
return (
|
|
56
|
+
<ResponsivePopoverContext.Provider value={isMobile}>
|
|
57
|
+
{isMobile ? (
|
|
58
|
+
<Drawer open={open} onOpenChange={handleOpenChange} showSwipeHandle>
|
|
59
|
+
{children}
|
|
60
|
+
</Drawer>
|
|
61
|
+
) : (
|
|
62
|
+
<Popover open={open} onOpenChange={handleOpenChange}>
|
|
63
|
+
{children}
|
|
64
|
+
</Popover>
|
|
65
|
+
)}
|
|
66
|
+
</ResponsivePopoverContext.Provider>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export interface ResponsivePopoverTriggerProps {
|
|
71
|
+
render: ReactElement;
|
|
72
|
+
nativeButton?: boolean;
|
|
73
|
+
disabled?: boolean;
|
|
74
|
+
children?: ReactNode;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function ResponsivePopoverTrigger(props: ResponsivePopoverTriggerProps) {
|
|
78
|
+
const isSheet = useIsResponsiveSheet();
|
|
79
|
+
return isSheet ? <DrawerTrigger {...props} /> : <PopoverTrigger {...props} />;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface ResponsivePopoverContentProps {
|
|
83
|
+
/** หัวแผ่นบนมือถือ และเป็นชื่อสำหรับ screen reader เสมอ */
|
|
84
|
+
title: string;
|
|
85
|
+
description?: string;
|
|
86
|
+
/** ซ่อนหัวแผ่น (ยังอ่านออกเสียง) เมื่อเนื้อหาบอกชัดอยู่แล้ว */
|
|
87
|
+
hideTitle?: boolean;
|
|
88
|
+
/** class ของ Popover เท่านั้น (ความกว้าง, padding …) */
|
|
89
|
+
className?: string;
|
|
90
|
+
/** class ของส่วนเนื้อหาที่เลื่อนได้ในแผ่น */
|
|
91
|
+
sheetClassName?: string;
|
|
92
|
+
/** class ของตัวแผ่นเอง (ทับ `bg-background text-left`) */
|
|
93
|
+
sheetContentClassName?: string;
|
|
94
|
+
/** ค้างใต้เนื้อหาที่เลื่อน (ในแผ่น) หรือต่อท้ายเนื้อหา (ใน Popover) */
|
|
95
|
+
footer?: ReactNode;
|
|
96
|
+
align?: "start" | "center" | "end";
|
|
97
|
+
side?: "top" | "bottom" | "left" | "right";
|
|
98
|
+
sideOffset?: number;
|
|
99
|
+
onClick?: MouseEventHandler<HTMLDivElement>;
|
|
100
|
+
onPointerDown?: PointerEventHandler<HTMLDivElement>;
|
|
101
|
+
onKeyDown?: KeyboardEventHandler<HTMLDivElement>;
|
|
102
|
+
children: ReactNode;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function ResponsivePopoverContent({
|
|
106
|
+
title,
|
|
107
|
+
description,
|
|
108
|
+
hideTitle = false,
|
|
109
|
+
className,
|
|
110
|
+
sheetClassName,
|
|
111
|
+
sheetContentClassName,
|
|
112
|
+
footer,
|
|
113
|
+
align = "start",
|
|
114
|
+
side,
|
|
115
|
+
sideOffset = 4,
|
|
116
|
+
children,
|
|
117
|
+
...eventProps
|
|
118
|
+
}: ResponsivePopoverContentProps) {
|
|
119
|
+
const isSheet = useIsResponsiveSheet();
|
|
120
|
+
|
|
121
|
+
if (!isSheet) {
|
|
122
|
+
return (
|
|
123
|
+
<PopoverContent
|
|
124
|
+
align={align}
|
|
125
|
+
side={side}
|
|
126
|
+
sideOffset={sideOffset}
|
|
127
|
+
className={className}
|
|
128
|
+
{...eventProps}
|
|
129
|
+
>
|
|
130
|
+
{children}
|
|
131
|
+
{footer}
|
|
132
|
+
</PopoverContent>
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return (
|
|
137
|
+
<DrawerContent className={cn("bg-background text-left", sheetContentClassName)} {...eventProps}>
|
|
138
|
+
<DrawerTitle className={cn("px-4 pt-1 pb-1 font-semibold text-sm", hideTitle && "sr-only")}>
|
|
139
|
+
{title}
|
|
140
|
+
</DrawerTitle>
|
|
141
|
+
<DrawerDescription className="sr-only">{description ?? title}</DrawerDescription>
|
|
142
|
+
<div
|
|
143
|
+
className={cn(
|
|
144
|
+
"min-h-0 flex-1 overflow-y-auto overflow-x-hidden overscroll-contain",
|
|
145
|
+
!footer && "pb-[max(0.5rem,env(safe-area-inset-bottom))]",
|
|
146
|
+
sheetClassName,
|
|
147
|
+
)}
|
|
148
|
+
>
|
|
149
|
+
{children}
|
|
150
|
+
</div>
|
|
151
|
+
{footer ? <div className="shrink-0 pb-[env(safe-area-inset-bottom)]">{footer}</div> : null}
|
|
152
|
+
</DrawerContent>
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** แถวเมนูที่สูงเป็นพื้นที่แตะ 44px เมื่ออยู่ในแผ่นล่าง (36px ใน Popover) */
|
|
157
|
+
export function ResponsivePopoverItem({
|
|
158
|
+
className,
|
|
159
|
+
type = "button",
|
|
160
|
+
...props
|
|
161
|
+
}: ComponentProps<"button">) {
|
|
162
|
+
const isSheet = useIsResponsiveSheet();
|
|
163
|
+
return (
|
|
164
|
+
<button
|
|
165
|
+
type={type}
|
|
166
|
+
className={cn(
|
|
167
|
+
"flex w-full cursor-pointer items-center gap-2 rounded-md px-2 text-left text-sm outline-none hover:bg-muted focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50",
|
|
168
|
+
isSheet ? "h-11" : "h-9",
|
|
169
|
+
className,
|
|
170
|
+
)}
|
|
171
|
+
{...props}
|
|
172
|
+
/>
|
|
173
|
+
);
|
|
174
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { cn } from "@suphark/ui/lib/utils";
|
|
2
|
+
import type { ComponentProps, CSSProperties } from "react";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* ตารางแถว (grid) ที่หัว/แถว/แถวกลุ่มใช้คอลัมน์ชุดเดียวกัน — หัวซ่อนบนมือถือ แถวเหลือคอลัมน์ที่เลือก (ปกติชื่อ + ปุ่ม)
|
|
6
|
+
* ปุ่มท้ายแถวโผล่ตอนชี้ (`RowTableActions`) แต่เห็นตลอดบนมือถือ/จอสัมผัส · ยกจาก pi-done (เขียนซ้ำ 4 ที่)
|
|
7
|
+
* ต่างจาก `DataList` (รายการแถวไม่มีหัวคอลัมน์) และ `SimpleTable` (`<table>` จริงที่เรียง/แบ่งหน้าได้): ตัวนี้เหมาะกับรายการที่ไม่กี่คอลัมน์ ไม่ต้องเรียงข้อมูล
|
|
8
|
+
*
|
|
9
|
+
* <RowTable columns="minmax(0,1fr) 3.5rem 7rem" mobileColumns="minmax(0,1fr) auto">
|
|
10
|
+
* <RowTableHeader><span>ชื่อ</span><span className="text-right">เปิด</span><span className="sr-only">คำสั่ง</span></RowTableHeader>
|
|
11
|
+
* <RowTableRow>
|
|
12
|
+
* <RowTableCell>โปรเจกต์ A</RowTableCell>
|
|
13
|
+
* <RowTableCell hideOnMobile align="end">12</RowTableCell>
|
|
14
|
+
* <RowTableActions><Button size="icon-sm" aria-label="แก้ไข">…</Button></RowTableActions>
|
|
15
|
+
* </RowTableRow>
|
|
16
|
+
* </RowTable>
|
|
17
|
+
*/
|
|
18
|
+
export function RowTable({
|
|
19
|
+
columns,
|
|
20
|
+
mobileColumns = "minmax(0,1fr) auto",
|
|
21
|
+
className,
|
|
22
|
+
style,
|
|
23
|
+
...props
|
|
24
|
+
}: ComponentProps<"div"> & {
|
|
25
|
+
/** `grid-template-columns` ตั้งแต่จอ md ขึ้นไป เช่น "minmax(0,1fr) 3.5rem 7rem" (จำนวนช่องต้องเท่าลูกของหัว/แถว) */
|
|
26
|
+
columns: string;
|
|
27
|
+
/** `grid-template-columns` ของมือถือ (ค่าเริ่มต้น "minmax(0,1fr) auto") — ซ่อนช่องที่เหลือด้วย `hideOnMobile` */
|
|
28
|
+
mobileColumns?: string;
|
|
29
|
+
}) {
|
|
30
|
+
return (
|
|
31
|
+
<div
|
|
32
|
+
data-slot="row-table"
|
|
33
|
+
style={
|
|
34
|
+
{
|
|
35
|
+
"--row-cols": columns,
|
|
36
|
+
"--row-cols-mobile": mobileColumns,
|
|
37
|
+
...style,
|
|
38
|
+
} as CSSProperties
|
|
39
|
+
}
|
|
40
|
+
className={cn("overflow-hidden rounded-xl border bg-card text-card-foreground", className)}
|
|
41
|
+
{...props}
|
|
42
|
+
/>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const GRID = "grid grid-cols-(--row-cols-mobile) items-center gap-x-4 md:grid-cols-(--row-cols)";
|
|
47
|
+
|
|
48
|
+
/** หัวคอลัมน์ — ซ่อนบนมือถือ */
|
|
49
|
+
export function RowTableHeader({ className, ...props }: ComponentProps<"div">) {
|
|
50
|
+
return (
|
|
51
|
+
<div
|
|
52
|
+
data-slot="row-table-header"
|
|
53
|
+
className={cn(
|
|
54
|
+
GRID,
|
|
55
|
+
"hidden h-10 border-b bg-muted/40 px-2 font-semibold text-label text-muted-foreground md:grid",
|
|
56
|
+
className,
|
|
57
|
+
)}
|
|
58
|
+
{...props}
|
|
59
|
+
/>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function RowTableRow({ className, ...props }: ComponentProps<"div">) {
|
|
64
|
+
return (
|
|
65
|
+
<div
|
|
66
|
+
data-slot="row-table-row"
|
|
67
|
+
className={cn(
|
|
68
|
+
GRID,
|
|
69
|
+
"group/row min-h-13 border-b px-2 last:border-b-0 hover:bg-muted/40",
|
|
70
|
+
className,
|
|
71
|
+
)}
|
|
72
|
+
{...props}
|
|
73
|
+
/>
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** ช่องของแถว — `hideOnMobile` ซ่อนบนมือถือ (ต้องตรงกับ `mobileColumns`) */
|
|
78
|
+
export function RowTableCell({
|
|
79
|
+
hideOnMobile = false,
|
|
80
|
+
align = "start",
|
|
81
|
+
className,
|
|
82
|
+
...props
|
|
83
|
+
}: ComponentProps<"div"> & {
|
|
84
|
+
hideOnMobile?: boolean;
|
|
85
|
+
align?: "start" | "end";
|
|
86
|
+
}) {
|
|
87
|
+
return (
|
|
88
|
+
<div
|
|
89
|
+
data-slot="row-table-cell"
|
|
90
|
+
className={cn(
|
|
91
|
+
"min-w-0 text-sm",
|
|
92
|
+
align === "end" && "text-right tabular-nums",
|
|
93
|
+
hideOnMobile && "hidden md:block",
|
|
94
|
+
className,
|
|
95
|
+
)}
|
|
96
|
+
{...props}
|
|
97
|
+
/>
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** ปุ่มท้ายแถว — โผล่ตอนชี้/โฟกัสในแถว (md ขึ้นไป) · มือถือและจอสัมผัสเห็นตลอด (`reveal={false}` = เห็นตลอดทุกจอ) */
|
|
102
|
+
export function RowTableActions({
|
|
103
|
+
reveal = true,
|
|
104
|
+
className,
|
|
105
|
+
...props
|
|
106
|
+
}: ComponentProps<"div"> & { reveal?: boolean }) {
|
|
107
|
+
return (
|
|
108
|
+
<div
|
|
109
|
+
data-slot="row-table-actions"
|
|
110
|
+
className={cn(
|
|
111
|
+
"flex shrink-0 items-center justify-end gap-0.5",
|
|
112
|
+
reveal &&
|
|
113
|
+
"transition-opacity md:opacity-0 md:group-focus-within/row:opacity-100 md:group-hover/row:opacity-100 pointer-coarse:opacity-100",
|
|
114
|
+
className,
|
|
115
|
+
)}
|
|
116
|
+
{...props}
|
|
117
|
+
/>
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** แถวหัวกลุ่ม (โฟลเดอร์) คั่นระหว่างแถว — ครอบทั้งความกว้าง ไม่ใช้คอลัมน์ของตาราง */
|
|
122
|
+
export function RowTableGroup({ className, ...props }: ComponentProps<"div">) {
|
|
123
|
+
return (
|
|
124
|
+
<div
|
|
125
|
+
data-slot="row-table-group"
|
|
126
|
+
className={cn(
|
|
127
|
+
"flex h-10 items-center gap-1.5 border-b bg-muted/20 px-3 font-semibold text-caption text-muted-foreground",
|
|
128
|
+
className,
|
|
129
|
+
)}
|
|
130
|
+
{...props}
|
|
131
|
+
/>
|
|
132
|
+
);
|
|
133
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { cn } from "@suphark/ui/lib/utils";
|
|
2
|
+
import type { ComponentProps } from "react";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* แถวเมนูข้างแบบเรียบ (ใช้เมื่อประกอบ sidebar เอง ไม่ใช่ `AppNav`): หน้าปัจจุบันอ่านเป็นสีหลัก (`bg-primary/10 text-primary`)
|
|
6
|
+
* สูงขั้นต่ำ 32px และ 44px บนจอสัมผัส · `draggingOver` ไฮไลต์ตอนลากรายการมาวาง · ยกจาก pi-done
|
|
7
|
+
* เป็น `div` (วาง `Link`/ปุ่มไว้ข้างใน) เพื่อให้ใช้เป็นที่วางของการลากได้
|
|
8
|
+
*
|
|
9
|
+
* <SidebarNavItem active={path === "/inbox"}>
|
|
10
|
+
* <Link href="/inbox" className="flex-1">กล่องเข้า</Link>
|
|
11
|
+
* <SidebarCountBadge count={unread} active={path === "/inbox"} />
|
|
12
|
+
* </SidebarNavItem>
|
|
13
|
+
*/
|
|
14
|
+
export function SidebarNavItem({
|
|
15
|
+
active = false,
|
|
16
|
+
draggingOver = false,
|
|
17
|
+
className,
|
|
18
|
+
...props
|
|
19
|
+
}: ComponentProps<"div"> & { active?: boolean; draggingOver?: boolean }) {
|
|
20
|
+
return (
|
|
21
|
+
<div
|
|
22
|
+
data-slot="sidebar-nav-item"
|
|
23
|
+
data-active={active || undefined}
|
|
24
|
+
className={cn(
|
|
25
|
+
"relative flex min-h-8 items-center justify-between rounded-md border border-transparent px-2.5 py-1 text-sm transition-[background-color,transform,border-color,color] duration-150 ease-out pointer-coarse:min-h-11",
|
|
26
|
+
active
|
|
27
|
+
? "bg-primary/10 font-semibold text-primary"
|
|
28
|
+
: "font-medium text-sidebar-foreground/85 hover:bg-sidebar-accent/60 hover:text-foreground",
|
|
29
|
+
draggingOver && "scale-[1.02] border-primary/20 bg-primary/10 text-primary shadow-2xs",
|
|
30
|
+
className,
|
|
31
|
+
)}
|
|
32
|
+
{...props}
|
|
33
|
+
/>
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** ตัวเลขท้ายแถวเมนู — ข้อความเทา ไม่ใช่ pill (เมนูอ่านชื่อก่อน) · 0 ซ่อน · เกิน `max` แสดง "999+" */
|
|
38
|
+
export function SidebarCountBadge({
|
|
39
|
+
count,
|
|
40
|
+
active = false,
|
|
41
|
+
max = 999,
|
|
42
|
+
className,
|
|
43
|
+
}: {
|
|
44
|
+
count: number;
|
|
45
|
+
active?: boolean;
|
|
46
|
+
max?: number;
|
|
47
|
+
className?: string;
|
|
48
|
+
}) {
|
|
49
|
+
if (count <= 0) return null;
|
|
50
|
+
return (
|
|
51
|
+
<span
|
|
52
|
+
data-slot="sidebar-count-badge"
|
|
53
|
+
className={cn(
|
|
54
|
+
"shrink-0 text-caption tabular-nums",
|
|
55
|
+
active ? "font-semibold text-primary" : "text-muted-foreground/80",
|
|
56
|
+
className,
|
|
57
|
+
)}
|
|
58
|
+
>
|
|
59
|
+
{count > max ? `${max}+` : count}
|
|
60
|
+
</span>
|
|
61
|
+
);
|
|
62
|
+
}
|
|
@@ -213,6 +213,11 @@ export interface UiText {
|
|
|
213
213
|
dayCount: string;
|
|
214
214
|
allShortcuts: string;
|
|
215
215
|
tapToFill: string;
|
|
216
|
+
/** `minDate`/`maxDate` — `{date}` = วันที่ขอบเขต (ตาม `locale` ของช่อง) */
|
|
217
|
+
beforeMin: string;
|
|
218
|
+
afterMax: string;
|
|
219
|
+
/** `DateRangePicker` (ตัวกรอง) */
|
|
220
|
+
clearRange: string;
|
|
216
221
|
last7Days: string;
|
|
217
222
|
last30Days: string;
|
|
218
223
|
thisMonth: string;
|
|
@@ -418,6 +423,9 @@ export const UI_TEXT_TH: UiText = {
|
|
|
418
423
|
dayCount: "{count} วัน",
|
|
419
424
|
allShortcuts: "พิมพ์ลัดทั้งหมด",
|
|
420
425
|
tapToFill: "กดเพื่อเติมในช่อง",
|
|
426
|
+
beforeMin: "เลือกได้ตั้งแต่วันที่ {date}",
|
|
427
|
+
afterMax: "เลือกได้ถึงวันที่ {date}",
|
|
428
|
+
clearRange: "ล้างช่วงวันที่",
|
|
421
429
|
last7Days: "7 วันล่าสุด",
|
|
422
430
|
last30Days: "30 วันล่าสุด",
|
|
423
431
|
thisMonth: "เดือนนี้",
|
|
@@ -629,6 +637,9 @@ export const UI_TEXT_EN: UiText = {
|
|
|
629
637
|
dayCount: "{count} days",
|
|
630
638
|
allShortcuts: "All shortcuts",
|
|
631
639
|
tapToFill: "Tap to fill the field",
|
|
640
|
+
beforeMin: "Pick {date} or later",
|
|
641
|
+
afterMax: "Pick {date} or earlier",
|
|
642
|
+
clearRange: "Clear selection",
|
|
632
643
|
last7Days: "Last 7 days",
|
|
633
644
|
last30Days: "Last 30 days",
|
|
634
645
|
thisMonth: "This month",
|
package/src/index.ts
CHANGED
|
@@ -77,6 +77,7 @@ export {
|
|
|
77
77
|
} from "./components/shared/confirm-dialog";
|
|
78
78
|
export { CountBadge } from "./components/shared/count-badge";
|
|
79
79
|
export { DataList, DataRow } from "./components/shared/data-list";
|
|
80
|
+
export { AccessibleCalendar } from "./components/shared/date/accessible-calendar";
|
|
80
81
|
export {
|
|
81
82
|
DatePicker,
|
|
82
83
|
type DatePickerProps,
|
|
@@ -131,7 +132,7 @@ export {
|
|
|
131
132
|
type FormInvalidNotice,
|
|
132
133
|
useFormInvalidNotice,
|
|
133
134
|
} from "./components/shared/form/form-invalid-notice";
|
|
134
|
-
export { CheckboxField, LabeledField } from "./components/shared/form/labeled-field";
|
|
135
|
+
export { CheckboxField, LabeledField, SwitchField } from "./components/shared/form/labeled-field";
|
|
135
136
|
export { NumberInput } from "./components/shared/form/number-input";
|
|
136
137
|
export { SearchInput, type SearchInputProps } from "./components/shared/form/search-input";
|
|
137
138
|
export {
|
|
@@ -175,6 +176,11 @@ export {
|
|
|
175
176
|
type LanguageSwitchProps,
|
|
176
177
|
} from "./components/shared/language/language-switch";
|
|
177
178
|
export { ErrorState, type RouteError } from "./components/shared/layout/error-state";
|
|
179
|
+
export {
|
|
180
|
+
ListSection,
|
|
181
|
+
ListSectionHeader,
|
|
182
|
+
type ListSectionTone,
|
|
183
|
+
} from "./components/shared/list-section";
|
|
178
184
|
export {
|
|
179
185
|
MonthStepper,
|
|
180
186
|
type MonthStepperProps,
|
|
@@ -211,6 +217,24 @@ export {
|
|
|
211
217
|
type RecordTone,
|
|
212
218
|
recordAccentColor,
|
|
213
219
|
} from "./components/shared/record-card";
|
|
220
|
+
export {
|
|
221
|
+
ResponsivePopover,
|
|
222
|
+
ResponsivePopoverContent,
|
|
223
|
+
type ResponsivePopoverContentProps,
|
|
224
|
+
ResponsivePopoverItem,
|
|
225
|
+
type ResponsivePopoverProps,
|
|
226
|
+
ResponsivePopoverTrigger,
|
|
227
|
+
type ResponsivePopoverTriggerProps,
|
|
228
|
+
useIsResponsiveSheet,
|
|
229
|
+
} from "./components/shared/responsive-popover";
|
|
230
|
+
export {
|
|
231
|
+
RowTable,
|
|
232
|
+
RowTableActions,
|
|
233
|
+
RowTableCell,
|
|
234
|
+
RowTableGroup,
|
|
235
|
+
RowTableHeader,
|
|
236
|
+
RowTableRow,
|
|
237
|
+
} from "./components/shared/row-table";
|
|
214
238
|
export {
|
|
215
239
|
describeCondition,
|
|
216
240
|
emptyGroup,
|
|
@@ -242,6 +266,7 @@ export { SelectionButton } from "./components/shared/selection-button";
|
|
|
242
266
|
export { SettingsLayout, type SettingsSection } from "./components/shared/settings-layout";
|
|
243
267
|
export { DangerZone, SettingsRow, SwitchRow } from "./components/shared/settings-row";
|
|
244
268
|
export { SetupChecklist, type SetupChecklistStep } from "./components/shared/setup-checklist";
|
|
269
|
+
export { SidebarCountBadge, SidebarNavItem } from "./components/shared/sidebar-nav-item";
|
|
245
270
|
export { SimpleDialog, SimpleSheet } from "./components/shared/simple-dialog";
|
|
246
271
|
export { paginationRange, SimplePagination } from "./components/shared/simple-pagination";
|
|
247
272
|
export {
|