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