# Ply UI Design — complete examples

All templates use verified Ply APIs. In the demo app import classes from `'Base'`; in the library use relative paths; consumers import from the copied `src/app/components/<name>/` files. Class names: `CardComponent`, `CardHeaderComponent`, `CardBodyComponent`, `CardFooterComponent`, `AvatarComponent`, `SkeletonComponent`, `EmptyStateComponent`, `AlertComponent`, `AlertActionsComponent`, `StatCardComponent`, `ProgressButtonComponent`, `LoadingOverlayComponent`, `DataTableComponent`, `TableCellDirective`, `TableEmptyDirective`, `BadgeComponent`, `InputGroupComponent`, `LabelComponent`, `ErrorComponent`, `InfoTextComponent`, `BaseInputDirective`, `BaseButtonDirective`, `StrokedButtonDirective`, `IconButtonDirective`, `IconComponent`, `DialogComponent`, `DialogHeaderComponent`, `DialogBodyComponent`, `DialogFooterComponent`, `DialogCloseDirective`, `DialogContext`, `DialogService`, `ToastService`.

## 1. Four-state data region (loading → error → empty → data)

One container, one `min-h`, one branch rendered at a time. The skeleton mirrors the final row layout (avatar + two lines + amount) and row count.

```ts
@Component({
  selector: 'app-recent-orders',
  changeDetection: ChangeDetectionStrategy.OnPush,
  imports: [
    CardComponent, CardHeaderComponent, CardBodyComponent, AvatarComponent,
    SkeletonComponent, EmptyStateComponent, AlertComponent, AlertActionsComponent,
    BaseButtonDirective, StrokedButtonDirective, IconComponent, CurrencyPipe, DatePipe,
  ],
  templateUrl: './recent-orders.component.html',
})
export class RecentOrdersComponent {
  private readonly api = inject(OrdersApi);

  readonly orders = signal<Order[]>([]);
  readonly loading = signal(true);
  readonly error = signal<string | null>(null);
  readonly filterActive = signal(false);

  constructor() {
    this.load();
  }

  load(): void {
    this.loading.set(true);
    this.error.set(null);
    this.api.recent().subscribe({
      next: (rows) => { this.orders.set(rows); this.loading.set(false); },
      error: () => { this.error.set("Couldn't load recent orders."); this.loading.set(false); },
    });
  }
}
```

```html
<ply-card>
  <ply-card-header class="h-auto py-4">
    <div>
      <p class="m-0! text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400">Sales</p>
      <h3 class="m-0! text-xl font-bold tracking-tight text-slate-900 dark:text-white">Recent orders</h3>
    </div>
    <button ply-stroked-button size="sm" (click)="load()" [disabled]="loading()">
      <ply-icon name="refresh" class="h-4 w-4"></ply-icon>
      Refresh
    </button>
  </ply-card-header>

  <ply-card-body class="min-h-80 p-6">
    @if (loading()) {
      <ul class="m-0! list-none space-y-4 p-0" aria-busy="true" aria-label="Loading recent orders">
        @for (i of [1, 2, 3, 4, 5]; track i) {
          <li class="flex items-center gap-3">
            <ply-skeleton variant="circular" width="40px" height="40px"></ply-skeleton>
            <div class="min-w-0 flex-1 space-y-2">
              <ply-skeleton width="60%"></ply-skeleton>
              <ply-skeleton width="35%"></ply-skeleton>
            </div>
            <ply-skeleton width="4.5rem" height="1rem"></ply-skeleton>
          </li>
        }
      </ul>
    } @else if (error()) {
      <ply-alert color="danger" icon="alert-triangle">
        {{ error() }}
        <ply-alert-actions>
          <button ply-stroked-button color="danger" size="sm" (click)="load()">Retry</button>
        </ply-alert-actions>
      </ply-alert>
    } @else {
      <ul class="m-0! list-none divide-y divide-slate-200 p-0 dark:divide-slate-700">
        @for (order of orders(); track order.id) {
          <li class="flex items-center gap-3 py-3 first:pt-0 last:pb-0">
            <ply-avatar size="md" shape="circle" [initials]="order.customerInitials"></ply-avatar>
            <div class="min-w-0 flex-1">
              <p class="m-0! truncate text-sm font-medium text-slate-900 dark:text-white" [title]="order.customer">
                {{ order.customer }}
              </p>
              <p class="m-0! line-clamp-1 text-xs text-slate-500 dark:text-slate-400">
                #{{ order.id }} · {{ order.placedAt | date: 'mediumDate' }}
              </p>
            </div>
            <span class="shrink-0 text-sm font-semibold tabular-nums text-slate-900 dark:text-white">
              {{ order.total | currency: order.currency }}
            </span>
          </li>
        } @empty {
          @if (filterActive()) {
            <ply-empty-state iconName="search" title="No matching orders"
              description="Nothing matches the current filter. Clear it to see all orders.">
              <button ply-stroked-button (click)="filterActive.set(false)">Clear filter</button>
            </ply-empty-state>
          } @else {
            <ply-empty-state iconName="inbox" title="No orders yet"
              description="Orders appear here as soon as a customer checks out.">
              <button ply-button color="primary">Create test order</button>
            </ply-empty-state>
          }
        }
      </ul>
    }
  </ply-card-body>
</ply-card>
```

