@suphark/ui 0.30.0 → 0.31.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 CHANGED
@@ -2,6 +2,14 @@
2
2
 
3
3
  รูปแบบตาม [Keep a Changelog](https://keepachangelog.com/) · semver: patch = หน้าตา/บั๊ก, minor = component/prop ใหม่, major = เปลี่ยน/ลบ props
4
4
 
5
+ ## [0.31.0] — 2026-10-06
6
+
7
+ > 0.30.0 ถูก publish ก่อน commit ของ `NotificationCenter` (ตรวจ tarball แล้วไม่มีไฟล์ `shared/notification-center.tsx`) จึงเลื่อนมาออกเป็น 0.31.0 — ของอื่นใน 0.30.0 อยู่ครบตามรายการข้างล่าง
8
+
9
+ ### Added
10
+
11
+ - **`NotificationCenter`** — ศูนย์การแจ้งเตือน (ยกจาก DueBell `notification-modal` + `urgent-bills-widget`): ชิปนับหลายกลุ่มที่กดกรองได้ + รายการที่ต้องจัดการ พร้อมปุ่มลัดต่อรายการและเมนู "…" · ออกแบบตามข้อกำหนดที่ DueBell ให้มา — ชิปเป็น array (`counts`) ไม่ใช่ prop ตายตัว, คำสั่งเป็น array (`actions` + `menuActions`), ของโดเมนส่งเป็น node ที่แอปเตรียมแล้ว (`status`/`meta`/`footer` — ยอดเงินที่ซ่อนได้, ความคืบหน้ารอบ), **แพ็กเกจไม่ฟอร์แมตวันที่เอง** และ **ไม่ตัดรายการเอง** (จะจำกัดจำนวนให้แอป slice ก่อนแล้วส่ง `viewAll` — ตัวเลขในชิปจึงตรงกับที่ผู้ใช้เห็นเสมอ); `busy` ปิดปุ่มทุกตัวระหว่างทำคำสั่งลัด · คีย์ `UiText.notifications`
12
+
5
13
  ## [0.30.0] — 2026-10-05
6
14
 
7
15
  ### Added
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@suphark/ui",
3
- "version": "0.30.0",
3
+ "version": "0.31.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 เอง",
@@ -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
+ }
@@ -65,6 +65,8 @@ export interface UiText {
65
65
  }>;
66
66
  /** ErrorState เซสชันหมดอายุ (optional) */
67
67
  sessionExpired?: Partial<{ title: string; description: string; signIn: string }>;
68
+ /** NotificationCenter (optional) */
69
+ notifications?: Partial<{ title: string; empty: string; viewAll: string }>;
68
70
  /** SelectionBar (optional) — `selected` ใช้ `{count}` */
69
71
  selection?: Partial<{ selected: string; clear: string }>;
70
72
  /** DataTable (optional) — `rowsSelected` ใช้ `{selected}`/`{total}`, `pageOf` ใช้ `{page}`/`{pages}` */
@@ -237,6 +239,11 @@ export const UI_TEXT_TH: UiText = {
237
239
  "คุณถูกออกจากระบบระหว่างใช้งาน ข้อมูลที่ยังไม่ได้บันทึกจะหายไป กรุณาเข้าสู่ระบบอีกครั้ง แล้วระบบจะพากลับมาหน้านี้",
238
240
  signIn: "เข้าสู่ระบบ",
239
241
  },
242
+ notifications: {
243
+ title: "การแจ้งเตือน",
244
+ empty: "ไม่มีรายการที่ต้องจัดการ",
245
+ viewAll: "ดูทั้งหมด",
246
+ },
240
247
  selection: { selected: "เลือก {count} รายการ", clear: "ล้างการเลือก" },
241
248
  sortable: {
242
249
  handle: "ลากเพื่อจัดลำดับ",
@@ -396,6 +403,11 @@ export const UI_TEXT_EN: UiText = {
396
403
  "You were signed out while working. Unsaved changes are lost — sign in again and we will bring you back to this page.",
397
404
  signIn: "Sign in",
398
405
  },
406
+ notifications: {
407
+ title: "Notifications",
408
+ empty: "Nothing needs your attention",
409
+ viewAll: "View all",
410
+ },
399
411
  selection: { selected: "{count} selected", clear: "Clear selection" },
400
412
  sortable: {
401
413
  handle: "Drag to reorder",
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";