Files
clean-architecture-frontend…/src/presentation/components/async-surface.tsx
T

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>
);
}