@suphark/ui
v0.83.0
Published
Design system กลางของ Suphark apps: shadcn/ui (base-nova, Base UI, Tailwind v4) + shared components + token/ฟอนต์ Noto Sans Thai Looped — ส่งเป็น source ให้แอป Next transpile เอง
Downloads
10,347
Maintainers
Readme
@suphark/ui
Design system กลางของ Suphark apps (Pi Auth, DueBell, Pi PDF, supplychain, แอปใหม่) — shadcn/ui สไตล์ base-nova (Base UI) + Tailwind v4 + lucide + ฟอนต์ Noto Sans Thai Looped + component ที่เราประกอบเองบนนั้น
แพ็กเกจส่ง source (.ts/.tsx) ไม่ build — แอป Next.js transpile เองผ่าน transpilePackages
ดูตัวอย่างสด: https://ui.suphark.com — /components (ตัวอย่าง + โค้ดทุก component), /design-system (กติกา), /shell (AppShell), /brand
ติดตั้ง
pnpm add @suphark/ui @base-ui/react lucide-react react-hook-formpeer ที่ต้องมีอยู่แล้ว: react/react-dom ≥19, tailwindcss ^4, next ≥15 (เฉพาะ @suphark/ui/next), @tanstack/react-table (เฉพาะ @suphark/ui/data-table), recharts (เฉพาะ components/ui/chart)
1. next.config.ts
const nextConfig: NextConfig = {
transpilePackages: ["@suphark/ui"],
};2. globals.css
@import "tailwindcss";
@import "@suphark/ui/styles.css"; /* token, ฟอนต์, base, dark variant */
@source "../../node_modules/@suphark/ui/src"; /* ให้ Tailwind สแกน class ในแพ็กเกจ (ปรับ ../ ตามตำแหน่งไฟล์) */ต้องการฟอนต์อื่น: override ใน @theme { --font-sans: ... } ของแอปหลัง import
3. root layout
import { ThemeProvider, Toaster } from "@suphark/ui";
<html lang="th" suppressHydrationWarning>
<body>
<ThemeProvider>
{children}
<Toaster richColors />
</ThemeProvider>
</body>
</html>4. tsconfig ของแอป
แอปจะ type-check source ของแพ็กเกจด้วย — แพ็กเกจผ่าน strict + noUncheckedIndexedAccess + verbatimModuleSyntax อยู่แล้ว
หลังอัปเดตเวอร์ชันแพ็กเกจ
ถ้า Turbopack ค้าง Package path ./styles.css is exported … but no valid target file (หรือ resolve entry อื่นไม่ได้) หลัง pnpm install เวอร์ชันใหม่ — เป็น cache ของ dev server ไม่ใช่ปัญหาแพ็กเกจ: หยุด dev server แล้วลบ .next ของแอป
การ import
// ทุกอย่างที่ไม่พึ่ง Next — primitives, shared, theme, hooks, cn
import { Button, Card, FormFieldInput, StatusBadge, ThemeToggle, ConfirmDialog, useConfirm, cn } from "@suphark/ui";
// file explorer / picker / rule: TreeView, Dropzone, UploadList, FileTypeIcon, ColorPicker, IconPicker, CalendarMonthGrid, RuleBuilder ก็อยู่ barrel
// ของที่ใช้ next/navigation หรือ next/link + โครงแอป
import { AppShell, UserMenu, filterNav, FormDialog, RowActions, LinkButton, RefreshButton, PageShell } from "@suphark/ui/next";
// ตารางข้อมูล (ต้องติดตั้ง @tanstack/react-table เอง)
import { DataTable, DataTableColumnHeader, useDataTable } from "@suphark/ui/data-table";
// PWA (PullToRefresh ใช้ next/navigation)
import { PwaServiceWorkerRegistration, PwaInstallCard, PullToRefresh } from "@suphark/ui/pwa";
// บอร์ด Kanban บน dnd-kit (แยก entry เหมือน data-table)
import { createKanbanCollisionDetection } from "@suphark/ui/kanban";
// ชุดไอคอนมาตรฐาน 300+ ตัว ค้นไทย/อังกฤษได้ (แยก entry เพราะ import ไอคอนทั้งชุด) — เก็บแค่ชื่อไอคอนในฐานข้อมูล
import { IconSelect, NamedIcon, ICON_CATALOG } from "@suphark/ui/icons";
// NamedIcon / IconSelect รับชื่อที่เก็บแบบเดิมได้ด้วย ("Home", "CreditCard", "alert-triangle") — ไม่ต้องแปลงข้อมูลใน DB
// ตรวจค่าฝั่ง server (server action / zod): icon: z.string().refine(isCatalogIconName) — 0.83.0
// ชุดสีมาตรฐาน 20 สี (สลับ dark ให้เอง) อยู่ใน barrel: ColorSelect, LabelBadge, paletteClasses, PALETTE_COLORS
// import ตรงรายไฟล์ก็ได้ (เช่น chart ที่ต้องมี recharts)
import { ChartContainer } from "@suphark/ui/components/ui/chart";โครงสร้าง
| โฟลเดอร์ | คืออะไร | กติกา |
|---|---|---|
| src/components/ui/* | shadcn/ui ต้นฉบับ 59 ตัว (+ __tests__) | ห้ามแก้มือ — เพิ่ม/อัปเดตด้วย pnpm dlx shadcn@latest add <name> ใน packages/ui (components.json ตั้ง alias ไว้แล้ว) |
| src/components/shared/* | ของที่เราประกอบเอง: ปุ่มมาตรฐาน (button/), form-field บน react-hook-form (form/), layout, theme, combobox, ชุดจาก Pi Auth (FormDialog, NameIdFields, StatusBadge, RowMenu, …), picker (IconPicker/ColorPicker/CalendarMonthGrid), file explorer (TreeView/Dropzone/UploadList/FileTypeIcon) | แก้ได้ แต่คง props เดิมให้มากที่สุด (minor = เพิ่ม, major = เปลี่ยน) |
| src/components/data-table/* | DataTable บน tanstack (toolbar, faceted filter, pagination, view options, row actions, ลากเรียงแถว) | entry @suphark/ui/data-table |
| src/components/pwa/* | PWA: ลงทะเบียน/อัปเดต service worker, การ์ดชวนติดตั้ง, ดึงลงเพื่อรีเฟรช | entry @suphark/ui/pwa |
| src/components/app-shell/* | AppShell (sidebar + header + เนื้อหา), AppNav, AppBrand, UserMenu, HeaderBreadcrumbs, NavLink, filterNav/searchNav | export ผ่าน @suphark/ui/next (ใช้ next/link, next/navigation) |
| src/components/design-system/* | หน้า showcase (render ใน apps/showcase) | ไม่อยู่ใน barrel |
| src/lib/*, src/hooks/* | cn, formatter วันที่ไทย + formatBytes, สีสถานะ, permission context, useIsMobile, useCopyToClipboard, useMounted, useTicker | |
| src/styles.css | token (slate + success/warning/info + พื้นอ่อนตามโทน *-subtle/*-border + sidebar/chart), สเกลตัวอักษรตามบทบาท (text-body …), radius, ฟอนต์, scrollbar, dark variant | |
| scripts/*.mjs | audit บังคับ: Base UI props (default*/onSelect), typography scale | แอปปลายทางรันได้จาก node_modules/@suphark/ui/scripts |
โครงแอป (AppShell)
// app/(app)/layout.tsx — server component ได้ (icon เป็น element)
import { ThemeMenuItems } from "@suphark/ui";
import { AppShell, type AppNavGroup, filterNav, UserMenu } from "@suphark/ui/next";
import { cookies } from "next/headers";
const NAV: AppNavGroup[] = [
{ title: "งาน", items: [
{ title: "แดชบอร์ด", href: "/", icon: <LayoutDashboard />, exact: true },
{ title: "จัดซื้อ", icon: <ShoppingCart />, items: [{ title: "แพ็กเกจ", href: "/packages", permission: "package:read" }] },
]},
];
export default async function Layout({ children }) {
const defaultOpen = (await cookies()).get("sidebar_state")?.value !== "false";
return (
<AppShell
brand={{ name: "Pi Auth", subtitle: tenant.name, version: "v1.2.0", versionHref: "/change-log" }}
nav={filterNav(NAV, can)}
searchable
defaultOpen={defaultOpen}
brandColor={tenant.primaryColor}
header={{ breadcrumbs: { skip: ["console"] }, actions: <NotificationBell /> }}
sidebarFooter={<UserMenu user={{ name, email, image }} onSignOut={signOut}><ThemeMenuItems /></UserMenu>}
>
{children}
</AppShell>
);
}layout="top"= แบบ Clerk dashboard:crumbs={[<HeaderCrumb …/>, …]}(switcher องค์กร/แอป/สภาพแวดล้อม — ห่อ DropdownMenu เอง),navชั้นแรกเป็นแท็บบน ชั้นสองเป็น sub-nav ซ้าย,header.actionsขวา — ตัวอย่างapps/showcase/src/app/shell-topheader={false}ไม่มี header ·collapsible="offcanvas"ซ่อนทั้งแผง (supplychain) ·contentMaxWidth="max-w-5xl"จำกัดความกว้าง (Pi Auth)pin={{ pinnedHrefs, onTogglePin }}เปิดปุ่มปักหมุดเมนู (แอปเก็บ state เอง) ·header.centerวาง pinned menus- layout top:
HeaderCrumbsบนจอแคบเหลือ crumb สุดท้าย + ปุ่ม…เปิด popover ให้สลับองค์กร/แอปได้ —header.actionsควรซ่อนของไม่จำเป็นบนมือถือ (hidden sm:inline-flex) ไม่งั้นแย่งที่ crumb sidebarHeaderซ่อนอัตโนมัติเมื่อยุบเป็นไอคอน — ใส่sidebarHeaderCollapsedถ้าต้องการไอคอนแทน (เช่นปุ่ม ⌘K)- ของที่ต้องเลื่อนไปกับเมนู (ต้นไม้โฟลเดอร์ยาว, มิเตอร์พื้นที่) ใส่
sidebarContentBefore/sidebarContentAfter(อยู่ใน SidebarContent) —sidebarHeaderตรึงอยู่บนไม่เลื่อน header.breadcrumbs.labelsรับฟังก์ชัน(segment, href) => ReactNodeได้ (ใส่t()ของ next-intl) — แต่ฟังก์ชันส่งจาก server layout ไม่ได้ (React error "Functions cannot be passed directly to Client Components") ให้ทำ client component ที่เรียก<HeaderBreadcrumbs labels={…} />แล้วส่งเป็นbreadcrumbs: <MyBreadcrumbs />(ดูapps/showcase/src/app/shell/breadcrumbs.tsx)<UserMenu>children คือรายการเมนู:ThemeMenuItemsมีDropdownMenuGroup+ หัว "ธีม" ในตัวแล้ว (ปรับข้อความด้วยlabel/labels) ไม่ต้องห่อเพิ่ม; รายการอื่นใส่DropdownMenuItem/DropdownMenuGroupเอง- ตัวอย่างรันได้ที่
apps/showcase/src/app/shell
Override contract — แอปปรับได้แค่นี้
| ทำได้ | ทำไม่ได้ |
|---|---|
| --brand / --brand-foreground (inline บน wrapper หรือ data-brand) | แก้ semantic token (--primary, --background, --muted, --destructive …) ตรง ๆ |
| --font-sans / --font-mono ใน @theme ของแอป | แก้ --sidebar-*, --chart-* |
| --radius (ค่าเดียว radius ทั้งหมดคำนวณจากตัวนี้) | เปลี่ยนวิธีสลับธีม (ต้องใช้ ThemeProvider ของแพ็กเกจ) |
| className เพื่อจัด layout | className เปลี่ยนสี/ขนาดตัวอักษรของ primitive |
กติกาฉบับเต็มสำหรับ agent/CLAUDE.md ของแอป: docs/agent-rules.md
สีแบรนด์ต่อ tenant/แอป (--brand)
ตั้ง --brand / --brand-foreground inline บน wrapper (หรือใส่ data-brand) แล้ว primary/ring/sidebar-primary ข้างในตามไปด้วย — ค่าเริ่มต้นไม่ตั้ง --brand (primary = slate) ดูตัวอย่างที่ apps/showcase/src/app/brand
<div style={{ "--brand": tenant.color, "--brand-foreground": "oklch(0.985 0 0)" } as React.CSSProperties}>…</div>ปุ่มไอคอน — tooltip มาให้เอง (มาตรฐานทุกโครงการ)
ไอคอนที่ไม่มีข้อความกำกับ ต้องบอกได้ว่าคืออะไรเมื่อวางเมาส์ — Button ของแพ็กเกจ ห่อ Tooltip ให้เองเมื่อมี aria-label และ children ไม่มีข้อความ (ไอคอนหรือ <span className="sr-only"> ไม่นับเป็นข้อความ) ทุก call site จึงได้ทันทีโดยไม่ต้องแก้อะไร รวมถึงปุ่มที่เป็น trigger ของ dialog/menu เพราะปุ่มอยู่ชั้นในสุดของ render=
<Button variant="ghost" size="icon-sm" aria-label="ดู client secret"><KeySquareIcon /></Button>
// → hover/focus แล้วขึ้น "ดู client secret" โดยไม่ต้องห่อ SimpleTooltip
<FormDialog trigger={<Button size="icon-sm" aria-label="แก้ไข"><PencilIcon /></Button>} … />
// → ได้ tooltip ด้วย (ไม่ต้องมี prop ใหม่ที่ FormDialog/RowActions)งานค้างต้องหมุน ห้ามเงียบ (0.36.0): <Button loading={saving}><SaveIcon /> บันทึก</Button> — ไอคอนกลายเป็น spinner, กดซ้ำไม่ได้, aria-busy · type="submit" ใน <form action={…}> หมุนเองจาก useFormStatus · FormDialog/ActionForm ทำให้ทั้งปุ่มหมุนและตาข่ายฟอร์ม invalid · ฟอร์มเขียนเอง:
const invalid = useFormInvalidNotice();
<form action={save} {...invalid.formProps}>
…
{invalid.notice /* "กรุณากรอกหรือแก้ไข: ชื่อ, รหัสสาขา" — รวมช่องที่ซ่อน/โฟกัสไม่ได้ */}
<Button type="submit">บันทึก</Button>
</form>ไม่อยากใส่ทีละฟอร์ม: <FormInvalidNet /> (0.37.0) แจ้งเป็น toast ให้ทุก <form> ที่ไม่มีตาข่ายของตัวเอง — AppShell ใส่ให้แล้ว หน้านอก AppShell วางใน root layout ใต้ Toaster
| ต้องการ | เขียน |
| --- | --- |
| ข้อความอื่นที่ไม่ใช่ aria-label (หรือปุ่มที่มีข้อความแล้วแต่อยากอธิบายเพิ่ม) | tooltip="ตรวจสอบอีกครั้ง" |
| ปิด (เช่นปุ่มในเมนูที่มีข้อความอยู่แล้ว) | tooltip={false} |
| เปลี่ยนด้าน / หน่วงเวลา | tooltipSide="bottom", tooltipDelay={0} |
- ไม่ต้องมี
TooltipProvider— Base UI ไม่บังคับ; ใส่ที่ root layout ได้ถ้าอยากคุม delay ร่วมกันทุก tooltip (ค่าเริ่มต้นของ Base UI = 600ms,TooltipProviderของแพ็กเกจตั้ง 0) - ปุ่มที่ถูกห่อ
SimpleTooltipอยู่แล้ว ไม่ขึ้นสองชั้น (SimpleTooltipปิดตัวอัตโนมัติให้ด้วยButtonTooltipSuppress) — ถ้าแอปห่อ tooltip ด้วยกลไกอื่นเอง ครอบ<ButtonTooltipSuppress>หรือใส่tooltip={false} - จอสัมผัสไม่มี hover —
aria-labelยังทำหน้าที่เดิมให้ screen reader; ข้อความที่ "ต้องอ่านได้ทุกคน" อย่าเก็บไว้ใน tooltip เท่านั้น - ไม่มี
aria-labelแต่ใช้รูปแบบ shadcn<span className="sr-only">ข้อความ</span>ข้างไอคอน ก็ใช้ข้อความนั้นเป็น tooltip ให้เหมือนกัน - ปุ่มที่
disabledไม่ขึ้น tooltip (ปุ่มที่ปิดอยู่ไม่รับ pointer ตามสไตล์ของ shadcn) — ถ้าต้องอธิบายว่าทำไมกดไม่ได้ ให้ห่อSimpleTooltipที่<span>ครอบปุ่มแทน - มี
title="…"อยู่เดิมให้เอาออก ไม่งั้นจะได้ tooltip ซ้อนกับของเบราว์เซอร์ <button>ดิบ (ไม่ผ่านButton) ไม่ได้ของนี้ — อีกเหตุผลที่ห้ามเขียนปุ่มเอง · และต้อง importButtonจาก@suphark/uiเท่านั้น:@suphark/ui/components/ui/buttonคือไฟล์ shadcn ดิบที่ไม่มี tooltip- ตรวจว่าปุ่มมี tooltip ไหม อย่าดูจาก
data-slot— ปุ่มที่เป็นrender=/triggerของ Dialog/Popover/DropdownMenu จะได้data-slotของ trigger ชั้นนอก (dialog-triggerฯลฯ) ทั้งที่มี tooltip อยู่ ให้เอาเมาส์ไปวางจริง หรือเช็ก[data-slot=tooltip-content]หลัง hover - ปุ่มไอคอนของแพ็กเกจเอง (
RowActions,ActionMenu,SimpleDialogปุ่มปิด,DataTableแบ่งหน้า,ThemeToggle,Notice,UploadList,TimePicker,MonthStepper,SecretReveal,CommandPalette…) ได้ tooltip ไปพร้อมกันตั้งแต่ 0.27.0
ยืนยันก่อนทำ — ห้ามใช้ window.confirm
webview/automation บางตัวปิด window.confirm ทิ้ง (กดแล้วไม่เกิดอะไร) และไม่เข้า design system — ใช้ 3 ตัวนี้แทน
| สถานการณ์ | ใช้ |
| --- | --- |
| รายการในเมนู … ท้ายแถว | RowActions / RowMenu prop confirm (string หรือ { title, description, confirmLabel }) |
| ปุ่มเดี่ยวเรียก server action | ConfirmActionButton (@suphark/ui/next) — confirm + action + data |
| โค้ด client อื่น ๆ (fetch/mutation เอง) | const { confirm, confirmDialog } = useConfirm(); if (!(await confirm({ description, destructive: true }))) return; แล้ว render {confirmDialog} |
| คุม state เอง | ConfirmDialog controlled |
ข้อความค่าเริ่มต้น (หัว/ปุ่ม) มาจาก UiText.confirmTitle / UiText.confirm
ต้องการ alert / prompt / choose (หลายปุ่ม) / checklist ด้วย หรือไม่อยาก render confirmDialog ทุกที่: ครอบ <DialogsProvider> ที่ root แล้ว const dialogs = useDialogs() — await dialogs.prompt({ description: "ชื่อใหม่", defaultValue }) คืน string หรือ null; หัว/ปุ่มค่าเริ่มต้นจาก UiText.ok/alertTitle/promptTitle/chooseTitle/checklistTitle
ลากจัดลำดับ (@suphark/ui/sortable)
ลากเรียงกลุ่ม เรียงรายการในกลุ่ม และย้ายรายการข้ามกลุ่ม ในตาราง/รายการเดียวกัน — แพ็กเกจดูแล sensors, collision, การย้ายระหว่างลาก และ คืนลำดับเดิมให้เองเมื่อบันทึกไม่สำเร็จ ส่วนการเรนเดอร์แถวยังเป็นของแอป
const [order, setOrder] = useState(() =>
sortableOrderOf(groups, items, { groupId: (g) => g.id, itemId: (i) => i.id, itemGroupId: (i) => i.groupId }),
);
<SortableProvider order={order} onOrderChange={setOrder} onCommit={(next) => saveOrder(sortableOrderToPayload(next))}>
<SortableGroup itemIds={order.items[groupId] ?? []}>
{/* แต่ละแถว: const row = useSortableRow({ type: "item", id }); */}
<TableRow ref={row.ref} style={row.style}>
<TableCell><DragHandle label={`ลาก ${item.name}`} {...row.handleProps} /></TableCell>
…
</TableRow>
</SortableGroup>
</SortableProvider>- กลุ่มที่ไม่มีหัวกลุ่ม (เช่น "ยังไม่จัดกลุ่ม") ใช้
useSortableDropZone(groupId)แปะกับแถว placeholder ของตัวเอง และดูว่าควรแสดงเมื่อไหร่จากuseSortableState().dragging— วางแถวนี้ไว้ใต้แถวที่ลากได้ทั้งหมด (ท้ายตาราง) ถ้ามันโผล่ขึ้นเหนือแถวที่กำลังลาก layout จะเลื่อนตอนเริ่มลาก แล้วการลากด้วยคีย์บอร์ดจะไม่ขยับ - ตั้ง
idของSortableProviderได้ (ไม่ตั้ง =useId()) — ต้องคงที่ระหว่าง SSR กับ client ไม่งั้นaria-describedbyของ dnd-kit จะ hydration mismatch · ตรวจได้จากที่จับ:aria-describedbyจะเป็น id ที่ส่งเข้าไปตรง ๆ (ไม่มี prefix) และต้องชี้ element ที่มีข้อความวิธีลาก — element นี้สร้างหลัง hydrate จึงต้องรอหน้า mount ก่อนค่อย query onCommitล้มเหลว (throw หรือ{ ok: false, error }) = คืนลำดับเดิม + toast; ถ้าอยากจัดการ error เองใส่onCommitError- การ์ดลอยตามเมาส์:
renderOverlay={(drag) => drag.kind === "item" ? <ItemCard id={drag.id} /> : <GroupCard id={drag.id} />}(วาดใน portal จึงเป็นdivได้ ไม่ต้องเป็น<tr>) — ไม่ใส่ = แถวขยับเองแบบเดิม · โฟลเดอร์ที่ย่ออยู่:useSortableDropZone(id, { onDwell: collapsed ? open : undefined })ลากค้าง 600ms แล้วเปิดให้ · วางบนหัวกลุ่ม = ขึ้นบนสุดของกลุ่ม, วางบนโซนของUNGROUPED= ต่อท้าย - เลือกหลายรายการแล้วลากพร้อมกัน:
SortableProvider multiSelectแล้วกระจายrow.selectPropsลงแถว (ทำสีด้วยdata-[selected]:bg-primary/10) — ⌘/Ctrl+คลิก, Shift+คลิก, ที่จับกดx/ ⌘/Ctrl+A, Esc ล้าง · แถบนับใช้SelectionBar+useSortableState().selected/clearSelection·renderOverlayได้drag.idsไว้ทำการ์ดซ้อน - จัดชั้นบนสุดอิสระ (กลุ่มกับรายการไม่มีกลุ่มสลับกันได้):
SortableProvider freeLayout+ เรนเดอร์ตามrootEntries(order)(ครอบทั้งหมดด้วย<SortableGroup itemIds={order.items[UNGROUPED]}>แล้วแต่ละกลุ่มมีSortableGroupของตัวเองข้างใน) + บันทึกpayload.rootและส่งกลับsortableOrderOf(..., { root })ตอนโหลด · หัวกลุ่มครึ่งบน = ก่อนกลุ่ม, ครึ่งล่าง = เข้ากลุ่ม 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 โฟกัสที่ช่องพิมพ์)- ห่อ component ที่ props เป็น union (เช่น
DatePickerที่มีmode="range"): ใช้DistributiveOmit/DistributivePickแทนOmit/Pickไม่งั้น union ถูกยุบจนแยกตามmodeไม่ได้ sortableOrderToPayload(order)คืน{ groups, items }—groupsคือลำดับของกลุ่ม,itemsคือรายการในแต่ละกลุ่มเรียงตามนั้น (ของที่ยังไม่มีกลุ่มมาก่อนด้วยgroupId: null)SortableProvider disabled(เช่นระหว่างกรอง ซึ่งลำดับที่เห็นไม่ใช่ลำดับจริง) ทำให้DragHandleจางลงและกดไม่ได้ — ไม่อยากให้เห็นเลยใส่hideWhenDisabled- ข้อความ screen reader: ใส่
announcements/screenReaderInstructionsเป็น prop (callback ได้{ id, kind, groupId, index, total }) หรือปล่อยให้ใช้แม่แบบจากUiText.sortable
PWA (@suphark/ui/pwa)
// layout ("use client" หรือ component ลูก) — ครั้งเดียว
<PwaServiceWorkerRegistration url="/sw.js" />
// ที่ไหนก็ได้
<PwaInstallCard appName="Pi Done" text={PWA_INSTALL_CARD_TEXT_EN} />
<PullToRefresh /> // + data-pull-to-refresh-root บน element ที่เลื่อนได้PwaServiceWorkerRegistration เช็คอัปเดตตอนโหลด / กลับมาที่แท็บ / ทุก 15 นาที และ reload หน้าครั้งเดียวเมื่อ worker ใหม่คุมหน้า (เลื่อนขณะผู้ใช้กำลังพิมพ์, ไม่ reload ตอนติดตั้งครั้งแรก) — ฝั่ง worker ของแอปต้อง: ฝัง deployment id ใน script (เช่น process.env.VERCEL_DEPLOYMENT_ID) ให้ byte เปลี่ยนทุก deploy, self.skipWaiting() ใน install โดยไม่ขึ้นกับผล precache, self.clients.claim() ใน activate, และเสิร์ฟ script ด้วย Cache-Control: no-cache
Kanban (dnd-kit)
createKanbanCollisionDetection({ columnItems, resolveColumnId }) (จาก @suphark/ui/kanban) ใช้เป็น collisionDetection ของ DndContext เมื่อมีหลายคอลัมน์ (SortableContext ต่อคอลัมน์) — ตัวอย่างบอร์ดเต็มที่ ui.suphark.com/components/kanban-board; wrapper ของการ์ดห้าม overflow-hidden (dnd-kit ใส่ transform ที่ node ที่ setNodeRef ถ้าถูกครอบด้วย overflow-hidden การ์ดที่ขยับหลบจะถูก clip)
ตรวจกติกาในแอป — npx suphark-ui-audit
แพ็กเกจมี bin ให้รันในแอปปลายทาง (ตรวจ src ค่าเริ่มต้น; ข้าม components/ui/**, tests, generated):
npx suphark-ui-audit # หรือใส่ใน package.json: "audit:ui": "suphark-ui-audit src"
npx suphark-ui-audit --strict # warning นับเป็น error (ใช้ใน CI เมื่อล้างหมดแล้ว)| กฎ | ระดับ | เจอ → ใช้แทน |
| --- | --- | --- |
| window-dialog | error | window.confirm/alert/prompt( → ConfirmDialog / useConfirm() / useDialogs() |
| radix-state | error | data-[state=open\|checked\|active…] → data-open / data-checked / data-active / data-popup-open (Base UI ไม่มี data-state) |
| arbitrary-text | error | text-[Npx] → text-xs … text-3xl |
| base-ui-props | error | default* ที่ค่าเปลี่ยนได้ / onSelect บนเมนู (ดู scripts/audit-base-ui-props.mjs) |
| raw-palette | warning | bg-red-500, text-slate-600 … → bg-destructive, text-muted-foreground, bg-success, bg-warning, bg-info, text-primary; พื้นอ่อน bg-sky-50 text-sky-800 → bg-info-subtle text-info-subtle-foreground |
| raw-palette (คู่ป้ายสี) | warning | bg-teal-100 text-teal-800 บรรทัดเดียวกัน = ป้ายสีที่ทำเอง ไม่มี dark → LabelBadge / paletteClasses(color) (0.81.0) |
| space-utility | warning | space-x-* / space-y-* → flex/grid + gap-* (0.81.0) |
| z-arbitrary | warning | z-[…] → overlay จัดลำดับเอง; ซ้อนภายใน component ใช้ z-1 / z-10 (0.81.0) |
| important | warning (เฉพาะ --important) | utility ลงท้าย ! ในโค้ดแพ็กเกจ |
ยกเว้นรายบรรทัด // ui-audit-ok: <เหตุผล> (บรรทัดเดียวกันหรือบรรทัดก่อน) — ยกเว้นทั้งไฟล์ /* ui-audit-ok-file: <เหตุผล> */
สิทธิ์ (ActionButton/LinkButton permission prop)
แพ็กเกจไม่รู้จักระบบสิทธิ์ของแอป — ครอบด้วย UiPermissionProvider แล้วส่งฟังก์ชัน check(requirement, options) ของแอปเข้ามา ถ้าไม่ครอบ = อนุญาตทุกอย่าง
<UiPermissionProvider check={(req, opts) => checkPermission(user, req, opts)}>…</UiPermissionProvider>พัฒนา
pnpm install
pnpm dev # apps/showcase → http://localhost:3200/design-system
pnpm verify # typecheck + biome + lint:base-ui + check:typography + check:changelog + check:barrel + audit + vitest
pnpm --filter showcase build && pnpm smoke:showcase # เปิดทุกหน้า showcase ใน Chrome ตรวจ error ฝั่ง client
pnpm --filter @suphark/ui testเพิ่ม shadcn component:
cd packages/ui && pnpm dlx shadcn@latest add <name>แล้วเพิ่ม export * from "./components/ui/<name>" ใน src/index.ts แล้วรัน pnpm fix:barrel ทันที (แปลงเป็นรายชื่อ export) — ห้ามมี export * ค้างใน src: server component ที่ import barrel แบบ export * ทำให้ทุกหน้าโหลด client component ทั้งแพ็กเกจ (pnpm check:barrel ตรวจใน verify, CI และก่อน publish); CLI เขียน import { cn } from "cn" ให้เปลี่ยนเป็น @suphark/ui/lib/utils
Versioning
semver — patch = หน้าตา/บั๊ก, minor = component/prop ใหม่, major = เปลี่ยน/ลบ props ดู CHANGELOG.md
Publish
pnpm verify # จาก root ก่อนทุกครั้ง (หลัง commit/push แล้ว)
cd packages/ui
npm publish --access public # Windows: npm.cmd — prepublishOnly รัน check:barrel + typecheck ให้อีกรอบ(ผู้ใช้ npm login เอง; ไม่มีขั้น build เพราะส่ง source)
