NAMCHE UI

Application patterns

Compose NAMCHE primitives around application-owned state and callbacks.

These recipes live outside the registry in src/components/recipes/application-patterns.tsx. They are type-checked with the site. They take plain values and async callbacks; copy them into your application's feature directory, then adapt the data layer. The source is also included below for agents.

1 projects

Projects in this workspace
Name
Expedition

Data table, loading, empty and error

ProjectTable accepts a discriminated state union. Initial loading has a named busy region, a live status and a decorative Skeleton. Failure offers Retry; empty data uses Empty. A ready table keeps cached rows visible during refresh and announces a background refresh failure without replacing the table. Use TableCaption, column scopes and stable row IDs. Sorting belongs in a labelled Button inside TableHead with aria-sort on the header; pagination belongs in application state and the URL where appropriate.

Form, validation and submit

ProjectForm connects a FieldLabel, Input and FieldError using unique IDs. Validation focuses the invalid control; network errors retain input. A pending submit disables duplicate actions and announces progress. Replace its small validation rule with the application's schema and map server field errors to matching IDs. Keep credentials and raw server errors out of UI messages.

Dialog and async mutation

ProjectDialog names and describes the dialog and closes only after save succeeds. Submit is not wrapped in DialogClose. On failure the form stays open. Dismissal is blocked while saving so a second dialog cannot race the same request. Give requests a timeout in the application so pending is bounded. Base UI owns focus trapping and restoration. If success removes the trigger, move focus to a surviving heading or row action in the application.

Destructive confirmation

DeleteProject uses AlertDialog, states the specific target and consequence, and leaves the dialog open on failure. The action waits for the mutation; Cancel and dismissal are blocked while deletion is in flight. After successful deletion, focus moves to the persistent result announcement. The application must give requests a timeout so pending cannot trap the user indefinitely. Do not optimistically remove irreversible data without a rollback plan.

List/detail layout

Use Sidebar for persistent application navigation and a semantic main for the selected detail. A list of Item links works for selectable records; Breadcrumb provides hierarchy. Keep selection in the router URL, use actual anchors, label the navigation region and mark the current link with aria-current="page". On narrow screens navigate to detail or use Sheet with a title; do not hide the only route back. Retain the list's scroll position when returning and move focus to the detail heading after navigation.

Settings screens

Group related fields with FieldSet and FieldLegend inside Card sections. Use Switch for immediate boolean settings, Checkbox for selections committed by Save, and Tabs only for local panels (route links for distinct URLs). Show unsaved changes as text. Disable Save while unchanged or pending, preserve dirty input on errors, and announce success. Destructive account actions belong in their own section with AlertDialog, not alongside routine Save.

Use Sidebar, Breadcrumb, NavigationMenu or Pagination according to scope. Use Button's render for links only when the action should look like a button; normal navigation stays an anchor. Avoid wrapping interactive controls in links.

Mount one Sonner Toaster in the application shell. Show a toast after a user-triggered mutation succeeds; keep actionable failures inline with Retry. A toast must not be the only record of a failure. If a dialog is dismissed while saving, the application mutation callback should announce completion through the shell's live region or toast. Do not announce the same event both inline and through a toast while the form is still visible.

Skeletons and progress

Use Skeleton only for unknown content, hidden from assistive technology, with one external loading announcement. Keep live status regions as siblings outside all aria-busy containers, including enclosing dialogs, so progress is announced while work is pending. Keep its shape close to the eventual content to avoid layout shifts. Use Spinner for short actions and Progress with a meaningful accessible label for measurable progress. Honour reduced motion in the application; never convey pending only through animation.

Recipe source

'use client';

