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 @@ -123,6 +123,8 @@ jobs:
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
bunx shadcn@latest add http://localhost:8099/r/attachment.json -y -o
grep -q 'rounded-menu' components/ui/attachment.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 @@ -58,6 +58,7 @@ npx shadcn@latest add @winlab/button
| `combobox` | `registry:ui` | `ComboboxTrigger` (same field look as `select`) and `ComboboxContent` (as wide as the trigger) around a `command` |
| `dropdown-menu` | `registry:ui` | Action menu: items, labels, separators and submenus |
| `calendar` | `registry:ui` | One month of 40px days to pick a single date; weeks start on Monday, captions read 2026 年 10 月 |
| `attachment` | `registry:ui` | One file per row: 40px icon or thumbnail, name, a detail line and actions; states `idle` (dashed), `uploading`, `error`, `done` |
| `checkbox` | `registry:ui` | Round 20px checkbox |
| `switch` | `registry:ui` | On / off toggle, 40 x 24 |
| `tabs` | `registry:ui` | Segmented tabs |
Expand Down
58 changes: 58 additions & 0 deletions app/_docs/demos/attachment.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { FileTextIcon, ImageIcon, RotateCwIcon, XIcon } from "lucide-react"

import {
Attachment,
AttachmentAction,
AttachmentActions,
AttachmentContent,
AttachmentDescription,
AttachmentGroup,
AttachmentMedia,
AttachmentTitle,
AttachmentTrigger,
} from "@/registry/winlab/ui/attachment"

