@suphark/ui 0.42.1 → 0.45.2

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.1] — 2026-10-07
5
+ ## [0.45.2] — 2026-10-08
6
+
7
+ ### Fixed
8
+
9
+ - `@suphark/ui/sortable`: ลากรายการเข้ากลุ่มอื่นไปวาง "ใต้" แถวหนึ่ง ระหว่างลากอยู่ถูกที่ แต่ปล่อยแล้วกระโดดไปอยู่ "เหนือ" แถวนั้น (เจ้าของเจอในตัวอย่างโครง `<tbody>`) — ตอนข้ามกลุ่มจัดตำแหน่งก่อน/หลังไว้แล้ว แต่ตอนปล่อย `reorderItems` ย้ายไปแทนที่แถวที่ชี้ซ้ำอีกรอบ · ตอนนี้ถ้าปล่อยตอนยังชี้แถวเดิมที่ข้ามกลุ่มเข้ามา ใช้ตำแหน่งที่จัดไว้เลย
10
+ - showcase: `/components/sortable-list` **เอาตัวอย่างโครง `<tbody>` ต่อกลุ่มออก** — เจ้าของเลือกให้มีรูปแบบเดียว (หัวกลุ่ม + แถวรายการเป็น `<tr>` ใน `<tbody>` เดียวกัน) เพื่อดูแลง่าย
11
+
12
+
13
+ ### Fixed
14
+
15
+ - `AuthLayout`: โลโก้แนวนอนถูกบีบในกรอบ 44×44 จนอ่านไม่ออก (Pi Auth — wordmark ของลูกค้า 315×70 เหลือสูง ~10px) · ตอนนี้ `<img>` สูง 44px กว้างตามสัดส่วน (ไม่เกิน 220px) ส่วนไอคอน/ตัวอักษร (svg, span) ยังเป็นกรอบจัตุรัสมนเหมือนเดิม — เลือกเองตามชนิดลูก แอปไม่ต้องรู้สัดส่วนรูป
16
+ - showcase: `/components/auth-layout` เพิ่มแท็บ "โลโก้แนวนอน"
17
+
18
+ ## [0.45.0] — 2026-10-08
19
+
20
+ > ประกาศพร้อม publish แล้ว แต่ยังไม่ขึ้น npm — รวมอยู่ใน 0.45.1
21
+
22
+
23
+ ### Added
24
+
25
+ - `RecordCard iconSize="sm" | "md"` (ขอจาก DueBell — การ์ดบิลโหมดกะทัดรัด): `sm` = กรอบ 36px (ไอคอน 16px) เยื้องรายละเอียด/แถบล่าง `ml-12` · `md` (ค่าเริ่มต้น) = 44px / `ml-14` เหมือนเดิม · ใช้คู่ `iconTile={false}` ได้เพื่อตั้งระยะเยื้องให้ตรงไอคอนของแอปเอง
26
+ - showcase: `/components/record-card` เพิ่มการ์ดแบบ `iconSize="sm"`
27
+
28
+ ## [0.44.0] — 2026-10-08
29
+
30
+ > ประกาศพร้อม publish แล้ว แต่ยังไม่ขึ้น npm — รวมอยู่ใน 0.45.0
31
+
32
+
33
+ ### Added
34
+
35
+ - **`Button tone`** (สเปกจาก DueBell — `ToneButton`): ปุ่มขอบโครงเดียวกัน ต่างแค่สีตามความหมายของรายการ — `income` เงินเข้า/รับเงิน · `expense` เงินออก/จ่าย · `reminder` รายการเตือน (ม่วง) · `warning` ย้อนกลับได้ (พักรายการ, รอยืนยัน) · `revert` เลิกทำ/ย้อนสถานะ (สีเดียวกับ expense) — ลบถาวรยังเป็น `variant="destructive"` · type `ButtonTone`
36
+ - 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
37
+ - showcase: `/components/button` เพิ่มแถวปุ่มโทน
38
+
39
+ ### Changed
40
+
41
+ - `RecordCard` กรอบไอคอน: ขอบ `color-mix(accent 25%)` (เดิม 35%) ให้ตรงสูตร `CategoryIcon` ของ DueBell (พื้น 12% / ขอบ 25%) — แอปส่งไอคอนเปล่าได้เลย
42
+
43
+ ## [0.43.0] — 2026-10-08
44
+
45
+ > ประกาศพร้อม publish แล้ว แต่ยังไม่ขึ้น npm — รวมอยู่ใน 0.44.0
46
+
47
+
48
+ ### Added
49
+
50
+ - **`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`
51
+
52
+ ### Changed
53
+
54
+ - **`NotificationCenter` ประกอบจาก `RecordCard` + `StatusBadge`** (DueBell: แถวเดิมเขียนเลย์เอาต์ซ้ำกับการ์ดของแอปและเป็น private) — เพิ่มอย่างเดียว ของเดิมใช้ต่อได้:
55
+ - แถวคือ `RecordCard` · `NotificationEntry.accent` (สี CSS สำหรับสีของประเภทรายการ เช่น รายรับ/รายจ่าย/เตือน) + `details`
56
+ - `renderItem(item, { busy })` เรนเดอร์แถวเองได้
57
+ - ชิปนับเป็น `StatusBadge` (ชิปทั้งระบบชุดเดียว) · tone รับ `danger`/`neutral` แบบ `StatusBadge` (`destructive`/`default` ยังได้)
58
+ - `NotificationAction.render` ใส่ปุ่มของแอปเองได้ (เช่น `ToneButton`) · `onSelect` ไม่บังคับเมื่อใช้ `render`
59
+ - `headingAs` / `headingClassName` (หัวข้อแบบ label ตัวเล็กของแอป)
60
+
61
+ ### Fixed
62
+
63
+ - **`@suphark/ui/sortable` ลากกลุ่มแล้วกลุ่มอื่นไม่เลื่อนหลบ เมื่อแอปแปะ ref ของกลุ่มที่ `<tbody>` ทั้งก้อน** (Pi Config รายงานหลัง bump 0.42.1): ตัดสินก่อน/หลังจากกลางของสิ่งที่ลาก — ก้อน `<tbody>` สูงจนกลางไม่เคยเลยครึ่งของกลุ่มเป้าหมาย → ได้ "ที่เดิม" เสมอ · ตอนนี้ใช้ตำแหน่งเมาส์ · และแถวที่อยู่ใน node ของกลุ่มเองไม่ได้ transform ซ้ำอีกชั้น (เดิมเลื่อนสองเท่า) และไม่ถูกนับซ้ำตอนวัดขนาดก้อน
64
+ - showcase: `/components/record-card` ใหม่ · `/components/notification-center` เพิ่มรายการเตือนสีม่วง (accent สี CSS) + รายละเอียด · `/components/sortable-list` เพิ่มตัวอย่างโครง `<tbody>` ต่อกลุ่ม (แบบ Pi Config)
65
+
6
66
 
