Skip to content
Open
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
17 changes: 17 additions & 0 deletions .changeset/tiny-peas-join.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
"@tailor-platform/app-shell": minor
---

Add `AttachmentCard` for ERP attachment workflows with drag-and-drop upload, image/file previews, and per-item `Download`/`Delete` actions.

```tsx
import { AttachmentCard } from "@tailor-platform/app-shell";

<AttachmentCard
title="Product images"
items={items}
onUpload={handleUpload}
onDownload={handleDownload}
onDelete={handleDelete}
/>;
```
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ This project has comprehensive documentation organized in the `docs/` directory:
- **[Card](./docs/components/card.md)** - General-purpose container with compound component API
- **[Table](./docs/components/table.md)** - Semantic HTML table sub-components
- **[ActivityCard](./docs/components/activity-card.md)** - Timeline of document activities with avatars and overflow dialog
- **[AttachmentCard](./docs/components/attachment-card.md)** - Upload, preview, and manage file/image attachments with per-item actions
- **[Dialog](./docs/components/dialog.md)** - Modal dialog with compound component API
- **[Menu](./docs/components/menu.md)** - Dropdown menu with compound component API, checkbox/radio items, groups, and sub-menus
- **[Sheet](./docs/components/sheet.md)** - Slide-in panel with swipe-to-dismiss support
Expand Down
84 changes: 84 additions & 0 deletions docs/components/attachment-card.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
---
title: AttachmentCard
description: Card component for uploading, previewing, and managing attachments
---

# AttachmentCard

`AttachmentCard` is a reusable file/image attachment surface for ERP detail pages. It provides a header with upload affordance, drag-and-drop upload support, image/file preview tiles, and per-item menu actions for download and delete.

## Import

```tsx
import { AttachmentCard } from "@tailor-platform/app-shell";
```

## Basic Usage

```tsx
import { AttachmentCard, type AttachmentItem } from "@tailor-platform/app-shell";

const items: AttachmentItem[] = [
{ id: "1", fileName: "shoe-red.png", mimeType: "image/png", previewUrl: "/img/shoe-red.png" },
{ id: "2", fileName: "Aug-Sep 2025_1234-12.pdf", mimeType: "application/pdf" },
];

<AttachmentCard
title="Product images"
items={items}
uploadLabel="Upload image"
accept="image/*,.pdf"
onUpload={(files) => console.log("upload", files)}
onDownload={(item) => console.log("download", item)}
onDelete={(item) => console.log("delete", item)}
/>;
```

## Props

| Prop | Type | Default | Description |
| --------------- | --------------------------------------------- | --------------- | ----------------------------------------------------------------- |
| `title` | `string` | `"Attachments"` | Card heading text |
| `items` | `AttachmentItem[]` | `[]` | Attachment list rendered as preview tiles |
| `onUpload` | `(files: File[]) => void` | - | Controlled upload callback for file input + drag/drop |
| `uploadFile` | `(file: File) => Promise<AttachmentItem>` | - | Optional async upload handler for built-in uploading lifecycle UI |
| `onUploadError` | `(ctx: { file: File; error: Error }) => void` | - | Called when `uploadFile` fails |
| `onDelete` | `(item: AttachmentItem) => void` | - | Called when Delete is chosen in a preview menu |
| `onDownload` | `(item: AttachmentItem) => void` | - | Called when Download is chosen in a preview menu |
| `uploadLabel` | `string` | `"Upload"` | Upload button text |
| `accept` | `string` | - | Accepted file types for hidden file input |
| `disabled` | `boolean` | `false` | Disables upload/drop and hides per-item menu actions |
| `className` | `string` | - | Additional classes on the root card |

## AttachmentItem

```ts
interface AttachmentItem {
id: string;
fileName: string;
mimeType: string;
previewUrl?: string;
status?: "ready" | "uploading";
}
```

## Upload Integration Modes

