From c488e80dd1370eccd087f067f3169247dc121f8e Mon Sep 17 00:00:00 2001
From: itsprade
Date: Tue, 31 Mar 2026 20:06:57 +0530
Subject: [PATCH 1/2] feat: add AttachmentCard component with generic async
upload lifecycle
Introduce a reusable AttachmentCard API with image/file previews, menu actions, and optional async upload handling so products can implement upload transport externally while getting consistent loading and error UI.
Made-with: Cursor
---
.changeset/tiny-peas-join.md | 17 +
CLAUDE.md | 1 +
docs/components/attachment-card.md | 85 +++++
examples/app-module/src/custom-module.tsx | 13 +
.../src/pages/attachment-card-demo.tsx | 112 ++++++
...achment-card__AttachmentCard.test.tsx.snap | 7 +
.../attachment-card/AttachmentCard.test.tsx | 265 +++++++++++++
.../attachment-card/AttachmentCard.tsx | 360 ++++++++++++++++++
.../src/components/attachment-card/index.ts | 2 +
.../src/components/attachment-card/types.ts | 41 ++
packages/core/src/components/content.tsx | 2 +-
packages/core/src/components/sonner.tsx | 1 +
packages/core/src/index.ts | 5 +
13 files changed, 910 insertions(+), 1 deletion(-)
create mode 100644 .changeset/tiny-peas-join.md
create mode 100644 docs/components/attachment-card.md
create mode 100644 examples/app-module/src/pages/attachment-card-demo.tsx
create mode 100644 packages/core/__snapshots__/src__components__attachment-card__AttachmentCard.test.tsx.snap
create mode 100644 packages/core/src/components/attachment-card/AttachmentCard.test.tsx
create mode 100644 packages/core/src/components/attachment-card/AttachmentCard.tsx
create mode 100644 packages/core/src/components/attachment-card/index.ts
create mode 100644 packages/core/src/components/attachment-card/types.ts
diff --git a/.changeset/tiny-peas-join.md b/.changeset/tiny-peas-join.md
new file mode 100644
index 00000000..4d3daad4
--- /dev/null
+++ b/.changeset/tiny-peas-join.md
@@ -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";
+
+;
+```
diff --git a/CLAUDE.md b/CLAUDE.md
index 59e4ec71..c0cb2da5 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -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
diff --git a/docs/components/attachment-card.md b/docs/components/attachment-card.md
new file mode 100644
index 00000000..e137d658
--- /dev/null
+++ b/docs/components/attachment-card.md
@@ -0,0 +1,85 @@
+---
+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" },
+];
+
+ 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` | - | Legacy controlled upload callback for file input + drag/drop |
+| `uploadFile` | `(file: File) => Promise` | - | Optional async upload handler for built-in uploading lifecycle UI |
+| `onUploadError` | `(ctx: { file: File; error: Error }) => void` | - | Called when `uploadFile` fails |
+| `onRetryUpload` | `(item: AttachmentItem) => Promise` | - | Optional retry strategy hook for consumer-defined recovery workflows |
+| `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" | "error";
+ errorMessage?: string;
+}
+```
+
+## 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.
+- **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)
diff --git a/examples/app-module/src/custom-module.tsx b/examples/app-module/src/custom-module.tsx
index 20d79f07..00d4afba 100644
--- a/examples/app-module/src/custom-module.tsx
+++ b/examples/app-module/src/custom-module.tsx
@@ -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,
@@ -82,6 +83,17 @@ export const customPageModule = defineModule({
View ActivityCard Demo
+
+
+ View AttachmentCard Demo
+
+
{
+ const toast = useToast();
+ const [items, setItems] = useState(initialItems);
+ const [asyncItems, setAsyncItems] = useState(initialItems);
+
+ const nextId = useMemo(() => items.length + 1, [items.length]);
+ const nextAsyncId = useMemo(() => asyncItems.length + 1, [asyncItems.length]);
+
+ return (
+
+
+
+
+ Two integration styles are shown below: controlled `onUpload` and generic async
+ `uploadFile`.
+
+ {
+ 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}`);
+ }}
+ />
+
+ {
+ 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}`);
+ }}
+ />
+
+
+ );
+};
+
+export const attachmentCardDemoResource = defineResource({
+ path: "attachment-card-demo",
+ meta: { title: "AttachmentCard Demo" },
+ component: AttachmentCardDemoPage,
+});
diff --git a/packages/core/__snapshots__/src__components__attachment-card__AttachmentCard.test.tsx.snap b/packages/core/__snapshots__/src__components__attachment-card__AttachmentCard.test.tsx.snap
new file mode 100644
index 00000000..3f4c4a92
--- /dev/null
+++ b/packages/core/__snapshots__/src__components__attachment-card__AttachmentCard.test.tsx.snap
@@ -0,0 +1,7 @@
+// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
+
+exports[`AttachmentCard > snapshots > disabled 1`] = `""`;
+
+exports[`AttachmentCard > snapshots > empty default 1`] = `""`;
+
+exports[`AttachmentCard > snapshots > populated mixed items 1`] = `""`;
diff --git a/packages/core/src/components/attachment-card/AttachmentCard.test.tsx b/packages/core/src/components/attachment-card/AttachmentCard.test.tsx
new file mode 100644
index 00000000..7e382d34
--- /dev/null
+++ b/packages/core/src/components/attachment-card/AttachmentCard.test.tsx
@@ -0,0 +1,265 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+
+import { AttachmentCard } from "./AttachmentCard";
+import type { AttachmentItem } from "./types";
+
+const toastError = vi.fn();
+
+vi.mock("@/hooks/use-toast", () => ({
+ useToast: () => ({
+ error: toastError,
+ }),
+}));
+
+afterEach(() => {
+ cleanup();
+ vi.clearAllMocks();
+});
+
+const mixedItems: AttachmentItem[] = [
+ {
+ id: "img-1",
+ fileName: "shoe-red.png",
+ mimeType: "image/png",
+ previewUrl: "https://example.com/shoe-red.png",
+ },
+ {
+ id: "file-1",
+ fileName: "Aug-Sep 2025_1234-12.pdf",
+ mimeType: "application/pdf",
+ },
+];
+
+describe("AttachmentCard", () => {
+ describe("snapshots", () => {
+ it("empty default", () => {
+ const { container } = render();
+ expect(container.innerHTML).toMatchSnapshot();
+ });
+
+ it("populated mixed items", () => {
+ const { container } = render();
+ expect(container.innerHTML).toMatchSnapshot();
+ });
+
+ it("disabled", () => {
+ const { container } = render();
+ expect(container.innerHTML).toMatchSnapshot();
+ });
+ });
+
+ it("renders title and upload button", () => {
+ render();
+ expect(screen.getByRole("heading", { name: "Product images" })).toBeDefined();
+ expect(screen.getByRole("button", { name: "Upload image" })).toBeDefined();
+ });
+
+ it("renders image and file preview branches", () => {
+ render();
+ expect(screen.getByRole("img", { name: "shoe-red.png" })).toBeDefined();
+ expect(screen.getByRole("button", { name: /Aug-Sep 2025_1234-12\.pdf/ })).toBeDefined();
+ expect(screen.getByTestId("attachment-file-icon")).toBeDefined();
+ });
+
+ it("calls onUpload when files are selected through input", () => {
+ const onUpload = vi.fn();
+ render();
+
+ const file = new File(["hello"], "invoice.pdf", { type: "application/pdf" });
+ const input = screen.getByTestId("attachment-upload-input") as HTMLInputElement;
+ fireEvent.change(input, { target: { files: [file] } });
+
+ expect(onUpload).toHaveBeenCalledTimes(1);
+ expect(onUpload.mock.calls[0]?.[0]).toHaveLength(1);
+ expect(onUpload.mock.calls[0]?.[0][0]?.name).toBe("invoice.pdf");
+ });
+
+ it("calls onUpload when files are dropped on the card", () => {
+ const onUpload = vi.fn();
+ const { container } = render();
+ const cardRoot = container.querySelector('[data-slot="attachment-card"]');
+ expect(cardRoot).toBeTruthy();
+
+ const file = new File(["hello"], "receipt.pdf", { type: "application/pdf" });
+ fireEvent.drop(cardRoot as HTMLElement, {
+ dataTransfer: {
+ files: [file],
+ },
+ });
+
+ expect(onUpload).toHaveBeenCalledTimes(1);
+ expect(onUpload.mock.calls[0]?.[0][0]?.name).toBe("receipt.pdf");
+ });
+
+ it("triggers download and delete actions from menu", async () => {
+ const user = userEvent.setup();
+ const onDownload = vi.fn();
+ const onDelete = vi.fn();
+
+ render();
+
+ const trigger = screen.getByRole("button", {
+ name: /Attachment options for Aug-Sep 2025_1234-12\.pdf/,
+ });
+ await user.click(trigger);
+
+ await waitFor(() => {
+ expect(screen.getByText("Download")).toBeDefined();
+ });
+
+ await user.click(screen.getByText("Download"));
+ expect(onDownload).toHaveBeenCalledTimes(1);
+ expect(onDownload).toHaveBeenCalledWith(mixedItems[1]);
+
+ await user.click(trigger);
+ await waitFor(() => {
+ expect(screen.getByText("Delete")).toBeDefined();
+ });
+ await user.click(screen.getByText("Delete"));
+ expect(onDelete).toHaveBeenCalledTimes(1);
+ expect(onDelete).toHaveBeenCalledWith(mixedItems[1]);
+ });
+
+ it("disables upload and hides menu actions when disabled", () => {
+ const onUpload = vi.fn();
+ render();
+
+ const uploadButton = screen.getByRole("button", { name: "Upload" });
+ expect(uploadButton).toHaveProperty("disabled", true);
+
+ const input = screen.getByTestId("attachment-upload-input") as HTMLInputElement;
+ fireEvent.change(input, {
+ target: {
+ files: [new File(["x"], "blocked.pdf", { type: "application/pdf" })],
+ },
+ });
+ expect(onUpload).not.toHaveBeenCalled();
+
+ const menuTrigger = screen.queryByRole("button", {
+ name: /Attachment options for/i,
+ });
+ expect(menuTrigger).toBeNull();
+ });
+
+ it("renders upload overlay and resolves async uploadFile results", async () => {
+ let resolveUpload: ((value: AttachmentItem) => void) | undefined;
+ const uploadFile = vi.fn(
+ () =>
+ new Promise((resolve) => {
+ resolveUpload = resolve;
+ }),
+ );
+
+ render();
+
+ const pendingFile = new File(["hello"], "pending.pdf", { type: "application/pdf" });
+ const input = screen.getByTestId("attachment-upload-input") as HTMLInputElement;
+ fireEvent.change(input, { target: { files: [pendingFile] } });
+
+ await waitFor(() => {
+ expect(uploadFile).toHaveBeenCalledTimes(1);
+ expect(screen.getAllByTestId("attachment-upload-overlay")).toHaveLength(1);
+ expect(screen.getAllByTestId("attachment-upload-spinner")).toHaveLength(1);
+ });
+
+ expect(
+ screen.queryByRole("button", { name: /Attachment options for pending\.pdf/ }),
+ ).toBeNull();
+
+ resolveUpload?.({
+ id: "uploaded-1",
+ fileName: "pending.pdf",
+ mimeType: "application/pdf",
+ status: "ready",
+ });
+
+ await waitFor(() => {
+ expect(screen.queryByTestId("attachment-upload-overlay")).toBeNull();
+ expect(screen.getByRole("button", { name: /pending\.pdf/ })).toBeDefined();
+ });
+ });
+
+ it("shows toast and removes failed temporary items in async upload mode", async () => {
+ const uploadFile = vi.fn(async () => {
+ throw new Error("Network failed");
+ });
+ const onUploadError = vi.fn();
+
+ render();
+
+ const failedFile = new File(["hello"], "bad-file.pdf", { type: "application/pdf" });
+ const input = screen.getByTestId("attachment-upload-input") as HTMLInputElement;
+ fireEvent.change(input, { target: { files: [failedFile] } });
+
+ await waitFor(() => {
+ expect(toastError).toHaveBeenCalledWith("Failed to upload bad-file.pdf");
+ expect(onUploadError).toHaveBeenCalledTimes(1);
+ });
+
+ await waitFor(() => {
+ expect(screen.queryByRole("button", { name: /bad-file\.pdf/ })).toBeNull();
+ expect(screen.queryByTestId("attachment-upload-overlay")).toBeNull();
+ });
+ });
+
+ it("falls back to image icon tile when image preview fails to load", async () => {
+ render(
+ ,
+ );
+
+ const image = screen.getByRole("img", { name: "IMG_0689_Original.jpg" });
+ fireEvent.error(image);
+
+ await waitFor(() => {
+ expect(screen.getByTestId("attachment-image-fallback-icon")).toBeDefined();
+ expect(screen.getByRole("button", { name: /IMG_0689_Original\.jpg/ })).toBeDefined();
+ });
+ });
+
+ it("removes async-uploaded local item when delete is selected", async () => {
+ const user = userEvent.setup();
+ const onDelete = vi.fn();
+ const uploadFile = vi.fn(async () => ({
+ id: "local-uploaded-1",
+ fileName: "local-delete.pdf",
+ mimeType: "application/pdf",
+ status: "ready" as const,
+ }));
+
+ render();
+
+ const input = screen.getByTestId("attachment-upload-input") as HTMLInputElement;
+ fireEvent.change(input, {
+ target: { files: [new File(["x"], "local-delete.pdf", { type: "application/pdf" })] },
+ });
+
+ await waitFor(() => {
+ expect(screen.getByRole("button", { name: /local-delete\.pdf/ })).toBeDefined();
+ });
+
+ const trigger = screen.getByRole("button", {
+ name: /Attachment options for local-delete\.pdf/,
+ });
+ await user.click(trigger);
+ await waitFor(() => {
+ expect(screen.getByText("Delete")).toBeDefined();
+ });
+ await user.click(screen.getByText("Delete"));
+
+ await waitFor(() => {
+ expect(screen.queryByRole("button", { name: /local-delete\.pdf/ })).toBeNull();
+ expect(onDelete).toHaveBeenCalledTimes(1);
+ });
+ });
+});
diff --git a/packages/core/src/components/attachment-card/AttachmentCard.tsx b/packages/core/src/components/attachment-card/AttachmentCard.tsx
new file mode 100644
index 00000000..051e53a7
--- /dev/null
+++ b/packages/core/src/components/attachment-card/AttachmentCard.tsx
@@ -0,0 +1,360 @@
+import * as React from "react";
+import { Ellipsis, File, Image as ImageIcon, Loader2 } from "lucide-react";
+
+import { useToast } from "@/hooks/use-toast";
+import { cn } from "@/lib/utils";
+
+import { Button } from "../button";
+import { Card } from "../card";
+import { Menu } from "../menu";
+import type { AttachmentCardProps, AttachmentItem } from "./types";
+
+const tileClasses =
+ "astw:relative astw:size-30 astw:shrink-0 astw:overflow-hidden astw:rounded-lg astw:border astw:border-border";
+
+type TemporaryUploadItem = {
+ item: AttachmentItem;
+ file: File;
+ previewUrl?: string;
+};
+
+function isImageItem(item: AttachmentItem): boolean {
+ return item.mimeType.startsWith("image/");
+}
+
+function toFiles(fileList: FileList | null): File[] {
+ return fileList ? Array.from(fileList) : [];
+}
+
+function splitFileName(fileName: string): { baseName: string; extension: string } {
+ const lastDotIndex = fileName.lastIndexOf(".");
+ if (lastDotIndex <= 0 || lastDotIndex === fileName.length - 1) {
+ return { baseName: fileName, extension: "" };
+ }
+
+ return {
+ baseName: fileName.slice(0, lastDotIndex),
+ extension: fileName.slice(lastDotIndex),
+ };
+}
+
+function createTemporaryUploadItem(file: File): TemporaryUploadItem {
+ const previewUrl = file.type.startsWith("image/") ? URL.createObjectURL(file) : undefined;
+ return {
+ file,
+ previewUrl,
+ item: {
+ id: `temp-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`,
+ fileName: file.name,
+ mimeType: file.type || "application/octet-stream",
+ previewUrl,
+ status: "uploading",
+ },
+ };
+}
+
+function mergeAttachmentItems(externalItems: AttachmentItem[], localItems: AttachmentItem[]) {
+ const merged: AttachmentItem[] = [];
+ const seen = new Set();
+
+ for (const item of [...localItems, ...externalItems]) {
+ if (seen.has(item.id)) continue;
+ seen.add(item.id);
+ merged.push(item);
+ }
+
+ return merged;
+}
+
+export function AttachmentCard({
+ title = "Attachments",
+ items = [],
+ onUpload,
+ uploadFile,
+ onUploadError,
+ onDelete,
+ onDownload,
+ uploadLabel = "Upload",
+ accept,
+ disabled = false,
+ className,
+}: AttachmentCardProps) {
+ const inputRef = React.useRef(null);
+ const [isDragOver, setIsDragOver] = React.useState(false);
+ const [localItems, setLocalItems] = React.useState([]);
+ const [failedImagePreviewIds, setFailedImagePreviewIds] = React.useState>(new Set());
+ const dragDepthRef = React.useRef(0);
+ const toast = useToast();
+
+ const handleUpload = React.useCallback(
+ async (files: File[]) => {
+ if (disabled || files.length === 0) return;
+ if (!uploadFile) {
+ onUpload?.(files);
+ return;
+ }
+
+ const temporaryItems = files.map(createTemporaryUploadItem);
+ setLocalItems((prev) => [...temporaryItems.map((entry) => entry.item), ...prev]);
+
+ await Promise.all(
+ temporaryItems.map(async (entry) => {
+ try {
+ const uploadedItem = await uploadFile(entry.file);
+ setLocalItems((prev) =>
+ prev.map((item) =>
+ item.id === entry.item.id
+ ? {
+ ...uploadedItem,
+ status: uploadedItem.status ?? "ready",
+ errorMessage: undefined,
+ }
+ : item,
+ ),
+ );
+ } catch (error: unknown) {
+ const uploadError =
+ error instanceof Error ? error : new Error("Failed to upload attachment");
+ toast.error(`Failed to upload ${entry.file.name}`);
+ onUploadError?.({ file: entry.file, error: uploadError });
+ setLocalItems((prev) => prev.filter((item) => item.id !== entry.item.id));
+ } finally {
+ if (entry.previewUrl) {
+ URL.revokeObjectURL(entry.previewUrl);
+ }
+ }
+ }),
+ );
+ },
+ [disabled, onUpload, onUploadError, toast, uploadFile],
+ );
+
+ const handleInputChange = React.useCallback(
+ (event: React.ChangeEvent) => {
+ void handleUpload(toFiles(event.target.files));
+ event.target.value = "";
+ },
+ [handleUpload],
+ );
+
+ const handleDrop = React.useCallback(
+ (event: React.DragEvent) => {
+ event.preventDefault();
+ if (disabled) return;
+ dragDepthRef.current = 0;
+ setIsDragOver(false);
+ void handleUpload(toFiles(event.dataTransfer.files));
+ },
+ [disabled, handleUpload],
+ );
+
+ const handleDragEnter = React.useCallback(
+ (event: React.DragEvent) => {
+ event.preventDefault();
+ if (disabled) return;
+ dragDepthRef.current += 1;
+ setIsDragOver(true);
+ },
+ [disabled],
+ );
+
+ const handleDragLeave = React.useCallback(
+ (event: React.DragEvent) => {
+ event.preventDefault();
+ if (disabled) return;
+ dragDepthRef.current = Math.max(0, dragDepthRef.current - 1);
+ if (dragDepthRef.current === 0) {
+ setIsDragOver(false);
+ }
+ },
+ [disabled],
+ );
+
+ const handleImagePreviewError = React.useCallback((itemId: string) => {
+ setFailedImagePreviewIds((prev) => {
+ const next = new Set(prev);
+ next.add(itemId);
+ return next;
+ });
+ }, []);
+
+ const handleDeleteItem = React.useCallback(
+ (item: AttachmentItem) => {
+ setLocalItems((prev) => prev.filter((candidate) => candidate.id !== item.id));
+ setFailedImagePreviewIds((prev) => {
+ if (!prev.has(item.id)) return prev;
+ const next = new Set(prev);
+ next.delete(item.id);
+ return next;
+ });
+ onDelete?.(item);
+ },
+ [onDelete],
+ );
+
+ const displayItems = React.useMemo(
+ () => mergeAttachmentItems(items, localItems),
+ [items, localItems],
+ );
+ const hasItems = displayItems.length > 0;
+
+ return (
+ event.preventDefault()}
+ onDragEnter={handleDragEnter}
+ onDragLeave={handleDragLeave}
+ onDrop={handleDrop}
+ >
+
+
+
{title}
+
+
+ Drag and drop images/files or
+
+
+
+
+
+
+ {hasItems && (
+
+
+ {displayItems.map((item) => {
+ const { baseName, extension } = splitFileName(item.fileName);
+ const isUploading = item.status === "uploading";
+ const hasFailedImagePreview = failedImagePreviewIds.has(item.id);
+ const shouldShowImagePreview =
+ isImageItem(item) && !!item.previewUrl && !hasFailedImagePreview;
+
+ return (
+
+ {isImageItem(item) ? (
+
+ {shouldShowImagePreview ? (
+

handleImagePreviewError(item.id)}
+ />
+ ) : (
+ <>
+
+
+
+ {baseName}
+
+ {extension ? {extension} : null}
+
+ >
+ )}
+ {isUploading ? (
+
+
+
+ ) : null}
+
+ ) : (
+
+
+
+
+ {baseName}
+
+ {extension ? {extension} : null}
+
+ {isUploading ? (
+
+
+
+ ) : null}
+
+ )}
+ {!disabled && !isUploading && (
+
+
+
+
+
+
+ onDownload?.(item)}>Download
+ handleDeleteItem(item)}>Delete
+
+
+
+ )}
+
+ );
+ })}
+
+
+ )}
+
+ );
+}
+
+export default AttachmentCard;
diff --git a/packages/core/src/components/attachment-card/index.ts b/packages/core/src/components/attachment-card/index.ts
new file mode 100644
index 00000000..c7111067
--- /dev/null
+++ b/packages/core/src/components/attachment-card/index.ts
@@ -0,0 +1,2 @@
+export { AttachmentCard, default } from "./AttachmentCard";
+export type { AttachmentCardProps, AttachmentItem } from "./types";
diff --git a/packages/core/src/components/attachment-card/types.ts b/packages/core/src/components/attachment-card/types.ts
new file mode 100644
index 00000000..abe7570a
--- /dev/null
+++ b/packages/core/src/components/attachment-card/types.ts
@@ -0,0 +1,41 @@
+export interface AttachmentItem {
+ /** Unique identifier for the attachment item. */
+ id: string;
+ /** Original filename shown for non-image attachments. */
+ fileName: string;
+ /** MIME type used to switch image/file preview rendering. */
+ mimeType: string;
+ /** Optional preview URL for image attachments. */
+ previewUrl?: string;
+ /** Lifecycle status used for upload rendering. */
+ status?: "ready" | "uploading" | "error";
+ /** Optional upload error message for UI/telemetry. */
+ errorMessage?: string;
+}
+
+export interface AttachmentCardProps {
+ /** Card title text. */
+ title?: string;
+ /** List of attachments to render. */
+ items?: AttachmentItem[];
+ /** Called when files are selected or dropped. */
+ onUpload?: (files: File[]) => void;
+ /** Optional async upload handler for built-in upload lifecycle UX. */
+ uploadFile?: (file: File) => Promise;
+ /** Called when an async upload fails. */
+ onUploadError?: (ctx: { file: File; error: Error }) => void;
+ /** Optional retry handler used by consumers for error recovery strategies. */
+ onRetryUpload?: (item: AttachmentItem) => Promise;
+ /** Called when delete action is selected for an item. */
+ onDelete?: (item: AttachmentItem) => void;
+ /** Called when download action is selected for an item. */
+ onDownload?: (item: AttachmentItem) => void;
+ /** Upload button label. */
+ uploadLabel?: string;
+ /** Accepted file types passed to the hidden file input. */
+ accept?: string;
+ /** Disable upload and item actions. */
+ disabled?: boolean;
+ /** Additional classes applied on the card root. */
+ className?: string;
+}
diff --git a/packages/core/src/components/content.tsx b/packages/core/src/components/content.tsx
index d0aabd98..511fa769 100644
--- a/packages/core/src/components/content.tsx
+++ b/packages/core/src/components/content.tsx
@@ -1,7 +1,7 @@
import { NavLink, Outlet } from "react-router";
-import { Toaster } from "sonner";
import { useAppShell } from "@/contexts/appshell-context";
import { Button } from "./button";
+import { Toaster } from "./sonner";
import { useT } from "@/i18n-labels";
import { useTitleResolver } from "@/hooks/i18n";
diff --git a/packages/core/src/components/sonner.tsx b/packages/core/src/components/sonner.tsx
index 45992d1e..37319e5d 100644
--- a/packages/core/src/components/sonner.tsx
+++ b/packages/core/src/components/sonner.tsx
@@ -7,6 +7,7 @@ const Toaster = ({ ...props }: ToasterProps) => {
return (
Date: Tue, 31 Mar 2026 20:38:53 +0530
Subject: [PATCH 2/2] fix: align AttachmentCard API contract and upload cleanup
Remove dead public upload fields, enforce mutually exclusive upload modes at the type level, and harden async object URL lifecycle cleanup to avoid unmount-related leaks while keeping behavior docs and tests in sync.
Made-with: Cursor
---
docs/components/attachment-card.md | 31 ++++++++-------
.../attachment-card/AttachmentCard.test.tsx | 34 +++++++++++++++++
.../attachment-card/AttachmentCard.tsx | 22 ++++++++++-
.../src/components/attachment-card/types.ts | 38 +++++++++++++------
4 files changed, 96 insertions(+), 29 deletions(-)
diff --git a/docs/components/attachment-card.md b/docs/components/attachment-card.md
index e137d658..3b0954c0 100644
--- a/docs/components/attachment-card.md
+++ b/docs/components/attachment-card.md
@@ -36,20 +36,19 @@ const items: AttachmentItem[] = [
## Props
-| Prop | Type | Default | Description |
-| --------------- | --------------------------------------------------- | --------------- | -------------------------------------------------------------------- |
-| `title` | `string` | `"Attachments"` | Card heading text |
-| `items` | `AttachmentItem[]` | `[]` | Attachment list rendered as preview tiles |
-| `onUpload` | `(files: File[]) => void` | - | Legacy controlled upload callback for file input + drag/drop |
-| `uploadFile` | `(file: File) => Promise` | - | Optional async upload handler for built-in uploading lifecycle UI |
-| `onUploadError` | `(ctx: { file: File; error: Error }) => void` | - | Called when `uploadFile` fails |
-| `onRetryUpload` | `(item: AttachmentItem) => Promise` | - | Optional retry strategy hook for consumer-defined recovery workflows |
-| `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 |
+| 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` | - | 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
@@ -59,8 +58,7 @@ interface AttachmentItem {
fileName: string;
mimeType: string;
previewUrl?: string;
- status?: "ready" | "uploading" | "error";
- errorMessage?: string;
+ status?: "ready" | "uploading";
}
```
@@ -68,6 +66,7 @@ interface AttachmentItem {
- **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
diff --git a/packages/core/src/components/attachment-card/AttachmentCard.test.tsx b/packages/core/src/components/attachment-card/AttachmentCard.test.tsx
index 7e382d34..ceefa280 100644
--- a/packages/core/src/components/attachment-card/AttachmentCard.test.tsx
+++ b/packages/core/src/components/attachment-card/AttachmentCard.test.tsx
@@ -262,4 +262,38 @@ describe("AttachmentCard", () => {
expect(onDelete).toHaveBeenCalledTimes(1);
});
});
+
+ it("revokes pending object URLs when unmounted during async upload", async () => {
+ const createObjectUrlSpy = vi
+ .spyOn(URL, "createObjectURL")
+ .mockReturnValue("blob:attachment-card-pending-image");
+ const revokeObjectUrlSpy = vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => undefined);
+
+ const uploadFile = vi.fn(
+ () =>
+ new Promise(() => {
+ // Intentionally unresolved promise to simulate in-flight upload.
+ }),
+ );
+
+ const { unmount } = render();
+ const input = screen.getByTestId("attachment-upload-input") as HTMLInputElement;
+
+ fireEvent.change(input, {
+ target: {
+ files: [new File(["image-bytes"], "pending-image.jpg", { type: "image/jpeg" })],
+ },
+ });
+
+ await waitFor(() => {
+ expect(uploadFile).toHaveBeenCalledTimes(1);
+ });
+
+ unmount();
+
+ expect(revokeObjectUrlSpy).toHaveBeenCalledWith("blob:attachment-card-pending-image");
+
+ createObjectUrlSpy.mockRestore();
+ revokeObjectUrlSpy.mockRestore();
+ });
});
diff --git a/packages/core/src/components/attachment-card/AttachmentCard.tsx b/packages/core/src/components/attachment-card/AttachmentCard.tsx
index 051e53a7..d7d850fd 100644
--- a/packages/core/src/components/attachment-card/AttachmentCard.tsx
+++ b/packages/core/src/components/attachment-card/AttachmentCard.tsx
@@ -84,8 +84,21 @@ export function AttachmentCard({
const [localItems, setLocalItems] = React.useState([]);
const [failedImagePreviewIds, setFailedImagePreviewIds] = React.useState>(new Set());
const dragDepthRef = React.useRef(0);
+ const objectUrlsRef = React.useRef>(new Set());
+ const isMountedRef = React.useRef(true);
const toast = useToast();
+ React.useEffect(() => {
+ const trackedObjectUrls = objectUrlsRef.current;
+ return () => {
+ isMountedRef.current = false;
+ for (const objectUrl of trackedObjectUrls) {
+ URL.revokeObjectURL(objectUrl);
+ }
+ trackedObjectUrls.clear();
+ };
+ }, []);
+
const handleUpload = React.useCallback(
async (files: File[]) => {
if (disabled || files.length === 0) return;
@@ -95,24 +108,30 @@ export function AttachmentCard({
}
const temporaryItems = files.map(createTemporaryUploadItem);
+ for (const temporaryItem of temporaryItems) {
+ if (temporaryItem.previewUrl) {
+ objectUrlsRef.current.add(temporaryItem.previewUrl);
+ }
+ }
setLocalItems((prev) => [...temporaryItems.map((entry) => entry.item), ...prev]);
await Promise.all(
temporaryItems.map(async (entry) => {
try {
const uploadedItem = await uploadFile(entry.file);
+ if (!isMountedRef.current) return;
setLocalItems((prev) =>
prev.map((item) =>
item.id === entry.item.id
? {
...uploadedItem,
status: uploadedItem.status ?? "ready",
- errorMessage: undefined,
}
: item,
),
);
} catch (error: unknown) {
+ if (!isMountedRef.current) return;
const uploadError =
error instanceof Error ? error : new Error("Failed to upload attachment");
toast.error(`Failed to upload ${entry.file.name}`);
@@ -121,6 +140,7 @@ export function AttachmentCard({
} finally {
if (entry.previewUrl) {
URL.revokeObjectURL(entry.previewUrl);
+ objectUrlsRef.current.delete(entry.previewUrl);
}
}
}),
diff --git a/packages/core/src/components/attachment-card/types.ts b/packages/core/src/components/attachment-card/types.ts
index abe7570a..3bfd4f51 100644
--- a/packages/core/src/components/attachment-card/types.ts
+++ b/packages/core/src/components/attachment-card/types.ts
@@ -8,24 +8,14 @@ export interface AttachmentItem {
/** Optional preview URL for image attachments. */
previewUrl?: string;
/** Lifecycle status used for upload rendering. */
- status?: "ready" | "uploading" | "error";
- /** Optional upload error message for UI/telemetry. */
- errorMessage?: string;
+ status?: "ready" | "uploading";
}
-export interface AttachmentCardProps {
+interface AttachmentCardBaseProps {
/** Card title text. */
title?: string;
/** List of attachments to render. */
items?: AttachmentItem[];
- /** Called when files are selected or dropped. */
- onUpload?: (files: File[]) => void;
- /** Optional async upload handler for built-in upload lifecycle UX. */
- uploadFile?: (file: File) => Promise;
- /** Called when an async upload fails. */
- onUploadError?: (ctx: { file: File; error: Error }) => void;
- /** Optional retry handler used by consumers for error recovery strategies. */
- onRetryUpload?: (item: AttachmentItem) => Promise;
/** Called when delete action is selected for an item. */
onDelete?: (item: AttachmentItem) => void;
/** Called when download action is selected for an item. */
@@ -39,3 +29,27 @@ export interface AttachmentCardProps {
/** Additional classes applied on the card root. */
className?: string;
}
+
+type ControlledUploadProps = {
+ /** Called when files are selected or dropped. */
+ onUpload: (files: File[]) => void;
+ uploadFile?: never;
+ onUploadError?: never;
+};
+
+type AsyncUploadProps = {
+ onUpload?: never;
+ /** Optional async upload handler for built-in upload lifecycle UX. */
+ uploadFile: (file: File) => Promise;
+ /** Called when an async upload fails. */
+ onUploadError?: (ctx: { file: File; error: Error }) => void;
+};
+
+type ReadOnlyListProps = {
+ onUpload?: undefined;
+ uploadFile?: undefined;
+ onUploadError?: never;
+};
+
+export type AttachmentCardProps = AttachmentCardBaseProps &
+ (ControlledUploadProps | AsyncUploadProps | ReadOnlyListProps);