attachment

Composable file and image attachment

PreviousNext
Project notes.pdfPDF · 240 KB · Ready
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';

import { NgIcon, provideIcons } from '@ng-icons/core';
import { lucideFileText, lucideX } from '@ng-icons/lucide';

import { ZardButtonComponent } from '@/shared/components/button/button.component';

import { ZardAttachmentImports } from '../attachment.imports';

@Component({
  selector: 'z-demo-attachment-preview',
  imports: [NgIcon, ZardButtonComponent, ...ZardAttachmentImports],
  template: `
    <div class="mx-auto flex w-full max-w-sm flex-col gap-4 py-6">
      @if (!removed()) {
        <z-attachment class="w-full">
          <z-attachment-media aria-hidden="true">
            <ng-icon name="lucideFileText" class="size-4" />
          </z-attachment-media>
          <z-attachment-content>
            <z-attachment-title>Project notes.pdf</z-attachment-title>
            <z-attachment-description>PDF · 240 KB · Ready</z-attachment-description>
          </z-attachment-content>
          <z-attachment-actions>
            <button type="button" z-attachment-action aria-label="Remove Project notes.pdf" (click)="removed.set(true)">
              <ng-icon name="lucideX" class="size-3.5" />
            </button>
          </z-attachment-actions>
        </z-attachment>
      } @else {
        <button type="button" z-button zType="outline" class="w-fit" (click)="removed.set(false)">Restore file</button>
      }
    </div>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
  viewProviders: [provideIcons({ lucideFileText, lucideX })],
})
export class ZardDemoAttachmentPreviewComponent {
  readonly removed = signal(false);
}

Installation

Copy
npx zard-cli@latest add attachment

Usage

import { ZardAttachmentImports } from '@/shared/components/attachment/attachment.imports';
Copy
<z-attachment>
  <z-attachment-media><ng-icon name="lucideFileText" /></z-attachment-media>
  <z-attachment-content>
    <z-attachment-title>report.pdf</z-attachment-title>
    <z-attachment-description>PDF · 1.2 MB</z-attachment-description>
  </z-attachment-content>
  <z-attachment-actions>
    <button z-attachment-action aria-label="Remove report.pdf">
      <ng-icon name="lucideX" />
    </button>
  </z-attachment-actions>
</z-attachment>
Copy

Examples

image

Illustration of green mountain peaksMountains.svgImage · 1 KB · Ready
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';

import { NgIcon, provideIcons } from '@ng-icons/core';
import { lucideX } from '@ng-icons/lucide';

import { ZardButtonComponent } from '@/shared/components/button/button.component';

import { ZardAttachmentImports } from '../attachment.imports';

@Component({
  selector: 'z-demo-attachment-image',
  imports: [NgIcon, ZardButtonComponent, ...ZardAttachmentImports],
  template: `
    <div class="mx-auto flex w-full max-w-sm flex-col gap-4 py-6">
      @if (!removed()) {
        <z-attachment zOrientation="vertical" class="w-64 overflow-hidden">
          <z-attachment-media zVariant="image" class="h-36 w-full">
            <img
              src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='240' height='120'%3E%3Crect width='240' height='120' fill='%23558078'/%3E%3Cpath d='M0 120L80 20L140 100L190 40L240 120' fill='%23b6d9cc'/%3E%3C/svg%3E"
              alt="Illustration of green mountain peaks"
              class="size-full object-cover"
            />
          </z-attachment-media>
          <z-attachment-content class="w-full px-1">
            <z-attachment-title>Mountains.svg</z-attachment-title>
            <z-attachment-description>Image · 1 KB · Ready</z-attachment-description>
          </z-attachment-content>
          <z-attachment-actions>
            <button type="button" z-attachment-action aria-label="Remove Mountains.svg" (click)="removed.set(true)">
              <ng-icon name="lucideX" class="size-3.5" />
            </button>
          </z-attachment-actions>
        </z-attachment>
      } @else {
        <button type="button" z-button zType="outline" class="w-fit" (click)="removed.set(false)">Restore image</button>
      }
    </div>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
  viewProviders: [provideIcons({ lucideX })],
})
export class ZardDemoAttachmentImageComponent {
  readonly removed = signal(false);
}

states

Report.pdfUploading · 42%Independent.txtReady · Unaffected by Report.pdf
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';

import { NgIcon, provideIcons } from '@ng-icons/core';
import {
  lucideCheck,
  lucideClock,
  lucideFileText,
  lucideLoaderCircle,
  lucideRefreshCw,
  lucideTriangleAlert,
  lucideX,
} from '@ng-icons/lucide';

