Files
accounted/components/common/DetailPageSkeleton.tsx
T
Jakob Wennberg 41424a1650 feat(ux): one silhouette from route fallback to detail content (#1944)
Opening a row on the customers, invoices, verifikat, supplier-invoice,
supplier, article and salary-run lists flashed three unrelated layouts:
the segment's list-shaped loading.tsx (or, for suppliers and articles, the
Hem-shaped dashboard fallback) during the RSC round trip, then the client
page's bare centred spinner in an h-64 box while it fetched, then the
content with a full layout change. Two flashes per click on the most
travelled drill-down path.

- components/common/DetailPageSkeleton.tsx: back link + title row + card
  grid + line block, the silhouette of a document/register detail page;
  InvoiceEditorSkeleton for the editor routes (same shape the Ny faktura
  dialog shows while its chunk loads).
- loading.tsx for every [id] segment (customers, invoices, invoices/edit,
  invoices/credit, bookkeeping, supplier-invoices, suppliers, articles,
  salary/runs) and for the two list segments that had none (suppliers,
  articles, cloned from customers/loading.tsx).
- The client pages render the same skeleton while they fetch instead of
  the centred Loader2, so the RSC fallback to client fallback handoff is
  invisible. The reference-data gates are already gone (A1 to A6); this
  only covers the primary-entity fetch.
- app/(dashboard)/__tests__/detail-loading-states.test.ts pins both.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 15:02:26 +02:00

87 lines
3.1 KiB
TypeScript

import { Skeleton } from '@/components/ui/skeleton'
import { cn } from '@/lib/utils'
interface DetailPageSkeletonProps {
/** Number of summary cards under the title row. */
cards?: 2 | 3
/** Full-width variant for MainContainer's wide routes (max-w-7xl). */
wide?: boolean
className?: string
}
/**
* Silhouette of a register/document detail page: back link, title row with
* action pills, then a card grid. Used both as the route-level loading.tsx of
* the [id] segments and as the client page's own loading state, so the
* handoff from the RSC fallback to the client fetch is a no-op visually
* instead of skeleton -> centred spinner -> content (three unrelated layouts
* on the most-travelled drill-down path).
*/
export function DetailPageSkeleton({ cards = 2, wide = false, className }: DetailPageSkeletonProps) {
return (
<div className={cn('space-y-8', className)} aria-busy="true" aria-live="polite">
{/* Back link */}
<Skeleton className="h-4 w-24" />
{/* Title (24px) + status pill + primary actions (pills) */}
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3">
<Skeleton className="h-8 w-56" />
<Skeleton className="h-6 w-20 rounded-full" />
</div>
<div className="flex gap-2">
<Skeleton className="h-9 w-28 rounded-full" />
<Skeleton className="h-9 w-24 rounded-full" />
</div>
</div>
{/* Card grid */}
<div
className={cn(
'grid gap-6',
cards === 3 ? 'md:grid-cols-3' : 'md:grid-cols-2',
wide && 'lg:grid-cols-[minmax(0,1fr)_280px]',
)}
>
{Array.from({ length: cards }, (_, i) => (
<div key={i} className="space-y-3 rounded-lg border border-border p-5">
<Skeleton className="h-3 w-28" />
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-4/5" />
<Skeleton className="h-4 w-3/5" />
</div>
))}
</div>
{/* Line/table block */}
<div>
<div className="flex h-10 items-center gap-4 border-b border-border px-4">
<Skeleton className="h-3 w-24" />
<Skeleton className="ml-auto h-3 w-14" />
</div>
{[1, 2, 3].map((i) => (
<div key={i} className="flex items-center justify-between gap-4 border-b border-border px-4 py-3 last:border-b-0">
<Skeleton className="h-4 w-48" />
<Skeleton className="h-4 w-20" />
</div>
))}
</div>
</div>
)
}
/**
* Silhouette of the invoice editor (single-column snabbflöde): heading, the
* customer/details block and the lines block. Matches the fallback the
* "Ny faktura" dialog shows while the editor chunk loads.
*/
export function InvoiceEditorSkeleton({ className }: { className?: string }) {
return (
<div className={cn('space-y-4', className)} aria-busy="true" aria-live="polite">
<Skeleton className="h-8 w-1/3" />
<Skeleton className="h-32 w-full rounded-lg" />
<Skeleton className="h-32 w-full rounded-lg" />
</div>
)
}