@suphark/ui 0.42.1 → 0.45.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 +55 -1
- package/package.json +1 -1
- package/src/components/shared/auth-layout.tsx +6 -1
- package/src/components/shared/button/button.tsx +35 -1
- package/src/components/shared/button/index.ts +1 -0
- package/src/components/shared/notification-center.tsx +139 -148
- package/src/components/shared/record-card.tsx +205 -0
- package/src/components/shared/ui-text.tsx +4 -0
- package/src/components/sortable/sortable-list.tsx +43 -11
- package/src/index.ts +8 -0
- package/src/styles.css +26 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,7 +2,61 @@
|
|
|
2
2
|
|
|
3
3
|
รูปแบบตาม [Keep a Changelog](https://keepachangelog.com/) · semver: patch = หน้าตา/บั๊ก, minor = component/prop ใหม่, major = เปลี่ยน/ลบ props
|
|
4
4
|
|
|
5
|
-
## [0.
|
|
5
|
+
## [0.45.1] — 2026-10-08
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- `AuthLayout`: โลโก้แนวนอนถูกบีบในกรอบ 44×44 จนอ่านไม่ออก (Pi Auth — wordmark ของลูกค้า 315×70 เหลือสูง ~10px) · ตอนนี้ `<img>` สูง 44px กว้างตามสัดส่วน (ไม่เกิน 220px) ส่วนไอคอน/ตัวอักษร (svg, span) ยังเป็นกรอบจัตุรัสมนเหมือนเดิม — เลือกเองตามชนิดลูก แอปไม่ต้องรู้สัดส่วนรูป
|
|
10
|
+
- showcase: `/components/auth-layout` เพิ่มแท็บ "โลโก้แนวนอน"
|
|
11
|
+
|
|
12
|
+
## [0.45.0] — 2026-10-08
|
|
13
|
+
|
|
14
|
+
> ประกาศพร้อม publish แล้ว แต่ยังไม่ขึ้น npm — รวมอยู่ใน 0.45.1
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
### Added
|
|
18
|
+
|
|
19
|
+
- `RecordCard iconSize="sm" | "md"` (ขอจาก DueBell — การ์ดบิลโหมดกะทัดรัด): `sm` = กรอบ 36px (ไอคอน 16px) เยื้องรายละเอียด/แถบล่าง `ml-12` · `md` (ค่าเริ่มต้น) = 44px / `ml-14` เหมือนเดิม · ใช้คู่ `iconTile={false}` ได้เพื่อตั้งระยะเยื้องให้ตรงไอคอนของแอปเอง
|
|
20
|
+
- showcase: `/components/record-card` เพิ่มการ์ดแบบ `iconSize="sm"`
|
|
21
|
+
|
|
22
|
+
## [0.44.0] — 2026-10-08
|
|
23
|
+
|
|
24
|
+
> ประกาศพร้อม publish แล้ว แต่ยังไม่ขึ้น npm — รวมอยู่ใน 0.45.0
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
### Added
|
|
28
|
+
|
|
29
|
+
- **`Button tone`** (สเปกจาก DueBell — `ToneButton`): ปุ่มขอบโครงเดียวกัน ต่างแค่สีตามความหมายของรายการ — `income` เงินเข้า/รับเงิน · `expense` เงินออก/จ่าย · `reminder` รายการเตือน (ม่วง) · `warning` ย้อนกลับได้ (พักรายการ, รอยืนยัน) · `revert` เลิกทำ/ย้อนสถานะ (สีเดียวกับ expense) — ลบถาวรยังเป็น `variant="destructive"` · type `ButtonTone`
|
|
30
|
+
- token `--tone-{income,expense,reminder,warning}` / `-border` / `-hover` ทั้งสองธีม — **แอป override เป็นสีประเภทรายการของตัวเองได้** (ค่าเริ่มต้นผูกกับตระกูล success/destructive/warning + ม่วงใหม่) · วัดบนเบราว์เซอร์แล้ว: ตัวอักษร 7.1–8.4:1 (สว่าง) / 12.5–16:1 (มืด), ขอบ 3.2–3.8:1 / 3.4–4.0:1
|
|
31
|
+
- showcase: `/components/button` เพิ่มแถวปุ่มโทน
|
|
32
|
+
|
|
33
|
+
### Changed
|
|
34
|
+
|
|
35
|
+
- `RecordCard` กรอบไอคอน: ขอบ `color-mix(accent 25%)` (เดิม 35%) ให้ตรงสูตร `CategoryIcon` ของ DueBell (พื้น 12% / ขอบ 25%) — แอปส่งไอคอนเปล่าได้เลย
|
|
36
|
+
|
|
37
|
+
## [0.43.0] — 2026-10-08
|
|
38
|
+
|
|
39
|
+
> ประกาศพร้อม publish แล้ว แต่ยังไม่ขึ้น npm — รวมอยู่ใน 0.44.0
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
### Added
|
|
43
|
+
|
|
44
|
+
- **`RecordCard` — การ์ดรายการมาตรฐาน** (ยกจาก `LedgerCard` ของ DueBell ที่ใช้ 3 ที่: รายการบิล, การ์ดแค็ตตาล็อก, ศูนย์แจ้งเตือน): หัว = ไอคอน + ชื่อ/สถานะ + meta → รายละเอียด → แถบล่าง (`footerSummary` ซ้าย, `actions` ขวา) โดยรายละเอียดและแถบล่างเยื้องตรงคอลัมน์ข้อความ · `accent` รับ tone (`neutral/info/success/warning/danger` + ชื่อเดิม `default/destructive`) **หรือสี CSS ตรง ๆ** (`var(--type-income)`) → ขอบซ้าย 3px + ขอบจาง 30% + พื้นไล่สี 11% + กรอบไอคอนตามสี · ชื่อยาวตัดที่ 2 บรรทัด ไม่ดันป้ายสถานะตก · `unread`, `onSelect` (ปุ่มในแถบล่างไม่ไปโดน), `as="li"`, `titleAs` · `recordAccentColor()` · `UiText.record.details`
|
|
45
|
+
|
|
46
|
+
### Changed
|
|
47
|
+
|
|
48
|
+
- **`NotificationCenter` ประกอบจาก `RecordCard` + `StatusBadge`** (DueBell: แถวเดิมเขียนเลย์เอาต์ซ้ำกับการ์ดของแอปและเป็น private) — เพิ่มอย่างเดียว ของเดิมใช้ต่อได้:
|
|
49
|
+
- แถวคือ `RecordCard` · `NotificationEntry.accent` (สี CSS สำหรับสีของประเภทรายการ เช่น รายรับ/รายจ่าย/เตือน) + `details`
|
|
50
|
+
- `renderItem(item, { busy })` เรนเดอร์แถวเองได้
|
|
51
|
+
- ชิปนับเป็น `StatusBadge` (ชิปทั้งระบบชุดเดียว) · tone รับ `danger`/`neutral` แบบ `StatusBadge` (`destructive`/`default` ยังได้)
|
|
52
|
+
- `NotificationAction.render` ใส่ปุ่มของแอปเองได้ (เช่น `ToneButton`) · `onSelect` ไม่บังคับเมื่อใช้ `render`
|
|
53
|
+
- `headingAs` / `headingClassName` (หัวข้อแบบ label ตัวเล็กของแอป)
|
|
54
|
+
|
|
55
|
+
### Fixed
|
|
56
|
+
|
|
57
|
+
- **`@suphark/ui/sortable` ลากกลุ่มแล้วกลุ่มอื่นไม่เลื่อนหลบ เมื่อแอปแปะ ref ของกลุ่มที่ `<tbody>` ทั้งก้อน** (Pi Config รายงานหลัง bump 0.42.1): ตัดสินก่อน/หลังจากกลางของสิ่งที่ลาก — ก้อน `<tbody>` สูงจนกลางไม่เคยเลยครึ่งของกลุ่มเป้าหมาย → ได้ "ที่เดิม" เสมอ · ตอนนี้ใช้ตำแหน่งเมาส์ · และแถวที่อยู่ใน node ของกลุ่มเองไม่ได้ transform ซ้ำอีกชั้น (เดิมเลื่อนสองเท่า) และไม่ถูกนับซ้ำตอนวัดขนาดก้อน
|
|
58
|
+
- showcase: `/components/record-card` ใหม่ · `/components/notification-center` เพิ่มรายการเตือนสีม่วง (accent สี CSS) + รายละเอียด · `/components/sortable-list` เพิ่มตัวอย่างโครง `<tbody>` ต่อกลุ่ม (แบบ Pi Config)
|
|
59
|
+
|
|
6
60
|
|
|
7
61
|
### Fixed
|
|
8
62
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@suphark/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.45.1",
|
|
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 เอง",
|
|
@@ -80,7 +80,12 @@ export function AuthLayout({
|
|
|
80
80
|
<div className="flex flex-col gap-6 px-6 py-8 sm:px-8">
|
|
81
81
|
<header className="flex flex-col items-center gap-3 text-center">
|
|
82
82
|
{logo && (
|
|
83
|
-
<
|
|
83
|
+
// รูป (<img>) สูง 44px กว้างตามสัดส่วน (wordmark แนวนอนของลูกค้า เช่น 315×70 อ่านออก — เดิมถูกบีบใน 44×44 เหลือสูง ~10px)
|
|
84
|
+
// ไอคอน/ตัวอักษร (svg, span) ยังเป็นกรอบจัตุรัสมนเหมือนเดิม — เลือกเองตามชนิดลูก แอปไม่ต้องรู้สัดส่วนรูป
|
|
85
|
+
<div
|
|
86
|
+
data-slot="auth-logo"
|
|
87
|
+
className="flex h-11 items-center justify-center not-has-[img]:w-11 not-has-[img]:overflow-hidden not-has-[img]:rounded-xl [&_img]:h-full [&_img]:w-auto [&_img]:max-w-[220px] [&_img]:object-contain [&_svg]:size-6"
|
|
88
|
+
>
|
|
84
89
|
{logo}
|
|
85
90
|
</div>
|
|
86
91
|
)}
|
|
@@ -17,6 +17,30 @@ import { useFormStatus } from "react-dom";
|
|
|
17
17
|
|
|
18
18
|
export type ButtonTooltipSide = "top" | "bottom" | "left" | "right";
|
|
19
19
|
|
|
20
|
+
/**
|
|
21
|
+
* โทนของปุ่มตามความหมายของรายการ (ปุ่มขอบโครงเดียวกัน ต่างแค่สี) — สีอ่านจาก CSS var `--tone-*`
|
|
22
|
+
* ที่แอป override เป็นสีประเภทรายการของตัวเองได้ (`--tone-income`, `--tone-income-border`, `--tone-income-hover`)
|
|
23
|
+
* - `income` เงินเข้า/รับเงิน · `expense` เงินออก/จ่าย · `reminder` รายการเตือน (ม่วง)
|
|
24
|
+
* - `warning` ย้อนกลับได้ (พักรายการ, รอยืนยัน) · `revert` เลิกทำ/ย้อนสถานะ (สีเดียวกับ expense)
|
|
25
|
+
* ลบถาวรยังใช้ `variant="destructive"` — คนละความหมายกับ `revert`
|
|
26
|
+
*/
|
|
27
|
+
export type ButtonTone = "income" | "expense" | "reminder" | "warning" | "revert";
|
|
28
|
+
|
|
29
|
+
// variant outline มี dark:border-input / dark:hover:bg-input/50 — ต้องใส่ dark: ซ้ำไม่งั้นธีมมืดขอบ/พื้นถูกทับ
|
|
30
|
+
// (เขียนเต็มทุกโทน: Tailwind ต้องเห็นชื่อคลาสครบในซอร์ส)
|
|
31
|
+
const TONE_CLASS: Record<ButtonTone, string> = {
|
|
32
|
+
income:
|
|
33
|
+
"border-(color:--tone-income-border) text-(color:--tone-income) hover:border-(color:--tone-income) hover:bg-(--tone-income-hover) hover:text-(color:--tone-income) dark:border-(color:--tone-income-border) dark:hover:border-(color:--tone-income) dark:hover:bg-(--tone-income-hover)",
|
|
34
|
+
expense:
|
|
35
|
+
"border-(color:--tone-expense-border) text-(color:--tone-expense) hover:border-(color:--tone-expense) hover:bg-(--tone-expense-hover) hover:text-(color:--tone-expense) dark:border-(color:--tone-expense-border) dark:hover:border-(color:--tone-expense) dark:hover:bg-(--tone-expense-hover)",
|
|
36
|
+
revert:
|
|
37
|
+
"border-(color:--tone-expense-border) text-(color:--tone-expense) hover:border-(color:--tone-expense) hover:bg-(--tone-expense-hover) hover:text-(color:--tone-expense) dark:border-(color:--tone-expense-border) dark:hover:border-(color:--tone-expense) dark:hover:bg-(--tone-expense-hover)",
|
|
38
|
+
reminder:
|
|
39
|
+
"border-(color:--tone-reminder-border) text-(color:--tone-reminder) hover:border-(color:--tone-reminder) hover:bg-(--tone-reminder-hover) hover:text-(color:--tone-reminder) dark:border-(color:--tone-reminder-border) dark:hover:border-(color:--tone-reminder) dark:hover:bg-(--tone-reminder-hover)",
|
|
40
|
+
warning:
|
|
41
|
+
"border-(color:--tone-warning-border) text-(color:--tone-warning) hover:border-(color:--tone-warning) hover:bg-(--tone-warning-hover) hover:text-(color:--tone-warning) dark:border-(color:--tone-warning-border) dark:hover:border-(color:--tone-warning) dark:hover:bg-(--tone-warning-hover)",
|
|
42
|
+
};
|
|
43
|
+
|
|
20
44
|
export interface ButtonProps extends ComponentProps<typeof ButtonBase> {
|
|
21
45
|
/**
|
|
22
46
|
* ข้อความ tooltip — ไม่ใส่ = ใช้ `aria-label` (หรือ `<span className="sr-only">` แบบ shadcn)
|
|
@@ -33,6 +57,8 @@ export interface ButtonProps extends ComponentProps<typeof ButtonBase> {
|
|
|
33
57
|
* (ทุกปุ่ม submit ในฟอร์มนั้นหมุนพร้อมกัน) · `false` = ปิดการตรวจอัตโนมัติ
|
|
34
58
|
*/
|
|
35
59
|
loading?: boolean;
|
|
60
|
+
/** ปุ่มขอบตามความหมายของรายการ (`income`/`expense`/`reminder`/`warning`/`revert`) — ใส่แล้วไม่ต้องส่ง `variant` */
|
|
61
|
+
tone?: ButtonTone;
|
|
36
62
|
}
|
|
37
63
|
|
|
38
64
|
const NoAutoTooltip = createContext(false);
|
|
@@ -136,8 +162,16 @@ export function Button({
|
|
|
136
162
|
tooltipSide = "top",
|
|
137
163
|
tooltipDelay,
|
|
138
164
|
loading,
|
|
139
|
-
|
|
165
|
+
tone,
|
|
166
|
+
...input
|
|
140
167
|
}: ButtonProps) {
|
|
168
|
+
const rest: ComponentProps<typeof ButtonBase> = tone
|
|
169
|
+
? {
|
|
170
|
+
...input,
|
|
171
|
+
variant: "outline",
|
|
172
|
+
className: cn("bg-card shadow-none dark:bg-transparent", TONE_CLASS[tone], input.className),
|
|
173
|
+
}
|
|
174
|
+
: input;
|
|
141
175
|
const suppressed = useContext(NoAutoTooltip);
|
|
142
176
|
// นอก <form> หรือฟอร์มที่ไม่ได้ใช้ `action=` → pending เป็น false เสมอ
|
|
143
177
|
const form = useFormStatus();
|
|
@@ -2,45 +2,44 @@
|
|
|
2
2
|
|
|
3
3
|
import { ActionMenu } from "@suphark/ui/components/shared/action-menu";
|
|
4
4
|
import { Button } from "@suphark/ui/components/shared/button/button";
|
|
5
|
+
import {
|
|
6
|
+
type RecordAccent,
|
|
7
|
+
RecordCard,
|
|
8
|
+
type RecordTone,
|
|
9
|
+
} from "@suphark/ui/components/shared/record-card";
|
|
10
|
+
import { StatusBadge, type Tone } from "@suphark/ui/components/shared/status-badge";
|
|
5
11
|
import { useUiText } from "@suphark/ui/components/shared/ui-text";
|
|
6
12
|
import { cn } from "@suphark/ui/lib/utils";
|
|
7
13
|
import { BellIcon, ChevronRightIcon } from "lucide-react";
|
|
8
|
-
import type
|
|
14
|
+
import { Fragment, type ReactNode } from "react";
|
|
9
15
|
|
|
10
|
-
|
|
16
|
+
/** ชื่อเดียวกับ `StatusBadge` (`neutral`/`danger`) ได้ และชื่อเดิม (`default`/`destructive`) ยังใช้ได้ */
|
|
17
|
+
export type NotificationTone = RecordTone;
|
|
11
18
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
default: "bg-muted text-muted-foreground",
|
|
22
|
-
info: "bg-info-subtle text-info-subtle-foreground",
|
|
23
|
-
success: "bg-success-subtle text-success-subtle-foreground",
|
|
24
|
-
warning: "bg-warning-subtle text-warning-subtle-foreground",
|
|
25
|
-
destructive: "bg-destructive-subtle text-destructive-subtle-foreground",
|
|
26
|
-
};
|
|
27
|
-
|
|
28
|
-
const CHIP_TONE: Record<NotificationTone, string> = {
|
|
29
|
-
default: "border-border bg-muted text-muted-foreground",
|
|
30
|
-
info: "border-info-border bg-info-subtle text-info-subtle-foreground",
|
|
31
|
-
success: "border-success-border bg-success-subtle text-success-subtle-foreground",
|
|
32
|
-
warning: "border-warning-border bg-warning-subtle text-warning-subtle-foreground",
|
|
33
|
-
destructive: "border-destructive-border bg-destructive-subtle text-destructive-subtle-foreground",
|
|
19
|
+
/** tone ของการแจ้งเตือน → tone ของ `StatusBadge` (ชิปทั้งระบบเป็นชุดเดียว) */
|
|
20
|
+
const BADGE_TONE: Record<NotificationTone, Tone> = {
|
|
21
|
+
neutral: "neutral",
|
|
22
|
+
default: "neutral",
|
|
23
|
+
info: "info",
|
|
24
|
+
success: "success",
|
|
25
|
+
warning: "warning",
|
|
26
|
+
danger: "danger",
|
|
27
|
+
destructive: "danger",
|
|
34
28
|
};
|
|
35
29
|
|
|
36
30
|
const ACTION_TONE: Record<NotificationTone, string> = {
|
|
31
|
+
neutral: "",
|
|
37
32
|
default: "",
|
|
38
33
|
info: "text-info-subtle-foreground",
|
|
39
34
|
success: "text-success-subtle-foreground",
|
|
40
35
|
warning: "text-warning-subtle-foreground",
|
|
36
|
+
danger: "text-destructive",
|
|
41
37
|
destructive: "text-destructive",
|
|
42
38
|
};
|
|
43
39
|
|
|
40
|
+
const isDanger = (tone: NotificationTone | undefined) =>
|
|
41
|
+
tone === "danger" || tone === "destructive";
|
|
42
|
+
|
|
44
43
|
export interface NotificationChip {
|
|
45
44
|
key: string;
|
|
46
45
|
label: ReactNode;
|
|
@@ -54,12 +53,18 @@ export interface NotificationChip {
|
|
|
54
53
|
export interface NotificationAction {
|
|
55
54
|
key: string;
|
|
56
55
|
label: string;
|
|
57
|
-
|
|
56
|
+
/** ไม่บังคับเมื่อส่ง `render` */
|
|
57
|
+
onSelect?: () => void;
|
|
58
58
|
icon?: ReactNode;
|
|
59
59
|
tone?: NotificationTone;
|
|
60
60
|
/** ปุ่มหลักของแถว (เด่นกว่าตัวอื่น) — ปกติมีตัวเดียว */
|
|
61
61
|
primary?: boolean;
|
|
62
62
|
disabled?: boolean;
|
|
63
|
+
/**
|
|
64
|
+
* ปุ่มของแอปเอง (เช่น `<ToneButton tone="income">`) แทนปุ่มมาตรฐาน — ใช้ได้เฉพาะ `actions`
|
|
65
|
+
* (เมนู "…" ใช้ `label`/`onSelect`) · แอปคุม disabled ตอน `busy` เอง
|
|
66
|
+
*/
|
|
67
|
+
render?: ReactNode;
|
|
63
68
|
}
|
|
64
69
|
|
|
65
70
|
export interface NotificationEntry {
|
|
@@ -68,6 +73,11 @@ export interface NotificationEntry {
|
|
|
68
73
|
description?: ReactNode;
|
|
69
74
|
icon?: ReactNode;
|
|
70
75
|
tone?: NotificationTone;
|
|
76
|
+
/**
|
|
77
|
+
* สีประจำแถวแบบสี CSS ตรง ๆ (`"var(--type-reminder)"`) — สำหรับสีที่สื่อ "ประเภทรายการ" ของแอป
|
|
78
|
+
* (รายรับ/รายจ่าย/เตือน) ไม่ใช่สถานะ · ไม่ส่ง = ตาม `tone`
|
|
79
|
+
*/
|
|
80
|
+
accent?: RecordAccent;
|
|
71
81
|
/**
|
|
72
82
|
* สถานะ/กำหนดเวลาที่ **แอปฟอร์แมตมาแล้ว** (เช่น `<DueDateBadge>` หรือ "23 ก.ย. · พรุ่งนี้")
|
|
73
83
|
* แพ็กเกจไม่ฟอร์แมตวันที่เอง เพราะปี พ.ศ./ภาษาเป็นของแอป
|
|
@@ -75,7 +85,9 @@ export interface NotificationEntry {
|
|
|
75
85
|
status?: ReactNode;
|
|
76
86
|
/** บรรทัดเล็กใต้ชื่อ เช่น กลุ่ม · หมวดหมู่ */
|
|
77
87
|
meta?: ReactNode;
|
|
78
|
-
/**
|
|
88
|
+
/** ส่วนรายละเอียด (ฟิลด์ของโดเมน) — มีเส้นคั่น เยื้องตรงคอลัมน์ข้อความ */
|
|
89
|
+
details?: ReactNode;
|
|
90
|
+
/** ซ้ายของแถบล่าง — เรื่องเฉพาะโดเมน เช่นยอดเงิน (ซ่อนได้) หรือความคืบหน้ารอบ */
|
|
79
91
|
footer?: ReactNode;
|
|
80
92
|
/** ยังไม่ได้อ่าน — จุดหน้าชื่อ + พื้นจาง */
|
|
81
93
|
unread?: boolean;
|
|
@@ -93,9 +105,14 @@ export interface NotificationCenterProps {
|
|
|
93
105
|
* แล้วส่ง `viewAll` เพื่อพาไปหน้ารวม (ตัวเลขใน `counts` จึงตรงกับสิ่งที่ผู้ใช้เห็นเสมอ)
|
|
94
106
|
*/
|
|
95
107
|
items: ReadonlyArray<NotificationEntry>;
|
|
96
|
-
/** ชิปนับด้านบน — กี่กลุ่มก็ได้ (เกินกำหนด / รอตรวจสอบ / ใกล้ถึงกำหนด
|
|
108
|
+
/** ชิปนับด้านบน (`StatusBadge`) — กี่กลุ่มก็ได้ (เกินกำหนด / รอตรวจสอบ / ใกล้ถึงกำหนด …) */
|
|
97
109
|
counts?: ReadonlyArray<NotificationChip>;
|
|
110
|
+
/** หัวข้อ — ส่ง node ได้ (เช่น label ตัวเล็กตัวพิมพ์ใหญ่ + ไอคอน แบบหัวกลุ่มของแอป) */
|
|
98
111
|
title?: ReactNode;
|
|
112
|
+
/** แท็กของหัวข้อ (ค่าเริ่มต้น h2) */
|
|
113
|
+
headingAs?: "h2" | "h3" | "h4" | "p";
|
|
114
|
+
/** ทับสไตล์หัวข้อ (ค่าเริ่มต้น `font-semibold text-sm`) */
|
|
115
|
+
headingClassName?: string;
|
|
99
116
|
description?: ReactNode;
|
|
100
117
|
/** ไม่มีรายการ — ส่ง element เองได้ทั้งก้อน */
|
|
101
118
|
empty?: ReactNode;
|
|
@@ -103,17 +120,22 @@ export interface NotificationCenterProps {
|
|
|
103
120
|
viewAll?: { label?: ReactNode; onSelect: () => void };
|
|
104
121
|
/** กำลังทำคำสั่งลัดอยู่ — ปิดปุ่มทั้งหมดกันกดซ้ำ */
|
|
105
122
|
busy?: boolean;
|
|
123
|
+
/**
|
|
124
|
+
* เรนเดอร์แถวเอง (การ์ดของแอป) — ปกติไม่ต้องใช้: แถวมาตรฐานคือ `RecordCard` ตัวเดียวกับที่แอปใช้ทำรายการ
|
|
125
|
+
* อยู่แล้ว · คืน node ที่จะอยู่ใน `<li>`
|
|
126
|
+
*/
|
|
127
|
+
renderItem?: (item: NotificationEntry, context: { busy: boolean }) => ReactNode;
|
|
106
128
|
className?: string;
|
|
107
129
|
}
|
|
108
130
|
|
|
109
131
|
/**
|
|
110
|
-
* ศูนย์การแจ้งเตือน: ชิปนับ +
|
|
132
|
+
* ศูนย์การแจ้งเตือน: หัวข้อ + ชิปนับ (`StatusBadge`) + รายการ (`RecordCard`) พร้อมปุ่มลัดต่อรายการ (ยกจาก DueBell)
|
|
111
133
|
* ทุกอย่างที่เป็นของโดเมน — วันที่/สถานะ/ยอดเงิน/ความคืบหน้า — ส่งเข้ามาเป็น node ที่แอปเตรียมแล้ว
|
|
112
134
|
* วางในหน้าได้ตรง ๆ หรือใส่ใน `SimpleDialog`/`SimpleSheet` เป็นกล่องแจ้งเตือนก็ได้
|
|
113
135
|
*
|
|
114
136
|
* <NotificationCenter
|
|
115
|
-
* counts={[{ key: "overdue", label: "เกินกำหนด", count: 3, tone: "
|
|
116
|
-
* items={bills.map((b) => ({ id: b.id, title: b.title, status: <DueDateBadge … />,
|
|
137
|
+
* counts={[{ key: "overdue", label: "เกินกำหนด", count: 3, tone: "danger" }]}
|
|
138
|
+
* items={bills.map((b) => ({ id: b.id, title: b.title, accent: typeAccent(b.type), status: <DueDateBadge … />,
|
|
117
139
|
* footer: <Amount value={b.amount} />, actions: [{ key: "pay", label: "จ่ายเลย", primary: true, onSelect: … }] }))}
|
|
118
140
|
* viewAll={{ onSelect: () => router.push("/alerts") }} />
|
|
119
141
|
*/
|
|
@@ -121,10 +143,13 @@ export function NotificationCenter({
|
|
|
121
143
|
items,
|
|
122
144
|
counts,
|
|
123
145
|
title,
|
|
146
|
+
headingAs: Heading = "h2",
|
|
147
|
+
headingClassName,
|
|
124
148
|
description,
|
|
125
149
|
empty,
|
|
126
150
|
viewAll,
|
|
127
151
|
busy = false,
|
|
152
|
+
renderItem,
|
|
128
153
|
className,
|
|
129
154
|
}: NotificationCenterProps) {
|
|
130
155
|
const text = useUiText();
|
|
@@ -138,39 +163,13 @@ export function NotificationCenter({
|
|
|
138
163
|
>
|
|
139
164
|
<div className="flex flex-wrap items-start justify-between gap-2">
|
|
140
165
|
<div className="flex min-w-0 flex-col gap-1.5">
|
|
141
|
-
<
|
|
166
|
+
<Heading className={cn("font-semibold text-sm", headingClassName)}>{heading}</Heading>
|
|
142
167
|
{description && <p className="text-muted-foreground text-xs">{description}</p>}
|
|
143
168
|
{counts && counts.length > 0 && (
|
|
144
169
|
<div className="flex flex-wrap items-center gap-1.5">
|
|
145
|
-
{counts.map((chip) =>
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
<>
|
|
149
|
-
{chip.label}
|
|
150
|
-
<span className="font-semibold tabular-nums">{chip.count}</span>
|
|
151
|
-
</>
|
|
152
|
-
);
|
|
153
|
-
const shared = cn(
|
|
154
|
-
"inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-label",
|
|
155
|
-
CHIP_TONE[tone],
|
|
156
|
-
chip.active && "ring-2 ring-ring/40",
|
|
157
|
-
);
|
|
158
|
-
return chip.onSelect ? (
|
|
159
|
-
<button
|
|
160
|
-
key={chip.key}
|
|
161
|
-
type="button"
|
|
162
|
-
aria-pressed={chip.active ?? false}
|
|
163
|
-
onClick={chip.onSelect}
|
|
164
|
-
className={cn(shared, "transition-colors hover:brightness-95")}
|
|
165
|
-
>
|
|
166
|
-
{content}
|
|
167
|
-
</button>
|
|
168
|
-
) : (
|
|
169
|
-
<span key={chip.key} className={shared}>
|
|
170
|
-
{content}
|
|
171
|
-
</span>
|
|
172
|
-
);
|
|
173
|
-
})}
|
|
170
|
+
{counts.map((chip) => (
|
|
171
|
+
<CountChip key={chip.key} chip={chip} />
|
|
172
|
+
))}
|
|
174
173
|
</div>
|
|
175
174
|
)}
|
|
176
175
|
</div>
|
|
@@ -193,105 +192,97 @@ export function NotificationCenter({
|
|
|
193
192
|
))
|
|
194
193
|
) : (
|
|
195
194
|
<ul className="flex flex-col gap-2">
|
|
196
|
-
{items.map((item) =>
|
|
197
|
-
|
|
198
|
-
|
|
195
|
+
{items.map((item) =>
|
|
196
|
+
renderItem ? (
|
|
197
|
+
<li key={item.id} data-slot="notification-row" className="min-w-0">
|
|
198
|
+
{renderItem(item, { busy })}
|
|
199
|
+
</li>
|
|
200
|
+
) : (
|
|
201
|
+
<NotificationRow key={item.id} item={item} busy={busy} />
|
|
202
|
+
),
|
|
203
|
+
)}
|
|
199
204
|
</ul>
|
|
200
205
|
)}
|
|
201
206
|
</section>
|
|
202
207
|
);
|
|
203
208
|
}
|
|
204
209
|
|
|
205
|
-
function
|
|
206
|
-
const
|
|
207
|
-
|
|
208
|
-
|
|
210
|
+
function CountChip({ chip }: { chip: NotificationChip }) {
|
|
211
|
+
const content = (
|
|
212
|
+
<>
|
|
213
|
+
{chip.label}
|
|
214
|
+
<span className="font-semibold tabular-nums">{chip.count}</span>
|
|
215
|
+
</>
|
|
216
|
+
);
|
|
217
|
+
const tone = BADGE_TONE[chip.tone ?? "neutral"];
|
|
218
|
+
if (!chip.onSelect) return <StatusBadge tone={tone}>{content}</StatusBadge>;
|
|
209
219
|
return (
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
data-unread={item.unread || undefined}
|
|
214
|
-
onClick={item.onSelect}
|
|
220
|
+
<StatusBadge
|
|
221
|
+
tone={tone}
|
|
222
|
+
render={<button type="button" aria-pressed={chip.active ?? false} onClick={chip.onSelect} />}
|
|
215
223
|
className={cn(
|
|
216
|
-
"
|
|
217
|
-
|
|
218
|
-
item.unread && "bg-muted/40",
|
|
219
|
-
clickable && "cursor-pointer transition-colors hover:bg-muted/60",
|
|
224
|
+
"cursor-pointer transition-[filter] hover:brightness-95",
|
|
225
|
+
chip.active && "ring-2 ring-ring/40",
|
|
220
226
|
)}
|
|
221
227
|
>
|
|
222
|
-
{
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
228
|
+
{content}
|
|
229
|
+
</StatusBadge>
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function NotificationRow({ item, busy }: { item: NotificationEntry; busy: boolean }) {
|
|
234
|
+
const text = useUiText();
|
|
235
|
+
const hasActions = Boolean(item.actions?.length || item.menuActions?.length);
|
|
236
|
+
return (
|
|
237
|
+
<RecordCard
|
|
238
|
+
as="li"
|
|
239
|
+
accent={item.accent ?? item.tone}
|
|
240
|
+
icon={item.icon}
|
|
241
|
+
title={item.title}
|
|
242
|
+
status={item.status}
|
|
243
|
+
meta={item.meta}
|
|
244
|
+
description={item.description}
|
|
245
|
+
details={item.details}
|
|
246
|
+
footerSummary={item.footer}
|
|
247
|
+
unread={item.unread}
|
|
248
|
+
onSelect={item.onSelect}
|
|
249
|
+
actions={
|
|
250
|
+
hasActions ? (
|
|
251
|
+
<>
|
|
252
|
+
{item.actions?.map((action) =>
|
|
253
|
+
action.render != null ? (
|
|
254
|
+
<Fragment key={action.key}>{action.render}</Fragment>
|
|
255
|
+
) : (
|
|
256
|
+
<Button
|
|
257
|
+
key={action.key}
|
|
258
|
+
size="sm"
|
|
259
|
+
variant={
|
|
260
|
+
action.primary ? (isDanger(action.tone) ? "destructive" : "default") : "outline"
|
|
261
|
+
}
|
|
262
|
+
disabled={busy || action.disabled}
|
|
263
|
+
onClick={action.onSelect}
|
|
264
|
+
className={cn(!action.primary && ACTION_TONE[action.tone ?? "neutral"])}
|
|
265
|
+
>
|
|
266
|
+
{action.icon}
|
|
267
|
+
{action.label}
|
|
268
|
+
</Button>
|
|
269
|
+
),
|
|
237
270
|
)}
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
<div className="flex flex-wrap items-end justify-between gap-2 pt-0.5">
|
|
250
|
-
<div className="min-w-0">{item.footer}</div>
|
|
251
|
-
{(item.actions?.length || item.menuActions?.length) && (
|
|
252
|
-
// คลิกปุ่มไม่ให้ไปโดน onSelect ของทั้งแถว
|
|
253
|
-
// biome-ignore lint/a11y/noStaticElementInteractions: ตัวกั้นคลิกเฉย ๆ ไม่ใช่ตัวควบคุม
|
|
254
|
-
// biome-ignore lint/a11y/useKeyWithClickEvents: คีย์บอร์ดกดปุ่มข้างในตรง ๆ อยู่แล้ว
|
|
255
|
-
<div
|
|
256
|
-
className="flex shrink-0 flex-wrap items-center gap-1.5"
|
|
257
|
-
onClick={(event) => event.stopPropagation()}
|
|
258
|
-
>
|
|
259
|
-
{item.actions?.map((action) => (
|
|
260
|
-
<Button
|
|
261
|
-
key={action.key}
|
|
262
|
-
size="sm"
|
|
263
|
-
variant={
|
|
264
|
-
action.primary
|
|
265
|
-
? action.tone === "destructive"
|
|
266
|
-
? "destructive"
|
|
267
|
-
: "default"
|
|
268
|
-
: "outline"
|
|
269
|
-
}
|
|
270
|
-
disabled={busy || action.disabled}
|
|
271
|
-
onClick={action.onSelect}
|
|
272
|
-
className={cn(!action.primary && ACTION_TONE[action.tone ?? "default"])}
|
|
273
|
-
>
|
|
274
|
-
{action.icon}
|
|
275
|
-
{action.label}
|
|
276
|
-
</Button>
|
|
277
|
-
))}
|
|
278
|
-
{item.menuActions && item.menuActions.length > 0 && (
|
|
279
|
-
<ActionMenu
|
|
280
|
-
label={text.actions}
|
|
281
|
-
items={item.menuActions.map((action) => ({
|
|
282
|
-
label: action.label,
|
|
283
|
-
icon: action.icon,
|
|
284
|
-
destructive: action.tone === "destructive",
|
|
285
|
-
disabled: busy || action.disabled,
|
|
286
|
-
onClick: action.onSelect,
|
|
287
|
-
}))}
|
|
288
|
-
/>
|
|
289
|
-
)}
|
|
290
|
-
</div>
|
|
271
|
+
{item.menuActions && item.menuActions.length > 0 && (
|
|
272
|
+
<ActionMenu
|
|
273
|
+
label={text.actions}
|
|
274
|
+
items={item.menuActions.map((action) => ({
|
|
275
|
+
label: action.label,
|
|
276
|
+
icon: action.icon,
|
|
277
|
+
destructive: isDanger(action.tone),
|
|
278
|
+
disabled: busy || action.disabled,
|
|
279
|
+
onClick: () => action.onSelect?.(),
|
|
280
|
+
}))}
|
|
281
|
+
/>
|
|
291
282
|
)}
|
|
292
|
-
|
|
293
|
-
)
|
|
294
|
-
|
|
295
|
-
|
|
283
|
+
</>
|
|
284
|
+
) : undefined
|
|
285
|
+
}
|
|
286
|
+
/>
|
|
296
287
|
);
|
|
297
288
|
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useUiText } from "@suphark/ui/components/shared/ui-text";
|
|
4
|
+
import { cn } from "@suphark/ui/lib/utils";
|
|
5
|
+
import type { CSSProperties, KeyboardEvent, MouseEvent, ReactNode } from "react";
|
|
6
|
+
|
|
7
|
+
/** สีตามความหมาย — หรือส่งสี CSS ตรง ๆ (`"var(--type-income)"`, `"oklch(…)"`) สำหรับสีของประเภทรายการในแอป */
|
|
8
|
+
export type RecordTone =
|
|
9
|
+
| "neutral"
|
|
10
|
+
| "default"
|
|
11
|
+
| "info"
|
|
12
|
+
| "success"
|
|
13
|
+
| "warning"
|
|
14
|
+
| "danger"
|
|
15
|
+
| "destructive";
|
|
16
|
+
export type RecordAccent = RecordTone | (string & {});
|
|
17
|
+
|
|
18
|
+
const TONE_VAR: Record<RecordTone, string> = {
|
|
19
|
+
neutral: "var(--border)",
|
|
20
|
+
default: "var(--border)",
|
|
21
|
+
info: "var(--info)",
|
|
22
|
+
success: "var(--success)",
|
|
23
|
+
warning: "var(--warning)",
|
|
24
|
+
danger: "var(--destructive)",
|
|
25
|
+
destructive: "var(--destructive)",
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/** tone → สี CSS (ชื่อที่ไม่รู้จัก = ถือเป็นสี CSS อยู่แล้ว) */
|
|
29
|
+
export function recordAccentColor(accent: RecordAccent | undefined): string | undefined {
|
|
30
|
+
if (!accent) return undefined;
|
|
31
|
+
return (TONE_VAR as Record<string, string>)[accent] ?? accent;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface RecordCardProps {
|
|
35
|
+
/** สีประจำการ์ด: ขอบซ้าย 3px + ขอบจาง ๆ + พื้นไล่สีจากซ้าย — tone หรือสี CSS (`var(--type-income)`) */
|
|
36
|
+
accent?: RecordAccent;
|
|
37
|
+
/** ไอคอน — ใส่ในกรอบสี่เหลี่ยมมนตามสี accent ให้เอง (`iconTile={false}` = วางตามที่ส่งมา) */
|
|
38
|
+
icon?: ReactNode;
|
|
39
|
+
iconTile?: boolean;
|
|
40
|
+
/**
|
|
41
|
+
* ขนาดคอลัมน์ไอคอน: `md` (ค่าเริ่มต้น) กรอบ 44px เยื้อง ml-14 · `sm` กรอบ 36px เยื้อง ml-12 สำหรับโหมดกะทัดรัด
|
|
42
|
+
* ใช้กับ `iconTile={false}` ได้ด้วย (กำหนดระยะเยื้องให้ตรงไอคอนของแอปเอง)
|
|
43
|
+
*/
|
|
44
|
+
iconSize?: "sm" | "md";
|
|
45
|
+
title: ReactNode;
|
|
46
|
+
/** ชื่อยาวตัดที่ 2 บรรทัด — ทับคลาสได้ */
|
|
47
|
+
titleClassName?: string;
|
|
48
|
+
/** แท็กของชื่อ (ค่าเริ่มต้น h3) — ให้ตรงกับลำดับหัวข้อของหน้า */
|
|
49
|
+
titleAs?: "h2" | "h3" | "h4" | "p";
|
|
50
|
+
/** มุมขวาบน เช่น `StatusBadge` / วันครบกำหนดที่แอปฟอร์แมตแล้ว */
|
|
51
|
+
status?: ReactNode;
|
|
52
|
+
/** บรรทัดเล็กใต้ชื่อ (หมวดหมู่ · กลุ่ม · รอบ) */
|
|
53
|
+
meta?: ReactNode;
|
|
54
|
+
/** ข้อความอธิบายใต้ meta */
|
|
55
|
+
description?: ReactNode;
|
|
56
|
+
/** ส่วนรายละเอียด (ฟิลด์ของโดเมน) — มีเส้นคั่นบน เยื้องตรงคอลัมน์ข้อความ */
|
|
57
|
+
details?: ReactNode;
|
|
58
|
+
/** ชื่อของส่วนรายละเอียดสำหรับ screen reader (ไม่ส่ง = `UiText.record.details`) */
|
|
59
|
+
detailsLabel?: string;
|
|
60
|
+
/** ซ้ายของแถบล่าง เช่นยอดเงิน / ความคืบหน้ารอบ (สูงกว่า 1 บรรทัดได้) */
|
|
61
|
+
footerSummary?: ReactNode;
|
|
62
|
+
/** ขวาของแถบล่าง — ปุ่ม (คลิกปุ่มไม่ไปโดน `onSelect` ของทั้งการ์ด) */
|
|
63
|
+
actions?: ReactNode;
|
|
64
|
+
/** ยังไม่ได้อ่าน — จุดหน้าชื่อ + `data-unread` */
|
|
65
|
+
unread?: boolean;
|
|
66
|
+
/** คลิกทั้งการ์ด (ทางลัดของเมาส์ — การ์ดยังต้องมีปุ่ม/ลิงก์ของตัวเองสำหรับคีย์บอร์ด) */
|
|
67
|
+
onSelect?: () => void;
|
|
68
|
+
/** element ภายนอก — `li` เมื่ออยู่ในรายการ (ค่าเริ่มต้น `article`) */
|
|
69
|
+
as?: "article" | "li" | "div";
|
|
70
|
+
className?: string;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* การ์ดรายการมาตรฐาน (ยกจาก `LedgerCard` ของ DueBell — ใช้ทั้งรายการบิล การ์ดแค็ตตาล็อก และศูนย์แจ้งเตือน):
|
|
75
|
+
* หัว = ไอคอน + ชื่อ/สถานะ + meta → รายละเอียด → แถบล่าง (สรุปซ้าย, ปุ่มขวา) — รายละเอียดและแถบล่างเยื้องตรงคอลัมน์ข้อความ
|
|
76
|
+
*
|
|
77
|
+
* <RecordCard accent="var(--type-expense)" icon={<ReceiptIcon />} title="ค่าไฟ"
|
|
78
|
+
* status={<StatusBadge tone="danger">เกินกำหนด</StatusBadge>} meta="บ้าน · รายเดือน"
|
|
79
|
+
* footerSummary={<Amount value={1250} />} actions={<Button size="sm">จ่ายแล้ว</Button>} />
|
|
80
|
+
*/
|
|
81
|
+
export function RecordCard({
|
|
82
|
+
accent,
|
|
83
|
+
icon,
|
|
84
|
+
iconTile = true,
|
|
85
|
+
iconSize = "md",
|
|
86
|
+
title,
|
|
87
|
+
titleClassName,
|
|
88
|
+
titleAs: Title = "h3",
|
|
89
|
+
status,
|
|
90
|
+
meta,
|
|
91
|
+
description,
|
|
92
|
+
details,
|
|
93
|
+
detailsLabel,
|
|
94
|
+
footerSummary,
|
|
95
|
+
actions,
|
|
96
|
+
unread = false,
|
|
97
|
+
onSelect,
|
|
98
|
+
as: Root = "article",
|
|
99
|
+
className,
|
|
100
|
+
}: RecordCardProps) {
|
|
101
|
+
const text = useUiText();
|
|
102
|
+
const color = recordAccentColor(accent);
|
|
103
|
+
const small = iconSize === "sm";
|
|
104
|
+
// เยื้อง = ความกว้างไอคอน + gap-3 (44+12 = 56 = ml-14, 36+12 = 48 = ml-12)
|
|
105
|
+
const indent = icon ? (small ? "ml-12 ps-0" : "ml-14 ps-0") : "";
|
|
106
|
+
const hasFooter = footerSummary != null || actions != null;
|
|
107
|
+
return (
|
|
108
|
+
// biome-ignore lint/a11y/useKeyWithClickEvents: คลิกทั้งการ์ดเป็นทางลัดของเมาส์ ปุ่มข้างในใช้คีย์บอร์ดได้อยู่แล้ว
|
|
109
|
+
<Root
|
|
110
|
+
data-slot="record-card"
|
|
111
|
+
data-unread={unread || undefined}
|
|
112
|
+
onClick={onSelect}
|
|
113
|
+
style={color ? ({ "--record-accent": color } as CSSProperties) : undefined}
|
|
114
|
+
className={cn(
|
|
115
|
+
"min-w-0 overflow-hidden rounded-xl border bg-card text-card-foreground",
|
|
116
|
+
color &&
|
|
117
|
+
"border-[color-mix(in_oklab,var(--record-accent)_30%,var(--border))] bg-[image:linear-gradient(90deg,color-mix(in_oklab,var(--record-accent)_11%,transparent)_0%,transparent_38%)] shadow-[inset_3px_0_0_var(--record-accent)]",
|
|
118
|
+
unread && "bg-muted/40",
|
|
119
|
+
onSelect && "cursor-pointer transition-colors hover:bg-muted/60",
|
|
120
|
+
className,
|
|
121
|
+
)}
|
|
122
|
+
>
|
|
123
|
+
<div
|
|
124
|
+
className={cn(
|
|
125
|
+
"grid min-w-0 gap-3 p-3 pb-2.5",
|
|
126
|
+
icon ? "grid-cols-[auto_minmax(0,1fr)]" : "grid-cols-1",
|
|
127
|
+
)}
|
|
128
|
+
>
|
|
129
|
+
{icon &&
|
|
130
|
+
(iconTile ? (
|
|
131
|
+
<div
|
|
132
|
+
data-slot="record-card-icon"
|
|
133
|
+
className={cn(
|
|
134
|
+
"flex shrink-0 items-center justify-center rounded-xl border",
|
|
135
|
+
small ? "size-9 [&_svg]:size-4" : "size-11 [&_svg]:size-5",
|
|
136
|
+
color
|
|
137
|
+
? "border-[color-mix(in_oklab,var(--record-accent)_25%,transparent)] bg-[color-mix(in_oklab,var(--record-accent)_12%,transparent)] text-(--record-accent)"
|
|
138
|
+
: "bg-muted text-muted-foreground",
|
|
139
|
+
)}
|
|
140
|
+
>
|
|
141
|
+
{icon}
|
|
142
|
+
</div>
|
|
143
|
+
) : (
|
|
144
|
+
<div className="shrink-0">{icon}</div>
|
|
145
|
+
))}
|
|
146
|
+
<div className="min-w-0">
|
|
147
|
+
<div className="flex min-w-0 items-start justify-between gap-2">
|
|
148
|
+
<Title
|
|
149
|
+
className={cn(
|
|
150
|
+
"line-clamp-2 min-w-0 break-words font-semibold text-sm leading-snug",
|
|
151
|
+
titleClassName,
|
|
152
|
+
)}
|
|
153
|
+
>
|
|
154
|
+
{unread && (
|
|
155
|
+
<span
|
|
156
|
+
className="me-1.5 inline-block size-1.5 rounded-full bg-primary align-middle"
|
|
157
|
+
aria-hidden="true"
|
|
158
|
+
/>
|
|
159
|
+
)}
|
|
160
|
+
{title}
|
|
161
|
+
</Title>
|
|
162
|
+
{status != null && <div className="shrink-0">{status}</div>}
|
|
163
|
+
</div>
|
|
164
|
+
{meta != null && (
|
|
165
|
+
<div className="mt-1 flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1 text-muted-foreground text-xs">
|
|
166
|
+
{meta}
|
|
167
|
+
</div>
|
|
168
|
+
)}
|
|
169
|
+
{description != null && (
|
|
170
|
+
<p className="mt-1 text-muted-foreground text-xs">{description}</p>
|
|
171
|
+
)}
|
|
172
|
+
</div>
|
|
173
|
+
</div>
|
|
174
|
+
{details != null && (
|
|
175
|
+
<section
|
|
176
|
+
aria-label={detailsLabel ?? text.record?.details ?? "ข้อมูลเพิ่มเติม"}
|
|
177
|
+
className={cn("border-t px-3 py-2.5", indent)}
|
|
178
|
+
>
|
|
179
|
+
{details}
|
|
180
|
+
</section>
|
|
181
|
+
)}
|
|
182
|
+
{hasFooter && (
|
|
183
|
+
<div
|
|
184
|
+
className={cn(
|
|
185
|
+
"flex min-h-14 flex-wrap items-center justify-between gap-3 border-t px-3 py-2.5",
|
|
186
|
+
indent,
|
|
187
|
+
)}
|
|
188
|
+
>
|
|
189
|
+
<div className="min-w-0 flex-1">{footerSummary}</div>
|
|
190
|
+
{actions != null && (
|
|
191
|
+
// คลิกปุ่มไม่ให้ไปโดน onSelect ของทั้งการ์ด
|
|
192
|
+
// biome-ignore lint/a11y/noStaticElementInteractions: ตัวกั้นคลิกเฉย ๆ ไม่ใช่ตัวควบคุม
|
|
193
|
+
<div
|
|
194
|
+
className="flex shrink-0 flex-wrap items-center justify-end gap-2"
|
|
195
|
+
onClick={(event: MouseEvent) => event.stopPropagation()}
|
|
196
|
+
onKeyDown={(event: KeyboardEvent) => event.stopPropagation()}
|
|
197
|
+
>
|
|
198
|
+
{actions}
|
|
199
|
+
</div>
|
|
200
|
+
)}
|
|
201
|
+
</div>
|
|
202
|
+
)}
|
|
203
|
+
</Root>
|
|
204
|
+
);
|
|
205
|
+
}
|
|
@@ -73,6 +73,8 @@ export interface UiText {
|
|
|
73
73
|
notifications?: Partial<{ title: string; empty: string; viewAll: string }>;
|
|
74
74
|
/** SelectionBar (optional) — `selected` ใช้ `{count}` */
|
|
75
75
|
selection?: Partial<{ selected: string; clear: string }>;
|
|
76
|
+
/** RecordCard (optional) — ชื่อส่วนรายละเอียดสำหรับ screen reader */
|
|
77
|
+
record?: Partial<{ details: string }>;
|
|
76
78
|
/** ตาข่ายฟอร์มที่ไม่ผ่าน validation (`useFormInvalidNotice`, FormDialog, ActionForm) — `fields` ใช้ `{fields}` */
|
|
77
79
|
invalidForm?: Partial<{ fields: string; generic: string }>;
|
|
78
80
|
/** DataTable (optional) — `rowsSelected` ใช้ `{selected}`/`{total}`, `pageOf` ใช้ `{page}`/`{pages}` */
|
|
@@ -286,6 +288,7 @@ export const UI_TEXT_TH: UiText = {
|
|
|
286
288
|
viewAll: "ดูทั้งหมด",
|
|
287
289
|
},
|
|
288
290
|
selection: { selected: "เลือก {count} รายการ", clear: "ล้างการเลือก" },
|
|
291
|
+
record: { details: "ข้อมูลเพิ่มเติม" },
|
|
289
292
|
invalidForm: { fields: "กรุณากรอกหรือแก้ไข: {fields}", generic: "กรุณากรอกข้อมูลให้ครบถ้วน" },
|
|
290
293
|
sortable: {
|
|
291
294
|
handle: "ลากเพื่อจัดลำดับ",
|
|
@@ -484,6 +487,7 @@ export const UI_TEXT_EN: UiText = {
|
|
|
484
487
|
viewAll: "View all",
|
|
485
488
|
},
|
|
486
489
|
selection: { selected: "{count} selected", clear: "Clear selection" },
|
|
490
|
+
record: { details: "More details" },
|
|
487
491
|
invalidForm: {
|
|
488
492
|
fields: "Please fill in or fix: {fields}",
|
|
489
493
|
generic: "Please complete the required fields",
|
|
@@ -74,6 +74,14 @@ const dndId = (kind: SortableKind | "zone", id: string) =>
|
|
|
74
74
|
const kindOf = (id: UniqueIdentifier) => String(id).slice(0, 1);
|
|
75
75
|
const rawOf = (id: UniqueIdentifier) => String(id).slice(2);
|
|
76
76
|
|
|
77
|
+
/** ตำแหน่ง y ของเมาส์ตอนนี้ = จุดที่กดเริ่มลาก + ระยะที่ลากมา (คีย์บอร์ด = null) */
|
|
78
|
+
function pointerYOf(activator: Event | null, delta: { y: number }): number | null {
|
|
79
|
+
if (activator && "clientY" in activator && typeof activator.clientY === "number") {
|
|
80
|
+
return activator.clientY + delta.y;
|
|
81
|
+
}
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
|
|
77
85
|
/** จังหวะเดียวกันทั้งแถวที่เลื่อนหลบและตอนปล่อย — ease-out สั้น ๆ แบบบอร์ดงาน (Trello/Todoist) */
|
|
78
86
|
const EASING = "cubic-bezier(0.25, 1, 0.5, 1)";
|
|
79
87
|
const SORT_TRANSITION = { duration: 200, easing: EASING };
|
|
@@ -145,6 +153,8 @@ interface SortableState {
|
|
|
145
153
|
selected: ReadonlySet<string>;
|
|
146
154
|
select: (id: string, mode: SelectMode) => void;
|
|
147
155
|
groupOf: (itemId: string) => string | undefined;
|
|
156
|
+
/** แถวรายการอยู่ใน node ของกลุ่มเองไหม (กลุ่มเป็น <tbody> ทั้งก้อน) — ถ้าใช่ เลื่อนตามพ่ออยู่แล้ว */
|
|
157
|
+
insideGroup: (itemId: string, groupId: string) => boolean;
|
|
148
158
|
register: (kind: SortableKind, id: string, node: HTMLElement | null) => void;
|
|
149
159
|
shifts: ShiftStore;
|
|
150
160
|
}
|
|
@@ -163,6 +173,7 @@ const SortableStateContext = createContext<SortableState>({
|
|
|
163
173
|
selected: EMPTY,
|
|
164
174
|
select: () => {},
|
|
165
175
|
groupOf: () => undefined,
|
|
176
|
+
insideGroup: () => false,
|
|
166
177
|
register: () => {},
|
|
167
178
|
shifts: createShiftStore(),
|
|
168
179
|
});
|
|
@@ -390,7 +401,9 @@ export function SortableProvider({
|
|
|
390
401
|
let { top, bottom, left, right } = getClientRect(header, { ignoreTransform: true });
|
|
391
402
|
for (const itemId of latest.current.items[groupId] ?? []) {
|
|
392
403
|
const node = nodes.current.items.get(itemId);
|
|
393
|
-
|
|
404
|
+
// แถวที่อยู่ "ใน" node ของกลุ่มเอง (แอปแปะ ref กลุ่มที่ <tbody>) — กรอบของกลุ่มครอบอยู่แล้ว
|
|
405
|
+
// และกรอบของแถวลูกติด transform ของพ่อ (ignoreTransform ไม่ช่วย) → นับซ้ำแล้วก้อนใหญ่เกินจริง
|
|
406
|
+
if (!node?.isConnected || header.contains(node)) continue;
|
|
394
407
|
const r = getClientRect(node, { ignoreTransform: true });
|
|
395
408
|
top = Math.min(top, r.top);
|
|
396
409
|
bottom = Math.max(bottom, r.bottom);
|
|
@@ -496,6 +509,7 @@ export function SortableProvider({
|
|
|
496
509
|
active: DragMoveEvent["active"],
|
|
497
510
|
over: DragMoveEvent["over"],
|
|
498
511
|
keyboard: boolean,
|
|
512
|
+
pointerY: number | null,
|
|
499
513
|
) {
|
|
500
514
|
const activeKey = String(active.id);
|
|
501
515
|
const keys = entryKeys(latest.current, activeKey);
|
|
@@ -519,12 +533,12 @@ export function SortableProvider({
|
|
|
519
533
|
const a = keys.indexOf(activeKey);
|
|
520
534
|
const o = keys.indexOf(overKey);
|
|
521
535
|
const rect = measureKey(overKey);
|
|
536
|
+
// ตัดสินด้วยตำแหน่งเมาส์ ไม่ใช่กลางของสิ่งที่ลาก: แอปที่แปะ ref ของกลุ่มที่ <tbody> ทั้งก้อน
|
|
537
|
+
// กลางของก้อนสูง ๆ ไม่เคยเลยครึ่งของกลุ่มเป้าหมาย → ได้ "ที่เดิม" เสมอ กลุ่มอื่นไม่เลื่อนหลบ (Pi Config)
|
|
522
538
|
const dragged = active.rect.current.translated;
|
|
539
|
+
const y = pointerY ?? (dragged ? dragged.top + dragged.height / 2 : null);
|
|
523
540
|
// คีย์บอร์ดขยับทีละตำแหน่งไปทับกลางเป้าหมายพอดี → ใช้ทิศทางแทน
|
|
524
|
-
const after =
|
|
525
|
-
keyboard || !rect || !dragged
|
|
526
|
-
? o > a
|
|
527
|
-
: dragged.top + dragged.height / 2 > (rect.top + rect.bottom) / 2;
|
|
541
|
+
const after = keyboard || !rect || y === null ? o > a : y > (rect.top + rect.bottom) / 2;
|
|
528
542
|
setTarget({ before: after ? nextAfter(o) : overKey });
|
|
529
543
|
} else {
|
|
530
544
|
setTarget(null);
|
|
@@ -630,7 +644,12 @@ export function SortableProvider({
|
|
|
630
644
|
}
|
|
631
645
|
|
|
632
646
|
function onDragMove({ active, over, delta, activatorEvent }: DragMoveEvent) {
|
|
633
|
-
updateTarget(
|
|
647
|
+
updateTarget(
|
|
648
|
+
active,
|
|
649
|
+
over,
|
|
650
|
+
activatorEvent instanceof KeyboardEvent,
|
|
651
|
+
pointerYOf(activatorEvent, delta),
|
|
652
|
+
);
|
|
634
653
|
// ไม่มี overlay: ก้อนของกลุ่มที่ลากลอยตามเมาส์ทั้งก้อน (หัวกลุ่ม + แถวรายการ)
|
|
635
654
|
if (overlayOn || kindOf(active.id) !== "g") return;
|
|
636
655
|
shifts.set(String(active.id), {
|
|
@@ -649,10 +668,11 @@ export function SortableProvider({
|
|
|
649
668
|
setActiveDrag(null);
|
|
650
669
|
}
|
|
651
670
|
|
|
652
|
-
function onDragOver({ active, over, activatorEvent }: DragOverEvent) {
|
|
671
|
+
function onDragOver({ active, over, activatorEvent, delta }: DragOverEvent) {
|
|
653
672
|
const keyboard = activatorEvent instanceof KeyboardEvent;
|
|
673
|
+
const pointerY = pointerYOf(activatorEvent, delta);
|
|
654
674
|
if (!over || kindOf(active.id) !== "i") {
|
|
655
|
-
updateTarget(active, over, keyboard);
|
|
675
|
+
updateTarget(active, over, keyboard, pointerY);
|
|
656
676
|
return;
|
|
657
677
|
}
|
|
658
678
|
const current = latest.current;
|
|
@@ -668,13 +688,13 @@ export function SortableProvider({
|
|
|
668
688
|
if (groupOfItem(current, raw) !== UNGROUPED) {
|
|
669
689
|
apply(moveItemToRoot(current, raw, { kind: "group", id: rawOf(over.id) }));
|
|
670
690
|
}
|
|
671
|
-
updateTarget(active, over, keyboard);
|
|
691
|
+
updateTarget(active, over, keyboard, pointerY);
|
|
672
692
|
return;
|
|
673
693
|
}
|
|
674
694
|
const from = containerOf(active.id, current);
|
|
675
695
|
const to = containerOf(over.id, current);
|
|
676
696
|
if (!from || !to || from === to) {
|
|
677
|
-
updateTarget(active, over, keyboard);
|
|
697
|
+
updateTarget(active, over, keyboard, pointerY);
|
|
678
698
|
return;
|
|
679
699
|
}
|
|
680
700
|
if (to === UNGROUPED) {
|
|
@@ -905,6 +925,11 @@ export function SortableProvider({
|
|
|
905
925
|
const groupIds = useMemo(() => order.groups.map((id) => dndId("group", id)), [order.groups]);
|
|
906
926
|
|
|
907
927
|
const groupOf = useCallback((itemId: string) => groupOfItem(order, itemId), [order]);
|
|
928
|
+
const insideGroup = useCallback((itemId: string, groupId: string) => {
|
|
929
|
+
const group = nodes.current.groups.get(groupId);
|
|
930
|
+
const item = nodes.current.items.get(itemId);
|
|
931
|
+
return Boolean(group && item && group !== item && group.contains(item));
|
|
932
|
+
}, []);
|
|
908
933
|
const rootDrag =
|
|
909
934
|
activeDrag !== null &&
|
|
910
935
|
(activeDrag.kind === "group" ||
|
|
@@ -931,6 +956,7 @@ export function SortableProvider({
|
|
|
931
956
|
selected,
|
|
932
957
|
select,
|
|
933
958
|
groupOf,
|
|
959
|
+
insideGroup,
|
|
934
960
|
register,
|
|
935
961
|
shifts,
|
|
936
962
|
}),
|
|
@@ -948,6 +974,7 @@ export function SortableProvider({
|
|
|
948
974
|
selected,
|
|
949
975
|
select,
|
|
950
976
|
groupOf,
|
|
977
|
+
insideGroup,
|
|
951
978
|
register,
|
|
952
979
|
shifts,
|
|
953
980
|
],
|
|
@@ -1097,7 +1124,12 @@ export function useSortableRow({
|
|
|
1097
1124
|
? // มี overlay ลอยตามเมาส์แล้ว แถวต้นทางเหลือเป็นช่องจาง ๆ บอกตำแหน่งที่จะวาง
|
|
1098
1125
|
{ ...style, opacity: 0.4 }
|
|
1099
1126
|
: { ...style, position: "relative", zIndex: 1, opacity: 0.85 };
|
|
1100
|
-
} else if (
|
|
1127
|
+
} else if (
|
|
1128
|
+
type === "item" &&
|
|
1129
|
+
state.rootDrag &&
|
|
1130
|
+
shift &&
|
|
1131
|
+
!(groupId && state.insideGroup(id, groupId))
|
|
1132
|
+
) {
|
|
1101
1133
|
const own = state.activeGroup !== null && state.activeGroup === groupId;
|
|
1102
1134
|
style = {
|
|
1103
1135
|
transform: shift.transform,
|
package/src/index.ts
CHANGED
|
@@ -48,6 +48,7 @@ export {
|
|
|
48
48
|
export {
|
|
49
49
|
Button,
|
|
50
50
|
type ButtonProps,
|
|
51
|
+
type ButtonTone,
|
|
51
52
|
type ButtonTooltipSide,
|
|
52
53
|
ButtonTooltipSuppress,
|
|
53
54
|
} from "./components/shared/button/button";
|
|
@@ -203,6 +204,13 @@ export { PageHeader } from "./components/shared/page-header";
|
|
|
203
204
|
export { PageSkeleton } from "./components/shared/page-skeleton";
|
|
204
205
|
export { type Presence, PresenceDot } from "./components/shared/presence-dot";
|
|
205
206
|
export { ProgressBar, type ProgressTone } from "./components/shared/progress-bar";
|
|
207
|
+
export {
|
|
208
|
+
type RecordAccent,
|
|
209
|
+
RecordCard,
|
|
210
|
+
type RecordCardProps,
|
|
211
|
+
type RecordTone,
|
|
212
|
+
recordAccentColor,
|
|
213
|
+
} from "./components/shared/record-card";
|
|
206
214
|
export {
|
|
207
215
|
describeCondition,
|
|
208
216
|
emptyGroup,
|
package/src/styles.css
CHANGED
|
@@ -177,6 +177,20 @@
|
|
|
177
177
|
--destructive-subtle: oklch(0.971 0.013 17.38);
|
|
178
178
|
--destructive-subtle-foreground: oklch(0.444 0.177 26.899);
|
|
179
179
|
--destructive-border: oklch(0.885 0.062 18.334);
|
|
180
|
+
/* ปุ่มโทน (`Button tone=`) — ตัวอักษร/ขอบ/พื้นตอน hover ของแต่ละโทน · แอป override เป็นสีประเภทรายการของตัวเองได้
|
|
181
|
+
ตัวอักษร = subtle-foreground ของตระกูลเดียวกัน (วัดแล้ว ≥ 7:1 ทั้งสองธีม) · ขอบ ≥ 3:1 กับพื้น (non-text contrast) */
|
|
182
|
+
--tone-income: var(--success-subtle-foreground);
|
|
183
|
+
--tone-income-border: oklch(0.64 0.1 165);
|
|
184
|
+
--tone-income-hover: var(--success-subtle);
|
|
185
|
+
--tone-expense: var(--destructive-subtle-foreground);
|
|
186
|
+
--tone-expense-border: oklch(0.64 0.13 18);
|
|
187
|
+
--tone-expense-hover: var(--destructive-subtle);
|
|
188
|
+
--tone-reminder: oklch(0.45 0.15 300);
|
|
189
|
+
--tone-reminder-border: oklch(0.62 0.12 300);
|
|
190
|
+
--tone-reminder-hover: oklch(0.97 0.02 300);
|
|
191
|
+
--tone-warning: var(--warning-subtle-foreground);
|
|
192
|
+
--tone-warning-border: oklch(0.66 0.13 70);
|
|
193
|
+
--tone-warning-hover: var(--warning-subtle);
|
|
180
194
|
--border: oklch(0.929 0.013 255.508);
|
|
181
195
|
--input: oklch(0.929 0.013 255.508);
|
|
182
196
|
--ring: oklch(0.704 0.04 256.788);
|
|
@@ -236,6 +250,18 @@
|
|
|
236
250
|
--destructive-subtle: oklch(0.258 0.092 26.042);
|
|
237
251
|
--destructive-subtle-foreground: oklch(0.885 0.062 18.334);
|
|
238
252
|
--destructive-border: oklch(0.396 0.141 25.723);
|
|
253
|
+
--tone-income: var(--success-subtle-foreground);
|
|
254
|
+
--tone-income-border: oklch(0.52 0.09 165);
|
|
255
|
+
--tone-income-hover: var(--success-subtle);
|
|
256
|
+
--tone-expense: var(--destructive-subtle-foreground);
|
|
257
|
+
--tone-expense-border: oklch(0.52 0.12 20);
|
|
258
|
+
--tone-expense-hover: var(--destructive-subtle);
|
|
259
|
+
--tone-reminder: oklch(0.85 0.07 300);
|
|
260
|
+
--tone-reminder-border: oklch(0.52 0.12 300);
|
|
261
|
+
--tone-reminder-hover: oklch(0.28 0.07 300);
|
|
262
|
+
--tone-warning: var(--warning-subtle-foreground);
|
|
263
|
+
--tone-warning-border: oklch(0.55 0.11 60);
|
|
264
|
+
--tone-warning-hover: var(--warning-subtle);
|
|
239
265
|
--border: oklch(1 0 0 / 10%);
|
|
240
266
|
--input: oklch(1 0 0 / 15%);
|
|
241
267
|
--ring: oklch(0.551 0.027 264.364);
|