Attachment
attachmentuiSingle file or image chip. Drive look with props: state, size, orientation, media, src, removable. Not a dropzone (see file-upload).
Installation
$pnpm dlx uipkge-ng@latest add attachment$npx uipkge-ng@latest add attachment$yarn dlx uipkge-ng@latest add attachment$bunx uipkge-ng@latest add attachmentManifest:
https://uipkge.dev/r/angular/attachment.jsonInstalls to:src/app/components/ui/attachment/Examples
Loading interactive previews…
Loading interactive previews…
Props
| Name | Type / Values | Default | Required |
|---|---|---|---|
size | 'default''sm''xs' | default | optional |
orientation | 'horizontal''vertical' | horizontal | optional |
title | string | — | optional |
description | string | — | optional |
state | 'idle''uploading''processing''error''done' | done | optional |
media | 'file''image''code' | file | optional |
src | string | — | optional |
alt | string | — | optional |
removable | boolean | false | optional |
class | string | — | optional |
remove | EventEmitter<void> | — | optional |
npm dependencies
Files installed (3)
src/app/components/ui/attachment/attachment.component.ts5.9 kB
import { Component, EventEmitter, Input, Output, booleanAttribute, ChangeDetectionStrategy } from '@angular/core' import { cn } from '@/lib/utils' import { attachmentMediaVariants, attachmentVariants, type AttachmentVariants } from './attachment.variants' export type AttachmentState = 'idle' | 'uploading' | 'processing' | 'error' | 'done' export type AttachmentSize = NonNullable<AttachmentVariants['size']> export type AttachmentOrientation = NonNullable<AttachmentVariants['orientation']> export type AttachmentMedia = 'file' | 'image' | 'code' /** * Angular port of UIPKGE Attachment. Single file/image chip driven by * `state`, `size`, `orientation`, `media`, `src`, `removable` props. * Class strings come from shared `attachmentVariants` — identical to Vue/React. */ @Component({ changeDetection: ChangeDetectionStrategy.Eager, selector: 'ui-attachment, [ui-attachment]', standalone: true, host: { '[attr.data-slot]': '"attachment"', '[attr.data-uipkge]': '""', '[attr.data-state]': 'state', '[attr.data-size]': 'size', '[attr.data-orientation]': 'orientation', '[class]': 'hostClass', }, template: ` <div data-uipkge data-slot="attachment-media" [class]="mediaClass"> @if (src && media === 'image' && !busy) { <img [src]="src" [alt]="alt" class="size-full object-cover" /> } @else if (busy) { <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-loader-circle size-4 motion-safe:animate-spin" aria-hidden="true" > <path d="M21 12a9 9 0 1 1-6.219-8.56" /> </svg> } @else if (media === 'code') { <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-file-code" aria-hidden="true" > <path d="M10 12.5 8 15l2 2.5" /> <path d="m14 12.5 2 2.5-2 2.5" /> <path d="M14 2v4a2 2 0 0 0 2 2h4" /> <path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7z" /> </svg> } @else if (media === 'image') { <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-image" aria-hidden="true" > <rect width="18" height="18" x="3" y="3" rx="2" ry="2" /> <circle cx="9" cy="9" r="2" /> <path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21" /> </svg> } @else { <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-file-text" aria-hidden="true" > <path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" /> <path d="M14 2v4a2 2 0 0 0 2 2h4" /> <path d="M10 9H8" /> <path d="M16 13H8" /> <path d="M16 17H8" /> </svg> } </div> <div data-uipkge data-slot="attachment-content" class="min-w-0 flex-1 leading-tight"> <span data-uipkge data-slot="attachment-title" [class]="titleClass">{{ title }}</span> @if (description) { <span data-uipkge data-slot="attachment-description" [class]="descriptionClass">{{ description }}</span> } </div> @if (removable) { <button type="button" data-uipkge 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" [attr.aria-label]="'Remove ' + title" (click)="remove.emit()" > <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-x size-3.5" aria-hidden="true" > <path d="M18 6 6 18" /> <path d="m6 6 12 12" /> </svg> </button> } <ng-content /> `, }) export class UiAttachmentComponent { @Input() title = '' @Input() description?: string @Input() state: AttachmentState = 'done' @Input() size: AttachmentSize = 'default' @Input() orientation: AttachmentOrientation = 'horizontal' @Input() media: AttachmentMedia = 'file' @Input() src?: string @Input() alt = '' @Input({ transform: booleanAttribute }) removable = false @Input('class') className?: string @Output() remove = new EventEmitter<void>() get busy(): boolean { return this.state === 'uploading' || this.state === 'processing' } get hostClass(): string { return cn(attachmentVariants({ size: this.size, orientation: this.orientation }), this.className) } get mediaClass(): string { return cn(attachmentMediaVariants({ size: this.size })) } get titleClass(): string { return cn('block truncate font-medium', this.busy && 'animate-pulse') } get descriptionClass(): string { return cn('text-muted-foreground mt-0.5 block truncate text-xs', this.state === 'error' && 'text-destructive/80') } } export { attachmentMediaVariants, attachmentVariants, type AttachmentVariants }src/app/components/ui/attachment/attachment.variants.ts1.7 kB
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>src/app/components/ui/attachment/index.ts0.2 kB
export { UiAttachmentComponent, attachmentMediaVariants, attachmentVariants, type AttachmentMedia, type AttachmentOrientation, type AttachmentSize, type AttachmentState, type AttachmentVariants, } from './attachment.component'
Raw manifest:https://uipkge.dev/r/angular/attachment.json