import { useId, useRef, useState, type FormEvent } from 'react';
import { Button } from '@/components/ui/button';
import { Alert, AlertTitle, AlertDescription } from '@/components/ui/alert';
import { Empty, EmptyHeader, EmptyTitle, EmptyContent } from '@/components/ui/empty';
import { Field, FieldLabel, FieldError } from '@/components/ui/field';
import { Input } from '@/components/ui/input';
import { Skeleton } from '@/components/ui/skeleton';
import { Table, TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Dialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog';
import { AlertDialog, AlertDialogTrigger, AlertDialogContent, AlertDialogHeader, AlertDialogTitle, AlertDialogDescription, AlertDialogFooter, AlertDialogCancel, AlertDialogAction } from '@/components/ui/alert-dialog';

export type Project = { id: string; name: string };
export type ProjectState =
  | { status: 'loading' }
  | { status: 'error'; retry: () => void }
  | { status: 'ready'; projects: Project[]; refreshing?: boolean; refreshError?: boolean };

export function ProjectTable({ state }: { state: ProjectState }) {
  if (state.status === 'loading') return (
    <section aria-label="Projects">
      <p role="status">Loading projects…</p>
      <div aria-busy="true">
        <Skeleton className="h-11 w-full" aria-hidden="true" />
      </div>
    </section>
  );
  if (state.status === 'error') return (
    <Alert role="alert">
      <AlertTitle>Projects could not be loaded</AlertTitle>
      <AlertDescription className="text-foreground">Try again. Your data has not changed.</AlertDescription>
      <Button variant="outline" onClick={state.retry}>Retry</Button>
    </Alert>
  );
  return (
    <section aria-label="Projects">
      <p role="status">{state.refreshing ? 'Refreshing projects…' : state.refreshError ? 'Refresh failed. Showing previously loaded projects.' : `${state.projects.length} projects`}</p>
      <div aria-busy={state.refreshing}>
        {state.projects.length === 0 ? (
          <Empty><EmptyHeader><EmptyTitle>No projects yet</EmptyTitle></EmptyHeader>
            <EmptyContent>Create a project to get started.</EmptyContent></Empty>
        ) : (
          <Table>
            <TableCaption className="text-foreground">Projects in this workspace</TableCaption>
            <TableHeader><TableRow><TableHead scope="col">Name</TableHead></TableRow></TableHeader>
            <TableBody>{state.projects.map(project => <TableRow key={project.id}><TableCell>{project.name}</TableCell></TableRow>)}</TableBody>
          </Table>
        )}
      </div>
    </section>
  );
}

export function ProjectForm({ save, onSaved, onPendingChange }: { save: (name: string) => Promise<void>; onSaved?: () => void; onPendingChange?: (pending: boolean) => void }) {
  const id = useId();
  const errorId = `${id}-error`;
  const input = useRef<HTMLInputElement>(null);
  const inFlight = useRef(false);
  const [name, setName] = useState('');
  const [pending, setPending] = useState(false);
  const [invalid, setInvalid] = useState(false);
  const [error, setError] = useState('');
  const [saved, setSaved] = useState(false);
  async function submit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    if (inFlight.current) return;
    setSaved(false);
    if (!name.trim()) {
      setInvalid(true);
      setError('Enter a project name.');
      input.current?.focus();
      return;
    }
    inFlight.current = true;
    setInvalid(false);
    setPending(true);
    onPendingChange?.(true);
    setError('');
    try { await save(name.trim()); setSaved(true); onSaved?.(); }
    catch { setError('The project could not be saved. Your changes are still here; try again.'); }
    finally { inFlight.current = false; setPending(false); onPendingChange?.(false); }
  }
  return (
    <div className="grid gap-4">
      <form onSubmit={submit} noValidate className="grid gap-4" aria-busy={pending}>
        <Field data-invalid={invalid}>
          <FieldLabel htmlFor={id}>Project name</FieldLabel>
          <Input ref={input} id={id} aria-label="Project name" value={name} onChange={event => setName(event.target.value)} required disabled={pending} aria-invalid={invalid} aria-describedby={error ? errorId : undefined} />
          {error && <FieldError id={errorId} className="text-foreground">{error}</FieldError>}
        </Field>
        <Button type="submit" disabled={pending}>{pending ? 'Saving…' : 'Save project'}</Button>
      </form>
      <p role="status">{pending ? 'Saving project…' : saved ? 'Project saved.' : ''}</p>
    </div>
  );
}

