Merge branch 'ui_ux' of github.com:Tria-plc/emaui into WorkflowChange

This commit is contained in:
Nati
2026-08-21 17:36:09 +00:00
92 changed files with 3024 additions and 2446 deletions

View File

@@ -9,6 +9,7 @@ export * from "./lib/feedback/FeatureUnavailable";
export * from "./lib/feedback/EmptyState";
export * from "./lib/feedback/ErrorState";
export * from "./lib/feedback/PageLoader";
export * from "./lib/feedback/StatusBadge";
export * from "./lib/components/MaritimeLoader";
export * from "./lib/theme/maritime-loader-theme";
export * from "./lib/layout/AppHeader";
@@ -19,13 +20,17 @@ export * from "./lib/layout/BrandAvatar";
export * from "./lib/layout/ColorSchemeToggle";
export * from "./lib/layout/LanguageSwitcher";
export * from "./lib/layout/PageHeader";
export * from "./lib/layout/SkipLink";
export * from "./lib/input/PasswordRequirements";
export * from "./lib/input/CountrySelect";
export * from "./lib/input/PhoneInput";
export * from "./lib/input/phone";
export * from "./lib/data/AdvancedTable";
export * from "./lib/data/WaitingFor";
export * from "./lib/data/StatTile";
export * from "./lib/feedback/use-error-handler";
export * from "./lib/data/useServerTable";
export * from "./lib/landing/LandingPage";
export * from "./lib/landing/landing-copy";
export * from "./lib/utils/person-name";
export * from "./lib/dev/ThemeGallery";

View File

