Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions app/components/demo/FileAttachmentDemo.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<script setup lang="ts">
import { FileSpreadsheet } from "lucide-vue-next";
import { FileAttachment } from "~/registry/blocks/file-attachment";
</script>

<template>
<div class="flex flex-wrap items-center gap-4">
<FileAttachment
name="product-shot.png"
type="image/png"
display-name="product-shot.png"
preview-url="https://images.unsplash.com/photo-1498050108023-c5249f4df085?q=80&w=240&auto=format&fit=crop"
status="uploaded"
removable
@remove="() => {}"
/>

<FileAttachment
name="quarterly-report.csv"
type="text/csv"
status="uploading"
>
<template #icon>
<FileSpreadsheet
class="size-5"
aria-hidden="true"
/>
</template>
</FileAttachment>

<FileAttachment
name="brief.pdf"
type="application/pdf"
status="error"
error="File is larger than the allowed limit"
removable
@remove="() => {}"
/>
</div>
</template>
162 changes: 162 additions & 0 deletions app/registry/blocks/file-attachment/FileAttachment.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
<script setup lang="ts">
import type { HTMLAttributes } from "vue";
import { computed } from "vue";
import { AlertCircle, File, Image, Loader2, X } from "@lucide/vue";
import { cn } from "@/lib/utils";

export type FileAttachmentStatus = "idle" | "uploading" | "uploaded" | "error";

export interface FileAttachmentProps {
/** Original file name supplied by the consuming app. */
name: string;
/** Optional MIME type used for the default fallback icon. */
type?: string;
/** Optional display name. Falls back to name. */
displayName?: string;
/** Optional preview image URL supplied by the consuming app. */
previewUrl?: string;
/** Display status supplied by the consuming app. */
status?: FileAttachmentStatus;
/** Error text shown when status is error. */
error?: string;
/** Show a remove button when not uploading. */
removable?: boolean;
/** Accessible label for the remove button. */
removeLabel?: string;
/** Accessible label for the attachment. */
ariaLabel?: string;
/** Additional CSS classes for the root element. */
class?: HTMLAttributes["class"];
}

const props = withDefaults(defineProps<FileAttachmentProps>(), {
type: "",
status: "idle",
removable: false,
removeLabel: "Remove attachment",
});

const emit = defineEmits<{
remove: [];
}>();

const label = computed(() => props.displayName ?? props.name);
const isImage = computed(() => Boolean(props.previewUrl && props.type?.startsWith("image/")));
const statusLabel = computed(() => {
if (props.status === "uploading") return "Uploading";
if (props.status === "error") return props.error || "Upload failed";
if (props.status === "uploaded") return "Uploaded";
return "Ready";
});
const attachmentLabel = computed(() => props.ariaLabel ?? `${label.value}, ${statusLabel.value}`);

function fallbackIcon() {
if (props.type?.startsWith("image/")) return Image;
return File;
}
</script>

<template>
<div
data-slot="file-attachment"
:class="cn('group relative inline-flex items-center gap-3 rounded-xl border bg-card p-2 pr-3 text-card-foreground shadow-sm', props.class)"
:aria-label="attachmentLabel"
:title="label"
>
<div
data-slot="file-attachment-preview"
class="relative flex size-11 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-muted text-muted-foreground"
>
<slot
name="preview"
:file-name="name"
:display-name="label"
:type="type"
:preview-url="previewUrl"
:status="status"
>
<img
v-if="isImage"
:src="previewUrl"
:alt="label"
class="size-full object-cover"
>
<slot
v-else
name="icon"
:file-name="name"
:display-name="label"
:type="type"
:status="status"
>
<component
:is="fallbackIcon()"
class="size-5"
aria-hidden="true"
/>
</slot>
</slot>

<div
v-if="status === 'uploading'"
data-slot="file-attachment-uploading"
class="absolute inset-0 flex items-center justify-center bg-background/70 backdrop-blur-xs"
>
<Loader2
class="size-5 animate-spin"
aria-hidden="true"
/>
</div>

<div
v-else-if="status === 'error'"
data-slot="file-attachment-error-icon"
class="absolute inset-0 flex items-center justify-center bg-destructive/80 text-destructive-foreground"
>
<AlertCircle
class="size-5"
aria-hidden="true"
/>
</div>
</div>