export function ProjectDialog({ save }: { save: (name: string) => Promise<void> }) {
  const [open, setOpen] = useState(false);
  const [pending, setPending] = useState(false);
  return (
    <Dialog open={open} onOpenChange={next => { if (!pending) setOpen(next); }}>
      <DialogTrigger render={<Button>New project</Button>} />
      <DialogContent showCloseButton={!pending}>
        <DialogHeader><DialogTitle>New project</DialogTitle><DialogDescription className="text-foreground">Create a project in this workspace.</DialogDescription></DialogHeader>
        <ProjectForm save={save} onPendingChange={setPending} onSaved={() => setOpen(false)} />
      </DialogContent>
    </Dialog>
  );
}

export function DeleteProject({ name, remove }: { name: string; remove: () => Promise<void> }) {
  const [open, setOpen] = useState(false);
  const [pending, setPending] = useState(false);
  const [error, setError] = useState('');
  const [removed, setRemoved] = useState(false);
  const result = useRef<HTMLParagraphElement>(null);
  const inFlight = useRef(false);
  async function confirm() {
    if (inFlight.current) return;
    inFlight.current = true;
    setPending(true);
    setError('');
    try { await remove(); setOpen(false); setRemoved(true); }
    catch { setError('Deletion failed. The project is still available. Try again.'); }
    finally { inFlight.current = false; setPending(false); }
  }
  return (
    <div>
      <AlertDialog open={open} onOpenChange={next => { if (!pending) { setOpen(next); setError(''); } }}>
        <AlertDialogTrigger render={<Button variant="outline" disabled={removed}>Delete {name}</Button>} />
        <AlertDialogContent finalFocus={removed ? result : undefined}>
          <AlertDialogHeader aria-busy={pending}>
            <AlertDialogTitle>Delete {name}?</AlertDialogTitle>
            <AlertDialogDescription className="text-foreground">This permanently removes the project. This action cannot be undone.</AlertDialogDescription>
          </AlertDialogHeader>
          {error && <p role="alert">{error}</p>}
          <p role="status">{pending ? 'Deleting project…' : ''}</p>
          <AlertDialogFooter aria-busy={pending}>
            <AlertDialogCancel disabled={pending}>Keep project</AlertDialogCancel>
            <AlertDialogAction variant="destructive" disabled={pending} onClick={confirm}>Delete project</AlertDialogAction>
          </AlertDialogFooter>
        </AlertDialogContent>
      </AlertDialog>
      <p ref={result} tabIndex={-1} role="status">{removed ? `${name} deleted.` : ''}</p>
    </div>
  );
}

// Local fixture only: application-owned callbacks are the boundary to real APIs.
export function ApplicationPatternsDemo() {
  const [fail, setFail] = useState(false);
  const [tableState, setTableState] = useState<'loading' | 'empty' | 'error' | 'ready'>('ready');
  const save = async () => {
    await new Promise(resolve => setTimeout(resolve, 600));
    if (fail) throw new Error('Example failure');
  };
  return (
    <div className="grid w-full gap-4">
      <Button variant="outline" onClick={() => setFail(!fail)} aria-pressed={fail}>Simulate failures: {fail ? 'on' : 'off'}</Button>
      <div role="group" aria-label="Table example state" className="flex flex-wrap gap-2">
        {(['loading', 'empty', 'error', 'ready'] as const).map(state => (
          <Button key={state} variant="outline" aria-pressed={tableState === state} onClick={() => setTableState(state)}>{state}</Button>
        ))}
      </div>
      <ProjectTable state={tableState === 'loading' ? { status: 'loading' }
        : tableState === 'error' ? { status: 'error', retry: () => setTableState('ready') }
        : { status: 'ready', projects: tableState === 'empty' ? [] : [{ id: 'one', name: 'Expedition' }] }} />
      <ProjectDialog save={save} />
      <DeleteProject name="Expedition" remove={save} />
    </div>
  );
}

On this page