GdzieWjade/src/components/ui.tsx

139 lines
5.8 KiB
TypeScript
Raw Normal View History

2026-10-03 13:33:50 +02:00
import type { ReactNode } from 'react';
import { useI18n } from '../lib/i18n';
import type { Confidence, Fact, SourceKind, Status, Verdict } from '../lib/types';
const SPRITE = `${import.meta.env.BASE_URL}uswds/sprite.svg`;
export function Icon({ name, size = 3, className = '' }: { name: string; size?: 2 | 3 | 4 | 5; className?: string }) {
return (
<svg className={`usa-icon usa-icon--size-${size} ${className}`} aria-hidden="true" focusable="false" role="img">
<use href={`${SPRITE}#${name}`} />
</svg>
);
}
export const VERDICT_STYLE: Record<Verdict, { icon: string; tag: string; alert: string; hex: string }> = {
match: { icon: 'check_circle', tag: 'bg-success-dark text-white', alert: 'usa-alert--success', hex: '#00a91c' },
partial: { icon: 'warning', tag: 'bg-warning text-ink', alert: 'usa-alert--warning', hex: '#ffbe2e' },
conflict: { icon: 'report', tag: 'bg-accent-warm-darker text-white', alert: 'usa-alert--warning', hex: '#a2461c' },
incomplete: { icon: 'help', tag: 'bg-base-lighter text-ink', alert: 'usa-alert--info', hex: '#ffffff' },
fail: { icon: 'error', tag: 'bg-error-dark text-white', alert: 'usa-alert--error', hex: '#b50909' },
};
export function VerdictTag({ verdict }: { verdict: Verdict }) {
const { t } = useI18n();
const s = VERDICT_STYLE[verdict];
return (
<span className={`usa-tag display-inline-flex flex-align-center ${s.tag}`}>
<Icon name={s.icon} size={2} className="margin-right-05" />
{t(`verdict.${verdict}`)}
</span>
);
}
const STATUS_STYLE: Record<Status, { icon: string; text: string }> = {
ok: { icon: 'check_circle', text: 'text-success-dark' },
warn: { icon: 'warning', text: 'text-warning-darker' },
fail: { icon: 'error', text: 'text-error-dark' },
unknown: { icon: 'help', text: 'text-base-dark' },
conflict: { icon: 'report', text: 'text-accent-warm-darker' },
};
export function StatusLabel({ status }: { status: Status }) {
const { t } = useI18n();
const s = STATUS_STYLE[status];
return (
<span className={`display-inline-flex flex-align-center text-bold ${s.text}`}>
<Icon name={s.icon} size={3} className="margin-right-05" />
{t(`status.${status}`)}
</span>
);
}
const CONF_STYLE: Record<Confidence, string> = {
verified: 'bg-success-dark text-white',
mapped: 'bg-primary text-white',
stale: 'bg-warning text-ink',
inferred: 'bg-base-dark text-white',
user: 'bg-accent-warm-darker text-white',
none: 'bg-base-lighter text-ink',
};
const CONF_ICON: Record<Confidence, string> = {
verified: 'verified', mapped: 'check', stale: 'update', inferred: 'info_outline', user: 'edit', none: 'help_outline',
};
export function ConfidenceTag({ confidence }: { confidence: Confidence }) {
const { t } = useI18n();
return (
<span className={`usa-tag display-inline-flex flex-align-center ${CONF_STYLE[confidence]}`} title={t(`conf.${confidence}.help`)}>
<Icon name={CONF_ICON[confidence]} size={2} className="margin-right-05" />
{t(`conf.${confidence}`)}
</span>
);
}
export function SourceLine({ source, confidence, date }: { source: SourceKind; confidence: Confidence; date: string | null }) {
const { t, date: fmt } = useI18n();
return (
<dl className="kbb-source font-sans-3xs margin-top-1 margin-bottom-0">
<div>
<dt className="text-base-dark">{t('src.label')}</dt>
<dd>{t(`src.${source}`)}</dd>
</div>
<div>
<dt className="text-base-dark">{t('fact.updated')}</dt>
<dd>{date ? fmt(date) : t('fact.dateUnknown')}</dd>
</div>
<div>
<dt className="text-base-dark">{t('fact.confidence')}</dt>
<dd><ConfidenceTag confidence={confidence} /></dd>
</div>
</dl>
);
}
export function FactCard({ fact, onReport }: { fact: Fact; onReport?: (() => void) | undefined }) {
const { t, tm } = useI18n();
const canReport = onReport && ['entrance', 'steps', 'width', 'toilet', 'ramp'].includes(fact.key);
return (
<li className="border-1px border-base-lighter radius-md padding-2 margin-bottom-2 bg-white">
<div className="display-flex flex-justify flex-wrap flex-align-center">
<h4 className="margin-0 font-sans-md">{t(`fact.title.${fact.key}`)}</h4>
<StatusLabel status={fact.status} />
</div>
<p className="margin-top-1 margin-bottom-0">{tm(fact.headline)}</p>
{fact.detail && <p className="margin-top-05 margin-bottom-0 text-base-dark font-sans-xs">{tm(fact.detail)}</p>}
<SourceLine source={fact.source} confidence={fact.confidence} date={fact.date} />
{fact.alt && (
<div className="usa-alert usa-alert--warning usa-alert--slim margin-top-2">
<div className="usa-alert__body">
<p className="usa-alert__text">
<strong>{t('fact.otherSource')}:</strong> {tm(fact.alt.headline)}
</p>
<SourceLine source={fact.alt.source} confidence={fact.alt.confidence} date={fact.alt.date} />
</div>
</div>
)}
<div className="display-flex flex-wrap flex-align-center margin-top-1">
{fact.required && <span className="font-sans-3xs text-base-dark margin-right-2">{t('fact.required')}</span>}
{canReport && (
<button type="button" className="usa-button usa-button--unstyled font-sans-xs" onClick={onReport}>
{t('fact.report')}
</button>
)}
</div>
</li>
);
}
export function Alert({ kind, heading, children, slim = false, role }: { kind: 'info' | 'warning' | 'error' | 'success'; heading?: string; children: ReactNode; slim?: boolean; role?: 'alert' | 'status' }) {
return (
<div className={`usa-alert usa-alert--${kind} ${slim ? 'usa-alert--slim' : ''}`} role={role}>
<div className="usa-alert__body">
{heading && <h4 className="usa-alert__heading">{heading}</h4>}
<div className="usa-alert__text">{children}</div>
</div>
</div>
);
}