## 2. Dashboard KPI grid with a matching skeleton

The skeleton grid uses the same `grid` classes and card chrome as the metrics, so the page does not reflow when data lands.

```html
<section aria-labelledby="kpi-heading">
  <h2 id="kpi-heading" class="sr-only">Key metrics</h2>

  @if (loading()) {
    <div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4" aria-busy="true">
      @for (i of [1, 2, 3, 4]; track i) {
        <div class="rounded-xl border border-slate-200 bg-white p-6 dark:border-slate-700 dark:bg-slate-800">
          <ply-skeleton width="45%" height="0.6rem" class="mb-4"></ply-skeleton>
          <ply-skeleton width="70%" height="1.875rem" class="mb-4"></ply-skeleton>
          <ply-skeleton variant="rectangular" width="100%" height="3rem"></ply-skeleton>
        </div>
      }
    </div>
  } @else {
    <div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
      <ply-stat-card variant="metric" label="Revenue" [value]="kpi().revenue | currency: 'USD' : 'symbol' : '1.0-0'"
        [delta]="kpi().revenueDelta" [series]="kpi().revenueSeries" caption="vs. last 30 days"></ply-stat-card>
      <ply-stat-card variant="metric" label="Orders" [value]="kpi().orders | number"
        [delta]="kpi().ordersDelta" [series]="kpi().ordersSeries" color="success"></ply-stat-card>
      <ply-stat-card variant="metric" label="Refund rate" [value]="kpi().refundRate | percent: '1.1-1'"
        [delta]="kpi().refundDelta" color="warning"></ply-stat-card>
      <ply-stat-card variant="metric" label="Avg. order" [value]="kpi().avgOrder | currency: 'USD'"
        [delta]="kpi().avgOrderDelta"></ply-stat-card>
    </div>
  }
</section>
```

Anything numeric you write yourself follows the same recipe as the stat card value:

```html
<p class="m-0! text-[30px] font-black leading-none tracking-tight tabular-nums text-slate-900 dark:text-white">
  {{ total() | currency }}
</p>
```

## 3. Dialog form with inline validation, async submit, and destructive confirm

Open with `inject(DialogService).open<ProjectDraft | undefined, ProjectDraft>(ProjectDialogComponent, existing)`. The container supplies backdrop blur, scale-in, focus trap, Escape, and labelling — the dialog component adds nothing for those.

```ts
@Component({
  selector: 'app-project-dialog',
  changeDetection: ChangeDetectionStrategy.OnPush,
  imports: [
    ReactiveFormsModule,
    DialogComponent, DialogHeaderComponent, DialogBodyComponent, DialogFooterComponent, DialogCloseDirective,
    InputGroupComponent, LabelComponent, BaseInputDirective, ErrorComponent, InfoTextComponent,
    ProgressButtonComponent, StrokedButtonDirective, IconButtonDirective, IconComponent,
  ],
  templateUrl: './project-dialog.component.html',
})
export class ProjectDialogComponent {
  private readonly context = inject<DialogContext<ProjectDraft | undefined, ProjectDraft>>(DialogContext);
  private readonly dialog = inject(DialogService);
  private readonly toast = inject(ToastService);
  private readonly api = inject(ProjectsApi);
  private readonly fb = inject(NonNullableFormBuilder);

  readonly isEdit = !!this.context.data;
  readonly saving = signal(false);
  readonly form = this.fb.group({
    name: [this.context.data?.name ?? '', [Validators.required, Validators.maxLength(60)]],
    owner: [this.context.data?.owner ?? '', [Validators.required, Validators.email]],
  });

  save(): void {
    if (this.form.invalid) { this.form.markAllAsTouched(); return; }
    this.saving.set(true);
    this.api.save(this.form.getRawValue()).subscribe({
      next: (saved) => { this.toast.success('Project saved'); this.context.close(saved); },
      error: () => { this.saving.set(false); this.toast.error("Couldn't save project", { action: { label: 'Retry', onClick: () => this.save() } }); },
    });
  }

  remove(): void {
    this.dialog.confirm({
      title: 'Delete this project?',
      description: 'Members lose access immediately. This cannot be undone.',
      confirmLabel: 'Delete',
      destructive: true,
    }).subscribe((ok) => {
      if (!ok) return;
      this.api.delete(this.context.data!.id).subscribe(() => {
        this.toast.success('Project deleted');
        this.context.close();
      });
    });
  }
}
```

