166 lines
4.8 KiB
TypeScript
166 lines
4.8 KiB
TypeScript
import { useId, type ReactNode } from "react";
|
|
|
|
import type { AsyncState } from "../../application/view-models/async-state.ts";
|
|
import type { AppFailure } from "../../contracts/errors.ts";
|
|
import { useLocale } from "../i18n/index.ts";
|
|
import { errorMessage } from "./error-copy.ts";
|
|
import { Button } from "./ui/button.ts";
|
|
|
|
export function LoadingSurface({ label }: Readonly<{ label?: string }>) {
|
|
const { message } = useLocale();
|
|
const accessibleLabel = label ?? message("async.loading");
|
|
return (
|
|
<section
|
|
className="state-surface state-surface--loading"
|
|
aria-busy="true"
|
|
aria-label={accessibleLabel}
|
|
aria-live="polite"
|
|
aria-atomic="true"
|
|
>
|
|
<div className="ui-skeleton" aria-hidden="true" />
|
|
<span className="visually-hidden">{accessibleLabel}</span>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
export type EmptySurfaceProps = Readonly<{
|
|
title?: string;
|
|
description?: string;
|
|
action?: ReactNode;
|
|
}>;
|
|
|
|
export function EmptySurface({
|
|
title,
|
|
description,
|
|
action,
|
|
}: EmptySurfaceProps) {
|
|
const { message } = useLocale();
|
|
return (
|
|
<section className="ui-empty state-surface" aria-live="polite">
|
|
<h2>{title ?? message("async.empty")}</h2>
|
|
{description ? <p>{description}</p> : null}
|
|
{action}
|
|
</section>
|
|
);
|
|
}
|
|
|
|
export type TerminalErrorSurfaceProps = Readonly<{
|
|
userMessageKey: string;
|
|
action: AppFailure["action"];
|
|
onAction?: () => void;
|
|
}>;
|
|
|
|
export function TerminalErrorSurface({
|
|
userMessageKey,
|
|
action,
|
|
onAction,
|
|
}: TerminalErrorSurfaceProps) {
|
|
const { locale, message } = useLocale();
|
|
const messageId = useId();
|
|
const actionLabels: Readonly<
|
|
Record<Exclude<AppFailure["action"], "none">, string>
|
|
> = Object.freeze({
|
|
retry: message("action.retry"),
|
|
reauth: message("action.reauth"),
|
|
navigate: message("action.navigateSafe"),
|
|
"reload-once": message("action.reloadOnce"),
|
|
"contact-support": message("action.contactSupport"),
|
|
});
|
|
return (
|
|
<section
|
|
className="ui-terminal-error state-surface state-surface--danger"
|
|
role="alert"
|
|
aria-labelledby={messageId}
|
|
data-message-key={userMessageKey}
|
|
>
|
|
<h2 id={messageId}>{errorMessage(userMessageKey, locale)}</h2>
|
|
{action !== "none" && onAction && (
|
|
<Button onClick={onAction}>{actionLabels[action]}</Button>
|
|
)}
|
|
</section>
|
|
);
|
|
}
|
|
|
|
export type AsyncSurfaceProps = Readonly<{
|
|
state: AsyncState;
|
|
children?: ReactNode;
|
|
onAction?: () => void;
|
|
onRetry?: () => void;
|
|
onResolveConflict?: () => void;
|
|
onReconcileUnknownEffect?: (
|
|
resolution: "APPLIED" | "NOT_APPLIED",
|
|
) => void;
|
|
}>;
|
|
|
|
export function AsyncSurface({
|
|
state,
|
|
children,
|
|
onAction,
|
|
onRetry,
|
|
onResolveConflict,
|
|
onReconcileUnknownEffect,
|
|
}: AsyncSurfaceProps) {
|
|
const { message } = useLocale();
|
|
if (state.base === "initial-loading") return <LoadingSurface />;
|
|
if (state.base === "empty") return <EmptySurface />;
|
|
if (state.base === "terminal-error" && state.failure) {
|
|
return (
|
|
<TerminalErrorSurface
|
|
userMessageKey={state.failure.userMessageKey}
|
|
action={state.failure.action}
|
|
onAction={
|
|
state.failure.action === "retry"
|
|
? onRetry ?? onAction
|
|
: onAction
|
|
}
|
|
/>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<section aria-busy={state.overlay.refreshing || state.overlay.mutationPending}>
|
|
{state.indicator ? (
|
|
<div role="status" aria-live="polite">
|
|
<span>
|
|
{message(
|
|
state.indicator === "stale-degraded"
|
|
? "async.staleDegraded"
|
|
: state.indicator === "mutation-effect-unknown"
|
|
? "async.mutationEffectUnknown"
|
|
: state.indicator === "mutation-conflict"
|
|
? "async.mutationConflict"
|
|
: state.indicator === "mutation-pending"
|
|
? "async.mutationPending"
|
|
: "async.refreshing",
|
|
)}
|
|
</span>
|
|
{state.indicator === "stale-degraded" && onRetry ? (
|
|
<Button onClick={onRetry}>{message("action.retry")}</Button>
|
|
) : null}
|
|
{state.indicator === "mutation-conflict" && onResolveConflict ? (
|
|
<Button onClick={onResolveConflict}>
|
|
{message("action.resolveConflict")}
|
|
</Button>
|
|
) : null}
|
|
{state.indicator === "mutation-effect-unknown" &&
|
|
onReconcileUnknownEffect ? (
|
|
<>
|
|
<Button
|
|
onClick={() => onReconcileUnknownEffect("APPLIED")}
|
|
>
|
|
{message("action.confirmMutationApplied")}
|
|
</Button>
|
|
<Button
|
|
onClick={() => onReconcileUnknownEffect("NOT_APPLIED")}
|
|
>
|
|
{message("action.confirmMutationNotApplied")}
|
|
</Button>
|
|
</>
|
|
) : null}
|
|
</div>
|
|
) : null}
|
|
{children}
|
|
</section>
|
|
);
|
|
}
|