@suphark/ui 0.47.0 → 0.57.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,113 @@
2
2
 
3
3
  รูปแบบตาม [Keep a Changelog](https://keepachangelog.com/) · semver: patch = หน้าตา/บั๊ก, minor = component/prop ใหม่, major = เปลี่ยน/ลบ props
4
4
 
5
+ ## [0.57.0] — 2026-10-24
6
+
7
+ ยกจาก pi-done (รอบสองของคำขอย้าย component ที่แอปทำเอง)
8
+
9
+ ### Added
10
+ - `RowTable` / `RowTableHeader` / `RowTableRow` / `RowTableCell` / `RowTableActions` / `RowTableGroup` — ตาราง grid ที่หัว/แถว/แถวกลุ่มใช้คอลัมน์ชุดเดียว (`columns` + `mobileColumns` เป็น `grid-template-columns` ผ่านตัวแปร CSS) หัวซ่อนบนมือถือ ช่อง `hideOnMobile` ซ่อนบนมือถือ ปุ่มท้ายแถวโผล่ตอนชี้/โฟกัส (เห็นตลอดบนจอสัมผัส) — แทนที่ pi-done เขียนซ้ำ 4 ที่
11
+ - `ListSection` / `ListSectionHeader` — กลุ่มรายการแบบไม่มีกล่อง (หัว + จำนวน + ปุ่มขวา + เส้นบาง + แถวคั่นเส้น) = `SectionPanel plain` + `TaskGroupHeader`
12
+ - `SidebarNavItem` / `SidebarCountBadge` — แถวเมนูข้างแบบเรียบ (active = `bg-primary/10 text-primary`, ตัวเลขเป็นข้อความเทา ไม่ใช่ pill, 44px บนจอสัมผัส, `draggingOver`)
13
+ - `ProgressBar tone="auto"` — เขียวเมื่อครบ (value ≥ max) นอกนั้นสีหลัก (ครอบคลุม `ProgressBar` ของ pi-done: ใช้ `<ProgressBar value={percent} tone="auto" className="w-10" />`)
14
+ - showcase: หน้า `/components/row-table`, `/components/list-section`, `/components/sidebar-nav-item`
15
+
16
+ ## [0.56.0] — 2026-10-24
17
+
18
+ ### Added
19
+ - `DatePicker` `minDate` / `maxDate` (`"YYYY-MM-DD"` | Date, รวมวันนั้น) ทั้งวันเดียวและ `mode="range"` (ขอโดย DueBell แทนการกันที่ `onChange`): ปฏิทินกดวันนอกช่วงไม่ได้ · เดือน/ปีเลื่อนไปนอกช่วงไม่ได้ (ลูกศรเดือน + ตารางเลือกเดือน) · ค่าที่พิมพ์แล้วอยู่นอกช่วง = ไม่ยืนยัน (Enter/Tab/ปุ่มผล) และกล่องผลแสดงเหตุผลสีแดงแทน · ค่าที่ส่งเข้ามาเองนอกช่วงไม่ถูกแก้ ช่องขึ้น `aria-invalid` · ข้อความ `UiText.datePicker.beforeMin` / `afterMax` (`{date}` ตาม `locale` ของช่อง) แอปทับได้
20
+
21
+ ## [0.55.0] — 2026-10-24
22
+
23
+ ยกจาก pi-done (รอบแรกของคำขอย้าย component ที่แอปทำเอง)
24
+
25
+ ### Added
26
+ - `ResponsivePopover` / `ResponsivePopoverTrigger` / `ResponsivePopoverContent` / `ResponsivePopoverItem` / `useIsResponsiveSheet()` — Popover บนจอ ≥ 768px · แผ่นล่าง (Drawer) บนมือถือ: สูงตามเนื้อหา, `footer` ค้างล่าง, ปัดลงปิด; `useIsResponsiveSheet()` ให้ picker ขยายพื้นที่แตะ (`ResponsivePopoverItem` = 44px ในแผ่น) · `sheetContentClassName` ทับสไตล์ตัวแผ่น
27
+ - `SwitchField` — สวิตช์ + ป้ายในตัว (คลิกป้ายสลับ, `hint` ได้, ป้ายสูง 44px บนจอสัมผัส) แทน `<label><Switch/>…</label>` ที่ต้อง biome-ignore เพราะ Switch เป็น span
28
+ - `PullToRefresh` (`@suphark/ui/pwa`) prop `onRefresh` — รีเฟรชเองได้ (แอป local-first ที่ `router.refresh()` ไปไม่ถึง) · คืน promise = หมุนจนเสร็จ ขั้นต่ำ 500ms (ไม่ส่ง = `router.refresh()` แล้วหมุน 700ms เหมือนเดิม)
29
+
30
+ ### Fixed
31
+ - `PullToRefresh`: วงโผล่ใต้ขอบบนของ root (เดิมติดขอบจอ จมใต้ header ที่ sticky) และ `z-50` · เริ่มดึงได้เฉพาะเมื่อแตะ "ในส่วน root" ที่เลื่อนอยู่บนสุด — sheet/dialog ที่ portal ออกไปนอก root จึงปัดลงของตัวเองได้ ไม่ถูกแย่งไปรีเฟรชหน้า (root เป็น `<html>`/`<body>` ทำงานเหมือนเดิม)
32
+ - showcase: หน้า `/components/responsive-popover` ใหม่ · labeled-field มีตัวอย่าง `SwitchField`
33
+
34
+ ## [0.54.0] — 2026-10-24
35
+
36
+ ### Added
37
+ - `AccessibleCalendar` — `Calendar` ที่ลูกศร/Home/End/PageUp/PageDown เลือกวันด้วยคีย์บอร์ดได้จริง (props เหมือน `Calendar` ทุกอย่าง) · ใช้แทน `Calendar` ดิบเมื่อวางปฏิทินเอง (ปุ่มวันของ shadcn ไม่ผูก `ref` ลูกศรจึงเลื่อนได้แค่สถานะภายใน) · `FocusableDayButton` ย้ายจาก `date-picker.tsx` มาอยู่ `shared/date/accessible-calendar.tsx`
38
+
39
+ ### Fixed
40
+ - `DateRangePicker` (ตัวกรอง) และ `DateTimePicker`: ปฏิทินใช้ `AccessibleCalendar` — เลือกวันด้วยคีย์บอร์ดได้จริง (เดิมโฟกัสค้างที่เดิม)
41
+ - `DateRangePicker`: ข้อความ placeholder/"ล้างช่วงวันที่" มาจาก `UiText.datePicker` (`rangePlaceholder`, ใหม่ `clearRange`) — แอปที่ไม่ส่ง `placeholder` และใช้ UiText ไทยจะเห็นข้อความไทยแทนอังกฤษ; ส่ง `placeholder` เองยังชนะเสมอ
42
+ - showcase: `/components/calendar` ใช้ `AccessibleCalendar`
43
+
44
+ ## [0.53.0] — 2026-10-23
45
+
46
+ ### Changed
47
+ - showcase: `/components/sortable-list` ปรับให้ตรง mockup — หัวตาราง "ตัวแปรสภาพแวดล้อม · N คีย์" + ปุ่ม ? ด้านบน, ตัวเลขจำนวนคีย์ในหัวกลุ่ม, เส้นนำแนวตั้งของคีย์ในกลุ่ม, สีตอนชี้แถว, เส้นแทรกสีหลักที่แถวที่กำลังลาก, แคปซูลเลือกหมู่อยู่ใต้ตาราง
48
+ - `DatePicker` ปรับให้ตรง mockup "Type first" (0.49.0 วาดไม่ตรง): ไอคอนปฏิทินอยู่ซ้ายของช่อง · ป้าย `Enter` อยู่ในช่องขวา (เดิมอยู่ในกล่องผล) · กล่องผลที่อ่านออก (เขียว) แยกเป็นกล่องของตัวเองเหนือการ์ดปฏิทิน · หัวปฏิทินเป็นปุ่มเดียว "ตุลาคม 2569 ▾" กดแล้วเป็นตารางเลือกเดือน/ปี (แทนเมนูเดือน + เมนูปีสองอัน) · แผ่น "ทั้งหมด" มีหัว "พิมพ์ลัดทั้งหมด · กดเพื่อเติมในช่อง" · `UiText.datePicker.allShortcuts`/`tapToFill`
49
+
50
+ ## [0.52.0] — 2026-10-23
51
+
52
+ ### Changed
53
+ - `variant="quiet"` ของ `RecordCard`/`NotificationCenter` ปรับให้ตรงแบบ mockup จริง (0.51.0 วาดไม่ตรง): ไอคอนอยู่ในกรอบ 36px พื้น muted สีตาม accent · `StatusBadge` ที่อยู่ในแถว quiet กลายเป็น "จุดสี + ข้อความสี" ไม่มีกรอบ/พื้นอัตโนมัติ (CSS `in-data-[variant=quiet]`, นอก quiet เหมือนเดิม) · ปุ่มรองเป็น outline (เดิม ghost) ปุ่มหลักใช้แถวเดียว
54
+
55
+ ### Added
56
+ - `RecordCard`/`NotificationEntry` `highlight` (quiet: พื้น accent จาง ๆ เน้นแถวที่ต้องจัดการก่อน) และ `progress` (quiet: แถบความคืบหน้า 0–100 ใต้ยอด)
57
+ - showcase: notification-center โหมด quiet ตามแบบ — ยอดไม่มีสตางค์, แถวแรกเน้น + ปุ่มหลักตัวเดียว, แถบ 35/60 รอบ, เมนู … ทุกแถว
58
+
59
+ ## [0.51.0] — 2026-10-23
60
+
61
+ ### Added
62
+ - `useSortableRow().toggleSelected()` — เลือก/ไม่เลือกแถวจากช่องติ๊กในแถว (ช่องติ๊กโผล่เมื่อชี้ เหมือนที่จับ)
63
+ - `SelectionBar variant="pill"` — แคปซูลสีหลักลอยกลางล่างจอ เด่นกว่า `floating` (ใช้กับแถบเลือกหมู่ของ Sortable)
64
+ - `RecordCard variant="quiet"` (opt-in, ค่าเริ่มต้น `card` เหมือนเดิม) — แถวเรียบแบบสมุดบัญชี "Quiet ledger": ไม่มีกรอบ/พื้นสี สีของ `accent` เหลือแค่ไอคอน, ชื่อ + (สถานะ · meta) ซ้าย, ยอดเงินขวา, ปุ่มท้ายแถว — เห็นได้มากกว่าเท่าตัวในที่เท่ากัน · props เดิมทั้งหมดใช้ต่อได้ ไม่ใช่ major
65
+ - `NotificationCenter variant="quiet"` + `NotificationEntry.section` — ชิปนับเป็นแท็บขีดเส้นใต้ (ตัวเลข danger เป็นสีแดง) รายการเป็นแถว quiet ในกรอบเดียว มีหัวกลุ่มคั่นตาม `section`
66
+ - showcase: sortable-list มีช่องติ๊กโผล่เมื่อชี้ + แคปซูลเลือกหมู่ + กรอบบอกตำแหน่งเดิมตอนลาก; record-card / notification-center มีตัวอย่าง quiet (notification-center สลับ card/quiet ได้)
67
+
68
+ ## [0.50.0] — 2026-10-23
69
+
70
+ ### Added
71
+ - `@suphark/ui/sortable`: `DragHandle reveal` (opt-in, ค่าเริ่มต้นเหมือนเดิม) — "สงบจนกว่าจะแตะ": ซ่อนที่จับ โผล่เมื่อชี้แถว / โฟกัสในแถว / แถวถูกเลือก (จอสัมผัสเห็นตลอด, Tab ถึงที่จับได้เสมอ) · แอปใส่ `group/row` ที่แถว
72
+ - showcase: `/components/sortable-list` ใช้ `reveal` และย้ายข้อความสอนยาวไว้หลังปุ่ม ? (แผ่นลัดแป้นพิมพ์)
73
+
74
+ ## [0.49.0] — 2026-10-23
75
+
76
+ ### Changed
77
+
78
+ - **`DatePicker` redesign "Type first"** (ทั้งวันเดียวและ `mode="range"`; API/props/ค่าที่ส่งในฟอร์ม `name`/`defaultValue`/`required` เหมือนเดิมทุกตัว — แอปที่ใช้อยู่ไม่ต้องแก้):
79
+ - **ช่องคือ input ที่พิมพ์ได้โดยตรง** (เดิมเป็นปุ่มที่ต้องเปิดป๊อปอัปก่อนถึงมีช่องพิมพ์ซ้อนอยู่ข้างใน) — คลิกช่อง = เลือกทั้งข้อความ พิมพ์ทับได้เลย · `<label>` ที่ครอบ/ชี้ (`LabeledField`) โฟกัสที่ช่องพิมพ์โดยตรง
80
+ - **เห็นผลที่อ่านออกใต้ช่องทันที**: "จ. 26 ต.ค. 2569 · ในอีก 3 วัน" / ช่วง "16–23 ต.ค. 2569 · 8 วัน" (กดผลนั้นหรือ Enter เพื่อยืนยัน) — แก้ปัญหา "10/02 อ่านได้สองแบบ"; อ่านไม่ออก = "อ่านไม่ออก — ลองพิมพ์ t+3" ไม่ทิ้งค่าที่พิมพ์ทิ้งเงียบ ๆ; พิมพ์วันเดียวในช่องช่วง = ช่วงหนึ่งวัน
81
+ - ปฏิทินเลื่อนตามข้อความที่พิมพ์และไฮไลต์วันที่อ่านได้ทันที · แถวพิมพ์ลัดบรรทัดเดียวใต้ปฏิทิน (กดเติมลงช่อง) + "ทั้งหมด ›" เปิดแผ่นตัวอย่างวิธีพิมพ์ในกล่องเดียวกัน — **เลิกปุ่ม (i) / ตารางวิธีพิมพ์ที่กินที่ / ปุ่ม "ตกลง"**
82
+ - ปุ่ม ✕ ล้างค่าและปุ่มเปิดปฏิทินเป็น `<button>` จริง (เดิม ✕ เป็น `<span role="button">`) · วันที่สัมพัทธ์ "(พรุ่งนี้)" ยังแสดงท้ายช่องตาม `showRelative`
83
+ - **ปฏิทินใช้ภาษา/ปีเดียวกับช่อง** (เดิมไม่ส่ง `locale` แล้วขึ้น "Oct 2026" อังกฤษ ทั้งที่ช่องแสดง พ.ศ.) · หัวคอลัมน์วันไทย 2 ตัว (อา จ อ พ พฤ ศ ส) แทนตัวเดียวที่ "อ"/"พ" กำกวม
84
+ - คลิกวันที่ที่เลือกอยู่แล้วในปฏิทิน = ยืนยันวันนั้น (เดิมล้างค่าแบบ toggle — ล้างใช้ปุ่ม ✕)
85
+ - `showSmartInput={false}` = ช่องอ่านอย่างเดียว กดแล้วเปิดปฏิทินอย่างเดียว (แบบเดิม ไม่มีแถบพิมพ์ลัด) · `showPresets` ยัง deprecated ไม่มีผล
86
+ - ป๊อปอัปไม่มี animation ตอนปิด (Base UI ถอดตัวดักโฟกัส `<span tabindex=0>` ที่วางต่อท้ายช่องก็ต่อเมื่อ animation จบ ระหว่างนั้น Tab ตกไปที่มัน)
87
+ - `UiText.datePicker` เพิ่ม `cannotRead` (`{example}`), `shortcutsLabel`, `showAll`, `backToCalendar`, `openCalendar`, `dayCount` (`{count}`) — ไทย/อังกฤษในตัว
88
+
89
+ ### Fixed
90
+
91
+ - **คีย์บอร์ด: ลูกศรเลือกวันในปฏิทินไม่ย้ายโฟกัสจริง** — `CalendarDayButton` ใน `components/ui/calendar.tsx` (ไฟล์ shadcn) มี effect สั่ง `ref.current?.focus()` แต่ **ไม่เคยผูก `ref` กับปุ่ม** ลูกศรจึงเลื่อนแค่สถานะภายในของ react-day-picker โฟกัสจริงค้างที่เดิม (กระทบ `Calendar` ทุกที่ รวม DatePicker แบบเดิม) · `DatePicker` แก้ที่ฝั่งตัวเองด้วย `components={{ DayButton }}` ที่ผูก ref ให้ · **`Calendar` ดิบที่แอปใช้เองยังมีบั๊กนี้** (ห้ามแก้ไฟล์ shadcn — จดใน todo)
92
+ - ป๊อปอัป DatePicker สูง ~400px ไม่พอทั้งบนและล่างแล้ว Base UI ย้ายไปวางข้างช่อง (ทับเนื้อหา) → พลิกได้เฉพาะบน↔ล่าง
93
+ - Tab ออกจากช่องที่เปิดป๊อปอัปอยู่ แล้วโฟกัสหลุดไป `body` แทนที่จะไปช่องถัดไป (ตัวดักโฟกัสของ Base UI)
94
+
95
+ ### Security
96
+
97
+ - อัป `next` (devDependency ของแพ็กเกจ + showcase ui.suphark.com) เป็น `^16.4.2` — ปิดช่องโหว่ใน `next >=16.0.0 <=16.4.0`: **critical** RCE ไม่ต้องล็อกอินใน Image Optimization API เมื่อใช้ไฟล์ SVG (GHSA-2464-fhr4-w9xw) · **critical** RCE ไม่ต้องล็อกอินใน `use cache` ที่คืน typed array (GHSA-673q-mjm6-r5xr) · high DoS ใน App Router ที่ใช้ Server Actions · override `source-map-js >=1.2.2` (high DoS ที่ติดมากับ postcss) · `pnpm audit --prod --audit-level=high` สะอาด · **peerDependency `next >=15` ไม่เปลี่ยน** — แอปที่ใช้ Next 16 อัปเองเป็น `^16.4.2` (แพ็กเกจส่ง source ไม่ได้บังคับเวอร์ชัน next)
98
+
99
+ ### Added
100
+
101
+ - `HeaderBreadcrumbs` (และ `AppShell` `header.breadcrumbs: { upButton }`): **`upButton`** + `upLabel` — ปุ่ม X ท้าย breadcrumb ถอยขึ้นหนึ่งระดับแบบ Vercel (ไปที่ segment ก่อนหน้า หรือ `homeHref` ถ้ามี segment เดียว · ไม่แสดงหน้าแรก) · ปิดไว้เป็นค่าเริ่มต้น แอปเดิมไม่เปลี่ยน (ขอจาก Pi Config ซึ่งทำเองใน `breadcrumbs.tsx` แล้ว) · showcase `/shell` เปิดใช้
102
+
103
+ ## [0.48.0] — 2026-10-10
104
+
105
+ > ประกาศพร้อม publish แล้วแต่ยังไม่ขึ้น npm — รวมอยู่ใน 0.49.0
106
+
107
+ ### Added
108
+
109
+ - showcase: หน้า `/components/web-push-row` (demo WebPushRow)
110
+ - WebPushRow (@suphark/ui/pwa): แถวตั้งค่าเปิด/ปิดการแจ้งเตือน Web Push บนเบราว์เซอร์ พร้อมจัดการ Service Worker registration, PushManager subscription, permission check, และ Feedback toast อัตโนมัติ
111
+
5
112
  ## [0.47.0] — 2026-10-08
6
113
 
7
114
  ### Changed
package/README.md CHANGED
@@ -236,6 +236,7 @@ const [order, setOrder] = useState(() =>
236
236
  - การ์ดลอยตามเมาส์: `renderOverlay={(drag) => drag.kind === "item" ? <ItemCard id={drag.id} /> : <GroupCard id={drag.id} />}` (วาดใน portal จึงเป็น `div` ได้ ไม่ต้องเป็น `<tr>`) — ไม่ใส่ = แถวขยับเองแบบเดิม · โฟลเดอร์ที่ย่ออยู่: `useSortableDropZone(id, { onDwell: collapsed ? open : undefined })` ลากค้าง 600ms แล้วเปิดให้ · วางบนหัวกลุ่ม = ขึ้นบนสุดของกลุ่ม, วางบนโซนของ `UNGROUPED` = ต่อท้าย
237
237
  - เลือกหลายรายการแล้วลากพร้อมกัน: `SortableProvider multiSelect` แล้วกระจาย `row.selectProps` ลงแถว (ทำสีด้วย `data-[selected]:bg-primary/10`) — ⌘/Ctrl+คลิก, Shift+คลิก, ที่จับกด `x` / ⌘/Ctrl+A, Esc ล้าง · แถบนับใช้ `SelectionBar` + `useSortableState().selected/clearSelection` · `renderOverlay` ได้ `drag.ids` ไว้ทำการ์ดซ้อน
238
238
  - จัดชั้นบนสุดอิสระ (กลุ่มกับรายการไม่มีกลุ่มสลับกันได้): `SortableProvider freeLayout` + เรนเดอร์ตาม `rootEntries(order)` (ครอบทั้งหมดด้วย `<SortableGroup itemIds={order.items[UNGROUPED]}>` แล้วแต่ละกลุ่มมี `SortableGroup` ของตัวเองข้างใน) + บันทึก `payload.root` และส่งกลับ `sortableOrderOf(..., { root })` ตอนโหลด · หัวกลุ่มครึ่งบน = ก่อนกลุ่ม, ครึ่งล่าง = เข้ากลุ่ม
239
+ - `DatePicker`: ช่องพิมพ์ได้โดยตรง — "15/3", "15/3/69", "t+3", "t+1m", ช่วง "1/7-5/7" / "t-7..t" (`mode="range"`) เห็นผลที่อ่านออกใต้ช่อง กด Enter ยืนยัน · ↓ เปิดปฏิทิน/เข้าปฏิทิน (ลูกศร+Enter เลือกวัน) · Esc ยกเลิก · ในฟอร์ม `name`/`defaultValue`/`required` ส่ง YYYY-MM-DD · ห่อใน `LabeledField` ได้ (label โฟกัสที่ช่องพิมพ์)
239
240
  - ห่อ component ที่ props เป็น union (เช่น `DatePicker` ที่มี `mode="range"`): ใช้ `DistributiveOmit`/`DistributivePick` แทน `Omit`/`Pick` ไม่งั้น union ถูกยุบจนแยกตาม `mode` ไม่ได้
240
241
  - `sortableOrderToPayload(order)` คืน `{ groups, items }` — `groups` คือลำดับของกลุ่ม, `items` คือรายการในแต่ละกลุ่มเรียงตามนั้น (ของที่ยังไม่มีกลุ่มมาก่อนด้วย `groupId: null`)
241
242
  - `SortableProvider disabled` (เช่นระหว่างกรอง ซึ่งลำดับที่เห็นไม่ใช่ลำดับจริง) ทำให้ `DragHandle` จางลงและกดไม่ได้ — ไม่อยากให้เห็นเลยใส่ `hideWhenDisabled`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@suphark/ui",
3
- "version": "0.47.0",
3
+ "version": "0.57.0",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Design system กลางของ Suphark apps: shadcn/ui (base-nova, Base UI, Tailwind v4) + shared components + token/ฟอนต์ Noto Sans Thai Looped — ส่งเป็น source ให้แอป Next transpile เอง",
@@ -120,7 +120,7 @@
120
120
  "@vitejs/plugin-react": "^6.1.0",
121
121
  "jsdom": "^29.1.1",
122
122
  "lucide-react": "^1.45.0",
123
- "next": "^16.3.8",
123
+ "next": "^16.4.2",
124
124
  "react": "^19.3.0",
125
125
  "react-dom": "^19.3.0",
126
126
  "react-hook-form": "^7.86.0",
@@ -8,6 +8,7 @@ import {
8
8
  BreadcrumbPage,
9
9
  BreadcrumbSeparator,
10
10
  } from "@suphark/ui/components/ui/breadcrumb";
11
+ import { XIcon } from "lucide-react";
11
12
  import Link from "next/link";
12
13
  import { usePathname } from "next/navigation";
13
14
  import { Fragment, type ReactNode } from "react";
@@ -28,6 +29,13 @@ export interface HeaderBreadcrumbsProps {
28
29
  skip?: string[];
29
30
  /** ซ่อน segment ที่ดูเหมือน id (ตัวเลข/uuid/cuid) */
30
31
  hideIds?: boolean;
32
+ /**
33
+ * ปุ่ม X ท้าย breadcrumb ถอยขึ้นหนึ่งระดับ (แบบ Vercel) — ไปที่ segment ก่อนหน้า หรือ `homeHref` ถ้ามี segment เดียว
34
+ * ไม่แสดงในหน้าแรก (ไม่มี segment) · ปิดไว้เป็นค่าเริ่มต้น จึงไม่เปลี่ยนแอปเดิม
35
+ */
36
+ upButton?: boolean;
37
+ /** ข้อความของปุ่ม X (aria-label + tooltip) — ค่าเริ่มต้น "Back" */
38
+ upLabel?: string;
31
39
  }
32
40
 
33
41
  // ตัวเลขล้วน / uuid / cuid / ตัวอักษร+ตัวเลขปน ≥16 ตัว (ต้องมีตัวเลขอย่างน้อย 1 — คำธรรมดายาว ๆ ไม่ใช่ id)
@@ -47,6 +55,8 @@ export function HeaderBreadcrumbs({
47
55
  homeLabel = "Home",
48
56
  skip = [],
49
57
  hideIds = false,
58
+ upButton = false,
59
+ upLabel = "Back",
50
60
  }: HeaderBreadcrumbsProps) {
51
61
  const pathname = usePathname();
52
62
  const all = pathname.split("/").filter(Boolean);
@@ -55,6 +65,8 @@ export function HeaderBreadcrumbs({
55
65
  .map((segment, index) => ({ segment, href: `/${all.slice(0, index + 1).join("/")}` }))
56
66
  .filter(({ segment }) => !skip.includes(segment) && !(hideIds && ID_LIKE.test(segment)));
57
67
 
68
+ const upHref = shown.length >= 2 ? (shown[shown.length - 2]?.href ?? homeHref) : homeHref;
69
+
58
70
  return (
59
71
  <Breadcrumb>
60
72
  <BreadcrumbList>
@@ -80,6 +92,18 @@ export function HeaderBreadcrumbs({
80
92
  </Fragment>
81
93
  );
82
94
  })}
95
+ {upButton && shown.length > 0 && (
96
+ <BreadcrumbItem>
97
+ <Link
98
+ href={upHref}
99
+ aria-label={upLabel}
100
+ title={upLabel}
101
+ className="inline-flex size-6 items-center justify-center rounded-sm text-muted-foreground hover:bg-accent hover:text-foreground"
102
+ >
103
+ <XIcon className="size-3.5" aria-hidden="true" />
104
+ </Link>
105
+ </BreadcrumbItem>
106
+ )}
83
107
  </BreadcrumbList>
84
108
  </Breadcrumb>
85
109
  );
@@ -8,6 +8,8 @@ import { useEffect, useRef, useState } from "react";
8
8
  const ACTIVATION_DISTANCE = 72;
9
9
  const MAX_PULL_DISTANCE = 112;
10
10
  const MOBILE_BREAKPOINT = 768;
11
+ /** หมุนอย่างน้อยเท่านี้ — refresh ที่เสร็จทันทีจะไม่กะพริบหายไปเลย */
12
+ const MIN_SPIN_MS = 500;
11
13
 
12
14
  export interface PullToRefreshText {
13
15
  pull: string;
@@ -28,20 +30,29 @@ export const PULL_TO_REFRESH_TEXT_EN: PullToRefreshText = {
28
30
  };
29
31
 
30
32
  /**
31
- * ดึงลงเพื่อรีเฟรช (มือถือ, < 768px) — เรียก `router.refresh()` เมื่อดึงเกินระยะ
32
- * ใส่ `data-pull-to-refresh-root` บน element ที่เลื่อนได้ของหน้า (ดึงได้เมื่อเลื่อนอยู่บนสุดเท่านั้น)
33
+ * ดึงลงเพื่อรีเฟรช (มือถือ, < 768px) — เรียก `onRefresh` (ไม่ส่ง = `router.refresh()`) เมื่อดึงเกินระยะ
34
+ * ใส่ `data-pull-to-refresh-root` บน element ที่เลื่อนได้ของหน้า — ดึงได้เมื่อแตะ "ในนั้น" และเลื่อนอยู่บนสุดเท่านั้น
35
+ * (sheet/dialog ที่ portal ออกไปนอก root จึงปัดลงของตัวเองได้ ไม่ถูกแย่งไปรีเฟรชหน้า) · วงโผล่ใต้ขอบบนของ root
36
+ * (ไม่จมใต้ header ที่ sticky) และหมุนจนกว่า `onRefresh` จะเสร็จ (ขั้นต่ำ 500ms)
33
37
  *
34
38
  * <main data-pull-to-refresh-root className="overflow-y-auto">…</main>
35
- * <PullToRefresh />
39
+ * <PullToRefresh /> // รีเฟรช server component
40
+ * <PullToRefresh onRefresh={() => store.sync()} /> // แอป local-first / ดึงข้อมูลเอง
36
41
  */
37
42
  export function PullToRefresh({
38
43
  text = PULL_TO_REFRESH_TEXT_TH,
39
44
  rootSelector = "[data-pull-to-refresh-root]",
45
+ onRefresh,
40
46
  }: {
41
47
  text?: PullToRefreshText;
42
48
  rootSelector?: string;
49
+ /** รีเฟรชเอง (คืน promise = หมุนจนเสร็จ) — ไม่ส่ง = `router.refresh()` แล้วหมุน 700ms */
50
+ onRefresh?: () => Promise<unknown> | void;
43
51
  }) {
44
52
  const router = useRouter();
53
+ const onRefreshRef = useRef(onRefresh);
54
+ onRefreshRef.current = onRefresh;
55
+ const [rootTop, setRootTop] = useState(0);
45
56
  const startRef = useRef<{ x: number; y: number } | null>(null);
46
57
  const isPullingRef = useRef(false);
47
58
  const [pullDistance, setPullDistance] = useState(0);
@@ -81,6 +92,13 @@ export function PullToRefresh({
81
92
  resetPull();
82
93
  return;
83
94
  }
95
+ // root ปกติ = ต้องแตะ "ในนั้น" (portal ของ sheet/dialog อยู่นอก root จึงไม่เริ่มดึง) · root เป็น <html>/<body> ครอบทุกอย่างอยู่แล้ว
96
+ const whole = root === document.documentElement || root === document.body;
97
+ if (!whole && !(event.target instanceof Node && root.contains(event.target))) {
98
+ resetPull();
99
+ return;
100
+ }
101
+ setRootTop(whole ? 0 : Math.max(0, root.getBoundingClientRect().top));
84
102
  startRef.current = { x: touch.clientX, y: touch.clientY };
85
103
  isPullingRef.current = false;
86
104
  };
@@ -110,13 +128,27 @@ export function PullToRefresh({
110
128
  setPullDistance(Math.min(MAX_PULL_DISTANCE, deltaY * 0.62));
111
129
  };
112
130
 
113
- const handleTouchEnd = () => {
131
+ const handleTouchEnd = async () => {
114
132
  const shouldRefresh = pullDistanceRef.current >= ACTIVATION_DISTANCE;
115
133
  resetPull();
116
134
  if (!shouldRefresh || isRefreshing()) return;
135
+ isRefreshingRef.current = true;
117
136
  setIsRefreshing(true);
118
- router.refresh();
119
- window.setTimeout(() => setIsRefreshing(false), 700);
137
+ const startedAt = Date.now();
138
+ const custom = onRefreshRef.current;
139
+ try {
140
+ if (custom) await custom();
141
+ else {
142
+ router.refresh();
143
+ await new Promise((resolve) => setTimeout(resolve, 700));
144
+ }
145
+ } catch {
146
+ // งาน refresh รายงานข้อผิดพลาดเอง — วงแค่หยุดหมุน
147
+ }
148
+ const wait = MIN_SPIN_MS - (Date.now() - startedAt);
149
+ if (wait > 0) await new Promise((resolve) => setTimeout(resolve, wait));
150
+ isRefreshingRef.current = false;
151
+ setIsRefreshing(false);
120
152
  };
121
153
 
122
154
  // touchmove ต้อง non-passive (preventDefault ระหว่างดึง) ซึ่งทำให้ browser ต้องรอ JS ก่อนเลื่อนหน้าทุกครั้ง
@@ -160,10 +192,14 @@ export function PullToRefresh({
160
192
  <div
161
193
  aria-live="polite"
162
194
  className={cn(
163
- "pointer-events-none fixed top-3 left-1/2 flex -translate-x-1/2 items-center gap-2 rounded-full border bg-card/95 px-3.5 py-2 text-xs font-medium text-foreground shadow-lg backdrop-blur-md transition-all duration-150 md:hidden",
195
+ "pointer-events-none fixed left-1/2 z-50 flex -translate-x-1/2 items-center gap-2 rounded-full border bg-card/95 px-3.5 py-2 text-xs font-medium text-foreground shadow-lg backdrop-blur-md transition-all duration-150 md:hidden",
164
196
  isVisible ? "translate-y-0 opacity-100" : "-translate-y-8 opacity-0",
165
197
  )}
166
- style={{ transform: `translate(-50%, ${isRefreshing ? 0 : progress * 18}px)` }}
198
+ style={{
199
+ // วงอยู่ใต้ขอบบนของ root (ไม่ใช่ขอบจอ) จึงไม่โดน header ที่ sticky บัง
200
+ top: rootTop + 12,
201
+ transform: `translate(-50%, ${isRefreshing ? 0 : progress * 18}px)`,
202
+ }}
167
203
  >
168
204
  {isRefreshing ? (
169
205
  <Loader2 className="size-3.5 animate-spin" aria-hidden="true" />
@@ -0,0 +1,126 @@
1
+ "use client";
2
+
3
+ import { SwitchRow } from "@suphark/ui/components/shared/settings-row";
4
+ import { type ReactNode, useEffect, useState, useTransition } from "react";
5
+ import { toast } from "sonner";
6
+
7
+ export interface WebPushRowProps {
8
+ /** ฟังก์ชันดึง VAPID Public Key จาก server action */
9
+ getPublicKey: () => Promise<string>;
10
+ /** Callback เมื่อผู้ใช้เปิดรับการแจ้งเตือน (ส่ง subscription ให้ server บันทึก) */
11
+ onSubscribe: (subscription: PushSubscriptionJSON) => Promise<unknown>;
12
+ /** Callback เมื่อผู้ใช้ปิดการแจ้งเตือน (ส่ง endpoint ให้ server ลบ) */
13
+ onUnsubscribe?: (endpoint: string) => Promise<unknown>;
14
+ /** Path ของ Service Worker (default: "/sw.js") */
15
+ swPath?: string;
16
+ /** หัวข้อแถว (default: "การแจ้งเตือนบนเบราว์เซอร์") */
17
+ title?: ReactNode;
18
+ /** คำอธิบาย (default: "รับแจ้งเตือนเตือนความจำและอัปเดตงานทันทีผ่าน Web Push") */
19
+ description?: ReactNode;
20
+ /** ข้อความเมื่อเปิดสำเร็จ (default: "เปิดรับการแจ้งเตือนบนเบราว์เซอร์แล้ว") */
21
+ successSubscribeMessage?: string;
22
+ /** ข้อความเมื่อปิดสำเร็จ (default: "ปิดการแจ้งเตือนแล้ว") */
23
+ successUnsubscribeMessage?: string;
24
+ /** ปิดการทำงาน (disabled) เพิ่มเติม */
25
+ disabled?: boolean;
26
+ className?: string;
27
+ }
28
+
29
+ /**
30
+ * แถวตั้งค่าเปิด/ปิดการแจ้งเตือน Web Push บนเบราว์เซอร์
31
+ * จัดการ Service Worker registration, PushManager subscription, และ Feedback toast อัตโนมัติ
32
+ *
33
+ * <WebPushRow
34
+ * getPublicKey={getVapidKey}
35
+ * onSubscribe={registerPush}
36
+ * onUnsubscribe={unregisterPush}
37
+ * />
38
+ */
39
+ export function WebPushRow({
40
+ getPublicKey,
41
+ onSubscribe,
42
+ onUnsubscribe,
43
+ swPath = "/sw.js",
44
+ title = "การแจ้งเตือนบนเบราว์เซอร์",
45
+ description = "รับแจ้งเตือนเตือนความจำและอัปเดตงานทันทีผ่าน Web Push",
46
+ successSubscribeMessage = "เปิดรับการแจ้งเตือนบนเบราว์เซอร์แล้ว",
47
+ successUnsubscribeMessage = "ปิดการแจ้งเตือนแล้ว",
48
+ disabled: customDisabled,
49
+ className,
50
+ }: WebPushRowProps) {
51
+ const [enabled, setEnabled] = useState(false);
52
+ const [supported, setSupported] = useState(true);
53
+ const [pending, startTransition] = useTransition();
54
+
55
+ useEffect(() => {
56
+ if (typeof window === "undefined") return;
57
+ if (!("serviceWorker" in navigator) || !("PushManager" in window)) {
58
+ setSupported(false);
59
+ return;
60
+ }
61
+ navigator.serviceWorker.ready
62
+ .then((reg) => reg.pushManager.getSubscription())
63
+ .then((sub) => {
64
+ setEnabled(Boolean(sub));
65
+ })
66
+ .catch(() => {
67
+ setEnabled(false);
68
+ });
69
+ }, []);
70
+
71
+ const handleToggle = (checked: boolean) => {
72
+ if (!supported) {
73
+ toast.error("เบราว์เซอร์นี้ไม่รองรับ Web Push");
74
+ return;
75
+ }
76
+
77
+ startTransition(async () => {
78
+ try {
79
+ const reg = await navigator.serviceWorker.register(swPath);
80
+ await navigator.serviceWorker.ready;
81
+
82
+ if (checked) {
83
+ if (Notification.permission === "denied") {
84
+ toast.error("คุณบล็อกการแจ้งเตือนไว้ในเบราว์เซอร์ โปรดปลดบล็อกในการตั้งค่าไซต์");
85
+ return;
86
+ }
87
+
88
+ const key = await getPublicKey();
89
+ const sub = await reg.pushManager.subscribe({
90
+ userVisibleOnly: true,
91
+ applicationServerKey: key,
92
+ });
93
+
94
+ await onSubscribe(sub.toJSON());
95
+ setEnabled(true);
96
+ toast.success(successSubscribeMessage);
97
+ } else {
98
+ const sub = await reg.pushManager.getSubscription();
99
+ if (sub) {
100
+ const endpoint = sub.endpoint;
101
+ await sub.unsubscribe();
102
+ if (onUnsubscribe) {
103
+ await onUnsubscribe(endpoint);
104
+ }
105
+ }
106
+ setEnabled(false);
107
+ toast.success(successUnsubscribeMessage);
108
+ }
109
+ } catch (err) {
110
+ console.error("WebPushRow error:", err);
111
+ toast.error("ตั้งค่าไม่สำเร็จ (ตรวจดูการอนุญาตแจ้งเตือนในเบราว์เซอร์)");
112
+ }
113
+ });
114
+ };
115
+
116
+ return (
117
+ <SwitchRow
118
+ title={title}
119
+ description={supported ? description : "เบราว์เซอร์นี้ไม่รองรับการแจ้งเตือน Web Push"}
120
+ checked={enabled}
121
+ disabled={!supported || pending || customDisabled}
122
+ onCheckedChange={handleToggle}
123
+ className={className}
124
+ />
125
+ );
126
+ }
@@ -0,0 +1,40 @@
1
+ "use client";
2
+
3
+ import { Calendar, CalendarDayButton } from "@suphark/ui/components/ui/calendar";
4
+ import { type ComponentProps, useEffect, useMemo, useRef } from "react";
5
+
6
+ /**
7
+ * ปุ่มวันของปฏิทินที่ย้ายโฟกัสจริงตามลูกศรคีย์บอร์ด — `CalendarDayButton` ของ shadcn (ไฟล์ห้ามแก้มือ) มี effect สั่ง
8
+ * `ref.current?.focus()` แต่ไม่เคยผูก `ref` กับปุ่ม ลูกศรจึงเลื่อนได้แค่สถานะภายในของ react-day-picker โฟกัสจริงอยู่ที่เดิม
9
+ * (ใช้เลือกวันด้วยคีย์บอร์ดไม่ได้) — ผูก ref ผ่าน `{...props}` ที่ CalendarDayButton ส่งต่อให้ปุ่ม
10
+ */
11
+ export function FocusableDayButton(props: ComponentProps<typeof CalendarDayButton>) {
12
+ const ref = useRef<HTMLButtonElement>(null);
13
+ const focused = props.modifiers.focused;
14
+ useEffect(() => {
15
+ if (focused) ref.current?.focus();
16
+ }, [focused]);
17
+ // React 19: ref เป็น prop ธรรมดาที่ถูก spread ต่อไปถึง <Button> — แต่ type ของ react-day-picker ไม่ประกาศ ref
18
+ return <CalendarDayButton {...props} {...({ ref } as Record<string, unknown>)} />;
19
+ }
20
+
21
+ /**
22
+ * `Calendar` ที่ใช้ลูกศร/Home/End/PageUp/PageDown เลือกวันด้วยคีย์บอร์ดได้จริง — ใช้แทน `Calendar` ดิบเสมอเมื่อวางปฏิทินเอง
23
+ * (`Calendar` ของ shadcn ย้ายโฟกัสวันไม่ได้ ดูคำอธิบายที่ `FocusableDayButton`) · props เหมือน `Calendar` ทุกอย่าง
24
+ * ส่ง `components.DayButton` เองได้ (ทับตัวนี้)
25
+ */
26
+ export function AccessibleCalendar({
27
+ components,
28
+ locale,
29
+ ...props
30
+ }: ComponentProps<typeof Calendar>) {
31
+ // ผูก locale เข้ากับปุ่มวัน (Calendar ของ shadcn ส่ง locale ให้ปุ่มของมันเอง แต่ปุ่มที่เราแทนต้องส่งต่อเอง)
32
+ const DayButton = useMemo(
33
+ () =>
34
+ function DayButtonWithLocale(buttonProps: ComponentProps<typeof CalendarDayButton>) {
35
+ return <FocusableDayButton locale={locale} {...buttonProps} />;
36
+ },
37
+ [locale],
38
+ );
39
+ return <Calendar locale={locale} components={{ DayButton, ...components }} {...props} />;
40
+ }