@suphark/ui 0.19.0 → 0.20.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 +36 -0
- package/package.json +14 -13
- package/scripts/audit.mjs +0 -0
- package/src/components/app-shell/app-shell.tsx +12 -0
- package/src/components/app-shell/index.ts +1 -0
- package/src/components/app-shell/mobile-tab-bar.tsx +104 -0
- package/src/components/shared/action-menu.tsx +157 -0
- package/src/components/shared/button/confirm-action-button.tsx +2 -0
- package/src/components/shared/filter-bar.tsx +108 -0
- package/src/components/shared/month-stepper.tsx +124 -0
- package/src/components/shared/row-actions.tsx +2 -0
- package/src/components/shared/segmented-control.tsx +73 -0
- package/src/components/shared/settings-layout.tsx +170 -0
- package/src/components/shared/setup-checklist.tsx +104 -0
- package/src/components/shared/simple-dialog.tsx +172 -0
- package/src/components/shared/simple-pagination.tsx +109 -0
- package/src/components/shared/simple-table.tsx +93 -0
- package/src/components/shared/simple-tabs.tsx +118 -0
- package/src/components/shared/simple-tooltip.tsx +87 -0
- package/src/components/shared/ui-text.tsx +28 -0
- package/src/components/ui/accordion.tsx +2 -2
- package/src/components/ui/alert-dialog.tsx +4 -4
- package/src/components/ui/alert.tsx +2 -2
- package/src/components/ui/attachment.tsx +9 -9
- package/src/components/ui/avatar.tsx +4 -4
- package/src/components/ui/badge.tsx +1 -1
- package/src/components/ui/breadcrumb.tsx +1 -1
- package/src/components/ui/bubble.tsx +123 -0
- package/src/components/ui/button-group.tsx +2 -2
- package/src/components/ui/button.tsx +6 -6
- package/src/components/ui/calendar.tsx +9 -9
- package/src/components/ui/card.tsx +3 -3
- package/src/components/ui/chart.tsx +2 -2
- package/src/components/ui/checkbox.tsx +1 -1
- package/src/components/ui/combobox.tsx +6 -6
- package/src/components/ui/command.tsx +4 -4
- package/src/components/ui/context-menu.tsx +7 -7
- package/src/components/ui/dialog.tsx +4 -4
- package/src/components/ui/direction.tsx +6 -0
- package/src/components/ui/drawer.tsx +10 -10
- package/src/components/ui/dropdown-menu.tsx +8 -8
- package/src/components/ui/empty.tsx +4 -4
- package/src/components/ui/field.tsx +8 -8
- package/src/components/ui/hover-card.tsx +1 -2
- package/src/components/ui/input-group.tsx +3 -3
- package/src/components/ui/input-otp.tsx +1 -1
- package/src/components/ui/input.tsx +1 -1
- package/src/components/ui/item.tsx +4 -4
- package/src/components/ui/kbd.tsx +1 -1
- package/src/components/ui/label.tsx +1 -1
- package/src/components/ui/marker.tsx +67 -0
- package/src/components/ui/menubar.tsx +9 -9
- package/src/components/ui/message-scroller.tsx +129 -0
- package/src/components/ui/message.tsx +84 -0
- package/src/components/ui/native-select.tsx +1 -1
- package/src/components/ui/navigation-menu.tsx +6 -6
- package/src/components/ui/popover.tsx +1 -1
- package/src/components/ui/progress.tsx +2 -3
- package/src/components/ui/questionnaire.tsx +314 -0
- package/src/components/ui/radio-group.tsx +1 -2
- package/src/components/ui/resizable.tsx +1 -1
- package/src/components/ui/select.tsx +4 -4
- package/src/components/ui/separator.tsx +0 -1
- package/src/components/ui/sheet.tsx +3 -3
- package/src/components/ui/sidebar.tsx +14 -14
- package/src/components/ui/switch.tsx +2 -3
- package/src/components/ui/textarea.tsx +1 -1
- package/src/components/ui/toast.tsx +221 -0
- package/src/components/ui/toggle-group.tsx +2 -2
- package/src/components/ui/toggle.tsx +1 -1
- package/src/components/ui/tooltip.tsx +0 -1
- package/src/index.ts +41 -0
- package/src/lib/month.ts +60 -0
- package/src/styles.css +10 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,42 @@
|
|
|
2
2
|
|
|
3
3
|
รูปแบบตาม [Keep a Changelog](https://keepachangelog.com/) · semver: patch = หน้าตา/บั๊ก, minor = component/prop ใหม่, major = เปลี่ยน/ลบ props
|
|
4
4
|
|
|
5
|
+
## [0.20.1] — 2026-10-01
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- **`confirm.requireText` ถูกทิ้งเงียบ ๆ** (รายงานจาก Pi Config): `RowActions`, `ConfirmActionButton` และ `ActionMenu` ส่งให้ `ConfirmDialog` แค่ title/description/label/destructive — ใส่ `confirm: { requireText: slug }` แล้วลบได้ด้วยคลิกเดียวทั้งที่ `ConfirmSpec` ประกาศว่ารองรับ → ส่ง `requireText` + `requireTextLabel` ต่อครบทั้งสามที่; tests 2 กรณี (รวม 185)
|
|
10
|
+
|
|
11
|
+
## [0.20.0] — 2026-10-01
|
|
12
|
+
|
|
13
|
+
ขั้น "layout" ของแผนรวม UI ทุกแอป (ดู `todo/todo.md`) — เพิ่มอย่างเดียว ไม่มี prop เดิมเปลี่ยนความหมาย
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
|
|
17
|
+
- **`MobileTabBar`** (`/next`) + **`AppShell mobileTabs`** — แถบเมนูล่างของมือถือ 3–5 แท็บ (ยกจาก DueBell `MobileNav`): ซ่อนตั้งแต่ `md`, แท็บสูง ≥ 56px, `badge` เป็น `CountBadge` บนไอคอน (รวมใน aria-label), `onClick` แทนการไปหน้า, ไฮไลต์ตาม path (`/` ตรงตัว, อื่น ๆ prefix หรือ `exact`); `icon` เป็น **element** (`<Home />`) จึงส่งจาก server layout ได้เหมือน `AppNav`; `AppShell` ทั้ง layout sidebar/top เว้นที่ใต้เนื้อหาให้เอง
|
|
18
|
+
- utility **`pb-safe`** (safe-area ล่างของ iPhone) และ **`pb-tab-bar`** (เว้นที่ให้พ้นแถบล่าง) ใน `styles.css`
|
|
19
|
+
- **`SettingsLayout`** — หน้าตั้งค่าหลายหัวข้อ (ยกจาก DueBell master-detail + PDF ค้นหาได้): จอกว้างสารบัญซ้าย sticky + เนื้อหา, มือถือรายการหัวข้อ → หน้าหัวข้อ + ปุ่ม "กลับ" (ย้ายโฟกัสไปหัวข้อให้ screen reader), `searchable` ค้น label/description/`keywords` (ไทย+อังกฤษ), `badge` ท้ายรายการ, คุมเองด้วย `value`/`onValueChange` หรือ `initialSection`
|
|
20
|
+
- **`FilterBar`** — แถบค้นหา + select ตัวกรองเหนือรายการ client-side (Pi Auth เขียนซ้ำ 14 หน้า): ปุ่ม "ล้างตัวกรอง" โผล่เมื่อมีค่าค้าง (เทียบกับ `allValue` ของแต่ละตัว) และตั้งกลับให้เองถ้าไม่ส่ง `onClear`; ตัวกรองแบบอื่นใส่เป็น `children` + `active`
|
|
21
|
+
- **`SetupChecklist`** — รายการ "ต้องตั้งอะไรให้ครบ" (ยกจาก Pi Auth `SetupSteps`): ✓ เมื่อทำแล้ว, ขั้นบังคับถัดไปถูกเน้น (`aria-current="step"`), ขั้น `optional` ไม่นับ, ครบแล้วซ่อนทั้งกล่อง (`hideWhenComplete`); `action` รับ `{ current, done }` ให้แอปส่ง `LinkButton` เอง (barrel ไม่พึ่ง next)
|
|
22
|
+
- **`MonthStepper`** + **`useMonthCursor`** — เลือกเดือน ‹ เดือน › กดชื่อเดือนกลับเดือนนี้, `min`/`max`, `locale` (`th-TH` = พ.ศ., `th-TH-u-ca-gregory` = ค.ศ.), `format` long|short, `formatLabel` (ยกจาก DueBell `MonthSelector`)
|
|
23
|
+
- **`lib/month.ts`** (barrel): `MonthKey` (`"YYYY-MM"` แบบเดียวกับ `CalendarMonthGrid`), `parseMonthKey`, `isMonthKey`, `toMonthKey`, `currentMonthKey`, `shiftMonth` (ข้ามปีให้), `compareMonth`, `formatMonthKey` — แทน `CycleMonth` ของ DueBell
|
|
24
|
+
- `UiText` key ใหม่ (optional ทั้งหมด มีค่าไทย/อังกฤษใน `UI_TEXT_TH`/`UI_TEXT_EN`): `month`, `setup`, `filters`, `settings`, `mobileTabs`
|
|
25
|
+
- **ชุด component สำเร็จรูป (Simple\*)** — ทิศทางของแพ็กเกจ: แอปเขียน component เดียว + props แทนการประกอบ primitive หลายชิ้นซ้อนกันแบบ shadcn (ดู `docs/agent-rules.md` "ใช้ตัวสำเร็จรูปก่อน"):
|
|
26
|
+
- `SimpleDialog` (แทน Dialog 7 ชิ้น) / `SimpleSheet` (+ `side`) — trigger, title, description, children, `footer` (ReactNode หรือ `(close) => …`), `size`, controlled ได้; **ปุ่ม X อ่าน `UiText.close`** แก้ปัญหา "Close" แปลภาษาไม่ได้โดยไม่แตะไฟล์ shadcn
|
|
27
|
+
- `SimpleTabs` (items: value/label/content/icon/badge/disabled, `variant` default|line, `fullWidth`) / `SimpleAccordion` (`multiple`, `initialOpen`)
|
|
28
|
+
- `SimpleTooltip` (ห่อ element เดียว, content ว่าง = ไม่มี tooltip) / `SimplePopover` (trigger + title/description + เนื้อหา)
|
|
29
|
+
- `ActionMenu` — เมนูคำสั่งจาก array: icon, shortcut, separator, label, href/external, destructive, **`confirm` เปิด `ConfirmDialog` ก่อนเรียก onClick**; trigger ไม่ส่ง = ปุ่ม …
|
|
30
|
+
- `SimplePagination` + `paginationRange` — page/pageCount/onPageChange, จำนวนปุ่มคงที่, มือถือเหลือ "x / y"
|
|
31
|
+
- `SimpleTable<T>` — columns (key/header/cell/align/className) + rows + getRowKey + empty + onRowClick; ค่าจาก key ตรง ๆ ถ้าไม่ส่ง cell
|
|
32
|
+
- `SegmentedControl<T>` — เลือกทีละหนึ่งและต้องมีค่าเสมอ (กดตัวเดิมไม่หลุด), `count`, `icon`, `size`, `fullWidth`
|
|
33
|
+
- `UiText` key ใหม่: `close`, `pagination`
|
|
34
|
+
- **shadcn/ui base-nova ล่าสุดทั้งชุด** (`shadcn add --all --overwrite`, CLI 4.21.0): primitive ใหม่ `bubble`, `marker`, `message`, `message-scroller`, `questionnaire`, `direction` (export ใน barrel) และ `toast` (Base UI — **ไม่อยู่ใน barrel** เพราะชื่อ `toast`/`Toaster` ชนกับ sonner ที่แพ็กเกจใช้; import จาก `@suphark/ui/components/ui/toast`); dep ใหม่ `@shadcn/react`
|
|
35
|
+
- ไฟล์เดิมได้ class/ลำดับจาก upstream (ส่วนใหญ่เรียง class ใหม่) + sidebar deps ของ useEffect/useMemo ครบขึ้น
|
|
36
|
+
- **คงของที่แก้ไว้เอง** (ติดมาจาก supplychain ตั้งแต่ 0.1.0 — upstream ยังไม่มี): `scroll-area.tsx` (`data-[orientation=…]` ของ Base UI — ไม่งั้นแถบเลื่อนกว้าง 0 มองไม่เห็นทั้งแอป), `slider.tsx`, `tabs.tsx` (orientation แบบเดียวกัน), `table.tsx` (prop `containerClassName`)
|
|
37
|
+
- CLI รุ่นนี้เขียน `import { cn } from "cn"` — เปลี่ยนกลับเป็น `@suphark/ui/lib/utils` ทุกไฟล์ เพราะ `cn` ของแพ็กเกจรู้จักคลาสตามบทบาท (`text-body` ฯลฯ, 0.13.1) ส่วน `cn` ทั่วไปจะลบทิ้งเงียบ ๆ
|
|
38
|
+
- `hooks/use-mobile.ts` (CLI เขียนทับ) เติม `"use client"` คืน; biome ปิด `noDoubleEquals` เฉพาะ `components/ui/**` (`field.tsx` ของ upstream ใช้ `==`)
|
|
39
|
+
- showcase: หน้า `/components/{month-stepper,filter-bar,setup-checklist,settings-layout,mobile-tab-bar,simple-dialog,simple-sheet,simple-tabs,simple-accordion,simple-tooltip,simple-popover,action-menu,simple-pagination,simple-table,segmented-control}` (118 หน้า) + `/shell` มี `mobileTabs` ให้ลองบนจอมือถือ; tests 20 กรณีเพิ่ม (รวม 183)
|
|
40
|
+
|
|
5
41
|
## [0.19.0] — 2026-09-30
|
|
6
42
|
|
|
7
43
|
ปิดรายการ "component เล็กที่ใช้ร่วม" ของแผนรวม UI ทุกแอป (ดู `todo/todo.md`) — เพิ่มอย่างเดียว ไม่มี prop เดิมเปลี่ยนความหมาย
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@suphark/ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.20.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 เอง",
|
|
@@ -54,11 +54,21 @@
|
|
|
54
54
|
"./hooks/*": "./src/hooks/*.ts",
|
|
55
55
|
"./package.json": "./package.json"
|
|
56
56
|
},
|
|
57
|
+
"scripts": {
|
|
58
|
+
"typecheck": "tsc --noEmit",
|
|
59
|
+
"audit": "node scripts/audit.mjs src --important",
|
|
60
|
+
"test": "vitest run",
|
|
61
|
+
"test:watch": "vitest",
|
|
62
|
+
"lint:base-ui": "node scripts/audit-base-ui-props.mjs",
|
|
63
|
+
"check:typography": "node scripts/audit-tailwind-typography.mjs",
|
|
64
|
+
"verify": "pnpm typecheck && pnpm lint:base-ui && pnpm check:typography && pnpm test"
|
|
65
|
+
},
|
|
57
66
|
"dependencies": {
|
|
58
67
|
"@dnd-kit/core": "^6.3.1",
|
|
59
68
|
"@dnd-kit/sortable": "^10.0.0",
|
|
60
69
|
"@dnd-kit/utilities": "^3.2.2",
|
|
61
70
|
"@fontsource/noto-sans-thai-looped": "^5.3.0",
|
|
71
|
+
"@shadcn/react": "^0.3.1",
|
|
62
72
|
"class-variance-authority": "^0.7.1",
|
|
63
73
|
"clsx": "^2.1.1",
|
|
64
74
|
"cmdk": "^1.1.1",
|
|
@@ -67,7 +77,7 @@
|
|
|
67
77
|
"embla-carousel-react": "^8.6.0",
|
|
68
78
|
"input-otp": "^1.5.0",
|
|
69
79
|
"next-themes": "^0.4.6",
|
|
70
|
-
"react-day-picker": "^10.0.
|
|
80
|
+
"react-day-picker": "^10.0.2",
|
|
71
81
|
"react-number-format": "^5.4.5",
|
|
72
82
|
"react-resizable-panels": "^4.12.3",
|
|
73
83
|
"sonner": "^2.0.8",
|
|
@@ -112,18 +122,9 @@
|
|
|
112
122
|
"react": "^19.3.0",
|
|
113
123
|
"react-dom": "^19.3.0",
|
|
114
124
|
"react-hook-form": "^7.86.0",
|
|
115
|
-
"recharts": "^3.
|
|
125
|
+
"recharts": "^3.8.0",
|
|
116
126
|
"tailwindcss": "^4.3.3",
|
|
117
127
|
"typescript": "^6.0.3",
|
|
118
128
|
"vitest": "^4.1.11"
|
|
119
|
-
},
|
|
120
|
-
"scripts": {
|
|
121
|
-
"typecheck": "tsc --noEmit",
|
|
122
|
-
"audit": "node scripts/audit.mjs src --important",
|
|
123
|
-
"test": "vitest run",
|
|
124
|
-
"test:watch": "vitest",
|
|
125
|
-
"lint:base-ui": "node scripts/audit-base-ui-props.mjs",
|
|
126
|
-
"check:typography": "node scripts/audit-tailwind-typography.mjs",
|
|
127
|
-
"verify": "pnpm typecheck && pnpm lint:base-ui && pnpm check:typography && pnpm test"
|
|
128
129
|
}
|
|
129
|
-
}
|
|
130
|
+
}
|
package/scripts/audit.mjs
CHANGED
|
File without changes
|
|
@@ -35,6 +35,7 @@ import { AppBrand, type AppBrandProps } from "./app-brand";
|
|
|
35
35
|
import { AppNav, type AppNavPinProps } from "./app-nav";
|
|
36
36
|
import { HeaderBreadcrumbs, type HeaderBreadcrumbsProps } from "./header-breadcrumbs";
|
|
37
37
|
import { HeaderCrumbs } from "./header-crumb";
|
|
38
|
+
import { MobileTabBar, type MobileTabItem } from "./mobile-tab-bar";
|
|
38
39
|
import { SubNav } from "./sub-nav";
|
|
39
40
|
import { isTabActive, TopTabs, topLevelTabs } from "./top-tabs";
|
|
40
41
|
import { type AppNavGroup, searchNav } from "./types";
|
|
@@ -102,6 +103,11 @@ export interface AppShellProps {
|
|
|
102
103
|
contentClassName?: string;
|
|
103
104
|
/** จำกัดความกว้างเนื้อหา เช่น `max-w-5xl` (Pi Auth) — ไม่ส่ง = เต็มจอ (supplychain) */
|
|
104
105
|
contentMaxWidth?: string;
|
|
106
|
+
/**
|
|
107
|
+
* แถบเมนูล่างบนมือถือ (< md) 3–5 แท็บ — เว้นที่ใต้เนื้อหาให้เอง; เดสก์ท็อปยังใช้ sidebar/แท็บบนตามเดิม
|
|
108
|
+
* (ยกจาก DueBell MobileNav)
|
|
109
|
+
*/
|
|
110
|
+
mobileTabs?: ReadonlyArray<MobileTabItem>;
|
|
105
111
|
children: ReactNode;
|
|
106
112
|
}
|
|
107
113
|
|
|
@@ -147,6 +153,7 @@ function SidebarShell({
|
|
|
147
153
|
defaultOpen = true,
|
|
148
154
|
contentClassName,
|
|
149
155
|
contentMaxWidth,
|
|
156
|
+
mobileTabs,
|
|
150
157
|
children,
|
|
151
158
|
}: AppShellProps) {
|
|
152
159
|
const style = brandColor
|
|
@@ -224,12 +231,14 @@ function SidebarShell({
|
|
|
224
231
|
<div
|
|
225
232
|
className={cn(
|
|
226
233
|
"flex flex-1 flex-col gap-4 p-4 md:gap-6 md:p-6 print:overflow-visible",
|
|
234
|
+
mobileTabs?.length && "pb-tab-bar md:pb-6",
|
|
227
235
|
contentMaxWidth && cn("mx-auto w-full", contentMaxWidth),
|
|
228
236
|
contentClassName,
|
|
229
237
|
)}
|
|
230
238
|
>
|
|
231
239
|
{children}
|
|
232
240
|
</div>
|
|
241
|
+
{mobileTabs?.length ? <MobileTabBar items={mobileTabs} /> : null}
|
|
233
242
|
</SidebarInset>
|
|
234
243
|
</SidebarProvider>
|
|
235
244
|
);
|
|
@@ -248,6 +257,7 @@ function TopShell({
|
|
|
248
257
|
brandForeground,
|
|
249
258
|
contentClassName,
|
|
250
259
|
contentMaxWidth,
|
|
260
|
+
mobileTabs,
|
|
251
261
|
children,
|
|
252
262
|
}: AppShellProps) {
|
|
253
263
|
const pathname = usePathname();
|
|
@@ -312,6 +322,7 @@ function TopShell({
|
|
|
312
322
|
<div
|
|
313
323
|
className={cn(
|
|
314
324
|
"flex w-full flex-1 flex-col gap-4 p-4 md:flex-row md:gap-8 md:p-6",
|
|
325
|
+
mobileTabs?.length && "pb-tab-bar md:pb-6",
|
|
315
326
|
contentMaxWidth && cn("mx-auto", contentMaxWidth),
|
|
316
327
|
)}
|
|
317
328
|
>
|
|
@@ -335,6 +346,7 @@ function TopShell({
|
|
|
335
346
|
{children}
|
|
336
347
|
</main>
|
|
337
348
|
</div>
|
|
349
|
+
{mobileTabs?.length ? <MobileTabBar items={mobileTabs} /> : null}
|
|
338
350
|
</div>
|
|
339
351
|
);
|
|
340
352
|
}
|
|
@@ -10,6 +10,7 @@ export {
|
|
|
10
10
|
} from "./command-palette";
|
|
11
11
|
export { HeaderBreadcrumbs, type HeaderBreadcrumbsProps } from "./header-breadcrumbs";
|
|
12
12
|
export { HeaderCrumb, HeaderCrumbs } from "./header-crumb";
|
|
13
|
+
export { MobileTabBar, type MobileTabItem } from "./mobile-tab-bar";
|
|
13
14
|
export { NavLink } from "./nav-link";
|
|
14
15
|
export {
|
|
15
16
|
type OrganizationOption,
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { CountBadge } from "@suphark/ui/components/shared/count-badge";
|
|
4
|
+
import { useUiText } from "@suphark/ui/components/shared/ui-text";
|
|
5
|
+
import { cn } from "@suphark/ui/lib/utils";
|
|
6
|
+
import Link from "next/link";
|
|
7
|
+
import { usePathname } from "next/navigation";
|
|
8
|
+
import type { ReactNode } from "react";
|
|
9
|
+
import { isNavActive } from "./types";
|
|
10
|
+
|
|
11
|
+
export interface MobileTabItem {
|
|
12
|
+
href: string;
|
|
13
|
+
label: string;
|
|
14
|
+
/** element เช่น `<Home />` (ไม่ใช่ component) — ส่งจาก server layout ได้เหมือน AppNav */
|
|
15
|
+
icon: ReactNode;
|
|
16
|
+
/** ตัวเลขบนไอคอน (เช่นแจ้งเตือนค้าง) — 0/undefined ไม่แสดง */
|
|
17
|
+
badge?: number;
|
|
18
|
+
/** ไฮไลต์เฉพาะ path ตรงตัว (ค่าเริ่มต้น: href "/" ตรงตัว, อื่น ๆ prefix) */
|
|
19
|
+
exact?: boolean;
|
|
20
|
+
/** กดแล้วทำ action แทนการไปหน้า (เช่นเปิด sheet แจ้งเตือน) */
|
|
21
|
+
onClick?: () => void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* แถบเมนูล่างสำหรับมือถือ (ยกจาก DueBell `MobileNav`) — ซ่อนตั้งแต่ md, เผื่อ safe-area ของ iPhone,
|
|
26
|
+
* แท็บละ ≥ 44px ตามเกณฑ์จอสัมผัส; ใช้ผ่าน `AppShell mobileTabs` (เว้นที่ใต้เนื้อหาให้เอง)
|
|
27
|
+
* หรือวางเองแล้วใส่ `pb-[calc(4rem+env(safe-area-inset-bottom))] md:pb-0` ให้เนื้อหา
|
|
28
|
+
* ควรมี 3–5 แท็บ — เกินนั้นให้ใช้เมนู sidebar
|
|
29
|
+
*/
|
|
30
|
+
export function MobileTabBar({
|
|
31
|
+
items,
|
|
32
|
+
className,
|
|
33
|
+
"aria-label": ariaLabel,
|
|
34
|
+
}: {
|
|
35
|
+
items: ReadonlyArray<MobileTabItem>;
|
|
36
|
+
className?: string;
|
|
37
|
+
"aria-label"?: string;
|
|
38
|
+
}) {
|
|
39
|
+
const pathname = usePathname();
|
|
40
|
+
const text = useUiText();
|
|
41
|
+
return (
|
|
42
|
+
<nav
|
|
43
|
+
data-slot="mobile-tab-bar"
|
|
44
|
+
aria-label={ariaLabel ?? text.mobileTabs ?? "เมนูหลัก"}
|
|
45
|
+
className={cn(
|
|
46
|
+
"fixed inset-x-0 bottom-0 z-40 border-t bg-background/95 pb-safe backdrop-blur md:hidden print:hidden",
|
|
47
|
+
className,
|
|
48
|
+
)}
|
|
49
|
+
>
|
|
50
|
+
<ul className="mx-auto flex max-w-lg items-stretch justify-around px-1">
|
|
51
|
+
{items.map((item) => {
|
|
52
|
+
const active = isNavActive(pathname, item.href, item.exact ?? item.href === "/");
|
|
53
|
+
const content: ReactNode = (
|
|
54
|
+
<>
|
|
55
|
+
<span className="relative">
|
|
56
|
+
<span
|
|
57
|
+
aria-hidden
|
|
58
|
+
className={cn("flex [&_svg]:size-5", active && "[&_svg]:stroke-[2.5]")}
|
|
59
|
+
>
|
|
60
|
+
{item.icon}
|
|
61
|
+
</span>
|
|
62
|
+
{item.badge ? (
|
|
63
|
+
<CountBadge
|
|
64
|
+
value={item.badge}
|
|
65
|
+
tone="destructive"
|
|
66
|
+
className="absolute -top-1.5 -right-3 min-w-4 px-1"
|
|
67
|
+
/>
|
|
68
|
+
) : null}
|
|
69
|
+
</span>
|
|
70
|
+
<span className="text-label leading-tight">{item.label}</span>
|
|
71
|
+
</>
|
|
72
|
+
);
|
|
73
|
+
const itemClass = cn(
|
|
74
|
+
"flex min-h-14 w-full min-w-14 flex-col items-center justify-center gap-0.5 rounded-lg px-2 py-1.5 no-underline outline-none transition-colors focus-visible:ring-2 focus-visible:ring-ring",
|
|
75
|
+
active ? "font-semibold text-primary" : "text-muted-foreground hover:text-foreground",
|
|
76
|
+
);
|
|
77
|
+
return (
|
|
78
|
+
<li key={item.href} className="flex flex-1">
|
|
79
|
+
{item.onClick ? (
|
|
80
|
+
<button
|
|
81
|
+
type="button"
|
|
82
|
+
onClick={item.onClick}
|
|
83
|
+
className={itemClass}
|
|
84
|
+
aria-label={item.badge ? `${item.label} (${item.badge})` : undefined}
|
|
85
|
+
>
|
|
86
|
+
{content}
|
|
87
|
+
</button>
|
|
88
|
+
) : (
|
|
89
|
+
<Link
|
|
90
|
+
href={item.href}
|
|
91
|
+
className={itemClass}
|
|
92
|
+
aria-current={active ? "page" : undefined}
|
|
93
|
+
aria-label={item.badge ? `${item.label} (${item.badge})` : undefined}
|
|
94
|
+
>
|
|
95
|
+
{content}
|
|
96
|
+
</Link>
|
|
97
|
+
)}
|
|
98
|
+
</li>
|
|
99
|
+
);
|
|
100
|
+
})}
|
|
101
|
+
</ul>
|
|
102
|
+
</nav>
|
|
103
|
+
);
|
|
104
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Button } from "@suphark/ui/components/ui/button";
|
|
4
|
+
import {
|
|
5
|
+
DropdownMenu,
|
|
6
|
+
DropdownMenuContent,
|
|
7
|
+
DropdownMenuGroup,
|
|
8
|
+
DropdownMenuItem,
|
|
9
|
+
DropdownMenuLabel,
|
|
10
|
+
DropdownMenuSeparator,
|
|
11
|
+
DropdownMenuShortcut,
|
|
12
|
+
DropdownMenuTrigger,
|
|
13
|
+
} from "@suphark/ui/components/ui/dropdown-menu";
|
|
14
|
+
import { MoreHorizontal } from "lucide-react";
|
|
15
|
+
import { type ReactElement, type ReactNode, useState } from "react";
|
|
16
|
+
import { ConfirmDialog, type ConfirmSpec, normalizeConfirm } from "./confirm-dialog";
|
|
17
|
+
import { useUiText } from "./ui-text";
|
|
18
|
+
|
|
19
|
+
export interface ActionMenuCommand {
|
|
20
|
+
type?: "item";
|
|
21
|
+
label: ReactNode;
|
|
22
|
+
icon?: ReactNode;
|
|
23
|
+
onClick?: () => unknown;
|
|
24
|
+
/** ลิงก์ธรรมดา (`<a>`) — ลิงก์ภายในของ Next ที่ต้อง prefetch ใช้ onClick + router.push */
|
|
25
|
+
href?: string;
|
|
26
|
+
/** เปิดแท็บใหม่ (ใช้กับ href) */
|
|
27
|
+
external?: boolean;
|
|
28
|
+
shortcut?: string;
|
|
29
|
+
destructive?: boolean;
|
|
30
|
+
disabled?: boolean;
|
|
31
|
+
/** ถามยืนยันก่อน onClick (string = คำถาม หรือ object กำหนดหัว/ปุ่มเอง) */
|
|
32
|
+
confirm?: ConfirmSpec;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export type ActionMenuItem =
|
|
36
|
+
| ActionMenuCommand
|
|
37
|
+
| { type: "separator" }
|
|
38
|
+
| { type: "label"; label: ReactNode };
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* เมนูคำสั่งสำเร็จรูปจาก array (แทน DropdownMenu/Trigger/Content/Item/Separator/Label/Shortcut)
|
|
42
|
+
* trigger ไม่ส่ง = ปุ่มไอคอน "…" (aria-label จาก `label` หรือ UiText.actions)
|
|
43
|
+
* แถวในตาราง/รายการที่เรียก server action + refresh ใช้ `RowActions` (`@suphark/ui/next`)
|
|
44
|
+
*
|
|
45
|
+
* <ActionMenu items={[
|
|
46
|
+
* { label: "แก้ไข", icon: <Pencil />, onClick: edit },
|
|
47
|
+
* { type: "separator" },
|
|
48
|
+
* { label: "ลบ", icon: <Trash2 />, destructive: true, confirm: "ลบรายการนี้?", onClick: remove },
|
|
49
|
+
* ]} />
|
|
50
|
+
*/
|
|
51
|
+
export function ActionMenu({
|
|
52
|
+
items,
|
|
53
|
+
trigger,
|
|
54
|
+
label,
|
|
55
|
+
align = "end",
|
|
56
|
+
}: {
|
|
57
|
+
items: ReadonlyArray<ActionMenuItem>;
|
|
58
|
+
trigger?: ReactElement;
|
|
59
|
+
/** aria-label ของปุ่ม … (ค่าเริ่มต้น UiText.actions) */
|
|
60
|
+
label?: string;
|
|
61
|
+
align?: "start" | "center" | "end";
|
|
62
|
+
}) {
|
|
63
|
+
const text = useUiText();
|
|
64
|
+
const [confirming, setConfirming] = useState<ActionMenuCommand | null>(null);
|
|
65
|
+
const [pending, setPending] = useState(false);
|
|
66
|
+
|
|
67
|
+
const run = async (fn?: () => unknown) => {
|
|
68
|
+
if (!fn) return;
|
|
69
|
+
setPending(true);
|
|
70
|
+
try {
|
|
71
|
+
await fn();
|
|
72
|
+
} finally {
|
|
73
|
+
setPending(false);
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const spec = confirming?.confirm ? normalizeConfirm(confirming.confirm) : null;
|
|
78
|
+
|
|
79
|
+
return (
|
|
80
|
+
<>
|
|
81
|
+
<DropdownMenu>
|
|
82
|
+
<DropdownMenuTrigger
|
|
83
|
+
render={
|
|
84
|
+
trigger ?? (
|
|
85
|
+
<Button variant="ghost" size="icon-sm" aria-label={label ?? text.actions}>
|
|
86
|
+
<MoreHorizontal />
|
|
87
|
+
</Button>
|
|
88
|
+
)
|
|
89
|
+
}
|
|
90
|
+
/>
|
|
91
|
+
<DropdownMenuContent align={align} className="min-w-40">
|
|
92
|
+
<DropdownMenuGroup>
|
|
93
|
+
{items.map((item, i) => {
|
|
94
|
+
if (item.type === "separator") {
|
|
95
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: ตัวคั่นไม่มี id — ลำดับคงที่ตาม items
|
|
96
|
+
return <DropdownMenuSeparator key={`sep-${i}`} />;
|
|
97
|
+
}
|
|
98
|
+
if (item.type === "label") {
|
|
99
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: หัวกลุ่มไม่มี id — ลำดับคงที่ตาม items
|
|
100
|
+
return <DropdownMenuLabel key={`label-${i}`}>{item.label}</DropdownMenuLabel>;
|
|
101
|
+
}
|
|
102
|
+
const content = (
|
|
103
|
+
<>
|
|
104
|
+
{item.icon}
|
|
105
|
+
{item.label}
|
|
106
|
+
{item.shortcut && <DropdownMenuShortcut>{item.shortcut}</DropdownMenuShortcut>}
|
|
107
|
+
</>
|
|
108
|
+
);
|
|
109
|
+
return (
|
|
110
|
+
<DropdownMenuItem
|
|
111
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: label เป็น ReactNode ใช้เป็น key ไม่ได้
|
|
112
|
+
key={`item-${i}`}
|
|
113
|
+
variant={item.destructive ? "destructive" : "default"}
|
|
114
|
+
disabled={item.disabled}
|
|
115
|
+
render={
|
|
116
|
+
item.href ? (
|
|
117
|
+
<a
|
|
118
|
+
href={item.href}
|
|
119
|
+
target={item.external ? "_blank" : undefined}
|
|
120
|
+
rel={item.external ? "noreferrer" : undefined}
|
|
121
|
+
/>
|
|
122
|
+
) : undefined
|
|
123
|
+
}
|
|
124
|
+
onClick={() => {
|
|
125
|
+
if (item.href) return;
|
|
126
|
+
if (item.confirm) setConfirming(item);
|
|
127
|
+
else void run(item.onClick);
|
|
128
|
+
}}
|
|
129
|
+
>
|
|
130
|
+
{content}
|
|
131
|
+
</DropdownMenuItem>
|
|
132
|
+
);
|
|
133
|
+
})}
|
|
134
|
+
</DropdownMenuGroup>
|
|
135
|
+
</DropdownMenuContent>
|
|
136
|
+
</DropdownMenu>
|
|
137
|
+
{spec && confirming && (
|
|
138
|
+
<ConfirmDialog
|
|
139
|
+
open
|
|
140
|
+
onOpenChange={(o) => !o && setConfirming(null)}
|
|
141
|
+
title={spec.title}
|
|
142
|
+
description={spec.description}
|
|
143
|
+
confirmLabel={spec.confirmLabel}
|
|
144
|
+
cancelLabel={spec.cancelLabel}
|
|
145
|
+
requireText={spec.requireText}
|
|
146
|
+
requireTextLabel={spec.requireTextLabel}
|
|
147
|
+
destructive={spec.destructive ?? confirming.destructive}
|
|
148
|
+
pending={pending}
|
|
149
|
+
onConfirm={async () => {
|
|
150
|
+
await run(confirming.onClick);
|
|
151
|
+
setConfirming(null);
|
|
152
|
+
}}
|
|
153
|
+
/>
|
|
154
|
+
)}
|
|
155
|
+
</>
|
|
156
|
+
);
|
|
157
|
+
}
|
|
@@ -66,6 +66,8 @@ export function ConfirmActionButton({
|
|
|
66
66
|
description={spec.description}
|
|
67
67
|
confirmLabel={spec.confirmLabel}
|
|
68
68
|
cancelLabel={spec.cancelLabel}
|
|
69
|
+
requireText={spec.requireText}
|
|
70
|
+
requireTextLabel={spec.requireTextLabel}
|
|
69
71
|
destructive={destructive}
|
|
70
72
|
pending={pending}
|
|
71
73
|
onConfirm={() => {
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Button } from "@suphark/ui/components/ui/button";
|
|
4
|
+
import { NativeSelect, NativeSelectOption } from "@suphark/ui/components/ui/native-select";
|
|
5
|
+
import { cn } from "@suphark/ui/lib/utils";
|
|
6
|
+
import { XIcon } from "lucide-react";
|
|
7
|
+
import type { ReactNode } from "react";
|
|
8
|
+
import { SearchInput } from "./form/search-input";
|
|
9
|
+
import { useUiText } from "./ui-text";
|
|
10
|
+
|
|
11
|
+
export interface FilterBarFilter {
|
|
12
|
+
id: string;
|
|
13
|
+
/** aria-label ของ select (ไม่มี label ที่มองเห็น) */
|
|
14
|
+
label: string;
|
|
15
|
+
value: string;
|
|
16
|
+
onValueChange: (value: string) => void;
|
|
17
|
+
options: ReadonlyArray<{ value: string; label: string }>;
|
|
18
|
+
/** ค่าที่หมายถึง "ทั้งหมด" (ไม่กรอง) — ใช้ตัดสินว่ามีตัวกรองค้างไหม และค่าที่ปุ่มล้างตั้งกลับ (ค่าเริ่มต้น "") */
|
|
19
|
+
allValue?: string;
|
|
20
|
+
className?: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* แถบค้นหา + ตัวกรองเหนือตาราง/รายการแบบ client-side (ยกจาก Pi Auth — มี 14 หน้าเขียนซ้ำ):
|
|
25
|
+
* ช่องค้นหาซ้าย · select ตัวกรองขวา · ปุ่ม "ล้างตัวกรอง" โผล่เมื่อมีอะไรค้าง
|
|
26
|
+
* ตารางที่ใช้ tanstack ให้ใช้ toolbar ของ `@suphark/ui/data-table` แทน
|
|
27
|
+
*
|
|
28
|
+
* <FilterBar
|
|
29
|
+
* search={{ value: q, onValueChange: setQ, placeholder: "ค้นหาชื่อหรืออีเมล" }}
|
|
30
|
+
* filters={[{ id: "role", label: "บทบาท", value: role, onValueChange: setRole, allValue: "ALL",
|
|
31
|
+
* options: [{ value: "ALL", label: "ทุกบทบาท" }, { value: "admin", label: "ผู้ดูแล" }] }]}
|
|
32
|
+
* />
|
|
33
|
+
*/
|
|
34
|
+
export function FilterBar({
|
|
35
|
+
search,
|
|
36
|
+
filters = [],
|
|
37
|
+
onClear,
|
|
38
|
+
active,
|
|
39
|
+
children,
|
|
40
|
+
className,
|
|
41
|
+
}: {
|
|
42
|
+
search?: { value: string; onValueChange: (value: string) => void; placeholder?: string };
|
|
43
|
+
filters?: FilterBarFilter[];
|
|
44
|
+
/** ไม่ส่ง = ล้างช่องค้นหา + ตั้งทุกตัวกรองกลับ `allValue` ให้เอง */
|
|
45
|
+
onClear?: () => void;
|
|
46
|
+
/** บังคับสถานะ "มีตัวกรองค้าง" (เช่นมีตัวกรองใน children) — ไม่ส่ง = คำนวณจาก search/filters */
|
|
47
|
+
active?: boolean;
|
|
48
|
+
/** ตัวกรองแบบอื่นที่แอปประกอบเอง (DateRangePicker, ToggleGroup …) วางต่อจาก select */
|
|
49
|
+
children?: ReactNode;
|
|
50
|
+
className?: string;
|
|
51
|
+
}) {
|
|
52
|
+
const text = useUiText();
|
|
53
|
+
const isActive =
|
|
54
|
+
active ?? ((search?.value ?? "") !== "" || filters.some((f) => f.value !== (f.allValue ?? "")));
|
|
55
|
+
|
|
56
|
+
const clear = () => {
|
|
57
|
+
if (onClear) return onClear();
|
|
58
|
+
search?.onValueChange("");
|
|
59
|
+
for (const f of filters) f.onValueChange(f.allValue ?? "");
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
return (
|
|
63
|
+
<div
|
|
64
|
+
data-slot="filter-bar"
|
|
65
|
+
role="search"
|
|
66
|
+
aria-label={text.filters?.label ?? "ตัวกรอง"}
|
|
67
|
+
className={cn(
|
|
68
|
+
"flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between",
|
|
69
|
+
className,
|
|
70
|
+
)}
|
|
71
|
+
>
|
|
72
|
+
{search && (
|
|
73
|
+
<SearchInput
|
|
74
|
+
value={search.value}
|
|
75
|
+
onValueChange={search.onValueChange}
|
|
76
|
+
placeholder={search.placeholder}
|
|
77
|
+
groupClassName="lg:max-w-sm flex-1"
|
|
78
|
+
/>
|
|
79
|
+
)}
|
|
80
|
+
{(filters.length > 0 || children || isActive) && (
|
|
81
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
82
|
+
{filters.map((f) => (
|
|
83
|
+
<NativeSelect
|
|
84
|
+
key={f.id}
|
|
85
|
+
aria-label={f.label}
|
|
86
|
+
value={f.value}
|
|
87
|
+
onChange={(e) => f.onValueChange(e.target.value)}
|
|
88
|
+
className={cn("w-auto max-w-48", f.className)}
|
|
89
|
+
>
|
|
90
|
+
{f.options.map((o) => (
|
|
91
|
+
<NativeSelectOption key={o.value} value={o.value}>
|
|
92
|
+
{o.label}
|
|
93
|
+
</NativeSelectOption>
|
|
94
|
+
))}
|
|
95
|
+
</NativeSelect>
|
|
96
|
+
))}
|
|
97
|
+
{children}
|
|
98
|
+
{isActive && (
|
|
99
|
+
<Button type="button" variant="ghost" size="sm" onClick={clear}>
|
|
100
|
+
<XIcon />
|
|
101
|
+
{text.filters?.clear ?? "ล้างตัวกรอง"}
|
|
102
|
+
</Button>
|
|
103
|
+
)}
|
|
104
|
+
</div>
|
|
105
|
+
)}
|
|
106
|
+
</div>
|
|
107
|
+
);
|
|
108
|
+
}
|