返回 CodeWhale
surface-state.tsx
根目录 / web / components / surface-state.tsx
1 import type { ReactNode } from "react";
2 import { getStates } from "@/lib/i18n/dictionaries";
3 import { WhalePose, type WhalePoseName } from "./whale-pose";
4
5 /**
6 * Shared surface states — the one empty / loading / error vocabulary every
7 * data-bearing page renders. Server-safe (no hooks); a caller that needs a
8 * live retry passes a client `RetryAction` through `action`.
9 *
10 * Rules:
11 * - An empty state is a statement that nothing exists, never a placeholder
12 * pretending something does. Copy comes from the states dictionary.
13 * - The whale's pose says what is going on (rest when there is nothing,
14 * busy while loading, hmm when something failed); the title says it in
15 * words, so the pose is decorative.
16 * - A loading state is announced (`role="status"`) and drawn as still
17 * skeleton lines plus one spinner that only turns when motion is allowed.
18 * - An error state says what failed and offers exactly one recovery.
19 */
20
21 type Tone = "empty" | "loading" | "error";
22 type TitleTag = "p" | "h1" | "h2";
23
24 function Block({
25 tone,
26 title,
27 body,
28 action,
29 compact,
30 role,
31 live,
32 pose,
33 titleAs: Title = "p",
34 }: {
35 tone: Tone;
36 title: string;
37 body?: string;
38 action?: ReactNode;
39 compact?: boolean;
40 role?: "status" | "alert";
41 live?: "polite" | "assertive";
42 pose: WhalePoseName;
43 /** A route-level plate that is the whole page owns its `<h1>`. */
44 titleAs?: TitleTag;
45 }) {
46 return (
47 <div
48 className={`state-block state-block-${tone}${compact ? " state-block-compact" : ""}`}
49 role={role}
50 aria-live={live}
51 data-state={tone}
52 >
53 <WhalePose pose={pose} />
54 <Title className="state-title">{title}</Title>
55 {body && <p className="state-body">{body}</p>}
56 {action && <div className="state-actions">{action}</div>}
57 </div>
58 );
59 }
60
61 export function EmptyState({
62 locale,
63 title,
64 body,
65 action,
66 compact,
67 titleAs,
68 pose = "rest",
69 }: {
70 locale: string;
71 title?: string;
72 body?: string;
73 action?: ReactNode;
74 compact?: boolean;
75 titleAs?: TitleTag;
76 pose?: WhalePoseName;
77 }) {
78 const t = getStates(locale);
79 return (
80 <Block
81 tone="empty"
82 title={title ?? t.emptyTitle}
83 body={body ?? t.emptyBody}
84 action={action}
85 compact={compact}
86 titleAs={titleAs}
87 pose={pose}
88 />
89 );
90 }
91
92 /**
93 * The source was not asked or did not answer. Same plate as an error, same
94 * single retry, but the copy says "not loaded" rather than "broken": on an
95 * ISR page the next refresh is the honest fix, and nothing is shown in place
96 * of the missing record.
97 */
98 export function UnavailableState({
99 locale,
100 action,
101 compact,
102 }: {
103 locale: string;
104 action?: ReactNode;
105 compact?: boolean;
106 }) {
107 const t = getStates(locale);
108 return (
109 <Block
110 tone="empty"
111 title={t.unavailableTitle}
112 body={t.unavailableBody}
113 action={action}
114 compact={compact}
115 role="status"
116 pose="sleep"
117 />
118 );
119 }
120
121 export function LoadingState({
122 locale,
123 label,
124 lines = 3,
125 compact,
126 }: {
127 locale: string;
128 label?: string;
129 lines?: number;
130 compact?: boolean;
131 }) {
132 const t = getStates(locale);
133 return (
134 <div
135 className={`state-block state-block-loading${compact ? " state-block-compact" : ""}`}
136 role="status"
137 aria-live="polite"
138 aria-busy="true"
139 data-state="loading"
140 >
141 <WhalePose pose="busy" />
142 <p className="state-title">
143 <span className="spinner" aria-hidden="true" /> {label ?? t.loadingLabel}
144 </p>
145 <div className="state-skeleton" aria-hidden="true">
146 {Array.from({ length: lines }, (_, i) => (
147 <span key={i} style={{ width: `${88 - i * 14}%` }} />
148 ))}
149 </div>
150 </div>
151 );
152 }
153
154 export function ErrorState({
155 locale,
156 title,
157 body,
158 action,
159 compact,
160 titleAs,
161 }: {
162 locale: string;
163 title?: string;
164 body?: string;
165 action?: ReactNode;
166 compact?: boolean;
167 titleAs?: TitleTag;
168 }) {
169 const t = getStates(locale);
170 return (
171 <Block
172 tone="error"
173 title={title ?? t.errorTitle}
174 body={body ?? t.errorBody}
175 action={action}
176 compact={compact}
177 role="alert"
178 titleAs={titleAs}
179 pose="hmm"
180 />
181 );
182 }
183
183 lines Plain Text