Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
18 changes: 18 additions & 0 deletions app/_docs/demos/action-panel.tsx
Original file line number Diff line number Diff line change
@@ -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<void>((resolve) => setTimeout(resolve, 1000))

export function Demo() {
return (
<ActionPanel
label="開門"
doneLabel="已開門"
icon={<DoorOpenIcon />}
onAction={wait}
/>
)
}
2 changes: 2 additions & 0 deletions app/_docs/demos/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -49,6 +50,7 @@ export const demos: Record<string, ComponentType> = {
"field-list": FieldListDemo,
"status-page": StatusPageDemo,
"date-picker": DatePickerDemo,
"action-panel": ActionPanelDemo,
button: Button,
badge: Badge,
input: Input,
Expand Down
8 changes: 8 additions & 0 deletions app/_docs/docs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,14 @@ export const docs: Doc[] = [
description: "外觀跟其他欄位一樣,點開是月曆,選了就關。",
item: "date-picker",
},
{
group: "區塊",
slug: "action-panel",
title: "單一動作",
description:
"整頁只做一件事,例如開門:一顆大按鈕,執行中鎖住,成功後短暫變色。",
item: "action-panel",
},
{
group: "元件",
slug: "button",
Expand Down
15 changes: 15 additions & 0 deletions registry.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
]
}
]
}
87 changes: 87 additions & 0 deletions registry/winlab/blocks/action-panel/action-panel.tsx
Original file line number Diff line number Diff line change
@@ -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<void>
}

// 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 (
<div data-slot="action-panel" className="flex flex-col items-center gap-6">
<button
type="button"
disabled={Boolean(disabledReason) || phase !== "ready"}
onClick={act}
aria-live="polite"
data-phase={phase}
className={cn(
"flex size-48 flex-col items-center justify-center gap-3 rounded-full bg-primary font-medium text-primary-foreground transition duration-state outline-none select-none hover:bg-primary/90 focus-visible:ring-3 focus-visible:ring-primary/50 active:scale-95 disabled:pointer-events-none data-[phase=done]:bg-success data-[phase=pending]:opacity-70 motion-reduce:active:scale-100 [&_svg]:size-12",
disabledReason && "bg-muted text-muted-foreground"
)}
>
{icon}
{/* text-title on its own element: cn would drop it next to a text color */}
<span className="text-title">
{phase === "pending"
? pendingLabel
: phase === "done"
? doneLabel
: label}
</span>
</button>
{disabledReason && (
<p className="text-muted-foreground">{disabledReason}</p>
)}
</div>
)
}

export { ActionPanel }
Loading