- **Controlled mode (`onUpload`)**: component emits selected files and the parent owns upload + list updates.
- **Async mode (`uploadFile`)**: component shows temporary uploading tiles with local previews, dark overlay, and spinner while awaiting each upload promise.
- `onUpload` and `uploadFile` are mutually exclusive integration modes.
- **Failure behavior**: when `uploadFile` rejects, the component removes the temporary tile, shows a toast, and calls `onUploadError`.

## Behavior

- **Image items** (`mimeType` starts with `image/`) render as 120x120 image thumbnails.
- **Non-image items** render as 120x120 file tiles with icon and wrapped filename.
- **Drag and drop** is supported on the entire card container.
- **Uploading state** renders a dark overlay + centered spinner on the 120x120 tile.
- **Item actions** are available through the preview menu (`Download`, `Delete`) when not disabled and not uploading.

## Related Components

- [Card](./card.md)
- [Button](./button.md)
- [Menu](./menu.md)
13 changes: 13 additions & 0 deletions examples/app-module/src/custom-module.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { ZapIcon } from "./pages/metric-card-demo";
import { actionPanelDemoResource } from "./pages/action-panel-demo";
import { metricCardDemoResource } from "./pages/metric-card-demo";
import { activityCardDemoResource } from "./pages/activity-card-demo";
import { attachmentCardDemoResource } from "./pages/attachment-card-demo";
import {
purchaseOrderDemoResource,
subPageResource,
Expand Down Expand Up @@ -82,6 +83,17 @@ export const customPageModule = defineModule({
View ActivityCard Demo
</Link>
</p>
<p>
<Link
to="/custom-page/attachment-card-demo"
style={{
color: "hsl(var(--primary))",
textDecoration: "underline",
}}
>
View AttachmentCard Demo
</Link>
</p>
<p>
<Link
to="/custom-page/layout-1-column"
Expand Down Expand Up @@ -197,6 +209,7 @@ export const customPageModule = defineModule({
actionPanelDemoResource,
metricCardDemoResource,
activityCardDemoResource,
attachmentCardDemoResource,
oneColumnLayoutResource,
twoColumnLayoutResource,
threeColumnLayoutResource,
Expand Down
112 changes: 112 additions & 0 deletions examples/app-module/src/pages/attachment-card-demo.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import {
AttachmentCard,
type AttachmentItem,
defineResource,
Layout,
useToast,
} from "@tailor-platform/app-shell";
import { useMemo, useState } from "react";

const initialItems: AttachmentItem[] = [
{
id: "img-1",
fileName: "shoe-red.png",
mimeType: "image/png",
previewUrl:
"https://images.unsplash.com/photo-1542291026-7eec264c27ff?auto=format&fit=crop&w=400&q=80",
},
{
id: "img-2",
fileName: "shoe-green.png",
mimeType: "image/png",
previewUrl:
"https://images.unsplash.com/photo-1460353581641-37baddab0fa2?auto=format&fit=crop&w=400&q=80",
},
{
id: "doc-1",
fileName: "Aug-Sep 2025_1234-12.pdf",
mimeType: "application/pdf",
},
];

const AttachmentCardDemoPage = () => {
const toast = useToast();
const [items, setItems] = useState<AttachmentItem[]>(initialItems);
const [asyncItems, setAsyncItems] = useState<AttachmentItem[]>(initialItems);

const nextId = useMemo(() => items.length + 1, [items.length]);
const nextAsyncId = useMemo(() => asyncItems.length + 1, [asyncItems.length]);

return (
<Layout>
<Layout.Header title="AttachmentCard Demo" />
<Layout.Column>
<p style={{ color: "var(--muted-foreground)", marginBottom: "1rem" }}>
Two integration styles are shown below: controlled `onUpload` and generic async
`uploadFile`.
</p>
<AttachmentCard
title="Product images (controlled mode)"
uploadLabel="Upload image"
accept="image/*,.pdf,.doc,.docx"
items={items}
onUpload={(files) => {
const mapped = files.map((file, index) => {
const id = `${Date.now()}-${nextId + index}`;
const previewUrl = file.type.startsWith("image/")
? URL.createObjectURL(file)
: undefined;
return {
id,
fileName: file.name,
mimeType: file.type || "application/octet-stream",
previewUrl,
} satisfies AttachmentItem;
});
setItems((prev) => [...mapped, ...prev]);
}}
onDelete={(item) => {
setItems((prev) => prev.filter((candidate) => candidate.id !== item.id));
}}
onDownload={(item) => {
toast(`Download clicked for: ${item.fileName}`);
}}
/>
<div style={{ height: "1.5rem" }} />
<AttachmentCard
title="Product images (async upload mode)"
uploadLabel="Upload image"
accept="image/*,.pdf,.doc,.docx"
items={asyncItems}
uploadFile={async (file) => {
await new Promise((resolve) => setTimeout(resolve, 900));
if (file.name.toLowerCase().includes("fail")) {
throw new Error("Simulated upload failure");
}

const id = `async-${Date.now()}-${nextAsyncId}`;
return {
id,
fileName: file.name,
mimeType: file.type || "application/octet-stream",
previewUrl: file.type.startsWith("image/") ? URL.createObjectURL(file) : undefined,
status: "ready",
} satisfies AttachmentItem;
}}
onDelete={(item) => {
setAsyncItems((prev) => prev.filter((candidate) => candidate.id !== item.id));
}}
onDownload={(item) => {
toast(`Download clicked for: ${item.fileName}`);
}}
/>
</Layout.Column>
</Layout>
);
};

export const attachmentCardDemoResource = defineResource({
path: "attachment-card-demo",
meta: { title: "AttachmentCard Demo" },
component: AttachmentCardDemoPage,
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html

exports[`AttachmentCard > snapshots > disabled 1`] = `"<div data-slot="attachment-card" class="astw:bg-card astw:text-card-foreground astw:flex astw:flex-col astw:rounded-xl astw:border astw:shadow-xs astw:@container astw:transition-colors"><div class="astw:px-5 astw:pt-5 astw:pb-4"><div class="astw:flex astw:w-full astw:items-center astw:justify-between astw:gap-4"><h3 class="astw:text-lg astw:font-semibold astw:text-card-foreground">Attachments</h3><div class="astw:flex astw:items-center astw:gap-2 astw:text-sm"><span class="astw:hidden astw:@[620px]:inline astw:text-foreground">Drag and drop images/files or</span><button type="button" data-slot="button" class="astw:inline-flex astw:items-center astw:justify-center astw:whitespace-nowrap astw:text-sm astw:font-medium astw:transition-all astw:disabled:pointer-events-none astw:disabled:opacity-50 astw:[&amp;_svg]:pointer-events-none astw:[&amp;_svg:not([class*='size-'])]:size-4 astw:shrink-0 astw:[&amp;_svg]:shrink-0 astw:outline-none astw:focus-visible:border-ring astw:focus-visible:ring-ring/50 astw:focus-visible:ring-[3px] astw:aria-invalid:ring-destructive/20 astw:dark:aria-invalid:ring-destructive/40 astw:aria-invalid:border-destructive astw:border astw:bg-background astw:shadow-xs astw:hover:bg-accent astw:hover:text-accent-foreground astw:dark:bg-input/30 astw:dark:border-input astw:dark:hover:bg-input/50 astw:h-8 astw:rounded-md astw:gap-1.5 astw:px-3 astw:has-[>svg]:px-2.5 astw:text-foreground" disabled="">Upload</button><input multiple="" disabled="" class="astw:hidden" data-testid="attachment-upload-input" type="file"></div></div></div><div data-slot="card-content" class="astw:px-5 astw:pb-5"><div class="astw:flex astw:flex-wrap astw:gap-4"><div class="astw:group astw:relative"><div class="astw:relative astw:size-30 astw:shrink-0 astw:overflow-hidden astw:rounded-lg astw:border astw:border-border astw:bg-muted"><img alt="shoe-red.png" class="astw:size-full astw:object-cover" src="https://example.com/shoe-red.png"></div></div><div class="astw:group astw:relative"><div class="astw:relative astw:size-30 astw:shrink-0 astw:overflow-hidden astw:rounded-lg astw:border astw:border-border astw:flex astw:flex-col astw:justify-between astw:bg-card astw:p-3"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-file astw:size-5 astw:text-muted-foreground astw:opacity-60" aria-hidden="true" data-testid="attachment-file-icon"><path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z"></path><path d="M14 2v5a1 1 0 0 0 1 1h5"></path></svg><p class="astw:flex astw:min-w-0 astw:items-end astw:gap-0.5 astw:text-xs astw:leading-normal astw:text-foreground"><span class="astw:min-w-0 astw:flex-1 astw:line-clamp-2 astw:break-all">Aug-Sep 2025_1234-12</span><span class="astw:shrink-0">.pdf</span></p></div></div></div></div></div>"`;

exports[`AttachmentCard > snapshots > empty default 1`] = `"<div data-slot="attachment-card" class="astw:bg-card astw:text-card-foreground astw:flex astw:flex-col astw:rounded-xl astw:border astw:shadow-xs astw:@container astw:transition-colors"><div class="astw:px-5 astw:pt-5 astw:pb-5"><div class="astw:flex astw:w-full astw:items-center astw:justify-between astw:gap-4"><h3 class="astw:text-lg astw:font-semibold astw:text-card-foreground">Attachments</h3><div class="astw:flex astw:items-center astw:gap-2 astw:text-sm"><span class="astw:hidden astw:@[620px]:inline astw:text-foreground">Drag and drop images/files or</span><button type="button" data-slot="button" class="astw:inline-flex astw:items-center astw:justify-center astw:whitespace-nowrap astw:text-sm astw:font-medium astw:transition-all astw:disabled:pointer-events-none astw:disabled:opacity-50 astw:[&amp;_svg]:pointer-events-none astw:[&amp;_svg:not([class*='size-'])]:size-4 astw:shrink-0 astw:[&amp;_svg]:shrink-0 astw:outline-none astw:focus-visible:border-ring astw:focus-visible:ring-ring/50 astw:focus-visible:ring-[3px] astw:aria-invalid:ring-destructive/20 astw:dark:aria-invalid:ring-destructive/40 astw:aria-invalid:border-destructive astw:border astw:bg-background astw:shadow-xs astw:hover:bg-accent astw:hover:text-accent-foreground astw:dark:bg-input/30 astw:dark:border-input astw:dark:hover:bg-input/50 astw:h-8 astw:rounded-md astw:gap-1.5 astw:px-3 astw:has-[>svg]:px-2.5 astw:text-foreground">Upload</button><input multiple="" class="astw:hidden" data-testid="attachment-upload-input" type="file"></div></div></div></div>"`;

exports[`AttachmentCard > snapshots > populated mixed items 1`] = `"<div data-slot="attachment-card" class="astw:bg-card astw:text-card-foreground astw:flex astw:flex-col astw:rounded-xl astw:border astw:shadow-xs astw:@container astw:transition-colors"><div class="astw:px-5 astw:pt-5 astw:pb-4"><div class="astw:flex astw:w-full astw:items-center astw:justify-between astw:gap-4"><h3 class="astw:text-lg astw:font-semibold astw:text-card-foreground">Attachments</h3><div class="astw:flex astw:items-center astw:gap-2 astw:text-sm"><span class="astw:hidden astw:@[620px]:inline astw:text-foreground">Drag and drop images/files or</span><button type="button" data-slot="button" class="astw:inline-flex astw:items-center astw:justify-center astw:whitespace-nowrap astw:text-sm astw:font-medium astw:transition-all astw:disabled:pointer-events-none astw:disabled:opacity-50 astw:[&amp;_svg]:pointer-events-none astw:[&amp;_svg:not([class*='size-'])]:size-4 astw:shrink-0 astw:[&amp;_svg]:shrink-0 astw:outline-none astw:focus-visible:border-ring astw:focus-visible:ring-ring/50 astw:focus-visible:ring-[3px] astw:aria-invalid:ring-destructive/20 astw:dark:aria-invalid:ring-destructive/40 astw:aria-invalid:border-destructive astw:border astw:bg-background astw:shadow-xs astw:hover:bg-accent astw:hover:text-accent-foreground astw:dark:bg-input/30 astw:dark:border-input astw:dark:hover:bg-input/50 astw:h-8 astw:rounded-md astw:gap-1.5 astw:px-3 astw:has-[>svg]:px-2.5 astw:text-foreground">Upload</button><input multiple="" class="astw:hidden" data-testid="attachment-upload-input" type="file"></div></div></div><div data-slot="card-content" class="astw:px-5 astw:pb-5"><div class="astw:flex astw:flex-wrap astw:gap-4"><div class="astw:group astw:relative"><div class="astw:relative astw:size-30 astw:shrink-0 astw:overflow-hidden astw:rounded-lg astw:border astw:border-border astw:bg-muted"><img alt="shoe-red.png" class="astw:size-full astw:object-cover" src="https://example.com/shoe-red.png"></div><div class="astw:absolute astw:top-2 astw:right-2 astw:opacity-0 astw:transition-opacity astw:group-hover:opacity-100 astw:group-focus-within:opacity-100"><button type="button" tabindex="0" aria-haspopup="menu" id="base-ui-_r_4_" data-slot="menu-trigger" aria-label="Attachment options for shoe-red.png" class="astw:inline-flex astw:size-7 astw:items-center astw:justify-center astw:rounded-md astw:border astw:bg-background/90 astw:text-foreground astw:shadow-xs astw:hover:bg-accent" aria-expanded="false"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-ellipsis astw:size-4" aria-hidden="true"><circle cx="12" cy="12" r="1"></circle><circle cx="19" cy="12" r="1"></circle><circle cx="5" cy="12" r="1"></circle></svg></button></div></div><div class="astw:group astw:relative"><div class="astw:relative astw:size-30 astw:shrink-0 astw:overflow-hidden astw:rounded-lg astw:border astw:border-border astw:flex astw:flex-col astw:justify-between astw:bg-card astw:p-3"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-file astw:size-5 astw:text-muted-foreground astw:opacity-60" aria-hidden="true" data-testid="attachment-file-icon"><path d="M6 22a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h8a2.4 2.4 0 0 1 1.704.706l3.588 3.588A2.4 2.4 0 0 1 20 8v12a2 2 0 0 1-2 2z"></path><path d="M14 2v5a1 1 0 0 0 1 1h5"></path></svg><p class="astw:flex astw:min-w-0 astw:items-end astw:gap-0.5 astw:text-xs astw:leading-normal astw:text-foreground"><span class="astw:min-w-0 astw:flex-1 astw:line-clamp-2 astw:break-all">Aug-Sep 2025_1234-12</span><span class="astw:shrink-0">.pdf</span></p></div><div class="astw:absolute astw:top-2 astw:right-2 astw:opacity-0 astw:transition-opacity astw:group-hover:opacity-100 astw:group-focus-within:opacity-100"><button type="button" tabindex="0" aria-haspopup="menu" id="base-ui-_r_a_" data-slot="menu-trigger" aria-label="Attachment options for Aug-Sep 2025_1234-12.pdf" class="astw:inline-flex astw:size-7 astw:items-center astw:justify-center astw:rounded-md astw:border astw:bg-background/90 astw:text-foreground astw:shadow-xs astw:hover:bg-accent" aria-expanded="false"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-ellipsis astw:size-4" aria-hidden="true"><circle cx="12" cy="12" r="1"></circle><circle cx="19" cy="12" r="1"></circle><circle cx="5" cy="12" r="1"></circle></svg></button></div></div></div></div></div>"`;
Loading
Loading