@uiresponse/renderer-react 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,290 @@
1
+ /**
2
+ * The three primitives every block is built from: a rendered VALUE, an ABSENCE, and a SKELETON.
3
+ *
4
+ * They exist once so that `format` and `empty` are applied in exactly one place. A `table` cell, a
5
+ * `metric` and a `detail` field must render `null` identically — the moment two of them disagree,
6
+ * the page is lying in one of them.
7
+ */
8
+ import React, { useEffect, useId, useRef, useState } from "react";
9
+ import { formatValue, isNumericFormat } from "../core/format.js";
10
+ import { emptyRender, isEmptyValue } from "../core/empty.js";
11
+ import { runWithConfirm, actionGate, actionLabel } from "../core/actions.js";
12
+ import { initialsFor, isDestructiveAction } from "../core/presentation.js";
13
+
14
+ /**
15
+ * AN IMAGE THAT ARRIVES, rather than one that is painted onto the page a band at a time.
16
+ *
17
+ * A browser given a 400KB JPEG and a box to put it in draws the JPEG top-down as its bytes land. On a
18
+ * shelf of twelve record sleeves that is twelve pictures wiping themselves in from above, and it is
19
+ * the ugliest thing on the page — it reads as damage, not as loading.
20
+ *
21
+ * ONE ELEMENT DOES ALL OF IT. The low-resolution proxy is the `<img>`'s own BACKGROUND, and the full
22
+ * image is its content. Before the content decodes, the element shows its background; a CSS `filter`
23
+ * blurs the element, so the moment the full image does appear it appears already blurred, in
24
+ * register, and sharpens as the filter lifts. That is a cross-fade with nothing to cross-fade — no
25
+ * second element, no absolute positioning, no z-index, and it therefore drops unchanged into the
26
+ * sleeve, the hero card and the table cell, each of which sizes its image differently.
27
+ *
28
+ * Nothing is preloaded. A `new Image()` warm-up would defeat `loading="lazy"` and fetch all twelve
29
+ * covers of a rail the moment the page painted, which is the opposite of the thing being fixed.
30
+ *
31
+ * WITHOUT A PROXY there is nothing to show, so nothing is shown: the image is transparent until it
32
+ * has loaded, then fades in whole. That is the universal fallback, and it is why this component is
33
+ * used even where no connector supplies a thumbnail. Painting nothing is not slower than painting a
34
+ * third of a photograph; it only looks slower to whoever wrote the loading code.
35
+ *
36
+ * `onError` settles the state as well as `onLoad`. A dead URL must not leave the page holding its
37
+ * breath at `opacity: 0` forever — settled, it draws the browser's own broken-image treatment, over
38
+ * the thumbnail if there was one. Both are honest; an invisible box is not.
39
+ */
40
+ const cssUrl = (u) => (typeof u === "string" && /^https?:\/\/[^"'()\s\\]+$/i.test(u) ? `url("${u}")` : null);
41
+
42
+ export function BlurImage({ src, thumb, alt = "", className, ...rest }) {
43
+ const ref = useRef(null);
44
+ const [loaded, setLoaded] = useState(false);
45
+
46
+ // A new src is a new picture, and a new picture has not loaded. A picture already in the browser's
47
+ // cache, though, fires `load` before React has attached a handler to it — so ask the element.
48
+ useEffect(() => {
49
+ const img = ref.current;
50
+ setLoaded(Boolean(img?.complete && img.naturalWidth > 0));
51
+ }, [src]);
52
+
53
+ const proxy = cssUrl(thumb);
54
+ return (
55
+ <img
56
+ {...rest}
57
+ ref={ref}
58
+ className={["uir-img", className].filter(Boolean).join(" ")}
59
+ src={src}
60
+ alt={alt}
61
+ loading="lazy"
62
+ decoding="async"
63
+ style={proxy ? { ...rest.style, backgroundImage: proxy } : rest.style}
64
+ data-blurup={proxy ? "thumb" : "none"}
65
+ data-loaded={loaded || undefined}
66
+ onLoad={() => setLoaded(true)}
67
+ onError={() => setLoaded(true)}
68
+ />
69
+ );
70
+ }
71
+
72
+ /**
73
+ * An absent value, drawn so it cannot be mistaken for a small one. `empty.show: "dash"` is OBL's
74
+ * default because "rendering an unfiled royalty as £0.00 is a lie the page tells confidently."
75
+ */
76
+ export function Absent({ policy }) {
77
+ const { kind, text } = emptyRender(policy);
78
+ if (kind === "hide" || kind === "blank") return null; // `hide` is handled by the block; here it draws nothing
79
+ if (kind === "zero") return <span className="uir-absent uir-absent--zero">0</span>;
80
+ if (kind === "text") return <span className="uir-absent uir-absent--text" title="No value recorded">{text}</span>;
81
+ return (
82
+ <span className="uir-absent uir-absent--dash" aria-label="No value recorded" title="No value recorded">
83
+
84
+ </span>
85
+ );
86
+ }
87
+
88
+ /**
89
+ * One resolved value, formatted per its binding's `format`, and falling back to its COLUMN's type
90
+ * when the page stated none — a currency column drawn without a format is still money.
91
+ *
92
+ * A column whose declared `role` is `image` is a picture, not a URL. `role` is the dataset stating
93
+ * what a column MEANS as opposed to what it stores (DECISIONS D7), and this is the renderer taking
94
+ * it at its word. The scheme is checked at the point of use: a bound value is only known at render
95
+ * time, so the schema's `^https?://` never ran on it.
96
+ */
97
+ export function Value({ value, format, empty, column, now, thumb, spec }) {
98
+ if (isEmptyValue(value)) return <Absent policy={empty} />;
99
+
100
+ if (column?.role === "image" && typeof value === "string") {
101
+ if (!/^https?:\/\//i.test(value)) return <Absent policy={empty} />;
102
+ return <BlurImage className="uir-thumb" src={value} thumb={thumb} alt="" />;
103
+ }
104
+
105
+ const text = formatValue(value, format, { column, now });
106
+
107
+ // `columnSpec.as: "badge"` — a status stated as a status, inside a row where the `badge` block
108
+ // cannot go (BLK12). Absence is still absence: an empty cell draws its `empty` policy above,
109
+ // never an empty chip, because a badge of nothing is a claim that nothing is a status.
110
+ if (spec?.as === "badge") {
111
+ const tone = spec.tone ?? spec.tone_map?.find((e) => e.value === value)?.tone ?? "neutral";
112
+ return <span className="uir-badge uir-badge--cell" data-tone={tone}>{text}</span>;
113
+ }
114
+
115
+ return <>{text}</>;
116
+ }
117
+
118
+ export const numericish = (format, column) => isNumericFormat(format, column);
119
+
120
+ /** A semantic name with no image still gets the sheet's quiet, deterministic identity marker. */
121
+ export function InitialsChip({ value, shape = "round" }) {
122
+ return <span className="uir-initials" data-shape={shape} aria-hidden="true">{initialsFor(value)}</span>;
123
+ }
124
+
125
+ const skeletonTitleCase = (name) => String(name).replace(/_/g, " ").replace(/^./, (c) => c.toUpperCase());
126
+
127
+ /**
128
+ * The frame every block paints on the first frame, before any row exists.
129
+ *
130
+ * A block's DOCUMENT arrives seconds before its ROWS do, and everything the document says is already
131
+ * knowable: what this tile is called, what the metric measures, which columns the table will have.
132
+ * Drawing three grey bars instead throws that away and makes a generation that is visibly working
133
+ * look like a generation that is hung — which is exactly what Dan was watching.
134
+ *
135
+ * So the skeleton shimmers only where DATA goes. Everything the model has already written is set in
136
+ * real type, at its real size, in its final position, and does not move when the rows land.
137
+ */
138
+ export function Skeleton({ variant = "bars", block }) {
139
+ const heading = block?.title;
140
+ const columns = block?.type === "table" ? block.columns : null;
141
+
142
+ // A metric knows its own label and unit. Only the number is pending.
143
+ if (variant === "metric") {
144
+ return (
145
+ <div className="uir-skeleton">
146
+ {block?.label ? <span className="uir-skeleton__label">{block.label}</span> : <div className="uir-skeleton__bar" />}
147
+ <div className="uir-skeleton__value" />
148
+ </div>
149
+ );
150
+ }
151
+ if (variant === "value") {
152
+ return (
153
+ <div className="uir-skeleton">
154
+ {block?.label ? <span className="uir-skeleton__label">{block.label}</span> : <div className="uir-skeleton__bar" />}
155
+ <div className="uir-skeleton__bar uir-skeleton__bar--narrow" />
156
+ </div>
157
+ );
158
+ }
159
+ return (
160
+ <div className="uir-skeleton">
161
+ {heading ? <h3 className="uir-skeleton__heading">{heading}</h3> : <div className="uir-skeleton__bar" />}
162
+ {columns?.length ? (
163
+ // The column heads are the table's shape. They are in the document, so they are on the screen.
164
+ <div className="uir-skeleton__cols">
165
+ {columns.map((c) => (
166
+ <span key={c.field}>{c.label ?? skeletonTitleCase(c.field)}</span>
167
+ ))}
168
+ </div>
169
+ ) : null}
170
+ <div className="uir-skeleton__value" style={{ height: 56, width: "100%" }} />
171
+ </div>
172
+ );
173
+ }
174
+
175
+ export const LoadingRule = () => <div className="uir-loading-rule" role="progressbar" aria-label="Resolving data" />;
176
+
177
+ /**
178
+ * A dataset that failed is an error, never an empty result set (DECISIONS R9).
179
+ *
180
+ * But it is one TILE that failed, not the page. It says so at the tile's own scale: a small mark, a
181
+ * short label, and the reason in one line that may be read in full on hover. A full-width red
182
+ * paragraph makes one unresolvable dataset look like an outage — dishonest in the other direction.
183
+ */
184
+ export function BlockError({ message, onRetry }) {
185
+ // The design sets the offending identifier in mono inside the sentence. Resolver messages already
186
+ // name it in backticks, so we render those spans as code rather than re-parsing the message.
187
+ const parts = String(message).split(/`([^`]+)`/g);
188
+
189
+ return (
190
+ <div className="uir-error" role="alert">
191
+ <span className="uir-error__mark" aria-hidden="true">!</span>
192
+ <span className="uir-error__body">
193
+ <span className="uir-error__label">Could not resolve</span>
194
+ <span className="uir-error__msg" title={message}>
195
+ {parts.map((p, i) => (i % 2 ? <code key={i}>{p}</code> : p))}
196
+ </span>
197
+ {/* The one action that can help. A dataset that failed can be asked again; the design puts a
198
+ Retry here, and we can honour it exactly because `refresh` is already a first-class verb. */}
199
+ {onRetry && (
200
+ <button type="button" className="uir-error__retry" onClick={onRetry}>Retry</button>
201
+ )}
202
+ </span>
203
+ </div>
204
+ );
205
+ }
206
+
207
+ /**
208
+ * The validation tick (`States — validation`).
209
+ *
210
+ * Rendered only when the HOST says the page was validated. The renderer does not validate — Phase 2
211
+ * runs before it — so a tick it drew on its own initiative would assert a check nobody performed.
212
+ * A mark that appears whether or not the check ran is a decoration, and this one is a claim.
213
+ */
214
+ export function ValidationTick() {
215
+ return (
216
+ <span className="uir-tick" aria-hidden="true">
217
+ <svg width="9" height="7" viewBox="0 0 9 7"><polyline points="1,3.5 3.5,6 8,1" /></svg>
218
+ </span>
219
+ );
220
+ }
221
+
222
+ /** The sheet's default confirmation surface. Hosts may still replace it with `UIResponsePage.confirm`. */
223
+ export function ConfirmDialog({ prompt, onResolve }) {
224
+ const panel = useRef(null);
225
+ const titleId = useId();
226
+ useEffect(() => {
227
+ panel.current?.focus();
228
+ const onKey = (event) => { if (event.key === "Escape") onResolve(false); };
229
+ document.addEventListener("keydown", onKey);
230
+ return () => document.removeEventListener("keydown", onKey);
231
+ }, [onResolve]);
232
+ return (
233
+ <div className="uir-confirm" role="dialog" aria-modal="true" aria-labelledby={titleId}>
234
+ <button className="uir-confirm__scrim" type="button" aria-label="Cancel" onClick={() => onResolve(false)} />
235
+ <div className="uir-confirm__panel" ref={panel} tabIndex={-1}>
236
+ <h2 id={titleId}>{prompt.title}</h2>
237
+ {prompt.body && <p>{prompt.body}</p>}
238
+ <div className="uir-confirm__actions">
239
+ <button type="button" className="uir-button uir-button--ghost" onClick={() => onResolve(false)}>Cancel</button>
240
+ <button type="button" className={`uir-button${prompt.destructive ? " uir-button--confirm" : ""}`} onClick={() => onResolve(true)}>{prompt.confirm_label ?? "Confirm"}</button>
241
+ </div>
242
+ </div>
243
+ </div>
244
+ );
245
+ }
246
+
247
+ /**
248
+ * An action, rendered as a button. An action that cannot run is drawn DISABLED wearing its reason —
249
+ * never omitted (the page said it should be there) and never live (it would lie).
250
+ *
251
+ * ONE treatment, two reasons, and they are different kinds of thing (DECISIONS A12). Either this
252
+ * renderer cannot execute the verb — a fact about the implementation — or the page's `enabled_when`
253
+ * says the action does not apply to the current data. `actionGate` decides which, and the second is
254
+ * a teaching affordance rather than a guard: the resolver owns authorization, always.
255
+ *
256
+ * The reason gets a unique id per instance. Keying it by verb, as this did, meant every disabled
257
+ * `mutate` on a page pointed its `aria-describedby` at the first one's reason.
258
+ */
259
+ export function ActionButton({ action, ctx, variant = "ghost", small = false, fallbackLabel }) {
260
+ const reasonId = useId();
261
+ const { disabled, reason } = actionGate(action, ctx);
262
+ const label = actionLabel(action, fallbackLabel);
263
+ const effectiveVariant = isDestructiveAction(action) ? "danger" : variant;
264
+ const className = [
265
+ "uir-button",
266
+ effectiveVariant === "ghost" ? "uir-button--ghost" : "",
267
+ effectiveVariant === "danger" ? "uir-button--danger" : "",
268
+ small ? "uir-button--small" : "",
269
+ ].filter(Boolean).join(" ");
270
+
271
+ const button = (
272
+ <button
273
+ type="button"
274
+ className={className}
275
+ disabled={disabled}
276
+ aria-describedby={disabled ? reasonId : undefined}
277
+ onClick={(e) => { e.stopPropagation(); runWithConfirm(action, ctx); }}
278
+ >
279
+ {label}
280
+ </button>
281
+ );
282
+
283
+ if (!disabled) return button;
284
+ return (
285
+ <span className="uir-disabled-wrap">
286
+ {button}
287
+ <span className="uir-reason" role="note" id={reasonId}>{reason}</span>
288
+ </span>
289
+ );
290
+ }