```html
<ply-dialog width="480">
  <ply-dialog-header>
    {{ isEdit ? 'Edit project' : 'New project' }}
    <button type="button" ply-icon-button color="transparent" ply-dialog-close aria-label="Close">
      <ply-icon name="close"></ply-icon>
    </button>
  </ply-dialog-header>

  <form [formGroup]="form" (ngSubmit)="save()" novalidate>
    <ply-dialog-body>
      <!-- ply-input-group links the label, shows ply-error after touch + invalid, paints the red shell,
           and sets aria-invalid / aria-describedby. No @if around ply-error, no manual ids. -->
      <div class="flex flex-col gap-4 py-1">
        <ply-input-group>
          <ply-label>Name</ply-label>
          <input ply-input formControlName="name" placeholder="Acme checkout" autocomplete="off" />
          <ply-error>
            @if (form.controls.name.hasError('required')) { Enter a project name. }
            @else { Keep the name under 60 characters. }
          </ply-error>
          <ply-info-text>Shown in the sidebar and on invoices.</ply-info-text>
        </ply-input-group>

        <ply-input-group>
          <ply-label>Owner email</ply-label>
          <input ply-input type="email" formControlName="owner" placeholder="you@company.com" />
          <ply-error>Enter a valid email address.</ply-error>
        </ply-input-group>
      </div>
    </ply-dialog-body>

    <ply-dialog-footer class="justify-between">
      <div>
        @if (isEdit) {
          <button type="button" ply-stroked-button color="danger" (click)="remove()" [disabled]="saving()">Delete</button>
        }
      </div>
      <div class="flex gap-2">
        <button type="button" ply-stroked-button ply-dialog-close [disabled]="saving()">Cancel</button>
        <ply-progress-button type="submit" color="primary" [loading]="saving()">
          {{ isEdit ? 'Save changes' : 'Create project' }}
        </ply-progress-button>
      </div>
    </ply-dialog-footer>
  </form>
</ply-dialog>
```

## 4. Data table with formatted numeric columns and every state

`ply-data-table` renders skeleton rows for `loading` and a message row for empty data; only the error branch is yours.

```ts
readonly columns: TableColumn[] = [
  { key: 'id', label: 'Invoice', sortable: true, width: '120px' },
  { key: 'customer', label: 'Customer', sortable: true },
  { key: 'issued', label: 'Issued', sortable: true, width: '140px' },
  { key: 'amount', label: 'Amount', sortable: true, align: 'right', width: '140px' },
  { key: 'status', label: 'Status', width: '120px' },
];
```

