W0 web foundation: Vite + React SPA under /app/, sync-pull ledger, auth, tokens, shared components
Build Kvitt images / build (push) Failing after 1h14m5s

web/ is the browser client's scaffold: Vite 8, React 19, TypeScript 5.9 strict, React Router 7 (basename /app, every
fixed route with a lazy placeholder page), TanStack Query 5 (refetch on focus and every 60 s), openapi-typescript
types generated from contracts/openapi.yaml with openapi-fetch. Reads go through GET /sync (no REST reads exist for
the ledger, SPEC §7): useLedger() pulls from cursor 0 and then incrementally, restarting on a new epoch or
cursor_ahead. Login pairs the browser as "Web · <browser>" with an invite code (E-3); every call sends the SPEC
§14.3.1 headers, a 401 signs out. kvitt-domain's split, balances, allocation and settlement rules are ported to
web/src/lib and pass every case of contracts/test-vectors. Design tokens from design-spec §2 in tokens.css
(light-dark()), Material Symbols Rounded (Apache-2.0) as generated inline SVG, sv/en catalogs generated from
docs/ux/strings.csv plus web-strings.csv. Gate: cd web && npm run check.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
This commit is contained in:
2026-10-21 21:00:03 +02:00
parent 3fbd393082
commit 2ae65a5a9f
154 changed files with 15651 additions and 0 deletions
@@ -0,0 +1,7 @@
.input {
font: var(--type-amount-medium);
}
.unit {
font: var(--type-title-medium);
color: var(--on-surface-variant);
}
+36
View File
@@ -0,0 +1,36 @@
import { screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { useState } from 'react';
import { describe, expect, it } from 'vitest';
import type { Language } from '../i18n/language';
import { renderWithProviders } from '../test/render';
import { AmountField } from './AmountField';
function Controlled({ onOre }: { onOre: (ore: number | null) => void }) {
const [text, setText] = useState('');
return (
<AmountField
label="Belopp"
value={text}
onChange={(next, ore) => {
setText(next);
onOre(ore);
}}
/>
);
}
describe('AmountField', () => {
it.each<[Language, string]>([
['sv', 'kr'],
['en', 'SEK'],
])('converts typed text to öre and shows the unit (%s)', async (language, unit) => {
let ore: number | null = null;
renderWithProviders(<Controlled onOre={(value) => (ore = value)} />, { language });
const input = screen.getByLabelText('Belopp');
await userEvent.type(input, '1247,605-x');
expect(input).toHaveValue('1247,60');
expect(ore).toBe(124760);
expect(screen.getByText(unit)).toBeInTheDocument();
});
});
+44
View File
@@ -0,0 +1,44 @@
import { useI18n } from '../i18n/useI18n';
import { amountInputFromText, amountInputOre, moneyUnit } from '../lib/money';
import styles from './AmountField.module.css';
import { TextField } from './TextField';
interface Props {
label: string;
/** The text as typed ("1234,5"); the parent keeps it so editing never reformats under the caret. */
value: string;
/** Called with the new text and its öre value (null when empty). Keystrokes that break the rules are refused. */
onChange: (text: string, ore: number | null) => void;
error?: string | null;
supporting?: string;
maxOre?: number;
autoFocus?: boolean;
disabled?: boolean;
id?: string;
className?: string;
}
/**
* Amount entry (SPEC §9.6): digits, one decimal separator (`,` or `.`), at most two decimals, no sign, converted to öre
* by string parsing. Shows `kr` after (sv) or `SEK` before (en) the number, tabular figures.
*/
export function AmountField({ label, value, onChange, maxOre, ...rest }: Props) {
const { language } = useI18n();
const unit = <span className={styles.unit}>{moneyUnit(language)}</span>;
return (
<TextField
{...rest}
label={label}
value={value}
inputMode="decimal"
autoComplete="off"
inputClassName={`amount ${styles.input}`}
prefix={language === 'en' ? unit : undefined}
suffix={language === 'sv' ? unit : undefined}
onChange={(text) => {
const input = amountInputFromText(text, maxOre);
if (input !== null) onChange(text, amountInputOre(input));
}}
/>
);
}
+23
View File
@@ -0,0 +1,23 @@
.avatar {
display: inline-grid;
place-items: center;
flex: none;
border-radius: 50%;
overflow: hidden;
font-weight: 600;
line-height: 1;
user-select: none;
}
.slot1 {
background: var(--avatar-1);
color: var(--on-avatar-1);
}
.slot2 {
background: var(--avatar-2);
color: var(--on-avatar-2);
}
.picture {
width: 100%;
height: 100%;
object-fit: cover;
}
+21
View File
@@ -0,0 +1,21 @@
import { screen } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { renderWithProviders } from '../test/render';
import { Avatar, avatarSlot, initials } from './Avatar';
describe('Avatar', () => {
it('derives initials and slots (AC-4.5, design-spec §2.5)', () => {
expect(initials('Jacob Hagstedt')).toBe('JH');
expect(initials(' anna ')).toBe('A');
expect(initials('Åsa Öberg Lind')).toBe('ÅÖ');
expect(avatarSlot('b', ['b', 'a'])).toBe(2);
expect(avatarSlot('a', ['b', 'a'])).toBe(1);
});
it('shows initials, or the picture, and is announced only when not decorative', () => {
const { rerender, wrap } = renderWithProviders(<Avatar displayName="Anna" slot={2} decorative={false} />);
expect(screen.getByRole('img', { name: 'Anna' })).toHaveTextContent('A');
rerender(wrap(<Avatar displayName="Anna" slot={2} pictureUrl="blob:picture" />));
expect(screen.queryByRole('img', { name: 'Anna' })).not.toBeInTheDocument();
expect(document.querySelector('img')).toHaveAttribute('src', 'blob:picture');
});
});
+46
View File
@@ -0,0 +1,46 @@
import styles from './Avatar.module.css';
/** Initials of AC-4.5: the first letter of the first two words, uppercase. */
export function initials(displayName: string): string {
return displayName
.trim()
.split(/\s+/)
.slice(0, 2)
.map((word) => Array.from(word)[0] ?? '')
.join('')
.toUpperCase();
}
/**
* Avatar slot of design-spec §2.5: of the household's users, the id that sorts first (plain string order) is slot 1.
*/
export function avatarSlot(userId: string, householdUserIds: readonly string[]): 1 | 2 {
const first = [...householdUserIds].sort()[0];
return first === undefined || first === userId ? 1 : 2;
}
interface Props {
displayName: string;
slot: 1 | 2;
/** px; design-spec §3.3 sizes 20 to 128. */
size?: number;
/** Object URL of the picture (api/pictures.ts `useUserPicture`); initials while null. */
pictureUrl?: string | null;
/** Decorative when the name is printed beside it (default); otherwise announced by the display name. */
decorative?: boolean;
className?: string;
}
/** design-spec §3.3 `PersonAvatar`: picture, else initials on the slot colour at 44 % of the size. */
export function Avatar({ displayName, slot, size = 40, pictureUrl = null, decorative = true, className }: Props) {
const a11y = decorative ? { 'aria-hidden': true } : { role: 'img', 'aria-label': displayName };
return (
<span
className={[styles.avatar, slot === 1 ? styles.slot1 : styles.slot2, className].filter(Boolean).join(' ')}
style={{ width: size, height: size, fontSize: size * 0.44 }}
{...a11y}
>
{pictureUrl === null ? initials(displayName) : <img src={pictureUrl} alt="" className={styles.picture} />}
</span>
);
}
+79
View File
@@ -0,0 +1,79 @@
.button {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--s2);
border: none;
border-radius: var(--radius-full);
font: var(--type-label-large);
letter-spacing: var(--tracking-label);
text-decoration: none;
cursor: pointer;
transition:
background-color var(--motion-effects-fast),
color var(--motion-effects-fast);
white-space: nowrap;
}
.button:disabled,
.button[aria-disabled='true'] {
cursor: default;
color: var(--disabled-content);
background: var(--disabled-container);
box-shadow: none;
}
.small {
min-height: 40px;
padding: 0 var(--s6);
}
.medium {
min-height: 56px;
padding: 0 var(--s8);
}
.small:has(svg),
.medium:has(svg) {
padding-left: var(--s4);
}
.filled {
background: var(--primary);
color: var(--on-primary);
}
.filled:not(:disabled):hover {
background: color-mix(in srgb, var(--primary) 92%, var(--on-primary));
}
.tonal {
background: var(--secondary-container);
color: var(--on-secondary-container);
}
.tonal:not(:disabled):hover {
background: color-mix(in srgb, var(--secondary-container) 92%, var(--on-secondary-container));
}
.outlined {
background: transparent;
color: var(--primary);
box-shadow: inset 0 0 0 1px var(--outline);
}
.outlined:disabled {
background: transparent;
box-shadow: inset 0 0 0 1px var(--disabled-container);
}
.text {
background: transparent;
color: var(--primary);
padding: 0 var(--s3);
}
.text:disabled {
background: transparent;
}
.outlined:not(:disabled):hover,
.text:not(:disabled):hover {
background: color-mix(in srgb, var(--primary) 8%, transparent);
}
.danger {
background: var(--error);
color: var(--on-error);
}
@media (pointer: coarse) {
.small {
min-height: var(--touch-target);
}
}
+30
View File
@@ -0,0 +1,30 @@
import { screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it } from 'vitest';
import { Icon } from '../design/Icon';
import { renderWithProviders } from '../test/render';
import { Button, ButtonLink } from './Button';
describe('Button', () => {
it('renders a typed button with an icon and runs its action', async () => {
let clicks = 0;
renderWithProviders(
<Button icon={<Icon name="add" />} onClick={() => (clicks += 1)}>
Lägg till utgift
</Button>,
);
const button = screen.getByRole('button', { name: 'Lägg till utgift' });
expect(button).toHaveAttribute('type', 'button');
expect(button.querySelector('svg')).toHaveAttribute('aria-hidden', 'true');
await userEvent.click(button);
expect(clicks).toBe(1);
});
it('renders a link that looks like a button', () => {
renderWithProviders(<ButtonLink to="/settle">Settle up</ButtonLink>);
expect(screen.getByRole('link', { name: 'Settle up' })).toHaveAttribute('href', '/settle');
});
it('can be disabled', () => {
renderWithProviders(<Button disabled>Spara</Button>);
expect(screen.getByRole('button', { name: 'Spara' })).toBeDisabled();
});
});
+45
View File
@@ -0,0 +1,45 @@
import type { ButtonHTMLAttributes, ReactNode } from 'react';
import { Link, type LinkProps } from 'react-router';
import styles from './Button.module.css';
export type ButtonVariant = 'filled' | 'tonal' | 'outlined' | 'text' | 'danger';
export type ButtonSize = 'small' | 'medium';
interface Common {
variant?: ButtonVariant;
size?: ButtonSize;
/** Leading icon, usually an <Icon size={20} />. */
icon?: ReactNode;
}
export function buttonClassName(variant: ButtonVariant = 'filled', size: ButtonSize = 'small', extra?: string): string {
return [styles.button, styles[variant], styles[size], extra].filter(Boolean).join(' ');
}
/** design-spec §3.1 buttons: full radius, small 40 px or medium 56 px, label-large; 48 px target on touch screens. */
export function Button({
variant,
size,
icon,
className,
children,
type = 'button',
...rest
}: Common & ButtonHTMLAttributes<HTMLButtonElement>) {
return (
<button type={type} className={buttonClassName(variant, size, className)} {...rest}>
{icon}
{children}
</button>
);
}
/** A link that looks like a Button (navigation, not an action). */
export function ButtonLink({ variant, size, icon, className, children, ...rest }: Common & LinkProps) {
return (
<Link className={buttonClassName(variant, size, className)} {...rest}>
{icon}
{children}
</Link>
);
}
+20
View File
@@ -0,0 +1,20 @@
.card {
display: flex;
flex-direction: column;
gap: var(--s3);
padding: var(--s4) var(--s5);
border-radius: var(--radius-extra-large);
color: var(--on-surface);
}
.card {
background: var(--card);
box-shadow: var(--elevation-card);
}
.soft {
background: var(--surface-container-low);
box-shadow: none;
}
.title {
font: var(--type-title-medium);
letter-spacing: var(--tracking-title-medium);
}
+12
View File
@@ -0,0 +1,12 @@
import { screen } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { renderWithProviders } from '../test/render';
import { Card } from './Card';
describe('Card', () => {
it('renders a titled section', () => {
renderWithProviders(<Card title="Konton">Vardag</Card>);
expect(screen.getByRole('heading', { name: 'Konton' })).toBeInTheDocument();
expect(screen.getByText('Vardag').tagName).toBe('SECTION');
});
});
+22
View File
@@ -0,0 +1,22 @@
import type { ReactNode } from 'react';
import styles from './Card.module.css';
interface Props {
children: ReactNode;
/** Card title (title-medium), rendered as a heading. */
title?: string;
/** `card` (white with shadow in light) or `soft` (surface-container-low, no shadow), design-spec §2.10. */
tone?: 'card' | 'soft';
as?: 'section' | 'div' | 'article';
className?: string;
}
/** design-spec §2.9 content card: radius 28, padding 16 to 20. */
export function Card({ children, title, tone = 'card', as: Tag = 'section', className }: Props) {
return (
<Tag className={[styles.card, styles[tone], className].filter(Boolean).join(' ')}>
{title === undefined ? null : <h2 className={styles.title}>{title}</h2>}
{children}
</Tag>
);
}
@@ -0,0 +1,10 @@
.tile {
display: inline-grid;
place-items: center;
flex: none;
border-radius: var(--radius-category-tile);
}
.archived {
filter: saturate(25%);
opacity: 0.75;
}
+24
View File
@@ -0,0 +1,24 @@
import { screen } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { CATEGORY_ICON_TOKENS } from '../design/icons.generated';
import { renderWithProviders } from '../test/render';
import { CategoryIcon, COLOR_TOKENS } from './CategoryIcon';
describe('CategoryIcon', () => {
it('knows every icon and colour token of SPEC §9.7', () => {
expect(CATEGORY_ICON_TOKENS).toHaveLength(33);
expect(COLOR_TOKENS).toHaveLength(14);
});
it('renders a solid tile in the category colour, named when labelled', () => {
renderWithProviders(<CategoryIcon icon="shopping_cart" color="green" label="Mat & hushåll" />);
const tile = screen.getByRole('img', { name: 'Mat & hushåll' });
expect(tile.style.backgroundColor).toBe('var(--cat-green)');
expect(tile.style.color).toBe('var(--on-cat)');
});
it('falls back for unknown tokens and is decorative without a label', () => {
renderWithProviders(<CategoryIcon icon="rocket" color="neon" variant="container" />);
const tile = document.querySelector('[data-color]');
expect(tile).toHaveAttribute('data-color', 'grey');
expect(tile).toHaveAttribute('aria-hidden', 'true');
});
});
+65
View File
@@ -0,0 +1,65 @@
import { Icon } from '../design/Icon';
import { CATEGORY_ICON_TOKENS, type CategoryIconToken } from '../design/icons.generated';
import styles from './CategoryIcon.module.css';
/** Colour tokens of SPEC §9.7 (14, `slate` and `amber` by D-054). */
export const COLOR_TOKENS = [
'green',
'orange',
'brown',
'yellow',
'blue',
'teal',
'purple',
'red',
'pink',
'grey',
'indigo',
'lime',
'slate',
'amber',
] as const;
export type ColorToken = (typeof COLOR_TOKENS)[number];
export function isColorToken(value: string): value is ColorToken {
return (COLOR_TOKENS as readonly string[]).includes(value);
}
export function isCategoryIconToken(value: string): value is CategoryIconToken {
return (CATEGORY_ICON_TOKENS as readonly string[]).includes(value);
}
interface Props {
/** A §9.7 icon token; an unknown token shows `category`. */
icon: string;
/** A §9.7 colour token; an unknown token shows `grey`. */
color: string;
/** px; design-spec §3.4 sizes 32 to 64 (40 in ledger rows). */
size?: number;
/** Solid: colour fill, on-cat icon. Container: tinted fill, coloured icon (unselected picker tiles, settings rows). */
variant?: 'solid' | 'container';
archived?: boolean;
/** Announced name; decorative when omitted (the category name is printed beside it). */
label?: string;
className?: string;
}
/** design-spec §3.4 `CategoryTile`: rounded tile (30 %), filled icon at 52 % of the tile. */
export function CategoryIcon({ icon, color, size = 40, variant = 'solid', archived = false, label, className }: Props) {
const token: CategoryIconToken = isCategoryIconToken(icon) ? icon : 'category';
const colour: ColorToken = isColorToken(color) ? color : 'grey';
const style =
variant === 'solid'
? { width: size, height: size, backgroundColor: `var(--cat-${colour})`, color: 'var(--on-cat)' }
: { width: size, height: size, backgroundColor: `var(--cat-${colour}-container)`, color: `var(--cat-${colour})` };
return (
<span
className={[styles.tile, archived ? styles.archived : undefined, className].filter(Boolean).join(' ')}
style={style}
data-color={colour}
{...(label === undefined ? { 'aria-hidden': true } : { role: 'img', 'aria-label': label })}
>
<Icon name={token} filled size={Math.round(size * 0.52)} />
</span>
);
}
+37
View File
@@ -0,0 +1,37 @@
.scrim {
position: fixed;
inset: 0;
z-index: 100;
display: grid;
place-items: center;
padding: var(--gutter);
background: var(--scrim);
}
.panel {
width: min(560px, 100%);
max-height: calc(100vh - 2 * var(--gutter));
overflow: auto;
display: flex;
flex-direction: column;
gap: var(--s4);
padding: var(--s6);
border-radius: var(--radius-extra-large);
background: var(--surface-container-high);
color: var(--on-surface);
box-shadow: var(--elevation-3);
outline: none;
}
.title {
font: var(--type-headline-small);
}
.body {
font: var(--type-body-medium);
letter-spacing: var(--tracking-body-medium);
color: var(--on-surface-variant);
}
.actions {
display: flex;
justify-content: flex-end;
flex-wrap: wrap;
gap: var(--s2);
}
+57
View File
@@ -0,0 +1,57 @@
import { screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { useState } from 'react';
import { describe, expect, it } from 'vitest';
import { renderWithProviders } from '../test/render';
import { Button } from './Button';
import { Dialog } from './Dialog';
function Harness() {
const [open, setOpen] = useState(false);
return (
<>
<Button
onClick={() => {
setOpen(true);
}}
>
Ta bort
</Button>
<Dialog
open={open}
alert
title="Ta bort utgiften?"
onClose={() => {
setOpen(false);
}}
actions={
<Button
onClick={() => {
setOpen(false);
}}
>
Avbryt
</Button>
}
>
Den försvinner för er båda.
</Dialog>
</>
);
}
describe('Dialog', () => {
it('opens with focus inside, closes on Esc and returns focus', async () => {
renderWithProviders(<Harness />);
const user = userEvent.setup();
const trigger = screen.getByRole('button', { name: 'Ta bort' });
await user.click(trigger);
const dialog = screen.getByRole('alertdialog', { name: 'Ta bort utgiften?' });
expect(dialog).toHaveAttribute('aria-modal', 'true');
expect(dialog).toHaveTextContent('Den försvinner för er båda.');
expect(screen.getByRole('button', { name: 'Avbryt' })).toHaveFocus();
await user.keyboard('{Escape}');
expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument();
expect(trigger).toHaveFocus();
});
});
+85
View File
@@ -0,0 +1,85 @@
import { useEffect, useId, useRef, type ReactNode } from 'react';
import { createPortal } from 'react-dom';
import { useShortcut } from '../app/shortcuts';
import styles from './Dialog.module.css';
interface Props {
open: boolean;
title: string;
onClose: () => void;
children?: ReactNode;
/** Buttons, right-aligned at the bottom (design-spec: dismiss first, confirm last). */
actions?: ReactNode;
/** Role `alertdialog` for confirmations of destructive actions. */
alert?: boolean;
}
const FOCUSABLE =
'button:not([disabled]), [href], input:not([disabled]), select, textarea, [tabindex]:not([tabindex="-1"])';
/**
* A modal dialog (design-spec §2.9 radius 28, §2.10 level 3 over a 38 % scrim). Esc and a click on the scrim close it;
* focus moves into the dialog, Tab stays inside, and focus returns to where it was on close.
*/
export function Dialog({ open, title, onClose, children, actions, alert = false }: Props) {
const titleId = useId();
const panelRef = useRef<HTMLDivElement>(null);
useShortcut('Escape', onClose, { enabled: open });
useEffect(() => {
if (!open) return undefined;
const previous = document.activeElement instanceof HTMLElement ? document.activeElement : null;
const panel = panelRef.current;
const first = panel?.querySelector<HTMLElement>(FOCUSABLE);
(first ?? panel)?.focus();
return () => {
previous?.focus();
};
}, [open]);
if (!open) return null;
const trapTab = (event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key !== 'Tab' || panelRef.current === null) return;
const items = [...panelRef.current.querySelectorAll<HTMLElement>(FOCUSABLE)];
const first = items[0];
const last = items.at(-1);
if (first === undefined || last === undefined) {
event.preventDefault();
return;
}
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
};
return createPortal(
<div
className={styles.scrim}
onMouseDown={(event) => {
if (event.target === event.currentTarget) onClose();
}}
>
<div
ref={panelRef}
className={styles.panel}
role={alert ? 'alertdialog' : 'dialog'}
aria-modal="true"
aria-labelledby={titleId}
tabIndex={-1}
onKeyDown={trapTab}
>
<h2 id={titleId} className={styles.title}>
{title}
</h2>
{children === undefined ? null : <div className={styles.body}>{children}</div>}
{actions === undefined ? null : <div className={styles.actions}>{actions}</div>}
</div>
</div>,
document.body,
);
}
+26
View File
@@ -0,0 +1,26 @@
.root {
display: flex;
flex-direction: column;
align-items: center;
gap: var(--s3);
padding: var(--s8) var(--gutter);
text-align: center;
}
.tile {
display: grid;
place-items: center;
width: 72px;
height: 72px;
border-radius: 50%;
background: var(--primary-container);
color: var(--on-primary-container);
}
.title {
font: var(--type-headline-small);
}
.body {
max-width: 44ch;
font: var(--type-body-large);
letter-spacing: var(--tracking-body-large);
color: var(--on-surface-variant);
}
+30
View File
@@ -0,0 +1,30 @@
import { screen } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { useI18n } from '../i18n/useI18n';
import { renderWithProviders } from '../test/render';
import { ButtonLink } from './Button';
import { EmptyState } from './EmptyState';
function HomeEmpty() {
const { t } = useI18n();
return (
<EmptyState
icon="add"
title={t('home_empty_title')}
body={t('home_empty_subtitle')}
action={<ButtonLink to="/accounts/new">{t('home_new_account')}</ButtonLink>}
/>
);
}
describe('EmptyState', () => {
it('renders title, body and action in Swedish and English', () => {
renderWithProviders(<HomeEmpty />);
expect(screen.getByRole('heading', { name: 'Skapa ert första konto' })).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Nytt konto' })).toBeInTheDocument();
});
it('renders in English', () => {
renderWithProviders(<HomeEmpty />, { language: 'en' });
expect(screen.getByRole('heading', { name: 'Create your first account' })).toBeInTheDocument();
});
});
+26
View File
@@ -0,0 +1,26 @@
import type { ReactNode } from 'react';
import { Icon } from '../design/Icon';
import type { OutlineIconName } from '../design/icons.generated';
import styles from './EmptyState.module.css';
interface Props {
icon: OutlineIconName;
title: string;
body?: string;
/** Usually one Button or ButtonLink. */
action?: ReactNode;
}
/** Empty state: icon in a primary-container disc, headline-small title, body-large text, optional action. */
export function EmptyState({ icon, title, body, action }: Props) {
return (
<div className={styles.root}>
<span className={styles.tile} aria-hidden="true">
<Icon name={icon} size={32} />
</span>
<h2 className={styles.title}>{title}</h2>
{body === undefined ? null : <p className={styles.body}>{body}</p>}
{action}
</div>
);
}
@@ -0,0 +1,17 @@
.list {
display: flex;
flex-direction: column;
gap: var(--s3);
margin: 0;
}
.row {
display: grid;
grid-template-columns: 4rem 1fr;
align-items: center;
gap: var(--s3);
color: var(--on-surface);
font: var(--type-body-large);
}
.row dd {
margin: 0;
}
@@ -0,0 +1,24 @@
import { screen, within } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { renderWithProviders } from '../test/render';
import { KeyboardShortcutsHelp } from './KeyboardShortcutsHelp';
describe('KeyboardShortcutsHelp', () => {
it('lists the four global shortcuts', () => {
renderWithProviders(<KeyboardShortcutsHelp open onClose={() => undefined} />, { language: 'en' });
const dialog = screen.getByRole('dialog', { name: 'Keyboard shortcuts' });
for (const [key, label] of [
['N', 'New expense'],
['/', 'Search'],
['Esc', 'Close dialog or go back'],
['?', 'Show keyboard shortcuts'],
]) {
expect(within(dialog).getByText(key ?? '')).toBeInTheDocument();
expect(within(dialog).getByText(label ?? '')).toBeInTheDocument();
}
});
it('renders nothing when closed', () => {
renderWithProviders(<KeyboardShortcutsHelp open={false} onClose={() => undefined} />);
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
});
});
@@ -0,0 +1,40 @@
import { useI18n } from '../i18n/useI18n';
import type { StringKey } from '../i18n/catalog';
import { Button } from './Button';
import { Dialog } from './Dialog';
import styles from './KeyboardShortcutsHelp.module.css';
/** The global shortcuts of the web (owner decision for the web, 2026-10-21): N, /, Esc, ?. */
export const GLOBAL_SHORTCUTS: readonly { keys: string; label: StringKey }[] = [
{ keys: 'N', label: 'web_shortcut_new_expense' },
{ keys: '/', label: 'web_shortcut_search' },
{ keys: 'Esc', label: 'web_shortcut_close' },
{ keys: '?', label: 'web_shortcut_help' },
];
export function KeyboardShortcutsHelp({ open, onClose }: { open: boolean; onClose: () => void }) {
const { t } = useI18n();
return (
<Dialog
open={open}
title={t('web_shortcuts_title')}
onClose={onClose}
actions={
<Button variant="text" onClick={onClose}>
{t('common_close')}
</Button>
}
>
<dl className={styles.list}>
{GLOBAL_SHORTCUTS.map((shortcut) => (
<div key={shortcut.keys} className={styles.row}>
<dt>
<kbd>{shortcut.keys}</kbd>
</dt>
<dd>{t(shortcut.label)}</dd>
</div>
))}
</dl>
</Dialog>
);
}
+101
View File
@@ -0,0 +1,101 @@
.group {
display: flex;
flex-direction: column;
gap: var(--gap-segmented);
border-radius: var(--radius-group-outer);
box-shadow: var(--elevation-card);
}
.item {
border-radius: var(--radius-group-inner);
overflow: hidden;
background: var(--card);
}
.item:first-child {
border-top-left-radius: var(--radius-group-outer);
border-top-right-radius: var(--radius-group-outer);
}
.item:last-child {
border-bottom-left-radius: var(--radius-group-outer);
border-bottom-right-radius: var(--radius-group-outer);
}
.row {
display: flex;
align-items: center;
gap: 14px;
width: 100%;
min-height: var(--ledger-row-min);
padding: 10px var(--s4);
border: none;
background: var(--card);
color: var(--on-surface);
text-align: start;
text-decoration: none;
font: inherit;
}
.settlement {
background: var(--settlement-row);
}
.interactive {
cursor: pointer;
}
.interactive:hover {
background: color-mix(in srgb, var(--on-surface) 4%, var(--card));
}
.settlement.interactive:hover {
background: color-mix(in srgb, var(--on-surface) 4%, var(--settlement-row));
}
.centre {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
}
.title {
font: var(--type-title-medium);
letter-spacing: var(--tracking-title-medium);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.settlementTitle {
font: var(--type-title-medium);
letter-spacing: var(--tracking-title-medium);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.detail {
font: var(--type-body-medium);
letter-spacing: var(--tracking-body-medium);
}
.end {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 2px;
}
.meta {
font: var(--type-body-small);
letter-spacing: var(--tracking-body-small);
color: var(--on-surface-variant);
}
.owed {
color: var(--owed);
}
.owing {
color: var(--owing);
}
.muted {
color: var(--on-surface-variant);
}
.settlementTile {
display: inline-grid;
place-items: center;
flex: none;
width: 40px;
height: 40px;
border-radius: 50%;
background: var(--primary-container);
color: var(--on-primary-container);
}
+40
View File
@@ -0,0 +1,40 @@
import { screen } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
import { renderWithProviders } from '../test/render';
import { CategoryIcon } from './CategoryIcon';
import { LedgerRow, SegmentedGroup, SegmentedItem } from './LedgerRow';
describe('LedgerRow', () => {
it('renders an expense row with its effect line as a link', () => {
renderWithProviders(
<SegmentedGroup>
<SegmentedItem>
<LedgerRow
kind="expense"
to="/expenses/e1"
leading={<CategoryIcon icon="shopping_cart" color="green" />}
description="ICA Maxi Lindhagen"
amountOre={124760}
effect={{ kind: 'borrowed', ore: 62380 }}
date="16 okt."
/>
</SegmentedItem>
</SegmentedGroup>,
);
const link = screen.getByRole('link');
expect(link).toHaveAttribute('href', '/expenses/e1');
expect(link).toHaveTextContent('ICA Maxi Lindhagen');
// toHaveTextContent collapses white space, U+00A0 included.
expect(link).toHaveTextContent('Du lånade 623,80 kr');
expect(link).toHaveTextContent('16 okt.');
expect(screen.getByRole('listitem')).toBeInTheDocument();
});
it('renders a settlement row in English with the account count', () => {
renderWithProviders(
<LedgerRow kind="settlement" title="Anna paid you SEK 800.00" accountCount={2} date="6 Oct" />,
{ language: 'en' },
);
expect(screen.getByText('Anna paid you SEK 800.00 · 2 accounts')).toBeInTheDocument();
expect(screen.getByText('Payment')).toBeInTheDocument();
});
});
+123
View File
@@ -0,0 +1,123 @@
import type { ReactNode } from 'react';
import { Link } from 'react-router';
import { Icon } from '../design/Icon';
import { useI18n } from '../i18n/useI18n';
import type { ExpenseEffect } from '../lib/effect';
import { formatMoney } from '../lib/money';
import styles from './LedgerRow.module.css';
import { Money } from './Money';
interface Common {
/** Route to open (rendered as a link), or a click handler (rendered as a button); neither = static row. */
to?: string;
onClick?: () => void;
/** Date text in the row meta ("16 okt.", SPEC §9.8). */
date: string;
}
export type LedgerRowProps = Common &
(
| {
kind: 'expense';
/** The CategoryIcon tile (size 40, solid). */
leading: ReactNode;
description: string;
amountOre: number;
effect: ExpenseEffect;
}
| {
kind: 'settlement';
/** "Anna betalade dig 800,00 kr" (`ledger_settlement_*`), already formatted. */
title: string;
accountCount: number;
}
);
function EffectText({ effect }: { effect: ExpenseEffect }) {
const { t, language } = useI18n();
switch (effect.kind) {
case 'lent':
return <span className={styles.owed}>{t('effect_lent', formatMoney(effect.ore, language))}</span>;
case 'borrowed':
return <span className={styles.owing}>{t('effect_borrowed', formatMoney(effect.ore, language))}</span>;
case 'not_involved':
return <span className={styles.muted}>{t('effect_not_involved')}</span>;
case 'paid_for_yourself':
return <span className={styles.muted}>{t('effect_paid_for_yourself')}</span>;
}
}
/**
* design-spec §3.5 ledger rows (S-5, S-12, search): an expense (category tile, description, effect line, amount, date)
* or a settlement (handshake tile on the settlement tint, title, "Betalning", date). Items of a SegmentedGroup.
*/
export function LedgerRow(props: LedgerRowProps) {
const { t, tp } = useI18n();
const content =
props.kind === 'expense' ? (
<>
{props.leading}
<span className={styles.centre}>
<span className={styles.title}>{props.description}</span>
<span className={styles.detail}>
<EffectText effect={props.effect} />
</span>
</span>
<span className={styles.end}>
<Money ore={props.amountOre} amountStyle="small" />
<span className={styles.meta}>{props.date}</span>
</span>
</>
) : (
<>
<span className={styles.settlementTile} aria-hidden="true">
<Icon name="handshake" filled size={22} />
</span>
<span className={styles.centre}>
<span className={styles.settlementTitle}>
{props.title}
{props.accountCount > 1
? `${t('common_separator_dot')}${tp('ledger_settlement_accounts', props.accountCount)}`
: ''}
</span>
<span className={`${styles.detail} ${styles.muted}`}>{t('ledger_settlement_label')}</span>
</span>
<span className={styles.end}>
<span className={styles.meta}>{props.date}</span>
</span>
</>
);
const className = [styles.row, props.kind === 'settlement' ? styles.settlement : undefined].filter(Boolean).join(' ');
if (props.to !== undefined) {
return (
<Link to={props.to} className={`${className} ${styles.interactive}`}>
{content}
</Link>
);
}
if (props.onClick !== undefined) {
return (
<button type="button" onClick={props.onClick} className={`${className} ${styles.interactive}`}>
{content}
</button>
);
}
return <div className={className}>{content}</div>;
}
/** design-spec §3.5 `SegmentedGroup`: items 3 px apart, outer corners 24, inner 8, one card shadow. */
export function SegmentedGroup({ children, label }: { children: ReactNode; label?: string }) {
return (
<div className={styles.group} role="list" aria-label={label}>
{children}
</div>
);
}
export function SegmentedItem({ children }: { children: ReactNode }) {
return (
<div role="listitem" className={styles.item}>
{children}
</div>
);
}
+29
View File
@@ -0,0 +1,29 @@
.hero {
font: var(--type-amount-hero);
letter-spacing: var(--tracking-amount-hero);
}
.large {
font: var(--type-amount-large);
letter-spacing: var(--tracking-amount-large);
}
.medium {
font: var(--type-amount-medium);
}
.small {
font: var(--type-amount-small);
}
.neutral {
color: var(--on-surface);
}
.owed {
color: var(--owed);
}
.owing {
color: var(--owing);
}
.muted {
color: var(--on-surface-variant);
}
.settled {
color: var(--settled);
}
+20
View File
@@ -0,0 +1,20 @@
import { screen } from '@testing-library/react';
import { describe, expect, it } from 'vitest';
/** Keeps U+00A0 (Testing Library's default normaliser turns it into a space). */
const exact = { normalizer: (text: string) => text };
import { renderWithProviders } from '../test/render';
import { Money } from './Money';
describe('Money', () => {
it('formats per language with tabular figures', () => {
const { rerender, wrap } = renderWithProviders(<Money ore={124760} tone="owed" />);
expect(screen.getByText('1\u00A0247,60\u00A0kr', exact)).toHaveClass('amount');
rerender(wrap(<Money ore={124760} />));
expect(screen.getByText('1\u00A0247,60\u00A0kr', exact)).toBeInTheDocument();
});
it('uses SEK first in English', () => {
renderWithProviders(<Money ore={500} />, { language: 'en' });
expect(screen.getByText('SEK\u00A05.00', exact)).toBeInTheDocument();
});
});
+26
View File
@@ -0,0 +1,26 @@
import { useI18n } from '../i18n/useI18n';
import { formatMoney } from '../lib/money';
import styles from './Money.module.css';
export type AmountStyle = 'hero' | 'large' | 'medium' | 'small';
export type AmountTone = 'neutral' | 'owed' | 'owing' | 'muted' | 'settled';
/** design-spec §3.2 `MoneyText`: formatted per SPEC §9.8, never a sign, tabular figures, never ellipsized. */
export function Money({
ore,
amountStyle = 'small',
tone = 'neutral',
className,
}: {
ore: number;
amountStyle?: AmountStyle;
tone?: AmountTone;
className?: string;
}) {
const { language } = useI18n();
return (
<span className={['amount', styles[amountStyle], styles[tone], className].filter(Boolean).join(' ')}>
{formatMoney(ore, language)}
</span>
);
}
+41
View File
@@ -0,0 +1,41 @@
.group {
display: inline-flex;
gap: 2px;
max-width: 100%;
}
.item {
flex: 1 1 auto;
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--s1);
min-height: 40px;
padding: 0 var(--s4);
border: none;
border-radius: var(--radius-small);
background: var(--surface-container-high);
color: var(--on-surface-variant);
font: var(--type-label-large);
cursor: pointer;
white-space: nowrap;
transition:
background-color var(--motion-effects-default),
color var(--motion-effects-default);
}
.item:first-child {
border-top-left-radius: var(--radius-full);
border-bottom-left-radius: var(--radius-full);
}
.item:last-child {
border-top-right-radius: var(--radius-full);
border-bottom-right-radius: var(--radius-full);
}
.checked {
background: var(--secondary);
color: var(--on-secondary);
}
@media (pointer: coarse) {
.item {
min-height: var(--touch-target);
}
}
+35
View File
@@ -0,0 +1,35 @@
import { screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { useState } from 'react';
import { describe, expect, it } from 'vitest';
import { renderWithProviders } from '../test/render';
import { Segmented } from './Segmented';
function Harness() {
const [value, setValue] = useState<'month' | 'week'>('month');
return (
<Segmented
label="Granularitet"
value={value}
onChange={setValue}
options={[
{ value: 'month', label: 'Månad' },
{ value: 'week', label: 'Vecka' },
]}
/>
);
}
describe('Segmented', () => {
it('is a radio group that changes by click and arrow keys', async () => {
renderWithProviders(<Harness />);
const user = userEvent.setup();
expect(screen.getByRole('radiogroup', { name: 'Granularitet' })).toBeInTheDocument();
expect(screen.getByRole('radio', { name: 'Månad' })).toBeChecked();
await user.click(screen.getByRole('radio', { name: 'Vecka' }));
expect(screen.getByRole('radio', { name: 'Vecka' })).toBeChecked();
await user.keyboard('{ArrowRight}');
expect(screen.getByRole('radio', { name: 'Månad' })).toBeChecked();
expect(screen.getByRole('radio', { name: 'Månad' })).toHaveFocus();
});
});
+70
View File
@@ -0,0 +1,70 @@
import { useRef, type KeyboardEvent, type ReactNode } from 'react';
import styles from './Segmented.module.css';
export interface SegmentedOption<T extends string> {
value: T;
label: string;
icon?: ReactNode;
}
interface Props<T extends string> {
/** Accessible name of the group. */
label: string;
options: readonly SegmentedOption<T>[];
value: T;
onChange: (value: T) => void;
className?: string;
}
/**
* Connected single-choice buttons (design-spec §3.1 `ButtonGroup`, fallback segmented row): outer ends full radius,
* inner corners 8. A radio group: arrow keys move the choice.
*/
export function Segmented<T extends string>({ label, options, value, onChange, className }: Props<T>) {
const refs = useRef<(HTMLButtonElement | null)[]>([]);
const select = (index: number) => {
const count = options.length;
const option = options[((index % count) + count) % count];
if (option === undefined) return;
onChange(option.value);
refs.current[options.indexOf(option)]?.focus();
};
const onKeyDown = (event: KeyboardEvent<HTMLButtonElement>, index: number) => {
if (event.key === 'ArrowRight' || event.key === 'ArrowDown') {
event.preventDefault();
select(index + 1);
} else if (event.key === 'ArrowLeft' || event.key === 'ArrowUp') {
event.preventDefault();
select(index - 1);
}
};
return (
<div role="radiogroup" aria-label={label} className={[styles.group, className].filter(Boolean).join(' ')}>
{options.map((option, index) => {
const checked = option.value === value;
return (
<button
key={option.value}
ref={(element) => {
refs.current[index] = element;
}}
type="button"
role="radio"
aria-checked={checked}
tabIndex={checked ? 0 : -1}
className={checked ? `${styles.item} ${styles.checked}` : styles.item}
onClick={() => {
onChange(option.value);
}}
onKeyDown={(event) => {
onKeyDown(event, index);
}}
>
{option.icon}
{option.label}
</button>
);
})}
</div>
);
}
+56
View File
@@ -0,0 +1,56 @@
.root {
display: flex;
flex-direction: column;
gap: var(--s1);
}
.label {
font: var(--type-label-medium);
letter-spacing: var(--tracking-label-small);
color: var(--on-surface-variant);
}
.field {
display: flex;
align-items: center;
gap: var(--s2);
min-height: var(--row-min);
padding: 0 var(--s4);
border-radius: var(--radius-large);
background: var(--surface-container-high);
color: var(--on-surface);
}
.field:focus-within {
outline: 2px solid var(--primary);
outline-offset: -2px;
}
.input {
flex: 1;
min-width: 0;
border: none;
background: transparent;
outline: none;
padding: var(--s3) 0;
font: var(--type-body-large);
resize: vertical;
}
.input::placeholder {
color: var(--on-surface-variant);
opacity: 0.7;
}
.supporting,
.error {
font: var(--type-body-small);
letter-spacing: var(--tracking-body-small);
padding: 0 var(--s4);
}
.supporting {
color: var(--on-surface-variant);
}
.error {
color: var(--error);
}
.invalid .label {
color: var(--error);
}
.invalid .field {
box-shadow: inset 0 0 0 2px var(--error);
}
+32
View File
@@ -0,0 +1,32 @@
import { screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { useState } from 'react';
import { describe, expect, it } from 'vitest';
import { renderWithProviders } from '../test/render';
import { TextField } from './TextField';
function Controlled({ error }: { error?: string }) {
const [value, setValue] = useState('');
return (
<TextField label="Beskrivning" value={value} onChange={setValue} error={error} supporting="Högst 100 tecken" />
);
}
describe('TextField', () => {
it('labels the input and shows supporting text', async () => {
renderWithProviders(<Controlled />);
const input = screen.getByLabelText('Beskrivning');
await userEvent.type(input, 'ICA Maxi');
expect(input).toHaveValue('ICA Maxi');
expect(input).toHaveAccessibleDescription('Högst 100 tecken');
});
it('marks an error', () => {
renderWithProviders(<Controlled error="Skriv en beskrivning" />);
expect(screen.getByLabelText('Beskrivning')).toHaveAttribute('aria-invalid', 'true');
expect(screen.getByRole('alert')).toHaveTextContent('Skriv en beskrivning');
});
it('renders a multiline field', () => {
renderWithProviders(<TextField label="Notes" value="" onChange={() => undefined} multiline />);
expect(screen.getByLabelText('Notes').tagName).toBe('TEXTAREA');
});
});
+91
View File
@@ -0,0 +1,91 @@
import { useId, type InputHTMLAttributes, type ReactNode, type Ref } from 'react';
import styles from './TextField.module.css';
type NativeProps = Omit<InputHTMLAttributes<HTMLInputElement>, 'value' | 'onChange' | 'prefix'>;
export interface TextFieldProps extends NativeProps {
label: string;
value: string;
onChange: (value: string) => void;
/** Shown under the field in the error colour; also sets aria-invalid. */
error?: string | null;
/** Help text under the field when there is no error. */
supporting?: string;
/** Rendered before or after the input inside the field (units, icons). */
prefix?: ReactNode;
suffix?: ReactNode;
multiline?: boolean;
rows?: number;
ref?: Ref<HTMLInputElement & HTMLTextAreaElement>;
inputClassName?: string;
}
/** design-spec §2.9 text field: filled, no indicator line, radius 16, label above the value. */
export function TextField({
label,
value,
onChange,
error,
supporting,
prefix,
suffix,
multiline = false,
rows = 3,
id,
className,
inputClassName,
ref,
...rest
}: TextFieldProps) {
const generated = useId();
const inputId = id ?? generated;
const messageId = `${inputId}-message`;
const message = error ?? supporting;
const common = {
id: inputId,
value,
'aria-invalid': error ? true : undefined,
'aria-describedby': message ? messageId : undefined,
className: [styles.input, inputClassName].filter(Boolean).join(' '),
};
return (
<div className={[styles.root, error ? styles.invalid : undefined, className].filter(Boolean).join(' ')}>
<label className={styles.label} htmlFor={inputId}>
{label}
</label>
<div className={styles.field}>
{prefix}
{multiline ? (
<textarea
{...common}
ref={ref}
rows={rows}
maxLength={rest.maxLength}
placeholder={rest.placeholder}
disabled={rest.disabled}
required={rest.required}
name={rest.name}
onChange={(e) => {
onChange(e.target.value);
}}
/>
) : (
<input
{...rest}
{...common}
ref={ref}
onChange={(e) => {
onChange(e.target.value);
}}
/>
)}
{suffix}
</div>
{message ? (
<p id={messageId} className={error ? styles.error : styles.supporting} role={error ? 'alert' : undefined}>
{message}
</p>
) : null}
</div>
);
}
+51
View File
@@ -0,0 +1,51 @@
.region {
position: fixed;
left: 50%;
bottom: var(--s6);
transform: translateX(-50%);
z-index: 200;
width: min(560px, calc(100% - 2 * var(--gutter)));
pointer-events: none;
}
@media (max-width: 719.98px) {
.region {
bottom: calc(var(--nav-height) + var(--s3));
}
}
.toast {
pointer-events: auto;
display: flex;
align-items: center;
gap: var(--s2);
min-height: 48px;
padding: var(--s1) var(--s1) var(--s1) var(--s4);
border-radius: var(--radius-small);
background: var(--inverse-surface);
color: var(--inverse-on-surface);
box-shadow: var(--elevation-3);
}
.message {
flex: 1;
font: var(--type-body-medium);
letter-spacing: var(--tracking-body-medium);
padding: var(--s2) 0;
}
.action,
.close {
border: none;
background: transparent;
cursor: pointer;
border-radius: var(--radius-full);
min-height: 40px;
}
.action {
padding: 0 var(--s3);
color: var(--inverse-primary);
font: var(--type-label-large);
}
.close {
display: grid;
place-items: center;
width: 40px;
color: var(--inverse-on-surface);
}
+42
View File
@@ -0,0 +1,42 @@
import { screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { describe, expect, it } from 'vitest';
import { useI18n } from '../i18n/useI18n';
import { renderWithProviders } from '../test/render';
import { useToast } from './Toast';
function Trigger({ onUndo }: { onUndo: () => void }) {
const { t } = useI18n();
const show = useToast();
return (
<button
type="button"
onClick={() => {
show({ message: t('snackbar_payment_deleted'), action: { label: t('common_undo'), run: onUndo } });
}}
>
go
</button>
);
}
describe('Toast', () => {
it('announces a message with an action and closes after the action', async () => {
let undone = false;
renderWithProviders(<Trigger onUndo={() => (undone = true)} />);
const user = userEvent.setup();
await user.click(screen.getByRole('button', { name: 'go' }));
expect(screen.getByRole('status')).toHaveTextContent('Betalningen togs bort');
await user.click(screen.getByRole('button', { name: 'Ångra' }));
expect(undone).toBe(true);
expect(screen.getByRole('status')).toBeEmptyDOMElement();
});
it('can be dismissed in English', async () => {
renderWithProviders(<Trigger onUndo={() => undefined} />, { language: 'en' });
const user = userEvent.setup();
await user.click(screen.getByRole('button', { name: 'go' }));
expect(screen.getByRole('status')).toHaveTextContent('Payment deleted');
await user.click(screen.getByRole('button', { name: 'Close' }));
expect(screen.getByRole('status')).toBeEmptyDOMElement();
});
});
+86
View File
@@ -0,0 +1,86 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from 'react';
import { Icon } from '../design/Icon';
import { useI18n } from '../i18n/useI18n';
import styles from './Toast.module.css';
export interface ToastOptions {
message: string;
/** One action, e.g. `common_undo` or `common_retry`. */
action?: { label: string; run: () => void };
}
interface ToastState extends ToastOptions {
id: number;
}
/** Material snackbar durations (Compose `SnackbarDuration.Short` / `.Long`): 4 s, 10 s with an action. */
const DURATION_MS = 4_000;
const DURATION_WITH_ACTION_MS = 10_000;
const ToastContext = createContext<((options: ToastOptions) => void) | null>(null);
/** Shows one snackbar at a time at the bottom (a newer one replaces the current), announced politely. */
export function ToastProvider({ children }: { children: ReactNode }) {
const [toast, setToast] = useState<ToastState | null>(null);
const show = useCallback((options: ToastOptions) => {
setToast((current) => ({ ...options, id: (current?.id ?? 0) + 1 }));
}, []);
useEffect(() => {
if (toast === null) return undefined;
const timer = window.setTimeout(
() => {
setToast(null);
},
toast.action === undefined ? DURATION_MS : DURATION_WITH_ACTION_MS,
);
return () => {
window.clearTimeout(timer);
};
}, [toast]);
return (
<ToastContext.Provider value={show}>
{children}
<div className={styles.region} role="status" aria-live="polite">
{toast === null ? null : (
<ToastView
key={toast.id}
toast={toast}
onDismiss={() => {
setToast(null);
}}
/>
)}
</div>
</ToastContext.Provider>
);
}
function ToastView({ toast, onDismiss }: { toast: ToastState; onDismiss: () => void }) {
const { t } = useI18n();
return (
<div className={styles.toast}>
<span className={styles.message}>{toast.message}</span>
{toast.action === undefined ? null : (
<button
type="button"
className={styles.action}
onClick={() => {
toast.action?.run();
onDismiss();
}}
>
{toast.action.label}
</button>
)}
<button type="button" className={styles.close} aria-label={t('common_close')} onClick={onDismiss}>
<Icon name="close" size={20} />
</button>
</div>
);
}
export function useToast(): (options: ToastOptions) => void {
const show = useContext(ToastContext);
if (show === null) throw new Error('useToast outside ToastProvider');
return useMemo(() => show, [show]);
}