import { ZardButtonComponent } from '@/shared/components/button/button.component';

import { ZardAttachmentImports } from '../attachment.imports';
import type { ZardAttachmentStateVariants } from '../attachment.variants';

@Component({
  selector: 'z-demo-attachment-states',
  imports: [NgIcon, ZardButtonComponent, ...ZardAttachmentImports],
  template: `
    <div class="mx-auto flex w-full max-w-sm flex-col gap-4 py-6">
      <div
        role="group"
        class="bg-muted/40 flex flex-wrap items-center gap-1.5 rounded-lg border p-1"
        aria-label="Upload state controls"
      >
        @for (value of states; track value) {
          <button
            type="button"
            z-button
            [zType]="state() === value ? 'default' : 'ghost'"
            zSize="xs"
            [attr.aria-pressed]="state() === value"
            (click)="state.set(value)"
          >
            {{ value }}
          </button>
        }
      </div>

      @if (!removed()) {
        <z-attachment [zState]="state()" class="w-full">
          <z-attachment-media aria-hidden="true">
            @switch (state()) {
              @case ('idle') {
                <ng-icon name="lucideClock" class="size-4" />
              }
              @case ('uploading') {
                <ng-icon name="lucideLoaderCircle" class="text-muted-foreground size-4 animate-spin" />
              }
              @case ('processing') {
                <ng-icon name="lucideFileText" class="size-4" />
              }
              @case ('error') {
                <ng-icon name="lucideTriangleAlert" class="text-destructive size-4" />
              }
              @case ('done') {
                <ng-icon name="lucideCheck" class="size-4 text-emerald-500 dark:text-emerald-400" />
              }
            }
          </z-attachment-media>
          <z-attachment-content>
            <z-attachment-title>Report.pdf</z-attachment-title>
            <z-attachment-description aria-live="polite">{{ descriptions[state()] }}</z-attachment-description>
          </z-attachment-content>
          <z-attachment-actions>
            @if (state() === 'error') {
              <button type="button" z-attachment-action aria-label="Retry Report.pdf" (click)="state.set('uploading')">
                <ng-icon name="lucideRefreshCw" class="size-3.5" />
              </button>
            }
            <button type="button" z-attachment-action aria-label="Remove Report.pdf" (click)="removed.set(true)">
              <ng-icon name="lucideX" class="size-3.5" />
            </button>
          </z-attachment-actions>
        </z-attachment>
      }

      <z-attachment class="w-full">
        <z-attachment-media aria-hidden="true">
          <ng-icon name="lucideFileText" class="size-4" />
        </z-attachment-media>
        <z-attachment-content>
          <z-attachment-title>Independent.txt</z-attachment-title>
          <z-attachment-description>Ready · Unaffected by Report.pdf</z-attachment-description>
        </z-attachment-content>
      </z-attachment>

      @if (removed()) {
        <button type="button" z-button zType="outline" class="w-fit" (click)="removed.set(false)">
          Restore Report.pdf
        </button>
      }
    </div>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
  viewProviders: [
    provideIcons({
      lucideCheck,
      lucideClock,
      lucideFileText,
      lucideLoaderCircle,
      lucideRefreshCw,
      lucideTriangleAlert,
      lucideX,
    }),
  ],
})
export class ZardDemoAttachmentStatesComponent {
  readonly states: ZardAttachmentStateVariants[] = ['idle', 'uploading', 'processing', 'error', 'done'];
  readonly state = signal<ZardAttachmentStateVariants>('uploading');
  readonly removed = signal(false);
  readonly descriptions = {
    idle: 'Waiting to upload',
    uploading: 'Uploading · 42%',
    processing: 'Processing file',
    error: 'Upload failed. Retry or remove the file.',
    done: 'Upload complete · 240 KB',
  };
}

sizes

default attachmentReady · 24 KBsm attachmentReady · 24 KBxs attachmentReady · 24 KB

Selected: none

import { ChangeDetectionStrategy, Component, signal } from '@angular/core';

import { NgIcon, provideIcons } from '@ng-icons/core';
import { lucideExternalLink, lucideFileText } from '@ng-icons/lucide';

import { ZardButtonComponent } from '@/shared/components/button/button.component';

import { ZardAttachmentImports } from '../attachment.imports';
import type { ZardAttachmentSizeVariants } from '../attachment.variants';

@Component({
  selector: 'z-demo-attachment-sizes',
  imports: [NgIcon, ZardButtonComponent, ...ZardAttachmentImports],
  template: `
    <div class="mx-auto flex w-full max-w-sm flex-col gap-4 py-6">
      <div class="flex flex-wrap gap-2">
        <button type="button" z-button zType="outline" zSize="sm" (click)="vertical.set(!vertical())">
          Toggle orientation
        </button>
        <button type="button" z-button zType="outline" zSize="sm" (click)="image.set(!image())">Toggle media</button>
      </div>

      <div class="flex flex-col gap-3">
        @for (size of sizes; track size) {
          <z-attachment [zSize]="size" [zOrientation]="vertical() ? 'vertical' : 'horizontal'" class="w-full">
            <z-attachment-media [zVariant]="image() ? 'image' : 'icon'">
              @if (image()) {
                <img
                  src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='40' height='40'%3E%3Crect width='40' height='40' fill='%23558078'/%3E%3C/svg%3E"
                  alt="Green colour sample"
                  class="size-full object-cover"
                />
              } @else {
                <ng-icon name="lucideFileText" class="size-4" />
              }
            </z-attachment-media>
            <z-attachment-content>
              <z-attachment-title>{{ size }} attachment</z-attachment-title>
              <z-attachment-description>Ready · 24 KB</z-attachment-description>
            </z-attachment-content>
            <z-attachment-actions>
              <button
                type="button"
                z-attachment-action
                [attr.aria-label]="'Inspect ' + size + ' attachment'"
                (click)="selected.set(size)"
              >
                <ng-icon name="lucideExternalLink" class="size-3.5" />
              </button>
            </z-attachment-actions>
          </z-attachment>
        }
      </div>

      <p role="status" class="text-muted-foreground text-xs">Selected: {{ selected() }}</p>
    </div>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
  viewProviders: [provideIcons({ lucideExternalLink, lucideFileText })],
})
export class ZardDemoAttachmentSizesComponent {
  readonly sizes: ZardAttachmentSizeVariants[] = ['default', 'sm', 'xs'];
  readonly vertical = signal(false);
  readonly image = signal(false);
  readonly selected = signal('none');
}

