Menu

Change language

GitHub repo

Empty States

blocklayout

Gallery of six zero-data cards — no data, no search results, first-use, error recovery, no access, and sync complete — each with a matching HTTP response panel, title, copy, and action pair.

Installation

$npx uipkge-ng@latest add empty-states
Manifest:https://uipkge.dev/r/angular/empty-states.jsonInstalls to:src/app/components/blocks/empty-states/

Variants

Loading interactive previews…

Schema

Type aliases exported from this item's source. Use these to shape the data you pass in.

EmptyStateCard
interface EmptyStateCard {
  variant: EmptyStateVariant
  image: string
  request: string
  status: string
  detail: string
  tone?: 'destructive' | 'warning'
  title: string
  description: string
  primaryLabel: string
  secondaryLabel: string
  role: 'status' | 'alert'
  primaryOnly: boolean
  secondaryOnly: boolean
}

npm dependencies

Includes

Files installed (2)

  • src/app/components/blocks/empty-states/empty-states.component.ts10.2 kB
    // Gallery of six zero-data cards: no data, no search results, first-use, error recovery,
    // no access, and sync complete — each with a matching HTTP response panel, title, copy, and
    // action pair. Port of the React block 1:1.
    import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core'
    import { RotateCcw, LucideAngularModule } from 'lucide-angular'
    import { cn } from '@/lib/utils'
    import { UiButtonComponent } from '@/ui/button/button.component'
    
    export type EmptyStateVariant =
      'no-data' | 'no-search-results' | 'first-use' | 'error-recovery' | 'no-access' | 'sync-complete'
    
    interface EmptyStateCard {
      variant: EmptyStateVariant
      image: string
      request: string
      status: string
      detail: string
      tone?: 'destructive' | 'warning'
      title: string
      description: string
      primaryLabel: string
      secondaryLabel: string
      role: 'status' | 'alert'
      primaryOnly: boolean
      secondaryOnly: boolean
    }
    
    const cards: EmptyStateCard[] = [
      {
        variant: 'no-data',
        image: '/illustrations/empty-no-data.jpg',
        request: 'GET /v1/projects',
        status: '200 OK',
        detail: '{ "data": [], "total": 0 }',
        title: 'Nothing here yet',
        description: 'Your workspace is empty. Create your first project or import existing data to get started.',
        primaryLabel: 'Create',
        secondaryLabel: 'Import data',
        role: 'status',
        primaryOnly: false,
        secondaryOnly: false,
      },
      {
        variant: 'no-search-results',
        image: '/illustrations/empty-no-search.jpg',
        request: 'GET /search?q=northwind',
        status: '200 OK',
        detail: '{ "hits": 0 }',
        title: 'No matches',
        description: 'Nothing matched your current filters. Try different keywords or broaden the search.',
        primaryLabel: 'Clear filters',
        secondaryLabel: 'View all',
        role: 'status',
        primaryOnly: false,
        secondaryOnly: false,
      },
      {
        variant: 'first-use',
        image: '/illustrations/empty-first-use.jpg',
        request: 'GET /v1/workspace',
        status: '200 OK',
        detail: '{ "onboarded": false }',
        title: 'Welcome to Acme',
        description: 'This is your new dashboard. Set up your workspace and invite the team to start collaborating.',
        primaryLabel: 'Get started',
        secondaryLabel: 'Take a tour',
        role: 'status',
        primaryOnly: false,
        secondaryOnly: false,
      },
      {
        variant: 'error-recovery',
        image: '/illustrations/empty-error-recovery.jpg',
        request: 'GET /api/projects',
        status: '500 Internal Server Error',
        detail: 'error: upstream_timeout',
        tone: 'destructive',
        title: 'Something broke',
        description: "We couldn't load your data. Your work is safe — check the connection and try again.",
        primaryLabel: 'Retry',
        secondaryLabel: 'Contact support',
        role: 'alert',
        primaryOnly: false,
        secondaryOnly: false,
      },
      {
        variant: 'no-access',
        image: '/illustrations/empty-no-access.jpg',
        request: 'GET /settings/billing',
        status: '403 Forbidden',
        detail: 'scope billing:read — missing',
        tone: 'warning',
        title: 'Ask your admin',
        description:
          "You don't have access to this workspace yet. Request permission and we'll notify you once it's granted.",
        primaryLabel: 'Request access',
        secondaryLabel: 'Switch account',
        role: 'status',
        primaryOnly: false,
        secondaryOnly: false,
      },
      {
        variant: 'sync-complete',
        image: '/illustrations/empty-sync-complete.jpg',
        request: 'POST /sync',
        status: '200 OK',
        detail: '{ "synced": 128, "lag_ms": 40 }',
        title: 'Everything synced',
        description: 'All changes were uploaded a few seconds ago. We will keep syncing in the background.',
        primaryLabel: 'View activity',
        secondaryLabel: 'View activity',
        role: 'status',
        primaryOnly: false,
        secondaryOnly: true,
      },
    ]
    
    @Component({
      changeDetection: ChangeDetectionStrategy.Eager,
      selector: 'ui-empty-states, [ui-empty-states]',
      standalone: true,
      // React renders the root <div> itself: the host stays out of layout and `class` goes to the root.
      host: { '[attr.class]': '"contents"' },
      imports: [LucideAngularModule, UiButtonComponent],
      template: `
        <div data-slot="empty-states" [attr.data-layout]="layout" [class]="rootClass">
          @for (card of visibleCards(); track card.variant) {
            <article
              [class]="cardClass"
              data-slot="empty-states-card"
              [attr.data-variant]="card.variant"
              [attr.role]="card.role"
            >
              @if (hasArt(card)) {
                <img [src]="art(card)" [alt]="imageAlt" [class]="imgClass" />
              } @else if (art(card) === undefined) {
                <div class="border-border bg-muted/40 border-b font-mono" aria-hidden="true">
                  <div class="border-border text-muted-foreground truncate border-b px-3 py-2 text-xs">
                    {{ card.request }}
                  </div>
                  <div class="space-y-1 p-3 text-xs leading-relaxed">
                    <p>
                      <span class="text-muted-foreground">HTTP/1.1</span>
                      @if (card.tone === 'destructive') {
                        <span class="text-destructive">{{ card.status }}</span>
                      } @else if (card.tone === 'warning') {
                        <span class="text-warning">{{ card.status }}</span>
                      } @else {
                        {{ card.status }}
                      }
                    </p>
                    <p class="text-muted-foreground">{{ card.detail }}</p>
                  </div>
                </div>
              }
              <div [class]="bodyClass">
                <h3 class="text-sm font-medium">{{ pickTitle(card) }}</h3>
                <p class="text-muted-foreground mt-1 max-w-xs text-sm">{{ pickDescription(card) }}</p>
                @if (showPrimary || showSecondary) {
                  <div [class]="actionsClass">
                    @if (showPrimary && !card.secondaryOnly) {
                      @if (primaryHref) {
                        <a ui-button [href]="primaryHref" [size]="actionSize" (click)="primary.emit()">
                          @if (card.variant === 'error-recovery') {
                            <lucide-icon [img]="RotateCcw" />
                          }
                          {{ pickPrimary(card) }}
                        </a>
                      } @else {
                        <button ui-button [size]="actionSize" [disabled]="primaryDisabled" (click)="primary.emit()">
                          @if (card.variant === 'error-recovery') {
                            <lucide-icon [img]="RotateCcw" />
                          }
                          {{ pickPrimary(card) }}
                        </button>
                      }
                    }
                    @if (showSecondary && !card.primaryOnly) {
                      @if (secondaryHref) {
                        <a
                          ui-button
                          [size]="actionSize"
                          variant="ghost"
                          [href]="secondaryHref"
                          (click)="secondary.emit()"
                          >{{ pickSecondary(card) }}</a
                        >
                      } @else {
                        <button
                          ui-button
                          [size]="actionSize"
                          variant="ghost"
                          [disabled]="secondaryDisabled"
                          (click)="secondary.emit()"
                        >
                          {{ pickSecondary(card) }}
                        </button>
                      }
                    }
                  </div>
                }
              </div>
            </article>
          }
        </div>
      `,
    })
    export class UiEmptyStatesComponent {
      protected readonly RotateCcw = RotateCcw
    
      /** `'all'` renders the full gallery; a single variant renders just that card. */
      @Input() variant: EmptyStateVariant | 'all' = 'all'
      @Input() size: 'sm' | 'default' = 'default'
      @Input() layout: 'grid' | 'list' = 'grid'
      @Input('class') className?: string
      @Input() title?: string
      @Input() description?: string
      /** Replaces the response panel on a single variant. Pass `''` to hide art. */
      @Input() image?: string
      @Input() imageAlt = ''
      @Input() primaryLabel?: string
      @Input() secondaryLabel?: string
      @Input() primaryHref?: string
      @Input() secondaryHref?: string
      @Input() showPrimary = true
      @Input() showSecondary = true
      @Input() primaryDisabled = false
      @Input() secondaryDisabled = false
      @Output() primary = new EventEmitter<void>()
      @Output() secondary = new EventEmitter<void>()
    
      protected readonly allCards = cards
    
      visibleCards(): EmptyStateCard[] {
        return this.variant === 'all' ? cards : cards.filter((c) => c.variant === this.variant)
      }
    
      private isSingle(v: EmptyStateVariant): boolean {
        return this.variant === v
      }
    
      pickTitle(card: EmptyStateCard): string {
        return this.isSingle(card.variant) && this.title !== undefined ? this.title : card.title
      }
    
      pickDescription(card: EmptyStateCard): string {
        return this.isSingle(card.variant) && this.description !== undefined ? this.description : card.description
      }
    
      pickPrimary(card: EmptyStateCard): string {
        return this.isSingle(card.variant) && this.primaryLabel !== undefined ? this.primaryLabel : card.primaryLabel
      }
    
      pickSecondary(card: EmptyStateCard): string {
        return this.isSingle(card.variant) && this.secondaryLabel !== undefined ? this.secondaryLabel : card.secondaryLabel
      }
    
      art(card: EmptyStateCard): string | undefined {
        if (this.isSingle(card.variant)) return this.image === undefined ? card.image : this.image
        return card.image
      }
    
      hasArt(card: EmptyStateCard): boolean {
        const img = this.art(card)
        return img !== undefined && img !== ''
      }
    
      get rootClass(): string {
        return cn(
          this.layout === 'list' ? 'flex flex-col items-center gap-4' : 'grid gap-4 sm:grid-cols-2 xl:grid-cols-3',
          this.className,
        )
      }
    
      get cardClass(): string {
        return cn(
          'border-border bg-card overflow-hidden rounded-xl border text-left',
          this.size === 'sm' && 'rounded-lg',
          this.layout === 'list' && 'w-full max-w-md',
        )
      }
    
      get imgClass(): string {
        return cn('w-full object-cover', this.size === 'sm' ? 'aspect-[16/9]' : 'aspect-[16/10]')
      }
    
      get bodyClass(): string {
        return this.size === 'sm' ? 'p-5' : 'p-6'
      }
    
      get actionsClass(): string {
        return cn('flex flex-wrap items-center gap-2', this.size === 'sm' ? 'mt-4' : 'mt-5')
      }
    
      get actionSize(): 'xs' | 'sm' {
        return this.size === 'sm' ? 'xs' : 'sm'
      }
    }
    
  • src/app/components/blocks/empty-states/index.ts0.1 kB

Raw manifest:https://uipkge.dev/r/angular/empty-states.json