@suphark/ui 0.42.0 → 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 CHANGED
@@ -2,7 +2,67 @@
2
2
 
3
3
  รูปแบบตาม [Keep a Changelog](https://keepachangelog.com/) · semver: patch = หน้าตา/บั๊ก, minor = component/prop ใหม่, major = เปลี่ยน/ลบ props
4
4
 
5
- ## [0.42.0] — 2026-10-07
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
+
60
+
61
+ ### Fixed
62
+
63
+ - **วันที่สัมพัทธ์นับผิด 1 วัน** (เจอตอนตรวจ 0.42.0 บน production: พิมพ์ `t+3` ได้ 10 ต.ค. ถูก แต่วงเล็บบอก "(ใน 2 วัน)") — `formatRelativeThaiDate` ใช้ `formatDistanceToNow` ซึ่งนับเวลาจาก "ตอนนี้" ถึงเที่ยงคืนของวันนั้น (บ่าย/ค่ำเหลือ ~2.4 วัน → 2) และมีคำว่า "ประมาณ 1 เดือน" · ตอนนี้นับเป็นวันปฏิทินด้วย `Intl.RelativeTimeFormat`: "ในอีก 3 วัน", "10 วันที่ผ่านมา", "เดือนหน้า", "ปีที่แล้ว" (< 31 วัน = วัน, < 12 เดือน = เดือน, ที่เหลือ = ปี) — กระทบ `DatePicker` และ `RelativeDate` · `DatePicker locale` ใช้สูตรเดียวกัน (เดิมนับเป็นวันอย่างเดียว "in 45 days")
64
+ - `formatRelativeCalendarDate(date, locale)` ใน `lib/formatters` (ใช้ร่วมกันข้างใน)
65
+
6
66
 
7
67
  ### Added
8
68
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@suphark/ui",
3
- "version": "0.42.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
- <div className="flex size-11 items-center justify-center overflow-hidden rounded-xl [&_img]:size-full [&_img]:object-contain [&_svg]:size-6">
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
- ...rest
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();
@@ -7,6 +7,7 @@ export { ActionButton, type ActionButtonProps } from "./base/action-button";
7
7
  export {
8
8
  Button,
9
9
  type ButtonProps,
10
+ type ButtonTone,
10
11
  type ButtonTooltipSide,
11
12
  ButtonTooltipSuppress,
12
13
  } from "./button";
@@ -11,7 +11,7 @@ import {
11
11
  } from "@suphark/ui/components/ui/input-group";
12
12
  import { Popover, PopoverContent, PopoverTrigger } from "@suphark/ui/components/ui/popover";
13
13
  import { useMounted } from "@suphark/ui/hooks/use-mounted";
14
- import { formatRelativeThaiDate, formatThaiFullDate } from "@suphark/ui/lib/formatters";
14
+ import { formatRelativeCalendarDate, formatThaiFullDate } from "@suphark/ui/lib/formatters";
15
15
  import { cn } from "@suphark/ui/lib/utils";
16
16
  import { addDays, addMonths, addWeeks, addYears } from "date-fns";
17
17
  import { CalendarIcon, Check, InfoIcon, X } from "lucide-react";
@@ -158,12 +158,9 @@ function formatDate(date: Date, locale: string | undefined): string {
158
158
  }).format(date);
159
159
  }
160
160
 
161
- /** "พรุ่งนี้" / "in 3 days" — นับเป็นวันตามปฏิทิน */
161
+ /** "พรุ่งนี้" / "in 3 days" — นับเป็นวันตามปฏิทิน (ไม่ส่ง locale = ไทย) */
162
162
  function formatRelative(date: Date, locale: string | undefined): string {
163
- if (!locale) return formatRelativeThaiDate(date);
164
- const startOf = (d: Date) => new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
165
- const days = Math.round((startOf(date) - startOf(new Date())) / 86_400_000);
166
- return new Intl.RelativeTimeFormat(locale, { numeric: "auto" }).format(days, "day");
163
+ return formatRelativeCalendarDate(date, locale ?? "th");
167
164
  }
168
165
 