group

Notes.pdfReady · 24 KBPhoto.pngReady · 24 KBBudget.csvReady · 24 KBArchive.zipReady · 24 KB
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';

import { NgIcon, provideIcons } from '@ng-icons/core';
import { lucideFileCode, lucideFileText, lucideImage, lucideX } from '@ng-icons/lucide';

import { ZardButtonComponent } from '@/shared/components/button/button.component';

import { ZardAttachmentImports } from '../attachment.imports';

@Component({
  selector: 'z-demo-attachment-group',
  imports: [NgIcon, ZardButtonComponent, ...ZardAttachmentImports],
  template: `
    <div class="mx-auto flex w-full max-w-sm flex-col gap-4 py-6">
      <z-attachment-group aria-label="Attached files" class="max-w-sm">
        @for (file of files(); track file) {
          <z-attachment class="w-64">
            <z-attachment-media aria-hidden="true">
              @if (file.endsWith('.pdf')) {
                <ng-icon name="lucideFileText" class="size-4" />
              } @else if (file.endsWith('.png')) {
                <ng-icon name="lucideImage" class="size-4" />
              } @else if (file.endsWith('.csv')) {
                <ng-icon name="lucideFileText" class="size-4" />
              } @else {
                <ng-icon name="lucideFileCode" class="size-4" />
              }
            </z-attachment-media>
            <z-attachment-content>
              <z-attachment-title>{{ file }}</z-attachment-title>
              <z-attachment-description>Ready · 24 KB</z-attachment-description>
            </z-attachment-content>
            <z-attachment-actions>
              <button type="button" z-attachment-action [attr.aria-label]="'Remove ' + file" (click)="remove(file)">
                <ng-icon name="lucideX" class="size-3.5" />
              </button>
            </z-attachment-actions>
          </z-attachment>
        }
      </z-attachment-group>
      <button type="button" z-button zType="outline" class="w-fit" (click)="restore()">Restore files</button>
    </div>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
  viewProviders: [
    provideIcons({
      lucideFileCode,
      lucideFileText,
      lucideImage,
      lucideX,
    }),
  ],
})
export class ZardDemoAttachmentGroupComponent {
  readonly files = signal(['Notes.pdf', 'Photo.png', 'Budget.csv', 'Archive.zip']);

  remove(file: string): void {
    this.files.update(files => files.filter(value => value !== file));
  }

  restore(): void {
    this.files.set(['Notes.pdf', 'Photo.png', 'Budget.csv', 'Archive.zip']);
  }
}

trigger

Preview.pdfReady · Click card to preview

Preview opened 0 times

import { ChangeDetectionStrategy, Component, signal } from '@angular/core';

import { NgIcon, provideIcons } from '@ng-icons/core';
import { lucideDownload, lucideFileText, lucideX } from '@ng-icons/lucide';

import { ZardButtonComponent } from '@/shared/components/button/button.component';

import { ZardAttachmentImports } from '../attachment.imports';

@Component({
  selector: 'z-demo-attachment-trigger',
  imports: [NgIcon, ZardButtonComponent, ...ZardAttachmentImports],
  template: `
    <div class="mx-auto flex w-full max-w-sm flex-col gap-4 py-6">
      @if (!removed()) {
        <z-attachment class="w-full">
          <z-attachment-media aria-hidden="true">
            <ng-icon name="lucideFileText" class="size-4" />
          </z-attachment-media>
          <z-attachment-content>
            <z-attachment-title>Preview.pdf</z-attachment-title>
            <z-attachment-description>Ready · Click card to preview</z-attachment-description>
          </z-attachment-content>
          <button
            type="button"
            z-attachment-trigger
            aria-label="Preview Preview.pdf"
            (click)="opened.update(increment)"
          ></button>
          <z-attachment-actions>
            <button type="button" z-attachment-action aria-label="Remove Preview.pdf" (click)="removed.set(true)">
              <ng-icon name="lucideX" class="size-3.5" />
            </button>
            <a z-attachment-action href="#attachment-download" download="Preview.pdf" aria-label="Download Preview.pdf">
              <ng-icon name="lucideDownload" class="size-3.5" />
            </a>
          </z-attachment-actions>
        </z-attachment>
      }

      <p role="status" class="text-muted-foreground text-xs">Preview opened {{ opened() }} times</p>

      @if (removed()) {
        <button type="button" z-button zType="outline" class="w-fit" (click)="removed.set(false)">
          Restore Preview.pdf
        </button>
      }
    </div>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush,
  viewProviders: [provideIcons({ lucideDownload, lucideFileText, lucideX })],
})
export class ZardDemoAttachmentTriggerComponent {
  readonly removed = signal(false);
  readonly opened = signal(0);
  readonly increment = (value: number) => value + 1;
}

