Skip to content
edmi-ui

Attachments

Files and images attached to a message or prompt: grid, inline and list layouts with preview and remove.

StackBlitz ↗StackBlitz ↗StackBlitz ↗
weights.csv
chart.png
chart.png
weights.csvtext/csv · 2 KB
chart.png
chart.pngimage/png · 184 KB
weights.csv
chart.png
chart.png
weights.csv
chart.png
chart.png
weights.csvtext/csv · 2 KB
chart.png
chart.pngimage/png · 184 KB
weights.csv
chart.png
chart.png
weights.csv
chart.png
chart.png
weights.csv text/csv · 2 KB
chart.png
chart.png image/png · 184 KB

Built on attachment.

Terminal window
npx shadcn@latest add @edmi-ui/ai-attachments
Terminal window
pnpm dlx shadcn@latest add @edmi-ui/ai-attachments
Terminal window
yarn dlx shadcn@latest add @edmi-ui/ai-attachments
Terminal window
bunx shadcn@latest add @edmi-ui/ai-attachments

Pick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.

Terminal window
npx shadcn-vue@latest add @edmi-ui/ai-attachments
Terminal window
pnpm dlx shadcn-vue@latest add @edmi-ui/ai-attachments
Terminal window
yarn dlx shadcn-vue@latest add @edmi-ui/ai-attachments
Terminal window
bunx shadcn-vue@latest add @edmi-ui/ai-attachments

Pick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.

Terminal window
npx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/ai-attachments.json
Terminal window
pnpm dlx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/ai-attachments.json
Terminal window
yarn dlx shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/ai-attachments.json
Terminal window
bunx --bun shadcn-svelte@latest add https://viandwi24.github.io/edmi-ui/r/svelte/ai-attachments.json

Pick your package manager above; the choice is remembered. Add the @edmi-ui registry first, see Getting started.

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>
registry/components/ai/attachments.tsx
"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>
);
registry/components/ai/attachments/Attachment.vue
<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>
registry/components/ai/attachments/AttachmentEmpty.vue
<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>
registry/components/ai/attachments/AttachmentHoverCard.vue
<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>
registry/components/ai/attachments/AttachmentHoverCardContent.vue
<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>
registry/components/ai/attachments/AttachmentHoverCardTrigger.vue
<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>
registry/components/ai/attachments/AttachmentInfo.vue
<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>
registry/components/ai/attachments/AttachmentPreview.vue
<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>
registry/components/ai/attachments/AttachmentRemove.vue
<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>
registry/components/ai/attachments/Attachments.vue
<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>
registry/components/ai/attachments/context.ts
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;
}
registry/components/ai/attachments/types.ts
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";
registry/components/ai/attachments/utils.ts
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`;
}
registry/components/ai/attachments/index.ts
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";
src/lib/registry/ai/attachments/attachment-empty.svelte
<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>
src/lib/registry/ai/attachments/attachment-hover-card-content.svelte
<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} />
src/lib/registry/ai/attachments/attachment-hover-card-trigger.svelte
<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} />
src/lib/registry/ai/attachments/attachment-hover-card.svelte
<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} />
src/lib/registry/ai/attachments/attachment-info.svelte
<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>
src/lib/registry/ai/attachments/attachment-preview.svelte
<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>
src/lib/registry/ai/attachments/attachment-remove.svelte
<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}
src/lib/registry/ai/attachments/attachment.svelte
<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}
src/lib/registry/ai/attachments/attachments.svelte
<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>
src/lib/registry/ai/attachments/index.ts
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,
};
src/lib/registry/ai/attachments/types.ts
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";
src/lib/registry/ai/attachments/use-attachments.svelte.ts
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;
}
src/lib/registry/ai/attachments/utils.ts
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
  • Remove: React onRemove prop, Vue @remove listener, Svelte onRemove prop; the remove button only shows when it is set. The hover card trigger wraps an Attachment with render (React), as-child (Vue) or a child snippet (Svelte).
  • 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 --popover chip with a 1px border (no transparency).