Files
emaui/libs/ui/src/lib/dev/ThemeGallery.tsx

363 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
</>
);
}