7
67
  ### Fixed
8
68
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@suphark/ui",
3
- "version": "0.42.1",
3
+ "version": "0.45.2",
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";
@@ -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
  });
@@ -308,6 +319,9 @@ export function SortableProvider({
308
319
  // กันกระพริบตอนย้ายข้ามกลุ่ม: จำเป้าหมายล่าสุดไว้ใช้ตอนที่ layout กำลังขยับแล้วหาเป้าหมายไม่เจอ
309
320
  const lastOverId = useRef<UniqueIdentifier | null>(null);
310
321
  const movedAcross = useRef(false);
322
+ // เป้าหมายตอนย้ายข้ามกลุ่มครั้งล่าสุด — ตำแหน่งถูกจัดไว้แล้ว (ก่อน/หลังตามครึ่งแถว) ถ้าปล่อยตอนยังชี้ตัวเดิม
323
+ // ห้าม reorderItems ซ้ำ (arrayMove ไปแทนที่ตัวนั้น = วาง "หลัง" กลายเป็น "ก่อน")
324
+ const crossedAt = useRef<UniqueIdentifier | null>(null);
311
325
  const beforeDrag = useRef<SortableOrder | null>(null);
312
326
  // อ่านลำดับล่าสุดตอนปล่อย (onDragOver เปลี่ยนไปหลายรอบแล้ว prop ยังไม่ทัน)
313
327
  const latest = useRef(order);
@@ -390,7 +404,9 @@ export function SortableProvider({
390
404
  let { top, bottom, left, right } = getClientRect(header, { ignoreTransform: true });
391
405
  for (const itemId of latest.current.items[groupId] ?? []) {
392
406
  const node = nodes.current.items.get(itemId);
393
- if (!node?.isConnected) continue;
407
+ // แถวที่อยู่ "ใน" node ของกลุ่มเอง (แอปแปะ ref กลุ่มที่ <tbody>) — กรอบของกลุ่มครอบอยู่แล้ว
408
+ // และกรอบของแถวลูกติด transform ของพ่อ (ignoreTransform ไม่ช่วย) → นับซ้ำแล้วก้อนใหญ่เกินจริง
409
+ if (!node?.isConnected || header.contains(node)) continue;
394
410
  const r = getClientRect(node, { ignoreTransform: true });
395
411
  top = Math.min(top, r.top);
396
412
  bottom = Math.max(bottom, r.bottom);
@@ -496,6 +512,7 @@ export function SortableProvider({
496
512
  active: DragMoveEvent["active"],
497
513
  over: DragMoveEvent["over"],
498
514
  keyboard: boolean,
515
+ pointerY: number | null,
499
516
  ) {
500
517
  const activeKey = String(active.id);
501
518
  const keys = entryKeys(latest.current, activeKey);
@@ -519,12 +536,12 @@ export function SortableProvider({
519
536
  const a = keys.indexOf(activeKey);
520
537
  const o = keys.indexOf(overKey);
521
538
  const rect = measureKey(overKey);
539
+ // ตัดสินด้วยตำแหน่งเมาส์ ไม่ใช่กลางของสิ่งที่ลาก: แอปที่แปะ ref ของกลุ่มที่ <tbody> ทั้งก้อน
540
+ // กลางของก้อนสูง ๆ ไม่เคยเลยครึ่งของกลุ่มเป้าหมาย → ได้ "ที่เดิม" เสมอ กลุ่มอื่นไม่เลื่อนหลบ (Pi Config)
522
541
  const dragged = active.rect.current.translated;
542
+ const y = pointerY ?? (dragged ? dragged.top + dragged.height / 2 : null);
523
543
  // คีย์บอร์ดขยับทีละตำแหน่งไปทับกลางเป้าหมายพอดี → ใช้ทิศทางแทน
524
- const after =
525
- keyboard || !rect || !dragged
526
- ? o > a
527
- : dragged.top + dragged.height / 2 > (rect.top + rect.bottom) / 2;
544
+ const after = keyboard || !rect || y === null ? o > a : y > (rect.top + rect.bottom) / 2;
528
545
  setTarget({ before: after ? nextAfter(o) : overKey });
529
546
  } else {
530
547
  setTarget(null);
@@ -612,6 +629,7 @@ export function SortableProvider({
612
629
 
613
630
  function onDragStart({ active }: DragStartEvent) {
614
631
  beforeDrag.current = latest.current;
632
+ crossedAt.current = null;
615
633
  lastOverId.current = null;
616
634
  const kind: SortableKind = kindOf(active.id) === "g" ? "group" : "item";
617
635
  const raw = rawOf(active.id);
@@ -630,7 +648,12 @@ export function SortableProvider({
630
648
  }
631
649
 
632
650
  function onDragMove({ active, over, delta, activatorEvent }: DragMoveEvent) {
633
- updateTarget(active, over, activatorEvent instanceof KeyboardEvent);
651
+ updateTarget(
652
+ active,
653
+ over,
654
+ activatorEvent instanceof KeyboardEvent,
655
+ pointerYOf(activatorEvent, delta),
656
+ );
634
657
  // ไม่มี overlay: ก้อนของกลุ่มที่ลากลอยตามเมาส์ทั้งก้อน (หัวกลุ่ม + แถวรายการ)
635
658
  if (overlayOn || kindOf(active.id) !== "g") return;
636
659
  shifts.set(String(active.id), {
@@ -649,16 +672,18 @@ export function SortableProvider({
649
672
  setActiveDrag(null);
650
673
  }
651
674
 
652
- function onDragOver({ active, over, activatorEvent }: DragOverEvent) {
675
+ function onDragOver({ active, over, activatorEvent, delta }: DragOverEvent) {
653
676
  const keyboard = activatorEvent instanceof KeyboardEvent;
677
+ const pointerY = pointerYOf(activatorEvent, delta);
654
678
  if (!over || kindOf(active.id) !== "i") {
655
- updateTarget(active, over, keyboard);
679
+ updateTarget(active, over, keyboard, pointerY);
656
680
  return;
657
681
  }
658
682
  const current = latest.current;
659
683
  const raw = rawOf(active.id);
660
684
  const apply = (next: SortableOrder) => {
661
685
  if (next === current) return;
686
+ crossedAt.current = over.id;
662
687
  movedAcross.current = true;
663
688
  latest.current = next;
664
689
  onOrderChange(next);
@@ -668,13 +693,15 @@ export function SortableProvider({
668
693
  if (groupOfItem(current, raw) !== UNGROUPED) {
669
694
  apply(moveItemToRoot(current, raw, { kind: "group", id: rawOf(over.id) }));
670
695
  }
671
- updateTarget(active, over, keyboard);
696
+ updateTarget(active, over, keyboard, pointerY);
672
697
  return;
673
698
  }
674
699
  const from = containerOf(active.id, current);
675
700
  const to = containerOf(over.id, current);
676
701
  if (!from || !to || from === to) {
677
- updateTarget(active, over, keyboard);
702
+ // ชี้ตัวอื่นในกลุ่มเดียวกันแล้ว = เรียงต่อแบบปกติ (ไม่ใช่ตำแหน่งที่จัดไว้ตอนข้ามกลุ่ม)
703
+ if (over.id !== crossedAt.current) crossedAt.current = null;
704
+ updateTarget(active, over, keyboard, pointerY);
678
705
  return;
679
706
  }
680
707
  if (to === UNGROUPED) {
@@ -758,7 +785,12 @@ export function SortableProvider({
758
785
  index,
759
786
  );
760
787
  }
761
- } else if (over && kindOf(active.id) === "i" && kindOf(over.id) === "i") {
788
+ } else if (
789
+ over &&
790
+ kindOf(active.id) === "i" &&
791
+ kindOf(over.id) === "i" &&
792
+ over.id !== crossedAt.current
793
+ ) {
762
794
  next = reorderItems(current, rawOf(active.id), rawOf(over.id));
763
795
  }
764
796
  // bulk: ตัวที่เลือกไว้ตัวอื่นตามมารวมที่จุดวางของตัวที่จับ (เรียงตามลำดับเดิม)
@@ -905,6 +937,11 @@ export function SortableProvider({
905
937
  const groupIds = useMemo(() => order.groups.map((id) => dndId("group", id)), [order.groups]);
906
938
 
907
939
  const groupOf = useCallback((itemId: string) => groupOfItem(order, itemId), [order]);
940
+ const insideGroup = useCallback((itemId: string, groupId: string) => {
941
+ const group = nodes.current.groups.get(groupId);
942
+ const item = nodes.current.items.get(itemId);
943
+ return Boolean(group && item && group !== item && group.contains(item));
944
+ }, []);
908
945
  const rootDrag =
909
946
  activeDrag !== null &&
910
947
  (activeDrag.kind === "group" ||
@@ -931,6 +968,7 @@ export function SortableProvider({
931
968
  selected,
932
969
  select,
933
970
  groupOf,
971
+ insideGroup,
934
972
  register,
935
973
  shifts,
936
974
  }),
@@ -948,6 +986,7 @@ export function SortableProvider({
948
986
  selected,
949
987
  select,
950
988
  groupOf,
989
+ insideGroup,
951
990
  register,
952
991
  shifts,
953
992
  ],
@@ -1097,7 +1136,12 @@ export function useSortableRow({
1097
1136
  ? // มี overlay ลอยตามเมาส์แล้ว แถวต้นทางเหลือเป็นช่องจาง ๆ บอกตำแหน่งที่จะวาง
1098
1137
  { ...style, opacity: 0.4 }
1099
1138
  : { ...style, position: "relative", zIndex: 1, opacity: 0.85 };
1100
- } else if (type === "item" && state.rootDrag && shift) {
1139
+ } else if (
1140
+ type === "item" &&
1141
+ state.rootDrag &&
1142
+ shift &&
1143
+ !(groupId && state.insideGroup(id, groupId))
1144
+ ) {
1101
1145
  const own = state.activeGroup !== null && state.activeGroup === groupId;
1102
1146
  style = {
1103
1147
  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);