diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d0308eb..39f01d5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -117,6 +117,8 @@ jobs: grep -q 'react-day-picker' package.json bunx shadcn@latest add http://localhost:8099/r/date-picker.json -y -o grep -q 'toDateString' components/date-picker.tsx + bunx shadcn@latest add http://localhost:8099/r/action-panel.json -y -o + grep -q 'doneLabel' components/action-panel.tsx bun run build - if: always() run: docker compose logs diff --git a/README.md b/README.md index aa4cfee..f9f9562 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,7 @@ npx shadcn@latest add @winlab/button | `field-list` | `registry:block` | One record's fields as a `dl`: muted names in a left column, values beside them (under them on a phone), split by dividers | | `status-page` | `registry:block` | A spotlight page that says what happened and the way out: not found, an error, no access; the site's own 404 uses it | | `date-picker` | `registry:block` | A date field with the same closed look as `select`; opens a `calendar`, closes on choice, can block days, and posts yyyy-mm-dd under `name` for `form-dialog` | +| `action-panel` | `registry:block` | A page whose job is one action, such as opening the door: one 192px round button that locks while it runs, turns `success` for a moment, and says why when it cannot be pressed | | `badge` | `registry:ui` | Status label; variants `default`, `muted`, `outline`, `destructive`, `success`, `warning` | Text falls back in that order: `font-sans` is Inter, then Noto Sans JP, then Noto Sans TC. `font-mono` puts JetBrains Mono in front of the same CJK fonts. Use Inter's `tabular-nums` for amounts and table figures, not `font-mono`. diff --git a/app/_docs/demos/action-panel.tsx b/app/_docs/demos/action-panel.tsx new file mode 100644 index 0000000..ed3298e --- /dev/null +++ b/app/_docs/demos/action-panel.tsx @@ -0,0 +1,18 @@ +"use client" + +import { DoorOpenIcon } from "lucide-react" + +import { ActionPanel } from "@/registry/winlab/blocks/action-panel/action-panel" + +const wait = () => new Promise((resolve) => setTimeout(resolve, 1000)) + +export function Demo() { + return ( + } + onAction={wait} + /> + ) +} diff --git a/app/_docs/demos/index.ts b/app/_docs/demos/index.ts index 5954157..12ee655 100644 --- a/app/_docs/demos/index.ts +++ b/app/_docs/demos/index.ts @@ -12,6 +12,7 @@ import { Demo as FieldListDemo } from "@/app/_docs/demos/field-list" import { Demo as StatusPageDemo } from "@/app/_docs/demos/status-page" import { Demo as DatePickerDemo } from "@/app/_docs/demos/date-picker" import { Demo as Wide } from "@/app/_docs/demos/wide" +import { Demo as ActionPanelDemo } from "@/app/_docs/demos/action-panel" import { Demo as Button } from "@/app/_docs/demos/button" import { Demo as Badge } from "@/app/_docs/demos/badge" import { Demo as Input } from "@/app/_docs/demos/input" @@ -49,6 +50,7 @@ export const demos: Record = { "field-list": FieldListDemo, "status-page": StatusPageDemo, "date-picker": DatePickerDemo, + "action-panel": ActionPanelDemo, button: Button, badge: Badge, input: Input, diff --git a/app/_docs/docs.ts b/app/_docs/docs.ts index 4c5e78e..fbfe869 100644 --- a/app/_docs/docs.ts +++ b/app/_docs/docs.ts @@ -96,6 +96,14 @@ export const docs: Doc[] = [ description: "外觀跟其他欄位一樣,點開是月曆,選了就關。", item: "date-picker", }, + { + group: "區塊", + slug: "action-panel", + title: "單一動作", + description: + "整頁只做一件事,例如開門:一顆大按鈕,執行中鎖住,成功後短暫變色。", + item: "action-panel", + }, { group: "元件", slug: "button", diff --git a/registry.json b/registry.json index c941992..8cfb6e5 100644 --- a/registry.json +++ b/registry.json @@ -766,6 +766,21 @@ "type": "registry:component" } ] + }, + { + "name": "action-panel", + "type": "registry:block", + "title": "Action Panel", + "description": "A page whose job is one action: one large round button that locks while it runs and shows success for a moment.", + "dependencies": [ + "cn" + ], + "files": [ + { + "path": "registry/winlab/blocks/action-panel/action-panel.tsx", + "type": "registry:component" + } + ] } ] } diff --git a/registry/winlab/blocks/action-panel/action-panel.tsx b/registry/winlab/blocks/action-panel/action-panel.tsx new file mode 100644 index 0000000..bd84a5f --- /dev/null +++ b/registry/winlab/blocks/action-panel/action-panel.tsx @@ -0,0 +1,87 @@ +"use client" + +import * as React from "react" +import { cn } from "cn" + +type ActionPanelProps = { + /** The verb: "開門". */ + label: string + /** Shown while onAction runs; defaults to the verb + "中…". */ + pendingLabel?: string + /** Shown for a moment after it worked: "已開門". */ + doneLabel: string + /** How long doneLabel stays before the button is ready again. */ + resetAfter?: number + icon?: React.ReactNode + /** Why it cannot be pressed now, said under the button: "門禁離線". */ + disabledReason?: string + /** Resolve when it worked; throw to go back to ready (report the error + * yourself, in a toast). */ + onAction: () => Promise +} + +// A page whose whole job is one action, such as opening the lab door: one +// large round button in app-shell's spotlight layout. It locks while the +// action runs, turns success for a moment when it worked, then is ready +// again. +function ActionPanel({ + label, + pendingLabel = `${label}中…`, + doneLabel, + resetAfter = 3000, + icon, + disabledReason, + onAction, +}: ActionPanelProps) { + const [phase, setPhase] = React.useState<"ready" | "pending" | "done">( + "ready" + ) + + React.useEffect(() => { + if (phase !== "done") return + const id = setTimeout(() => setPhase("ready"), resetAfter) + return () => clearTimeout(id) + }, [phase, resetAfter]) + + async function act() { + setPhase("pending") + try { + await onAction() + setPhase("done") + } catch (error) { + console.error(error) + setPhase("ready") + } + } + + return ( +
+ + {disabledReason && ( +

{disabledReason}

+ )} +
+ ) +} + +export { ActionPanel }