diff --git a/AGENTS.md b/AGENTS.md index e195343..6adace2 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 staggered file/image rows, upload/success/failure/removal feedback with retry, shared-layout 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..20ee2f0 100644 --- a/components/motion/attachment-upload.tsx +++ b/components/motion/attachment-upload.tsx @@ -1,18 +1,26 @@ "use client"; import { - Download, + AlertCircle, + Check, ExternalLink, FileImage, Link as LinkIcon, + LoaderCircle, Mic, Paperclip, Pause, Play, + RotateCcw, Upload, X, } from "lucide-react"; -import { AnimatePresence, motion, useReducedMotion } from "motion/react"; +import { + AnimatePresence, + LayoutGroup, + motion, + useReducedMotion, +} from "motion/react"; import { useCallback, useEffect, @@ -20,23 +28,34 @@ import { useRef, useState, } from "react"; -import { EASE_OUT, SPRING_PRESS } from "@/lib/ease"; +import { createPortal } from "react-dom"; +import { Tooltip } from "@/components/motion/tooltip"; +import { + EASE_OUT, + SPRING_LAYOUT, + 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 AttachmentUploadStatus = + | "idle" + | "uploading" + | "complete" + | "failed"; export type AttachmentUploadItem = { id: string; name: string; kind: AttachmentUploadKind; - display?: AttachmentUploadDisplay; size?: number; href?: string; previewUrl?: string; currentTime?: number; duration?: number; + status?: AttachmentUploadStatus; + error?: string; file?: File; }; @@ -44,7 +63,6 @@ export type AttachmentUploadClassNames = { dropzone?: string; list?: string; row?: string; - media?: string; }; export interface AttachmentUploadProps { @@ -54,6 +72,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; @@ -70,6 +89,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, @@ -139,44 +161,320 @@ function AttachmentIcon({ kind }: { kind: AttachmentUploadKind }) { return ; } -function RemoveButton({ +function imageSource(item: AttachmentUploadItem) { + if (item.kind !== "image") return undefined; + return item.previewUrl ?? item.href; +} + +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 + } > - - + { + 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, + layoutId, + onClose, + reduce, +}: { + item: AttachmentUploadItem | null; + layoutId?: string; + 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; + const content = + item && src ? ( +
+ + +
+ + + + + + +
+
+ ) : null; + + return createPortal( + reduce ? content : {content}, + document.body, ); } function AttachmentRow({ item, playing, + uploading, + uploadComplete, + failed, + removing, + arrivalIndex, + imageLayoutId, onAudioToggle, + onImagePreview, onRemove, + onRetry, reduce, className, }: { item: AttachmentUploadItem; playing: boolean; + uploading: boolean; + uploadComplete: boolean; + failed: boolean; + removing: boolean; + arrivalIndex: number; + imageLayoutId?: string; onAudioToggle?: (item: AttachmentUploadItem) => void; + onImagePreview: (item: AttachmentUploadItem) => void; onRemove: (item: AttachmentUploadItem) => void; + onRetry?: (item: AttachmentUploadItem) => void; reduce: boolean; className?: string; }) { @@ -185,26 +483,85 @@ function AttachmentRow({ item.duration && item.duration > 0 ? Math.min(1, Math.max(0, (item.currentTime ?? 0) / item.duration)) : 0; + const actionState: RowActionState = removing + ? "removing" + : uploading + ? "uploading" + : uploadComplete + ? "complete" + : failed + ? "failed" + : "idle"; + const arrivalDelay = Math.min(Math.max(arrivalIndex, 0), 5) * 0.055; + const rowTransition = + !reduce && arrivalIndex >= 0 + ? { + ...SPRING_LAYOUT, + delay: arrivalDelay, + opacity: { + duration: 0.16, + ease: EASE_OUT, + delay: arrivalDelay, + }, + } + : ITEM_TRANSITION; + const showUploadProgress = uploading || uploadComplete; + const uploadProgress = ( + + ); return ( = 0 + ? { opacity: 0, y: -16, scale: 0.985 } + : { opacity: 0, y: 6 } + } + animate={{ opacity: 1, y: 0, scale: 1 }} + exit={reduce ? undefined : { opacity: 0, y: -4 }} + transition={rowTransition} className={cn( "flex min-h-14 items-center gap-1 rounded-2xl bg-muted/70 p-1", className, )} > -
- +
+ {failed ? ( +
- onRemove(item)} - /> - - ); -} - -function MediaTile({ - item, - onRemove, - reduce, -}: { - item: AttachmentUploadItem; - onRemove: (item: AttachmentUploadItem) => void; - reduce: boolean; -}) { - return ( - -
- {item.previewUrl ? null : ( - - - + {reduce ? ( + showUploadProgress ? ( + uploadProgress + ) : null + ) : ( + + {showUploadProgress ? uploadProgress : null} + )} - - {formatBytes(item.size) ?? "Image"} -
- onRemove(item)} - className="absolute -right-2 -top-2 size-7 rounded-full border border-border bg-background shadow-sm" + + { + if (actionState === "failed") { + onRetry?.(item); + return; + } + onRemove(item); + }} + state={actionState} + retryable={onRetry !== undefined} + reduce={reduce} />
); @@ -358,6 +688,7 @@ export function AttachmentUpload({ onFilesAdded, onFilesRejected, onRemove, + onRetry, playingId, onAudioToggle, accept, @@ -375,25 +706,53 @@ export function AttachmentUpload({ const inputRef = useRef(null); const dragDepthRef = useRef(0); const ownedUrlsRef = useRef(new Set()); + const lifecycleTimersRef = useRef( + new Set>(), + ); const reduce = useReducedMotion() ?? false; const [dragging, setDragging] = useState(false); + const [previewItem, setPreviewItem] = + useState(null); + const [uploadingIds, setUploadingIds] = useState>( + () => new Set(), + ); + const [uploadCompleteIds, setUploadCompleteIds] = useState>( + () => new Set(), + ); + const [removingIds, setRemovingIds] = useState>( + () => new Set(), + ); const [items, setItems] = useControllableList({ value, defaultValue, onValueChange, }); + const itemsRef = useRef(items); + itemsRef.current = items; useEffect( () => () => { for (const url of ownedUrlsRef.current) URL.revokeObjectURL(url); ownedUrlsRef.current.clear(); + for (const timer of lifecycleTimersRef.current) { + clearTimeout(timer); + } + lifecycleTimersRef.current.clear(); }, [], ); const maxReached = items.length >= maxFiles; - const rowItems = items.filter((item) => item.display !== "media"); - const mediaItems = items.filter((item) => item.display === "media"); + const scheduleLifecycle = useCallback( + (callback: () => void, delay: number) => { + const timer = setTimeout(() => { + lifecycleTimersRef.current.delete(timer); + callback(); + }, delay); + lifecycleTimersRef.current.add(timer); + }, + [], + ); const addFiles = useCallback( (incomingFiles: File[]) => { @@ -430,7 +789,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, @@ -442,6 +800,28 @@ export function AttachmentUpload({ if (added.length === 0) return; setItems([...items, ...added]); + const addedIds = added.map((item) => item.id); + setUploadingIds((current) => new Set([...current, ...addedIds])); + scheduleLifecycle( + () => { + setUploadingIds((current) => { + const next = new Set(current); + for (const id of addedIds) next.delete(id); + return next; + }); + setUploadCompleteIds( + (current) => new Set([...current, ...addedIds]), + ); + scheduleLifecycle(() => { + setUploadCompleteIds((current) => { + const next = new Set(current); + for (const id of addedIds) next.delete(id); + return next; + }); + }, UPLOAD_COMPLETE_HOLD_MS); + }, + reduce ? 140 : UPLOAD_PROGRESS_MS, + ); onFilesAdded?.(added, accepted); }, [ @@ -452,11 +832,13 @@ export function AttachmentUpload({ multiple, onFilesAdded, onFilesRejected, + reduce, + scheduleLifecycle, setItems, ], ); - const removeItem = useCallback( + const finalizeRemove = useCallback( (item: AttachmentUploadItem) => { const ownedUrl = [item.previewUrl, item.href].find( (url): url is string => @@ -466,19 +848,73 @@ export function AttachmentUpload({ URL.revokeObjectURL(ownedUrl); ownedUrlsRef.current.delete(ownedUrl); } - setItems(items.filter((entry) => entry.id !== item.id)); + setPreviewItem((current) => + current?.id === item.id ? null : current, + ); + setUploadingIds((current) => { + const next = new Set(current); + next.delete(item.id); + return next; + }); + setUploadCompleteIds((current) => { + const next = new Set(current); + next.delete(item.id); + return next; + }); + setItems(itemsRef.current.filter((entry) => entry.id !== item.id)); onRemove?.(item); }, - [items, onRemove, setItems], + [onRemove, setItems], + ); + + const requestRemove = useCallback( + (item: AttachmentUploadItem) => { + if (removingIds.has(item.id)) return; + + setRemovingIds((current) => new Set(current).add(item.id)); + scheduleLifecycle( + () => { + finalizeRemove(item); + setRemovingIds((current) => { + const next = new Set(current); + next.delete(item.id); + return next; + }); + }, + reduce ? 140 : REMOVE_PENDING_MS, + ); + }, + [ + finalizeRemove, + reduce, + removingIds, + scheduleLifecycle, + ], ); 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]); + + const uploadOrder = Array.from(uploadingIds); + const previewLayoutId = previewItem + ? `attachment-image-${previewItem.id}` + : undefined; return ( -
+ +
- {rowItems.length > 0 ? ( + {items.length > 0 ? (
    - - {rowItems.map((item) => ( + 0}> + {items.map((item) => ( @@ -595,29 +1047,16 @@ export function AttachmentUpload({
) : 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..b6b5fa5 100644 --- a/components/previews/blocks/attachment-upload.preview.tsx +++ b/components/previews/blocks/attachment-upload.preview.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { AttachmentUpload, type AttachmentUploadItem, @@ -13,13 +13,16 @@ const INITIAL_ITEMS: AttachmentUploadItem[] = [ kind: "file", size: 32_400_000, href: "data:application/pdf,beUI%20launch%20brief", + status: "failed", + error: "Upload failed", }, { - 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,38 +31,21 @@ 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() { const [items, setItems] = useState(INITIAL_ITEMS); const [playingId, setPlayingId] = useState(); + const retryTimersRef = useRef([]); + + useEffect( + () => () => { + for (const timer of retryTimersRef.current) { + window.clearTimeout(timer); + } + }, + [], + ); useEffect(() => { if (!playingId) return; @@ -96,6 +82,35 @@ export function AttachmentUploadPreview() { { + setItems((current) => + current.map((item) => + item.id === retryItem.id + ? { ...item, status: "uploading", error: undefined } + : item, + ), + ); + + const completeTimer = window.setTimeout(() => { + setItems((current) => + current.map((item) => + item.id === retryItem.id + ? { ...item, status: "complete" } + : item, + ), + ); + }, 900); + const readyTimer = window.setTimeout(() => { + setItems((current) => + current.map((item) => + item.id === retryItem.id + ? { ...item, status: "idle" } + : item, + ), + ); + }, 1900); + retryTimersRef.current.push(completeTimer, readyTimer); + }} playingId={playingId} onAudioToggle={(item) => { setPlayingId((current) => diff --git a/lib/registry.ts b/lib/registry.ts index f2ac6f3..2bc12ff 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, staggered file and image rows, animated upload, success, failure, retry and removal feedback, shared-layout image previews, and an audio waveform.", badge: "new", launchedAt: "2026-07-30", installSlug: "attachment-upload", diff --git a/tests/a11y.test.tsx b/tests/a11y.test.tsx index e1595ac..5f9dd15 100644 --- a/tests/a11y.test.tsx +++ b/tests/a11y.test.tsx @@ -70,6 +70,8 @@ const cases: Array<[name: string, render: () => ReactElement]> = [ name: "brief.pdf", kind: "file", size: 240_000, + status: "failed", + error: "Upload failed", }, { id: "voice", @@ -79,6 +81,7 @@ const cases: Array<[name: string, render: () => ReactElement]> = [ duration: 18, }, ]} + onRetry={() => {}} /> ), ], diff --git a/tests/attachment-upload.test.tsx b/tests/attachment-upload.test.tsx index 02c1a5b..020d974 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, @@ -15,16 +20,22 @@ const FILE_ITEM: AttachmentUploadItem = { }; describe("AttachmentUpload", () => { - test("removes attachments in uncontrolled mode", () => { + test("shows pending feedback before removing an attachment", async () => { const onRemove = mock(() => {}); - const { getByLabelText, queryByText } = render( + const { getByLabelText, queryByLabelText, queryByText } = render( , ); fireEvent.click(getByLabelText("Remove brief.pdf")); - expect(queryByText("brief.pdf")).toBeNull(); - expect(onRemove).toHaveBeenCalledWith(FILE_ITEM); + expect(getByLabelText("Removing brief.pdf")).toBeTruthy(); + expect(queryByText("brief.pdf")).toBeTruthy(); + + await waitFor(() => { + expect(onRemove).toHaveBeenCalledWith(FILE_ITEM); + expect(queryByLabelText("Removing brief.pdf")).toBeNull(); + expect(queryByLabelText("Remove brief.pdf")).toBeNull(); + }); }); test("rejects files over the size limit", () => { @@ -46,6 +57,59 @@ describe("AttachmentUpload", () => { expect(onFilesRejected).toHaveBeenCalledWith([file], "too-large"); }); + test("shows upload progress for newly added files", async () => { + const file = new File(["draft"], "draft.txt", { + type: "text/plain", + }); + const { + getByLabelText, + getByRole, + queryByLabelText, + } = render( + , + ); + + fireEvent.change(getByLabelText("Upload attachments"), { + target: { files: [file] }, + }); + + expect( + getByRole("progressbar", { name: "Uploading draft.txt" }), + ).toBeTruthy(); + expect(queryByLabelText("Remove draft.txt")).toBeNull(); + + await waitFor(() => { + expect( + getByLabelText("Upload complete for draft.txt"), + ).toBeTruthy(); + expect(queryByLabelText("Remove draft.txt")).toBeNull(); + }); + + await waitFor(() => { + expect(getByLabelText("Remove draft.txt")).toBeTruthy(); + }, { timeout: 1600 }); + }); + + test("shows failed uploads with a retry action", () => { + const failedItem: AttachmentUploadItem = { + ...FILE_ITEM, + status: "failed", + error: "Network interrupted", + }; + const onRetry = mock(() => {}); + const { getByLabelText, getByText } = render( + , + ); + + expect(getByText("Network interrupted")).toBeTruthy(); + fireEvent.click(getByLabelText("Retry brief.pdf")); + + expect(onRetry).toHaveBeenCalledWith(failedItem); + }); + test("forwards audio playback actions", () => { const audio: AttachmentUploadItem = { id: "note", @@ -66,4 +130,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 } = render( + , + ); + + fireEvent.click(getByLabelText("Preview cover.png")); + + expect( + getByRole("dialog", { name: "Preview of cover.png" }), + ).toBeTruthy(); + + fireEvent.keyDown(document, { key: "Escape" }); + + await waitFor(() => { + expect(document.body.style.overflow).toBe(""); + }); + }); }); diff --git a/tests/setup.ts b/tests/setup.ts index 1f03e49..ad0245a 100644 --- a/tests/setup.ts +++ b/tests/setup.ts @@ -40,3 +40,6 @@ globalThis.IntersectionObserver ??= if (typeof window.scrollTo !== "function") { window.scrollTo = () => {}; } + +URL.createObjectURL ??= () => "blob:test-upload"; +URL.revokeObjectURL ??= () => {};