An account's details: each name beside its value where there is room, above it where there is not.
Layouts
A list of named values: each row is a term, its value and, if it has any, the row's actions. The list's own width decides the layout, not the screen's. From 30rem of the list's width the terms sit in a column beside their values, and under it each term stands above its value. A list beside a side panel or in a narrow card stacks on a wide screen, and every list stacks on a phone.
The list is a size container, so it takes its width from its column, never from its content, and has no width of its own to give. A block, a flex: 1 item or a 1fr grid track gives it one. A holder that sizes it from its content, such as a flex item without flex: 1, width: max-content or an auto grid track, must give it a width, or it draws nothing wide.
.details {
flex: 1;
}layout holds one layout at every width: horizontal or stacked. The term column is 30% of the row, which holds "Routing number" on one line from 30rem; set --description-list-term-size on the list for a wider or narrower one. Rows follow density, and a rule separates each row from the next.
Example
Example
- Name
- Everyday Visa
- Type
- Credit card
- Institution
- Northwind Bank
- Routing number
- 021000021
- Name
- Everyday Visa
- Type
- Credit card
- Institution
- Northwind Bank
- Routing number
- 021000021
- Name
- Everyday Visa
- Type
- Credit card
- Institution
- Northwind Bank
- Routing number
- 021000021
import { DescriptionList, DescriptionListItem } from '@snapshot-ui/react';
import type { CSSProperties, ReactElement } from 'react';
const DETAILS = [
<DescriptionListItem key="name" term="Name">
Everyday Visa
</DescriptionListItem>,
<DescriptionListItem key="type" term="Type">
Credit card
</DescriptionListItem>,
<DescriptionListItem key="institution" term="Institution">
Northwind Bank
</DescriptionListItem>,
<DescriptionListItem key="routing" term="Routing number" isNumeric>
021000021
</DescriptionListItem>,
];
/** A wider name column, for terms longer than the 30% default holds on one line. */
const WIDE_TERMS = { '--description-list-term-size': '40%' } as CSSProperties;
export default function DescriptionListBasic(): ReactElement {
return (
<div className="stack" style={{ width: '100%', minWidth: 0, gridTemplateColumns: 'minmax(0, 1fr)' }}>
{/* The list's own width decides: beside the values here, above them on a phone. */}
<DescriptionList>{DETAILS}</DescriptionList>
{/* In a narrow column the terms stand above their values, whatever the screen's width. */}
<div style={{ width: '100%', maxWidth: '18rem' }}>
<DescriptionList>{DETAILS}</DescriptionList>
</div>
{/* layout holds one layout at every width, and the custom property sets the name column. */}
<DescriptionList layout="horizontal" style={WIDE_TERMS}>
{DETAILS}
</DescriptionList>
</div>
);
}Actions
actions puts a row's controls in a cell of their own after the value: at the end of the row side by side, and at the end of the term's line when stacked. Name each control with its term, as GOV.UK names its "Change" links: it shows "Edit" and reads "Edit Nickname", with aria-label or visually hidden words. The name starts with the word it shows, so voice control finds it. A value that is missing takes no action beside "Not set": pass a link to add it as emptyValue, so it stands where the value would.
A status is a value too. Put its word in a Badge and the fix in a link after it: a bank feed that failed to sync is a failure, so its Badge is error, and an account that is open is neutral, never success, which means a save.
Example
Example
- Name
- Everyday Visa
- Nickname
- Travel card
- Bank feed
- FailedReconnect
- Status
- Active
'use client';
import {
Badge,
Button,
DescriptionList,
DescriptionListItem,
Dialog,
Link,
TextField,
} from '@snapshot-ui/react';
import { useState, type ReactElement } from 'react';
type Field = 'Name' | 'Nickname';
export default function DescriptionListActions(): ReactElement {
const [values, setValues] = useState<Record<Field, string>>({
Name: 'Everyday Visa',
Nickname: 'Travel card',
});
// The field stays set while the dialog leaves, so its title does not change on the way out.
const [field, setField] = useState<Field>('Name');
const [isOpen, setOpen] = useState(false);
const [draft, setDraft] = useState('');
const open = (row: Field): void => {
setDraft(values[row]);
setField(row);
setOpen(true);
};
/** An Edit for one row, shown as "Edit" and named with the term, so it reads "Edit Nickname". */
const edit = (row: Field): ReactElement => (
<Button
variant="secondary"
aria-label={`Edit ${row}`}
onPress={() => {
open(row);
}}
>
Edit
</Button>
);
return (
<>
<DescriptionList style={{ width: '100%' }}>
<DescriptionListItem term="Name" actions={edit('Name')}>
{values.Name}
</DescriptionListItem>
<DescriptionListItem term="Nickname" actions={edit('Nickname')}>
{values.Nickname}
</DescriptionListItem>
{/* A status: the word in a Badge, and the fix as a link after it. A failed feed is an error. */}
<DescriptionListItem term="Bank feed">
<span className="row">
<Badge tone="error">Failed</Badge>
<Link href="#reconnect">Reconnect</Link>
</span>
</DescriptionListItem>
{/* An account that is open is neutral: success means a save, not a state that is merely fine. */}
<DescriptionListItem term="Status">
<Badge>Active</Badge>
</DescriptionListItem>
</DescriptionList>
<Dialog
title={`Edit ${field}`}
isOpen={isOpen}
onOpenChange={setOpen}
actions={({ close }) => (
<>
<Button variant="secondary" onPress={close}>
Cancel
</Button>
<Button
onPress={() => {
setValues({ ...values, [field]: draft.trim() });
close();
}}
>
Save
</Button>
</>
)}
>
<TextField label={field} value={draft} onChange={setDraft} />
</Dialog>
</>
);
}A value to copy
A value the reader pastes elsewhere, such as a setup address, takes a copy button in its row's actions, named "Copy" and the term. It copies the string you pass it, which is in the page's HTML, so copy only what the row shows: never the whole digits behind a masked number. The list stays static; only the button is a client component.
Example
Example
- Provider
- Northwind Hooks
- Setup address
https://hooks.example.com/ ledger/ 7Fq2
'use client';
import { CopyButton, DescriptionList, DescriptionListItem } from '@snapshot-ui/react';
import type { ReactElement } from 'react';
const ADDRESS = 'https://hooks.example.com/ledger/7Fq2';
export default function CopyButtonDescriptionList(): ReactElement {
return (
<DescriptionList style={{ width: '100%' }}>
<DescriptionListItem term="Provider">Northwind Hooks</DescriptionListItem>
{/* In the row's actions, named "Copy" and the term, as every action in a list is named. */}
<DescriptionListItem
term="Setup address"
actions={<CopyButton value={ADDRESS} labels={{ copy: 'Copy Setup address' }} />}
>
{/* A break opportunity after each part, so a narrow column wraps the address between its words. */}
<code style={{ fontSize: 'var(--text-small)' }}>
https://
<wbr />
hooks.example.com/
<wbr />
ledger/
<wbr />
7Fq2
</code>
</DescriptionListItem>
</DescriptionList>
);
}Empty, long and numeric values
A value that is absent, null, false, true, an empty string, or an array of nothing but those shows "Not set" in slate. Pass emptyValue for your own words, or a link to add the value, as GOV.UK's summary list does: the link stands where the value would, and the row has no action of its own. 0 is a value.
A long value wraps, and a long unbroken word, such as an IBAN or an email address, breaks anywhere inside its column. Nothing is cut off with an ellipsis.
Set isNumeric on amounts, limits, percentages and the last four digits of a card: tabular figures, read left to right and starting at the line's start, on a Persian page too.
Example
Example
- Nickname
- Not set
- Notes
- Add a note
- Balance
- $0.00
- Billing address
- 12 Harbour Street, Flat 4, Northgate, Springfield, SP1 2AB, United Kingdom
- IBAN
- GB29NWBK60161331926819000000000000
- Credit limit
- $5,000.00
- APR
- 24.9%
- Last four
- 4821
import { DescriptionList, DescriptionListItem, Link } from '@snapshot-ui/react';
import type { ReactElement } from 'react';
export default function DescriptionListValues(): ReactElement {
return (
<DescriptionList style={{ width: '100%' }}>
{/* Empty: "Not set" in slate, never a dash or a blank. */}
<DescriptionListItem term="Nickname" />
{/* Or your own words, here a link to add the value. */}
<DescriptionListItem term="Notes" emptyValue={<Link href="#notes">Add a note</Link>} />
{/* Zero is a value. */}
<DescriptionListItem term="Balance" isNumeric>
$0.00
</DescriptionListItem>
{/* A long value wraps, and an unbroken one breaks anywhere. Nothing is cut off. */}
<DescriptionListItem term="Billing address">
12 Harbour Street, Flat 4, Northgate, Springfield, SP1 2AB, United Kingdom
</DescriptionListItem>
<DescriptionListItem term="IBAN">GB29NWBK60161331926819000000000000</DescriptionListItem>
{/* Amounts, limits, percentages and the last four digits: tabular, and left to right on a Persian page. */}
<DescriptionListItem term="Credit limit" isNumeric>
$5,000.00
</DescriptionListItem>
<DescriptionListItem term="APR" isNumeric>
24.9%
</DescriptionListItem>
<DescriptionListItem term="Last four" isNumeric>
4821
</DescriptionListItem>
</DescriptionList>
);
}In a card
Make the list a card's body for an account's details. A row whose controls would sit in the card's rounded corner, the last row of a list that ends the card or the first of one that starts it, grows by the card's corner inset, so its controls stand clear of the curve and stay on the column's end edge. The list does this itself: 7px more in Rounded, nothing in Soft and Square. It does so in a card's body, directly in a card, and at the end of a collapsible card's body. Stacked, the last row's controls stand on the term's line at the row's top, clear of the curve, so the row keeps its spacing.
Example
Example
Account information
- Name
- Everyday Visa
- Type
- Credit card
- Institution
- Northwind Bank
- Last four
- 4821
- Bank feed
- Connected
- Nickname
- Add a nickname
- APR
- 24.9%
import {
Badge,
Button,
Card,
CardActions,
CardBody,
CardHeader,
CardTitle,
DescriptionList,
DescriptionListItem,
Link,
} from '@snapshot-ui/react';
import type { ReactElement } from 'react';
/**
* The list is the card's body. Its last row's Edit ends the card, inside its bottom end curve, so the row takes
* the card's corner inset under it and Edit keeps its place on the column's end edge: 7px more in Rounded,
* nothing more in Soft and Square, and nothing while the list is stacked, where Edit is on the term's line. The
* list does this itself. The header's Edit, a secondary Button in CardActions, stands clear of the top end curve
* the same way: beside the title it moves down by the corner inset less the card's padding. An app opens its
* form from it, as the card page's Actions example does.
*/
export default function DescriptionListCard(): ReactElement {
return (
<Card
as="section"
aria-labelledby="description-list-card-title"
style={{ width: '100%', maxWidth: 560, marginInline: 'auto' }}
>
<CardHeader>
<CardTitle id="description-list-card-title" level={3}>
Account information
</CardTitle>
<CardActions>
<Button variant="secondary" aria-label="Edit details">
Edit
</Button>
</CardActions>
</CardHeader>
<CardBody>
<DescriptionList>
<DescriptionListItem term="Name">Everyday Visa</DescriptionListItem>
<DescriptionListItem term="Type">Credit card</DescriptionListItem>
<DescriptionListItem term="Institution">Northwind Bank</DescriptionListItem>
<DescriptionListItem term="Last four" isNumeric>
4821
</DescriptionListItem>
<DescriptionListItem term="Bank feed">
<Badge>Connected</Badge>
</DescriptionListItem>
{/* A missing value: a link to add it, in place of the value, and no action beside it. */}
<DescriptionListItem term="Nickname" emptyValue={<Link href="#nickname">Add a nickname</Link>} />
<DescriptionListItem term="APR" isNumeric>
24.9%
</DescriptionListItem>
<DescriptionListItem
term="Credit limit"
isNumeric
actions={
<Link href="#credit-limit" aria-label="Edit Credit limit">
Edit
</Link>
}
>
$5,000.00
</DescriptionListItem>
</DescriptionList>
</CardBody>
</Card>
);
}The Accounts recipe
An app's account pages, from the list to a card's credit tab, built from the library's parts. The example below is one client component, since this site is static: the three pages are ?account= and &tab=credit on this page's URL, read with useSearchParams inside a Suspense and opened through a RouterProvider whose navigate is router.push(href, { scroll: false }).
- Accounts. Four StatTiles sum up cash, investments, what is owed and net worth, with its change in words. Under them a DataTable per type lists each account: the bank's Avatar, its name and last four digits, which statements you hold, the bank feed as a neutral Badge, or an error Badge and a Reconnect link when it failed, a warning Badge for a possible duplicate, and the balance with its date. Each row opens its account.
- An account. A PageHeader with its trail, the balance as a LineChart, and the Details card: the list as its body and, in its header, Edit and a More menu (CardActions, at most two visible). Edit opens a Dialog to set a nickname; the empty Nickname row's "Add a nickname" opens the same form. More holds Merge, a Dialog, and Remove, a ConfirmDialog in the danger tone. Closing a dialog gives focus back to the button that opened it, and to Edit once a saved nickname has replaced the add link; removing the account opens the list with focus on its heading, never the page's body. Opening another page, an account from the list, the list from the trail, gives that page's heading focus; a tab keeps it on the tab.
- Credit, a card's second tab. The score is a Gauge: an
lgsemicircle on 300 to 850, its figure and its word, and marks at 580, 670, 740 and 800. The range's ends under it are the app's text, hidden from assistive technology since the meter reports the range, and a legend of the five bands follows. Fair and Poor are bothwarning, nevererror, which means something failed: the word under the figure and the arc's place between the marks tell them apart. Utilization is anmdring with its two thresholds in words, and under it a table of the cards with a 32px ring on each row, whose marks carry no words, so the thresholds are said once above the table. Statement coverage is a MonthGrid in a card, with its sentence and its hidden table.
In an app the account's page is a server component: the header, the Details card and its list, the gauges and the month grid render there with no client JavaScript, and the chart's svg and table render there too. Only the leaves that hold state are client components: the chart's keys and readout, the tables, the tabs, Edit's Dialog, the More menu and its dialogs. The server formats every figure, date and word, in the reader's language, before it reaches a part.
// app/accounts/[id]/page.tsx, a server component.
<Card as="section" aria-labelledby="details-title">
<CardHeader>
<CardTitle id="details-title" level={2}>
Details
</CardTitle>
<CardActions>
{/* 'use client' leaves: each opens its own Dialog. */}
<EditDetails account={account} />
<MoreActions account={account} />
</CardActions>
</CardHeader>
<CardBody>
<DescriptionList>
<DescriptionListItem term="Name">{account.name}</DescriptionListItem>
<DescriptionListItem term="Last four" isNumeric>
{account.lastFour}
</DescriptionListItem>
<DescriptionListItem term="Credit limit" isNumeric>
{format.money(account.creditLimit)}
</DescriptionListItem>
</DescriptionList>
</CardBody>
</Card>Example
Example
Accounts
Every account you hold, by type, with what its bank last sent.
Cash
$16,612.56
Checking and savings
Investments
$18,925.30
At their last price
Owed
$3,500.00
Across your cards
Net worth
$32,037.86
Up $1,204.50 since September
Cash and investments, less what you owe
Credit cards
| Bank | Account | Bank feed | Balance |
|---|---|---|---|
| Everyday Visa ending 4821 | Connected | $1,150.00 | |
| Travel Mastercard ending 0937 | FailedReconnect | $2,350.00 |
Checking
| Bank | Account | Bank feed | Balance |
|---|---|---|---|
| Everyday checking ending 3310 | Connected | $4,212.56 | |
| Checking ending 3310 | ConnectedPossible duplicate | $4,212.56 |
Savings
| Bank | Account | Bank feed | Balance |
|---|---|---|---|
| Rainy day savings ending 7745 | Connected | $12,400.00 |
Investments
| Bank | Account | Bank feed | Balance |
|---|---|---|---|
| Index fund ending 2290 | Connected | $18,925.30 |
'use client';
import { LineChart } from '@snapshot-ui/charts';
import {
Avatar,
Badge,
Breadcrumb,
Breadcrumbs,
Button,
Card,
CardActions,
CardBody,
CardHeader,
CardNote,
CardTitle,
ConfirmDialog,
DataTable,
DescriptionList,
DescriptionListItem,
Dialog,
Gauge,
Heading,
IconButton,
Link,
Menu,
MenuItem,
MenuTrigger,
MonthGrid,
PageColumn,
PageHeader,
RouterProvider,
StatTile,
Tab,
TabList,
TabPanel,
Tabs,
TextField,
type DataTableColumn,
} from '@snapshot-ui/react';
import { Ellipsis } from 'lucide-react';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import {
Suspense,
useCallback,
useEffect,
useId,
useRef,
useState,
type CSSProperties,
type ReactElement,
type RefObject,
} from 'react';
import {
ACCOUNT_TYPES,
ACCOUNTS,
BALANCE_DAYS,
balanceSeries,
balanceValue,
COVERAGE,
COVERAGE_STATES,
dollars,
MONTH_LABELS,
MONTH_NAMES,
ROW_MARKS,
SCORE,
SCORE_BANDS,
SCORE_MARKS,
scoreBand,
UTILIZATION_MARKS,
utilizationOf,
type Account,
} from './accounts-data';
/** What every view is given: the accounts the app holds, where each one lives, and what changes them. */
interface ViewProps {
accounts: readonly Account[];
listHref: string;
hrefOf: (id: string, tab?: 'credit') => string;
focusHeadingRef: RefObject<boolean>;
rename: (id: string, nickname: string) => void;
remove: (id: string) => void;
}
/** Gives focus to the heading a view opened with, once, after an action elsewhere moved the reader to it. */
function useHeadingFocus(header: RefObject<HTMLElement | null>, focusHeadingRef: RefObject<boolean>): void {
useEffect(() => {
if (!focusHeadingRef.current) return;
focusHeadingRef.current = false;
const heading = header.current?.querySelector<HTMLElement>('.page-header-title');
if (heading == null) return;
// A heading that takes focus from a script only, as a side panel's does.
heading.tabIndex = -1;
heading.focus();
}, [header, focusHeadingRef]);
}
/** A sum of balances, as the app's server totals them; the possible duplicate is counted once, as itself. */
const total = (accounts: readonly Account[], types: readonly Account['type'][]): number =>
accounts
.filter((account) => types.includes(account.type) && account.isPossibleDuplicate !== true)
.reduce((sum, account) => sum + balanceValue(account), 0);
/** The summary over the list: four StatTiles, net worth with its change. */
function Summary({ accounts }: { accounts: readonly Account[] }): ReactElement {
const cash = total(accounts, ['checking', 'savings']);
const investments = total(accounts, ['investment']);
const owed = total(accounts, ['credit']);
return (
<div className="tile-row">
<StatTile label="Cash" value={dollars(cash)} caption="Checking and savings" />
<StatTile label="Investments" value={dollars(investments)} caption="At their last price" />
<StatTile label="Owed" value={dollars(owed)} caption="Across your cards" />
<StatTile
label="Net worth"
value={dollars(cash + investments - owed)}
change={{ direction: 'up', label: 'Up $1,204.50 since September' }}
caption="Cash and investments, less what you owe"
/>
</div>
);
}
/** The bank feed in words: a neutral Badge while it syncs, an error Badge and the fix when it failed. */
function Feed({ account, href }: { account: Account; href: string }): ReactElement {
return (
<span
style={{
display: 'inline-flex',
flexWrap: 'wrap',
alignItems: 'center',
gap: '4px var(--spacing-part)',
}}
>
{account.feed === 'failed' ? (
<>
<Badge tone="error">Failed</Badge>
<Link href={href} aria-label={`Reconnect ${account.name}`}>
Reconnect
</Link>
</>
) : (
<Badge>Connected</Badge>
)}
{account.isPossibleDuplicate === true ? <Badge tone="warning">Possible duplicate</Badge> : null}
</span>
);
}
const nameOf = (account: Account): string => `${account.name} ending ${account.lastFour}`;
/** The Accounts page: the summary, then a table per type, each row opening its account. */
function AccountsView({ accounts, hrefOf, focusHeadingRef }: ViewProps): ReactElement {
const header = useRef<HTMLElement>(null);
useHeadingFocus(header, focusHeadingRef);
const id = useId();
const columns: readonly DataTableColumn<Account>[] = [
{
id: 'bank',
header: 'Bank',
isHeaderHidden: true,
width: '4rem',
// The bank's logo, named by the bank; a square, as a logo is. In the list face it leads the item's title.
cell: (account) => (
<Avatar name={account.institution} initials={account.initials} shape="square" size="sm" />
),
listSlot: 'title',
},
{
id: 'account',
header: 'Account',
isRowHeader: true,
cell: (account) => (
<>
{nameOf(account)}
<span className="data-table-cell-meta">{account.coverage}</span>
</>
),
textValue: nameOf,
},
{
id: 'feed',
header: 'Bank feed',
// Reconnect leads to the account, whose page an app gives the bank's sign-in.
cell: (account) => <Feed account={account} href={`${hrefOf(account.id)}#reconnect`} />,
textValue: (account) => (account.feed === 'failed' ? 'Failed' : 'Connected'),
listSlot: 'meta',
// Two Badges may not fit one line of a phone's list: the line wraps, and so does a Badge's word.
overflow: 'wrap',
},
{
id: 'balance',
header: 'Balance',
align: 'end',
cell: (account) => (
<>
{account.balance}
<span className="data-table-cell-meta">{account.balanceDate}</span>
</>
),
textValue: (account) => account.balance,
listSlot: 'end',
},
];
return (
<>
<PageHeader
ref={header}
// An example on a docs page, which has its own h1; a page's own header keeps the default, 1.
headingLevel={2}
title="Accounts"
description="Every account you hold, by type, with what its bank last sent."
/>
<PageColumn className="stack">
<Summary accounts={accounts} />
{ACCOUNT_TYPES.map((type) => {
const rows = accounts.filter((account) => account.type === type.id);
if (rows.length === 0) return null;
return (
<section key={type.id} aria-labelledby={`${id}-${type.id}`} className="stack" style={{ gap: 8 }}>
<Heading level={3} size={4} id={`${id}-${type.id}`}>
{type.label}
</Heading>
<DataTable
aria-labelledby={`${id}-${type.id}`}
items={rows}
columns={columns}
getKey={(account) => account.id}
getRowHref={(account) => hrefOf(account.id)}
/>
</section>
);
})}
</PageColumn>
</>
);
}
/** The balance over the last weeks, in a card. */
function Balance({ account }: { account: Account }): ReactElement {
const id = useId();
const end = balanceValue(account);
const top = Math.max(1000, Math.ceil(end / 1000) * 1000);
return (
<Card as="section" aria-labelledby={id}>
<CardHeader>
<CardTitle id={id} level={3}>
Balance
</CardTitle>
<CardNote>{`${account.balance} on ${account.balanceDate}`}</CardNote>
</CardHeader>
<LineChart
aria-labelledby={id}
series={[balanceSeries(end)]}
xLabels={BALANCE_DAYS}
yDomain={[0, top]}
yTicks={[
{ value: 0, label: '$0' },
{ value: top / 2, label: dollars(top / 2).replace('.00', '') },
{ value: top, label: dollars(top).replace('.00', '') },
]}
height={160}
/>
</Card>
);
}
/**
* The Details card: a server component in an app, the list of what the account is. Its Edit and its More menu
* are the client leaves: Edit opens a Dialog, More holds Merge (a Dialog) and Remove (a ConfirmDialog).
*/
function Details({
account,
reconnectHref,
rename,
remove,
}: { account: Account; reconnectHref: string } & Pick<ViewProps, 'rename' | 'remove'>): ReactElement {
const id = useId();
const [open, setOpen] = useState<'edit' | 'merge' | 'remove' | null>(null);
const [nickname, setNickname] = useState(account.nickname);
const editRef = useRef<HTMLButtonElement>(null);
// Which control opened Edit's form. "Add a nickname" leaves once a nickname is saved, so the Dialog cannot give
// focus back to it; Edit takes it instead, once the Dialog has closed and let focus go.
const openerRef = useRef<'button' | 'link'>('button');
const [refocus, setRefocus] = useState(false);
useEffect(() => {
if (!refocus || open !== null) return undefined;
// After the Dialog's own return of focus, which runs on the next frame.
let frame = requestAnimationFrame(() => {
frame = requestAnimationFrame(() => {
setRefocus(false);
editRef.current?.focus();
});
});
return () => {
cancelAnimationFrame(frame);
};
}, [refocus, open]);
const close = (isOpen: boolean): void => {
if (!isOpen) setOpen(null);
};
const edit = (opener: 'button' | 'link'): void => {
openerRef.current = opener;
setNickname(account.nickname);
setOpen('edit');
};
return (
<Card as="section" aria-labelledby={id}>
<CardHeader>
<CardTitle id={id} level={3}>
Details
</CardTitle>
{/* At most two visible actions: Edit, and the rest in More. */}
<CardActions>
<Button
ref={editRef}
variant="secondary"
aria-label="Edit details"
onPress={() => {
edit('button');
}}
>
Edit
</Button>
<MenuTrigger>
<IconButton aria-label="More actions" variant="outline">
<Ellipsis />
</IconButton>
<Menu
onAction={(key) => {
setOpen(key === 'merge' ? 'merge' : 'remove');
}}
>
<MenuItem id="merge">Merge</MenuItem>
<MenuItem id="remove" variant="danger">
Remove
</MenuItem>
</Menu>
</MenuTrigger>
</CardActions>
</CardHeader>
<CardBody>
<DescriptionList>
<DescriptionListItem term="Name">{account.name}</DescriptionListItem>
<DescriptionListItem term="Type">{account.typeLabel}</DescriptionListItem>
<DescriptionListItem term="Institution">{account.institution}</DescriptionListItem>
<DescriptionListItem term="Last four" isNumeric>
{account.lastFour}
</DescriptionListItem>
<DescriptionListItem term="Bank feed">
<Feed account={account} href={reconnectHref} />
</DescriptionListItem>
{account.creditLimit === undefined ? null : (
<DescriptionListItem term="Credit limit" isNumeric>
{account.creditLimit}
</DescriptionListItem>
)}
{account.apr === undefined ? null : (
<DescriptionListItem term="APR" isNumeric>
{account.apr}
</DescriptionListItem>
)}
{/* A missing value: a link to add it, where the value would be. It opens the same form as Edit. */}
<DescriptionListItem
term="Nickname"
emptyValue={
// With no address it is a button to assistive technology, and opens Edit's form.
<Link
onPress={() => {
edit('link');
}}
>
Add a nickname
</Link>
}
>
{account.nickname}
</DescriptionListItem>
</DescriptionList>
</CardBody>
<Dialog
isOpen={open === 'edit'}
onOpenChange={close}
title="Edit details"
description="A nickname shows beside the account's name in your lists."
actions={({ close: done }) => (
<>
<Button variant="secondary" onPress={done}>
Cancel
</Button>
<Button
onPress={() => {
const saved = nickname.trim();
rename(account.id, saved);
// The add link gives way to the nickname, so focus goes to Edit, never the page's body.
if (openerRef.current === 'link' && saved !== '') setRefocus(true);
done();
}}
>
Save details
</Button>
</>
)}
>
<TextField label="Nickname" value={nickname} onChange={setNickname} />
</Dialog>
<Dialog
isOpen={open === 'merge'}
onOpenChange={close}
title={`Merge ${account.name}`}
description="Its transactions move into the account you choose, and this one closes."
actions={({ close: done }) => (
<>
<Button variant="secondary" onPress={done}>
Cancel
</Button>
<Button onPress={done}>Merge accounts</Button>
</>
)}
/>
<ConfirmDialog
isOpen={open === 'remove'}
onOpenChange={close}
tone="danger"
title={`Remove ${account.name}?`}
description="Its transactions and its history leave every report. This cannot be undone."
confirmLabel={`Remove ${account.name}`}
onConfirm={() => {
remove(account.id);
}}
/>
</Card>
);
}
/**
* The range's ends, under the semicircle's ends: an lg dial is 160px wide and centred in the gauge, and its arc
* ends 80px down, so each figure sits just under its end. The meter reports the range already, so these repeat it
* for the eye only (spec correction 41).
*/
const END: CSSProperties = {
position: 'absolute',
insetBlockStart: 'calc(5rem + var(--spacing-part))',
fontSize: 'var(--text-small)',
lineHeight: 'var(--text-small--line-height)',
fontVariantNumeric: 'tabular-nums',
color: 'var(--color-slate)',
};
const SMALL: CSSProperties = {
margin: 0,
fontSize: 'var(--text-small)',
lineHeight: 'var(--text-small--line-height)',
};
/** The score: an lg semicircle on 300 to 850 with the bands' four edges as marks, its ends, and the bands in words. */
function Score(): ReactElement {
const id = useId();
const { caption, tone } = scoreBand(SCORE);
return (
<Card as="section" aria-labelledby={id}>
<CardHeader>
<CardTitle id={id} level={3}>
Credit score
</CardTitle>
<CardNote>From your bureau on Oct 1</CardNote>
</CardHeader>
<CardBody>
<div className="stack" style={{ justifyItems: 'center' }}>
<div style={{ position: 'relative', display: 'inline-grid' }}>
<Gauge
aria-labelledby={id}
form="semicircle"
size="lg"
minValue={300}
maxValue={850}
value={SCORE}
valueLabel={`${String(SCORE)} of 850, ${caption}`}
figure={String(SCORE)}
figureCaption={caption}
tone={tone}
marks={SCORE_MARKS}
/>
<span aria-hidden="true" style={{ ...END, insetInlineStart: 'calc(50% - 5rem)' }}>
300
</span>
<span aria-hidden="true" style={{ ...END, insetInlineEnd: 'calc(50% - 5rem)' }}>
850
</span>
</div>
{/* The bands said once: Fair and Poor are both warning, told apart by the word and the marks. */}
<ul
style={{
...SMALL,
display: 'flex',
flexWrap: 'wrap',
justifyContent: 'center',
gap: '4px var(--spacing-peer)',
padding: 0,
listStyle: 'none',
}}
>
{SCORE_BANDS.map(({ word, range }) => (
<li key={word}>
<span style={{ fontWeight: 500 }}>{word}</span>{' '}
<span style={{ color: 'var(--color-slate)', fontVariantNumeric: 'tabular-nums' }}>
{range}
</span>
</li>
))}
</ul>
</div>
</CardBody>
</Card>
);
}
/** Utilization: the cards' total as an md ring with its two thresholds and their words. */
function Utilization({ accounts }: { accounts: readonly Account[] }): ReactElement {
const id = useId();
const overall = utilizationOf(accounts.filter((account) => account.type === 'credit'));
const over = overall > 30;
return (
<Card as="section" aria-labelledby={id}>
<CardHeader>
<CardTitle id={id} level={3}>
Utilization
</CardTitle>
<CardNote>How much of your cards' limits you use</CardNote>
</CardHeader>
<CardBody>
<div style={{ display: 'grid', justifyItems: 'center' }}>
<Gauge
label="Across your cards"
value={overall}
valueLabel={`${String(overall)}% of your limits${over ? ', above 30%' : ''}`}
figure={`${String(overall)}%`}
// A word at md: the value text under the ring says the rest.
figureCaption={over ? 'High' : undefined}
tone={over ? 'warning' : 'neutral'}
marks={UTILIZATION_MARKS}
/>
</div>
</CardBody>
</Card>
);
}
/** Each card's utilization as a 32px ring in its row, the thresholds said once above the table. */
function Cards({ accounts }: { accounts: readonly Account[] }): ReactElement {
const id = useId();
const columns: readonly DataTableColumn<Account>[] = [
// A card's name wraps to two lines rather than lose its last four digits to an ellipsis on a phone.
{ id: 'card', header: 'Card', cell: nameOf, isRowHeader: true, overflow: 'wrap' },
{
id: 'utilization',
header: 'Utilization',
// Named with its row, since the column header names the cell; the value text beside it says the share.
cell: (card) => {
const used = card.utilization ?? 0;
return (
<Gauge
aria-label={`Utilization, ${nameOf(card)}`}
size="sm"
value={used}
valueLabel={used > 30 ? `${String(used)}%, above 30%` : `${String(used)}%`}
tone={used > 30 ? 'warning' : 'neutral'}
marks={ROW_MARKS}
/>
);
},
textValue: (card) => `${String(card.utilization ?? 0)}%`,
listSlot: 'meta',
overflow: 'wrap',
},
{ id: 'balance', header: 'Balance', cell: (card) => card.balance, align: 'end', listSlot: 'end' },
];
return (
<section aria-labelledby={id} style={{ display: 'grid', gap: 'var(--spacing-part)' }}>
<Heading level={3} size={4} id={id}>
Your cards
</Heading>
{/* The thresholds, said once: every row's ring draws them with no words. */}
<p className="copy copy-small" style={SMALL}>
Utilization marks: best under 10%, and keep it under 30%.
</p>
<DataTable
aria-labelledby={id}
items={accounts.filter((account) => account.type === 'credit')}
columns={columns}
getKey={(card) => card.id}
/>
</section>
);
}
/** Which statements the app holds for the card, a month to a cell, with the sentence and the hidden table. */
function Coverage({ account }: { account: Account }): ReactElement | null {
const id = useId();
const years = COVERAGE[account.id];
if (years === undefined) return null;
return (
<Card as="section" aria-labelledby={id}>
<CardHeader>
<CardTitle id={id} level={3}>
Statement coverage
</CardTitle>
</CardHeader>
<MonthGrid
aria-labelledby={id}
states={COVERAGE_STATES}
years={years}
monthLabels={MONTH_LABELS}
monthNames={MONTH_NAMES}
/>
</Card>
);
}
const GRID: CSSProperties = {
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(min(20rem, 100%), 1fr))',
gap: 'var(--spacing-separate)',
alignItems: 'start',
};
/** An account's page: its header, then Overview, and Credit for a card, chosen by the URL. */
function AccountView({
account,
tab,
listHref,
hrefOf,
accounts,
rename,
remove,
focusHeadingRef,
}: ViewProps & { account: Account; tab: 'overview' | 'credit' }): ReactElement {
const header = useRef<HTMLElement>(null);
useHeadingFocus(header, focusHeadingRef);
const overview = (
<div className="stack" style={{ gap: 'var(--spacing-separate)' }}>
<Balance account={account} />
<Details
account={account}
reconnectHref={`${hrefOf(account.id)}#reconnect`}
rename={rename}
remove={remove}
/>
</div>
);
return (
<>
<PageHeader
ref={header}
headingLevel={2}
breadcrumbs={
<Breadcrumbs>
<Breadcrumb href={listHref}>Accounts</Breadcrumb>
<Breadcrumb>{account.name}</Breadcrumb>
</Breadcrumbs>
}
title={account.nickname === '' ? account.name : `${account.name} (${account.nickname})`}
description={`${account.typeLabel} at ${account.institution}, ending ${account.lastFour}.`}
/>
<PageColumn>
{account.type === 'credit' ? (
// The URL holds the tab; the arrows move focus, and Enter or Space follows the focused tab's link.
<Tabs selectedKey={tab} keyboardActivation="manual">
<TabList aria-label={account.name}>
<Tab id="overview" href={hrefOf(account.id)}>
Overview
</Tab>
<Tab id="credit" href={hrefOf(account.id, 'credit')}>
Credit
</Tab>
</TabList>
{/* One panel, the route's content, with the selected key as its id. */}
<TabPanel id={tab}>
{tab === 'credit' ? (
<div className="stack" style={{ gap: 'var(--spacing-separate)' }}>
<div style={GRID}>
<Score />
<Utilization accounts={accounts} />
</div>
<Cards accounts={accounts} />
<Coverage account={account} />
</div>
) : (
overview
)}
</TabPanel>
</Tabs>
) : (
overview
)}
</PageColumn>
</>
);
}
/** The view the URL names: an account under `?account=`, its credit tab under `&tab=credit`, or the list. */
function Views({ params, ...props }: ViewProps & { params: URLSearchParams }): ReactElement {
const account = props.accounts.find((each) => each.id === params.get('account'));
if (account === undefined) return <AccountsView {...props} />;
const tab = account.type === 'credit' && params.get('tab') === 'credit' ? 'credit' : 'overview';
// Keyed by the account, so another one opens afresh.
return <AccountView key={account.id} {...props} account={account} tab={tab} />;
}
function ViewsFromUrl(props: ViewProps): ReactElement {
const params = useSearchParams();
return <Views {...props} params={new URLSearchParams(params)} />;
}
/** The first state: no parameters, as the static page's server renders it. */
const FIRST = new URLSearchParams();
export default function AccountsRecipe(): ReactElement {
const router = useRouter();
const pathname = usePathname();
const hrefOf = useCallback(
(id: string, tab?: 'credit'): string =>
`${pathname}?account=${encodeURIComponent(id)}${tab === undefined ? '' : `&tab=${tab}`}`,
[pathname],
);
// In an app the server holds these; the example keeps them in state so Save and Remove show their effect.
const [accounts, setAccounts] = useState<readonly Account[]>(ACCOUNTS);
const focusHeadingRef = useRef(false);
const props: ViewProps = {
accounts,
listHref: pathname,
hrefOf,
focusHeadingRef,
rename: (id, nickname) => {
setAccounts((current) => current.map((each) => (each.id === id ? { ...each, nickname } : each)));
},
remove: (id) => {
setAccounts((current) => current.filter((each) => each.id !== id));
// The list's heading takes focus once it is drawn, never the page's body.
focusHeadingRef.current = true;
router.push(pathname, { scroll: false });
},
};
return (
// An app wraps its whole tree once; the example wraps only itself.
<RouterProvider
navigate={(href) => {
// A link to another page, an account from the list, the list from its trail or another account, gives
// that page's heading focus once it is drawn; a tab, on the same account, keeps focus on the tab.
const account = (search: string): string | null => new URLSearchParams(search).get('account');
if (account(new URL(href, window.location.href).search) !== account(window.location.search)) {
focusHeadingRef.current = true;
}
router.push(href, { scroll: false });
}}
>
<div style={{ width: '100%', minWidth: 0 }}>
{/* The page is static, so the server renders the fallback: the Accounts page. */}
<Suspense fallback={<Views {...props} params={FIRST} />}>
<ViewsFromUrl {...props} />
</Suspense>
</div>
</RouterProvider>
);
}Usage
The list is static: a server component renders it with no client JavaScript. It is composed, so a value can be any node, such as a Badge or a link, and each row takes its own props. A DescriptionListItem belongs in a DescriptionList: on its own it is a div of a dt and dd with no list around them, which is invalid HTML. The library writes "Not set" in English, so a page in another language passes its own emptyValue, or wraps DescriptionListItem in a component that does.
import { DescriptionList, DescriptionListItem, Link } from '@snapshot-ui/react';
<DescriptionList>
<DescriptionListItem term="Name">Everyday Visa</DescriptionListItem>
<DescriptionListItem term="Credit limit" isNumeric>
$5,000.00
</DescriptionListItem>
<DescriptionListItem
term="Nickname"
emptyValue={<Link href="/accounts/visa/nickname">Add a nickname</Link>}
/>
</DescriptionList>;Props
Both parts accept every attribute of their element, a dl and a div.
children ReactNode | DescriptionListItem elements, in order. |
|---|---|
className string | |
hasDividers boolean | A rule between rows, never above the first or under the last. Default: true |
layout DescriptionListLayout |
Default: 'auto' |
actions ReactNode | The row's controls, such as an Edit button or link. Name each with the term, so it reads "Edit Nickname" while it shows "Edit". |
|---|---|
children ReactNode | The value. Absent, |
className string | |
emptyValue ReactNode | In place of an empty value: words in slate, or a link to add it, as GOV.UK's summary list does. A page in another language passes its own words. Default: 'Not set' |
isNumeric boolean | An amount, a limit, a percentage or an identifier in Latin digits: tabular figures, left to right and isolated in every direction, at the start of the line. Default: false |
term ReactNode | The name of the value: "Credit limit". |
Accessibility
The list is a real dl, each row a div holding a dt and its dds, which the HTML standard allows. No role is added: role="list" would drop the term and definition semantics. VoiceOver announces a description list and its terms; NVDA and JAWS announce less, and every reader reads each term and its value in order. Tab moves through the rows' actions in reading order; the list has no keys of its own. Name each action with its term, so a list of Edits reads as Edit Name, Edit Nickname.
Design rules
What keeps description lists in the system.
Keep terms short, a word or two.
Write each value in the reader's words, as they would say it.
Show "Not set", or a link to add the value, for a value that is missing.
Never show a dash or a blank for an empty value.
Never truncate a value. Let it wrap.
Never color a value to say its state. Use a Badge with its word.