@suphark/ui 0.47.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 +107 -0
- package/README.md +1 -0
- package/package.json +2 -2
- package/src/components/app-shell/header-breadcrumbs.tsx +24 -0
- package/src/components/pwa/pull-to-refresh.tsx +44 -8
- package/src/components/pwa/web-push-row.tsx +126 -0
- package/src/components/shared/date/accessible-calendar.tsx +40 -0
- package/src/components/shared/date/date-picker.tsx +844 -321
- 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/notification-center.tsx +99 -11
- package/src/components/shared/progress-bar.tsx +9 -2
- package/src/components/shared/record-card.tsx +158 -0
- package/src/components/shared/responsive-popover.tsx +174 -0
- package/src/components/shared/row-table.tsx +133 -0
- package/src/components/shared/selection-bar.tsx +20 -6
- package/src/components/shared/sidebar-nav-item.tsx +62 -0
- package/src/components/shared/status-badge.tsx +16 -2
- package/src/components/shared/ui-text.tsx +36 -0
- package/src/components/sortable/sortable-list.tsx +17 -1
- package/src/index.ts +26 -1
- package/src/pwa.ts +2 -1
|
@@ -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
|
+
}
|
|
@@ -89,8 +89,14 @@ export interface NotificationEntry {
|
|
|
89
89
|
details?: ReactNode;
|
|
90
90
|
/** ซ้ายของแถบล่าง — เรื่องเฉพาะโดเมน เช่นยอดเงิน (ซ่อนได้) หรือความคืบหน้ารอบ */
|
|
91
91
|
footer?: ReactNode;
|
|
92
|
+
/** `variant="quiet"`: เน้นแถวที่ต้องจัดการก่อน (พื้นสี accent/tone จาง ๆ) — ปกติใช้แถวเดียว */
|
|
93
|
+
highlight?: boolean;
|
|
94
|
+
/** `variant="quiet"`: แถบความคืบหน้า 0–100 ใต้ยอด (`footer`) */
|
|
95
|
+
progress?: number;
|
|
92
96
|
/** ยังไม่ได้อ่าน — จุดหน้าชื่อ + พื้นจาง */
|
|
93
97
|
unread?: boolean;
|
|
98
|
+
/** หัวข้อกลุ่มคั่น (`variant="quiet"`) — แถวติดกันที่ `section` เดียวกันอยู่ใต้หัวเดียว เช่น "ต้องจัดการก่อน" */
|
|
99
|
+
section?: string;
|
|
94
100
|
/** ปุ่มในแถว (ปุ่มที่ `primary` จะเด่นสุด) */
|
|
95
101
|
actions?: ReadonlyArray<NotificationAction>;
|
|
96
102
|
/** คำสั่งรองใน "…" */
|
|
@@ -120,6 +126,11 @@ export interface NotificationCenterProps {
|
|
|
120
126
|
viewAll?: { label?: ReactNode; onSelect: () => void };
|
|
121
127
|
/** กำลังทำคำสั่งลัดอยู่ — ปิดปุ่มทั้งหมดกันกดซ้ำ */
|
|
122
128
|
busy?: boolean;
|
|
129
|
+
/**
|
|
130
|
+
* `card` (ค่าเริ่มต้น) = ชิปนับ + การ์ดแยกใบ · `quiet` = "Quiet ledger": ชิปนับเป็นแท็บขีดเส้นใต้ (ตัวเลข tone danger เป็นสีแดง)
|
|
131
|
+
* รายการเป็นแถวเรียบในกรอบเดียว (`RecordCard variant="quiet"`) มีหัวกลุ่มคั่นด้วย `section` — เห็นได้มากกว่าเท่าตัว
|
|
132
|
+
*/
|
|
133
|
+
variant?: "card" | "quiet";
|
|
123
134
|
/**
|
|
124
135
|
* เรนเดอร์แถวเอง (การ์ดของแอป) — ปกติไม่ต้องใช้: แถวมาตรฐานคือ `RecordCard` ตัวเดียวกับที่แอปใช้ทำรายการ
|
|
125
136
|
* อยู่แล้ว · คืน node ที่จะอยู่ใน `<li>`
|
|
@@ -149,9 +160,11 @@ export function NotificationCenter({
|
|
|
149
160
|
empty,
|
|
150
161
|
viewAll,
|
|
151
162
|
busy = false,
|
|
163
|
+
variant = "card",
|
|
152
164
|
renderItem,
|
|
153
165
|
className,
|
|
154
166
|
}: NotificationCenterProps) {
|
|
167
|
+
const quiet = variant === "quiet";
|
|
155
168
|
const text = useUiText();
|
|
156
169
|
const heading = title ?? text.notifications?.title ?? "การแจ้งเตือน";
|
|
157
170
|
|
|
@@ -165,7 +178,7 @@ export function NotificationCenter({
|
|
|
165
178
|
<div className="flex min-w-0 flex-col gap-1.5">
|
|
166
179
|
<Heading className={cn("font-semibold text-sm", headingClassName)}>{heading}</Heading>
|
|
167
180
|
{description && <p className="text-muted-foreground text-xs">{description}</p>}
|
|
168
|
-
{counts && counts.length > 0 && (
|
|
181
|
+
{counts && counts.length > 0 && !quiet && (
|
|
169
182
|
<div className="flex flex-wrap items-center gap-1.5">
|
|
170
183
|
{counts.map((chip) => (
|
|
171
184
|
<CountChip key={chip.key} chip={chip} />
|
|
@@ -181,6 +194,8 @@ export function NotificationCenter({
|
|
|
181
194
|
)}
|
|
182
195
|
</div>
|
|
183
196
|
|
|
197
|
+
{quiet && counts && counts.length > 0 && <QuietTabs counts={counts} />}
|
|
198
|
+
|
|
184
199
|
{items.length === 0 ? (
|
|
185
200
|
(empty ?? (
|
|
186
201
|
<div className="flex flex-col items-center gap-1 py-8 text-center">
|
|
@@ -191,16 +206,31 @@ export function NotificationCenter({
|
|
|
191
206
|
</div>
|
|
192
207
|
))
|
|
193
208
|
) : (
|
|
194
|
-
<ul
|
|
195
|
-
{
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
{renderItem(item, { busy })}
|
|
199
|
-
</li>
|
|
200
|
-
) : (
|
|
201
|
-
<NotificationRow key={item.id} item={item} busy={busy} />
|
|
202
|
-
),
|
|
209
|
+
<ul
|
|
210
|
+
className={cn(
|
|
211
|
+
"flex flex-col",
|
|
212
|
+
quiet ? "overflow-hidden rounded-xl border bg-card" : "gap-2",
|
|
203
213
|
)}
|
|
214
|
+
>
|
|
215
|
+
{items.map((item, index) => (
|
|
216
|
+
<Fragment key={item.id}>
|
|
217
|
+
{quiet && item.section && item.section !== items[index - 1]?.section && (
|
|
218
|
+
<li
|
|
219
|
+
role="presentation"
|
|
220
|
+
className="border-b bg-muted px-3.5 pt-2 pb-1.5 font-semibold text-muted-foreground text-xs not-first:border-t"
|
|
221
|
+
>
|
|
222
|
+
{item.section}
|
|
223
|
+
</li>
|
|
224
|
+
)}
|
|
225
|
+
{renderItem ? (
|
|
226
|
+
<li data-slot="notification-row" className="min-w-0">
|
|
227
|
+
{renderItem(item, { busy })}
|
|
228
|
+
</li>
|
|
229
|
+
) : (
|
|
230
|
+
<NotificationRow item={item} busy={busy} quiet={quiet} />
|
|
231
|
+
)}
|
|
232
|
+
</Fragment>
|
|
233
|
+
))}
|
|
204
234
|
</ul>
|
|
205
235
|
)}
|
|
206
236
|
</section>
|
|
@@ -230,12 +260,70 @@ function CountChip({ chip }: { chip: NotificationChip }) {
|
|
|
230
260
|
);
|
|
231
261
|
}
|
|
232
262
|
|
|
233
|
-
|
|
263
|
+
/** แท็บนับแบบขีดเส้นใต้ (`variant="quiet"`) — ตัวเลขของ tone danger เป็นสีแดงตัวหนา */
|
|
264
|
+
function QuietTabs({ counts }: { counts: ReadonlyArray<NotificationChip> }) {
|
|
265
|
+
return (
|
|
266
|
+
<div className="flex flex-wrap gap-x-5 border-b">
|
|
267
|
+
{counts.map((chip) => {
|
|
268
|
+
const body = (
|
|
269
|
+
<>
|
|
270
|
+
{chip.label}
|
|
271
|
+
<span
|
|
272
|
+
className={cn(
|
|
273
|
+
"ms-1 text-xs tabular-nums",
|
|
274
|
+
isDanger(chip.tone) ? "font-bold text-destructive" : "text-muted-foreground",
|
|
275
|
+
)}
|
|
276
|
+
>
|
|
277
|
+
{chip.count}
|
|
278
|
+
</span>
|
|
279
|
+
</>
|
|
280
|
+
);
|
|
281
|
+
const tab = cn(
|
|
282
|
+
"-mb-px border-b-2 px-0.5 pt-2 pb-2.5 text-sm",
|
|
283
|
+
chip.active
|
|
284
|
+
? "border-primary font-semibold"
|
|
285
|
+
: "border-transparent font-medium text-muted-foreground",
|
|
286
|
+
);
|
|
287
|
+
return chip.onSelect ? (
|
|
288
|
+
<button
|
|
289
|
+
key={chip.key}
|
|
290
|
+
type="button"
|
|
291
|
+
aria-pressed={chip.active ?? false}
|
|
292
|
+
onClick={chip.onSelect}
|
|
293
|
+
className={cn(
|
|
294
|
+
tab,
|
|
295
|
+
"cursor-pointer outline-none focus-visible:ring-3 focus-visible:ring-ring/50",
|
|
296
|
+
)}
|
|
297
|
+
>
|
|
298
|
+
{body}
|
|
299
|
+
</button>
|
|
300
|
+
) : (
|
|
301
|
+
<span key={chip.key} className={tab}>
|
|
302
|
+
{body}
|
|
303
|
+
</span>
|
|
304
|
+
);
|
|
305
|
+
})}
|
|
306
|
+
</div>
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function NotificationRow({
|
|
311
|
+
item,
|
|
312
|
+
busy,
|
|
313
|
+
quiet,
|
|
314
|
+
}: {
|
|
315
|
+
item: NotificationEntry;
|
|
316
|
+
busy: boolean;
|
|
317
|
+
quiet: boolean;
|
|
318
|
+
}) {
|
|
234
319
|
const text = useUiText();
|
|
235
320
|
const hasActions = Boolean(item.actions?.length || item.menuActions?.length);
|
|
236
321
|
return (
|
|
237
322
|
<RecordCard
|
|
238
323
|
as="li"
|
|
324
|
+
variant={quiet ? "quiet" : "card"}
|
|
325
|
+
highlight={item.highlight}
|
|
326
|
+
progress={item.progress}
|
|
239
327
|
accent={item.accent ?? item.tone}
|
|
240
328
|
icon={item.icon}
|
|
241
329
|
title={item.title}
|
|
@@ -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
|
);
|
|
@@ -32,6 +32,16 @@ export function recordAccentColor(accent: RecordAccent | undefined): string | un
|
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
export interface RecordCardProps {
|
|
35
|
+
/**
|
|
36
|
+
* `card` (ค่าเริ่มต้น) = การ์ดมีสี/ขอบ/พื้นไล่สีครบ · `quiet` = แถวเรียบแบบสมุดบัญชี ("Quiet ledger"):
|
|
37
|
+
* ไม่มีกรอบ/พื้นสี เส้นคั่นล่างบาง ๆ สีของ `accent` เหลือแค่ไอคอน · ชื่อ + (status · meta) ซ้าย, `footerSummary` (ยอดเงิน) ขวา,
|
|
38
|
+
* `actions` ท้ายแถว — ยัดได้มากกว่าเท่าตัวในที่เท่ากัน · วางในกรอบ/`<ul>` เดียวกัน (เส้นคั่นล่างของแถวสุดท้ายหายเอง)
|
|
39
|
+
*/
|
|
40
|
+
variant?: "card" | "quiet";
|
|
41
|
+
/** `quiet` เท่านั้น: เน้นแถวที่ต้องจัดการก่อน — พื้นไล่เป็นสีของ `accent` จางมาก (ใช้ตัวเดียวต่อรายการ ไม่ใช่ทุกแถว) */
|
|
42
|
+
highlight?: boolean;
|
|
43
|
+
/** `quiet` เท่านั้น: แถบความคืบหน้า 0–100 ใต้ `footerSummary` (เช่นรอบที่ทำแล้ว 35/60 = 58) */
|
|
44
|
+
progress?: number;
|
|
35
45
|
/** สีประจำการ์ด: ขอบซ้าย 3px + ขอบจาง ๆ + พื้นไล่สีจากซ้าย — tone หรือสี CSS (`var(--type-income)`) */
|
|
36
46
|
accent?: RecordAccent;
|
|
37
47
|
/** ไอคอน — ใส่ในกรอบสี่เหลี่ยมมนตามสี accent ให้เอง (`iconTile={false}` = วางตามที่ส่งมา) */
|
|
@@ -96,10 +106,23 @@ export function RecordCard({
|
|
|
96
106
|
unread = false,
|
|
97
107
|
onSelect,
|
|
98
108
|
as: Root = "article",
|
|
109
|
+
variant = "card",
|
|
110
|
+
highlight = false,
|
|
111
|
+
progress,
|
|
99
112
|
className,
|
|
100
113
|
}: RecordCardProps) {
|
|
101
114
|
const text = useUiText();
|
|
102
115
|
const color = recordAccentColor(accent);
|
|
116
|
+
if (variant === "quiet") {
|
|
117
|
+
return (
|
|
118
|
+
<QuietRecord
|
|
119
|
+
{...{ Root, Title, color, icon, title, titleClassName, status, meta, description }}
|
|
120
|
+
{...{ details, detailsLabel, footerSummary, actions, unread, onSelect, className }}
|
|
121
|
+
{...{ highlight, progress }}
|
|
122
|
+
fallbackDetailsLabel={text.record?.details ?? "ข้อมูลเพิ่มเติม"}
|
|
123
|
+
/>
|
|
124
|
+
);
|
|
125
|
+
}
|
|
103
126
|
const small = iconSize === "sm";
|
|
104
127
|
// เยื้อง = ความกว้างไอคอน + gap-3 (44+12 = 56 = ml-14, 36+12 = 48 = ml-12)
|
|
105
128
|
const indent = icon ? (small ? "ml-12 ps-0" : "ml-14 ps-0") : "";
|
|
@@ -203,3 +226,138 @@ export function RecordCard({
|
|
|
203
226
|
</Root>
|
|
204
227
|
);
|
|
205
228
|
}
|
|
229
|
+
|
|
230
|
+
/** variant="quiet": แถวเดียวต่อรายการ — ไอคอน | ชื่อ + (สถานะ · meta) | ยอด | ปุ่ม */
|
|
231
|
+
function QuietRecord({
|
|
232
|
+
Root,
|
|
233
|
+
Title,
|
|
234
|
+
color,
|
|
235
|
+
icon,
|
|
236
|
+
title,
|
|
237
|
+
titleClassName,
|
|
238
|
+
status,
|
|
239
|
+
meta,
|
|
240
|
+
description,
|
|
241
|
+
details,
|
|
242
|
+
detailsLabel,
|
|
243
|
+
fallbackDetailsLabel,
|
|
244
|
+
footerSummary,
|
|
245
|
+
actions,
|
|
246
|
+
unread,
|
|
247
|
+
onSelect,
|
|
248
|
+
highlight,
|
|
249
|
+
progress,
|
|
250
|
+
className,
|
|
251
|
+
}: Pick<
|
|
252
|
+
RecordCardProps,
|
|
253
|
+
| "icon"
|
|
254
|
+
| "title"
|
|
255
|
+
| "titleClassName"
|
|
256
|
+
| "status"
|
|
257
|
+
| "meta"
|
|
258
|
+
| "description"
|
|
259
|
+
| "details"
|
|
260
|
+
| "detailsLabel"
|
|
261
|
+
| "footerSummary"
|
|
262
|
+
| "actions"
|
|
263
|
+
| "unread"
|
|
264
|
+
| "onSelect"
|
|
265
|
+
| "highlight"
|
|
266
|
+
| "progress"
|
|
267
|
+
| "className"
|
|
268
|
+
> & {
|
|
269
|
+
Root: "article" | "li" | "div";
|
|
270
|
+
Title: "h2" | "h3" | "h4" | "p";
|
|
271
|
+
color: string | undefined;
|
|
272
|
+
fallbackDetailsLabel: string;
|
|
273
|
+
}) {
|
|
274
|
+
return (
|
|
275
|
+
// biome-ignore lint/a11y/useKeyWithClickEvents: คลิกทั้งแถวเป็นทางลัดของเมาส์ ปุ่มข้างในใช้คีย์บอร์ดได้อยู่แล้ว
|
|
276
|
+
<Root
|
|
277
|
+
data-slot="record-card"
|
|
278
|
+
data-variant="quiet"
|
|
279
|
+
data-unread={unread || undefined}
|
|
280
|
+
onClick={onSelect}
|
|
281
|
+
style={color ? ({ "--record-accent": color } as CSSProperties) : undefined}
|
|
282
|
+
className={cn(
|
|
283
|
+
"min-w-0 border-b bg-transparent px-3.5 py-2.5 text-card-foreground last:border-b-0",
|
|
284
|
+
unread && "bg-muted/40",
|
|
285
|
+
highlight && color && "bg-[color-mix(in_oklab,var(--record-accent)_9%,var(--card))]",
|
|
286
|
+
onSelect && "cursor-pointer transition-colors hover:bg-muted/60",
|
|
287
|
+
className,
|
|
288
|
+
)}
|
|
289
|
+
>
|
|
290
|
+
<div className="flex min-w-0 flex-wrap items-center gap-x-3 gap-y-2">
|
|
291
|
+
{icon && (
|
|
292
|
+
<div
|
|
293
|
+
data-slot="record-card-icon"
|
|
294
|
+
className={cn(
|
|
295
|
+
"flex size-9 shrink-0 items-center justify-center rounded-[10px] bg-muted [&_svg]:size-[1.125rem]",
|
|
296
|
+
color ? "text-(--record-accent)" : "text-muted-foreground",
|
|
297
|
+
)}
|
|
298
|
+
>
|
|
299
|
+
{icon}
|
|
300
|
+
</div>
|
|
301
|
+
)}
|
|
302
|
+
<div className="min-w-0 flex-1 basis-48">
|
|
303
|
+
<Title className={cn("truncate font-semibold text-sm leading-snug", titleClassName)}>
|
|
304
|
+
{unread && (
|
|
305
|
+
<span
|
|
306
|
+
className="me-1.5 inline-block size-1.5 rounded-full bg-primary align-middle"
|
|
307
|
+
aria-hidden="true"
|
|
308
|
+
/>
|
|
309
|
+
)}
|
|
310
|
+
{title}
|
|
311
|
+
</Title>
|
|
312
|
+
{(status != null || meta != null) && (
|
|
313
|
+
<div className="mt-0.5 flex min-w-0 flex-wrap items-center gap-x-1.5 gap-y-0.5 text-muted-foreground text-xs">
|
|
314
|
+
{status}
|
|
315
|
+
{status != null && meta != null && <span aria-hidden="true">·</span>}
|
|
316
|
+
{meta}
|
|
317
|
+
</div>
|
|
318
|
+
)}
|
|
319
|
+
{description != null && (
|
|
320
|
+
<p className="mt-0.5 text-muted-foreground text-xs">{description}</p>
|
|
321
|
+
)}
|
|
322
|
+
</div>
|
|
323
|
+
{footerSummary != null && (
|
|
324
|
+
<div className="min-w-24 shrink-0 text-end tabular-nums">
|
|
325
|
+
{footerSummary}
|
|
326
|
+
{progress != null && (
|
|
327
|
+
<div
|
|
328
|
+
role="progressbar"
|
|
329
|
+
aria-valuenow={progress}
|
|
330
|
+
aria-valuemin={0}
|
|
331
|
+
aria-valuemax={100}
|
|
332
|
+
className="mt-1 h-[3px] overflow-hidden rounded-full bg-muted"
|
|
333
|
+
>
|
|
334
|
+
<div
|
|
335
|
+
className="h-full rounded-full bg-info"
|
|
336
|
+
style={{ width: `${Math.min(100, Math.max(0, progress))}%` }}
|
|
337
|
+
/>
|
|
338
|
+
</div>
|
|
339
|
+
)}
|
|
340
|
+
</div>
|
|
341
|
+
)}
|
|
342
|
+
{actions != null && (
|
|
343
|
+
// biome-ignore lint/a11y/noStaticElementInteractions: ตัวกั้นคลิกเฉย ๆ ไม่ใช่ตัวควบคุม
|
|
344
|
+
<div
|
|
345
|
+
className="flex shrink-0 items-center gap-1.5"
|
|
346
|
+
onClick={(event: MouseEvent) => event.stopPropagation()}
|
|
347
|
+
onKeyDown={(event: KeyboardEvent) => event.stopPropagation()}
|
|
348
|
+
>
|
|
349
|
+
{actions}
|
|
350
|
+
</div>
|
|
351
|
+
)}
|
|
352
|
+
</div>
|
|
353
|
+
{details != null && (
|
|
354
|
+
<section
|
|
355
|
+
aria-label={detailsLabel ?? fallbackDetailsLabel}
|
|
356
|
+
className={cn("mt-2 border-t pt-2", icon && "ms-12")}
|
|
357
|
+
>
|
|
358
|
+
{details}
|
|
359
|
+
</section>
|
|
360
|
+
)}
|
|
361
|
+
</Root>
|
|
362
|
+
);
|
|
363
|
+
}
|