```html
<div class="mb-4 flex flex-wrap items-center justify-between gap-3">
  <div>
    <p class="m-0! text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-400">Billing</p>
    <h2 class="m-0! text-xl font-bold tracking-tight text-slate-900 dark:text-white">Invoices</h2>
  </div>
  <button ply-button color="primary" size="sm" (click)="openNewInvoice()">
    <ply-icon name="plus" class="h-4 w-4"></ply-icon>
    New invoice
  </button>
</div>

@if (error()) {
  <ply-alert color="danger" icon="alert-triangle" class="mb-4">
    {{ error() }}
    <ply-alert-actions>
      <button ply-stroked-button color="danger" size="sm" (click)="fetch(lastPage())">Retry</button>
    </ply-alert-actions>
  </ply-alert>
}

<ply-data-table
  [columns]="columns"
  [data]="rows()"
  [loading]="loading()"
  sortable pageable bordered hoverable
  serverSide [pageSize]="25" [totalItems]="total()"
  emptyMessage="No invoices match these filters"
  (pageChange)="fetch($event)"
  (sortChange)="onSort($event)"
  (rowClick)="openInvoice($event)">

  <ng-template plyTableCell="id" let-value="value">
    <span class="font-mono text-xs text-slate-500 dark:text-slate-400">{{ value }}</span>
  </ng-template>

  <ng-template plyTableCell="customer" let-row>
    <div class="flex min-w-0 items-center gap-3">
      <ply-avatar size="sm" shape="circle" [initials]="row.initials"></ply-avatar>
      <div class="min-w-0">
        <p class="m-0! truncate text-sm font-medium text-slate-900 dark:text-white" [title]="row.customer">{{ row.customer }}</p>
        <p class="m-0! truncate text-xs text-slate-500 dark:text-slate-400" [title]="row.email">{{ row.email }}</p>
      </div>
    </div>
  </ng-template>

  <ng-template plyTableCell="issued" let-value="value">
    <span class="whitespace-nowrap tabular-nums">{{ value | date: 'mediumDate' }}</span>
  </ng-template>

  <ng-template plyTableCell="amount" let-row let-value="value">
    <span class="whitespace-nowrap font-semibold tabular-nums text-slate-900 dark:text-white">
      {{ value | currency: row.currency }}
    </span>
  </ng-template>

  <ng-template plyTableCell="status" let-value="value">
    <ply-badge [color]="statusColor(value)" size="sm" shape="rounded">{{ value }}</ply-badge>
  </ng-template>

  <ng-template plyTableEmpty>
    <div class="py-6">
      <ply-empty-state iconName="filter" title="No invoices match"
        description="Widen the date range or clear the status filter.">
        <button ply-stroked-button size="sm" (click)="clearFilters()">Clear filters</button>
      </ply-empty-state>
    </div>
  </ng-template>
</ply-data-table>
```

## 5. Refreshing in place and lazy-loading heavy widgets

```html
<!-- Keep stale data visible while refreshing -->
<ply-loading-overlay [visible]="refreshing()" message="Updating…" class="rounded-xl">
  <app-revenue-chart [series]="series()"></app-revenue-chart>
</ply-loading-overlay>

<!-- Defer a heavy widget with a same-size placeholder -->
<ply-card>
  <ply-card-body class="p-6">
    @defer (on viewport; prefetch on idle) {
      <app-revenue-chart [series]="series()" class="block h-64"></app-revenue-chart>
    } @placeholder {
      <ply-skeleton variant="rectangular" width="100%" height="16rem"></ply-skeleton>
    } @loading (minimum 300ms) {
      <ply-skeleton variant="rectangular" width="100%" height="16rem"></ply-skeleton>
    }
  </ply-card-body>
</ply-card>
```

## 6. Custom menu panel (only when `ply-dropdown-menu` / `ply-popover` do not fit)

Prefer `<ply-dropdown-menu>` with `[ply-dropdown-menu-trigger]` and `ply-dropdown-menu-item`, or `<ply-popover>` with `[ply-popover-trigger]`. If you must hand-roll a panel, copy the dropdown's surface and item recipe exactly, position with `dropdownConnectedPositions()`, and layer with `Z_SCALE.popover`.

```html
<div
  role="menu"
  class="min-w-40 origin-top overflow-hidden rounded-md border border-slate-200 bg-white shadow-xl outline-none transition-[opacity,transform] dark:border-slate-700 dark:bg-slate-800"
  [class]="open() ? 'scale-100 opacity-100 duration-200 ease-out' : 'pointer-events-none scale-95 opacity-0 duration-150 ease-in'"
  [style.z-index]="Z_SCALE.popover">
  <button role="menuitem" class="flex h-12 w-full items-center gap-3 px-6 text-sm text-slate-600 outline-none transition-colors duration-200 hover:bg-slate-100 focus-visible:bg-slate-100 dark:text-slate-300 dark:hover:bg-slate-700 dark:focus-visible:bg-slate-700">
    <ply-icon name="edit" class="h-4 w-4 stroke-current"></ply-icon>
    Rename
  </button>
  <button role="menuitem" class="flex h-12 w-full items-center gap-3 px-6 text-sm text-red-600 outline-none transition-colors duration-200 hover:bg-red-50 focus-visible:bg-red-50 dark:text-red-400 dark:hover:bg-red-900/30 dark:focus-visible:bg-red-900/30">
    <ply-icon name="trash" class="h-4 w-4 stroke-current"></ply-icon>
    Delete
  </button>
</div>
```
