Table with Detail Panel
An order list whose rows open in a panel beside the table, so clicking down the list swaps what the panel shows. Below 1024px the same detail arrives as a drawer.
Live preview
OrdersPage.svelte
Order | Customer | Status | Total |
|---|---|---|---|
ORD-2418 | Nadia Osei | Shipped | €80.50 |
ORD-2419 | Tomás Ferreira | Paid | €79.00 |
ORD-2420 | Hanna Virtanen | Packing | €145.00 |
ORD-2421 | Marek Zieliński | Refunded | €210.00 |
ORD-2422 | Aline Dubois | Shipped | €85.50 |
ORD-2423 | Yusuf Demir | Paid | €124.00 |
<script lang="ts">
import { MediaQuery } from 'svelte/reactivity';
import { Avatar, Badge, Card, Drawer } from '@urbicon-ui/blocks';
import { Table, TableColumns, type Column } from '@urbicon-ui/table';
import { useI18n } from '@urbicon-ui/i18n';
type OrderStatus = 'paid' | 'packing' | 'shipped' | 'refunded';
interface OrderLine { name: string; qty: number; price: number }
interface Order {
id: string; customer: string; email: string; city: string;
status: OrderStatus; total: number; placedAt: string; lines: OrderLine[];
}
const orders: Order[] = [/* … your rows … */];
// StatusBadge knows eleven statuses (active, pending, processing, …). An
// order's four are not among them, so each one is named here.
const ORDER_STATUS = {
paid: { intent: 'success' as const, text: 'Paid', icon: true },
packing: { intent: 'warning' as const, text: 'Packing', icon: true },
shipped: { intent: 'primary' as const, text: 'Shipped', icon: true },
refunded: { intent: 'neutral' as const, text: 'Refunded', icon: false }
};
// Four columns, because the panel carries the rest: date, contact details and
// line items are what you read once, not what you scan by. The annotation is
// what checks the accessors. With Column<Order>[], a first argument that is
// not a primitive-valued key of the row is a type error.
//
// priority: 2 makes customer the second line of a mobile card. Below
// cardsBelow — measured on the table's own container, not the window — the
// grid is replaced by one card per row, and those cards fire onRowClick and
// carry the active rail exactly like the rows do.
const columns: Column<Order>[] = [
TableColumns.text('id', 'Order', { width: '110px', groupable: false, hideable: false }),
TableColumns.text('customer', 'Customer', { priority: 2 }),
TableColumns.status('status', 'Status', { statusMap: ORDER_STATUS }),
TableColumns.number('total', 'Total', { format: 'currency', currency: 'EUR', groupable: false })
];
/** The record the panel shows. */
let shown = $state<Order | null>(null);
/** Whether the drawer is up. It holds no record of its own — shown is the only one. */
let sheetOpen = $state(false);
// 1023px is Tailwind's lg step minus one: the lg: classes below own the
// layout, and this only decides whether the detail additionally gets a
// drawer. An open drawer locks page scroll and traps focus, so it has to be
// closed rather than hidden — a CSS class cannot do that.
const narrow = new MediaQuery('(max-width: 1023px)');
function openOrder(order: Order) {
shown = order;
// Only the drawer needs opening; the panel is already showing shown.
// Setting this unconditionally would arm a drawer that springs open the
// moment a wide window is narrowed.
sheetOpen = narrow.current;
}
// Locale comes from the <I18nProvider> at your app root, which is where the
// table's own number and date cells read it from. Without one it silently
// falls back to English.
const i18n = useI18n();
const money = $derived(new Intl.NumberFormat(i18n.locale, { style: 'currency', currency: 'EUR' }));
const day = $derived(new Intl.DateTimeFormat(i18n.locale, { dateStyle: 'medium', timeZone: 'UTC' }));
</script>
<!-- One detail, two hosts: the panel below and the drawer at the bottom both
render this. -->
{#snippet detail(order: Order)}
<div class="space-y-5">
<div class="flex items-center justify-between gap-3">
<Badge intent={ORDER_STATUS[order.status].intent}>{ORDER_STATUS[order.status].text}</Badge>
<span class="text-text-tertiary text-sm">{day.format(new Date(order.placedAt))}</span>
</div>
<div class="flex items-center gap-3">
<Avatar name={order.customer} size="md" />
<div class="min-w-0">
<p class="text-text-primary truncate font-medium">{order.customer}</p>
<p class="text-text-tertiary truncate text-sm">{order.email}</p>
<p class="text-text-tertiary text-sm">{order.city}</p>
</div>
</div>
<ul class="divide-border-hairline divide-y">
{#each order.lines as line (line.name)}
<li class="flex items-baseline justify-between gap-4 py-2">
<span class="text-text-primary text-sm">{line.qty} × {line.name}</span>
<span class="text-text-secondary text-sm tabular-nums">
{money.format(line.qty * line.price)}
</span>
</li>
{/each}
</ul>
<div class="border-border-default flex items-baseline justify-between border-t pt-3">
<span class="text-text-primary font-semibold">Total</span>
<span class="text-text-primary font-semibold tabular-nums">{money.format(order.total)}</span>
</div>
</div>
{/snippet}
<div class="grid gap-6 lg:grid-cols-[minmax(0,1fr)_20rem] lg:items-start">
<!-- The toolbar above the grid — search, filters, sort, grouping, summaries,
column visibility — is enableSmartFilter, on by default. Pass false for a
bare grid; the column headers keep sorting either way.
activeRowId is matched against item.id. Rows without one fall back to
their position in the items array, so the mark follows a different record
the next time you replace it. -->
<Table
items={orders}
{columns}
ariaLabel="Orders"
cardsBelow="28rem"
viewDefaults={{ pageSize: 6 }}
onRowClick={openOrder}
activeRowId={shown?.id ?? null}
/>
<!-- The panel is an elevated Card: the shadow lifts it off the page, no
border needed. Hidden below lg, where the Drawer takes over. -->
<aside class="hidden lg:block">
<Card variant="elevated">
<h2 class="text-text-primary mb-4 text-base font-semibold">
{shown ? 'Order ' + shown.id : 'Order detail'}
</h2>
{#if shown}
{@render detail(shown)}
{:else}
<p class="text-text-tertiary text-sm">Pick a row to read the order here.</p>
{/if}
</Card>
</aside>
</div>
<Drawer
open={narrow.current && sheetOpen}
onClose={() => (sheetOpen = false)}
title={shown ? 'Order ' + shown.id : ''}
placement="right"
size="lg"
>
{#if shown}{@render detail(shown)}{/if}
</Drawer>Two decisions
The drawer only exists below 1024px
There is no second column at that width, so the same detail snippet is handed to a Drawer. That one is modal: it covers the list, and
dismissing it is the way back.
A current row is not a selection
activeRowId gives the open row aria-current and a rail on its left edge. selectionMode means something else: a set someone marked
for a bulk action, and it adds a checkbox column to collect it. Use it when a toolbar button
acts on many orders at once; here one record is on screen, and the checkbox column would go
unused.
Which order is open is view state, like the sort and the page number. useUrlParam from @urbicon-ui/sveltekit-utils/url.svelte puts its id in
the address bar, so a link reopens the panel on the right record. For the table's own view
(search, sort, page, page size, filters, grouping) use bindViewToUrl, on URL State.