export function Demo() {
return (
<AttachmentGroup className="w-full max-w-md">
<Attachment>
<AttachmentTrigger aria-label="開啟 收據-文具.pdf" />
<AttachmentMedia>
<FileTextIcon />
</AttachmentMedia>
<AttachmentContent>
<AttachmentTitle>收據-文具.pdf</AttachmentTitle>
<AttachmentDescription>PDF · 240 KB</AttachmentDescription>
</AttachmentContent>
<AttachmentActions>
<AttachmentAction aria-label="移除">
<XIcon />
</AttachmentAction>
</AttachmentActions>
</Attachment>
<Attachment state="uploading">
<AttachmentMedia>
<ImageIcon />
</AttachmentMedia>
<AttachmentContent>
<AttachmentTitle>發票照片.jpg</AttachmentTitle>
<AttachmentDescription>上傳中 · 62%</AttachmentDescription>
</AttachmentContent>
</Attachment>
<Attachment state="error">
<AttachmentMedia>
<FileTextIcon />
</AttachmentMedia>
<AttachmentContent>
<AttachmentTitle>出差報告.docx</AttachmentTitle>
<AttachmentDescription>不支援這種檔案</AttachmentDescription>
</AttachmentContent>
<AttachmentActions>
<AttachmentAction aria-label="重試">
<RotateCwIcon />
</AttachmentAction>
</AttachmentActions>
</Attachment>
</AttachmentGroup>
)
}
2 changes: 2 additions & 0 deletions app/_docs/demos/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { Demo as Label } from "@/app/_docs/demos/label"
import { Demo as Select } from "@/app/_docs/demos/select"
import { Demo as Combobox } from "@/app/_docs/demos/combobox"
import { Demo as Calendar } from "@/app/_docs/demos/calendar"
import { Demo as Attachment } from "@/app/_docs/demos/attachment"
import { Demo as Checkbox } from "@/app/_docs/demos/checkbox"
import { Demo as Switch } from "@/app/_docs/demos/switch"
import { Demo as Table } from "@/app/_docs/demos/table"
Expand Down Expand Up @@ -59,6 +60,7 @@ export const demos: Record<string, ComponentType> = {
select: Select,
combobox: Combobox,
calendar: Calendar,
attachment: Attachment,
checkbox: Checkbox,
switch: Switch,
table: Table,
Expand Down
7 changes: 7 additions & 0 deletions app/_docs/docs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,13 @@ export const docs: Doc[] = [
description: "從月曆選一天,週一開頭。",
item: "calendar",
},
{
group: "元件",
slug: "attachment",
title: "附件",
description: "一個檔案的圖示、檔名與狀態,可以移除或重試。",
item: "attachment",
},
{
group: "元件",
slug: "checkbox",
Expand Down
20 changes: 20 additions & 0 deletions registry.json
Original file line number Diff line number Diff line change
Expand Up @@ -656,6 +656,26 @@
}
]
},
{
"name": "attachment",
"type": "registry:ui",
"title": "Attachment",
"description": "One file as a row: icon or thumbnail, name, detail line and actions, with idle, uploading, error and done states.",
"dependencies": [
"@base-ui/react",
"cn"
],
"registryDependencies": [
"https://ui.winlab.tw/r/utils.json",
"https://ui.winlab.tw/r/button.json"
],
"files": [
{
"path": "registry/winlab/ui/attachment.tsx",
"type": "registry:ui"
}
]
},
{
"name": "app-shell",
"type": "registry:block",
Expand Down
166 changes: 166 additions & 0 deletions registry/winlab/ui/attachment.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
import * as React from "react"
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"

import { cn } from "@/registry/winlab/lib/utils"
import { Button } from "@/registry/winlab/ui/button"

type AttachmentState = "idle" | "uploading" | "error" | "done"

// One file: a 40px icon or thumbnail, its name and a detail line, then
// actions. It keeps its own outline like an input, since it is a control;
// rounded-menu with p-1 around the control-radius media (concentric).
// idle is dashed (not sent yet), uploading pulses, error turns destructive.
function Attachment({
className,
state = "done",
...props
}: React.ComponentProps<"div"> & { state?: AttachmentState }) {
return (
<div
data-slot="attachment"
data-state={state}
className={cn(
"group/attachment relative flex w-full min-w-0 items-center gap-3 rounded-menu border border-border p-1 pr-2 text-body transition-colors duration-state has-[>[data-slot=attachment-trigger]]:hover:bg-muted/50 data-[state=error]:border-destructive/30 data-[state=idle]:border-dashed",
className
)}
{...props}
/>
)
}

function AttachmentMedia({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="attachment-media"
className={cn(
"flex size-10 shrink-0 items-center justify-center overflow-hidden rounded-control bg-muted group-data-[state=error]/attachment:bg-destructive/10 group-data-[state=error]/attachment:text-destructive group-data-[state=uploading]/attachment:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 *:[img]:size-full *:[img]:object-cover",
className
)}
{...props}
/>
)
}

function AttachmentContent({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="attachment-content"
className={cn("flex min-w-0 flex-1 flex-col", className)}
{...props}
/>
)
}

function AttachmentTitle({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="attachment-title"
className={cn(
"truncate font-medium group-data-[state=uploading]/attachment:animate-pulse",
className
)}
{...props}
/>
)
}

function AttachmentDescription({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="attachment-description"
className={cn(
"truncate text-muted-foreground tabular-nums group-data-[state=error]/attachment:text-destructive",
className
)}
{...props}
/>
)
}

// Sits above the trigger, so its buttons stay clickable.
function AttachmentActions({
className,
...props
}: React.ComponentProps<"div">) {
return (
<div
data-slot="attachment-actions"
className={cn("relative z-20 flex shrink-0 items-center", className)}
{...props}
/>
)
}

// A ghost icon button; give it an aria-label naming the verb.
function AttachmentAction({
className,
...props
}: React.ComponentProps<typeof Button>) {
return (
<Button
data-slot="attachment-action"
variant="ghost"
size="icon"
className={cn(className)}
{...props}
/>
)
}

// Makes the whole attachment clickable (open, preview); render a link with
// render={<a href=… />}.
function AttachmentTrigger({
className,
render,
type,
...props
}: useRender.ComponentProps<"button">) {
return useRender({
defaultTagName: "button",
props: mergeProps<"button">(
{
type: render ? type : (type ?? "button"),
className: cn(
"absolute inset-0 z-10 rounded-menu outline-none focus-visible:ring-3 focus-visible:ring-ring/50",
className
),
},
props
),
render,
state: { slot: "attachment-trigger" },
})
}

// A list of attachments, one per row.
function AttachmentGroup({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="attachment-group"
className={cn("flex min-w-0 flex-col gap-2", className)}
{...props}
/>
)
}

export {
Attachment,
AttachmentAction,
AttachmentActions,
AttachmentContent,
AttachmentDescription,
AttachmentGroup,
AttachmentMedia,
AttachmentTitle,
AttachmentTrigger,
}
Loading