API Reference

z-attachmentComponent

File or image container. Consumer projection owns labels, URLs, progress and transport.

PropertyDescriptionTypeDefault
[zState] Upload state idle | uploading | processing | error | done done
[zSize] Density default | sm | xs default
[zOrientation] Layout horizontal | vertical horizontal
[class] Override or extend default classes. ClassValue ''

z-attachment-mediaComponent

Projected icon or image. Give images meaningful alt text.

PropertyDescriptionTypeDefault
[zVariant] Media presentation icon | image icon
[class] Override or extend default classes. ClassValue ''

z-attachment-contentComponent

Projected text container.

PropertyDescriptionTypeDefault
[class] Override or extend default classes. ClassValue ''

z-attachment-titleComponent

Title; shimmers only while the nearest attachment is busy.

PropertyDescriptionTypeDefault
[class] Override or extend default classes. ClassValue ''

z-attachment-descriptionComponent

Projected progress or error description; use an aria-live region when appropriate.

PropertyDescriptionTypeDefault
[class] Override or extend default classes. ClassValue ''

z-attachment-actionsComponent

Independent controls above the full-card trigger.

PropertyDescriptionTypeDefault
[class] Override or extend default classes. ClassValue ''

button[z-attachment-action]Component

Also a[z-attachment-action]. Native actions inherit the complete z-button API and loading observer lifecycle. Native href, target and download remain consumer attributes.

PropertyDescriptionTypeDefault
[zType] Button appearance default | destructive | outline | secondary | ghost | link ghost
[zSize] Button size default | xs | sm | lg | icon | icon-xs | icon-sm | icon-lg icon-xs
[zShape] Button shape default | circle | square default
[zLoading] Loading indicator boolean false
[zDisabled] Disabled state boolean false
[disabled] Native disabled state boolean false
[type] Native button type (ignored on anchors) button | submit | reset button
[tabindex] Consumer tab order; disabled links use -1 string | number | null null
[class] Override or extend default classes. ClassValue ''

button[z-attachment-trigger]Component

Also a[z-attachment-trigger]. Project a named native overlay button or link alongside actions, never around them.

PropertyDescriptionTypeDefault
[type] Native button type (ignored on anchors) button | submit | reset button
[class] Override or extend default classes. ClassValue ''

z-attachment-groupComponent

Horizontal snapping group. Provide aria-label or aria-labelledby. Host arrow keys scroll one current viewport; descendant controls keep their keys.

PropertyDescriptionTypeDefault
[class] Override or extend default classes. ClassValue ''
github iconwhatsapp icondiscord iconX icon

Made with ❤ in Brazil. Open source and available on GitHub .