@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,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,39 @@ 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
|
+
labelClassName,
|
|
113
|
+
...props
|
|
114
|
+
}: ComponentProps<typeof Switch> & {
|
|
115
|
+
label: ReactNode;
|
|
116
|
+
/** คำอธิบายใต้ label (เยื้องตรงกับ label) */
|
|
117
|
+
hint?: ReactNode;
|
|
118
|
+
/** ทับสไตล์ข้อความป้าย (ค่าเริ่มต้น `text-sm`) เช่น `text-caption font-semibold` */
|
|
119
|
+
labelClassName?: string;
|
|
120
|
+
}) {
|
|
121
|
+
return (
|
|
122
|
+
<Label
|
|
123
|
+
className={cn(
|
|
124
|
+
"flex cursor-pointer items-center gap-2 font-normal pointer-coarse:min-h-11",
|
|
125
|
+
hint && "items-start",
|
|
126
|
+
className,
|
|
127
|
+
)}
|
|
128
|
+
>
|
|
129
|
+
<Switch className={cn(hint && "mt-0.5")} {...props} />
|
|
130
|
+
<span className="flex flex-col gap-0.5">
|
|
131
|
+
<span className={cn("text-sm", labelClassName)}>{label}</span>
|
|
132
|
+
{hint && <span className="text-muted-foreground text-xs">{hint}</span>}
|
|
133
|
+
</span>
|
|
134
|
+
</Label>
|
|
135
|
+
);
|
|
136
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -122,6 +122,8 @@ export interface NotificationCenterProps {
|
|
|
122
122
|
description?: ReactNode;
|
|
123
123
|
/** ไม่มีรายการ — ส่ง element เองได้ทั้งก้อน */
|
|
124
124
|
empty?: ReactNode;
|
|
125
|
+
/** ปุ่มคำสั่งของทั้งกล่องที่หัว เช่น "อ่านทั้งหมดแล้ว" (ซ้ายของ "ดูทั้งหมด") — ไม่ต้องยืม `viewAll` มาใช้ผิดความหมาย */
|
|
126
|
+
headerAction?: ReactNode;
|
|
125
127
|
/** ลิงก์/ปุ่ม "ดูทั้งหมด" มุมขวาบน */
|
|
126
128
|
viewAll?: { label?: ReactNode; onSelect: () => void };
|
|
127
129
|
/** กำลังทำคำสั่งลัดอยู่ — ปิดปุ่มทั้งหมดกันกดซ้ำ */
|
|
@@ -159,6 +161,7 @@ export function NotificationCenter({
|
|
|
159
161
|
description,
|
|
160
162
|
empty,
|
|
161
163
|
viewAll,
|
|
164
|
+
headerAction,
|
|
162
165
|
busy = false,
|
|
163
166
|
variant = "card",
|
|
164
167
|
renderItem,
|
|
@@ -186,11 +189,16 @@ export function NotificationCenter({
|
|
|
186
189
|
</div>
|
|
187
190
|
)}
|
|
188
191
|
</div>
|
|
189
|
-
{viewAll && (
|
|
190
|
-
<
|
|
191
|
-
{
|
|
192
|
-
|
|
193
|
-
|
|
192
|
+
{(headerAction || viewAll) && (
|
|
193
|
+
<div className="flex shrink-0 items-center gap-1">
|
|
194
|
+
{headerAction}
|
|
195
|
+
{viewAll && (
|
|
196
|
+
<Button variant="ghost" size="sm" onClick={viewAll.onSelect} className="shrink-0">
|
|
197
|
+
{viewAll.label ?? text.notifications?.viewAll ?? "ดูทั้งหมด"}
|
|
198
|
+
<ChevronRightIcon />
|
|
199
|
+
</Button>
|
|
200
|
+
)}
|
|
201
|
+
</div>
|
|
194
202
|
)}
|
|
195
203
|
</div>
|
|
196
204
|
|
|
@@ -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
|
+
}
|
|
@@ -81,6 +81,10 @@ export interface UiText {
|
|
|
81
81
|
table?: Partial<{
|
|
82
82
|
rowsPerPage: string;
|
|
83
83
|
pageOf: string;
|
|
84
|
+
/** `variant="quiet"` — `{from}`/`{to}`/`{total}` และ `{shown}`/`{total}` */
|
|
85
|
+
rangeOf: string;
|
|
86
|
+
shownOf: string;
|
|
87
|
+
density: string;
|
|
84
88
|
rowsSelected: string;
|
|
85
89
|
noResults: string;
|
|
86
90
|
deleteTitle: string;
|
|
@@ -213,6 +217,11 @@ export interface UiText {
|
|
|
213
217
|
dayCount: string;
|
|
214
218
|
allShortcuts: string;
|
|
215
219
|
tapToFill: string;
|
|
220
|
+
/** `minDate`/`maxDate` — `{date}` = วันที่ขอบเขต (ตาม `locale` ของช่อง) */
|
|
221
|
+
beforeMin: string;
|
|
222
|
+
afterMax: string;
|
|
223
|
+
/** `DateRangePicker` (ตัวกรอง) */
|
|
224
|
+
clearRange: string;
|
|
216
225
|
last7Days: string;
|
|
217
226
|
last30Days: string;
|
|
218
227
|
thisMonth: string;
|
|
@@ -311,6 +320,9 @@ export const UI_TEXT_TH: UiText = {
|
|
|
311
320
|
table: {
|
|
312
321
|
rowsPerPage: "แถวต่อหน้า",
|
|
313
322
|
pageOf: "หน้า {page} จาก {pages}",
|
|
323
|
+
rangeOf: "{from}–{to} จาก {total}",
|
|
324
|
+
shownOf: "{shown} จาก {total} รายการ",
|
|
325
|
+
density: "ความหนาแน่นของแถว",
|
|
314
326
|
rowsSelected: "เลือก {selected} จาก {total} แถว",
|
|
315
327
|
noResults: "ไม่พบข้อมูล",
|
|
316
328
|
deleteTitle: "ยืนยันการลบ",
|
|
@@ -418,6 +430,9 @@ export const UI_TEXT_TH: UiText = {
|
|
|
418
430
|
dayCount: "{count} วัน",
|
|
419
431
|
allShortcuts: "พิมพ์ลัดทั้งหมด",
|
|
420
432
|
tapToFill: "กดเพื่อเติมในช่อง",
|
|
433
|
+
beforeMin: "เลือกได้ตั้งแต่วันที่ {date}",
|
|
434
|
+
afterMax: "เลือกได้ถึงวันที่ {date}",
|
|
435
|
+
clearRange: "ล้างช่วงวันที่",
|
|
421
436
|
last7Days: "7 วันล่าสุด",
|
|
422
437
|
last30Days: "30 วันล่าสุด",
|
|
423
438
|
thisMonth: "เดือนนี้",
|
|
@@ -522,6 +537,9 @@ export const UI_TEXT_EN: UiText = {
|
|
|
522
537
|
table: {
|
|
523
538
|
rowsPerPage: "Rows per page",
|
|
524
539
|
pageOf: "Page {page} of {pages}",
|
|
540
|
+
rangeOf: "{from}–{to} of {total}",
|
|
541
|
+
shownOf: "{shown} of {total} items",
|
|
542
|
+
density: "Row density",
|
|
525
543
|
rowsSelected: "{selected} of {total} row(s) selected",
|
|
526
544
|
noResults: "No results",
|
|
527
545
|
deleteTitle: "Are you sure?",
|
|
@@ -629,6 +647,9 @@ export const UI_TEXT_EN: UiText = {
|
|
|
629
647
|
dayCount: "{count} days",
|
|
630
648
|
allShortcuts: "All shortcuts",
|
|
631
649
|
tapToFill: "Tap to fill the field",
|
|
650
|
+
beforeMin: "Pick {date} or later",
|
|
651
|
+
afterMax: "Pick {date} or earlier",
|
|
652
|
+
clearRange: "Clear selection",
|
|
632
653
|
last7Days: "Last 7 days",
|
|
633
654
|
last30Days: "Last 30 days",
|
|
634
655
|
thisMonth: "This month",
|
package/src/data-table.ts
CHANGED
package/src/hooks/use-mobile.ts
CHANGED
|
@@ -8,12 +8,14 @@ export function useIsMobile() {
|
|
|
8
8
|
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(undefined);
|
|
9
9
|
|
|
10
10
|
React.useEffect(() => {
|
|
11
|
+
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
|
|
12
|
+
// เบราว์เซอร์เก่า/jsdom ที่ไม่มี matchMedia: ใช้ค่าตอน mount อย่างเดียว (ไม่ติดตามการปรับขนาด) แทนที่จะพังทั้งหน้า
|
|
13
|
+
if (typeof window.matchMedia !== "function") return;
|
|
11
14
|
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
|
|
12
15
|
const onChange = () => {
|
|
13
16
|
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
|
|
14
17
|
};
|
|
15
18
|
mql.addEventListener("change", onChange);
|
|
16
|
-
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
|
|
17
19
|
return () => mql.removeEventListener("change", onChange);
|
|
18
20
|
}, []);
|
|
19
21
|
|