@@ -51,6 +51,10 @@ interface AdvancedTableProps<T> {
rowStyle?: (row: T, index: number) => CSSProperties | undefined;
/** Makes rows clickable (adds pointer cursor). */
onRowClick?: (row: T) => void;
/** Card title, top-left. Defaults to `tableName`, which every caller already passes. */
title?: ReactNode;
/** Search box, filters, export — rendered top-right before Refresh/View. */
toolbar?: ReactNode;
}
function getByPath(obj: unknown, path?: string): unknown {
@@ -82,6 +86,8 @@ export function AdvancedTable<T extends { id?: string | number }>({
verticalSpacing = "sm",
rowStyle,
onRowClick,
title,
toolbar,
}: AdvancedTableProps<T>) {
const { t } = useTranslation();
const [visible, setVisible] = useState<boolean[]>(
@@ -94,13 +100,22 @@ export function AdvancedTable<T extends { id?: string | number }>({
});
const shownColumns = columns.filter((_, i) => visible[i] ?? true);
const heading = title ?? tableName;
const from = itemCount === 0 ? 0 : pageIndex * pageSize + 1;
const to = Math.min(itemCount, pageIndex * pageSize + data.length);
return (
<Paper withBorder radius="md" p="md">
<Group justify="space-between" mb="md">
<Paper withBorder radius="lg" p={0}>
<Group justify="space-between" px="md" py="sm" wrap="wrap" gap="sm">
<Group gap="xs">
<Text fw={600}>{""}</Text>
{heading && (
<Text fw={600} size="sm">
{heading}
</Text>
)}
</Group>
<Group gap="xs">
<Group gap="xs" wrap="wrap">
{toolbar}
{refresh && (
<Button
variant="default"
@@ -162,13 +177,7 @@ export function AdvancedTable<T extends { id?: string | number }>({
</Group>
<Table.ScrollContainer minWidth={480}>
<Table
striped
highlightOnHover
withTableBorder
withColumnBorders
verticalSpacing={verticalSpacing}
>
<Table verticalSpacing={verticalSpacing}>
<Table.Thead>
<Table.Tr>
{shownColumns.map((col, i) => (
@@ -230,8 +239,21 @@ export function AdvancedTable<T extends { id?: string | number }>({
</Table>
</Table.ScrollContainer>
{(itemCount > pageSize || onPageSizeChange) && (
<Group justify="flex-end" mt="md">
<Group
justify="space-between"
px="md"
py="sm"
style={{ borderTop: "1px solid var(--ema-border-subtle)" }}
>
<Text size="xs" c="dimmed">
{t("common.showingRange", {
from,
to,
total: itemCount,
defaultValue: "Showing {{from}}{{to}} of {{total}}",
})}
</Text>
<Group gap="sm">
{onPageSizeChange && (
<Select
size="sm"
@@ -253,7 +275,7 @@ export function AdvancedTable<T extends { id?: string | number }>({
/>
)}
</Group>
)}
</Group>
</Paper>
);
}

View File

@@ -0,0 +1,83 @@
import type { ReactNode } from 'react';
import { Group, Paper, Text, ThemeIcon, UnstyledButton } from '@mantine/core';
import type { Icon } from '@tabler/icons-react';
import { STATUS_TONE_COLOR, type StatusTone } from '@ema-platform/shared';
import './stat-tile.css';
export interface StatTileProps {
label: string;
/** The number itself. A string so callers can pass "—" while loading. */
value: ReactNode;
/** One line under the value: what the number means, or how it is trending. */
hint?: ReactNode;
icon?: Icon;
/**
* Which tone the icon carries. Tone rather than colour so a tile counting
* overdue work is the same red as an overdue badge.
*/
tone?: StatusTone;
/** Makes the whole tile a button — use when the number has somewhere to go. */
onClick?: () => void;
}
/**
* One figure on a dashboard.
*
* The four stat cards on the backoffice home were `<Card>` + two `<Text>`,
* re-declared inline on every dashboard that wanted them — so the logistics
* overview and the backoffice home showed the same kind of number at different
* sizes. The number leads, the label sits above it small and quiet, and the
* icon is decoration that carries the tone.
*
* A tile with `onClick` becomes a real button: dashboards exist to be a
* jumping-off point, and a count you cannot click is a dead end.
*/
export function StatTile({ label, value, hint, icon: TileIcon, tone, onClick }: StatTileProps) {
const body = (
<>
<Group justify="space-between" align="flex-start" wrap="nowrap">
<Text size="xs" c="dimmed" tt="uppercase" fw={700} lh={1.4}>
{label}
</Text>
{TileIcon && (
<ThemeIcon
size={38}
radius="md"
variant="light"
color={tone ? STATUS_TONE_COLOR[tone] : undefined}
>
<TileIcon size={20} stroke={1.7} />
</ThemeIcon>
)}
</Group>
<Text fz={30} fw={800} lh={1.15} mt="xs">
{value}
</Text>
{hint && (
<Text size="xs" c="dimmed" mt={2}>
{hint}
</Text>
)}
</>
);
if (!onClick) {
return (
<Paper withBorder radius="lg" p="lg">
{body}
</Paper>
);
}
return (
<UnstyledButton
onClick={onClick}
className="ema-stat-tile-clickable"
style={{ display: 'block', width: '100%', height: '100%' }}
>
<Paper withBorder radius="lg" p="lg" h="100%">
{body}
</Paper>
</UnstyledButton>
);
}

View File

@@ -0,0 +1,83 @@
import { Text, Tooltip } from '@mantine/core';
import { statusToneVars } from '@ema-platform/shared';
export interface WaitingForProps {
/** When the clock started — normally the moment the applicant submitted. */
since: string | null | undefined;
/**
* Days after which the wait reads as overdue. Amber at half of it, red past
* it. Defaults to 14, which is the shortest SLA any configured licence type
* currently uses; pass the real one where a queue knows it.
*/
slaDays?: number;
/** Set once the item is decided — a closed item is not waiting for anyone. */
done?: boolean;
}
/**
* Fraction of the target elapsed before the wait reads as at-risk. Matches the
* licence queue's own `WARNING_RATIO`, so a queue using this component and one
* using `computeSla` turn amber at the same point rather than disagreeing.
*/
const WARNING_RATIO = 0.7;
/** Whole days between `since` and now, floored. Negative clock skew reads as 0. */
function daysSince(since: string): number {
const ms = Date.now() - new Date(since).getTime();
return Math.max(0, Math.floor(ms / 86_400_000));
}
/**
* How long an item has been sitting in a queue.
*
* No review queue showed this. An officer opening a list of thirty
* registrations could see what each one *was*, but not which had been waiting
* three days and which had been waiting three weeks — so the queue was worked
* top-down by whatever the sort happened to be rather than by urgency.
*
* Colour comes from the platform's status tones rather than its own scale, so
* "overdue" here is the same red as "rejected" everywhere else.
*/
export function WaitingFor({ since, slaDays = 14, done }: WaitingForProps) {
if (!since) {
return (
<Text size="sm" c="dimmed">
</Text>
);
}
const days = daysSince(since);
// A decided item keeps its elapsed time visible — useful when reviewing how
// long something took — but never coloured, because nothing is pending.
const tone = done
? 'neutral'
: days >= slaDays
? 'danger'
: days >= slaDays * WARNING_RATIO
? 'pending'
: 'neutral';
const label = days === 0 ? 'today' : `${days}d`;
return (
<Tooltip
label={
done
? `Took ${days} day${days === 1 ? '' : 's'}`
: `Waiting ${days} day${days === 1 ? '' : 's'} · ${slaDays}-day target`
}
withArrow
>
<Text
size="sm"
fw={tone === 'neutral' ? 400 : 600}
c={tone === 'neutral' ? 'dimmed' : undefined}
style={tone === 'neutral' ? undefined : { color: statusToneVars(tone).color }}
>
{label}
</Text>
</Tooltip>
);
}

View File

@@ -0,0 +1,16 @@
/*
* A clickable StatTile. The hover cue lives here rather than in inline style
* handlers so it can use a real `:hover` — a JS mouseenter/leave pair misses
* keyboard focus, and `:focus-visible` needs the app's focus ring anyway.
*/
.ema-stat-tile-clickable > * {
transition: border-color 120ms ease, box-shadow 120ms ease;
}
.ema-stat-tile-clickable:hover > * {
border-color: var(--mantine-primary-color-filled);
}
.ema-stat-tile-clickable:focus-visible > * {
border-color: var(--mantine-primary-color-filled);
}

View File

@@ -0,0 +1,362 @@
import {
SimpleGrid,
Alert,
Anchor,
Badge,
Box,
Button,
Card,
Checkbox,
Divider,
Group,
Paper,
Radio,
Select,
Stack,
Switch,
Table,
Tabs,
Text,
Textarea,
TextInput,
ThemeIcon,
Title,
useMantineTheme,
} from '@mantine/core';
import { IconAlertTriangle, IconInbox } from '@tabler/icons-react';
import { SkipLink, MAIN_CONTENT_ID } from '../layout/SkipLink';
import { StatusBadge } from '../feedback/StatusBadge';
import { WaitingFor } from '../data/WaitingFor';
import { StatTile } from '../data/StatTile';
/**
* Fixed clock. The gallery is screenshotted by the visual suite, so a tile
* reading "3d" must read "3d" tomorrow too — `Date.now()` would rewrite the
* baseline every day.
*/
const GALLERY_NOW = '2026-08-21T00:00:00.000Z';
const daysAgo = (n: number) =>
new Date(Date.parse(GALLERY_NOW) - n * 86_400_000).toISOString();
/**
* Every primitive the theme controls, on one page.
*
* This is the visual-regression surface for theme work. A real feature page
* renders a handful of primitives and only their happy states; a theme change
* that breaks `Button disabled` or `TextInput error` would sail past it. This
* renders all of them, including the states nothing else shows, so a screenshot
* diff says precisely what a theme edit did.
*
* Deliberately free of data, auth and network so it cannot flake: mounted at
* `/__gallery` outside the protected routes, it needs no API and no database.
*
* Not part of the product. Excluded from the nav on purpose.
*/
const SWATCH_SHADES = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9] as const;
function Section({ title, children }: { title: string; children: React.ReactNode }) {
return (
<Stack gap="sm">
<Title order={3}>{title}</Title>
<Divider />
{children}
</Stack>
);
}
/** A full 09 ramp, so a palette swap is visible shade by shade. */
function ColorRamp({ name }: { name: string }) {
return (
<Stack gap={4}>
<Text size="xs" c="dimmed" tt="uppercase" fw={700}>
{name}
</Text>
<Group gap={0} wrap="nowrap">
{SWATCH_SHADES.map((shade) => (
<Box
key={shade}
style={{
background: `var(--mantine-color-${name}-${shade})`,
width: 48,
height: 40,
display: 'flex',
alignItems: 'flex-end',
justifyContent: 'center',
fontSize: 10,
// Shades 0-4 are light enough to need dark text; 5+ need light.
color: shade < 5 ? 'var(--mantine-color-black)' : 'var(--mantine-color-white)',
}}
>
{shade}
</Box>
))}
</Group>
</Stack>
);
}
export function ThemeGallery() {
const theme = useMantineTheme();
const paletteNames = Object.keys(theme.colors).filter((c) =>
// The stock Mantine ramps are noise here; show the ones this app defines,
// plus gray because overriding it is the riskiest single theme change.
c.startsWith('ema') || c === 'gray',
);
return (
<>
{/* Mirrors the real app shells, so the skip-link contract is testable
without a session. */}
<SkipLink />
<Box id={MAIN_CONTENT_ID} p="xl" style={{ maxWidth: 1100, margin: '0 auto' }}>
<Stack gap="xl">
<Stack gap={4}>
<Title order={1}>Theme Gallery</Title>
<Text c="dimmed">
Visual-regression surface for theme changes. Not a product page.
</Text>
</Stack>
<Section title="Typography">
<Stack gap="xs">
<Title order={1}>Heading 1 Maritime licensing</Title>
<Title order={2}>Heading 2 Seafarer registry</Title>
<Title order={3}>Heading 3 Vessel registration</Title>
<Title order={4}>Heading 4 Certificate of competency</Title>
<Title order={5}>Heading 5 Sea service record</Title>
<Text>
Body text. The quick brown fox jumps over the lazy dog.
</Text>
<Text size="sm" c="dimmed">
Small dimmed text, used for subtitles and helper copy.
</Text>
<Text size="xs" c="dimmed">
Extra-small text, used for metadata.
</Text>
{/* Mixed-script line: the case a [lang] font swap would break. */}
<Text>Amharic: አበበ SF-2024-0001 </Text>
{/* Ge'ez at heading size, where its taller ascenders clip first. */}
<Title order={2}> </Title>
<Title order={4}> Seafarer Registry</Title>
<Text ff="monospace">Monospace: ET-IMO-0231 · 28,450 GT</Text>
</Stack>
</Section>
<Section title="Color ramps">
<Stack gap="md">
{paletteNames.map((name) => (
<ColorRamp key={name} name={name} />
))}
</Stack>
</Section>
<Section title="Buttons">
<Stack gap="sm">
<Group>
<Button>Filled</Button>
<Button variant="light">Light</Button>
<Button variant="outline">Outline</Button>
<Button variant="subtle">Subtle</Button>
<Button variant="default">Default</Button>
<Button variant="white">White</Button>
</Group>
<Group>
<Button size="xs">xs</Button>
<Button size="sm">sm</Button>
<Button size="md">md</Button>
<Button size="lg">lg</Button>
</Group>
<Group>
<Button color="red">Destructive</Button>
<Button disabled>Disabled</Button>
<Button loading>Loading</Button>
<Button fullWidth>Full width</Button>
</Group>
</Stack>
</Section>
<Section title="Surfaces">
<Group align="stretch" grow>
<Card withBorder>
<Text fw={600}>Card withBorder</Text>
<Text size="sm" c="dimmed">
Radius and shadow come from the theme.
</Text>
</Card>
<Paper p="md" withBorder>
<Text fw={600}>Paper withBorder</Text>
<Text size="sm" c="dimmed">
Default radius applies here too.
</Text>
</Paper>
<Paper p="md" shadow="md">
<Text fw={600}>Paper shadow=md</Text>
<Text size="sm" c="dimmed">
Shadow ramp check.
</Text>
</Paper>
</Group>
</Section>
<Section title="Badges">
<Group>
<Badge>Default</Badge>
<Badge color="green">Active</Badge>
<Badge color="yellow">Pending</Badge>
<Badge color="red">Suspended</Badge>
<Badge color="orange">Expiring</Badge>
<Badge color="gray">Draft</Badge>
<Badge variant="filled">Filled</Badge>
<Badge variant="outline">Outline</Badge>
<Badge variant="dot">Dot</Badge>
</Group>
</Section>
<Section title="Form controls">
<Stack gap="md" style={{ maxWidth: 520 }}>
<TextInput label="Text input" placeholder="you@example.com" />
<TextInput
label="With description"
description="Helper text that persists, unlike a placeholder."
placeholder="ET-000000"
/>
<TextInput
label="Error state"
error="This field is required."
placeholder="Required"
/>
<TextInput label="Disabled" disabled value="Cannot edit" />
<TextInput label="Read-only" readOnly value="Read-only value" />
<Select
label="Select"
data={['Ethiopian', 'Djiboutian', 'Kenyan']}
placeholder="Pick one"
/>
<Textarea label="Textarea" placeholder="Full permanent address" />
<Group>
<Checkbox label="Checkbox" defaultChecked />
<Radio label="Radio" defaultChecked />
<Switch label="Switch" defaultChecked />
</Group>
</Stack>
</Section>
<Section title="Feedback">
<Stack gap="sm">
<Alert color="blue" title="Information">
A unique Seafarer ID is generated on approval.
</Alert>
<Alert color="yellow" title="Warning">
Upload clear copies of all required documents.
</Alert>
<Alert color="red" title="Error">
The certificate number could not be verified.
</Alert>
<Group>
<ThemeIcon size="lg">Ic</ThemeIcon>
<ThemeIcon size="lg" variant="light">
Lt
</ThemeIcon>
<ThemeIcon size="lg" color="green" variant="light">
Ok
</ThemeIcon>
</Group>
</Stack>
</Section>
<Section title="Tabs">
<Tabs defaultValue="overview">
<Tabs.List>
<Tabs.Tab value="overview">Overview</Tabs.Tab>
<Tabs.Tab value="training">Training</Tabs.Tab>
<Tabs.Tab value="medical">Medical</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="overview" pt="md">
<Text size="sm">Overview panel content.</Text>
</Tabs.Panel>
</Tabs>
</Section>
<Section title="Status badges">
<Group>
<StatusBadge tone="success" label="Approved" />
<StatusBadge tone="warning" label="Expiring" />
<StatusBadge tone="danger" label="Rejected" />
<StatusBadge tone="info" label="Submitted" />
<StatusBadge tone="pending" label="Corrections requested" />
<StatusBadge tone="neutral" label="Draft" />
</Group>
</Section>
<Section title="Waiting for">
<Group>
<WaitingFor since={GALLERY_NOW} />
<WaitingFor since={daysAgo(9)} />
<WaitingFor since={daysAgo(16)} />
<WaitingFor since={daysAgo(30)} done />
<WaitingFor since={null} />
</Group>
</Section>
<Section title="Stat tiles">
<SimpleGrid cols={{ base: 1, sm: 2, lg: 4 }}>
<StatTile label="Awaiting claim" value={12} hint="Nobody has picked these up" icon={IconInbox} tone="info" />
<StatTile label="Needs applicant action" value={3} hint="Returned for corrections" icon={IconAlertTriangle} tone="pending" />
<StatTile label="Overdue" value={2} hint="Past the turnaround target" icon={IconAlertTriangle} tone="danger" />
<StatTile label="Not permitted" value="—" hint="No access to this queue" icon={IconInbox} tone="neutral" />
</SimpleGrid>
</Section>
<Section title="Table">
<Table>
<Table.Thead>
<Table.Tr>
<Table.Th>Seafarer ID</Table.Th>
<Table.Th>Name</Table.Th>
<Table.Th>Region</Table.Th>
<Table.Th>Status</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
<Table.Tr>
<Table.Td>
<Anchor size="sm">SF-2024-0001</Anchor>
</Table.Td>
<Table.Td>Abebe Girma</Table.Td>
<Table.Td>Addis Ababa</Table.Td>
<Table.Td>
<Badge color="green">Active</Badge>
</Table.Td>
</Table.Tr>
<Table.Tr>
<Table.Td>
<Anchor size="sm">SF-2024-0002</Anchor>
</Table.Td>
<Table.Td> </Table.Td>
<Table.Td>Dire Dawa</Table.Td>
<Table.Td>
<Badge color="yellow">Pending</Badge>
</Table.Td>
</Table.Tr>
</Table.Tbody>
</Table>
</Section>
<Section title="Focus states">
<Text size="sm" c="dimmed">
Tab through these to check the focus ring. Every interactive element
must show a visible indicator.
</Text>
<Group mt="sm">
<Button>Button</Button>
<Anchor href="#gallery-focus">Link</Anchor>
<TextInput placeholder="Input" />
<Checkbox label="Checkbox" />
</Group>
</Section>
</Stack>
</Box>
</>
);
}

View File

@@ -80,7 +80,10 @@ export function PageLoader({
<Text
fw={600}
size="md"
c={isDark ? 'gray.1' : 'navy.9'}
// Was `navy.9`, which is defined in neither theme — Mantine
// silently drops unresolved keys, so this label had been
// rendering an inherited colour rather than the intended one.
c={isDark ? 'gray.1' : 'gray.9'}
style={{ lineHeight: 1.3 }}
>
{resolvedLabel}
@@ -108,6 +111,7 @@ export function PageLoader({
position: 'absolute',
top: 0,
bottom: 0,
left: 0,
width: '40%',
background: 'linear-gradient(90deg, #078930, #FCD116, #2563EB)',
borderRadius: '2px',
@@ -115,9 +119,13 @@ export function PageLoader({
}}
/>
<style>{`
/* Translating rather than animating \`left\`: the latter runs
layout on every frame of an animation that plays during page
loads, which is exactly when the main thread is busiest.
Reduced motion is handled globally in semantic.css. */
@keyframes ema-shimmer {
0% { left: -40%; }
100% { left: 100%; }
0% { transform: translateX(-100%); }
100% { transform: translateX(250%); }
}
`}</style>
</Box>

View File

@@ -0,0 +1,30 @@
import type { ReactNode } from 'react';
import { Badge, type BadgeProps } from '@mantine/core';
import { STATUS_TONE_COLOR, type StatusTone } from '@ema-platform/shared';
export interface StatusBadgeProps extends Omit<BadgeProps, 'color' | 'children'> {
/** The platform tone this status maps onto. */
tone: StatusTone;
/** Human label. Already translated by the caller. */
label: ReactNode;
}
/**
* One badge for every status in the platform.
*
* There were 29 files rendering `<Badge variant="light" color={MAP[status]}>`,
* each re-deciding size, variant and radius alongside the colour — which is why
* a "pending" badge in one queue did not match "pending" in the next. Callers
* now supply a tone and a label; how a status *looks* is decided once, here.
*
* Tone rather than colour on purpose: `STATUS_TONE_COLOR` is the single place a
* tone becomes a Mantine colour, so restyling the platform's idea of "danger"
* stays one edit rather than a sweep.
*/
export function StatusBadge({ tone, label, ...props }: StatusBadgeProps) {
return (
<Badge variant="light" size="sm" radius="sm" {...props} color={STATUS_TONE_COLOR[tone]}>
{label}
</Badge>
);
}

View File

@@ -157,8 +157,8 @@ function SidebarItem({ item, collapsed, activePath, onNavigate, opened, onToggle
height: rem(40),
borderRadius: rem(10),
opacity: item.soon ? 0.55 : 1,
color: branchActive ? 'var(--mantine-color-blue-6)' : undefined,
backgroundColor: branchActive ? 'var(--mantine-color-blue-light)' : undefined,
color: branchActive ? 'var(--mantine-primary-color-filled)' : undefined,
backgroundColor: branchActive ? 'var(--mantine-primary-color-light)' : undefined,
}}
>
<ItemIcon size={20} stroke={1.6} />
@@ -222,7 +222,10 @@ function SidebarItem({ item, collapsed, activePath, onNavigate, opened, onToggle
opened={hasChildren ? opened : undefined}
onChange={hasChildren ? onToggle : undefined}
onClick={() => !hasChildren && onNavigate(item)}
variant="light"
// A leaf that is the current page is filled in the app's primary; a
// branch whose child is current stays a tint, so the filled item is
// always exactly the page you are on.
variant={hasChildren ? 'light' : 'filled'}
styles={{
root: { borderRadius: rem(10), opacity: item.soon ? 0.7 : 1 },
label: { fontWeight: 500 },

View File

@@ -2,17 +2,54 @@ import { Group, Stack, Text, Title } from '@mantine/core';
import type { ReactNode } from 'react';
interface PageHeaderProps {
title: string;
subtitle?: string;
/**
* The page's name. A `ReactNode` rather than a string because detail pages
* title themselves with the record they are showing, which is often a
* localised or composed value rather than a literal.
*/
title: ReactNode;
subtitle?: ReactNode;
/**
* Identifiers that belong beside the title rather than under it — a
* reference number, a status badge. Rendered inline, small and dimmed.
*/
meta?: ReactNode;
/** Right-aligned actions (e.g. a primary button). */
action?: ReactNode;
/**
* Set when the header sits directly inside a gapped `<Stack>`, which already
* spaces it from what follows — the built-in margin would double that gap.
*/
noMargin?: boolean;
}
export function PageHeader({ title, subtitle, action }: PageHeaderProps) {
/**
* The heading every backoffice page starts with.
*
* It exists because 25 pages each rolled their own: eight at `order={2}`,
* seventeen at `order={3}`, with `mb="xs"`, `mb={4}`, `mb="lg"` and nothing at
* all between them. Clicking between two screens moved the title, which is the
* single loudest signal that a product was assembled rather than designed.
*
* `order={2}` is deliberate — it is the page's only `h2`, sitting under the
* app-level `h1`, so the heading outline reads correctly for a screen reader.
* Section headings inside cards stay at `order={4}`/`{5}` and are not this
* component's business.
*/
export function PageHeader({ title, subtitle, meta, action, noMargin }: PageHeaderProps) {
return (
<Group justify="space-between" align="flex-end" wrap="wrap" gap="sm">
<Group
justify="space-between"
align="flex-end"
wrap="wrap"
gap="sm"
mb={noMargin ? undefined : 'lg'}
>
<Stack gap={2}>
<Title order={2}>{title}</Title>
<Group gap="sm" align="center" wrap="wrap">
<Title order={2}>{title}</Title>
{meta}
</Group>
{subtitle && (
<Text c="dimmed" size="sm">
{subtitle}

View File

@@ -0,0 +1,27 @@
import { useTranslation } from 'react-i18next';
/** The id the skip link targets. Exported so the main region cannot drift. */
export const MAIN_CONTENT_ID = 'ema-main-content';
/**
* "Skip to main content" — the first thing a keyboard user should reach.
*
* Both apps put a sidebar of 20-plus navigation items before the page body, so
* without this, reaching the actual content means tabbing through every one of
* them on every navigation. WCAG 2.4.1 asks for a bypass; there was none.
*
* Hidden until focused, which is why it is positioned off-screen rather than
* `display: none` — the latter would make it unfocusable and defeat the point.
* Styling lives in `semantic.css` as `.ema-skip-link`.
*
* Render it as the first child of the shell, before the header.
*/
export function SkipLink() {
const { t } = useTranslation();
return (
<a className="ema-skip-link" href={`#${MAIN_CONTENT_ID}`}>
{t('a11y.skipToContent', 'Skip to main content')}
</a>
);
}