<div class="min-w-0">
<p
data-slot="file-attachment-name"
class="max-w-40 truncate text-sm font-medium"
>
{{ label }}
</p>
<p
data-slot="file-attachment-status"
:class="cn('text-xs text-muted-foreground', status === 'error' && 'text-destructive')"
>
{{ statusLabel }}
</p>
</div>

<slot
name="actions"
:file-name="name"
:display-name="label"
:type="type"
:preview-url="previewUrl"
:status="status"
:remove="() => emit('remove')"
>
<button
v-if="removable && status !== 'uploading'"
type="button"
data-slot="file-attachment-remove"
class="absolute -right-2 -top-2 inline-flex size-6 items-center justify-center rounded-full border bg-background text-muted-foreground opacity-0 shadow-sm transition-opacity hover:text-foreground focus-visible:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 group-hover:opacity-100"
:aria-label="removeLabel"
@click="emit('remove')"
>
<X
class="size-3.5"
aria-hidden="true"
/>
</button>
</slot>
</div>
</template>
1 change: 1 addition & 0 deletions app/registry/blocks/file-attachment/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { default as FileAttachment, type FileAttachmentProps, type FileAttachmentStatus } from "./FileAttachment.vue";
83 changes: 83 additions & 0 deletions content/docs/2.components/17.file-attachment.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
---
title: FileAttachment
description: Render display-only file attachment UI while your app owns upload behavior.
category: chat
---

::component-preview
---
name: FileAttachmentDemo
---
::

## Installation

```bash
npx shadcn-vue@latest add "https://ui.stackhacker.io/r/file-attachment.json"
```

## Usage

```vue
<script setup lang="ts">
import { FileAttachment } from '@/components/file-attachment'
</script>

<template>
<FileAttachment
name="product-shot.png"
type="image/png"
preview-url="/uploads/product-shot.png"
status="uploaded"
removable
@remove="removeAttachment"
/>
</template>
```

## App-Owned Upload State

`FileAttachment` renders display-only attachment UI. Your app owns upload handlers, preview URL creation and cleanup, file limits, server state, toast messages, and removal confirmation.

Pass display-ready state through props. Use the `icon`, `preview`, or `actions` slots when your app needs custom file-type icons, preview rendering, or actions.

## Examples

### Status states

::component-preview
---
name: FileAttachmentDemo
---
::

## API Reference

### Props

| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `name` | `string` | — | Original file name supplied by your app. |
| `type` | `string` | `""` | Optional MIME type used for default fallback icons. |
| `displayName` | `string` | — | Optional display name. Falls back to `name`. |
| `previewUrl` | `string` | — | Optional preview image URL supplied by your app. |
| `status` | `'idle' \| 'uploading' \| 'uploaded' \| 'error'` | `'idle'` | Display status supplied by your app. |
| `error` | `string` | — | Error text shown when status is `error`. |
| `removable` | `boolean` | `false` | Show a remove button when not uploading. |
| `removeLabel` | `string` | `'Remove attachment'` | Accessible label for the remove button. |
| `ariaLabel` | `string` | — | Accessible label for the attachment. |
| `class` | `string` | — | Additional CSS classes for the root element. |

### Events

| Event | Payload | Description |
|------|---------|-------------|
| `remove` | `[]` | Emitted when the remove button is clicked. |

### Slots

| Slot | Props | Description |
|------|-------|-------------|
| `preview` | `{ name, displayName, type, previewUrl, status }` | Replace the preview area. |
| `icon` | `{ name, displayName, type, status }` | Replace the fallback file icon. |
| `actions` | `{ name, displayName, type, previewUrl, status, remove }` | Replace the remove/action area. |
4 changes: 4 additions & 0 deletions scripts/registry-verify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,10 @@ const expectedItems: Record<string, ExpectedRegistryItem> = {
dependencies: ["@lucide/vue"],
registryDependencies: [],
},
"file-attachment": {
dependencies: ["@lucide/vue"],
registryDependencies: [],
},
};

if (registryIndex) {
Expand Down
Loading