Attachments
Files and images attached to a message or prompt: grid, inline and list layouts with preview and remove.
import { Attachment, AttachmentHoverCard, AttachmentHoverCardContent, AttachmentHoverCardTrigger, AttachmentInfo, AttachmentPreview, AttachmentRemove, Attachments,} from "@/components/ai/attachments";import { Button } from "@/components/ui/button";import { useState } from "react";
const chart = "data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='220' height='120'><defs><linearGradient id='g' x1='0' y1='0' x2='1' y2='1'><stop offset='0' stop-color='%2362b36f'/><stop offset='1' stop-color='%23386fd6'/></linearGradient></defs><rect width='220' height='120' fill='url(%23g)'/></svg>";
const initialFiles = [ { id: "1", type: "file" as const, filename: "weights.csv", mediaType: "text/csv", url: "", size: 2048, }, { id: "2", type: "file" as const, filename: "chart.png", mediaType: "image/png", url: chart, size: 188416, },];
export default function Demo() { const [files, setFiles] = useState(initialFiles); const remove = (id: string) => setFiles((fs) => fs.filter((f) => f.id !== id)); return ( <div className="flex w-full max-w-md flex-col gap-6"> {files.length === 0 && ( <Button variant="outline" size="sm" className="self-start" onClick={() => setFiles(initialFiles)} > Reset </Button> )} <Attachments variant="grid"> {files.map((f) => ( <Attachment key={f.id} data={f} onRemove={() => remove(f.id)}> <AttachmentPreview /> <AttachmentInfo /> <AttachmentRemove /> </Attachment> ))} </Attachments> <Attachments variant="list"> {files.map((f) => ( <Attachment key={f.id} data={f} onRemove={() => remove(f.id)}> <AttachmentPreview /> <AttachmentInfo showMediaType /> <AttachmentRemove /> </Attachment> ))} </Attachments> <Attachments variant="inline"> {files.map((f) => ( <AttachmentHoverCard key={f.id}> <AttachmentHoverCardTrigger render={ <Attachment data={f} onRemove={() => remove(f.id)}> <AttachmentPreview /> <AttachmentInfo /> <AttachmentRemove /> </Attachment> } /> <AttachmentHoverCardContent> {f.mediaType.startsWith("image/") ? ( <img alt={f.filename} src={f.url} className="h-24 rounded-lg object-cover" /> ) : ( <span className="px-1 text-xs">{f.filename}</span> )} </AttachmentHoverCardContent> </AttachmentHoverCard> ))} </Attachments> </div> );}<script setup lang="ts">import { Attachment, AttachmentHoverCard, AttachmentHoverCardContent, AttachmentHoverCardTrigger, AttachmentInfo, AttachmentPreview, AttachmentRemove, Attachments,} from "@/components/ai/attachments";import { Button } from "@/components/ui/button";import { ref } from "vue";
const chart = "data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='220' height='120'><defs><linearGradient id='g' x1='0' y1='0' x2='1' y2='1'><stop offset='0' stop-color='%2362b36f'/><stop offset='1' stop-color='%23386fd6'/></linearGradient></defs><rect width='220' height='120' fill='url(%23g)'/></svg>";
const initialFiles = [ { id: "1", type: "file" as const, filename: "weights.csv", mediaType: "text/csv", url: "", size: 2048 }, { id: "2", type: "file" as const, filename: "chart.png", mediaType: "image/png", url: chart, size: 188416 },];
const files = ref([...initialFiles]);const remove = (id: string) => { files.value = files.value.filter((f) => f.id !== id);};</script>
<template> <div class="flex w-full max-w-md flex-col gap-6"> <Button v-if="files.length === 0" variant="outline" size="sm" class="self-start" @click="files = [...initialFiles]"> Reset </Button> <Attachments variant="grid"> <Attachment v-for="f in files" :key="f.id" :data="f" @remove="remove(f.id)"> <AttachmentPreview /> <AttachmentInfo /> <AttachmentRemove /> </Attachment> </Attachments> <Attachments variant="list"> <Attachment v-for="f in files" :key="f.id" :data="f" @remove="remove(f.id)"> <AttachmentPreview /> <AttachmentInfo show-media-type /> <AttachmentRemove /> </Attachment> </Attachments> <Attachments variant="inline"> <AttachmentHoverCard v-for="f in files" :key="f.id"> <AttachmentHoverCardTrigger as-child> <Attachment :data="f" @remove="remove(f.id)"> <AttachmentPreview /> <AttachmentInfo /> <AttachmentRemove /> </Attachment> </AttachmentHoverCardTrigger> <AttachmentHoverCardContent> <img v-if="f.mediaType.startsWith('image/')" :alt="f.filename" :src="f.url" class="h-24 rounded-lg object-cover"> <span v-else class="px-1 text-xs">{{ f.filename }}</span> </AttachmentHoverCardContent> </AttachmentHoverCard> </Attachments> </div></template><script lang="ts"> import { Attachment, AttachmentHoverCard, AttachmentHoverCardContent, AttachmentHoverCardTrigger, AttachmentInfo, AttachmentPreview, AttachmentRemove, Attachments, } from "$lib/components/ai/attachments"; import { Button } from "$lib/components/ui/button";
const chart = "data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='220' height='120'><defs><linearGradient id='g' x1='0' y1='0' x2='1' y2='1'><stop offset='0' stop-color='%2362b36f'/><stop offset='1' stop-color='%23386fd6'/></linearGradient></defs><rect width='220' height='120' fill='url(%23g)'/></svg>";
const initialFiles = [ { id: "1", type: "file" as const, filename: "weights.csv", mediaType: "text/csv", url: "", size: 2048 }, { id: "2", type: "file" as const, filename: "chart.png", mediaType: "image/png", url: chart, size: 188416 }, ];
let files = $state([...initialFiles]); const remove = (id: string) => { files = files.filter((f) => f.id !== id); };</script>
<div class="flex w-full max-w-md flex-col gap-6"> {#if files.length === 0} <Button variant="outline" size="sm" class="self-start" onclick={() => (files = [...initialFiles])}> Reset </Button> {/if} <Attachments variant="grid"> {#each files as f (f.id)} <Attachment data={f} onRemove={() => remove(f.id)}> <AttachmentPreview /> <AttachmentInfo /> <AttachmentRemove /> </Attachment> {/each} </Attachments> <Attachments variant="list"> {#each files as f (f.id)} <Attachment data={f} onRemove={() => remove(f.id)}> <AttachmentPreview /> <AttachmentInfo showMediaType /> <AttachmentRemove /> </Attachment> {/each} </Attachments> <Attachments variant="inline"> {#each files as f (f.id)} <AttachmentHoverCard> <AttachmentHoverCardTrigger> {#snippet child({ props })} <Attachment data={f} onRemove={() => remove(f.id)} {...props}> <AttachmentPreview /> <AttachmentInfo /> <AttachmentRemove /> </Attachment> {/snippet} </AttachmentHoverCardTrigger> <AttachmentHoverCardContent> {#if f.mediaType.startsWith("image/")} <img alt={f.filename} src={f.url} class="h-24 rounded-lg object-cover" /> {:else} <span class="px-1 text-xs">{f.filename}</span> {/if} </AttachmentHoverCardContent> </AttachmentHoverCard> {/each} </Attachments></div>Built on attachment.
Installation
Section titled “Installation”npx shadcn@latest add @edmi-ui/ai-attachmentspnpm dlx shadcn@latest add @edmi-ui/ai-attachmentsyarn dlx shadcn@latest add @edmi-ui/ai-attachmentsbunx shadcn@latest add @edmi-ui/ai-attachmentsPick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.
Install the dependencies:
Terminal window npm install aiTerminal window pnpm add aiTerminal window yarn add aiTerminal window bun add aiAdd the Edmi components this one builds on:
button,hover-card,attachment.Copy the code from Component source into your project and update the import paths to match your aliases.
npx shadcn-vue@latest add @edmi-ui/ai-attachmentspnpm dlx shadcn-vue@latest add @edmi-ui/ai-attachmentsyarn dlx shadcn-vue@latest add @edmi-ui/ai-attachmentsbunx shadcn-vue@latest add @edmi-ui/ai-attachmentsPick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.
Install the dependencies:
Terminal window npm install ai @lucide/vue reka-ui @vueuse/coreTerminal window pnpm add ai @lucide/vue reka-ui @vueuse/coreTerminal window yarn add ai @lucide/vue reka-ui @vueuse/coreTerminal window bun add ai @lucide/vue reka-ui @vueuse/coreAdd the Edmi components this one builds on:
button,hover-card,attachment.Copy the code from Component source into your project and update the import paths to match your aliases.
npx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/ai-attachments.jsonpnpm dlx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/ai-attachments.jsonyarn dlx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/ai-attachments.jsonbunx --bun shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/ai-attachments.jsonPick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.
Add the Edmi components this one builds on:
button,hover-card,attachment.Copy the code from Component source into your project and update the import paths to match your aliases.
import { Attachments } from "@/components/ai/attachments";
<div className="flex w-full max-w-md flex-col gap-6"> <Attachments variant="grid"> </Attachments> <Attachments variant="list"> </Attachments> <Attachments variant="inline"> </Attachments></div><script setup lang="ts">import { Attachment, AttachmentInfo, AttachmentPreview, AttachmentRemove, Attachments,} from "@/components/ai/attachments";import { Button } from "@/components/ui/button";</script>
<template> <div class="flex w-full max-w-md flex-col gap-6"> <Button variant="outline" size="sm" class="self-start"> Reset </Button> <Attachments variant="grid"> <Attachment :key="f.id" :data="f"> <AttachmentPreview /> <AttachmentInfo /> <AttachmentRemove /> </Attachment> </Attachments> <Attachments variant="list"> <Attachment :key="f.id" :data="f"> <AttachmentPreview /> <AttachmentInfo show-media-type /> <AttachmentRemove /> </Attachment> </Attachments> <!-- … --> </div></template><script lang="ts"> import { Attachment, AttachmentInfo, AttachmentPreview, AttachmentRemove, Attachments, } from "$lib/components/ai/attachments"; import { Button } from "$lib/components/ui/button";</script>
<div class="flex w-full max-w-md flex-col gap-6"> {#if files.length === 0} <Button variant="outline" size="sm" class="self-start"> Reset </Button> {/if} <Attachments variant="grid"> {#each files as f (f.id)} <Attachment data={f}> <AttachmentPreview /> <AttachmentInfo /> <AttachmentRemove /> </Attachment> {/each} </Attachments> <Attachments variant="list"> {#each files as f (f.id)} <Attachment data={f}> <AttachmentPreview /> <AttachmentInfo showMediaType /> <AttachmentRemove /> </Attachment> {/each} </Attachments> <!-- … --></div>Component source
Section titled “Component source”"use client";
import type { FileUIPart, SourceDocumentUIPart } from "ai";import { cn } from "cn";import type { ComponentProps, HTMLAttributes, ReactNode } from "react";import { createContext, useCallback, useContext, useMemo } from "react";import { MusicNoteIcon, FileTextIcon, ImageIcon, GlobeIcon, PaperclipIcon, VideoIcon, XIcon } from "@phosphor-icons/react";import { Attachment as UiAttachment } from "@/registry/edmi/ui/attachment";import { Button } from "@/registry/edmi/ui/button";import { HoverCard, HoverCardContent, HoverCardTrigger,} from "@/registry/edmi/ui/hover-card";
// Thin layer on ui/attachment (DESIGN §5b): three layouts around the ui item (grid tiles, inline pills, list rows).
// ============================================================================// Types// ============================================================================
export type AttachmentData = | (FileUIPart & { id: string /** Bytes, shown in the list meta line. */; size?: number; }) | (SourceDocumentUIPart & { id: string; size?: number });
export type AttachmentMediaCategory = | "image" | "video" | "audio" | "document" | "source" | "unknown";
export type AttachmentVariant = "grid" | "inline" | "list";
const icons: Record<AttachmentMediaCategory, ReactNode> = { audio: ( <MusicNoteIcon /> ), document: ( <FileTextIcon /> ), image: ( <ImageIcon /> ), source: ( <GlobeIcon /> ), unknown: ( <PaperclipIcon /> ), video: ( <VideoIcon /> ),};
// ============================================================================// Utility Functions// ============================================================================
export const getMediaCategory = ( data: AttachmentData,): AttachmentMediaCategory => { if (data.type === "source-document") { return "source"; }
const mediaType = data.mediaType ?? "";
if (mediaType.startsWith("image/")) { return "image"; } if (mediaType.startsWith("video/")) { return "video"; } if (mediaType.startsWith("audio/")) { return "audio"; } if (mediaType.startsWith("application/") || mediaType.startsWith("text/")) { return "document"; }
return "unknown";};
export const getAttachmentLabel = (data: AttachmentData): string => { if (data.type === "source-document") { return data.title || data.filename || "Source"; }
const category = getMediaCategory(data); return data.filename || (category === "image" ? "Image" : "Attachment");};
const formatSize = (bytes?: number): string | undefined => { if (bytes === undefined) return undefined; if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`; return `${(bytes / 1024 / 1024).toFixed(1)} MB`;};
// ============================================================================// Contexts// ============================================================================
interface AttachmentsContextValue { variant: AttachmentVariant;}
const AttachmentsContext = createContext<AttachmentsContextValue | null>(null);
interface AttachmentContextValue { data: AttachmentData; mediaCategory: AttachmentMediaCategory; onRemove?: () => void; variant: AttachmentVariant;}
const AttachmentContext = createContext<AttachmentContextValue | null>(null);
export const useAttachmentsContext = () => useContext(AttachmentsContext) ?? { variant: "grid" as const };
export const useAttachmentContext = () => { const ctx = useContext(AttachmentContext); if (!ctx) { throw new Error("Attachment components must be used within <Attachment>"); } return ctx;};
// ============================================================================// Attachments - Container// ============================================================================
export type AttachmentsProps = HTMLAttributes<HTMLDivElement> & { variant?: AttachmentVariant;};
export const Attachments = ({ variant = "grid", className, children, ...props}: AttachmentsProps) => { const contextValue = useMemo(() => ({ variant }), [variant]);
return ( <AttachmentsContext.Provider value={contextValue}> <div data-slot="ai-attachments" data-variant={variant} className={cn( "flex items-start", variant === "list" ? "w-full flex-col gap-2" : "flex-wrap gap-2", className, )} {...props} > {children} </div> </AttachmentsContext.Provider> );};
// ============================================================================// Attachment - Item// ============================================================================
export type AttachmentProps = HTMLAttributes<HTMLDivElement> & { data: AttachmentData; onRemove?: () => void;};
export const Attachment = ({ data, onRemove, className, children, ...props}: AttachmentProps) => { const { variant } = useAttachmentsContext(); const mediaCategory = getMediaCategory(data);
const contextValue = useMemo<AttachmentContextValue>( () => ({ data, mediaCategory, onRemove, variant }), [data, mediaCategory, onRemove, variant], );
return ( <AttachmentContext.Provider value={contextValue}> {variant === "grid" ? ( <div data-slot="ai-attachment" data-variant="grid" className={cn("group/ai-attachment relative w-[110px]", className)} {...props} > {children} </div> ) : ( <UiAttachment data-slot="ai-attachment" data-variant={variant} size={variant === "inline" ? "xs" : "default"} className={cn( "group/ai-attachment items-center", variant === "inline" && "h-8 w-fit min-w-0 cursor-pointer rounded-lg px-2 py-0 text-[13px]", variant === "list" && "w-full gap-3 rounded-xl p-2.5", className, )} {...props} > {children} </UiAttachment> )} </AttachmentContext.Provider> );};
// ============================================================================// AttachmentPreview - Media preview// ============================================================================
export type AttachmentPreviewProps = HTMLAttributes<HTMLDivElement> & { fallbackIcon?: ReactNode;};
export const AttachmentPreview = ({ fallbackIcon, className, ...props}: AttachmentPreviewProps) => { const { data, mediaCategory, variant } = useAttachmentContext();
const renderContent = () => { if (mediaCategory === "image" && data.type === "file" && data.url) { return ( <img alt={data.filename || "Image"} className={cn( "size-full object-cover", variant !== "grid" && "rounded-[5px]", )} src={data.url} /> ); }
if (mediaCategory === "video" && data.type === "file" && data.url) { return <video className="size-full object-cover" muted src={data.url} />; }
return fallbackIcon ?? icons[mediaCategory]; };
return ( <div data-slot="ai-attachment-preview" className={cn( "flex shrink-0 items-center justify-center overflow-hidden text-muted-foreground [&_svg:not([class*='size-'])]:size-4", variant === "grid" && "h-20 w-full rounded-xl border border-border bg-muted [&_svg:not([class*='size-'])]:size-5", variant === "inline" && "size-4 [&_svg:not([class*='size-'])]:size-3.5", variant === "list" && "size-9 rounded-lg border border-border bg-muted text-foreground", className, )} {...props} > {renderContent()} </div> );};
// ============================================================================// AttachmentInfo - Name and type display// ============================================================================
export type AttachmentInfoProps = HTMLAttributes<HTMLDivElement> & { showMediaType?: boolean;};
export const AttachmentInfo = ({ showMediaType = false, className, ...props}: AttachmentInfoProps) => { const { data, variant } = useAttachmentContext(); const label = getAttachmentLabel(data); const meta = [data.mediaType, formatSize(data.size)] .filter(Boolean) .join(" · ");
return ( <div data-slot="ai-attachment-info" className={cn( "min-w-0", variant === "grid" ? "mt-1.5 w-full" : "flex-1", className, )} {...props} > <span className={cn( "block truncate", variant === "inline" && "font-medium", variant === "list" && "text-[13.5px] font-medium", variant === "grid" && "text-xs text-foreground", )} > {label} </span> {showMediaType && meta && ( <span className="block truncate text-xs text-muted-foreground"> {meta} </span> )} </div> );};
// ============================================================================// AttachmentRemove - Remove button// ============================================================================
export type AttachmentRemoveProps = ComponentProps<typeof Button> & { label?: string;};
export const AttachmentRemove = ({ label = "Remove", className, children, ...props}: AttachmentRemoveProps) => { const { onRemove, variant } = useAttachmentContext();
const handleClick = useCallback( (e: React.MouseEvent<HTMLButtonElement>) => { e.stopPropagation(); onRemove?.(); }, [onRemove], );
if (!onRemove) { return null; }
return ( <Button data-slot="ai-attachment-remove" aria-label={label} className={cn( // solid chip over the tile (DESIGN §4.7): --popover + 1px border, no transparency variant === "grid" && "absolute top-1.5 right-1.5 size-[22px] rounded-md border border-border bg-popover p-0 hover:bg-accent [&>svg]:size-3", variant === "inline" && "size-5 rounded-md p-0 [&>svg]:size-3", variant === "list" && "size-8 shrink-0 rounded-md p-0 [&>svg]:size-4", className, )} onClick={handleClick} type="button" variant="ghost" {...props} > {children ?? ( <XIcon /> )} <span className="sr-only">{label}</span> </Button> );};
// ============================================================================// AttachmentHoverCard - Hover preview// ============================================================================
export type AttachmentHoverCardProps = ComponentProps<typeof HoverCard>;
export const AttachmentHoverCard = (props: AttachmentHoverCardProps) => ( <HoverCard {...props} />);
export type AttachmentHoverCardTriggerProps = ComponentProps< typeof HoverCardTrigger>;
export const AttachmentHoverCardTrigger = ({ delay = 0, closeDelay = 0, ...props}: AttachmentHoverCardTriggerProps) => ( <HoverCardTrigger closeDelay={closeDelay} delay={delay} {...props} />);
export type AttachmentHoverCardContentProps = ComponentProps< typeof HoverCardContent>;
export const AttachmentHoverCardContent = ({ align = "start", className, ...props}: AttachmentHoverCardContentProps) => ( <HoverCardContent align={align} className={cn("w-auto p-2", className)} {...props} />);
// ============================================================================// AttachmentEmpty - Empty state// ============================================================================
export type AttachmentEmptyProps = HTMLAttributes<HTMLDivElement>;
export const AttachmentEmpty = ({ className, children, ...props}: AttachmentEmptyProps) => ( <div data-slot="ai-attachment-empty" className={cn( "flex items-center justify-center p-4 text-sm text-muted-foreground", className, )} {...props} > {children ?? "No attachments"} </div>);<script setup lang="ts">import type { HTMLAttributes } from "vue"import type { AttachmentData } from "./types"import { computed, getCurrentInstance, provide } from "vue"import { cn } from "@/registry/edmi/lib/utils"import { Attachment as UiAttachment } from "@/registry/edmi/ui/attachment"import { AttachmentKey, useAttachmentsContext } from "./context"import { getMediaCategory } from "./utils"
// Thin layer on ui/attachment (DESIGN §5b): three layouts around the ui item (grid tiles, inline pills, list rows).
const props = defineProps<{ data: AttachmentData class?: HTMLAttributes["class"]}>()
const emit = defineEmits<{ (e: "remove"): void}>()
const { variant } = useAttachmentsContext()const data = computed(() => props.data)const mediaCategory = computed(() => getMediaCategory(props.data))
// The remove button only shows when the parent listens for `@remove`.const hasRemove = !!getCurrentInstance()?.vnode.props?.onRemove
provide(AttachmentKey, { data, mediaCategory, remove: hasRemove ? () => emit("remove") : undefined, variant,})</script>
<template> <div v-if="variant === 'grid'" data-slot="ai-attachment" data-variant="grid" :class="cn('group/ai-attachment relative w-[110px]', props.class)" > <slot /> </div> <UiAttachment v-else data-slot="ai-attachment" :data-variant="variant" :size="variant === 'inline' ? 'xs' : 'default'" :class="cn( 'group/ai-attachment items-center', variant === 'inline' && 'h-8 w-fit min-w-0 cursor-pointer rounded-lg px-2 py-0 text-[13px]', variant === 'list' && 'w-full gap-3 rounded-xl p-2.5', props.class, )" > <slot /> </UiAttachment></template><script setup lang="ts">import type { HTMLAttributes } from "vue"import { cn } from "@/registry/edmi/lib/utils"
const props = defineProps<{ class?: HTMLAttributes["class"]}>()</script>
<template> <div data-slot="ai-attachment-empty" :class="cn('flex items-center justify-center p-4 text-sm text-muted-foreground', props.class)" > <slot>No attachments</slot> </div></template><script setup lang="ts">import type { HoverCardRootEmits, HoverCardRootProps } from "reka-ui"import { useForwardPropsEmits } from "reka-ui"import { HoverCard } from "@/registry/edmi/ui/hover-card"
const props = withDefaults(defineProps<HoverCardRootProps>(), { open: undefined, defaultOpen: undefined, openDelay: 0, closeDelay: 0,})const emits = defineEmits<HoverCardRootEmits>()const forwarded = useForwardPropsEmits(props, emits)</script>
<template> <HoverCard v-bind="forwarded"> <slot /> </HoverCard></template><script setup lang="ts">import type { HoverCardContentProps } from "reka-ui"import type { HTMLAttributes } from "vue"import { reactiveOmit } from "@vueuse/core"import { cn } from "@/registry/edmi/lib/utils"import { HoverCardContent } from "@/registry/edmi/ui/hover-card"
const props = withDefaults(defineProps<HoverCardContentProps & { class?: HTMLAttributes["class"] }>(), { align: "start",})const delegated = reactiveOmit(props, "class")</script>
<template> <HoverCardContent v-bind="delegated" :class="cn('w-auto p-2', props.class)"> <slot /> </HoverCardContent></template><script setup lang="ts">import type { HoverCardTriggerProps } from "reka-ui"import { HoverCardTrigger } from "@/registry/edmi/ui/hover-card"
const props = defineProps<HoverCardTriggerProps>()</script>
<template> <HoverCardTrigger v-bind="props"> <slot /> </HoverCardTrigger></template><script setup lang="ts">import type { HTMLAttributes } from "vue"import { computed } from "vue"import { cn } from "@/registry/edmi/lib/utils"import { useAttachmentContext } from "./context"import { formatSize, getAttachmentLabel } from "./utils"
const props = withDefaults(defineProps<{ showMediaType?: boolean class?: HTMLAttributes["class"]}>(), { showMediaType: false,})
const { data, variant } = useAttachmentContext()const label = computed(() => getAttachmentLabel(data.value))const meta = computed(() => [data.value.mediaType, formatSize(data.value.size)].filter(Boolean).join(" · "))</script>
<template> <div data-slot="ai-attachment-info" :class="cn('min-w-0', variant === 'grid' ? 'mt-1.5 w-full' : 'flex-1', props.class)" > <span :class="cn( 'block truncate', variant === 'inline' && 'font-medium', variant === 'list' && 'text-[13.5px] font-medium', variant === 'grid' && 'text-xs text-foreground', )" > {{ label }} </span> <span v-if="props.showMediaType && meta" class="block truncate text-xs text-muted-foreground"> {{ meta }} </span> </div></template><script setup lang="ts">import type { Component, HTMLAttributes } from "vue"import type { AttachmentMediaCategory } from "./types"import { FileTextIcon, GlobeIcon, ImageIcon, PaperclipIcon, PlayIcon, VideoIcon,} from "@lucide/vue"import { computed } from "vue"import { cn } from "@/registry/edmi/lib/utils"import { useAttachmentContext } from "./context"
const props = defineProps<{ fallbackIcon?: Component class?: HTMLAttributes["class"]}>()
const { data, mediaCategory, variant } = useAttachmentContext()
const fileUrl = computed(() => (data.value.type === "file" ? data.value.url : undefined))const showImage = computed(() => mediaCategory.value === "image" && data.value.type === "file" && !!fileUrl.value)const showVideo = computed(() => mediaCategory.value === "video" && data.value.type === "file" && !!fileUrl.value)
const iconMap: Record<AttachmentMediaCategory, Component> = { image: ImageIcon, video: VideoIcon, audio: PlayIcon, source: GlobeIcon, document: FileTextIcon, unknown: PaperclipIcon,}
const icon = computed(() => props.fallbackIcon ?? iconMap[mediaCategory.value])const imageAlt = computed(() => (data.value.type === "file" ? data.value.filename : undefined) || "Image")</script>
<template> <div data-slot="ai-attachment-preview" :class="cn( 'flex shrink-0 items-center justify-center overflow-hidden text-muted-foreground [&_svg:not([class*=size-])]:size-4', variant === 'grid' && 'h-20 w-full rounded-xl border border-border bg-muted [&_svg:not([class*=size-])]:size-5', variant === 'inline' && 'size-4 [&_svg:not([class*=size-])]:size-3.5', variant === 'list' && 'size-9 rounded-lg border border-border bg-muted text-foreground', props.class, )" > <img v-if="showImage" :alt="imageAlt" :class="cn('size-full object-cover', variant !== 'grid' && 'rounded-[5px]')" :src="fileUrl" > <video v-else-if="showVideo" class="size-full object-cover" muted :src="fileUrl" /> <component :is="icon" v-else /> </div></template><script setup lang="ts">import type { HTMLAttributes } from "vue"import { XIcon } from "@lucide/vue"import { cn } from "@/registry/edmi/lib/utils"import { Button } from "@/registry/edmi/ui/button"import { useAttachmentContext } from "./context"
const props = withDefaults(defineProps<{ label?: string class?: HTMLAttributes["class"]}>(), { label: "Remove",})
const { remove, variant } = useAttachmentContext()
function handleClick(e: Event) { e.stopPropagation() remove?.()}</script>
<template> <Button v-if="remove" data-slot="ai-attachment-remove" :aria-label="props.label" :class="cn( // solid chip over the tile (DESIGN §4.7): --popover + 1px border, no transparency variant === 'grid' && 'absolute top-1.5 right-1.5 size-[22px] rounded-md border border-border bg-popover p-0 hover:bg-accent [&>svg]:size-3', variant === 'inline' && 'size-5 rounded-md p-0 [&>svg]:size-3', variant === 'list' && 'size-8 shrink-0 rounded-md p-0 [&>svg]:size-4', props.class, )" type="button" variant="ghost" @click="handleClick" > <slot> <XIcon /> </slot> <span class="sr-only">{{ props.label }}</span> </Button></template><script setup lang="ts">import type { HTMLAttributes } from "vue"import type { AttachmentVariant } from "./types"import { computed, provide } from "vue"import { cn } from "@/registry/edmi/lib/utils"import { AttachmentsKey } from "./context"
const props = withDefaults(defineProps<{ variant?: AttachmentVariant class?: HTMLAttributes["class"]}>(), { variant: "grid",})
const variant = computed(() => props.variant)provide(AttachmentsKey, { variant })</script>
<template> <div data-slot="ai-attachments" :data-variant="variant" :class="cn( 'flex items-start', variant === 'list' ? 'w-full flex-col gap-2' : 'flex-wrap gap-2', props.class, )" > <slot /> </div></template>import type { InjectionKey, Ref } from "vue";import { computed, inject } from "vue";import type { AttachmentData, AttachmentMediaCategory, AttachmentVariant,} from "./types";
export interface AttachmentsContextValue { variant: Ref<AttachmentVariant>;}
export const AttachmentsKey: InjectionKey<AttachmentsContextValue> = Symbol("Attachments");
export function useAttachmentsContext(): AttachmentsContextValue { const ctx = inject(AttachmentsKey, null); if (!ctx) { return { variant: computed(() => "grid" as const) }; } return ctx;}
export interface AttachmentContextValue { data: Ref<AttachmentData>; mediaCategory: Ref<AttachmentMediaCategory>; /** Undefined when the parent has no `@remove` listener (the remove button then hides). */ remove?: () => void; variant: Ref<AttachmentVariant>;}
export const AttachmentKey: InjectionKey<AttachmentContextValue> = Symbol("Attachment");
export function useAttachmentContext(): AttachmentContextValue { const ctx = inject(AttachmentKey); if (!ctx) { throw new Error("Attachment components must be used within <Attachment>"); } return ctx;}import type { FileUIPart, SourceDocumentUIPart } from "ai";
export type AttachmentData = | (FileUIPart & { id: string; /** Bytes, shown in the list meta line. */ size?: number; }) | (SourceDocumentUIPart & { id: string; size?: number });
export type AttachmentMediaCategory = | "image" | "video" | "audio" | "document" | "source" | "unknown";
export type AttachmentVariant = "grid" | "inline" | "list";import type { AttachmentData, AttachmentMediaCategory } from "./types";
export function getMediaCategory( data: AttachmentData,): AttachmentMediaCategory { if (data.type === "source-document") { return "source"; }
const mediaType = data.mediaType ?? "";
if (mediaType.startsWith("image/")) { return "image"; } if (mediaType.startsWith("video/")) { return "video"; } if (mediaType.startsWith("audio/")) { return "audio"; } if (mediaType.startsWith("application/") || mediaType.startsWith("text/")) { return "document"; }
return "unknown";}
export function getAttachmentLabel(data: AttachmentData): string { if (data.type === "source-document") { return data.title || data.filename || "Source"; }
const category = getMediaCategory(data); return data.filename || (category === "image" ? "Image" : "Attachment");}
export function formatSize(bytes?: number): string | undefined { if (bytes === undefined) return undefined; if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`; return `${(bytes / 1024 / 1024).toFixed(1)} MB`;}export { default as Attachment } from "./Attachment.vue";export { default as AttachmentEmpty } from "./AttachmentEmpty.vue";export { default as AttachmentHoverCard } from "./AttachmentHoverCard.vue";export { default as AttachmentHoverCardContent } from "./AttachmentHoverCardContent.vue";export { default as AttachmentHoverCardTrigger } from "./AttachmentHoverCardTrigger.vue";export { default as AttachmentInfo } from "./AttachmentInfo.vue";export { default as AttachmentPreview } from "./AttachmentPreview.vue";export { default as AttachmentRemove } from "./AttachmentRemove.vue";export { default as Attachments } from "./Attachments.vue";export * from "./context";export * from "./types";export * from "./utils";<script lang="ts"> import { cn } from "$lib/utils.js"; import type { Snippet } from "svelte"; import type { HTMLAttributes } from "svelte/elements";
let { class: className, children, ...restProps }: HTMLAttributes<HTMLDivElement> & { children?: Snippet } = $props();</script>
<div data-slot="ai-attachment-empty" class={cn("flex items-center justify-center p-4 text-sm text-muted-foreground", className)} {...restProps}> {#if children} {@render children()} {:else} No attachments {/if}</div><script lang="ts"> import { HoverCardContent } from "$lib/registry/ui/hover-card/index.js"; import { cn } from "$lib/utils.js"; import type { ComponentProps } from "svelte";
let { ref = $bindable(null), align = "start", class: className, ...restProps }: ComponentProps<typeof HoverCardContent> = $props();</script>
<HoverCardContent bind:ref {align} class={cn("w-auto p-2", className)} {...restProps} /><script lang="ts"> import { HoverCardTrigger } from "$lib/registry/ui/hover-card/index.js"; import type { ComponentProps } from "svelte";
// Wrap an `Attachment` through the `child` snippet: `{#snippet child({ props })}<Attachment {...props} ... />{/snippet}`. let { ref = $bindable(null), ...restProps }: ComponentProps<typeof HoverCardTrigger> = $props();</script>
<HoverCardTrigger bind:ref {...restProps} /><script lang="ts"> import { HoverCard } from "$lib/registry/ui/hover-card/index.js"; import type { ComponentProps } from "svelte";
let { open = $bindable(false), openDelay = 0, closeDelay = 0, ...restProps }: ComponentProps<typeof HoverCard> = $props();</script>
<HoverCard bind:open {openDelay} {closeDelay} {...restProps} /><script lang="ts"> import { cn } from "$lib/utils.js"; import type { HTMLAttributes } from "svelte/elements"; import { useAttachmentContext } from "./use-attachments.svelte.js"; import { formatSize, getAttachmentLabel } from "./utils.js";
let { showMediaType = false, class: className, ...restProps }: HTMLAttributes<HTMLDivElement> & { showMediaType?: boolean; } = $props();
const attachment = useAttachmentContext(); const label = $derived(getAttachmentLabel(attachment.data)); const meta = $derived( [attachment.data.mediaType, formatSize(attachment.data.size)].filter(Boolean).join(" · ") ); const variant = $derived(attachment.variant);</script>
<div data-slot="ai-attachment-info" class={cn("min-w-0", variant === "grid" ? "mt-1.5 w-full" : "flex-1", className)} {...restProps}> <span class={cn( "block truncate", variant === "inline" && "font-medium", variant === "list" && "text-[13.5px] font-medium", variant === "grid" && "text-xs text-foreground" )} > {label} </span> {#if showMediaType && meta} <span class="block truncate text-xs text-muted-foreground">{meta}</span> {/if}</div><script lang="ts"> import MusicNoteIcon from "phosphor-svelte/lib/MusicNote"; import FileTextIcon from "phosphor-svelte/lib/FileText"; import ImageIcon from "phosphor-svelte/lib/Image"; import GlobeIcon from "phosphor-svelte/lib/Globe"; import VideoIcon from "phosphor-svelte/lib/Video"; import PaperclipIcon from "phosphor-svelte/lib/Paperclip"; import { cn } from "$lib/utils.js"; import type { Snippet } from "svelte"; import type { HTMLAttributes } from "svelte/elements"; import { useAttachmentContext } from "./use-attachments.svelte.js";
let { fallbackIcon, class: className, ...restProps }: HTMLAttributes<HTMLDivElement> & { fallbackIcon?: Snippet; } = $props();
const attachment = useAttachmentContext(); const data = $derived(attachment.data); const category = $derived(attachment.mediaCategory); const variant = $derived(attachment.variant); const fileUrl = $derived(data.type === "file" ? data.url : undefined); const filename = $derived(data.type === "file" ? data.filename : undefined);</script>
<div data-slot="ai-attachment-preview" class={cn( "flex shrink-0 items-center justify-center overflow-hidden text-muted-foreground [&_svg:not([class*='size-'])]:size-4", variant === "grid" && "h-20 w-full rounded-xl border border-border bg-muted [&_svg:not([class*='size-'])]:size-5", variant === "inline" && "size-4 [&_svg:not([class*='size-'])]:size-3.5", variant === "list" && "size-9 rounded-lg border border-border bg-muted text-foreground", className )} {...restProps}> {#if category === "image" && fileUrl} <img alt={filename || "Image"} class={cn("size-full object-cover", variant !== "grid" && "rounded-[5px]")} src={fileUrl} /> {:else if category === "video" && fileUrl} <!-- svelte-ignore a11y_media_has_caption --> <video class="size-full object-cover" muted src={fileUrl}></video> {:else if fallbackIcon} {@render fallbackIcon()} {:else if category === "audio"} <MusicNoteIcon /> {:else if category === "document"} <FileTextIcon /> {:else if category === "image"} <ImageIcon /> {:else if category === "source"} <GlobeIcon /> {:else if category === "video"} <VideoIcon /> {:else} <PaperclipIcon /> {/if}</div><script lang="ts"> import XIcon from "phosphor-svelte/lib/X"; import { Button, type ButtonProps } from "$lib/registry/ui/button/index.js"; import { cn } from "$lib/utils.js"; import { useAttachmentContext } from "./use-attachments.svelte.js";
let { label = "Remove", class: className, children, ...restProps }: Omit<ButtonProps, "onclick" | "href"> & { label?: string; } = $props();
const attachment = useAttachmentContext();
function handleClick(event: MouseEvent) { event.stopPropagation(); attachment.onRemove?.(); }</script>
{#if attachment.onRemove} <Button data-slot="ai-attachment-remove" aria-label={label} class={cn( // solid chip over the tile (DESIGN §4.7): --popover + 1px border, no transparency attachment.variant === "grid" && "absolute top-1.5 right-1.5 size-[22px] rounded-md border border-border bg-popover p-0 hover:bg-accent [&>svg]:size-3", attachment.variant === "inline" && "size-5 rounded-md p-0 [&>svg]:size-3", attachment.variant === "list" && "size-8 shrink-0 rounded-md p-0 [&>svg]:size-4", className )} onclick={handleClick} type="button" variant="ghost" {...restProps} > {#if children} {@render children()} {:else} <XIcon /> {/if} <span class="sr-only">{label}</span> </Button>{/if}<script lang="ts"> import { Attachment as UiAttachment } from "$lib/registry/ui/attachment/index.js"; import { cn } from "$lib/utils.js"; import type { Snippet } from "svelte"; import type { HTMLAttributes } from "svelte/elements"; import type { AttachmentData } from "./types.js"; import { setAttachmentContext, useAttachmentsContext } from "./use-attachments.svelte.js"; import { getMediaCategory } from "./utils.js";
// Thin layer on ui/attachment (DESIGN §5b): three layouts around the ui item (grid tiles, inline pills, list rows).
let { data, onRemove, class: className, children, ...restProps }: HTMLAttributes<HTMLDivElement> & { data: AttachmentData; onRemove?: () => void; children?: Snippet; } = $props();
const attachments = useAttachmentsContext();
setAttachmentContext({ get data() { return data; }, get mediaCategory() { return getMediaCategory(data); }, get onRemove() { return onRemove; }, get variant() { return attachments.variant; }, });</script>
{#if attachments.variant === "grid"} <div data-slot="ai-attachment" data-variant="grid" class={cn("group/ai-attachment relative w-[110px]", className)} {...restProps} > {@render children?.()} </div>{:else} <UiAttachment data-slot="ai-attachment" data-variant={attachments.variant} size={attachments.variant === "inline" ? "xs" : "default"} class={cn( "group/ai-attachment items-center", attachments.variant === "inline" && "h-8 w-fit min-w-0 cursor-pointer rounded-lg px-2 py-0 text-[13px]", attachments.variant === "list" && "w-full gap-3 rounded-xl p-2.5", className )} {...restProps} > {@render children?.()} </UiAttachment>{/if}<script lang="ts"> import { cn } from "$lib/utils.js"; import type { Snippet } from "svelte"; import type { HTMLAttributes } from "svelte/elements"; import type { AttachmentVariant } from "./types.js"; import { setAttachmentsContext } from "./use-attachments.svelte.js";
let { variant = "grid", class: className, children, ...restProps }: HTMLAttributes<HTMLDivElement> & { variant?: AttachmentVariant; children?: Snippet; } = $props();
setAttachmentsContext({ get variant() { return variant; }, });</script>
<div data-slot="ai-attachments" data-variant={variant} class={cn( "flex items-start", variant === "list" ? "w-full flex-col gap-2" : "flex-wrap gap-2", className )} {...restProps}> {@render children?.()}</div>import Item from "./attachment.svelte";import Empty from "./attachment-empty.svelte";import HoverCard from "./attachment-hover-card.svelte";import HoverCardContent from "./attachment-hover-card-content.svelte";import HoverCardTrigger from "./attachment-hover-card-trigger.svelte";import Info from "./attachment-info.svelte";import Preview from "./attachment-preview.svelte";import Remove from "./attachment-remove.svelte";import Root from "./attachments.svelte";
export * from "./types.js";export * from "./use-attachments.svelte.js";export * from "./utils.js";export { Empty, Empty as AttachmentEmpty, HoverCard, HoverCard as AttachmentHoverCard, HoverCardContent, HoverCardContent as AttachmentHoverCardContent, HoverCardTrigger, HoverCardTrigger as AttachmentHoverCardTrigger, Info, Info as AttachmentInfo, Item, Item as Attachment, Preview, Preview as AttachmentPreview, Remove, Remove as AttachmentRemove, Root, // Root as Attachments,};import type { FileUIPart, SourceDocumentUIPart } from "ai";
export type AttachmentData = | (FileUIPart & { id: string; /** Bytes, shown in the list meta line. */ size?: number; }) | (SourceDocumentUIPart & { id: string; size?: number });
export type AttachmentMediaCategory = | "image" | "video" | "audio" | "document" | "source" | "unknown";
export type AttachmentVariant = "grid" | "inline" | "list";import { getContext, setContext } from "svelte";import type { AttachmentData, AttachmentMediaCategory, AttachmentVariant,} from "./types.js";
/** Getter objects keep the contexts reactive. */export interface AttachmentsContextValue { readonly variant: AttachmentVariant;}
export interface AttachmentContextValue { readonly data: AttachmentData; readonly mediaCategory: AttachmentMediaCategory; /** Undefined when the parent passes no `onRemove` (the remove button then hides). */ readonly onRemove: (() => void) | undefined; readonly variant: AttachmentVariant;}
const ATTACHMENTS_KEY = Symbol("ai-attachments");const ATTACHMENT_KEY = Symbol("ai-attachment");
export function setAttachmentsContext(value: AttachmentsContextValue) { return setContext(ATTACHMENTS_KEY, value);}
export function useAttachmentsContext(): AttachmentsContextValue { return ( getContext<AttachmentsContextValue | undefined>(ATTACHMENTS_KEY) ?? { variant: "grid", } );}
export function setAttachmentContext(value: AttachmentContextValue) { return setContext(ATTACHMENT_KEY, value);}
export function useAttachmentContext(): AttachmentContextValue { const ctx = getContext<AttachmentContextValue | undefined>(ATTACHMENT_KEY); if (!ctx) { throw new Error("Attachment components must be used within <Attachment>"); } return ctx;}import type { AttachmentData, AttachmentMediaCategory } from "./types.js";
export function getMediaCategory( data: AttachmentData,): AttachmentMediaCategory { if (data.type === "source-document") { return "source"; }
const mediaType = data.mediaType ?? "";
if (mediaType.startsWith("image/")) { return "image"; } if (mediaType.startsWith("video/")) { return "video"; } if (mediaType.startsWith("audio/")) { return "audio"; } if (mediaType.startsWith("application/") || mediaType.startsWith("text/")) { return "document"; }
return "unknown";}
export function getAttachmentLabel(data: AttachmentData): string { if (data.type === "source-document") { return data.title || data.filename || "Source"; }
const category = getMediaCategory(data); return data.filename || (category === "image" ? "Image" : "Attachment");}
export function formatSize(bytes?: number): string | undefined { if (bytes === undefined) return undefined; if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`; return `${(bytes / 1024 / 1024).toFixed(1)} MB`;}| Part | Prop | Type | Default |
|---|---|---|---|
| Attachments | variant |
grid | inline | list |
grid |
| Attachment | data, onRemove |
FileUIPart | SourceDocumentUIPart + id, size? |
|
| AttachmentInfo | showMediaType |
boolean |
false |
| AttachmentPreview, AttachmentRemove, AttachmentHoverCard*, AttachmentEmpty |
Vue and Svelte
Section titled “Vue and Svelte”- Remove: React
onRemoveprop, Vue@removelistener, SvelteonRemoveprop; the remove button only shows when it is set. The hover card trigger wraps anAttachmentwithrender(React),as-child(Vue) or achildsnippet (Svelte).
✦ Edmi additions
Section titled “✦ Edmi additions”- The grid shows the file name under the tile; the list meta line is
mediaType · size. - The remove button on a grid tile is a solid
--popoverchip with a 1px border (no transparency).