init project

This commit is contained in:
root
2026-06-25 19:06:59 -04:00
parent c5dfee6efb
commit 5d017f533a
349 changed files with 31330 additions and 0 deletions
+70
View File
@@ -0,0 +1,70 @@
import { StateAction, StateActions, StateShell } from '@/components/app-shell/StateShell';
import {
isLocale,
localizedPath,
routeIdFromSlug,
type Locale,
type RouteId,
} from '@/lib/localization/config';
import { getMessages, type ShellMessages } from '@/lib/localization/messages';
import { buildLocalizedMetadata } from '@/lib/metadata/build-metadata';
import type { Metadata } from 'next';
import { notFound } from 'next/navigation';
interface PendingRouteProps {
params: Promise<{ locale: string; slug: string }>;
}
function routeLabel(messages: ShellMessages, routeId: RouteId): string {
const labels = messages.footer.links;
switch (routeId) {
case 'login':
return labels.login;
case 'privacy':
return labels.privacy;
case 'terms':
return labels.terms;
case 'accessibility':
return labels.accessibility;
case 'home':
return 'RentalDriveGo';
}
}
async function resolveParams(params: PendingRouteProps['params']) {
const { locale: localeValue, slug } = await params;
if (!isLocale(localeValue)) notFound();
const routeId = routeIdFromSlug(localeValue, slug);
if (!routeId || routeId === 'home') notFound();
return { locale: localeValue as Locale, routeId };
}
export async function generateMetadata({ params }: PendingRouteProps): Promise<Metadata> {
const { locale, routeId } = await resolveParams(params);
const messages = getMessages(locale).shell;
const label = routeLabel(messages, routeId);
return buildLocalizedMetadata({
locale,
routeId,
title: `${label} | ${messages.metadata.pendingTitle}`,
description: messages.metadata.pendingDescription,
indexable: false,
});
}
export default async function PendingRoute({ params }: PendingRouteProps) {
const { locale, routeId } = await resolveParams(params);
const messages = getMessages(locale).shell;
const label = routeLabel(messages, routeId);
return (
<StateShell
title={`${label}: ${messages.states.pendingTitle}`}
body={messages.states.pendingBody}
>
<StateActions>
<StateAction href={localizedPath('home', locale)}>{messages.states.backHome}</StateAction>
</StateActions>
</StateShell>
);
}
@@ -0,0 +1,96 @@
'use client';
import { Button } from '@/components/actions/Button';
import { Accordion } from '@/components/controls/Accordion';
import { SegmentedControl } from '@/components/controls/SegmentedControl';
import { Tabs } from '@/components/controls/Tabs';
import { Alert } from '@/components/feedback/Alert';
import { Checkbox, Switch } from '@/components/forms/ChoiceControls';
import { FormField } from '@/components/forms/FormField';
import { TextInput } from '@/components/forms/TextInput';
import { DropdownMenu } from '@/components/overlays/DropdownMenu';
import { Dialog } from '@/components/overlays/Dialog';
import type { ComponentLabCopy } from '@/content/development/component-lab';
import { useRef, useState } from 'react';
import styles from './page.module.css';
export function ComponentLabInteractive({ copy }: { copy: ComponentLabCopy }) {
const [dialogOpen, setDialogOpen] = useState(false);
const [segment, setSegment] = useState('comfortable');
const triggerRef = useRef<HTMLButtonElement>(null);
return (
<div className={styles.interactiveGrid}>
<section className={styles.group} aria-labelledby="lab-actions">
<h2 id="lab-actions">Actions</h2>
<div className={styles.cluster}>
<Button>{copy.actions.primary}</Button>
<Button intent="conversion">{copy.actions.conversion}</Button>
<Button loading>{copy.actions.loading}</Button>
<Button disabled>{copy.actions.disabled}</Button>
<Button ref={triggerRef} intent="secondary" onClick={() => setDialogOpen(true)}>
{copy.dialog.open}
</Button>
<DropdownMenu
label={copy.menu.label}
items={[
{ id: 'review', label: copy.menu.first, onSelect: () => undefined },
{
id: 'archive',
label: copy.menu.second,
onSelect: () => undefined,
destructive: true,
},
]}
/>
</div>
</section>
<section className={styles.group} aria-labelledby="lab-form">
<h2 id="lab-form">Form controls</h2>
<FormField
id="fixture-email"
label={copy.form.label}
supportingText={copy.form.supporting}
error={copy.form.error}
required
>
<TextInput
id="fixture-email"
type="email"
placeholder={copy.form.placeholder}
invalid
describedBy="fixture-email-help fixture-email-error"
/>
</FormField>
<Checkbox label={copy.form.checkbox} />
<Switch label={copy.form.switch} />
<SegmentedControl
label="Density"
value={segment}
onChange={setSegment}
options={[
{ value: 'comfortable', label: 'Comfortable' },
{ value: 'compact', label: 'Compact' },
]}
/>
</section>
<section className={styles.group} aria-labelledby="lab-controls">
<h2 id="lab-controls">Selection and disclosure</h2>
<Tabs label={copy.tabs.label} items={copy.tabs.items} />
<Accordion items={copy.accordion.items} />
</section>
<Alert tone="warning" title={copy.alert.title}>
{copy.alert.body}
</Alert>
<Dialog
open={dialogOpen}
onOpenChange={setDialogOpen}
title={copy.dialog.title}
description={copy.dialog.description}
closeLabel={copy.dialog.close}
returnFocusRef={triggerRef}
>
<p>{copy.dialog.content}</p>
</Dialog>
</div>
);
}
@@ -0,0 +1,32 @@
.main {
min-block-size: 100dvh;
}
.interactiveGrid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-6);
}
.group {
display: grid;
align-content: start;
gap: var(--space-4);
min-inline-size: 0;
padding: var(--space-6);
border: 1px solid var(--border-standard);
border-radius: var(--radius-md);
background: var(--surface-elevated);
}
.group h2 {
margin: 0;
font-size: var(--type-heading-3-size);
}
.cluster {
display: flex;
flex-wrap: wrap;
gap: var(--space-3);
}
@media (max-width: 767px) {
.interactiveGrid {
grid-template-columns: 1fr;
}
}
+81
View File
@@ -0,0 +1,81 @@
import { Container, Section, Stack } from '@/components/layout/LayoutPrimitives';
import { Comparison } from '@/components/marketing/Comparison';
import { CTA } from '@/components/marketing/CTA';
import { Metric } from '@/components/marketing/Evidence';
import { ProductPreview } from '@/components/marketing/ProductPreview';
import { SectionHeader } from '@/components/marketing/SectionHeader';
import { Workflow } from '@/components/marketing/Workflow';
import { getComponentLabCopy } from '@/content/development/component-lab';
import { isLocale, type Locale } from '@/lib/localization/config';
import { notFound } from 'next/navigation';
import { ComponentLabInteractive } from './ComponentLabInteractive';
import styles from './page.module.css';
export const metadata = { robots: { index: false, follow: false } };
export default async function ComponentLabPage({
params,
}: {
params: Promise<{ locale: string }>;
}) {
if (process.env.COMPONENT_FIXTURES_ENABLED !== 'true') notFound();
const { locale } = await params;
if (!isLocale(locale)) notFound();
const resolvedLocale: Locale = locale;
const copy = getComponentLabCopy(resolvedLocale);
return (
<main id="main-content" className={styles.main}>
<Section>
<Container>
<Stack gap="large">
<SectionHeader
eyebrow={copy.developmentLabel}
title={copy.title}
body={copy.body}
headingLevel={1}
/>
<ComponentLabInteractive copy={copy} />
<Workflow label={copy.title} items={copy.workflow} />
<Comparison
columns={[
{
title: copy.comparison.beforeTitle,
items: copy.comparison.before,
tone: 'problem',
},
{
title: copy.comparison.afterTitle,
items: copy.comparison.after,
tone: 'solution',
},
]}
/>
<ProductPreview
title={copy.preview.title}
caption={copy.preview.caption}
illustrativeLabel={copy.preview.illustrative}
rows={copy.preview.rows.map((row, index) => ({
...row,
statusTone: index === 0 ? 'success' : 'warning',
}))}
/>
<Metric
content={{
value: copy.metric.value,
label: copy.metric.label,
qualification: copy.metric.qualification,
status: 'research-only',
}}
statusLabel={copy.metric.status}
/>
<CTA
title={copy.cta.title}
body={copy.cta.body}
primary={{ label: copy.cta.primary, disabledReason: copy.cta.unavailable }}
/>
</Stack>
</Container>
</Section>
</main>
);
}
+23
View File
@@ -0,0 +1,23 @@
'use client';
import { StateButton, StateActions, StateShell } from '@/components/app-shell/StateShell';
import { getClientShellMessages } from '@/lib/localization/client-messages';
import { useParams } from 'next/navigation';
export default function ErrorPage({
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
const params = useParams<{ locale?: string }>();
const messages = getClientShellMessages(params?.locale);
return (
<StateShell title={messages.states.errorTitle} body={messages.states.errorBody}>
<StateActions>
<StateButton onClick={reset}>{messages.states.retry}</StateButton>
</StateActions>
</StateShell>
);
}
+70
View File
@@ -0,0 +1,70 @@
import '@fontsource-variable/inter/index.css';
import '@fontsource-variable/noto-sans-arabic/index.css';
import '@/styles/tokens.css';
import '@/styles/component-tokens.css';
import '@/styles/globals.css';
import { SiteFooter } from '@/components/app-shell/SiteFooter';
import { SiteHeader } from '@/components/app-shell/SiteHeader';
import { ThemeController } from '@/components/app-shell/ThemeController';
import { getDirection, isLocale, type Locale } from '@/lib/localization/config';
import { getMessages } from '@/lib/localization/messages';
import { themeBootstrapScript } from '@/lib/theme/bootstrap-script';
import {
isThemePreference,
serverResolvedTheme,
themeCookie,
type ThemePreference,
} from '@/lib/theme/config';
import { cookies, headers } from 'next/headers';
import { notFound } from 'next/navigation';
import type { ReactNode } from 'react';
export const dynamic = 'force-dynamic';
interface LocaleLayoutProps {
children: ReactNode;
params: Promise<{ locale: string }>;
}
export default async function LocaleLayout({ children, params }: LocaleLayoutProps) {
const { locale: localeValue } = await params;
if (!isLocale(localeValue)) notFound();
const locale: Locale = localeValue;
const messages = getMessages(locale);
const requestHeaders = await headers();
const nonce = requestHeaders.get('x-nonce') ?? undefined;
const cookieStore = await cookies();
const cookiePreference = cookieStore.get(themeCookie)?.value;
const preference: ThemePreference = isThemePreference(cookiePreference)
? cookiePreference
: 'system';
const resolvedTheme = serverResolvedTheme(preference);
return (
<html
lang={locale}
dir={getDirection(locale)}
data-theme-preference={preference}
data-theme={resolvedTheme}
suppressHydrationWarning
>
<head>
<script
id="theme-bootstrap"
nonce={nonce}
dangerouslySetInnerHTML={{ __html: themeBootstrapScript }}
/>
</head>
<body>
<ThemeController />
<a className="skip-link" href="#main-content">
{messages.shell.skipToContent}
</a>
<SiteHeader locale={locale} messages={messages.shell} themePreference={preference} />
{children}
<SiteFooter locale={locale} messages={messages.shell} />
</body>
</html>
);
}
+5
View File
@@ -0,0 +1,5 @@
import { LocalizedLoading } from '@/components/app-shell/LocalizedLoading';
export default function Loading() {
return <LocalizedLoading />;
}
+5
View File
@@ -0,0 +1,5 @@
import { LocalizedNotFound } from '@/components/app-shell/LocalizedNotFound';
export default function NotFound() {
return <LocalizedNotFound />;
}
+219
View File
@@ -0,0 +1,219 @@
.main {
min-block-size: calc(100dvh - var(--header-min-block-size));
overflow: clip;
}
.hero {
position: relative;
isolation: isolate;
padding-block: clamp(var(--space-12), 9vw, var(--space-24));
background:
radial-gradient(
circle at 12% 18%,
color-mix(in srgb, var(--status-info-surface) 82%, transparent),
transparent 40%
),
radial-gradient(
circle at 88% 72%,
color-mix(in srgb, var(--surface-strong) 78%, transparent),
transparent 42%
),
var(--surface-page);
}
.hero::after {
position: absolute;
z-index: -1;
inset-block-end: 0;
inset-inline: 0;
block-size: 1px;
background: var(--border-standard);
content: '';
}
.heroGrid {
display: grid;
grid-template-columns: minmax(0, 1.02fr) minmax(22rem, 0.98fr);
align-items: center;
gap: clamp(var(--space-10), 7vw, var(--space-20));
}
.heroCopy {
display: grid;
gap: var(--space-5);
min-inline-size: 0;
}
.heroCopy > p:last-of-type {
max-inline-size: 58ch;
}
.heroActions {
display: flex;
flex-wrap: wrap;
gap: var(--space-3);
padding-block-start: var(--space-2);
}
.previewStage {
position: relative;
min-inline-size: 0;
padding: clamp(var(--space-3), 2.5vw, var(--space-6));
}
.previewStage::before,
.previewStage::after {
position: absolute;
z-index: -1;
border-radius: var(--radius-md);
content: '';
}
.previewStage::before {
inset: 0;
background: var(--surface-strong);
transform: rotate(-2deg);
}
.previewStage::after {
inset-block-start: 8%;
inset-inline: 8%;
block-size: 88%;
border: 1px solid var(--border-strong);
transform: rotate(2deg);
}
.sectionBorder {
border-block-end: 1px solid var(--border-standard);
}
.splitSection,
.pricingGrid {
display: grid;
grid-template-columns: minmax(0, 0.9fr) minmax(22rem, 1.1fr);
align-items: start;
gap: clamp(var(--space-8), 7vw, var(--space-16));
}
.capabilityList,
.factorList,
.methodList {
margin: 0;
}
.capabilityList,
.factorList {
display: grid;
gap: var(--space-4);
padding: 0;
list-style: none;
}
.capabilityList {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.capabilityList li,
.factorList li {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
align-items: start;
gap: var(--space-3);
}
.capabilityList svg,
.factorList svg {
margin-block-start: 0.28rem;
color: var(--interactive-primary);
}
.methodList {
display: grid;
gap: var(--space-2);
padding-inline-start: var(--space-5);
}
.pricingCopy {
display: grid;
gap: var(--space-8);
}
.pricingCard {
display: grid;
gap: var(--space-5);
}
.pricingCard > svg {
color: var(--interactive-primary);
}
.pricingCard > :last-child {
justify-self: start;
}
.finalSection {
padding-block-start: clamp(var(--space-8), 6vw, var(--space-16));
}
@media (max-width: 1023px) {
.heroGrid {
grid-template-columns: minmax(0, 1fr) minmax(18rem, 0.9fr);
gap: var(--space-10);
}
.splitSection,
.pricingGrid {
grid-template-columns: minmax(0, 1fr) minmax(18rem, 1fr);
}
}
@media (max-width: 767px) {
.heroGrid,
.splitSection,
.pricingGrid {
grid-template-columns: 1fr;
}
.heroCopy {
justify-items: start;
}
.heroActions,
.heroActions > *,
.pricingCard > :last-child {
inline-size: 100%;
}
.previewStage {
padding-inline: 0;
}
.previewStage::before,
.previewStage::after {
display: none;
}
.capabilityList {
grid-template-columns: 1fr;
}
}
@media (max-width: 359px) {
.hero {
padding-block: var(--space-10);
}
}
@media (forced-colors: active) {
.previewStage::before,
.previewStage::after {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.previewStage::before,
.previewStage::after {
transform: none;
}
}
+334
View File
@@ -0,0 +1,334 @@
import { ActionLink } from '@/components/actions/ActionLink';
import { Accordion } from '@/components/controls/Accordion';
import { Icon } from '@/components/foundation/Icon';
import { Container, Section, Stack } from '@/components/layout/LayoutPrimitives';
import { CTA } from '@/components/marketing/CTA';
import { Comparison } from '@/components/marketing/Comparison';
import { FeatureCard } from '@/components/marketing/FeatureCard';
import { ProductPreview } from '@/components/marketing/ProductPreview';
import { SectionHeader } from '@/components/marketing/SectionHeader';
import { FeatureGrid } from '@/components/marketing/SectionPatterns';
import { Workflow } from '@/components/marketing/Workflow';
import { Callout } from '@/components/surfaces/Callout';
import { Card } from '@/components/surfaces/Card';
import { Eyebrow, Heading, Text } from '@/components/typography/Typography';
import { buildHomepageContent } from '@/content/homepage-model';
import { isLocale, localizedPath, type Locale } from '@/lib/localization/config';
import { getMessages } from '@/lib/localization/messages';
import { buildLocalizedMetadata } from '@/lib/metadata/build-metadata';
import type { Metadata } from 'next';
import { notFound } from 'next/navigation';
import styles from './page.module.css';
interface LocalePageProps {
params: Promise<{ locale: string }>;
}
export async function generateMetadata({ params }: LocalePageProps): Promise<Metadata> {
const { locale: localeValue } = await params;
if (!isLocale(localeValue)) notFound();
const messages = getMessages(localeValue);
return buildLocalizedMetadata({
locale: localeValue,
routeId: 'home',
title: messages.homepage.meta.title,
description: messages.homepage.meta.description,
indexable: true,
});
}
export default async function LocaleHomePage({ params }: LocalePageProps) {
const { locale: localeValue } = await params;
if (!isLocale(localeValue)) notFound();
const locale: Locale = localeValue;
const messages = getMessages(locale);
const content = buildHomepageContent(messages.homepage, messages.shell);
const homePath = localizedPath('home', locale);
return (
<main id="main-content" className={styles.main} tabIndex={-1}>
<Section id="product" className={styles.hero} aria-label={content.hero.title}>
<Container>
<div className={styles.heroGrid}>
<div className={styles.heroCopy}>
<Eyebrow>{content.hero.eyebrow}</Eyebrow>
<Heading level={1} appearance="display">
{content.hero.title}
</Heading>
<Text variant="lead">{content.hero.body}</Text>
<div className={styles.heroActions} role="group" aria-label={content.hero.eyebrow}>
<ActionLink
href={homePath}
variant="button-conversion"
disabledReason={content.hero.primary.disabledReason}
>
{content.hero.primary.label}
</ActionLink>
<ActionLink
href={homePath}
variant="button-primary"
disabledReason={content.hero.secondary.disabledReason}
>
{content.hero.secondary.label}
</ActionLink>
</div>
</div>
<div className={styles.previewStage}>
<ProductPreview {...content.hero.preview} headingLevel={2} />
</div>
</div>
</Container>
</Section>
<Section className={styles.sectionBorder} aria-label={content.trust.title}>
<Container>
<Stack gap="large">
<SectionHeader
eyebrow={content.trust.eyebrow}
title={content.trust.title}
body={content.trust.body}
alignment="center"
/>
<FeatureGrid>
{content.trust.items.map((item) => (
<FeatureCard
key={item.id}
title={item.title}
description={item.description}
{...(item.icon ? { icon: item.icon } : {})}
emphasis="muted"
/>
))}
</FeatureGrid>
</Stack>
</Container>
</Section>
<Section tone="muted" aria-label={content.comparison.title}>
<Container>
<Stack gap="large">
<SectionHeader
eyebrow={content.comparison.eyebrow}
title={content.comparison.title}
body={content.comparison.body}
/>
<Comparison
columns={[
{
title: content.comparison.beforeTitle,
items: content.comparison.beforeItems,
tone: 'problem',
},
{
title: content.comparison.afterTitle,
items: content.comparison.afterItems,
tone: 'solution',
},
]}
/>
</Stack>
</Container>
</Section>
<Section id="workflow" aria-label={content.workflow.title}>
<Container>
<Stack gap="large">
<SectionHeader
eyebrow={content.workflow.eyebrow}
title={content.workflow.title}
body={content.workflow.body}
/>
<Workflow items={content.workflow.steps} label={content.workflow.title} />
</Stack>
</Container>
</Section>
<Section tone="strong" aria-label={content.roles.title}>
<Container>
<Stack gap="large">
<SectionHeader
eyebrow={content.roles.eyebrow}
title={content.roles.title}
alignment="center"
/>
<FeatureGrid>
{content.roles.items.map((item) => (
<FeatureCard
key={item.id}
title={item.title}
description={item.description}
{...(item.icon ? { icon: item.icon } : {})}
emphasis="primary"
/>
))}
</FeatureGrid>
</Stack>
</Container>
</Section>
<Section id="modules" aria-label={content.modules.title}>
<Container>
<Stack gap="large">
<SectionHeader
eyebrow={content.modules.eyebrow}
title={content.modules.title}
body={content.modules.body}
/>
<FeatureGrid>
{content.modules.items.map((item) => (
<FeatureCard
key={item.id}
title={item.title}
description={item.description}
{...(item.icon ? { icon: item.icon } : {})}
/>
))}
</FeatureGrid>
</Stack>
</Container>
</Section>
<Section tone="muted" aria-label={content.results.title}>
<Container>
<Stack gap="large">
<SectionHeader
eyebrow={content.results.eyebrow}
title={content.results.title}
body={content.results.body}
/>
<FeatureGrid>
{content.results.metrics.map((metric) => (
<FeatureCard
key={metric.id}
title={metric.title}
description={metric.description}
{...(metric.icon ? { icon: metric.icon } : {})}
emphasis="muted"
/>
))}
</FeatureGrid>
<Callout icon="info" title={content.results.methodTitle}>
<ol className={styles.methodList}>
{content.results.method.map((item) => (
<li key={item}>{item}</li>
))}
</ol>
</Callout>
</Stack>
</Container>
</Section>
<Section aria-label={content.integrations.title}>
<Container>
<div className={styles.splitSection}>
<SectionHeader
eyebrow={content.integrations.eyebrow}
title={content.integrations.title}
body={content.integrations.body}
/>
<Card as="div" tone="elevated" padding="spacious">
<ul className={styles.capabilityList}>
{content.integrations.items.map((item) => (
<li key={item}>
<Icon name="plus" size="sm" />
<span>{item}</span>
</li>
))}
</ul>
</Card>
</div>
</Container>
</Section>
<Section tone="strong" aria-label={content.security.title}>
<Container>
<Stack gap="large">
<SectionHeader
eyebrow={content.security.eyebrow}
title={content.security.title}
body={content.security.body}
/>
<FeatureGrid>
{content.security.checks.map((check) => (
<FeatureCard
key={check.id}
title={check.title}
description={check.description}
{...(check.icon ? { icon: check.icon } : {})}
emphasis="primary"
/>
))}
</FeatureGrid>
</Stack>
</Container>
</Section>
<Section id="pricing" aria-label={content.pricing.title}>
<Container>
<div className={styles.pricingGrid}>
<div className={styles.pricingCopy}>
<SectionHeader
eyebrow={content.pricing.eyebrow}
title={content.pricing.title}
body={content.pricing.body}
/>
<ul className={styles.factorList}>
{content.pricing.factors.map((factor) => (
<li key={factor}>
<Icon name="check" size="sm" />
<span>{factor}</span>
</li>
))}
</ul>
</div>
<aside>
<Card as="div" tone="elevated" padding="spacious" className={styles.pricingCard}>
<Icon name="info" size="lg" />
<Text variant="supporting">{content.pricing.note}</Text>
<ActionLink
href={homePath}
variant="button-conversion"
disabledReason={content.pricing.action.disabledReason}
>
{content.pricing.action.label}
</ActionLink>
</Card>
</aside>
</div>
</Container>
</Section>
<Section id="faq" tone="muted" aria-label={content.faq.title}>
<Container width="content">
<Stack gap="large">
<SectionHeader
eyebrow={content.faq.eyebrow}
title={content.faq.title}
alignment="center"
/>
<Accordion
items={content.faq.items.map((item, index) => ({
id: item.id,
title: item.title,
content: <Text variant="supporting">{item.body}</Text>,
open: index === 0,
}))}
/>
</Stack>
</Container>
</Section>
<Section className={styles.finalSection} aria-label={content.final.title}>
<Container>
<CTA
eyebrow={content.final.eyebrow}
title={content.final.title}
body={content.final.body}
primary={content.final.primary}
secondary={content.final.secondary}
/>
</Container>
</Section>
</main>
);
}
+11
View File
@@ -0,0 +1,11 @@
import type { MetadataRoute } from 'next';
import { getSiteOrigin } from '@/lib/localization/site-origin';
export default function robots(): MetadataRoute.Robots {
const origin = getSiteOrigin();
const approved = process.env.PUBLIC_RELEASE_APPROVED === 'true';
return {
rules: approved ? { userAgent: '*', allow: '/' } : { userAgent: '*', disallow: '/' },
host: origin.href,
};
}
@@ -0,0 +1,59 @@
.link {
display: inline-flex;
min-block-size: 2.75rem;
align-items: center;
gap: var(--space-2);
font-weight: 650;
text-underline-offset: 0.18em;
}
.inline {
min-block-size: auto;
font-weight: inherit;
text-decoration-thickness: 0.08em;
}
.navigation {
color: var(--text-primary);
text-decoration: none;
}
.navigation:hover {
color: var(--link-hover);
text-decoration: underline;
}
.standalone {
text-decoration: none;
}
.standalone:hover {
text-decoration: underline;
}
.button-primary,
.button-conversion {
justify-content: center;
padding-block: var(--space-3);
padding-inline: var(--space-5);
border-radius: var(--radius-sm);
color: var(--text-inverse);
text-decoration: none;
}
.button-primary {
background: var(--interactive-primary);
}
.button-primary:hover {
background: var(--interactive-primary-hover);
color: var(--text-inverse);
}
.button-conversion {
background: var(--interactive-conversion);
}
.button-conversion:hover {
background: var(--interactive-conversion-hover);
color: var(--text-inverse);
}
.muted {
color: var(--text-secondary);
font-size: var(--type-label-size);
}
.disabled {
cursor: not-allowed;
color: var(--control-disabled-text);
text-decoration: none;
}
+76
View File
@@ -0,0 +1,76 @@
import { Icon, type ApprovedIconName } from '@/components/foundation/Icon';
import { classNames } from '@/lib/components/classNames';
import type { AnchorHTMLAttributes, ReactNode } from 'react';
import styles from './ActionLink.module.css';
type LinkVariant =
| 'inline'
| 'navigation'
| 'standalone'
| 'button-primary'
| 'button-conversion'
| 'muted';
interface ActionLinkProps extends Omit<AnchorHTMLAttributes<HTMLAnchorElement>, 'href'> {
href: string;
children: ReactNode;
variant?: LinkVariant;
icon?: ApprovedIconName;
external?: boolean;
newTab?: boolean;
disabledReason?: string;
}
export function ActionLink({
href,
children,
variant = 'inline',
icon,
external = false,
newTab = false,
disabledReason,
className,
...props
}: ActionLinkProps) {
if (disabledReason) {
return (
<span
className={classNames(styles.link, styles[variant], styles.disabled, className)}
aria-disabled="true"
title={disabledReason}
>
<span>{children}</span>
<span className="visually-hidden">: {disabledReason}</span>
</span>
);
}
const isExternal = external || /^https?:\/\//.test(href);
const trailingIcon = icon ?? (isExternal ? 'external' : undefined);
const content = (
<>
<span>{children}</span>
{trailingIcon ? (
<Icon
name={trailingIcon}
size="sm"
directionBehavior={
trailingIcon === 'chevron-forward' || trailingIcon === 'arrow-forward'
? 'mirror'
: 'fixed'
}
/>
) : null}
{newTab ? <span className="visually-hidden"> (opens in a new tab)</span> : null}
</>
);
const shared = {
className: classNames(styles.link, styles[variant], className),
...(newTab ? { target: '_blank', rel: 'noopener noreferrer' } : {}),
...props,
};
return (
<a href={href} {...shared}>
{content}
</a>
);
}
+139
View File
@@ -0,0 +1,139 @@
.button {
display: inline-flex;
min-block-size: 2.75rem;
align-items: center;
justify-content: center;
gap: var(--space-2);
border: 1px solid transparent;
border-radius: var(--radius-sm);
cursor: pointer;
font-weight: 750;
line-height: 1.25;
text-align: center;
text-decoration: none;
transition:
background-color var(--duration-fast) var(--easing-productive),
border-color var(--duration-fast) var(--easing-productive),
color var(--duration-fast) var(--easing-productive),
transform var(--duration-fast) var(--easing-productive);
}
.button:active:not(:disabled) {
transform: translateY(1px);
}
.button:disabled {
cursor: not-allowed;
opacity: var(--opacity-disabled);
}
.small {
min-block-size: 2.5rem;
padding-block: var(--space-2);
padding-inline: var(--space-3);
font-size: var(--type-label-size);
}
.medium {
padding-block: var(--space-3);
padding-inline: var(--space-5);
}
.large {
min-block-size: 3.25rem;
padding-block: var(--space-3);
padding-inline: var(--space-6);
font-size: var(--type-body-large-size);
}
.primary {
background: var(--interactive-primary);
color: var(--text-inverse);
}
.primary:hover:not(:disabled) {
background: var(--interactive-primary-hover);
}
.conversion {
background: var(--interactive-conversion);
color: var(--text-inverse);
}
.conversion:hover:not(:disabled) {
background: var(--interactive-conversion-hover);
}
.secondary {
border-color: var(--interactive-primary);
background: var(--surface-primary);
color: var(--interactive-primary);
}
.secondary:hover:not(:disabled) {
background: var(--status-info-surface);
}
.tertiary {
border-color: var(--border-standard);
background: var(--surface-elevated);
color: var(--text-primary);
}
.tertiary:hover:not(:disabled) {
border-color: var(--border-strong);
background: var(--surface-muted);
}
.ghost {
background: transparent;
color: var(--text-primary);
}
.ghost:hover:not(:disabled) {
background: var(--surface-muted);
}
.destructive {
background: var(--interactive-danger);
color: var(--text-inverse);
}
.destructive:hover:not(:disabled) {
filter: brightness(0.92);
}
.link {
min-block-size: 2.75rem;
padding-inline: 0;
background: transparent;
color: var(--link-default);
text-decoration: underline;
text-underline-offset: 0.18em;
}
.link:hover:not(:disabled) {
color: var(--link-hover);
}
.iconOnly {
aspect-ratio: 1;
padding: var(--space-3);
}
.fullWidth {
inline-size: 100%;
}
.loading {
position: relative;
}
.spinner {
position: absolute;
animation: spin 0.9s linear infinite;
}
.loadingContent {
display: inline-flex;
align-items: center;
gap: var(--space-2);
opacity: 0;
}
.label {
min-inline-size: 0;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: reduce) {
.button {
transition: none;
}
.spinner {
animation-duration: 1.8s;
}
}
@media (forced-colors: active) {
.button {
border-color: ButtonText;
}
}
+97
View File
@@ -0,0 +1,97 @@
'use client';
import { Icon, type ApprovedIconName } from '@/components/foundation/Icon';
import { classNames } from '@/lib/components/classNames';
import { forwardRef, type ButtonHTMLAttributes, type ReactNode } from 'react';
import styles from './Button.module.css';
export type ButtonIntent =
| 'primary'
| 'conversion'
| 'secondary'
| 'tertiary'
| 'ghost'
| 'destructive'
| 'link';
export type ButtonSize = 'small' | 'medium' | 'large';
interface CommonButtonProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, 'children'> {
intent?: ButtonIntent;
size?: ButtonSize;
loading?: boolean;
fullWidth?: boolean;
leadingIcon?: ApprovedIconName;
trailingIcon?: ApprovedIconName;
children: ReactNode;
}
interface IconOnlyButtonProps extends Omit<
CommonButtonProps,
'children' | 'leadingIcon' | 'trailingIcon'
> {
icon: ApprovedIconName;
'aria-label': string;
children?: never;
}
export type ButtonProps = CommonButtonProps | IconOnlyButtonProps;
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(props, ref) {
const {
intent = 'primary',
size = 'medium',
loading = false,
fullWidth = false,
disabled = false,
className,
type = 'button',
...rest
} = props;
const iconOnly = 'icon' in props;
const content = iconOnly ? (
<Icon name={props.icon} />
) : (
<>
{props.leadingIcon ? <Icon name={props.leadingIcon} /> : null}
<span className={styles.label}>{props.children}</span>
{props.trailingIcon ? (
<Icon
name={props.trailingIcon}
directionBehavior={
props.trailingIcon === 'arrow-forward' || props.trailingIcon === 'chevron-forward'
? 'mirror'
: 'fixed'
}
/>
) : null}
</>
);
const nativeProps = { ...rest } as ButtonHTMLAttributes<HTMLButtonElement>;
delete (nativeProps as Partial<CommonButtonProps>).leadingIcon;
delete (nativeProps as Partial<CommonButtonProps>).trailingIcon;
delete (nativeProps as Partial<IconOnlyButtonProps>).icon;
return (
<button
ref={ref}
type={type}
className={classNames(
styles.button,
styles[intent],
styles[size],
iconOnly && styles.iconOnly,
fullWidth && styles.fullWidth,
loading && styles.loading,
className,
)}
disabled={disabled || loading}
aria-busy={loading || undefined}
data-loading={loading || undefined}
{...nativeProps}
>
{loading ? <Icon name="spinner" className={styles.spinner} /> : null}
<span className={loading ? styles.loadingContent : undefined}>{content}</span>
</button>
);
});
+22
View File
@@ -0,0 +1,22 @@
'use client';
import { localizedPath, routeIdFromPathname, type Locale } from '@/lib/localization/config';
import { usePathname } from 'next/navigation';
import styles from './SiteHeader.module.css';
export function BrandLink({ locale, label }: { locale: Locale; label: string }) {
const pathname = usePathname();
return (
<a
className={styles.brand}
href={localizedPath('home', locale)}
aria-label={label}
aria-current={routeIdFromPathname(pathname) === 'home' ? 'page' : undefined}
>
<span aria-hidden="true" className={styles.mark}>
RDG
</span>
<span className={styles.brandName}>RentalDriveGo</span>
</a>
);
}
@@ -0,0 +1,38 @@
.field {
display: grid;
gap: var(--space-1);
}
.label {
color: var(--text-secondary);
font-size: var(--type-caption-size);
font-weight: 700;
}
.select {
min-block-size: var(--size-touch-min);
min-inline-size: 7.25rem;
max-inline-size: 100%;
border: 1px solid var(--border-strong);
border-radius: var(--radius-sm);
padding-block: var(--space-2);
padding-inline: var(--space-3) var(--space-8);
background: var(--surface-elevated);
color: var(--text-primary);
}
.select:hover {
border-color: var(--interactive-primary);
}
.compactLabel {
position: absolute;
inline-size: 1px;
block-size: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
border: 0;
clip-path: inset(50%);
white-space: nowrap;
}
@@ -0,0 +1,29 @@
import { StatusBadge } from '@/components/foundation/StatusBadge';
import styles from './SiteHeader.module.css';
interface DisabledActionProps {
label: string;
reason: string;
pendingLabel?: string;
conversion?: boolean;
}
export function DisabledAction({
label,
reason,
pendingLabel,
conversion = false,
}: DisabledActionProps) {
return (
<span
className={conversion ? styles.conversionDisabled : styles.actionDisabled}
role="link"
aria-disabled="true"
aria-label={`${label}. ${reason}`}
title={reason}
>
<span>{label}</span>
{pendingLabel ? <StatusBadge>{pendingLabel}</StatusBadge> : null}
</span>
);
}
@@ -0,0 +1,57 @@
'use client';
import { localizedPath, routeIdFromPathname, type Locale } from '@/lib/localization/config';
import { usePathname } from 'next/navigation';
import { useEffect, useState } from 'react';
import styles from './SiteHeader.module.css';
export type HeaderNavId = 'product' | 'workflow' | 'modules' | 'pricing' | 'faq';
interface HeaderNavigationProps {
locale: Locale;
label: string;
labels: Record<HeaderNavId, string>;
onNavigate?: () => void;
mobile?: boolean;
}
const navigationIds: HeaderNavId[] = ['product', 'workflow', 'modules', 'pricing', 'faq'];
export function HeaderNavigation({
locale,
label,
labels,
onNavigate,
mobile = false,
}: HeaderNavigationProps) {
const pathname = usePathname();
const currentRoute = routeIdFromPathname(pathname);
const [currentHash, setCurrentHash] = useState('');
useEffect(() => {
const updateHash = () => setCurrentHash(window.location.hash.replace(/^#/, ''));
updateHash();
window.addEventListener('hashchange', updateHash);
return () => window.removeEventListener('hashchange', updateHash);
}, []);
const home = localizedPath('home', locale);
return (
<nav aria-label={label} className={mobile ? styles.drawerNavigation : styles.navigation}>
<ul>
{navigationIds.map((id) => (
<li key={id}>
<a
href={`${home}#${id}`}
aria-current={currentRoute === 'home' && currentHash === id ? 'location' : undefined}
onClick={onNavigate}
>
{labels[id]}
</a>
</li>
))}
</ul>
</nav>
);
}
@@ -0,0 +1,52 @@
'use client';
import { persistLocale } from '@/lib/localization/client';
import {
localeSwitchUrl,
locales,
routeIdFromPathname,
type Locale,
} from '@/lib/localization/config';
import { usePathname } from 'next/navigation';
import styles from './Controls.module.css';
interface LocaleSelectorProps {
currentLocale: Locale;
label: string;
localeNames: Record<Locale, string>;
compact?: boolean;
}
export function LocaleSelector({
currentLocale,
label,
localeNames,
compact = false,
}: LocaleSelectorProps) {
const pathname = usePathname();
const routeId = routeIdFromPathname(pathname) ?? 'home';
return (
<label className={styles.field}>
<span className={compact ? styles.compactLabel : styles.label}>{label}</span>
<select
className={styles.select}
aria-label={label}
value={currentLocale}
onChange={(event) => {
const targetLocale = event.target.value as Locale;
persistLocale(targetLocale);
window.location.assign(
localeSwitchUrl(new URL(window.location.href), targetLocale, routeId),
);
}}
>
{locales.map((locale) => (
<option key={locale} value={locale}>
{localeNames[locale]}
</option>
))}
</select>
</label>
);
}
@@ -0,0 +1,15 @@
'use client';
import { getClientShellMessages } from '@/lib/localization/client-messages';
import { useParams } from 'next/navigation';
import { LoadingSkeleton, StateShell } from './StateShell';
export function LocalizedLoading() {
const params = useParams<{ locale?: string }>();
const messages = getClientShellMessages(params?.locale);
return (
<StateShell title={messages.states.loadingTitle} body={messages.states.loadingBody} status>
<LoadingSkeleton />
</StateShell>
);
}
@@ -0,0 +1,20 @@
'use client';
import { getClientShellMessages } from '@/lib/localization/client-messages';
import { isLocale, localizedPath } from '@/lib/localization/config';
import { usePathname } from 'next/navigation';
import { StateAction, StateActions, StateShell } from './StateShell';
export function LocalizedNotFound() {
const pathname = usePathname();
const localeValue = pathname.split('/').filter(Boolean)[0];
const locale = isLocale(localeValue) ? localeValue : 'en';
const messages = getClientShellMessages(locale);
return (
<StateShell title={messages.states.notFoundTitle} body={messages.states.notFoundBody}>
<StateActions>
<StateAction href={localizedPath('home', locale)}>{messages.states.backHome}</StateAction>
</StateActions>
</StateShell>
);
}
@@ -0,0 +1,135 @@
'use client';
import type { ShellMessages } from '@/lib/localization/messages';
import type { Locale } from '@/lib/localization/config';
import type { ThemePreference } from '@/lib/theme/config';
import { useEffect, useRef, useState } from 'react';
import { DisabledAction } from './DisabledAction';
import { HeaderNavigation } from './HeaderNavigation';
import { LocaleSelector } from './LocaleSelector';
import styles from './SiteHeader.module.css';
import { ThemeSelector } from './ThemeSelector';
interface MobileNavigationProps {
locale: Locale;
messages: ShellMessages;
themePreference: ThemePreference;
}
export function MobileNavigation({ locale, messages, themePreference }: MobileNavigationProps) {
const [open, setOpen] = useState(false);
const dialogRef = useRef<HTMLDialogElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const closeRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
if (open && !dialog.open) {
dialog.showModal();
document.body.dataset.navigationOpen = 'true';
closeRef.current?.focus();
} else if (!open && dialog.open) {
dialog.close();
}
return () => {
delete document.body.dataset.navigationOpen;
};
}, [open]);
const close = () => {
dialogRef.current?.close();
setOpen(false);
delete document.body.dataset.navigationOpen;
window.requestAnimationFrame(() => triggerRef.current?.focus());
};
return (
<div className={styles.mobileNavigation}>
<button
ref={triggerRef}
type="button"
className={styles.menuButton}
aria-label={messages.header.menuOpen}
aria-haspopup="dialog"
aria-expanded={open}
onClick={() => setOpen(true)}
>
<span aria-hidden="true" className={styles.menuIcon}>
<span />
<span />
<span />
</span>
</button>
<dialog
ref={dialogRef}
className={styles.drawer}
aria-label={messages.header.navigationLabel}
onClose={() => {
setOpen(false);
delete document.body.dataset.navigationOpen;
}}
onCancel={(event) => {
event.preventDefault();
close();
}}
>
<div className={styles.drawerPanel}>
<div className={styles.drawerHeader}>
<strong>RentalDriveGo</strong>
<button
ref={closeRef}
type="button"
className={styles.closeButton}
aria-label={messages.header.menuClose}
onClick={close}
>
<span aria-hidden="true">×</span>
</button>
</div>
<HeaderNavigation
locale={locale}
label={messages.header.navigationLabel}
labels={messages.header.nav}
mobile
onNavigate={close}
/>
<div
className={styles.drawerControls}
role="group"
aria-label={messages.header.controlsLabel}
>
<LocaleSelector
currentLocale={locale}
label={messages.controls.language}
localeNames={messages.controls.localeNames}
/>
<ThemeSelector
initialPreference={themePreference}
label={messages.controls.theme}
statusTemplate={messages.controls.themeStatus}
optionLabels={messages.controls.themes}
/>
</div>
<div className={styles.drawerActions}>
<DisabledAction
label={messages.header.login}
reason={messages.header.loginUnavailable}
/>
<DisabledAction
label={messages.header.demo}
reason={messages.header.demoUnavailable}
conversion
/>
</div>
</div>
</dialog>
</div>
);
}
@@ -0,0 +1,118 @@
.footer {
border-block-start: 1px solid var(--border-standard);
background: var(--surface-primary);
}
.inner {
display: grid;
inline-size: min(100%, var(--container-wide));
margin-inline: auto;
padding-block: var(--space-12) var(--space-6);
padding-inline: var(--layout-gutter);
gap: var(--space-10);
}
.brandColumn {
display: grid;
align-content: start;
gap: var(--space-4);
max-inline-size: 32rem;
}
.brand {
display: inline-flex;
align-items: center;
gap: var(--space-3);
color: var(--text-primary);
font-weight: 800;
}
.mark {
display: inline-grid;
min-inline-size: var(--size-touch-min);
min-block-size: var(--size-touch-min);
padding-inline: var(--space-2);
place-items: center;
border-radius: var(--radius-sm);
background: var(--interactive-primary);
color: var(--text-inverse);
font-size: var(--type-caption-size);
}
.tagline,
.releaseNotice {
margin: 0;
color: var(--text-secondary);
}
.groups {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 11rem), 1fr));
gap: var(--space-8);
}
.group h2 {
margin-block-end: var(--space-3);
font-size: var(--type-label-size);
letter-spacing: var(--tracking-label);
text-transform: uppercase;
}
html[dir='rtl'] .group h2 {
letter-spacing: normal;
text-transform: none;
}
.group ul {
display: grid;
margin: 0;
padding: 0;
gap: var(--space-2);
list-style: none;
}
.group a,
.pendingItem {
display: inline-flex;
min-block-size: var(--size-touch-min);
align-items: center;
gap: var(--space-2);
color: var(--text-secondary);
text-decoration: none;
}
.group a:hover {
color: var(--interactive-primary);
text-decoration: underline;
}
.pendingItem {
flex-wrap: wrap;
opacity: var(--opacity-disabled);
}
.legalLink {
flex-wrap: wrap;
}
.bottom {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: var(--space-4);
padding-block-start: var(--space-6);
border-block-start: 1px solid var(--border-standard);
color: var(--text-subdued);
font-size: var(--type-caption-size);
}
@media (min-width: 768px) {
.inner {
grid-template-columns: minmax(14rem, 0.8fr) minmax(0, 2fr);
}
.bottom {
grid-column: 1 / -1;
}
}
+153
View File
@@ -0,0 +1,153 @@
import { StatusBadge } from '@/components/foundation/StatusBadge';
import { localizedPath, type Locale } from '@/lib/localization/config';
import type { ShellMessages } from '@/lib/localization/messages';
import styles from './SiteFooter.module.css';
interface SiteFooterProps {
locale: Locale;
messages: ShellMessages;
}
function HashLink({ locale, hash, children }: { locale: Locale; hash: string; children: string }) {
return <a href={`${localizedPath('home', locale)}#${hash}`}>{children}</a>;
}
function PendingRouteLink({
locale,
routeId,
label,
pending,
}: {
locale: Locale;
routeId: 'login' | 'privacy' | 'terms' | 'accessibility';
label: string;
pending: string;
}) {
return (
<a className={styles.legalLink} href={localizedPath(routeId, locale)}>
<span>{label}</span>
<StatusBadge>{pending}</StatusBadge>
</a>
);
}
function PendingItem({ label, pending }: { label: string; pending: string }) {
return (
<span className={styles.pendingItem} aria-disabled="true">
<span>{label}</span>
<StatusBadge>{pending}</StatusBadge>
</span>
);
}
export function SiteFooter({ locale, messages }: SiteFooterProps) {
const footer = messages.footer;
const year = new Date().getUTCFullYear();
return (
<footer className={styles.footer} aria-label={footer.landmarkLabel}>
<div className={styles.inner}>
<div className={styles.brandColumn}>
<div className={styles.brand}>
<span aria-hidden="true" className={styles.mark}>
RDG
</span>
<span>RentalDriveGo</span>
</div>
<p className={styles.tagline}>{footer.tagline}</p>
<p className={styles.releaseNotice}>{footer.releaseNotice}</p>
</div>
<div className={styles.groups}>
<section className={styles.group} aria-labelledby="footer-product">
<h2 id="footer-product">{footer.groups.product}</h2>
<ul>
<li>
<HashLink locale={locale} hash="workflow">
{footer.links.workflow}
</HashLink>
</li>
<li>
<HashLink locale={locale} hash="modules">
{footer.links.modules}
</HashLink>
</li>
<li>
<HashLink locale={locale} hash="pricing">
{footer.links.pricing}
</HashLink>
</li>
<li>
<HashLink locale={locale} hash="faq">
{footer.links.faq}
</HashLink>
</li>
</ul>
</section>
<section className={styles.group} aria-labelledby="footer-company">
<h2 id="footer-company">{footer.groups.company}</h2>
<ul>
<li>
<PendingItem label={footer.links.contact} pending={footer.pending} />
</li>
<li>
<PendingItem label={footer.links.demo} pending={footer.pending} />
</li>
</ul>
</section>
<section className={styles.group} aria-labelledby="footer-account">
<h2 id="footer-account">{footer.groups.account}</h2>
<ul>
<li>
<PendingRouteLink
locale={locale}
routeId="login"
label={footer.links.login}
pending={footer.pending}
/>
</li>
</ul>
</section>
<section className={styles.group} aria-labelledby="footer-legal">
<h2 id="footer-legal">{footer.groups.legal}</h2>
<ul>
<li>
<PendingRouteLink
locale={locale}
routeId="privacy"
label={footer.links.privacy}
pending={footer.pending}
/>
</li>
<li>
<PendingRouteLink
locale={locale}
routeId="terms"
label={footer.links.terms}
pending={footer.pending}
/>
</li>
<li>
<PendingRouteLink
locale={locale}
routeId="accessibility"
label={footer.links.accessibility}
pending={footer.pending}
/>
</li>
</ul>
</section>
</div>
<div className={styles.bottom}>
<span>
{footer.copyrightPrefix} {year} RentalDriveGo
</span>
</div>
</div>
</footer>
);
}
@@ -0,0 +1,263 @@
.header {
position: sticky;
z-index: var(--layer-sticky);
inset-block-start: 0;
border-block-end: 1px solid var(--border-standard);
background: color-mix(in srgb, var(--surface-primary) 94%, transparent);
box-shadow: var(--shadow-subtle);
backdrop-filter: blur(var(--space-3));
}
.inner {
display: grid;
min-block-size: var(--header-min-block-size);
inline-size: min(100%, var(--container-wide));
margin-inline: auto;
padding-inline: var(--layout-gutter);
grid-template-columns: auto minmax(0, 1fr) auto;
align-items: center;
gap: var(--space-5);
}
.brand {
display: inline-flex;
min-block-size: var(--size-touch-min);
align-items: center;
gap: var(--space-3);
color: var(--text-primary);
font-weight: 800;
text-decoration: none;
white-space: nowrap;
}
.mark {
display: inline-grid;
min-inline-size: var(--size-touch-min);
min-block-size: var(--size-touch-min);
padding-inline: var(--space-2);
place-items: center;
border-radius: var(--radius-sm);
background: var(--interactive-primary);
color: var(--text-inverse);
font-size: var(--type-caption-size);
letter-spacing: var(--tracking-label);
}
.navigation ul,
.drawerNavigation ul {
display: flex;
margin: 0;
padding: 0;
list-style: none;
}
.navigation ul {
justify-content: center;
gap: var(--space-1);
}
.navigation a,
.drawerNavigation a {
display: inline-flex;
min-block-size: var(--size-touch-min);
align-items: center;
border-radius: var(--radius-sm);
color: var(--text-primary);
font-weight: 650;
text-decoration: none;
}
.navigation a {
padding-inline: var(--space-3);
}
.navigation a:hover,
.navigation a[aria-current],
.drawerNavigation a:hover,
.drawerNavigation a[aria-current] {
background: var(--surface-muted);
color: var(--interactive-primary);
}
.desktopControls {
display: flex;
align-items: center;
gap: var(--space-2);
}
.actionDisabled,
.conversionDisabled {
display: inline-flex;
min-block-size: var(--size-touch-min);
align-items: center;
justify-content: center;
gap: var(--space-2);
border: 1px solid var(--border-strong);
border-radius: var(--radius-sm);
padding-block: var(--space-2);
padding-inline: var(--space-3);
background: var(--control-disabled-surface);
color: var(--control-disabled-text);
font-weight: 700;
opacity: var(--opacity-disabled);
white-space: nowrap;
}
.conversionDisabled {
border-color: var(--interactive-conversion);
}
.mobileActions,
.mobileNavigation {
display: none;
}
.menuButton,
.closeButton {
display: inline-grid;
min-inline-size: var(--size-touch-min);
min-block-size: var(--size-touch-min);
place-items: center;
border: 1px solid var(--border-strong);
border-radius: var(--radius-sm);
background: var(--surface-elevated);
color: var(--text-primary);
}
.menuIcon {
display: grid;
inline-size: var(--space-5);
gap: var(--space-1);
}
.menuIcon span {
display: block;
block-size: 2px;
border-radius: var(--radius-pill);
background: currentColor;
}
.drawer {
inline-size: min(88vw, 384px);
max-inline-size: none;
block-size: 100dvh;
max-block-size: none;
margin-block: 0;
margin-inline: 0 auto;
padding: 0;
border: 0;
background: transparent;
color: var(--text-primary);
}
html[dir='rtl'] .drawer {
margin-inline: auto 0;
}
.drawer::backdrop {
background: var(--overlay-scrim);
}
.drawerPanel {
display: flex;
block-size: 100%;
flex-direction: column;
gap: var(--space-6);
overflow-y: auto;
padding: var(--space-5);
background: var(--surface-elevated);
box-shadow: var(--shadow-overlay);
}
.drawerHeader {
display: flex;
min-block-size: var(--size-touch-min);
align-items: center;
justify-content: space-between;
gap: var(--space-4);
}
.closeButton {
font-size: var(--type-heading-3-size);
line-height: 1;
}
.drawerNavigation ul {
flex-direction: column;
gap: var(--space-1);
}
.drawerNavigation a {
inline-size: 100%;
padding-inline: var(--space-3);
}
.drawerControls,
.drawerActions {
display: grid;
gap: var(--space-4);
}
.drawerActions {
margin-block-start: auto;
}
@media (min-width: 1120px) and (max-width: 1279px) {
.brandName {
position: absolute;
inline-size: 1px;
block-size: 1px;
margin: -1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
}
@media (max-width: 1119px) {
.inner {
grid-template-columns: minmax(0, 1fr) auto;
}
.desktopNavigation,
.desktopControls {
display: none;
}
.mobileActions,
.mobileNavigation {
display: flex;
align-items: center;
gap: var(--space-2);
}
}
@media (max-width: 559px) {
.brandName {
position: absolute;
inline-size: 1px;
block-size: 1px;
margin: -1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.mobileActions > .conversionDisabled {
display: none;
}
}
@media (forced-colors: active) {
.header {
backdrop-filter: none;
}
.mark,
.actionDisabled,
.conversionDisabled,
.menuButton,
.closeButton {
border: 1px solid CanvasText;
}
}
+69
View File
@@ -0,0 +1,69 @@
import type { Locale } from '@/lib/localization/config';
import type { ShellMessages } from '@/lib/localization/messages';
import type { ThemePreference } from '@/lib/theme/config';
import { BrandLink } from './BrandLink';
import { DisabledAction } from './DisabledAction';
import { HeaderNavigation } from './HeaderNavigation';
import { LocaleSelector } from './LocaleSelector';
import { MobileNavigation } from './MobileNavigation';
import styles from './SiteHeader.module.css';
import { ThemeSelector } from './ThemeSelector';
interface SiteHeaderProps {
locale: Locale;
messages: ShellMessages;
themePreference: ThemePreference;
}
export function SiteHeader({ locale, messages, themePreference }: SiteHeaderProps) {
return (
<header className={styles.header}>
<div className={styles.inner}>
<BrandLink locale={locale} label={messages.brandLabel} />
<div className={styles.desktopNavigation}>
<HeaderNavigation
locale={locale}
label={messages.header.navigationLabel}
labels={messages.header.nav}
/>
</div>
<div
className={styles.desktopControls}
role="group"
aria-label={messages.header.controlsLabel}
>
<LocaleSelector
currentLocale={locale}
label={messages.controls.language}
localeNames={messages.controls.localeNames}
compact
/>
<ThemeSelector
initialPreference={themePreference}
label={messages.controls.theme}
statusTemplate={messages.controls.themeStatus}
optionLabels={messages.controls.themes}
compact
/>
<DisabledAction label={messages.header.login} reason={messages.header.loginUnavailable} />
<DisabledAction
label={messages.header.demo}
reason={messages.header.demoUnavailable}
conversion
/>
</div>
<div className={styles.mobileActions}>
<DisabledAction
label={messages.header.demo}
reason={messages.header.demoUnavailable}
conversion
/>
<MobileNavigation locale={locale} messages={messages} themePreference={themePreference} />
</div>
</div>
</header>
);
}
@@ -0,0 +1,104 @@
.main {
display: grid;
min-block-size: calc(100dvh - var(--header-min-block-size));
place-items: center;
padding-block: var(--section-space);
padding-inline: var(--layout-gutter);
}
.panel {
display: grid;
inline-size: min(100%, var(--container-reading));
gap: var(--space-5);
padding: clamp(var(--space-6), 6vw, var(--space-12));
border: 1px solid var(--border-standard);
border-radius: var(--radius-lg);
background: var(--surface-elevated);
box-shadow: var(--shadow-card);
text-align: start;
}
.panel h1 {
margin: 0;
font-size: var(--type-heading-1-size);
}
.panel p {
margin: 0;
color: var(--text-secondary);
font-size: var(--type-body-large-size);
}
.actions {
display: flex;
flex-wrap: wrap;
gap: var(--space-3);
}
.action {
display: inline-flex;
min-block-size: var(--size-touch-min);
align-items: center;
justify-content: center;
border: 1px solid var(--interactive-primary);
border-radius: var(--radius-sm);
padding-block: var(--space-2);
padding-inline: var(--space-4);
background: var(--interactive-primary);
color: var(--text-inverse);
font-weight: 750;
text-decoration: none;
}
.action:hover {
background: var(--interactive-primary-hover);
color: var(--text-inverse);
}
.skeleton {
display: grid;
gap: var(--space-3);
}
.skeleton span {
display: block;
block-size: var(--space-4);
border-radius: var(--radius-pill);
background: linear-gradient(
90deg,
var(--skeleton-base),
var(--skeleton-highlight),
var(--skeleton-base)
);
background-size: 200% 100%;
animation: skeleton-shift 1.4s var(--easing-standard) infinite;
}
.skeleton span:first-child {
inline-size: 64%;
}
.skeleton span:last-child {
inline-size: 82%;
}
@keyframes skeleton-shift {
from {
background-position: 100% 0;
}
to {
background-position: -100% 0;
}
}
@media (prefers-reduced-motion: reduce) {
.skeleton span {
animation: none;
}
}
.content {
display: grid;
gap: var(--space-4);
}
+55
View File
@@ -0,0 +1,55 @@
import type { ComponentPropsWithoutRef, ReactNode } from 'react';
import styles from './StateShell.module.css';
interface StateShellProps {
title: string;
body: string;
children?: ReactNode;
status?: boolean;
}
export function StateShell({ title, body, children, status = false }: StateShellProps) {
return (
<main id="main-content" className={styles.main} tabIndex={-1}>
<section
className={styles.panel}
role={status ? 'status' : undefined}
aria-live={status ? 'polite' : undefined}
>
<h1>{title}</h1>
<p>{body}</p>
{children ? <div className={styles.content}>{children}</div> : null}
</section>
</main>
);
}
export function StateActions({ children }: { children: ReactNode }) {
return <div className={styles.actions}>{children}</div>;
}
export function StateAction({ children, ...props }: ComponentPropsWithoutRef<'a'>) {
return (
<a className={styles.action} {...props}>
{children}
</a>
);
}
export function StateButton({ children, ...props }: ComponentPropsWithoutRef<'button'>) {
return (
<button className={styles.action} type="button" {...props}>
{children}
</button>
);
}
export function LoadingSkeleton() {
return (
<div className={styles.skeleton} aria-hidden="true">
<span />
<span />
<span />
</div>
);
}
@@ -0,0 +1,31 @@
'use client';
import { applyTheme, currentThemePreference } from '@/lib/theme/client';
import { themeStorageKey } from '@/lib/theme/config';
import { useEffect } from 'react';
export function ThemeController() {
useEffect(() => {
const media = window.matchMedia('(prefers-color-scheme: dark)');
const handleSystemChange = () => {
if (currentThemePreference() === 'system') applyTheme('system');
};
const handleStorage = (event: StorageEvent) => {
if (event.key === themeStorageKey && event.newValue) {
const preference = event.newValue;
if (preference === 'light' || preference === 'dark' || preference === 'system') {
applyTheme(preference);
}
}
};
media.addEventListener('change', handleSystemChange);
window.addEventListener('storage', handleStorage);
return () => {
media.removeEventListener('change', handleSystemChange);
window.removeEventListener('storage', handleStorage);
};
}, []);
return null;
}
@@ -0,0 +1,74 @@
'use client';
import { persistTheme, themePreferenceEvent } from '@/lib/theme/client';
import { themePreferences, themeStorageKey, type ThemePreference } from '@/lib/theme/config';
import { useEffect, useId, useState } from 'react';
import styles from './Controls.module.css';
interface ThemeSelectorProps {
initialPreference: ThemePreference;
label: string;
statusTemplate: string;
optionLabels: Record<ThemePreference, string>;
compact?: boolean;
}
export function ThemeSelector({
initialPreference,
label,
statusTemplate,
optionLabels,
compact = false,
}: ThemeSelectorProps) {
const [preference, setPreference] = useState<ThemePreference>(initialPreference);
const statusId = useId();
useEffect(() => {
const handleStorage = (event: StorageEvent) => {
if (
event.key === themeStorageKey &&
(event.newValue === 'light' || event.newValue === 'dark' || event.newValue === 'system')
) {
setPreference(event.newValue);
}
};
const handlePreference = (event: Event) => {
const preferenceEvent = event as CustomEvent<ThemePreference>;
setPreference(preferenceEvent.detail);
};
window.addEventListener('storage', handleStorage);
window.addEventListener(themePreferenceEvent, handlePreference);
return () => {
window.removeEventListener('storage', handleStorage);
window.removeEventListener(themePreferenceEvent, handlePreference);
};
}, []);
const status = statusTemplate.replace('{value}', optionLabels[preference]);
return (
<label className={styles.field}>
<span className={compact ? styles.compactLabel : styles.label}>{label}</span>
<select
className={styles.select}
aria-label={label}
aria-describedby={statusId}
value={preference}
onChange={(event) => {
const nextPreference = event.target.value as ThemePreference;
setPreference(nextPreference);
persistTheme(nextPreference);
}}
>
{themePreferences.map((option) => (
<option key={option} value={option}>
{optionLabels[option]}
</option>
))}
</select>
<span id={statusId} className="visually-hidden" aria-live="polite">
{status}
</span>
</label>
);
}
@@ -0,0 +1,41 @@
.accordion {
border-block-start: 1px solid var(--border-standard);
}
.item {
border-block-end: 1px solid var(--border-standard);
}
.summary {
display: flex;
min-block-size: 3.5rem;
align-items: center;
justify-content: space-between;
gap: var(--space-4);
padding-block: var(--space-4);
cursor: pointer;
list-style: none;
}
.summary::-webkit-details-marker {
display: none;
}
.heading {
margin: 0;
font-size: var(--type-body-large-size);
}
.icon {
transition: transform var(--duration-fast) var(--easing-productive);
}
.item[open] .icon {
transform: rotate(180deg);
}
.content {
padding-block-end: var(--space-5);
color: var(--text-secondary);
}
.content > :last-child {
margin-block-end: 0;
}
@media (prefers-reduced-motion: reduce) {
.icon {
transition: none;
}
}
+33
View File
@@ -0,0 +1,33 @@
import { Icon } from '@/components/foundation/Icon';
import type { ReactNode } from 'react';
import styles from './Accordion.module.css';
export interface AccordionItem {
id: string;
title: string;
content: ReactNode;
open?: boolean;
}
export function Accordion({
items,
headingLevel = 3,
}: {
items: AccordionItem[];
headingLevel?: 2 | 3 | 4;
}) {
const Heading = `h${headingLevel}` as const;
return (
<div className={styles.accordion}>
{items.map((item) => (
<details key={item.id} className={styles.item} open={item.open}>
<summary className={styles.summary}>
<Heading className={styles.heading}>{item.title}</Heading>
<Icon name="chevron-down" className={styles.icon} />
</summary>
<div className={styles.content}>{item.content}</div>
</details>
))}
</div>
);
}
@@ -0,0 +1,33 @@
.pagination {
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
gap: var(--space-4);
}
.pagination > :last-child {
justify-self: end;
}
.pages {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: var(--space-1);
margin: 0;
padding: 0;
list-style: none;
}
.page {
min-inline-size: 2.75rem;
justify-content: center;
text-decoration: none;
}
.page[aria-current='page'] {
border-radius: var(--radius-sm);
background: var(--interactive-primary);
color: var(--text-inverse);
}
@media (max-width: 479px) {
.pages {
display: none;
}
}
+51
View File
@@ -0,0 +1,51 @@
import { ActionLink } from '@/components/actions/ActionLink';
import { Icon } from '@/components/foundation/Icon';
import styles from './Pagination.module.css';
export interface PaginationPage {
number: number;
href: string;
current?: boolean;
}
interface PaginationProps {
label: string;
pages: PaginationPage[];
previous?: { href: string; label: string };
next?: { href: string; label: string };
}
export function Pagination({ label, pages, previous, next }: PaginationProps) {
return (
<nav aria-label={label} className={styles.pagination}>
{previous ? (
<ActionLink href={previous.href} variant="navigation">
<Icon name="arrow-back" directionBehavior="mirror" /> {previous.label}
</ActionLink>
) : (
<span />
)}
<ol className={styles.pages}>
{pages.map((page) => (
<li key={page.number}>
<ActionLink
href={page.href}
variant="navigation"
aria-current={page.current ? 'page' : undefined}
className={styles.page}
>
{page.number}
</ActionLink>
</li>
))}
</ol>
{next ? (
<ActionLink href={next.href} variant="navigation">
{next.label} <Icon name="arrow-forward" directionBehavior="mirror" />
</ActionLink>
) : (
<span />
)}
</nav>
);
}
@@ -0,0 +1,31 @@
.control {
display: inline-flex;
max-inline-size: 100%;
gap: var(--space-1);
padding: var(--space-1);
border: 1px solid var(--border-standard);
border-radius: var(--radius-sm);
background: var(--surface-muted);
overflow-x: auto;
}
.option {
flex: none;
min-block-size: 2.5rem;
padding-block: var(--space-2);
padding-inline: var(--space-4);
border: 0;
border-radius: calc(var(--radius-sm) - 2px);
background: transparent;
color: var(--text-secondary);
cursor: pointer;
font-weight: 700;
}
.option[aria-checked='true'] {
background: var(--surface-elevated);
color: var(--text-primary);
box-shadow: var(--shadow-subtle);
}
.option:disabled {
cursor: not-allowed;
opacity: var(--opacity-disabled);
}
@@ -0,0 +1,44 @@
'use client';
import { classNames } from '@/lib/components/classNames';
import styles from './SegmentedControl.module.css';
export interface SegmentOption {
value: string;
label: string;
disabled?: boolean;
}
interface SegmentedControlProps {
label: string;
value: string;
options: SegmentOption[];
onChange: (value: string) => void;
className?: string;
}
export function SegmentedControl({
label,
value,
options,
onChange,
className,
}: SegmentedControlProps) {
return (
<div className={classNames(styles.control, className)} role="radiogroup" aria-label={label}>
{options.map((option) => (
<button
key={option.value}
type="button"
role="radio"
aria-checked={option.value === value}
disabled={option.disabled}
className={styles.option}
onClick={() => onChange(option.value)}
>
{option.label}
</button>
))}
</div>
);
}
+33
View File
@@ -0,0 +1,33 @@
.tabs {
min-inline-size: 0;
}
.list {
display: flex;
gap: var(--space-1);
overflow-x: auto;
border-block-end: 1px solid var(--border-standard);
scrollbar-width: thin;
}
.tab {
flex: none;
min-block-size: 2.75rem;
padding-block: var(--space-3);
padding-inline: var(--space-4);
border: 0;
border-block-end: 3px solid transparent;
background: transparent;
color: var(--text-secondary);
cursor: pointer;
font-weight: 700;
}
.tab[aria-selected='true'] {
border-block-end-color: var(--interactive-primary);
color: var(--text-primary);
}
.tab:disabled {
cursor: not-allowed;
opacity: var(--opacity-disabled);
}
.panel {
padding-block: var(--space-5);
}
+116
View File
@@ -0,0 +1,116 @@
'use client';
import { classNames } from '@/lib/components/classNames';
import { useId, useRef, useState, type KeyboardEvent, type ReactNode } from 'react';
import styles from './Tabs.module.css';
export interface TabItem {
id: string;
label: string;
content: ReactNode;
disabled?: boolean;
}
interface TabsProps {
items: TabItem[];
label: string;
defaultActiveId?: string;
activation?: 'automatic' | 'manual';
className?: string;
}
export function Tabs({
items,
label,
defaultActiveId,
activation = 'automatic',
className,
}: TabsProps) {
const available = items.filter((item) => !item.disabled);
const initial =
defaultActiveId && available.some((item) => item.id === defaultActiveId)
? defaultActiveId
: (available[0]?.id ?? '');
const [activeId, setActiveId] = useState(initial);
const [focusedId, setFocusedId] = useState(initial);
const refs = useRef(new Map<string, HTMLButtonElement>());
const prefix = useId().replaceAll(':', '');
const focusByOffset = (currentId: string, offset: number) => {
const currentIndex = available.findIndex((item) => item.id === currentId);
if (currentIndex < 0 || available.length === 0) return;
const next = available[(currentIndex + offset + available.length) % available.length];
if (!next) return;
setFocusedId(next.id);
if (activation === 'automatic') setActiveId(next.id);
refs.current.get(next.id)?.focus();
};
const handleKeyDown = (event: KeyboardEvent<HTMLButtonElement>, id: string) => {
if (event.key === 'ArrowRight') {
event.preventDefault();
focusByOffset(id, document.documentElement.dir === 'rtl' ? -1 : 1);
} else if (event.key === 'ArrowLeft') {
event.preventDefault();
focusByOffset(id, document.documentElement.dir === 'rtl' ? 1 : -1);
} else if (event.key === 'Home') {
event.preventDefault();
const first = available[0];
if (first) focusByOffset(first.id, 0);
} else if (event.key === 'End') {
event.preventDefault();
const last = available.at(-1);
if (last) focusByOffset(last.id, 0);
} else if ((event.key === 'Enter' || event.key === ' ') && activation === 'manual') {
event.preventDefault();
setActiveId(id);
}
};
return (
<div className={classNames(styles.tabs, className)}>
<div className={styles.list} role="tablist" aria-label={label}>
{items.map((item) => {
const selected = item.id === activeId;
return (
<button
key={item.id}
ref={(node) => {
if (node) refs.current.set(item.id, node);
else refs.current.delete(item.id);
}}
type="button"
role="tab"
id={`${prefix}-tab-${item.id}`}
aria-controls={`${prefix}-panel-${item.id}`}
aria-selected={selected}
tabIndex={item.id === focusedId ? 0 : -1}
disabled={item.disabled}
className={styles.tab}
onClick={() => {
setFocusedId(item.id);
setActiveId(item.id);
}}
onKeyDown={(event) => handleKeyDown(event, item.id)}
>
{item.label}
</button>
);
})}
</div>
{items.map((item) => (
<div
key={item.id}
role="tabpanel"
id={`${prefix}-panel-${item.id}`}
aria-labelledby={`${prefix}-tab-${item.id}`}
hidden={item.id !== activeId}
tabIndex={0}
className={styles.panel}
>
{item.content}
</div>
))}
</div>
);
}
+34
View File
@@ -0,0 +1,34 @@
.alert {
display: grid;
grid-template-columns: auto 1fr;
gap: var(--space-3);
padding: var(--space-4);
border: 1px solid currentColor;
border-radius: var(--radius-sm);
}
.inline {
padding: var(--space-3);
}
.info {
background: var(--status-info-surface);
color: var(--status-info-text);
}
.success {
background: var(--status-success-surface);
color: var(--status-success-text);
}
.warning {
background: var(--status-warning-surface);
color: var(--status-warning-text);
}
.error {
background: var(--status-error-surface);
color: var(--status-error-text);
}
.title {
display: block;
margin-block-end: var(--space-1);
}
.content > :last-child {
margin-block-end: 0;
}
+45
View File
@@ -0,0 +1,45 @@
import { Icon, type ApprovedIconName } from '@/components/foundation/Icon';
import { classNames } from '@/lib/components/classNames';
import type { ReactNode } from 'react';
import styles from './Alert.module.css';
export type AlertTone = 'info' | 'success' | 'warning' | 'error';
const toneIcon: Record<AlertTone, ApprovedIconName> = {
info: 'info',
success: 'check',
warning: 'warning',
error: 'error',
};
interface AlertProps {
tone?: AlertTone;
title?: string;
children: ReactNode;
live?: 'off' | 'polite' | 'assertive';
inline?: boolean;
className?: string;
}
export function Alert({
tone = 'info',
title,
children,
live = 'off',
inline = false,
className,
}: AlertProps) {
return (
<div
className={classNames(styles.alert, styles[tone], inline && styles.inline, className)}
role={live === 'assertive' ? 'alert' : live === 'polite' ? 'status' : undefined}
aria-live={live === 'off' ? undefined : live}
>
<Icon name={toneIcon[tone]} />
<div>
{title ? <strong className={styles.title}>{title}</strong> : null}
<div className={styles.content}>{children}</div>
</div>
</div>
);
}
@@ -0,0 +1,43 @@
.spinnerGroup {
display: inline-flex;
align-items: center;
justify-content: center;
}
.spinner {
animation: spin 0.9s linear infinite;
}
.skeleton {
display: grid;
gap: var(--space-3);
}
.skeleton span {
block-size: 0.9rem;
border-radius: var(--radius-pill);
background: linear-gradient(
90deg,
var(--skeleton-base),
var(--skeleton-highlight),
var(--skeleton-base)
);
background-size: 200% 100%;
animation: shimmer 1.5s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
@keyframes shimmer {
to {
background-position: -200% 0;
}
}
@media (prefers-reduced-motion: reduce) {
.spinner {
animation-duration: 1.8s;
}
.skeleton span {
animation: none;
background: var(--skeleton-base);
}
}
+38
View File
@@ -0,0 +1,38 @@
import { Icon } from '@/components/foundation/Icon';
import { classNames } from '@/lib/components/classNames';
import type { HTMLAttributes } from 'react';
import styles from './Loading.module.css';
export function Spinner({ label, className }: { label: string; className?: string }) {
return (
<span className={classNames(styles.spinnerGroup, className)} role="status">
<Icon name="spinner" className={styles.spinner} />
<span className="visually-hidden">{label}</span>
</span>
);
}
interface SkeletonProps extends HTMLAttributes<HTMLDivElement> {
lines?: number;
label?: string;
}
export function Skeleton({
lines = 3,
label = 'Loading content',
className,
...props
}: SkeletonProps) {
return (
<div
className={classNames(styles.skeleton, className)}
aria-label={label}
aria-busy="true"
{...props}
>
{Array.from({ length: lines }, (_, index) => (
<span key={index} style={{ inlineSize: index === lines - 1 ? '68%' : '100%' }} />
))}
</div>
);
}
@@ -0,0 +1,16 @@
.group {
display: grid;
gap: var(--space-2);
}
.labelRow {
display: flex;
justify-content: space-between;
gap: var(--space-4);
font-size: var(--type-label-size);
font-weight: 700;
}
.progress {
inline-size: 100%;
block-size: 0.75rem;
accent-color: var(--interactive-primary);
}
+31
View File
@@ -0,0 +1,31 @@
import { BidiText } from '@/components/foundation/BidiText';
import { classNames } from '@/lib/components/classNames';
import styles from './Progress.module.css';
interface ProgressProps {
value?: number;
max?: number;
label: string;
showValue?: boolean;
className?: string;
}
export function Progress({ value, max = 100, label, showValue = false, className }: ProgressProps) {
const normalized = value === undefined ? undefined : Math.min(max, Math.max(0, value));
return (
<div className={classNames(styles.group, className)}>
<div className={styles.labelRow}>
<span>{label}</span>
{showValue && normalized !== undefined ? (
<BidiText>{`${Math.round((normalized / max) * 100)}%`}</BidiText>
) : null}
</div>
<progress
className={styles.progress}
max={max}
aria-label={label}
{...(normalized === undefined ? {} : { value: normalized })}
/>
</div>
);
}
@@ -0,0 +1,17 @@
.state {
display: grid;
justify-items: start;
max-inline-size: var(--container-reading);
gap: var(--space-3);
padding: var(--space-8);
border: 1px dashed var(--border-strong);
border-radius: var(--radius-md);
background: var(--surface-muted);
}
.state h2,
.state p {
margin: 0;
}
.state p {
color: var(--text-secondary);
}
+25
View File
@@ -0,0 +1,25 @@
import { ActionLink } from '@/components/actions/ActionLink';
import { Icon, type ApprovedIconName } from '@/components/foundation/Icon';
import styles from './StateMessage.module.css';
interface StateMessageProps {
icon?: ApprovedIconName;
title: string;
body: string;
action?: { label: string; href: string };
}
export function StateMessage({ icon = 'info', title, body, action }: StateMessageProps) {
return (
<section className={styles.state}>
<Icon name={icon} size="lg" />
<h2>{title}</h2>
<p>{body}</p>
{action ? (
<ActionLink href={action.href} variant="standalone" icon="arrow-forward">
{action.label}
</ActionLink>
) : null}
</section>
);
}
@@ -0,0 +1,77 @@
.choice {
display: flex;
min-block-size: 2.75rem;
align-items: flex-start;
gap: var(--space-3);
cursor: pointer;
}
.choice > input:not(.switchInput) {
inline-size: 1.25rem;
block-size: 1.25rem;
margin-block-start: 0.2rem;
accent-color: var(--interactive-primary);
}
.content {
display: grid;
gap: var(--space-1);
}
.label {
font-weight: 700;
}
.description {
color: var(--text-secondary);
font-size: var(--type-label-size);
}
.invalid .label {
color: var(--status-error-text);
}
.switchInput {
position: absolute;
inline-size: 1px;
block-size: 1px;
opacity: 0;
}
.switchTrack {
position: relative;
flex: none;
inline-size: 2.75rem;
block-size: 1.5rem;
margin-block-start: 0.1rem;
border: 1px solid var(--border-strong);
border-radius: var(--radius-pill);
background: var(--surface-strong);
transition: background var(--duration-fast) var(--easing-productive);
}
.switchTrack span {
position: absolute;
inset-block-start: 0.18rem;
inset-inline-start: 0.2rem;
inline-size: 1rem;
block-size: 1rem;
border-radius: 50%;
background: var(--surface-elevated);
box-shadow: var(--shadow-subtle);
transition: transform var(--duration-fast) var(--easing-productive);
}
.switchInput:checked + .switchTrack {
background: var(--interactive-primary);
}
.switchInput:checked + .switchTrack span {
transform: translateX(1.2rem);
}
html[dir='rtl'] .switchInput:checked + .switchTrack span {
transform: translateX(-1.2rem);
}
.switchInput:focus-visible + .switchTrack {
outline: 3px solid var(--focus-ring);
outline-offset: 3px;
}
.switchInput:disabled + .switchTrack {
opacity: var(--opacity-disabled);
}
@media (prefers-reduced-motion: reduce) {
.switchTrack,
.switchTrack span {
transition: none;
}
}
+58
View File
@@ -0,0 +1,58 @@
'use client';
import { classNames } from '@/lib/components/classNames';
import type { InputHTMLAttributes, ReactNode } from 'react';
import styles from './ChoiceControls.module.css';
interface ChoiceProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'type'> {
label: ReactNode;
description?: ReactNode;
invalid?: boolean;
}
function Choice({
label,
description,
invalid = false,
className,
type,
...props
}: ChoiceProps & { type: 'checkbox' | 'radio' }) {
return (
<label className={classNames(styles.choice, invalid && styles.invalid, className)}>
<input type={type} aria-invalid={invalid || undefined} {...props} />
<span className={styles.content}>
<span className={styles.label}>{label}</span>
{description ? <span className={styles.description}>{description}</span> : null}
</span>
</label>
);
}
export function Checkbox(props: ChoiceProps) {
return <Choice type="checkbox" {...props} />;
}
export function Radio(props: ChoiceProps) {
return <Choice type="radio" {...props} />;
}
interface SwitchProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'type' | 'role'> {
label: ReactNode;
description?: ReactNode;
}
export function Switch({ label, description, className, ...props }: SwitchProps) {
return (
<label className={classNames(styles.choice, className)}>
<input type="checkbox" role="switch" className={styles.switchInput} {...props} />
<span aria-hidden="true" className={styles.switchTrack}>
<span />
</span>
<span className={styles.content}>
<span className={styles.label}>{label}</span>
{description ? <span className={styles.description}>{description}</span> : null}
</span>
</label>
);
}
@@ -0,0 +1,24 @@
.summary {
padding: var(--space-5);
border: 2px solid var(--status-error-text);
border-radius: var(--radius-sm);
background: var(--status-error-surface);
color: var(--status-error-text);
}
.heading {
display: flex;
align-items: center;
gap: var(--space-2);
}
.heading h2 {
margin: 0;
font-size: var(--type-heading-3-size);
}
.summary ul {
margin-block-end: 0;
padding-inline-start: var(--space-6);
}
.summary a {
color: currentColor;
font-weight: 700;
}
+31
View File
@@ -0,0 +1,31 @@
import { Icon } from '@/components/foundation/Icon';
import styles from './ErrorSummary.module.css';
export interface FormErrorItem {
fieldId: string;
message: string;
}
export function ErrorSummary({ title, errors }: { title: string; errors: FormErrorItem[] }) {
if (errors.length === 0) return null;
return (
<section
className={styles.summary}
role="alert"
aria-labelledby="form-error-summary-title"
tabIndex={-1}
>
<div className={styles.heading}>
<Icon name="error" />
<h2 id="form-error-summary-title">{title}</h2>
</div>
<ul>
{errors.map((error) => (
<li key={error.fieldId}>
<a href={`#${error.fieldId}`}>{error.message}</a>
</li>
))}
</ul>
</section>
);
}
+47
View File
@@ -0,0 +1,47 @@
.field {
display: grid;
gap: var(--space-2);
}
.label {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: var(--space-2);
font-weight: 700;
}
.required {
color: var(--status-error-text);
}
.optional {
color: var(--text-secondary);
font-size: var(--type-caption-size);
font-weight: 500;
}
.help,
.error {
margin: 0;
font-size: var(--type-label-size);
}
.help {
color: var(--text-secondary);
}
.error {
color: var(--status-error-text);
font-weight: 650;
}
.invalid .label {
color: var(--status-error-text);
}
.fieldset {
display: grid;
gap: var(--space-4);
min-inline-size: 0;
margin: 0;
padding: 0;
border: 0;
}
.legend {
margin-block-end: var(--space-3);
padding: 0;
font-weight: 750;
}
+69
View File
@@ -0,0 +1,69 @@
import { classNames } from '@/lib/components/classNames';
import type { ReactNode } from 'react';
import styles from './FormField.module.css';
interface FormFieldProps {
id: string;
label: string;
required?: boolean;
optionalLabel?: string;
supportingText?: string;
error?: string;
children: ReactNode;
className?: string;
}
export function FormField({
id,
label,
required = false,
optionalLabel,
supportingText,
error,
children,
className,
}: FormFieldProps) {
return (
<div className={classNames(styles.field, error && styles.invalid, className)}>
<label className={styles.label} htmlFor={id}>
<span>{label}</span>
{required ? (
<span aria-hidden="true" className={styles.required}>
*
</span>
) : null}
{!required && optionalLabel ? (
<span className={styles.optional}>{optionalLabel}</span>
) : null}
</label>
{children}
{supportingText ? (
<p id={`${id}-help`} className={styles.help}>
{supportingText}
</p>
) : null}
{error ? (
<p id={`${id}-error`} className={styles.error}>
<span aria-hidden="true">!</span> {error}
</p>
) : null}
</div>
);
}
export function Fieldset({
legend,
children,
className,
}: {
legend: string;
children: ReactNode;
className?: string;
}) {
return (
<fieldset className={classNames(styles.fieldset, className)}>
<legend className={styles.legend}>{legend}</legend>
{children}
</fieldset>
);
}
+43
View File
@@ -0,0 +1,43 @@
.control {
inline-size: 100%;
min-block-size: 2.875rem;
padding-block: var(--space-3);
padding-inline: var(--space-4);
border: 1px solid var(--border-strong);
border-radius: var(--radius-sm);
background: var(--surface-primary);
color: var(--text-primary);
}
.control::placeholder {
color: var(--text-subdued);
opacity: 1;
}
.control:hover:not(:disabled) {
border-color: var(--interactive-primary);
}
.control[aria-invalid='true'] {
border-color: var(--status-error-text);
box-shadow: 0 0 0 1px var(--status-error-text);
}
.control:disabled {
background: var(--control-disabled-surface);
color: var(--control-disabled-text);
cursor: not-allowed;
}
.textarea {
min-block-size: 8rem;
resize: vertical;
}
.textareaGroup {
display: grid;
gap: var(--space-1);
}
.count {
margin: 0;
color: var(--text-secondary);
font-size: var(--type-caption-size);
text-align: end;
}
.select {
appearance: auto;
}
+91
View File
@@ -0,0 +1,91 @@
import { BidiText } from '@/components/foundation/BidiText';
import { classNames } from '@/lib/components/classNames';
import type { InputHTMLAttributes, SelectHTMLAttributes, TextareaHTMLAttributes } from 'react';
import styles from './TextInput.module.css';
export type TextInputType = 'text' | 'email' | 'tel' | 'url' | 'search';
interface TextInputProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'type'> {
type?: TextInputType;
invalid?: boolean;
describedBy?: string;
bidiValue?: boolean;
}
export function TextInput({
type = 'text',
invalid = false,
describedBy,
bidiValue = false,
className,
...props
}: TextInputProps) {
const dir = bidiValue || type === 'email' || type === 'tel' || type === 'url' ? 'ltr' : undefined;
return (
<input
type={type}
dir={dir}
className={classNames(styles.control, className)}
aria-invalid={invalid || undefined}
aria-describedby={describedBy}
{...props}
/>
);
}
interface TextAreaProps extends TextareaHTMLAttributes<HTMLTextAreaElement> {
invalid?: boolean;
describedBy?: string;
characterCount?: { current: number; max: number; label: string };
}
export function TextArea({
invalid = false,
describedBy,
characterCount,
className,
...props
}: TextAreaProps) {
const countId = characterCount && props.id ? `${props.id}-count` : undefined;
const ids = [describedBy, countId].filter(Boolean).join(' ') || undefined;
return (
<div className={styles.textareaGroup}>
<textarea
className={classNames(styles.control, styles.textarea, className)}
aria-invalid={invalid || undefined}
aria-describedby={ids}
{...props}
/>
{characterCount ? (
<p id={countId} className={styles.count} aria-live="polite">
<BidiText>{`${characterCount.current}/${characterCount.max}`}</BidiText>{' '}
{characterCount.label}
</p>
) : null}
</div>
);
}
interface SelectProps extends SelectHTMLAttributes<HTMLSelectElement> {
invalid?: boolean;
describedBy?: string;
}
export function Select({
invalid = false,
describedBy,
className,
children,
...props
}: SelectProps) {
return (
<select
className={classNames(styles.control, styles.select, className)}
aria-invalid={invalid || undefined}
aria-describedby={describedBy}
{...props}
>
{children}
</select>
);
}
@@ -0,0 +1,17 @@
import type { ReactElement } from 'react';
interface AccessibleIconProps {
icon: ReactElement;
label?: string;
}
export function AccessibleIcon({ icon, label }: AccessibleIconProps) {
if (label) {
return (
<span role="img" aria-label={label}>
{icon}
</span>
);
}
return <span aria-hidden="true">{icon}</span>;
}
@@ -0,0 +1,3 @@
.noWrap {
white-space: nowrap;
}
+23
View File
@@ -0,0 +1,23 @@
import { classNames } from '@/lib/components/classNames';
import type { HTMLAttributes, ReactNode } from 'react';
import styles from './BidiText.module.css';
interface BidiTextProps extends Omit<HTMLAttributes<HTMLElement>, 'dir'> {
children: ReactNode;
direction?: 'ltr' | 'rtl' | 'auto';
noWrap?: boolean;
}
export function BidiText({
children,
direction = 'ltr',
noWrap = true,
className,
...props
}: BidiTextProps) {
return (
<bdi dir={direction} className={classNames(noWrap && styles.noWrap, className)} {...props}>
{children}
</bdi>
);
}
+25
View File
@@ -0,0 +1,25 @@
.icon {
flex: none;
inline-size: 1.25rem;
block-size: 1.25rem;
}
.sm {
inline-size: 1rem;
block-size: 1rem;
}
.md {
inline-size: 1.25rem;
block-size: 1.25rem;
}
.lg {
inline-size: 1.5rem;
block-size: 1.5rem;
}
html[dir='rtl'] .mirror {
transform: scaleX(-1);
}
@media (prefers-reduced-motion: reduce) {
.icon {
animation: none !important;
}
}
+139
View File
@@ -0,0 +1,139 @@
import { classNames } from '@/lib/components/classNames';
import type { ReactNode, SVGProps } from 'react';
import styles from './Icon.module.css';
export const approvedIconNames = [
'arrow-back',
'arrow-forward',
'calendar',
'car',
'check',
'chevron-down',
'chevron-forward',
'close',
'error',
'external',
'globe',
'info',
'menu',
'minus',
'plus',
'search',
'settings',
'spinner',
'warning',
] as const;
export type ApprovedIconName = (typeof approvedIconNames)[number];
export type IconSize = 'sm' | 'md' | 'lg';
export type IconDirectionBehavior = 'fixed' | 'mirror';
interface IconProps extends Omit<SVGProps<SVGSVGElement>, 'name'> {
name: ApprovedIconName;
size?: IconSize;
decorative?: boolean;
label?: string;
directionBehavior?: IconDirectionBehavior;
}
const paths: Record<ApprovedIconName, ReactNode> = {
'arrow-back': <path d="m15 18-6-6 6-6M9 12h10" />,
'arrow-forward': <path d="m9 18 6-6-6-6m6 6H5" />,
calendar: (
<>
<rect x="3" y="5" width="18" height="16" rx="2" />
<path d="M16 3v4M8 3v4M3 10h18" />
</>
),
car: (
<>
<path d="m5 11 2-5h10l2 5" />
<path d="M3 11h18v7H3zM6 18v2m12-2v2M7 14h.01M17 14h.01" />
</>
),
check: <path d="m5 12 4 4L19 6" />,
'chevron-down': <path d="m6 9 6 6 6-6" />,
'chevron-forward': <path d="m9 18 6-6-6-6" />,
close: <path d="M6 6l12 12M18 6 6 18" />,
error: (
<>
<circle cx="12" cy="12" r="9" />
<path d="M12 7v6m0 4h.01" />
</>
),
external: (
<>
<path d="M14 4h6v6M20 4l-9 9" />
<path d="M18 13v6a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h6" />
</>
),
globe: (
<>
<circle cx="12" cy="12" r="9" />
<path d="M3 12h18M12 3c3 3 3 15 0 18M12 3c-3 3-3 15 0 18" />
</>
),
info: (
<>
<circle cx="12" cy="12" r="9" />
<path d="M12 11v6m0-10h.01" />
</>
),
menu: <path d="M4 7h16M4 12h16M4 17h16" />,
minus: <path d="M5 12h14" />,
plus: <path d="M12 5v14M5 12h14" />,
search: (
<>
<circle cx="11" cy="11" r="7" />
<path d="m16 16 5 5" />
</>
),
settings: (
<>
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.7 1.7 0 0 0 .3 1.9l.1.1-2.8 2.8-.1-.1a1.7 1.7 0 0 0-1.9-.3 1.7 1.7 0 0 0-1 1.6v.2h-4V21a1.7 1.7 0 0 0-1-1.6 1.7 1.7 0 0 0-1.9.3l-.1.1L4.2 17l.1-.1a1.7 1.7 0 0 0 .3-1.9A1.7 1.7 0 0 0 3 14H2.8v-4H3a1.7 1.7 0 0 0 1.6-1 1.7 1.7 0 0 0-.3-1.9L4.2 7 7 4.2l.1.1A1.7 1.7 0 0 0 9 4.6 1.7 1.7 0 0 0 10 3V2.8h4V3a1.7 1.7 0 0 0 1 1.6 1.7 1.7 0 0 0 1.9-.3l.1-.1L19.8 7l-.1.1a1.7 1.7 0 0 0-.3 1.9 1.7 1.7 0 0 0 1.6 1h.2v4H21a1.7 1.7 0 0 0-1.6 1Z" />
</>
),
spinner: <path d="M21 12a9 9 0 1 1-9-9" />,
warning: (
<>
<path d="M12 3 2.5 20h19L12 3Z" />
<path d="M12 9v5m0 3h.01" />
</>
),
};
export function Icon({
name,
size = 'md',
decorative = true,
label,
directionBehavior = 'fixed',
className,
...props
}: IconProps) {
const accessibleProps = decorative
? ({ 'aria-hidden': true } as const)
: ({ role: 'img', 'aria-label': label ?? name } as const);
return (
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
focusable="false"
className={classNames(
styles.icon,
styles[size],
directionBehavior === 'mirror' && styles.mirror,
className,
)}
{...accessibleProps}
{...props}
>
{paths[name]}
</svg>
);
}
@@ -0,0 +1,11 @@
.region {
position: absolute;
inline-size: 1px;
block-size: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
border: 0;
clip-path: inset(50%);
white-space: nowrap;
}
+15
View File
@@ -0,0 +1,15 @@
import type { ReactNode } from 'react';
import styles from './LiveRegion.module.css';
interface LiveRegionProps {
children: ReactNode;
politeness?: 'polite' | 'assertive';
}
export function LiveRegion({ children, politeness = 'polite' }: LiveRegionProps) {
return (
<span className={styles.region} role="status" aria-live={politeness} aria-atomic="true">
{children}
</span>
);
}
@@ -0,0 +1,34 @@
.badge {
display: inline-flex;
min-block-size: 1.75rem;
align-items: center;
gap: var(--space-1);
inline-size: fit-content;
padding-block: var(--space-1);
padding-inline: var(--space-3);
border: 1px solid currentColor;
border-radius: var(--radius-pill);
font-size: var(--type-caption-size);
font-weight: 800;
line-height: 1.2;
}
.neutral {
background: var(--surface-muted);
color: var(--text-secondary);
}
.info {
background: var(--status-info-surface);
color: var(--status-info-text);
}
.success {
background: var(--status-success-surface);
color: var(--status-success-text);
}
.warning {
background: var(--status-warning-surface);
color: var(--status-warning-text);
}
.error {
background: var(--status-error-surface);
color: var(--status-error-text);
}
+29
View File
@@ -0,0 +1,29 @@
import { Icon, type ApprovedIconName } from './Icon';
import styles from './StatusBadge.module.css';
export type StatusTone = 'neutral' | 'info' | 'success' | 'warning' | 'error';
const icons: Partial<Record<StatusTone, ApprovedIconName>> = {
info: 'info',
success: 'check',
warning: 'warning',
error: 'error',
};
export function StatusBadge({
children,
tone = 'neutral',
showIcon = false,
}: {
children: string;
tone?: StatusTone;
showIcon?: boolean;
}) {
const icon = icons[tone];
return (
<span className={`${styles.badge} ${styles[tone]}`}>
{showIcon && icon ? <Icon name={icon} size="sm" /> : null}
{children}
</span>
);
}
@@ -0,0 +1,14 @@
import type { HTMLAttributes, ReactNode } from 'react';
interface VisuallyHiddenProps extends HTMLAttributes<HTMLSpanElement> {
children: ReactNode;
}
export function VisuallyHidden({ children, className, ...props }: VisuallyHiddenProps) {
const classes = ['visually-hidden', className].filter(Boolean).join(' ');
return (
<span className={classes} {...props}>
{children}
</span>
);
}
+74
View File
@@ -0,0 +1,74 @@
'use client';
import { useEffect, useId, useState, useSyncExternalStore, type RefObject } from 'react';
export function useAccessibleId(prefix = 'rdg'): string {
return `${prefix}-${useId().replaceAll(':', '')}`;
}
const subscribeHydration = () => () => undefined;
export function useHydrated(): boolean {
return useSyncExternalStore(
subscribeHydration,
() => true,
() => false,
);
}
export function useMediaQuery(query: string): boolean {
const [matches, setMatches] = useState(false);
useEffect(() => {
const media = window.matchMedia(query);
const update = () => setMatches(media.matches);
update();
media.addEventListener('change', update);
return () => media.removeEventListener('change', update);
}, [query]);
return matches;
}
export function useReducedMotion(): boolean {
return useMediaQuery('(prefers-reduced-motion: reduce)');
}
export function useEscapeKey(onEscape: () => void, enabled = true): void {
useEffect(() => {
if (!enabled) return;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') onEscape();
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [enabled, onEscape]);
}
export function useOutsideClick<T extends HTMLElement>(
ref: RefObject<T | null>,
onOutside: () => void,
enabled = true,
): void {
useEffect(() => {
if (!enabled) return;
const handlePointerDown = (event: PointerEvent) => {
if (ref.current && !ref.current.contains(event.target as Node)) onOutside();
};
document.addEventListener('pointerdown', handlePointerDown);
return () => document.removeEventListener('pointerdown', handlePointerDown);
}, [enabled, onOutside, ref]);
}
export function useScrollLock(locked: boolean): void {
useEffect(() => {
if (!locked) return;
const previousOverflow = document.body.style.overflow;
const previousPadding = document.body.style.paddingInlineEnd;
const scrollbar = window.innerWidth - document.documentElement.clientWidth;
document.body.style.overflow = 'hidden';
if (scrollbar > 0) document.body.style.paddingInlineEnd = `${scrollbar}px`;
return () => {
document.body.style.overflow = previousOverflow;
document.body.style.paddingInlineEnd = previousPadding;
};
}, [locked]);
}
@@ -0,0 +1,81 @@
.container {
inline-size: min(100%, var(--container-wide));
margin-inline: auto;
padding-inline: var(--layout-gutter);
}
.content {
max-inline-size: var(--container-content);
}
.reading {
max-inline-size: var(--container-reading);
}
.full {
max-inline-size: none;
}
.section {
padding-block: var(--section-space);
}
.sectionMuted {
background: var(--surface-muted);
}
.sectionStrong {
background: var(--surface-strong);
}
.stack {
display: flex;
flex-direction: column;
}
.stackSmall {
gap: var(--space-3);
}
.stackMedium {
gap: var(--space-6);
}
.stackLarge {
gap: var(--space-12);
}
.cluster {
display: flex;
flex-wrap: wrap;
align-items: center;
}
.clusterSmall {
gap: var(--space-2);
}
.clusterMedium {
gap: var(--space-4);
}
.clusterLarge {
gap: var(--space-8);
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 16rem), 1fr));
gap: var(--space-6);
}
.bleed {
inline-size: 100vw;
margin-inline: calc(50% - 50vw);
}
.sticky {
position: sticky;
z-index: var(--layer-sticky);
inset-block-start: 0;
}
@@ -0,0 +1,92 @@
import type { HTMLAttributes, ReactNode } from 'react';
import styles from './LayoutPrimitives.module.css';
type ContainerWidth = 'wide' | 'content' | 'reading' | 'full';
type Gap = 'small' | 'medium' | 'large';
type SectionTone = 'default' | 'muted' | 'strong';
function classes(...values: Array<string | false | undefined>): string {
return values.filter(Boolean).join(' ');
}
export function Container({
children,
width = 'wide',
className,
...props
}: HTMLAttributes<HTMLDivElement> & { children: ReactNode; width?: ContainerWidth }) {
return (
<div className={classes(styles.container, styles[width], className)} {...props}>
{children}
</div>
);
}
export function Section({
children,
tone = 'default',
className,
...props
}: HTMLAttributes<HTMLElement> & { children: ReactNode; tone?: SectionTone }) {
return (
<section
className={classes(
styles.section,
tone === 'muted' && styles.sectionMuted,
tone === 'strong' && styles.sectionStrong,
className,
)}
{...props}
>
{children}
</section>
);
}
export function Stack({
children,
gap = 'medium',
className,
...props
}: HTMLAttributes<HTMLDivElement> & { children: ReactNode; gap?: Gap }) {
const gapClass = {
small: styles.stackSmall,
medium: styles.stackMedium,
large: styles.stackLarge,
}[gap];
return (
<div className={classes(styles.stack, gapClass, className)} {...props}>
{children}
</div>
);
}
export function Cluster({
children,
gap = 'medium',
className,
...props
}: HTMLAttributes<HTMLDivElement> & { children: ReactNode; gap?: Gap }) {
const gapClass = {
small: styles.clusterSmall,
medium: styles.clusterMedium,
large: styles.clusterLarge,
}[gap];
return (
<div className={classes(styles.cluster, gapClass, className)} {...props}>
{children}
</div>
);
}
export function ResponsiveGrid({
children,
className,
...props
}: HTMLAttributes<HTMLDivElement> & { children: ReactNode }) {
return (
<div className={classes(styles.grid, className)} {...props}>
{children}
</div>
);
}
+42
View File
@@ -0,0 +1,42 @@
.cta {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: var(--space-8);
}
.copy {
display: grid;
gap: var(--space-3);
}
.eyebrow {
margin: 0;
color: var(--interactive-primary);
font-size: var(--type-label-size);
font-weight: 800;
}
.actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: var(--space-3);
}
.inline {
border-radius: var(--radius-sm);
}
.card {
grid-template-columns: 1fr;
}
.card .actions {
justify-content: flex-start;
}
@media (max-width: 767px) {
.cta {
grid-template-columns: 1fr;
}
.actions {
justify-content: flex-start;
}
.actions > * {
inline-size: 100%;
}
}
+65
View File
@@ -0,0 +1,65 @@
import { ActionLink } from '@/components/actions/ActionLink';
import { Card } from '@/components/surfaces/Card';
import { Heading, Text, type HeadingLevel } from '@/components/typography/Typography';
import styles from './CTA.module.css';
export interface CTAAction {
label: string;
href?: string;
disabledReason?: string;
}
interface CTAProps {
eyebrow?: string;
title: string;
body?: string;
primary: CTAAction;
secondary?: CTAAction;
headingLevel?: HeadingLevel;
layout?: 'inline' | 'section' | 'card';
}
export function CTA({
eyebrow,
title,
body,
primary,
secondary,
headingLevel = 2,
layout = 'section',
}: CTAProps) {
return (
<Card
as="section"
tone={layout === 'section' ? 'strong' : 'elevated'}
padding={layout === 'inline' ? 'compact' : 'spacious'}
className={`${styles.cta} ${styles[layout]}`}
>
<div className={styles.copy}>
{eyebrow ? <p className={styles.eyebrow}>{eyebrow}</p> : null}
<Heading level={headingLevel} appearance={layout === 'inline' ? 'subheading' : 'section'}>
{title}
</Heading>
{body ? <Text variant="supporting">{body}</Text> : null}
</div>
<div className={styles.actions}>
<ActionLink
href={primary.href ?? '/'}
variant="button-conversion"
{...(primary.disabledReason ? { disabledReason: primary.disabledReason } : {})}
>
{primary.label}
</ActionLink>
{secondary ? (
<ActionLink
href={secondary.href ?? '/'}
variant="button-primary"
{...(secondary.disabledReason ? { disabledReason: secondary.disabledReason } : {})}
>
{secondary.label}
</ActionLink>
) : null}
</div>
</Card>
);
}
@@ -0,0 +1,35 @@
.wrapper {
display: grid;
gap: var(--space-6);
}
.grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--space-6);
}
.column {
display: grid;
align-content: start;
gap: var(--space-4);
}
.column ul {
display: grid;
gap: var(--space-3);
margin: 0;
padding: 0;
list-style: none;
}
.column li {
display: grid;
grid-template-columns: auto 1fr;
gap: var(--space-2);
align-items: start;
}
.column li svg {
margin-block-start: 0.25rem;
}
@media (max-width: 767px) {
.grid {
grid-template-columns: 1fr;
}
}
+51
View File
@@ -0,0 +1,51 @@
import { Icon } from '@/components/foundation/Icon';
import { Card } from '@/components/surfaces/Card';
import { Heading, Text, type HeadingLevel } from '@/components/typography/Typography';
import styles from './Comparison.module.css';
export interface ComparisonColumn {
title: string;
items: string[];
tone: 'problem' | 'solution';
}
interface ComparisonProps {
title?: string;
body?: string;
columns: [ComparisonColumn, ComparisonColumn];
headingLevel?: HeadingLevel;
}
export function Comparison({ title, body, columns, headingLevel = 2 }: ComparisonProps) {
return (
<div className={styles.wrapper}>
{title ? (
<Heading level={headingLevel} appearance="section">
{title}
</Heading>
) : null}
{body ? <Text variant="lead">{body}</Text> : null}
<div className={styles.grid}>
{columns.map((column) => (
<Card
key={column.title}
tone={column.tone === 'solution' ? 'elevated' : 'muted'}
className={styles.column}
>
<Heading level={(headingLevel + 1) as 2 | 3 | 4 | 5 | 6} appearance="subheading">
{column.title}
</Heading>
<ul>
{column.items.map((item) => (
<li key={item}>
<Icon name={column.tone === 'solution' ? 'check' : 'minus'} size="sm" />
<span>{item}</span>
</li>
))}
</ul>
</Card>
))}
</div>
</div>
);
}
@@ -0,0 +1,25 @@
.metric {
display: grid;
align-content: start;
gap: var(--space-3);
}
.value {
display: block;
font-size: var(--type-heading-1-size);
font-weight: 850;
line-height: 1;
}
.source {
color: var(--text-secondary);
}
.shell {
display: grid;
gap: var(--space-4);
}
.shellHeader {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: var(--space-3);
}
+81
View File
@@ -0,0 +1,81 @@
import { BidiText } from '@/components/foundation/BidiText';
import { StatusBadge } from '@/components/foundation/StatusBadge';
import { Card } from '@/components/surfaces/Card';
import { Heading, Text, type HeadingLevel } from '@/components/typography/Typography';
import type { ReactNode } from 'react';
import styles from './Evidence.module.css';
export type EvidenceStatus = 'approved' | 'pending-review' | 'research-only' | 'prohibited';
export interface MetricContent {
value: string;
label: string;
qualification?: string;
sourceReference?: string;
status: EvidenceStatus;
}
const statusTone = {
approved: 'success',
'pending-review': 'warning',
'research-only': 'info',
prohibited: 'error',
} as const;
export function canRenderProductionEvidence(status: EvidenceStatus): boolean {
return status === 'approved';
}
interface MetricProps {
content: MetricContent;
statusLabel: string;
production?: boolean;
headingLevel?: HeadingLevel;
}
export function Metric({
content,
statusLabel,
production = false,
headingLevel = 3,
}: MetricProps) {
if (production && !canRenderProductionEvidence(content.status)) return null;
return (
<Card className={styles.metric}>
<StatusBadge tone={statusTone[content.status]}>{statusLabel}</StatusBadge>
<BidiText className={styles.value}>{content.value}</BidiText>
<Heading level={headingLevel} appearance="subheading">
{content.label}
</Heading>
{content.qualification ? <Text variant="supporting">{content.qualification}</Text> : null}
{content.sourceReference ? (
<small className={styles.source}>{content.sourceReference}</small>
) : null}
</Card>
);
}
export function EvidenceShell({
title,
status,
statusLabel,
children,
}: {
title: string;
status: EvidenceStatus;
statusLabel: string;
children?: ReactNode;
}) {
if (status === 'prohibited') return null;
return (
<Card className={styles.shell} tone="muted">
<div className={styles.shellHeader}>
<Heading level={3} appearance="subheading">
{title}
</Heading>
<StatusBadge tone={statusTone[status]}>{statusLabel}</StatusBadge>
</div>
{children}
</Card>
);
}
@@ -0,0 +1,18 @@
.card {
display: grid;
align-content: start;
gap: var(--space-4);
block-size: 100%;
}
.icon {
display: grid;
place-items: center;
inline-size: 3rem;
block-size: 3rem;
border-radius: var(--radius-sm);
background: var(--status-info-surface);
color: var(--status-info-text);
}
.card > :last-child {
margin-block-start: auto;
}
+50
View File
@@ -0,0 +1,50 @@
import { ActionLink } from '@/components/actions/ActionLink';
import { Icon, type ApprovedIconName } from '@/components/foundation/Icon';
import { Card } from '@/components/surfaces/Card';
import { Heading, Text, type HeadingLevel } from '@/components/typography/Typography';
import styles from './FeatureCard.module.css';
interface FeatureCardProps {
title: string;
description: string;
icon?: ApprovedIconName;
headingLevel?: HeadingLevel;
emphasis?: 'default' | 'primary' | 'muted';
action?: { label: string; href: string; disabledReason?: string };
}
export function FeatureCard({
title,
description,
icon,
headingLevel = 3,
emphasis = 'default',
action,
}: FeatureCardProps) {
return (
<Card
tone={emphasis === 'muted' ? 'muted' : emphasis === 'primary' ? 'elevated' : 'default'}
className={styles.card}
>
{icon ? (
<span className={styles.icon}>
<Icon name={icon} size="lg" />
</span>
) : null}
<Heading level={headingLevel} appearance="subheading">
{title}
</Heading>
<Text variant="supporting">{description}</Text>
{action ? (
<ActionLink
href={action.href}
variant="standalone"
icon="arrow-forward"
{...(action.disabledReason ? { disabledReason: action.disabledReason } : {})}
>
{action.label}
</ActionLink>
) : null}
</Card>
);
}
@@ -0,0 +1,69 @@
.figure {
display: grid;
gap: var(--space-3);
margin: 0;
}
.preview {
overflow: hidden;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-4);
padding: var(--space-5);
border-block-end: 1px solid var(--border-standard);
}
.header > div {
display: grid;
gap: var(--space-1);
}
.dots {
display: flex;
gap: var(--space-1);
}
.dots i {
inline-size: 0.5rem;
block-size: 0.5rem;
border-radius: 50%;
background: var(--border-strong);
}
.table {
display: grid;
}
.row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: var(--space-4);
padding: var(--space-4) var(--space-5);
border-block-end: 1px solid var(--border-standard);
}
.row:last-child {
border-block-end: 0;
}
.row > div:first-child {
display: grid;
gap: var(--space-1);
min-inline-size: 0;
}
.secondary {
color: var(--text-secondary);
font-size: var(--type-caption-size);
overflow-wrap: anywhere;
}
.empty {
margin: 0;
padding: var(--space-8);
color: var(--text-secondary);
text-align: center;
}
.figure figcaption {
color: var(--text-secondary);
font-size: var(--type-caption-size);
}
@media (max-width: 479px) {
.row {
grid-template-columns: 1fr;
}
}
@@ -0,0 +1,71 @@
import { BidiText } from '@/components/foundation/BidiText';
import { StatusBadge, type StatusTone } from '@/components/foundation/StatusBadge';
import { Card } from '@/components/surfaces/Card';
import { Heading, Text, type HeadingLevel } from '@/components/typography/Typography';
import styles from './ProductPreview.module.css';
export interface PreviewRow {
id: string;
primary: string;
secondary: string;
status: string;
statusTone?: StatusTone;
}
interface ProductPreviewProps {
title: string;
caption: string;
rows: PreviewRow[];
illustrativeLabel: string;
emptyLabel?: string;
headingLevel?: HeadingLevel;
}
export function ProductPreview({
title,
caption,
rows,
illustrativeLabel,
emptyLabel = 'No preview data',
headingLevel = 3,
}: ProductPreviewProps) {
return (
<figure className={styles.figure}>
<Card tone="elevated" padding="none" className={styles.preview}>
<header className={styles.header}>
<div>
<Heading level={headingLevel} appearance="subheading">
{title}
</Heading>
<Text variant="caption">{illustrativeLabel}</Text>
</div>
<span className={styles.dots} aria-hidden="true">
<i />
<i />
<i />
</span>
</header>
{rows.length > 0 ? (
<div className={styles.table} role="table" aria-label={title}>
{rows.map((row) => (
<div key={row.id} className={styles.row} role="row">
<div role="cell">
<strong>{row.primary}</strong>
<BidiText className={styles.secondary}>{row.secondary}</BidiText>
</div>
<div role="cell">
<StatusBadge {...(row.statusTone ? { tone: row.statusTone } : {})}>
{row.status}
</StatusBadge>
</div>
</div>
))}
</div>
) : (
<p className={styles.empty}>{emptyLabel}</p>
)}
</Card>
<figcaption>{caption}</figcaption>
</figure>
);
}
@@ -0,0 +1,13 @@
.header {
display: grid;
gap: var(--space-4);
max-inline-size: var(--container-reading);
}
.center {
margin-inline: auto;
justify-items: center;
text-align: center;
}
.center p {
margin-inline: auto;
}
@@ -0,0 +1,31 @@
import { Eyebrow, Heading, Text, type HeadingLevel } from '@/components/typography/Typography';
import { classNames } from '@/lib/components/classNames';
import styles from './SectionHeader.module.css';
interface SectionHeaderProps {
eyebrow?: string;
title: string;
body?: string;
headingLevel?: HeadingLevel;
alignment?: 'start' | 'center';
className?: string;
}
export function SectionHeader({
eyebrow,
title,
body,
headingLevel = 2,
alignment = 'start',
className,
}: SectionHeaderProps) {
return (
<header className={classNames(styles.header, styles[alignment], className)}>
{eyebrow ? <Eyebrow>{eyebrow}</Eyebrow> : null}
<Heading level={headingLevel} appearance="section">
{title}
</Heading>
{body ? <Text variant="lead">{body}</Text> : null}
</header>
);
}
@@ -0,0 +1,44 @@
.featureGrid {
grid-template-columns: repeat(auto-fit, minmax(min(100%, 18rem), 1fr));
}
.split {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
align-items: center;
gap: clamp(var(--space-8), 7vw, var(--space-16));
}
.copy {
min-inline-size: 0;
}
.media {
min-inline-size: 0;
}
.reverse .copy {
order: 2;
}
.reverse .media {
order: 1;
}
.metricStrip {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 12rem), 1fr));
gap: var(--space-4);
}
.logoStrip {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: var(--space-6);
padding: var(--space-6);
border-block: 1px solid var(--border-standard);
}
@media (max-width: 767px) {
.split {
grid-template-columns: 1fr;
}
.reverse .copy,
.reverse .media {
order: initial;
}
}
@@ -0,0 +1,36 @@
import { ResponsiveGrid } from '@/components/layout/LayoutPrimitives';
import type { ReactNode } from 'react';
import styles from './SectionPatterns.module.css';
export function FeatureGrid({ children }: { children: ReactNode }) {
return <ResponsiveGrid className={styles.featureGrid}>{children}</ResponsiveGrid>;
}
export function SplitContent({
copy,
media,
reverse = false,
}: {
copy: ReactNode;
media: ReactNode;
reverse?: boolean;
}) {
return (
<div className={`${styles.split} ${reverse ? styles.reverse : ''}`}>
<div className={styles.copy}>{copy}</div>
<div className={styles.media}>{media}</div>
</div>
);
}
export function MetricStrip({ children }: { children: ReactNode }) {
return <div className={styles.metricStrip}>{children}</div>;
}
export function LogoStripShell({ label, children }: { label: string; children: ReactNode }) {
return (
<div className={styles.logoStrip} role="group" aria-label={label}>
{children}
</div>
);
}
@@ -0,0 +1,48 @@
.workflow {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 13rem), 1fr));
gap: var(--space-6);
margin: 0;
padding: 0;
list-style: none;
counter-reset: workflow;
}
.step {
position: relative;
display: grid;
grid-template-columns: auto 1fr;
align-content: start;
gap: var(--space-4);
min-inline-size: 0;
}
.marker {
position: relative;
z-index: 1;
display: grid;
place-items: center;
inline-size: 3rem;
block-size: 3rem;
border: 2px solid var(--interactive-primary);
border-radius: 50%;
background: var(--surface-elevated);
color: var(--interactive-primary);
font-weight: 800;
}
.content {
display: grid;
gap: var(--space-2);
}
.connector {
position: absolute;
inset-block-start: 0.8rem;
inset-inline-end: calc(var(--space-6) * -0.75);
color: var(--text-subdued);
}
@media (max-width: 767px) {
.workflow {
grid-template-columns: 1fr;
}
.connector {
display: none;
}
}
+43
View File
@@ -0,0 +1,43 @@
import { BidiText } from '@/components/foundation/BidiText';
import { Icon, type ApprovedIconName } from '@/components/foundation/Icon';
import { Heading, Text, type HeadingLevel } from '@/components/typography/Typography';
import styles from './Workflow.module.css';
export interface WorkflowItem {
id: string;
ordinal: string;
title: string;
description: string;
icon?: ApprovedIconName;
}
interface WorkflowProps {
items: WorkflowItem[];
label: string;
headingLevel?: HeadingLevel;
}
export function Workflow({ items, label, headingLevel = 3 }: WorkflowProps) {
return (
<ol className={styles.workflow} aria-label={label}>
{items.map((item, index) => (
<li key={item.id} className={styles.step}>
<div className={styles.marker} aria-hidden="true">
{item.icon ? <Icon name={item.icon} /> : <BidiText>{item.ordinal}</BidiText>}
</div>
<div className={styles.content}>
<Heading level={headingLevel} appearance="subheading">
{item.title}
</Heading>
<Text variant="supporting">{item.description}</Text>
</div>
{index < items.length - 1 ? (
<span className={styles.connector} aria-hidden="true">
<Icon name="arrow-forward" directionBehavior="mirror" />
</span>
) : null}
</li>
))}
</ol>
);
}
@@ -0,0 +1,39 @@
.figure {
display: grid;
gap: var(--space-2);
margin: 0;
}
.frame {
position: relative;
display: grid;
place-items: center;
min-inline-size: 0;
overflow: hidden;
border: 1px solid var(--border-standard);
border-radius: var(--radius-md);
background: var(--surface-muted);
}
.square {
aspect-ratio: 1;
}
.landscape {
aspect-ratio: 4 / 3;
}
.wide {
aspect-ratio: 16 / 9;
}
.portrait {
aspect-ratio: 3 / 4;
}
.auto {
aspect-ratio: auto;
}
.image {
inline-size: 100%;
block-size: 100%;
object-fit: cover;
}
.figure figcaption {
color: var(--text-secondary);
font-size: var(--type-caption-size);
}
+47
View File
@@ -0,0 +1,47 @@
import { classNames } from '@/lib/components/classNames';
import Image, { type ImageProps } from 'next/image';
import type { ReactNode } from 'react';
import styles from './MediaFrame.module.css';
export type MediaRatio = 'square' | 'landscape' | 'wide' | 'portrait' | 'auto';
interface MediaFrameProps {
ratio?: MediaRatio;
children: ReactNode;
caption?: string;
decorative?: boolean;
className?: string;
}
export function MediaFrame({
ratio = 'landscape',
children,
caption,
decorative = false,
className,
}: MediaFrameProps) {
return (
<figure className={classNames(styles.figure, className)} aria-hidden={decorative || undefined}>
<div className={classNames(styles.frame, styles[ratio])}>{children}</div>
{caption ? <figcaption>{caption}</figcaption> : null}
</figure>
);
}
interface ResponsiveImageProps extends Omit<ImageProps, 'fill'> {
ratio?: MediaRatio;
caption?: string;
}
export function ResponsiveImage({
ratio = 'landscape',
caption,
alt,
...props
}: ResponsiveImageProps) {
return (
<MediaFrame ratio={ratio} decorative={alt === ''} {...(caption ? { caption } : {})}>
<Image alt={alt} {...props} className={classNames(styles.image, props.className)} />
</MediaFrame>
);
}
@@ -0,0 +1,18 @@
.nav ol {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
margin: 0;
padding: 0;
list-style: none;
}
.nav li {
display: inline-flex;
align-items: center;
gap: var(--space-2);
color: var(--text-secondary);
font-size: var(--type-label-size);
}
.separator {
color: var(--text-subdued);
}
+39
View File
@@ -0,0 +1,39 @@
import { ActionLink } from '@/components/actions/ActionLink';
import { Icon } from '@/components/foundation/Icon';
import styles from './Breadcrumbs.module.css';
export interface BreadcrumbItem {
label: string;
href?: string;
}
export function Breadcrumbs({ label, items }: { label: string; items: BreadcrumbItem[] }) {
return (
<nav aria-label={label} className={styles.nav}>
<ol>
{items.map((item, index) => {
const current = index === items.length - 1;
return (
<li key={`${item.label}-${index}`}>
{index > 0 ? (
<Icon
name="chevron-forward"
size="sm"
directionBehavior="mirror"
className={styles.separator}
/>
) : null}
{item.href && !current ? (
<ActionLink href={item.href} variant="inline">
{item.label}
</ActionLink>
) : (
<span aria-current={current ? 'page' : undefined}>{item.label}</span>
)}
</li>
);
})}
</ol>
</nav>
);
}
@@ -0,0 +1,11 @@
.group h2 {
margin: 0;
font-size: var(--type-body-size);
}
.group ul {
display: grid;
gap: var(--space-2);
margin: var(--space-3) 0 0;
padding: 0;
list-style: none;
}
@@ -0,0 +1,30 @@
import { ActionLink } from '@/components/actions/ActionLink';
import styles from './FooterLinkGroup.module.css';
export interface FooterLinkItem {
id: string;
label: string;
href?: string;
disabledReason?: string;
}
export function FooterLinkGroup({ title, links }: { title: string; links: FooterLinkItem[] }) {
return (
<section className={styles.group}>
<h2>{title}</h2>
<ul>
{links.map((link) => (
<li key={link.id}>
<ActionLink
href={link.href ?? '/'}
variant="muted"
{...(link.disabledReason ? { disabledReason: link.disabledReason } : {})}
>
{link.label}
</ActionLink>
</li>
))}
</ul>
</section>
);
}
@@ -0,0 +1,20 @@
.nav {
overflow-x: auto;
border-block-end: 1px solid var(--border-standard);
}
.nav ul {
display: flex;
inline-size: max-content;
min-inline-size: 100%;
gap: var(--space-6);
margin: 0;
padding: 0;
list-style: none;
}
.nav a {
border-block-end: 3px solid transparent;
}
.nav a[aria-current='location'] {
border-block-end-color: var(--interactive-primary);
color: var(--text-primary);
}
@@ -0,0 +1,35 @@
import { ActionLink } from '@/components/actions/ActionLink';
import styles from './SectionNavigation.module.css';
export interface SectionNavigationItem {
id: string;
label: string;
href: string;
current?: boolean;
}
export function SectionNavigation({
label,
items,
}: {
label: string;
items: SectionNavigationItem[];
}) {
return (
<nav aria-label={label} className={styles.nav}>
<ul>
{items.map((item) => (
<li key={item.id}>
<ActionLink
href={item.href}
variant="navigation"
aria-current={item.current ? 'location' : undefined}
>
{item.label}
</ActionLink>
</li>
))}
</ul>
</nav>
);
}
+71
View File
@@ -0,0 +1,71 @@
.dialog {
max-inline-size: none;
max-block-size: none;
margin: auto;
padding: 0;
border: 0;
background: transparent;
color: var(--text-primary);
overflow: visible;
}
.dialog::backdrop {
background: var(--overlay-scrim);
}
.panel {
display: grid;
max-block-size: calc(100dvh - var(--space-8));
overflow: hidden;
border: 1px solid var(--border-standard);
border-radius: var(--radius-md);
background: var(--surface-elevated);
box-shadow: var(--shadow-overlay);
}
.modal .panel {
inline-size: min(44rem, calc(100vw - var(--space-8)));
}
.drawer {
margin-block: 0;
margin-inline-start: auto;
margin-inline-end: 0;
}
.drawer .panel {
inline-size: min(24rem, 88vw);
min-block-size: 100dvh;
border-radius: 0;
}
.header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--space-4);
padding: var(--space-6);
border-block-end: 1px solid var(--border-standard);
}
.header h2 {
margin: 0;
font-size: var(--type-heading-3-size);
}
.header p {
margin-block: var(--space-2) 0;
color: var(--text-secondary);
}
.content {
min-block-size: 0;
padding: var(--space-6);
overflow-y: auto;
}
@media (max-width: 479px) {
.modal .panel {
inline-size: calc(100vw - var(--space-4));
max-block-size: calc(100dvh - var(--space-4));
}
.header,
.content {
padding: var(--space-4);
}
}
@media (forced-colors: active) {
.panel {
border: 2px solid CanvasText;
}
}
+99
View File
@@ -0,0 +1,99 @@
'use client';
import { Button } from '@/components/actions/Button';
import { useScrollLock } from '@/components/foundation/client-hooks';
import { classNames } from '@/lib/components/classNames';
import { useEffect, useId, useRef, type ReactNode, type RefObject } from 'react';
import styles from './Dialog.module.css';
interface DialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
title: string;
description?: string;
children: ReactNode;
closeLabel: string;
initialFocusRef?: RefObject<HTMLElement | null>;
returnFocusRef?: RefObject<HTMLElement | null>;
variant?: 'modal' | 'drawer';
dismissible?: boolean;
className?: string;
}
export function Dialog({
open,
onOpenChange,
title,
description,
children,
closeLabel,
initialFocusRef,
returnFocusRef,
variant = 'modal',
dismissible = true,
className,
}: DialogProps) {
const dialogRef = useRef<HTMLDialogElement>(null);
const closeRef = useRef<HTMLButtonElement>(null);
const titleId = useId();
const descriptionId = useId();
useScrollLock(open);
useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
if (open && !dialog.open) {
dialog.showModal();
window.requestAnimationFrame(() => (initialFocusRef?.current ?? closeRef.current)?.focus());
} else if (!open && dialog.open) {
dialog.close();
}
}, [initialFocusRef, open]);
const close = () => {
dialogRef.current?.close();
onOpenChange(false);
window.requestAnimationFrame(() => returnFocusRef?.current?.focus());
};
return (
<dialog
ref={dialogRef}
className={classNames(styles.dialog, styles[variant], className)}
aria-labelledby={titleId}
aria-describedby={description ? descriptionId : undefined}
onCancel={(event) => {
if (!dismissible) {
event.preventDefault();
return;
}
event.preventDefault();
close();
}}
onClose={() => onOpenChange(false)}
onClick={(event) => {
if (dismissible && event.target === dialogRef.current) close();
}}
>
<div className={styles.panel}>
<header className={styles.header}>
<div>
<h2 id={titleId}>{title}</h2>
{description ? <p id={descriptionId}>{description}</p> : null}
</div>
{dismissible ? (
<Button
ref={closeRef}
intent="ghost"
size="small"
icon="close"
aria-label={closeLabel}
onClick={close}
/>
) : null}
</header>
<div className={styles.content}>{children}</div>
</div>
</dialog>
);
}
@@ -0,0 +1,39 @@
.root {
position: relative;
display: inline-block;
}
.menu {
position: absolute;
z-index: var(--layer-overlay);
inset-block-start: calc(100% + var(--space-2));
inset-inline-end: 0;
display: grid;
min-inline-size: 13rem;
padding: var(--space-2);
border: 1px solid var(--border-standard);
border-radius: var(--radius-sm);
background: var(--surface-elevated);
box-shadow: var(--shadow-overlay);
}
.item {
min-block-size: 2.75rem;
padding-block: var(--space-2);
padding-inline: var(--space-3);
border: 0;
border-radius: calc(var(--radius-sm) - 2px);
background: transparent;
color: var(--text-primary);
cursor: pointer;
text-align: start;
}
.item:hover:not(:disabled),
.item:focus-visible {
background: var(--surface-muted);
}
.item:disabled {
cursor: not-allowed;
opacity: var(--opacity-disabled);
}
.destructive {
color: var(--status-error-text);
}
+90
View File
@@ -0,0 +1,90 @@
'use client';
import { Button } from '@/components/actions/Button';
import { useEscapeKey, useOutsideClick } from '@/components/foundation/client-hooks';
import { classNames } from '@/lib/components/classNames';
import { useRef, useState, type KeyboardEvent } from 'react';
import styles from './DropdownMenu.module.css';
export interface MenuItem {
id: string;
label: string;
onSelect: () => void;
disabled?: boolean;
destructive?: boolean;
}
interface DropdownMenuProps {
label: string;
items: MenuItem[];
className?: string;
}
export function DropdownMenu({ label, items, className }: DropdownMenuProps) {
const [open, setOpen] = useState(false);
const rootRef = useRef<HTMLDivElement>(null);
const itemRefs = useRef<Array<HTMLButtonElement | null>>([]);
useOutsideClick(rootRef, () => setOpen(false), open);
useEscapeKey(() => setOpen(false), open);
const focusItem = (index: number) => {
const enabled = items
.map((item, itemIndex) => ({ item, itemIndex }))
.filter(({ item }) => !item.disabled);
const target = enabled[(index + enabled.length) % enabled.length];
if (target) itemRefs.current[target.itemIndex]?.focus();
};
const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
const currentIndex = itemRefs.current.findIndex((node) => node === document.activeElement);
if (event.key === 'ArrowDown') {
event.preventDefault();
focusItem(currentIndex + 1);
} else if (event.key === 'ArrowUp') {
event.preventDefault();
focusItem(currentIndex - 1);
} else if (event.key === 'Home') {
event.preventDefault();
focusItem(0);
} else if (event.key === 'End') {
event.preventDefault();
focusItem(items.length - 1);
}
};
return (
<div ref={rootRef} className={classNames(styles.root, className)} onKeyDown={handleKeyDown}>
<Button
intent="tertiary"
trailingIcon="chevron-down"
aria-haspopup="menu"
aria-expanded={open}
onClick={() => setOpen((value) => !value)}
>
{label}
</Button>
{open ? (
<div className={styles.menu} role="menu">
{items.map((item, index) => (
<button
key={item.id}
ref={(node) => {
itemRefs.current[index] = node;
}}
type="button"
role="menuitem"
disabled={item.disabled}
className={classNames(styles.item, item.destructive && styles.destructive)}
onClick={() => {
item.onSelect();
setOpen(false);
}}
>
{item.label}
</button>
))}
</div>
) : null}
</div>
);
}
@@ -0,0 +1,41 @@
.root {
position: relative;
display: inline-flex;
}
.tooltip {
position: absolute;
z-index: var(--layer-overlay);
inset-block-end: calc(100% + var(--space-2));
inset-inline-start: 50%;
inline-size: max-content;
max-inline-size: 16rem;
padding-block: var(--space-2);
padding-inline: var(--space-3);
border-radius: var(--radius-sm);
background: var(--surface-strong);
color: var(--text-primary);
font-size: var(--type-caption-size);
opacity: 0;
pointer-events: none;
transform: translateX(-50%) translateY(var(--space-1));
transition:
opacity var(--duration-fast) var(--easing-productive),
transform var(--duration-fast) var(--easing-productive);
}
.root:hover .tooltip,
.root:focus-within .tooltip {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
html[dir='rtl'] .tooltip {
transform: translateX(50%) translateY(var(--space-1));
}
html[dir='rtl'] .root:hover .tooltip,
html[dir='rtl'] .root:focus-within .tooltip {
transform: translateX(50%) translateY(0);
}
@media (prefers-reduced-motion: reduce) {
.tooltip {
transition: none;
}
}
+22
View File
@@ -0,0 +1,22 @@
import { classNames } from '@/lib/components/classNames';
import type { ReactNode } from 'react';
import styles from './Tooltip.module.css';
export function Tooltip({
content,
children,
className,
}: {
content: string;
children: ReactNode;
className?: string;
}) {
return (
<span className={classNames(styles.root, className)}>
{children}
<span role="tooltip" className={styles.tooltip}>
{content}
</span>
</span>
);
}
@@ -0,0 +1,16 @@
.callout {
display: grid;
grid-template-columns: auto 1fr;
gap: var(--space-4);
padding: var(--space-5);
border-inline-start: 4px solid var(--interactive-primary);
background: var(--status-info-surface);
color: var(--status-info-text);
}
.callout strong {
display: block;
margin-block-end: var(--space-1);
}
.callout :last-child {
margin-block-end: 0;
}
+23
View File
@@ -0,0 +1,23 @@
import { Icon, type ApprovedIconName } from '@/components/foundation/Icon';
import type { ReactNode } from 'react';
import styles from './Callout.module.css';
export function Callout({
icon = 'info',
title,
children,
}: {
icon?: ApprovedIconName;
title: string;
children: ReactNode;
}) {
return (
<aside className={styles.callout}>
<Icon name={icon} size="lg" />
<div>
<strong>{title}</strong>
<div>{children}</div>
</div>
</aside>
);
}

Some files were not shown because too many files have changed in this diff Show More