From e39e900432d765a3c1869e216eb3f3273f2a7000 Mon Sep 17 00:00:00 2001 From: Saurabh Date: Thu, 30 Jul 2026 23:22:14 +0530 Subject: [PATCH 1/7] feat: improve attachment image previews --- AGENTS.md | 2 +- components/motion/attachment-upload.tsx | 288 +++++++++++++----- .../blocks/attachment-upload.preview.tsx | 36 +-- lib/registry.ts | 2 +- tests/attachment-upload.test.tsx | 32 +- 5 files changed, 247 insertions(+), 113 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index e195343..8512865 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -83,7 +83,7 @@ Before building a new component, check this list. If it exists, import it. If it | `overflow-actions` | `components/motion/overflow-actions.tsx` | Connected pill rail that springs open to reveal extra controls | | `expandable-tabs` | `components/motion/expandable-tabs.tsx` | Icon tab bar where active tab expands to labeled pill with height-morphing panel | | `swipeable-list` | `components/motion/swipeable-list.tsx` | List rows that swipe left/right to reveal contextual action buttons | -| `file-upload` | `components/motion/file-upload.tsx`, `attachment-upload.tsx` | Two upload patterns. `AttachmentUpload` mixes compact file/link rows, an audio waveform, media tiles and mention chips; `FileUpload` is the original progress queue with retry/remove actions | +| `file-upload` | `components/motion/file-upload.tsx`, `attachment-upload.tsx` | Two upload patterns. `AttachmentUpload` mixes compact file/image rows, hover and fullscreen image previews, and an audio waveform; `FileUpload` is the original progress queue with retry/remove actions | | `prediction-market` | `components/motion/prediction-market.tsx` | Trade ticket with buy/sell modes, outcome prices and rolling amount entry | | `otp-input` | `components/motion/otp-input.tsx` | One-time-code input with gliding focus ring, roll-in digits, error shake and success draw | | `bloom-menu` | `components/motion/bloom-menu.tsx` | Button that morphs open into a menu and blooms iris-out from center via shared layout + clip-path, with radially staggered items | diff --git a/components/motion/attachment-upload.tsx b/components/motion/attachment-upload.tsx index a2bf520..67c742c 100644 --- a/components/motion/attachment-upload.tsx +++ b/components/motion/attachment-upload.tsx @@ -20,18 +20,18 @@ import { useRef, useState, } from "react"; +import { createPortal } from "react-dom"; +import { Tooltip } from "@/components/motion/tooltip"; import { EASE_OUT, SPRING_PRESS } from "@/lib/ease"; import { cn } from "@/lib/utils"; export type AttachmentUploadKind = "file" | "link" | "image" | "audio"; -export type AttachmentUploadDisplay = "row" | "media"; export type AttachmentRejectReason = "too-large" | "max-files"; export type AttachmentUploadItem = { id: string; name: string; kind: AttachmentUploadKind; - display?: AttachmentUploadDisplay; size?: number; href?: string; previewUrl?: string; @@ -44,7 +44,6 @@ export type AttachmentUploadClassNames = { dropzone?: string; list?: string; row?: string; - media?: string; }; export interface AttachmentUploadProps { @@ -139,6 +138,11 @@ function AttachmentIcon({ kind }: { kind: AttachmentUploadKind }) { return ; } +function imageSource(item: AttachmentUploadItem) { + if (item.kind !== "image") return undefined; + return item.previewUrl ?? item.href; +} + function RemoveButton({ label, onClick, @@ -165,10 +169,175 @@ function RemoveButton({ ); } +function ImageThumbnail({ + item, + onPreview, + reduce, +}: { + item: AttachmentUploadItem; + onPreview: (item: AttachmentUploadItem) => void; + reduce: boolean; +}) { + const src = imageSource(item); + + if (!src) { + return ( + + ); + } + + return ( + + + + Click to preview + + + } + > + { + event.currentTarget.blur(); + onPreview(item); + }} + whileTap={reduce ? undefined : { scale: 0.94 }} + transition={SPRING_PRESS} + className="group/image relative size-9 shrink-0 overflow-hidden rounded-[10px] bg-muted outline-none ring-1 ring-border/70 focus-visible:ring-2 focus-visible:ring-ring" + > + + + + ); +} + +function ImagePreviewDialog({ + item, + onClose, + reduce, +}: { + item: AttachmentUploadItem | null; + onClose: () => void; + reduce: boolean; +}) { + const closeRef = useRef(null); + + useEffect(() => { + if (!item) return; + + const previousFocus = + document.activeElement instanceof HTMLElement + ? document.activeElement + : null; + const previousOverflow = document.body.style.overflow; + document.body.style.overflow = "hidden"; + closeRef.current?.focus(); + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") onClose(); + if (event.key === "Tab") { + event.preventDefault(); + closeRef.current?.focus(); + } + }; + document.addEventListener("keydown", handleKeyDown); + + return () => { + document.removeEventListener("keydown", handleKeyDown); + document.body.style.overflow = previousOverflow; + previousFocus?.focus(); + }; + }, [item, onClose]); + + if (typeof document === "undefined") return null; + + const src = item ? imageSource(item) : undefined; + + return createPortal( + + {item && src ? ( +
+ + +
+ + {item.name} + + + + +
+
+ ) : null} +
, + document.body, + ); +} + function AttachmentRow({ item, playing, onAudioToggle, + onImagePreview, onRemove, reduce, className, @@ -176,6 +345,7 @@ function AttachmentRow({ item: AttachmentUploadItem; playing: boolean; onAudioToggle?: (item: AttachmentUploadItem) => void; + onImagePreview: (item: AttachmentUploadItem) => void; onRemove: (item: AttachmentUploadItem) => void; reduce: boolean; className?: string; @@ -199,12 +369,20 @@ function AttachmentRow({ )} >
- + {item.kind === "image" ? ( + + ) : ( + + )} {item.kind === "audio" ? ( <> @@ -307,50 +485,6 @@ function AttachmentRow({ ); } -function MediaTile({ - item, - onRemove, - reduce, -}: { - item: AttachmentUploadItem; - onRemove: (item: AttachmentUploadItem) => void; - reduce: boolean; -}) { - return ( - -
- {item.previewUrl ? null : ( - - - - )} - - {formatBytes(item.size) ?? "Image"} - -
- onRemove(item)} - className="absolute -right-2 -top-2 size-7 rounded-full border border-border bg-background shadow-sm" - /> -
- ); -} - export function AttachmentUpload({ value, defaultValue, @@ -377,6 +511,8 @@ export function AttachmentUpload({ const ownedUrlsRef = useRef(new Set()); const reduce = useReducedMotion() ?? false; const [dragging, setDragging] = useState(false); + const [previewItem, setPreviewItem] = + useState(null); const [items, setItems] = useControllableList({ value, defaultValue, @@ -392,8 +528,6 @@ export function AttachmentUpload({ ); const maxReached = items.length >= maxFiles; - const rowItems = items.filter((item) => item.display !== "media"); - const mediaItems = items.filter((item) => item.display === "media"); const addFiles = useCallback( (incomingFiles: File[]) => { @@ -430,7 +564,6 @@ export function AttachmentUpload({ id: `${Date.now()}-${index}-${file.name}`, name: file.name, kind, - display: kind === "image" ? ("media" as const) : ("row" as const), size: file.size, previewUrl: kind === "image" ? objectUrl : undefined, href: objectUrl, @@ -466,16 +599,27 @@ export function AttachmentUpload({ URL.revokeObjectURL(ownedUrl); ownedUrlsRef.current.delete(ownedUrl); } + if (previewItem?.id === item.id) setPreviewItem(null); setItems(items.filter((entry) => entry.id !== item.id)); onRemove?.(item); }, - [items, onRemove, setItems], + [items, onRemove, previewItem, setItems], ); const resetDrag = useCallback(() => { dragDepthRef.current = 0; setDragging(false); }, []); + const closePreview = useCallback(() => setPreviewItem(null), []); + + useEffect(() => { + if ( + previewItem && + !items.some((item) => item.id === previewItem.id) + ) { + setPreviewItem(null); + } + }, [items, previewItem]); return (
@@ -578,15 +722,16 @@ export function AttachmentUpload({ {attachmentsLabel} - {rowItems.length > 0 ? ( + {items.length > 0 ? (
    - {rowItems.map((item) => ( + {items.map((item) => (
) : null} - - {mediaItems.length > 0 ? ( -
    - - {mediaItems.map((item) => ( - - ))} - -
- ) : null} ) : null} +
); } diff --git a/components/previews/blocks/attachment-upload.preview.tsx b/components/previews/blocks/attachment-upload.preview.tsx index 34bb0a3..f38171c 100644 --- a/components/previews/blocks/attachment-upload.preview.tsx +++ b/components/previews/blocks/attachment-upload.preview.tsx @@ -15,11 +15,12 @@ const INITIAL_ITEMS: AttachmentUploadItem[] = [ href: "data:application/pdf,beUI%20launch%20brief", }, { - id: "cover", - name: "launch-cover.jpeg", + id: "flowers", + name: "orange-flowers.jpg", kind: "image", - size: 198_000, - href: "/og/grainient-component.jpg", + size: 9_800_000, + previewUrl: + "https://images.unsplash.com/photo-1490750967868-88aa4486c946?auto=format&fit=crop&w=1200&q=85", }, { id: "voice-note", @@ -28,33 +29,6 @@ const INITIAL_ITEMS: AttachmentUploadItem[] = [ currentTime: 12, duration: 48, }, - { - id: "flowers", - name: "orange-flowers.jpg", - kind: "image", - display: "media", - size: 10_300_000, - previewUrl: - "https://images.unsplash.com/photo-1490750967868-88aa4486c946?auto=format&fit=crop&w=520&q=80", - }, - { - id: "field", - name: "green-field.jpg", - kind: "image", - display: "media", - size: 5_800_000, - previewUrl: - "https://images.unsplash.com/photo-1501004318641-b39e6451bec6?auto=format&fit=crop&w=520&q=80", - }, - { - id: "cosmos", - name: "pink-cosmos.jpg", - kind: "image", - display: "media", - size: 8_200_000, - previewUrl: - "https://images.unsplash.com/photo-1497250681960-ef046c08a56e?auto=format&fit=crop&w=520&q=80", - }, ]; export function AttachmentUploadPreview() { diff --git a/lib/registry.ts b/lib/registry.ts index f2ac6f3..4184853 100644 --- a/lib/registry.ts +++ b/lib/registry.ts @@ -887,7 +887,7 @@ export const registry: CategoryEntry[] = [ slug: "attachment-upload", name: "Attachment Upload", description: - "A mixed attachment workspace with a dropzone, compact file and link rows, an audio waveform, and media tiles.", + "A mixed attachment workspace with a dropzone, compact file and image rows, hover and fullscreen image previews, and an audio waveform.", badge: "new", launchedAt: "2026-07-30", installSlug: "attachment-upload", diff --git a/tests/attachment-upload.test.tsx b/tests/attachment-upload.test.tsx index 02c1a5b..2b8a9dd 100644 --- a/tests/attachment-upload.test.tsx +++ b/tests/attachment-upload.test.tsx @@ -1,5 +1,10 @@ import { afterEach, describe, expect, mock, test } from "bun:test"; -import { cleanup, fireEvent, render } from "@testing-library/react"; +import { + cleanup, + fireEvent, + render, + waitFor, +} from "@testing-library/react"; import { AttachmentUpload, type AttachmentUploadItem, @@ -66,4 +71,29 @@ describe("AttachmentUpload", () => { expect(onAudioToggle).toHaveBeenCalledWith(audio); }); + + test("opens image rows in a dismissible preview dialog", async () => { + const image: AttachmentUploadItem = { + id: "cover", + name: "cover.png", + kind: "image", + size: 320_000, + previewUrl: "data:image/svg+xml,", + }; + const { getByLabelText, getByRole, queryByRole } = render( + , + ); + + fireEvent.click(getByLabelText("Preview cover.png")); + + expect( + getByRole("dialog", { name: "Preview of cover.png" }), + ).toBeTruthy(); + + fireEvent.keyDown(document, { key: "Escape" }); + + await waitFor(() => { + expect(queryByRole("dialog")).toBeNull(); + }); + }); }); From 2f108b357254eaa7de8f4f34db5aeab38e22c553 Mon Sep 17 00:00:00 2001 From: Saurabh Date: Thu, 30 Jul 2026 23:40:46 +0530 Subject: [PATCH 2/7] feat: add attachment upload lifecycle states --- AGENTS.md | 2 +- components/motion/attachment-upload.tsx | 335 +++++++++++++++--- .../blocks/attachment-upload.preview.tsx | 43 ++- lib/registry.ts | 2 +- tests/a11y.test.tsx | 3 + tests/attachment-upload.test.tsx | 66 +++- tests/setup.ts | 3 + 7 files changed, 406 insertions(+), 48 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 8512865..b9afc3c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -83,7 +83,7 @@ Before building a new component, check this list. If it exists, import it. If it | `overflow-actions` | `components/motion/overflow-actions.tsx` | Connected pill rail that springs open to reveal extra controls | | `expandable-tabs` | `components/motion/expandable-tabs.tsx` | Icon tab bar where active tab expands to labeled pill with height-morphing panel | | `swipeable-list` | `components/motion/swipeable-list.tsx` | List rows that swipe left/right to reveal contextual action buttons | -| `file-upload` | `components/motion/file-upload.tsx`, `attachment-upload.tsx` | Two upload patterns. `AttachmentUpload` mixes compact file/image rows, hover and fullscreen image previews, and an audio waveform; `FileUpload` is the original progress queue with retry/remove actions | +| `file-upload` | `components/motion/file-upload.tsx`, `attachment-upload.tsx` | Two upload patterns. `AttachmentUpload` mixes compact file/image rows, upload/success/failure/removal feedback with retry, hover and fullscreen image previews, and an audio waveform; `FileUpload` is the original progress queue with retry/remove actions | | `prediction-market` | `components/motion/prediction-market.tsx` | Trade ticket with buy/sell modes, outcome prices and rolling amount entry | | `otp-input` | `components/motion/otp-input.tsx` | One-time-code input with gliding focus ring, roll-in digits, error shake and success draw | | `bloom-menu` | `components/motion/bloom-menu.tsx` | Button that morphs open into a menu and blooms iris-out from center via shared layout + clip-path, with radially staggered items | diff --git a/components/motion/attachment-upload.tsx b/components/motion/attachment-upload.tsx index 67c742c..d91d476 100644 --- a/components/motion/attachment-upload.tsx +++ b/components/motion/attachment-upload.tsx @@ -1,14 +1,17 @@ "use client"; import { - Download, + AlertCircle, + Check, ExternalLink, FileImage, Link as LinkIcon, + LoaderCircle, Mic, Paperclip, Pause, Play, + RotateCcw, Upload, X, } from "lucide-react"; @@ -27,6 +30,11 @@ import { cn } from "@/lib/utils"; export type AttachmentUploadKind = "file" | "link" | "image" | "audio"; export type AttachmentRejectReason = "too-large" | "max-files"; +export type AttachmentUploadStatus = + | "idle" + | "uploading" + | "complete" + | "failed"; export type AttachmentUploadItem = { id: string; @@ -37,6 +45,8 @@ export type AttachmentUploadItem = { previewUrl?: string; currentTime?: number; duration?: number; + status?: AttachmentUploadStatus; + error?: string; file?: File; }; @@ -53,6 +63,7 @@ export interface AttachmentUploadProps { onFilesAdded?: (items: AttachmentUploadItem[], files: File[]) => void; onFilesRejected?: (files: File[], reason: AttachmentRejectReason) => void; onRemove?: (item: AttachmentUploadItem) => void; + onRetry?: (item: AttachmentUploadItem) => void; playingId?: string; onAudioToggle?: (item: AttachmentUploadItem) => void; accept?: string; @@ -69,6 +80,9 @@ export interface AttachmentUploadProps { const ITEM_TRANSITION = { duration: 0.2, ease: EASE_OUT } as const; const DEFAULT_MAX_FILE_SIZE = 500 * 1024 * 1024; +const UPLOAD_PROGRESS_MS = 900; +const UPLOAD_COMPLETE_HOLD_MS = 1000; +const REMOVE_PENDING_MS = 420; const WAVEFORM_BARS = [ 18, 31, 24, 39, 30, 43, 27, 18, 9, 29, 38, 24, 34, 18, 26, 37, 21, 14, @@ -143,29 +157,115 @@ function imageSource(item: AttachmentUploadItem) { return item.previewUrl ?? item.href; } -function RemoveButton({ +type RowActionState = + | "idle" + | "uploading" + | "complete" + | "failed" + | "removing"; + +function RowAction({ label, onClick, - className, + state, + retryable = false, + reduce = false, }: { label: string; onClick: () => void; - className?: string; + state: RowActionState; + retryable?: boolean; + reduce?: boolean; }) { + if (state === "uploading") { + return