W0 web foundation: Vite + React SPA under /app/, sync-pull ledger, auth, tokens, shared components
Build Kvitt images / build (push) Failing after 1h14m5s
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:
@@ -0,0 +1,7 @@
|
||||
.input {
|
||||
font: var(--type-amount-medium);
|
||||
}
|
||||
.unit {
|
||||
font: var(--type-title-medium);
|
||||
color: var(--on-surface-variant);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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]);
|
||||
}
|
||||
Reference in New Issue
Block a user