@suphark/ui 0.30.0 → 0.33.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 +36 -0
- package/package.json +2 -2
- package/src/components/design-system/sections/showcase-foundations.tsx +1 -1
- package/src/components/shared/activity-feed.tsx +13 -7
- package/src/components/shared/count-badge.tsx +1 -1
- package/src/components/shared/notification-center.tsx +297 -0
- package/src/components/shared/time/client-date-formatter.tsx +6 -2
- package/src/components/shared/ui-text.tsx +16 -0
- package/src/components/sortable/sortable-list.tsx +7 -1
- package/src/index.ts +8 -0
- package/src/styles.css +10 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,42 @@
|
|
|
2
2
|
|
|
3
3
|
รูปแบบตาม [Keep a Changelog](https://keepachangelog.com/) · semver: patch = หน้าตา/บั๊ก, minor = component/prop ใหม่, major = เปลี่ยน/ลบ props
|
|
4
4
|
|
|
5
|
+
## [0.33.0] — 2026-10-06
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- **คอนทราสต์ไม่ผ่าน WCAG AA 3 จุด** (DueBell วัดจากหน้าจริงทั้งสองธีม — ผมคำนวณจาก token ซ้ำได้ตัวเลขตรงกันทุกจุด):
|
|
10
|
+
- `--info` ธีมสว่าง: `text-info` บนพื้นขาว **4.02:1** → ลดความสว่าง L 0.588 → 0.53 = **5.05:1** (ตัวหนังสือขาวบน `bg-info` ก็ขึ้นเป็น 5.05:1 เท่ากัน) — สีฟ้าเข้มขึ้นเล็กน้อย
|
|
11
|
+
- `--muted-foreground` ธีมสว่าง: บนพื้น `--muted` **4.35:1** (กระทบ `CountBadge tone="muted"`, แท็บที่ไม่ได้เลือกบน `TabsList`, ป้ายนับใน `SegmentedControl` …) → L 0.554 → 0.52 = **5.02:1** (บนขาว 5.4:1) — ตัวหนังสือรองเข้มขึ้นเล็กน้อยทั้งระบบ
|
|
12
|
+
- ป้ายนับ `CountBadge tone="destructive"` (รวมปุ่มแจ้งเตือนใน `AppShell`/`MobileTabBar`) ธีมมืด: ตัวหนังสือขาวบน `--destructive` **2.89:1** → token ใหม่ **`--destructive-foreground`** (สว่าง = ขาว 4.76:1, มืด = สีพื้นเข้ม **6.97:1**) — ไม่ลดความสว่างของ `--destructive` ธีมมืดเพราะ `text-destructive` บนพื้นมืดจะไม่ผ่านแทน
|
|
13
|
+
- ใช้ `text-destructive-foreground` คู่ `bg-destructive` ได้ทุกที่ (ใหม่ใน `@theme`)
|
|
14
|
+
|
|
15
|
+
## [0.32.0] — 2026-10-06
|
|
16
|
+
|
|
17
|
+
> แก้บั๊กที่แอปรายงานหลัง 0.30.0 — เดิมเตรียมไว้ใน 0.31.0 แต่ 0.31.0 ถูก publish (12:53 น.) ก่อน commit ทั้งสามนี้ (12:56–13:09 น.) ตรวจ tarball แล้วไม่มี จึงออกเป็น 0.32.0
|
|
18
|
+
|
|
19
|
+
### Fixed
|
|
20
|
+
|
|
21
|
+
- **`@suphark/ui/sortable`: แถวกลุ่มไม่ได้อยู่ใน `SortableContext`** (รายงานจาก Pi Config ตอนย้าย `values-table`) — `SortableGroup` สร้าง context ให้เฉพาะรายการในกลุ่ม ส่วนแถวกลุ่มที่ลงทะเบียนด้วย `useSortableRow({ type: "group" })` ไม่มี context ไหนรับ ทำให้ลากกลุ่มแล้วไม่มีการเลื่อนแถวให้เห็นระหว่างลาก (ปล่อยแล้วลำดับยังถูก) — ตอนนี้ `SortableProvider` สร้าง `SortableContext` ชั้นนอกจาก `order.groups` ให้เอง **ไม่ต้องแก้ call site และลำดับใน context หลุดจากลำดับจริงไม่ได้**
|
|
22
|
+
|
|
23
|
+
- **`ActivityFeed relative={false}` พังทุกแถว** (รายงานจาก Pi Auth): วันที่กลายเป็น `-` และ console เต็มไปด้วย `TypeError: Invalid option` เพราะส่ง `timeStyle` ปนกับ `day`/`month`/`year` ซึ่ง `Intl.DateTimeFormat` ไม่ยอม — ต้นเหตุจริงอยู่ที่ **`ClientDateFormatter` เอา default (`day`/`month`/`year`) ไปผสมกับ options ของผู้เรียกเสมอ** ทำให้ทุกแอปที่ส่ง `dateStyle`/`timeStyle` พังเหมือนกัน; ตอนนี้ถ้าผู้เรียกใช้ `dateStyle`/`timeStyle` จะไม่ผสม default ให้ และ `ActivityFeed` เปลี่ยนไปใช้ `{ dateStyle: "medium", timeStyle: "short" }` (เทสต์ใหม่ครอบทั้งสองชั้น)
|
|
24
|
+
|
|
25
|
+
### Added
|
|
26
|
+
|
|
27
|
+
- `prepublishOnly` เรียก `scripts/release-check.mjs` ก่อน publish: working tree ต้องสะอาด, HEAD ต้องตรงกับ `origin/main` หลัง fetch และ CHANGELOG ต้องมีหัวข้อของเวอร์ชันนั้น — กันปล่อยรุ่นที่ขาด commit (เกิดมาแล้ว 3 ครั้ง: 0.17.0, 0.30.0, 0.31.0; ข้อเสนอจาก Pi Auth/Pi Config)
|
|
28
|
+
|
|
29
|
+
### Changed
|
|
30
|
+
|
|
31
|
+
- `ActivityFeed`: รับ `locale` แล้วส่งต่อให้ตัวฟอร์แมตวันที่ (ไม่ส่ง = `th-TH` เหมือนเดิม) — เดิมวันที่เป็นไทยตายตัวสำหรับทุกคน แอปสองภาษาจึงแสดงผิดเมื่อผู้ใช้เลือกอังกฤษ (รายงานจาก Pi Auth ตอนทำหน้า audit log); `emptyLabel` ค่าเริ่มต้นย้ายไปอ่านจาก `UiText.activity.empty` (คีย์ใหม่)
|
|
32
|
+
|
|
33
|
+
## [0.31.0] — 2026-10-06
|
|
34
|
+
|
|
35
|
+
> 0.30.0 ถูก publish ก่อน commit ของ `NotificationCenter` (ตรวจ tarball แล้วไม่มีไฟล์ `shared/notification-center.tsx`) จึงเลื่อนมาออกเป็น 0.31.0 — ของอื่นใน 0.30.0 อยู่ครบตามรายการข้างล่าง
|
|
36
|
+
|
|
37
|
+
### Added
|
|
38
|
+
|
|
39
|
+
- **`NotificationCenter`** — ศูนย์การแจ้งเตือน (ยกจาก DueBell `notification-modal` + `urgent-bills-widget`): ชิปนับหลายกลุ่มที่กดกรองได้ + รายการที่ต้องจัดการ พร้อมปุ่มลัดต่อรายการและเมนู "…" · ออกแบบตามข้อกำหนดที่ DueBell ให้มา — ชิปเป็น array (`counts`) ไม่ใช่ prop ตายตัว, คำสั่งเป็น array (`actions` + `menuActions`), ของโดเมนส่งเป็น node ที่แอปเตรียมแล้ว (`status`/`meta`/`footer` — ยอดเงินที่ซ่อนได้, ความคืบหน้ารอบ), **แพ็กเกจไม่ฟอร์แมตวันที่เอง** และ **ไม่ตัดรายการเอง** (จะจำกัดจำนวนให้แอป slice ก่อนแล้วส่ง `viewAll` — ตัวเลขในชิปจึงตรงกับที่ผู้ใช้เห็นเสมอ); `busy` ปิดปุ่มทุกตัวระหว่างทำคำสั่งลัด · คีย์ `UiText.notifications`
|
|
40
|
+
|
|
5
41
|
## [0.30.0] — 2026-10-05
|
|
6
42
|
|
|
7
43
|
### Added
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@suphark/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.33.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "Design system กลางของ Suphark apps: shadcn/ui (base-nova, Base UI, Tailwind v4) + shared components + token/ฟอนต์ Noto Sans Thai Looped — ส่งเป็น source ให้แอป Next transpile เอง",
|
|
@@ -64,7 +64,7 @@
|
|
|
64
64
|
"check:typography": "node scripts/audit-tailwind-typography.mjs",
|
|
65
65
|
"verify": "pnpm typecheck && pnpm check:barrel && pnpm lint:base-ui && pnpm check:typography && pnpm test",
|
|
66
66
|
"check:barrel": "node ../../scripts/barrel-exports.mjs",
|
|
67
|
-
"prepublishOnly": "node ../../scripts/barrel-exports.mjs && tsc --noEmit"
|
|
67
|
+
"prepublishOnly": "node ../../scripts/release-check.mjs && node ../../scripts/barrel-exports.mjs && tsc --noEmit"
|
|
68
68
|
},
|
|
69
69
|
"dependencies": {
|
|
70
70
|
"@dnd-kit/core": "^6.3.1",
|
|
@@ -60,7 +60,7 @@ export function ShowcaseFoundations() {
|
|
|
60
60
|
},
|
|
61
61
|
{
|
|
62
62
|
name: "Destructive",
|
|
63
|
-
class: "bg-destructive text-
|
|
63
|
+
class: "bg-destructive text-destructive-foreground",
|
|
64
64
|
token: "var(--destructive)",
|
|
65
65
|
tailwind: "bg-destructive / text-destructive",
|
|
66
66
|
usage: "Irreversible actions, Delete confirmation, Error callouts",
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { Initials } from "@suphark/ui/components/shared/initials";
|
|
4
4
|
import { ClientDateFormatter } from "@suphark/ui/components/shared/time/client-date-formatter";
|
|
5
|
+
import { useUiText } from "@suphark/ui/components/shared/ui-text";
|
|
5
6
|
import { cn } from "@suphark/ui/lib/utils";
|
|
6
7
|
import type { ReactNode } from "react";
|
|
7
8
|
|
|
@@ -43,20 +44,27 @@ const DOT_TONE: Record<ActivityTone, string> = {
|
|
|
43
44
|
export function ActivityFeed({
|
|
44
45
|
items,
|
|
45
46
|
relative = true,
|
|
47
|
+
locale,
|
|
46
48
|
emptyLabel,
|
|
47
49
|
className,
|
|
48
50
|
}: {
|
|
49
51
|
items: ReadonlyArray<ActivityItem>;
|
|
50
52
|
/** true = "5 นาทีที่แล้ว" (ค่าเริ่มต้น) · false = วันที่/เวลาเต็ม */
|
|
51
53
|
relative?: boolean;
|
|
52
|
-
/**
|
|
54
|
+
/**
|
|
55
|
+
* ภาษาของวันที่ เช่น `"en-US"` หรือ `"th-TH-u-ca-gregory"` — ไม่ส่ง = `th-TH` (พ.ศ.)
|
|
56
|
+
* แอปสองภาษาต้องส่งตาม locale ของผู้ใช้ ไม่งั้นวันที่จะเป็นไทยสำหรับทุกคน
|
|
57
|
+
*/
|
|
58
|
+
locale?: string;
|
|
59
|
+
/** ไม่มีรายการ (ไม่ส่ง = `UiText.activity.empty`) */
|
|
53
60
|
emptyLabel?: ReactNode;
|
|
54
61
|
className?: string;
|
|
55
62
|
}) {
|
|
63
|
+
const text = useUiText();
|
|
56
64
|
if (items.length === 0) {
|
|
57
65
|
return (
|
|
58
66
|
<p className={cn("py-6 text-center text-muted-foreground text-sm", className)}>
|
|
59
|
-
{emptyLabel ?? "ยังไม่มีรายการ"}
|
|
67
|
+
{emptyLabel ?? text.activity?.empty ?? "ยังไม่มีรายการ"}
|
|
60
68
|
</p>
|
|
61
69
|
);
|
|
62
70
|
}
|
|
@@ -95,12 +103,10 @@ export function ActivityFeed({
|
|
|
95
103
|
)}
|
|
96
104
|
<ClientDateFormatter
|
|
97
105
|
date={item.at}
|
|
106
|
+
locale={locale}
|
|
98
107
|
formatType={relative ? "relative" : "standard"}
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
? undefined
|
|
102
|
-
: { day: "numeric", month: "short", year: "numeric", timeStyle: "short" }
|
|
103
|
-
}
|
|
108
|
+
// Intl ห้ามใช้ dateStyle/timeStyle ปนกับ field รายตัว (day/month/year) — โยน TypeError
|
|
109
|
+
options={relative ? undefined : { dateStyle: "medium", timeStyle: "short" }}
|
|
104
110
|
className="text-muted-foreground text-xs"
|
|
105
111
|
/>
|
|
106
112
|
</div>
|
|
@@ -29,7 +29,7 @@ export function CountBadge({
|
|
|
29
29
|
tone === "primary"
|
|
30
30
|
? "bg-primary text-primary-foreground"
|
|
31
31
|
: tone === "destructive"
|
|
32
|
-
? "bg-destructive text-
|
|
32
|
+
? "bg-destructive text-destructive-foreground"
|
|
33
33
|
: "bg-muted text-muted-foreground",
|
|
34
34
|
className,
|
|
35
35
|
)}
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { ActionMenu } from "@suphark/ui/components/shared/action-menu";
|
|
4
|
+
import { Button } from "@suphark/ui/components/shared/button/button";
|
|
5
|
+
import { useUiText } from "@suphark/ui/components/shared/ui-text";
|
|
6
|
+
import { cn } from "@suphark/ui/lib/utils";
|
|
7
|
+
import { BellIcon, ChevronRightIcon } from "lucide-react";
|
|
8
|
+
import type { ReactNode } from "react";
|
|
9
|
+
|
|
10
|
+
export type NotificationTone = "default" | "info" | "success" | "warning" | "destructive";
|
|
11
|
+
|
|
12
|
+
const ACCENT: Record<NotificationTone, string> = {
|
|
13
|
+
default: "border-l-border",
|
|
14
|
+
info: "border-l-info",
|
|
15
|
+
success: "border-l-success",
|
|
16
|
+
warning: "border-l-warning",
|
|
17
|
+
destructive: "border-l-destructive",
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const ICON_TONE: Record<NotificationTone, string> = {
|
|
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",
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const ACTION_TONE: Record<NotificationTone, string> = {
|
|
37
|
+
default: "",
|
|
38
|
+
info: "text-info-subtle-foreground",
|
|
39
|
+
success: "text-success-subtle-foreground",
|
|
40
|
+
warning: "text-warning-subtle-foreground",
|
|
41
|
+
destructive: "text-destructive",
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export interface NotificationChip {
|
|
45
|
+
key: string;
|
|
46
|
+
label: ReactNode;
|
|
47
|
+
count: number;
|
|
48
|
+
tone?: NotificationTone;
|
|
49
|
+
/** กดเพื่อกรอง — ไม่ส่ง = ชิปนับอย่างเดียว */
|
|
50
|
+
onSelect?: () => void;
|
|
51
|
+
active?: boolean;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface NotificationAction {
|
|
55
|
+
key: string;
|
|
56
|
+
label: string;
|
|
57
|
+
onSelect: () => void;
|
|
58
|
+
icon?: ReactNode;
|
|
59
|
+
tone?: NotificationTone;
|
|
60
|
+
/** ปุ่มหลักของแถว (เด่นกว่าตัวอื่น) — ปกติมีตัวเดียว */
|
|
61
|
+
primary?: boolean;
|
|
62
|
+
disabled?: boolean;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface NotificationEntry {
|
|
66
|
+
id: string;
|
|
67
|
+
title: ReactNode;
|
|
68
|
+
description?: ReactNode;
|
|
69
|
+
icon?: ReactNode;
|
|
70
|
+
tone?: NotificationTone;
|
|
71
|
+
/**
|
|
72
|
+
* สถานะ/กำหนดเวลาที่ **แอปฟอร์แมตมาแล้ว** (เช่น `<DueDateBadge>` หรือ "23 ก.ย. · พรุ่งนี้")
|
|
73
|
+
* แพ็กเกจไม่ฟอร์แมตวันที่เอง เพราะปี พ.ศ./ภาษาเป็นของแอป
|
|
74
|
+
*/
|
|
75
|
+
status?: ReactNode;
|
|
76
|
+
/** บรรทัดเล็กใต้ชื่อ เช่น กลุ่ม · หมวดหมู่ */
|
|
77
|
+
meta?: ReactNode;
|
|
78
|
+
/** ท้ายแถว — เรื่องเฉพาะโดเมน เช่นยอดเงิน (ซ่อนได้) หรือความคืบหน้ารอบ */
|
|
79
|
+
footer?: ReactNode;
|
|
80
|
+
/** ยังไม่ได้อ่าน — จุดหน้าชื่อ + พื้นจาง */
|
|
81
|
+
unread?: boolean;
|
|
82
|
+
/** ปุ่มในแถว (ปุ่มที่ `primary` จะเด่นสุด) */
|
|
83
|
+
actions?: ReadonlyArray<NotificationAction>;
|
|
84
|
+
/** คำสั่งรองใน "…" */
|
|
85
|
+
menuActions?: ReadonlyArray<NotificationAction>;
|
|
86
|
+
/** คลิกทั้งแถว (ปุ่มในแถว stopPropagation ให้แล้ว) */
|
|
87
|
+
onSelect?: () => void;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface NotificationCenterProps {
|
|
91
|
+
/**
|
|
92
|
+
* รายการที่จะแสดง — **แพ็กเกจไม่ตัดรายการเอง** ถ้าจะจำกัดจำนวนให้แอป slice มาก่อน
|
|
93
|
+
* แล้วส่ง `viewAll` เพื่อพาไปหน้ารวม (ตัวเลขใน `counts` จึงตรงกับสิ่งที่ผู้ใช้เห็นเสมอ)
|
|
94
|
+
*/
|
|
95
|
+
items: ReadonlyArray<NotificationEntry>;
|
|
96
|
+
/** ชิปนับด้านบน — กี่กลุ่มก็ได้ (เกินกำหนด / รอตรวจสอบ / ใกล้ถึงกำหนด / เลื่อนเตือนอยู่ …) */
|
|
97
|
+
counts?: ReadonlyArray<NotificationChip>;
|
|
98
|
+
title?: ReactNode;
|
|
99
|
+
description?: ReactNode;
|
|
100
|
+
/** ไม่มีรายการ — ส่ง element เองได้ทั้งก้อน */
|
|
101
|
+
empty?: ReactNode;
|
|
102
|
+
/** ลิงก์/ปุ่ม "ดูทั้งหมด" มุมขวาบน */
|
|
103
|
+
viewAll?: { label?: ReactNode; onSelect: () => void };
|
|
104
|
+
/** กำลังทำคำสั่งลัดอยู่ — ปิดปุ่มทั้งหมดกันกดซ้ำ */
|
|
105
|
+
busy?: boolean;
|
|
106
|
+
className?: string;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* ศูนย์การแจ้งเตือน: ชิปนับ + รายการที่ต้องจัดการ พร้อมปุ่มลัดต่อรายการ (ยกจาก DueBell)
|
|
111
|
+
* ทุกอย่างที่เป็นของโดเมน — วันที่/สถานะ/ยอดเงิน/ความคืบหน้า — ส่งเข้ามาเป็น node ที่แอปเตรียมแล้ว
|
|
112
|
+
* วางในหน้าได้ตรง ๆ หรือใส่ใน `SimpleDialog`/`SimpleSheet` เป็นกล่องแจ้งเตือนก็ได้
|
|
113
|
+
*
|
|
114
|
+
* <NotificationCenter
|
|
115
|
+
* counts={[{ key: "overdue", label: "เกินกำหนด", count: 3, tone: "destructive" }]}
|
|
116
|
+
* items={bills.map((b) => ({ id: b.id, title: b.title, status: <DueDateBadge … />,
|
|
117
|
+
* footer: <Amount value={b.amount} />, actions: [{ key: "pay", label: "จ่ายเลย", primary: true, onSelect: … }] }))}
|
|
118
|
+
* viewAll={{ onSelect: () => router.push("/alerts") }} />
|
|
119
|
+
*/
|
|
120
|
+
export function NotificationCenter({
|
|
121
|
+
items,
|
|
122
|
+
counts,
|
|
123
|
+
title,
|
|
124
|
+
description,
|
|
125
|
+
empty,
|
|
126
|
+
viewAll,
|
|
127
|
+
busy = false,
|
|
128
|
+
className,
|
|
129
|
+
}: NotificationCenterProps) {
|
|
130
|
+
const text = useUiText();
|
|
131
|
+
const heading = title ?? text.notifications?.title ?? "การแจ้งเตือน";
|
|
132
|
+
|
|
133
|
+
return (
|
|
134
|
+
<section
|
|
135
|
+
data-slot="notification-center"
|
|
136
|
+
aria-label={typeof heading === "string" ? heading : undefined}
|
|
137
|
+
className={cn("flex flex-col gap-3", className)}
|
|
138
|
+
>
|
|
139
|
+
<div className="flex flex-wrap items-start justify-between gap-2">
|
|
140
|
+
<div className="flex min-w-0 flex-col gap-1.5">
|
|
141
|
+
<h2 className="font-semibold text-sm">{heading}</h2>
|
|
142
|
+
{description && <p className="text-muted-foreground text-xs">{description}</p>}
|
|
143
|
+
{counts && counts.length > 0 && (
|
|
144
|
+
<div className="flex flex-wrap items-center gap-1.5">
|
|
145
|
+
{counts.map((chip) => {
|
|
146
|
+
const tone = chip.tone ?? "default";
|
|
147
|
+
const content = (
|
|
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
|
+
})}
|
|
174
|
+
</div>
|
|
175
|
+
)}
|
|
176
|
+
</div>
|
|
177
|
+
{viewAll && (
|
|
178
|
+
<Button variant="ghost" size="sm" onClick={viewAll.onSelect} className="shrink-0">
|
|
179
|
+
{viewAll.label ?? text.notifications?.viewAll ?? "ดูทั้งหมด"}
|
|
180
|
+
<ChevronRightIcon />
|
|
181
|
+
</Button>
|
|
182
|
+
)}
|
|
183
|
+
</div>
|
|
184
|
+
|
|
185
|
+
{items.length === 0 ? (
|
|
186
|
+
(empty ?? (
|
|
187
|
+
<div className="flex flex-col items-center gap-1 py-8 text-center">
|
|
188
|
+
<BellIcon className="size-8 text-muted-foreground/60" aria-hidden="true" />
|
|
189
|
+
<p className="font-medium text-sm">
|
|
190
|
+
{text.notifications?.empty ?? "ไม่มีรายการที่ต้องจัดการ"}
|
|
191
|
+
</p>
|
|
192
|
+
</div>
|
|
193
|
+
))
|
|
194
|
+
) : (
|
|
195
|
+
<ul className="flex flex-col gap-2">
|
|
196
|
+
{items.map((item) => (
|
|
197
|
+
<NotificationRow key={item.id} item={item} busy={busy} />
|
|
198
|
+
))}
|
|
199
|
+
</ul>
|
|
200
|
+
)}
|
|
201
|
+
</section>
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function NotificationRow({ item, busy }: { item: NotificationEntry; busy: boolean }) {
|
|
206
|
+
const text = useUiText();
|
|
207
|
+
const tone = item.tone ?? "default";
|
|
208
|
+
const clickable = Boolean(item.onSelect);
|
|
209
|
+
return (
|
|
210
|
+
// biome-ignore lint/a11y/useKeyWithClickEvents: แถวมีปุ่มของตัวเองอยู่แล้ว คลิกทั้งแถวเป็นทางลัดของเมาส์
|
|
211
|
+
<li
|
|
212
|
+
data-slot="notification-row"
|
|
213
|
+
data-unread={item.unread || undefined}
|
|
214
|
+
onClick={item.onSelect}
|
|
215
|
+
className={cn(
|
|
216
|
+
"flex gap-3 rounded-xl border border-l-4 bg-card p-3",
|
|
217
|
+
ACCENT[tone],
|
|
218
|
+
item.unread && "bg-muted/40",
|
|
219
|
+
clickable && "cursor-pointer transition-colors hover:bg-muted/60",
|
|
220
|
+
)}
|
|
221
|
+
>
|
|
222
|
+
{item.icon && (
|
|
223
|
+
<span
|
|
224
|
+
className={cn(
|
|
225
|
+
"flex size-9 shrink-0 items-center justify-center rounded-lg [&_svg]:size-4.5",
|
|
226
|
+
ICON_TONE[tone],
|
|
227
|
+
)}
|
|
228
|
+
>
|
|
229
|
+
{item.icon}
|
|
230
|
+
</span>
|
|
231
|
+
)}
|
|
232
|
+
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
|
|
233
|
+
<div className="flex flex-wrap items-start justify-between gap-x-2 gap-y-1">
|
|
234
|
+
<p className="flex min-w-0 items-center gap-1.5 font-semibold text-sm leading-snug">
|
|
235
|
+
{item.unread && (
|
|
236
|
+
<span className="size-1.5 shrink-0 rounded-full bg-primary" aria-hidden="true" />
|
|
237
|
+
)}
|
|
238
|
+
<span className="min-w-0 break-words">{item.title}</span>
|
|
239
|
+
</p>
|
|
240
|
+
{item.status && <span className="shrink-0">{item.status}</span>}
|
|
241
|
+
</div>
|
|
242
|
+
{item.meta && (
|
|
243
|
+
<p className="flex flex-wrap items-center gap-1.5 text-muted-foreground text-xs">
|
|
244
|
+
{item.meta}
|
|
245
|
+
</p>
|
|
246
|
+
)}
|
|
247
|
+
{item.description && <p className="text-muted-foreground text-xs">{item.description}</p>}
|
|
248
|
+
{(item.footer || item.actions || item.menuActions) && (
|
|
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>
|
|
291
|
+
)}
|
|
292
|
+
</div>
|
|
293
|
+
)}
|
|
294
|
+
</div>
|
|
295
|
+
</li>
|
|
296
|
+
);
|
|
297
|
+
}
|
|
@@ -66,10 +66,14 @@ export function ClientDateFormatter({
|
|
|
66
66
|
if (formatType === "relative") {
|
|
67
67
|
return formatDistance(dateObj, now, { addSuffix: true, locale: getLocaleObject(locale) });
|
|
68
68
|
}
|
|
69
|
+
const user: Intl.DateTimeFormatOptions = optionsKey ? JSON.parse(optionsKey) : {};
|
|
70
|
+
// Intl ห้าม dateStyle/timeStyle ปนกับ field รายตัว (day/month/year/hour…) — โยน TypeError
|
|
71
|
+
// ผู้เรียกที่ใช้สไตล์จึงต้องไม่ถูกยัด DEFAULT_OPTIONS (day/month/year) เข้าไปด้วย
|
|
72
|
+
const usesStyle = user.dateStyle !== undefined || user.timeStyle !== undefined;
|
|
69
73
|
const opts: Intl.DateTimeFormatOptions = {
|
|
70
|
-
...DEFAULT_OPTIONS,
|
|
74
|
+
...(usesStyle ? {} : DEFAULT_OPTIONS),
|
|
71
75
|
timeZone,
|
|
72
|
-
...
|
|
76
|
+
...user,
|
|
73
77
|
};
|
|
74
78
|
return getDateTimeFormat(locale, opts).format(dateObj);
|
|
75
79
|
} catch (error) {
|
|
@@ -65,6 +65,10 @@ export interface UiText {
|
|
|
65
65
|
}>;
|
|
66
66
|
/** ErrorState เซสชันหมดอายุ (optional) */
|
|
67
67
|
sessionExpired?: Partial<{ title: string; description: string; signIn: string }>;
|
|
68
|
+
/** ActivityFeed (optional) */
|
|
69
|
+
activity?: Partial<{ empty: string }>;
|
|
70
|
+
/** NotificationCenter (optional) */
|
|
71
|
+
notifications?: Partial<{ title: string; empty: string; viewAll: string }>;
|
|
68
72
|
/** SelectionBar (optional) — `selected` ใช้ `{count}` */
|
|
69
73
|
selection?: Partial<{ selected: string; clear: string }>;
|
|
70
74
|
/** DataTable (optional) — `rowsSelected` ใช้ `{selected}`/`{total}`, `pageOf` ใช้ `{page}`/`{pages}` */
|
|
@@ -237,6 +241,12 @@ export const UI_TEXT_TH: UiText = {
|
|
|
237
241
|
"คุณถูกออกจากระบบระหว่างใช้งาน ข้อมูลที่ยังไม่ได้บันทึกจะหายไป กรุณาเข้าสู่ระบบอีกครั้ง แล้วระบบจะพากลับมาหน้านี้",
|
|
238
242
|
signIn: "เข้าสู่ระบบ",
|
|
239
243
|
},
|
|
244
|
+
activity: { empty: "ยังไม่มีรายการ" },
|
|
245
|
+
notifications: {
|
|
246
|
+
title: "การแจ้งเตือน",
|
|
247
|
+
empty: "ไม่มีรายการที่ต้องจัดการ",
|
|
248
|
+
viewAll: "ดูทั้งหมด",
|
|
249
|
+
},
|
|
240
250
|
selection: { selected: "เลือก {count} รายการ", clear: "ล้างการเลือก" },
|
|
241
251
|
sortable: {
|
|
242
252
|
handle: "ลากเพื่อจัดลำดับ",
|
|
@@ -396,6 +406,12 @@ export const UI_TEXT_EN: UiText = {
|
|
|
396
406
|
"You were signed out while working. Unsaved changes are lost — sign in again and we will bring you back to this page.",
|
|
397
407
|
signIn: "Sign in",
|
|
398
408
|
},
|
|
409
|
+
activity: { empty: "No activity yet" },
|
|
410
|
+
notifications: {
|
|
411
|
+
title: "Notifications",
|
|
412
|
+
empty: "Nothing needs your attention",
|
|
413
|
+
viewAll: "View all",
|
|
414
|
+
},
|
|
399
415
|
selection: { selected: "{count} selected", clear: "Clear selection" },
|
|
400
416
|
sortable: {
|
|
401
417
|
handle: "Drag to reorder",
|
|
@@ -310,6 +310,8 @@ export function SortableProvider({
|
|
|
310
310
|
};
|
|
311
311
|
}, [announcements, describe, text.sortable]);
|
|
312
312
|
|
|
313
|
+
const groupIds = useMemo(() => order.groups.map((id) => dndId("group", id)), [order.groups]);
|
|
314
|
+
|
|
313
315
|
const state = useMemo<SortableState>(
|
|
314
316
|
() => ({
|
|
315
317
|
dragging,
|
|
@@ -333,7 +335,11 @@ export function SortableProvider({
|
|
|
333
335
|
onDragEnd={onDragEnd}
|
|
334
336
|
onDragCancel={onDragCancel}
|
|
335
337
|
>
|
|
336
|
-
{
|
|
338
|
+
{/* ชั้นนอก = ลำดับของกลุ่ม (provider รู้ `order.groups` อยู่แล้ว แอปจึงไม่ต้องสร้างเอง
|
|
339
|
+
และลำดับใน context หลุดจากลำดับจริงไม่ได้) · ชั้นใน = `SortableGroup` ของแต่ละกลุ่ม */}
|
|
340
|
+
<SortableContext items={groupIds} strategy={verticalListSortingStrategy}>
|
|
341
|
+
{children}
|
|
342
|
+
</SortableContext>
|
|
337
343
|
</DndContext>
|
|
338
344
|
</SortableStateContext.Provider>
|
|
339
345
|
);
|
package/src/index.ts
CHANGED
|
@@ -175,6 +175,14 @@ export {
|
|
|
175
175
|
toIdentifier,
|
|
176
176
|
} from "./components/shared/name-id-fields";
|
|
177
177
|
export { Notice, type NoticeTone } from "./components/shared/notice";
|
|
178
|
+
export {
|
|
179
|
+
type NotificationAction,
|
|
180
|
+
NotificationCenter,
|
|
181
|
+
type NotificationCenterProps,
|
|
182
|
+
type NotificationChip,
|
|
183
|
+
type NotificationEntry,
|
|
184
|
+
type NotificationTone,
|
|
185
|
+
} from "./components/shared/notification-center";
|
|
178
186
|
export { type DecimalLike, FormattedNumber } from "./components/shared/number/formatted-number";
|
|
179
187
|
export { PageHeader } from "./components/shared/page-header";
|
|
180
188
|
export { PageSkeleton } from "./components/shared/page-skeleton";
|
package/src/styles.css
CHANGED
|
@@ -45,6 +45,7 @@
|
|
|
45
45
|
--color-input: var(--input);
|
|
46
46
|
--color-border: var(--border);
|
|
47
47
|
--color-destructive: var(--destructive);
|
|
48
|
+
--color-destructive-foreground: var(--destructive-foreground);
|
|
48
49
|
--color-success: var(--success);
|
|
49
50
|
--color-success-foreground: var(--success-foreground);
|
|
50
51
|
--color-warning: var(--warning);
|
|
@@ -148,17 +149,21 @@
|
|
|
148
149
|
--secondary: oklch(0.968 0.007 247.896);
|
|
149
150
|
--secondary-foreground: oklch(0.208 0.042 265.755);
|
|
150
151
|
--muted: oklch(0.968 0.007 247.896);
|
|
151
|
-
|
|
152
|
+
/* 0.554 → 0.52: บนพื้น --muted (แท็บ, ป้ายนับ, ช่อง disabled) เดิมได้ 4.35:1 ไม่ผ่าน AA → 5.02:1 (บนขาว 5.4:1) */
|
|
153
|
+
--muted-foreground: oklch(0.52 0.046 257.417);
|
|
152
154
|
--accent: oklch(0.968 0.007 247.896);
|
|
153
155
|
--accent-foreground: oklch(0.208 0.042 265.755);
|
|
154
156
|
--destructive: oklch(0.577 0.245 27.325);
|
|
157
|
+
/* ตัวหนังสือบนพื้น --destructive (ป้ายนับแจ้งเตือน) — ขาว 4.76:1 */
|
|
158
|
+
--destructive-foreground: oklch(1 0 0);
|
|
155
159
|
--success: oklch(0.527 0.154 150.069);
|
|
156
160
|
--success-foreground: oklch(0.985 0.002 247.839);
|
|
157
161
|
--warning: oklch(0.852 0.199 91.936);
|
|
158
162
|
--warning-foreground: oklch(0.286 0.066 53.813);
|
|
159
163
|
--warning-muted-foreground: oklch(0.421 0.095 57.708);
|
|
160
164
|
/* โทน: ค่าเท่า Tailwind sky/emerald/amber/red ที่ Notice/StatusBadge เคยใช้ตรง ๆ (50 พื้น · 200 ขอบ · 800 ตัวอักษร) */
|
|
161
|
-
|
|
165
|
+
/* 0.588 → 0.53: `text-info` บนขาวเดิม 4.02:1 ไม่ผ่าน AA → 5.05:1 (ตัวหนังสือขาวบน bg-info ก็ผ่านเท่ากัน) */
|
|
166
|
+
--info: oklch(0.53 0.158 241.966);
|
|
162
167
|
--info-foreground: oklch(0.984 0.003 247.858);
|
|
163
168
|
--info-subtle: oklch(0.977 0.013 236.62);
|
|
164
169
|
--info-subtle-foreground: oklch(0.443 0.11 240.79);
|
|
@@ -208,6 +213,9 @@
|
|
|
208
213
|
--accent: oklch(0.279 0.041 260.031);
|
|
209
214
|
--accent-foreground: oklch(0.984 0.003 247.858);
|
|
210
215
|
--destructive: oklch(0.704 0.191 22.216);
|
|
216
|
+
/* ขาวบน --destructive ธีมมืด = 2.89:1 ไม่ผ่าน — ใช้สีพื้นหลังมืดเป็นตัวหนังสือ = 6.97:1
|
|
217
|
+
(ไม่ลดความสว่างของ --destructive เพราะ text-destructive บนพื้นมืดจะไม่ผ่านแทน) */
|
|
218
|
+
--destructive-foreground: oklch(0.129 0.042 264.695);
|
|
211
219
|
--success: oklch(0.627 0.194 149.214);
|
|
212
220
|
--success-foreground: oklch(0.145 0.028 147.727);
|
|
213
221
|
--warning: oklch(0.852 0.199 91.936);
|