169
166
  interface DatePickerBaseProps {
@@ -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 { ReactNode } from "react";
14
+ import { Fragment, type ReactNode } from "react";
9
15
 
10
- export type NotificationTone = "default" | "info" | "success" | "warning" | "destructive";
16
+ /** ชื่อเดียวกับ `StatusBadge` (`neutral`/`danger`) ได้ และชื่อเดิม (`default`/`destructive`) ยังใช้ได้ */
17
+ export type NotificationTone = RecordTone;
11
18
 
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",
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
- onSelect: () => void;
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
- * ศูนย์การแจ้งเตือน: ชิปนับ + รายการที่ต้องจัดการ พร้อมปุ่มลัดต่อรายการ (ยกจาก DueBell)
132
+ * ศูนย์การแจ้งเตือน: หัวข้อ + ชิปนับ (`StatusBadge`) + รายการ (`RecordCard`) พร้อมปุ่มลัดต่อรายการ (ยกจาก DueBell)
111
133
  * ทุกอย่างที่เป็นของโดเมน — วันที่/สถานะ/ยอดเงิน/ความคืบหน้า — ส่งเข้ามาเป็น node ที่แอปเตรียมแล้ว
112
134
  * วางในหน้าได้ตรง ๆ หรือใส่ใน `SimpleDialog`/`SimpleSheet` เป็นกล่องแจ้งเตือนก็ได้
113
135
  *
114
136
  * <NotificationCenter
115
- * counts={[{ key: "overdue", label: "เกินกำหนด", count: 3, tone: "destructive" }]}
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
- <h2 className="font-semibold text-sm">{heading}</h2>
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
- 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
- })}
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
- <NotificationRow key={item.id} item={item} busy={busy} />
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 NotificationRow({ item, busy }: { item: NotificationEntry; busy: boolean }) {
206
- const text = useUiText();
207
- const tone = item.tone ?? "default";
208
- const clickable = Boolean(item.onSelect);
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
- // biome-ignore lint/a11y/useKeyWithClickEvents: แถวมีปุ่มของตัวเองอยู่แล้ว คลิกทั้งแถวเป็นทางลัดของเมาส์
211
- <li
212
- data-slot="notification-row"
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
- "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",
224
+ "cursor-pointer transition-[filter] hover:brightness-95",
225
+ chip.active && "ring-2 ring-ring/40",
220
226
  )}
221
227
  >
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" />
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
- <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>
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
- </div>
293
- )}
294
- </div>
295
- </li>
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
- if (!node?.isConnected) continue;
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(active, over, activatorEvent instanceof KeyboardEvent);
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 (type === "item" && state.rootDrag && shift) {
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,
@@ -1,24 +1,36 @@
1
- import { differenceInCalendarDays, formatDistanceToNow } from "date-fns";
2
- import { th } from "date-fns/locale/th";
1
+ import {
2
+ differenceInCalendarDays,
3
+ differenceInCalendarMonths,
4
+ differenceInCalendarYears,
5
+ } from "date-fns";
3
6
 
4
7
  /**
5
8
  * ฟอร์แมตวันที่แบบไทยที่ primitives ใช้ร่วมกัน (DatePicker, RelativeDate)
6
9
  * ยกมาจาก supplychain `lib/utils/formatters.ts` เฉพาะส่วนที่ไม่ผูก domain
7
10
  */
8
11
 
9
- /** วันที่แบบสัมพัทธ์ภาษาไทย เช่น "วันนี้", "เมื่อวาน", "พรุ่งนี้", "10 วันที่ผ่านมา", "ใน 2 เดือน" */
12
+ /**
13
+ * วันที่สัมพัทธ์นับตาม "วันปฏิทิน" ไม่ใช่ระยะเวลาจากตอนนี้ — "ในอีก 3 วัน" ทั้งวันไม่ว่าตอนนี้กี่โมง
14
+ * (เดิมใช้ formatDistanceToNow: 3 วันข้างหน้าตอนบ่ายเหลือ ~2.4 วัน → "ใน 2 วัน", และมี "ประมาณ 1 เดือน")
15
+ * น้อยกว่า 31 วัน = วัน, น้อยกว่า 12 เดือน = เดือน, ที่เหลือ = ปี
16
+ */
17
+ export function formatRelativeCalendarDate(date: Date, locale = "th"): string {
18
+ const now = new Date();
19
+ const fmt = new Intl.RelativeTimeFormat(locale, { numeric: "auto" });
20
+ const days = differenceInCalendarDays(date, now);
21
+ if (Math.abs(days) < 31) return fmt.format(days, "day");
22
+ const months = differenceInCalendarMonths(date, now);
23
+ if (Math.abs(months) < 12) return fmt.format(months, "month");
24
+ return fmt.format(differenceInCalendarYears(date, now), "year");
25
+ }
26
+
27
+ /** วันที่แบบสัมพัทธ์ภาษาไทย เช่น "วันนี้", "เมื่อวาน", "พรุ่งนี้", "10 วันที่ผ่านมา", "ในอีก 2 เดือน" */
10
28
  export function formatRelativeThaiDate(date: Date | string | number | null | undefined): string {
11
29
  if (!date) return "ไม่ได้ระบุ";
12
30
  const d = new Date(date);
13
31
  if (Number.isNaN(d.getTime())) return "ไม่ได้ระบุ";
14
32
 
15
- // เทียบเป็นวันปฏิทิน (ไม่ใช่ 24 ชม.) และคงเครื่องหมาย — อนาคต 1 วันคือ "พรุ่งนี้" ไม่ใช่ "เมื่อวาน"
16
- const diffDays = differenceInCalendarDays(new Date(), d);
17
- if (diffDays === 0) return "วันนี้";
18
- if (diffDays === 1) return "เมื่อวาน";
19
- if (diffDays === -1) return "พรุ่งนี้";
20
-
21
- return formatDistanceToNow(d, { addSuffix: true, locale: th });
33
+ return formatRelativeCalendarDate(d, "th");
22
34
  }
23
35
 
24
36
  const THAI_MONTH_SHORT = [
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);