feat: model complete async UI surface states

This commit is contained in:
donghyeon-ka
2026-07-25 21:00:21 +09:00
parent 652e0250f3
commit 438dc11548
4 changed files with 237 additions and 3 deletions
@@ -0,0 +1,81 @@
export const ASYNC_BASE_STATES = Object.freeze([
"initial-loading",
"success",
"empty",
"terminal-error",
]);
export const ASYNC_OVERLAYS = Object.freeze([
"refreshing",
"stale-degraded",
"mutation-pending",
"mutation-conflict",
]);
/**
* @typedef {{
* data?: unknown,
* isInitialLoading?: boolean,
* failure?: import("../../contracts/errors.js").ApiFailure,
* isFetching?: boolean,
* isStale?: boolean,
* isDegraded?: boolean,
* isMutationPending?: boolean,
* hasMutationConflict?: boolean
* }} AsyncSignals
*/
/** @param {AsyncSignals} signals */
export function deriveAsyncState(signals) {
const hasData = signals.data !== undefined && signals.data !== null;
const empty =
hasData &&
((Array.isArray(signals.data) && signals.data.length === 0) ||
signals.data === "");
let base;
if (signals.isInitialLoading && !hasData) {
base = "initial-loading";
} else if (signals.failure && !hasData) {
base = "terminal-error";
} else if (empty) {
base = "empty";
} else if (hasData) {
base = "success";
} else {
base = "initial-loading";
}
const overlay = Object.freeze({
refreshing: Boolean(signals.isFetching && hasData),
staleDegraded: Boolean(signals.isStale && signals.isDegraded && hasData),
mutationPending: Boolean(signals.isMutationPending && hasData),
mutationConflict: Boolean(signals.hasMutationConflict && hasData),
});
const state = {
base,
data: base === "success" || base === "empty" ? signals.data : undefined,
failure: base === "terminal-error" ? signals.failure : undefined,
overlay,
indicator: selectOverlayIndicator(overlay),
};
return Object.freeze(state);
}
/**
* @param {{
* refreshing: boolean,
* staleDegraded: boolean,
* mutationPending: boolean,
* mutationConflict: boolean
* }} overlay
*/
export function selectOverlayIndicator(overlay) {
if (overlay.mutationConflict) return "mutation-conflict";
if (overlay.mutationPending) return "mutation-pending";
if (overlay.staleDegraded) return "stale-degraded";
if (overlay.refreshing) return "refreshing";
return null;
}
+8 -3
View File
@@ -9,13 +9,18 @@ const DROP_SENSITIVE = Object.freeze([
"storageValue",
]);
/**
* @typedef {"retry" | "reauth" | "navigate" | "reload-once" |
* "contact-support" | "none"} ErrorAction
*/
/**
* @typedef {{
* kind: string,
* defaultRetryable: boolean,
* severity: string,
* userMessageKey: string,
* action: string,
* action: ErrorAction,
* telemetryEvent: string,
* redaction: readonly string[]
* }} ErrorDefinition
@@ -25,7 +30,7 @@ const DROP_SENSITIVE = Object.freeze([
* @param {string} kind
* @param {boolean} defaultRetryable
* @param {string} severity
* @param {string} action
* @param {ErrorAction} action
* @param {string} [telemetryEvent]
* @returns {Readonly<ErrorDefinition>}
*/
@@ -156,7 +161,7 @@ export const ERROR_REGISTRY = Object.freeze({
* traceId?: string,
* retryAfterMs?: number,
* userMessageKey: string,
* action: string,
* action: ErrorAction,
* causeClass?: string
* }} ApiFailure
*/
@@ -0,0 +1,72 @@
/** @param {{ label?: string }} props */
export function LoadingSurface({ label = "불러오는 중" }) {
return (
<section aria-busy="true" aria-label={label}>
<div className="ui-skeleton" aria-hidden="true" />
<span className="sr-only">{label}</span>
</section>
);
}
/** @param {{ title?: string, action?: React.ReactNode }} props */
export function EmptySurface({ title = "표시할 항목이 없습니다.", action }) {
return (
<section>
<p>{title}</p>
{action}
</section>
);
}
/**
* @param {{
* userMessageKey: string,
* action: "retry" | "reauth" | "navigate" | "reload-once" |
* "contact-support" | "none",
* onAction?: () => void
* }} props
*/
export function TerminalErrorSurface({ userMessageKey, action, onAction }) {
return (
<section role="alert" aria-labelledby="terminal-error-message">
<p id="terminal-error-message">{userMessageKey}</p>
{action !== "none" && (
<button type="button" onClick={onAction}>
{action}
</button>
)}
</section>
);
}
/**
* @param {{
* state: ReturnType<typeof import("../../application/view-models/async-state.js").deriveAsyncState>,
* children?: React.ReactNode,
* onAction?: () => void
* }} props
*/
export function AsyncSurface({ state, children, onAction }) {
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={onAction}
/>
);
}
return (
<section aria-busy={state.overlay.refreshing || state.overlay.mutationPending}>
{state.indicator && (
<p role="status" aria-live="polite">
{state.indicator}
</p>
)}
{children}
</section>
);
}