mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 19:12:50 +00:00
Merge branch 'ui_ux' of github.com:Tria-plc/emaui into WorkflowChange
This commit is contained in:
@@ -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";
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
83
libs/ui/src/lib/data/StatTile.tsx
Normal file
83
libs/ui/src/lib/data/StatTile.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
83
libs/ui/src/lib/data/WaitingFor.tsx
Normal file
83
libs/ui/src/lib/data/WaitingFor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
16
libs/ui/src/lib/data/stat-tile.css
Normal file
16
libs/ui/src/lib/data/stat-tile.css
Normal 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);
|
||||
}
|
||||
362
libs/ui/src/lib/dev/ThemeGallery.tsx
Normal file
362
libs/ui/src/lib/dev/ThemeGallery.tsx
Normal 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 0–9 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
30
libs/ui/src/lib/feedback/StatusBadge.tsx
Normal file
30
libs/ui/src/lib/feedback/StatusBadge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 },
|
||||
|
||||
@@ -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}
|
||||
|
||||
27
libs/ui/src/lib/layout/SkipLink.tsx
Normal file
27
libs/ui/src/lib/layout/SkipLink.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user