GdzieWjade/src/components/ui.tsx
2026-10-03 18:22:27 +02:00

219 lines
10 KiB
TypeScript

import { useId, useLayoutEffect, useRef, useState, type ReactNode } from 'react';
import { useI18n } from '../lib/i18n';
import { CATEGORY_ICON } from '../lib/categoryIcon';
import type { Confidence, Fact, PlaceCategory, SourceKind, Status, Verdict, VetState } 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>
);
}
/** Category pictogram in a soft square; decorative, the category name is always written next to it. */
export function CategoryIcon({ cat, size = 'md' }: { cat: PlaceCategory; size?: 'sm' | 'md' }) {
return (
<span className={`kbb-cat kbb-cat--${size}`} aria-hidden="true">
<Icon name={CATEGORY_ICON[cat]} size={size === 'sm' ? 2 : 3} />
</span>
);
}
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>
);
}
/** Accessible help bubble: hover, focus or click opens it; Escape or blur closes it (WCAG 1.4.13). */
export function InfoTip({ topic, text }: { topic: string; text: string }) {
const { t } = useI18n();
const [open, setOpen] = useState(false);
const [hover, setHover] = useState(false);
const id = useId();
const show = open || hover;
const bubble = useRef<HTMLSpanElement>(null);
const [shift, setShift] = useState(0);
useLayoutEffect(() => {
const el = bubble.current;
if (!show || !el) return;
el.style.transform = '';
const r = el.getBoundingClientRect();
const over = r.right - (window.innerWidth - 8);
setShift(over > 0 ? -Math.min(over, r.left - 8) : 0);
}, [show]);
return (
<span className="kbb-tip" onMouseEnter={() => setHover(true)} onMouseLeave={() => setHover(false)}>
<button
type="button" className="kbb-tip__btn" aria-expanded={show} aria-controls={id} aria-label={t('tip.label', { topic })}
onClick={() => setOpen((o) => !o)}
onBlur={() => setOpen(false)}
onKeyDown={(e) => { if (e.key === 'Escape') { setOpen(false); setHover(false); } }}
>
<Icon name="help_outline" size={3} />
</button>
<span id={id} ref={bubble} role="tooltip" className="kbb-tip__bubble" hidden={!show} style={{ transform: `translateX(${shift}px)` }}>{text}</span>
</span>
);
}
const TRIAGE_STYLE = { 'fast-track': 'bg-primary-dark text-white', review: 'bg-base-lighter text-ink', 'likely-reject': 'bg-base-dark text-white' } as const;
const SEVERITY_STYLE = { critical: 'bg-error-dark text-white', major: 'bg-accent-warm-darker text-white', minor: 'bg-warning text-ink', none: 'bg-base-lighter text-ink' } as const;
/** Automatic first assessment of a report. Always framed as advisory. */
export function VettingSummary({ v, detailed = false }: { v: VetState | undefined; detailed?: boolean }) {
const { t, tm } = useI18n();
if (!v) return null;
if (v.state === 'running') return <p className="font-sans-xs margin-y-05" role="status"><Icon name="update" size={2} className="margin-right-05" />{t('vet.running')}</p>;
if (v.state === 'failed') {
return <p className="font-sans-xs margin-y-05 text-error-dark" role="status"><Icon name="error_outline" size={2} className="margin-right-05" />{t(v.reason === 'no-credits' ? 'vet.noCredits' : v.reason === 'skipped' ? 'vet.skipped' : 'vet.error')}</p>;
}
const r = v.result;
return (
<div className="margin-y-05">
<p className="margin-y-05 display-flex flex-wrap flex-align-center">
<span className={`usa-tag margin-right-05 ${TRIAGE_STYLE[r.triage]}`}>{t(`vet.triage.${r.triage}`)}</span>
<span className={`usa-tag margin-right-05 ${SEVERITY_STYLE[r.severity]}`}>{t('vet.severityLabel')}: {t(`vet.severity.${r.severity}`)}</span>
<span className="usa-tag bg-base-lighter text-ink margin-right-05">{t(`vet.validity.${r.validity}`)}</span>
{r.flags.map((f) => <span key={f} className="usa-tag bg-warning text-ink margin-right-05">{t(`vet.flag.${f}`)}</span>)}
</p>
{detailed && (
<>
<p className="font-sans-xs margin-y-05">
{t('vet.classLabel')}: <strong>{t(`vet.class.${r.accessibilityClass}`)}</strong> · {t(`vet.consistency.${r.consistency}`)} · {t('vet.confidence', { pct: Math.round(r.confidence * 100) })}
</p>
<details className="font-sans-xs">
<summary>{t('dash.queue.reasons')}</summary>
<ul className="usa-list">
{r.reasons.map((m, i) => <li key={i}>{tm(m)}</li>)}
</ul>
<p className="text-base-dark margin-y-05">{t('vet.provider', { name: r.provider })}</p>
</details>
<p className="font-sans-3xs text-base-dark margin-y-05">{t('vet.disclaimer')}</p>
</>
)}
</div>
);
}