mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 07:26:30 +00:00
ba70ad28b8
Arabic runs the app right to left, but margins, paddings, insets, text alignment, borders and corner radii were written for the left side, so icons sat against the wrong edge of their labels, indents and badges pointed the wrong way and amount columns aligned left. They now use the logical forms (ms-, ps-, start-, text-start, marginInlineStart, insetInlineEnd and their CSS equivalents), which are the very same sides in a left to right language. Geometry stays physical on purpose: maps, positions measured from the pointer or a bounding box, time axes, sliders, transforms and print output. Amounts drawn as several spans keep their order in a right to left row, the time picker keeps hours before minutes, and two switches whose thumb slid out of its track in Arabic now move the right way. lint:rtl counts what is still physical per file against a baseline that may only shrink (1512 uses in 370 files before, 319 in 113 now), and CI runs it, so new code reaches for the logical forms.
264 lines
8.0 KiB
TypeScript
264 lines
8.0 KiB
TypeScript
import React, { useState, useEffect, useRef } from 'react';
|
|
import { useNavigate } from 'react-router';
|
|
import { Info, AlertTriangle, AlertOctagon, X } from 'lucide-react';
|
|
import { useSystemNoticeStore } from '../../store/systemNoticeStore.js';
|
|
import type { SystemNoticeDTO } from '../../store/systemNoticeStore.js';
|
|
import { useTranslation } from '../../i18n/index.js';
|
|
import { runNoticeAction } from './noticeActions.js';
|
|
|
|
const SEVERITY_ICONS: Record<string, React.ElementType> = {
|
|
info: Info,
|
|
warn: AlertTriangle,
|
|
critical: AlertOctagon,
|
|
};
|
|
|
|
const SEVERITY = {
|
|
info: {
|
|
bg: 'bg-white dark:bg-slate-900',
|
|
border: 'border-blue-500 dark:border-blue-400',
|
|
text: 'text-slate-900 dark:text-slate-100',
|
|
icon: 'text-blue-500 dark:text-blue-400',
|
|
ariaLive: 'polite' as const,
|
|
role: 'status' as const,
|
|
},
|
|
warn: {
|
|
bg: 'bg-amber-50 dark:bg-amber-950',
|
|
border: 'border-amber-500 dark:border-amber-400',
|
|
text: 'text-amber-900 dark:text-amber-100',
|
|
icon: 'text-amber-500 dark:text-amber-400',
|
|
ariaLive: 'polite' as const,
|
|
role: 'status' as const,
|
|
},
|
|
critical: {
|
|
bg: 'bg-rose-50 dark:bg-rose-950',
|
|
border: 'border-rose-600 dark:border-rose-400',
|
|
text: 'text-rose-900 dark:text-rose-100',
|
|
icon: 'text-rose-600 dark:text-rose-400',
|
|
ariaLive: 'assertive' as const,
|
|
role: 'alert' as const,
|
|
},
|
|
} as const;
|
|
|
|
interface BannerItemProps {
|
|
notice: SystemNoticeDTO;
|
|
onDismiss: () => void;
|
|
}
|
|
|
|
function CTALink({
|
|
notice,
|
|
cta,
|
|
label,
|
|
onDismiss,
|
|
}: {
|
|
notice: SystemNoticeDTO;
|
|
cta: NonNullable<SystemNoticeDTO['cta']>;
|
|
label: string;
|
|
onDismiss: () => void;
|
|
}) {
|
|
const navigate = useNavigate();
|
|
|
|
function handleClick() {
|
|
if (cta.kind === 'nav') {
|
|
navigate(cta.href);
|
|
if (notice.dismissible) onDismiss();
|
|
} else if (cta.kind === 'link') {
|
|
window.open(cta.href, '_blank', 'noopener,noreferrer');
|
|
} else {
|
|
runNoticeAction(cta.actionId, { navigate });
|
|
if (cta.dismissOnAction !== false) onDismiss();
|
|
}
|
|
}
|
|
|
|
if (cta.kind === 'nav' || cta.kind === 'link') {
|
|
return (
|
|
<a
|
|
href={cta.href}
|
|
onClick={e => { e.preventDefault(); handleClick(); }}
|
|
className="underline hover:no-underline font-medium ms-3 shrink-0"
|
|
>
|
|
{label}
|
|
</a>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<button type="button"
|
|
onClick={handleClick}
|
|
className="underline hover:no-underline font-medium ms-3 shrink-0"
|
|
>
|
|
{label}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
function BannerItem({ notice, onDismiss }: BannerItemProps) {
|
|
const { t } = useTranslation();
|
|
const s = SEVERITY[notice.severity] ?? SEVERITY.info;
|
|
const title = t(notice.titleKey);
|
|
const body = t(notice.bodyKey);
|
|
const ctaLabel = notice.cta ? t(notice.cta.labelKey) : null;
|
|
|
|
// Tailwind 3.3+ supports border-s-4 (logical, RTL-aware)
|
|
const accentBorder = 'border-s-4';
|
|
|
|
return (
|
|
<div
|
|
role={s.role}
|
|
aria-live={s.ariaLive}
|
|
aria-atomic="true"
|
|
className={`flex items-start gap-x-3 py-3 px-4 ${accentBorder} ${s.bg} ${s.border} ${s.text}`}
|
|
>
|
|
{React.createElement(
|
|
(SEVERITY_ICONS[notice.severity] ?? Info) as React.ElementType,
|
|
{ size: 20, className: `shrink-0 mt-0.5 ${s.icon}` },
|
|
)}
|
|
<div className="flex-1 min-w-0">
|
|
<span className="font-semibold">{title}</span>
|
|
{body !== title && (
|
|
<span className="ms-2 opacity-80">{body}</span>
|
|
)}
|
|
{ctaLabel && notice.cta && (
|
|
<CTALink notice={notice} cta={notice.cta} label={ctaLabel} onDismiss={onDismiss} />
|
|
)}
|
|
</div>
|
|
{notice.dismissible && (
|
|
<button type="button"
|
|
onClick={onDismiss}
|
|
className="shrink-0 p-2 -me-2 rounded hover:bg-black/5 dark:hover:bg-white/10 transition"
|
|
aria-label={`Dismiss: ${title}`}
|
|
>
|
|
<X size={20} />
|
|
</button>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
interface AnimatedBannerItemProps {
|
|
notice: SystemNoticeDTO;
|
|
onDismiss: () => void;
|
|
}
|
|
|
|
function AnimatedBannerItem({ notice, onDismiss }: AnimatedBannerItemProps) {
|
|
const [mounted, setMounted] = useState(false);
|
|
const prefersReducedMotion =
|
|
typeof window !== 'undefined' &&
|
|
(window.matchMedia?.('(prefers-reduced-motion: reduce)')?.matches ?? false);
|
|
|
|
useEffect(() => {
|
|
if (typeof requestAnimationFrame !== 'undefined') {
|
|
const id = requestAnimationFrame(() => setMounted(true));
|
|
return () => cancelAnimationFrame(id);
|
|
}
|
|
setMounted(true);
|
|
}, []);
|
|
|
|
const transition = prefersReducedMotion
|
|
? 'transition-opacity duration-[120ms]'
|
|
: 'transition-all duration-200 ease-out';
|
|
const state = mounted ? 'opacity-100 translate-y-0' : 'opacity-0 -translate-y-2';
|
|
|
|
return (
|
|
<div className={`${transition} ${state}`}>
|
|
<BannerItem notice={notice} onDismiss={onDismiss} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
interface BannerRendererProps {
|
|
notices: SystemNoticeDTO[];
|
|
}
|
|
|
|
export function BannerRenderer({ notices }: BannerRendererProps) {
|
|
const { dismiss } = useSystemNoticeStore();
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
|
|
// Show at most 2 highest-priority banners
|
|
const visible = notices.slice(0, 2);
|
|
|
|
// Report banner stack height for layout reflow
|
|
useEffect(() => {
|
|
const el = containerRef.current;
|
|
if (!el) return;
|
|
const observer = new ResizeObserver(() => {
|
|
document.documentElement.style.setProperty('--banner-stack-h', el.offsetHeight + 'px');
|
|
});
|
|
observer.observe(el);
|
|
return () => {
|
|
observer.disconnect();
|
|
document.documentElement.style.setProperty('--banner-stack-h', '0px');
|
|
};
|
|
}, []);
|
|
|
|
if (visible.length === 0) return null;
|
|
|
|
return (
|
|
<div
|
|
ref={containerRef}
|
|
className="fixed inset-x-0 z-40"
|
|
style={{ top: 'var(--nav-h, 0px)' }}
|
|
>
|
|
{visible.map((notice, i) => (
|
|
<React.Fragment key={notice.id}>
|
|
{i > 0 && <div className="border-t border-black/10 dark:border-white/10" />}
|
|
<AnimatedBannerItem
|
|
notice={notice}
|
|
onDismiss={() => dismiss(notice.id)}
|
|
/>
|
|
</React.Fragment>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
interface ToastRendererProps {
|
|
notices: SystemNoticeDTO[];
|
|
}
|
|
|
|
export function ToastRenderer({ notices }: ToastRendererProps) {
|
|
const { dismiss } = useSystemNoticeStore();
|
|
const { t } = useTranslation();
|
|
const firedRef = useRef(new Set<string>());
|
|
|
|
useEffect(() => {
|
|
for (const notice of notices) {
|
|
if (firedRef.current.has(notice.id)) continue;
|
|
firedRef.current.add(notice.id);
|
|
|
|
// Critical should not be a toast — log and skip
|
|
if (notice.severity === 'critical') {
|
|
console.warn(
|
|
`[systemNotices] notice "${notice.id}" is critical but display=toast. ` +
|
|
'Should be banner or modal.'
|
|
);
|
|
dismiss(notice.id);
|
|
continue;
|
|
}
|
|
|
|
const variantMap: Record<string, string> = { info: 'info', warn: 'warning' };
|
|
const variant = variantMap[notice.severity] ?? 'info';
|
|
const titleStr = t(notice.titleKey);
|
|
const bodyStr = t(notice.bodyKey);
|
|
const message = bodyStr !== titleStr ? `${titleStr}: ${bodyStr}` : titleStr;
|
|
const duration = notice.severity === 'warn' ? 9000 : 6000;
|
|
|
|
// Fire the toast, retrying on the next frame if __addToast isn't registered yet
|
|
// (race between ToastContainer mounting and SystemNoticeHost mounting on cold load).
|
|
const fireToast = (attempt = 0) => {
|
|
if (typeof window.__addToast === 'function') {
|
|
window.__addToast(message, variant as 'info' | 'success' | 'error' | 'warning', duration);
|
|
} else if (attempt < 10) {
|
|
requestAnimationFrame(() => fireToast(attempt + 1));
|
|
return; // don't schedule dismiss until the toast actually fires
|
|
} else {
|
|
console.warn(`[systemNotices] toast "${notice.id}" dropped — __addToast never registered`);
|
|
}
|
|
setTimeout(() => dismiss(notice.id), duration + 500);
|
|
};
|
|
fireToast();
|
|
}
|
|
}, [notices]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
return null;
|
|
}
|