
3D Extruded Buildings & Urban Footprints
Real-world 3D building extrusions with dynamic sunlight shadows, terrain DEM elevations, and pitch/bearing camera controls.
attachmentuiSingle file or image chip. Drive look with props: state, size, orientation, media, src, removable. Not a dropzone (see file-upload).
Also available for React ->$pnpm dlx shadcn-vue@latest add https://uipkge.dev/r/vue/attachment.json$npx shadcn-vue@latest add https://uipkge.dev/r/vue/attachment.json$yarn dlx shadcn-vue@latest add https://uipkge.dev/r/vue/attachment.json$bunx shadcn-vue@latest add https://uipkge.dev/r/vue/attachment.jsonnpx shadcn-vue@latest add @uipkge/attachmentInstalls to:app/components/ui/attachment/| Name | Type / Values | Default | Required |
|---|---|---|---|
title | string | — | required |
description | string | — | optional |
state | 'idle''uploading''processing''error''done' | 'done' | optional |
size | 'default''sm''xs' | 'default' | optional |
orientation | 'horizontal''vertical' | 'horizontal' | optional |
media | 'file''image''code' | 'file' | optional |
src | string | — | optional |
alt | string | '' | optional |
removable | boolean | false | optional |
class | HTMLAttributes['class'] | — | optional |
<script setup lang="ts">
import { computed } from 'vue'
import type { HTMLAttributes } from 'vue'
import { FileCode, FileText, Image as ImageIcon, Loader2, X } from 'lucide-vue-next'
import { cn } from '@/lib/utils'
import { attachmentMediaVariants, attachmentVariants } from './attachment.variants'
const props = withDefaults(
defineProps<{
title: string
description?: string
state?: 'idle' | 'uploading' | 'processing' | 'error' | 'done'
size?: 'default' | 'sm' | 'xs'
orientation?: 'horizontal' | 'vertical'
media?: 'file' | 'image' | 'code'
src?: string
alt?: string
removable?: boolean
class?: HTMLAttributes['class']
}>(),
{
state: 'done',
size: 'default',
orientation: 'horizontal',
media: 'file',
alt: '',
removable: false,
},
)
const emit = defineEmits<{
remove: []
}>()
const busy = computed(() => props.state === 'uploading' || props.state === 'processing')
</script>
<template>
<div
data-uipkge
data-slot="attachment"
:data-state="state"
:data-size="size"
:data-orientation="orientation"
:class="cn(attachmentVariants({ size, orientation }), props.class)"
>
<div data-slot="attachment-media" :class="cn(attachmentMediaVariants({ size }))">
<img v-if="src && media === 'image' && !busy" :src="src" :alt="alt" class="size-full object-cover" />
<Loader2 v-else-if="busy" class="size-4 motion-safe:animate-spin" aria-hidden="true" />
<FileCode v-else-if="media === 'code'" aria-hidden="true" />
<ImageIcon v-else-if="media === 'image'" aria-hidden="true" />
<FileText v-else aria-hidden="true" />
</div>
<div data-slot="attachment-content" class="min-w-0 flex-1 leading-tight">
<span data-slot="attachment-title" :class="cn('block truncate font-medium', busy && 'animate-pulse')">
{{ title }}
</span>
<span
v-if="description"
data-slot="attachment-description"
:class="cn('text-muted-foreground mt-0.5 block truncate text-xs', state === 'error' && 'text-destructive/80')"
>
{{ description }}
</span>
</div>
<button
v-if="removable"
type="button"
data-slot="attachment-remove"
class="text-muted-foreground hover:bg-accent hover:text-foreground relative z-10 inline-flex size-7 shrink-0 items-center justify-center rounded-md"
:aria-label="`Remove ${title}`"
@click="emit('remove')"
>
<X class="size-3.5" />
</button>
</div>
</template>
import type { VariantProps } from 'class-variance-authority'
import { cva } from 'class-variance-authority'
/**
* Variant definitions live in their own file (rather than the package
* `index.ts`) so consuming Vue SFCs can import without creating a circular
* dependency through the index. See card.variants.ts for the canonical
* example + the SSR symptom that motivated the split.
*/
export const attachmentVariants = cva(
'group/attachment relative flex w-fit max-w-full min-w-0 shrink-0 rounded-xl border bg-card text-card-foreground transition-colors data-[state=error]:border-destructive/30 data-[state=idle]:border-dashed',
{
variants: {
size: {
default: 'gap-2 p-2 text-sm',
sm: 'gap-2 p-1.5 text-xs',
xs: 'gap-1.5 rounded-lg p-1 text-xs',
},
orientation: {
horizontal: 'min-w-40 items-center',
vertical: 'w-24 flex-col',
},
},
defaultVariants: {
size: 'default',
orientation: 'horizontal',
},
},
)
export const attachmentMediaVariants = cva(
"relative flex aspect-square shrink-0 items-center justify-center overflow-hidden rounded-lg bg-muted text-foreground group-data-[orientation=vertical]/attachment:w-full group-data-[state=error]/attachment:bg-destructive/10 group-data-[state=error]/attachment:text-destructive [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4",
{
variants: {
size: {
default: 'w-10',
sm: 'w-8',
xs: 'w-7 rounded-md [&_svg:not([class*="size-"])]:size-3.5',
},
},
defaultVariants: {
size: 'default',
},
},
)
export type AttachmentVariants = VariantProps<typeof attachmentVariants>
export { default as Attachment } from './Attachment.vue'
export { attachmentVariants, attachmentMediaVariants, type AttachmentVariants } from './attachment.variants'
Raw manifest:https://uipkge.dev/r/vue/attachment.json