@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.
- package/ATTRIBUTION.md +59 -0
- package/README.md +191 -0
- package/dist/uir-renderer-web.css +1 -0
- package/dist/uir-renderer-web.js +2213 -0
- package/package.json +63 -0
- package/src/UIResponsePage.jsx +576 -0
- package/src/blocks/containers.jsx +150 -0
- package/src/blocks/content.jsx +292 -0
- package/src/blocks/context.js +27 -0
- package/src/blocks/data.jsx +551 -0
- package/src/blocks/primitives.jsx +290 -0
- package/src/blocks/viz.jsx +616 -0
- package/src/core/actions.js +201 -0
- package/src/core/condition.js +90 -0
- package/src/core/empty.js +44 -0
- package/src/core/format.js +225 -0
- package/src/core/presentation.js +58 -0
- package/src/core/record.js +160 -0
- package/src/core/store.js +357 -0
- package/src/core/value.js +211 -0
- package/src/index.js +22 -0
- package/src/theme/tokens.js +364 -0
- package/src/theme/uir.css +1070 -0
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The three containers: `section`, `card`, `tabs`.
|
|
3
|
+
*
|
|
4
|
+
* Containers own ARRANGEMENT; blocks own EMPHASIS. There is no layout tree to keep in sync with the
|
|
5
|
+
* block tree, and no per-block position or size — structure IS layout (DECISIONS P1). A section of
|
|
6
|
+
* three cards arranged `grid` tells a web renderer to draw columns, an e-ink renderer to stack, and
|
|
7
|
+
* a voice renderer to say "three cards". This file is the web renderer's half of that bargain.
|
|
8
|
+
*/
|
|
9
|
+
import React, { useState } from "react";
|
|
10
|
+
import { UIResponseContext, useUIResponse, scopeFor } from "./context.js";
|
|
11
|
+
import { runWithConfirm } from "../core/actions.js";
|
|
12
|
+
import { isEditorialSection } from "../core/record.js";
|
|
13
|
+
|
|
14
|
+
const arrangementOf = (block) => block.layout?.arrangement ?? "stack";
|
|
15
|
+
const densityOf = (block) => block.layout?.density ?? undefined;
|
|
16
|
+
|
|
17
|
+
/** `intent.priority` as an ordering tiebreak among siblings of equal emphasis. */
|
|
18
|
+
const PRIORITY = { primary: 0, supporting: 1, ambient: 2 };
|
|
19
|
+
const EMPHASIS = { hero: 0, primary: 1, normal: 2, subtle: 3 };
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Sibling order. The DOCUMENT order is authoritative — a page's block order is a claim the author
|
|
23
|
+
* made, and reordering it would rewrite the page. `intent.priority` only breaks ties BETWEEN BLOCKS
|
|
24
|
+
* THE AUTHOR GAVE THE SAME EMPHASIS, and only inside `grid`/`row`/`flow` arrangements, where the
|
|
25
|
+
* blocks are peers scanned together rather than read in sequence. A `stack` is a reading order and
|
|
26
|
+
* is never reordered.
|
|
27
|
+
*/
|
|
28
|
+
export function orderSiblings(children, arrangement) {
|
|
29
|
+
if (arrangement === "stack") return children;
|
|
30
|
+
return children
|
|
31
|
+
.map((block, index) => ({ block, index }))
|
|
32
|
+
.sort((a, b) => {
|
|
33
|
+
const ea = EMPHASIS[a.block.emphasis ?? "normal"], eb = EMPHASIS[b.block.emphasis ?? "normal"];
|
|
34
|
+
if (ea !== eb) return ea - eb;
|
|
35
|
+
const pa = PRIORITY[a.block.intent?.priority] ?? 1, pb = PRIORITY[b.block.intent?.priority] ?? 1;
|
|
36
|
+
if (pa !== pb) return pa - pb;
|
|
37
|
+
return a.index - b.index;
|
|
38
|
+
})
|
|
39
|
+
.map((x) => x.block);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function Arrange({ block, children }) {
|
|
43
|
+
return (
|
|
44
|
+
<div className="uir-arrange" data-arrangement={arrangementOf(block)}>
|
|
45
|
+
{children}
|
|
46
|
+
</div>
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* A section is a LABELLED GROUPING of tiles, not a framed region.
|
|
52
|
+
*
|
|
53
|
+
* It draws no border, no rule and no rail. Its title is a small quiet label sitting above its tiles,
|
|
54
|
+
* and the grouping is legible from the gap around it — which is how grouping works on a canvas.
|
|
55
|
+
* A framed region inside a page is a page inside a page, and the tiles were already the objects.
|
|
56
|
+
*
|
|
57
|
+
* THE EXCEPTION IS THE EDITORIAL SECTION, and it is inferred rather than declared. A section whose
|
|
58
|
+
* title is followed by a `text` block is a magazine section: a headline, then one sentence saying
|
|
59
|
+
* what this run of things has in common, then the things. That composition was always expressible —
|
|
60
|
+
* `section.title` plus a `text` child is the only way OBL can say it — and the renderer drew both
|
|
61
|
+
* parts identically, a 12px grey label above a paragraph of body copy.
|
|
62
|
+
*
|
|
63
|
+
* So the pattern is READ, not added: no prop, no new block, nothing for a voice renderer to fail to
|
|
64
|
+
* honour (it already reads the title, then the sentence, then the things). The document is unchanged;
|
|
65
|
+
* only its treatment knows what it is looking at. The text block keeps its own `.uir-block` wrapper
|
|
66
|
+
* and its own `visible_when`, because it is still a block — it is styled as a standfirst by POSITION,
|
|
67
|
+
* not by being taken apart.
|
|
68
|
+
*/
|
|
69
|
+
export function SectionBlock({ block, renderChild, hideTitle }) {
|
|
70
|
+
const uir = useUIResponse();
|
|
71
|
+
const arrangement = arrangementOf(block);
|
|
72
|
+
const density = densityOf(block) ?? uir.density;
|
|
73
|
+
const editorial = isEditorialSection(block) && !hideTitle;
|
|
74
|
+
return (
|
|
75
|
+
<section className="uir-section" data-density={densityOf(block)} data-editorial={editorial || undefined}>
|
|
76
|
+
{/* Inside `tabs`, the section's title IS the tab label. Drawing it again would say the same
|
|
77
|
+
word twice, six pixels apart. */}
|
|
78
|
+
{block.title && !hideTitle && <h2 className="uir-section__label">{block.title}</h2>}
|
|
79
|
+
<UIResponseContext.Provider value={{ ...uir, density }}>
|
|
80
|
+
<Arrange block={block}>
|
|
81
|
+
{/* An editorial section's intro sentence is its standfirst and belongs above the things it
|
|
82
|
+
introduces. `orderSiblings` would happily promote a `hero` list above it on emphasis. */}
|
|
83
|
+
{orderSiblings(block.children ?? [], editorial ? "stack" : arrangement).map(renderChild)}
|
|
84
|
+
</Arrange>
|
|
85
|
+
</UIResponseContext.Provider>
|
|
86
|
+
</section>
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function CardBlock({ block, renderChild, emphasis }) {
|
|
91
|
+
const uir = useUIResponse();
|
|
92
|
+
const clickable = Boolean(block.on_click);
|
|
93
|
+
return (
|
|
94
|
+
<div
|
|
95
|
+
className={`uir-card${clickable ? " uir-card--clickable" : ""}`}
|
|
96
|
+
data-emphasis={emphasis}
|
|
97
|
+
onClick={clickable ? () => runWithConfirm(block.on_click, { ...uir.actionCtx, scope: scopeFor(uir) }) : undefined}
|
|
98
|
+
>
|
|
99
|
+
{block.title && <h3 className="uir-card__title">{block.title}</h3>}
|
|
100
|
+
{/* A card IS a tile. Its children are the card's contents, so they shed their own tile chrome
|
|
101
|
+
(see `.uir-card .uir-block[data-tile]` in uir.css) — a tile inside a tile is a box inside
|
|
102
|
+
a box, which is the exact thing this composition model exists to stop. */}
|
|
103
|
+
<div className="uir-arrange" data-arrangement="grid">
|
|
104
|
+
{orderSiblings(block.children ?? [], "grid").map(renderChild)}
|
|
105
|
+
</div>
|
|
106
|
+
</div>
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Tabs are alternative views of one subject, one at a time. Their children are SECTIONS, and each
|
|
112
|
+
* section's `title` is its tab label (DECISIONS P3) — there is no anonymous tab container.
|
|
113
|
+
*/
|
|
114
|
+
export function TabsBlock({ block, renderChild }) {
|
|
115
|
+
const [active, setActive] = useState(0);
|
|
116
|
+
const sections = block.children ?? [];
|
|
117
|
+
|
|
118
|
+
return (
|
|
119
|
+
<div className="uir-tabs">
|
|
120
|
+
<div className="uir-tabs__list" role="tablist">
|
|
121
|
+
{sections.map((section, i) => (
|
|
122
|
+
<button
|
|
123
|
+
key={section.id}
|
|
124
|
+
type="button"
|
|
125
|
+
role="tab"
|
|
126
|
+
className="uir-tab"
|
|
127
|
+
aria-selected={i === active}
|
|
128
|
+
aria-controls={`panel-${section.id}`}
|
|
129
|
+
onClick={() => setActive(i)}
|
|
130
|
+
>
|
|
131
|
+
{section.title}
|
|
132
|
+
</button>
|
|
133
|
+
))}
|
|
134
|
+
</div>
|
|
135
|
+
{sections.map((section, i) => (
|
|
136
|
+
<div
|
|
137
|
+
key={section.id}
|
|
138
|
+
id={`panel-${section.id}`}
|
|
139
|
+
role="tabpanel"
|
|
140
|
+
// The inactive panel is HIDDEN, not unmounted. Unmounting would restart its datasets'
|
|
141
|
+
// progressive load every time the user flicked between tabs, and a tab is a view of data
|
|
142
|
+
// the page already asked for.
|
|
143
|
+
hidden={i !== active}
|
|
144
|
+
>
|
|
145
|
+
{renderChild(section, i)}
|
|
146
|
+
</div>
|
|
147
|
+
))}
|
|
148
|
+
</div>
|
|
149
|
+
);
|
|
150
|
+
}
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The content and interactive leaves: `text`, `media`, `input`, `button`.
|
|
3
|
+
*/
|
|
4
|
+
import React, { useEffect, useRef, useState } from "react";
|
|
5
|
+
import { useUIResponse, scopeFor } from "./context.js";
|
|
6
|
+
import { ActionButton, BlockError, BlurImage } from "./primitives.jsx";
|
|
7
|
+
import { resolveValue, isBinding, boundColumn } from "../core/value.js";
|
|
8
|
+
import { runWithConfirm, runInputAction } from "../core/actions.js";
|
|
9
|
+
import { thumbNameFor } from "../core/record.js";
|
|
10
|
+
import { formatAudioTime } from "../core/presentation.js";
|
|
11
|
+
|
|
12
|
+
// ───────────────────────────────────────────────────────────────────────────────
|
|
13
|
+
// text — prose the page author wrote. PLAIN TEXT: no markdown, no HTML.
|
|
14
|
+
// ───────────────────────────────────────────────────────────────────────────────
|
|
15
|
+
export function TextBlock({ block, emphasis }) {
|
|
16
|
+
// Rendered as a text node, never `dangerouslySetInnerHTML`. `body` is plain text by schema
|
|
17
|
+
// (Principle 8: microsyntax in a string is invisible to JSON Schema), and a renderer that parsed
|
|
18
|
+
// markdown here would quietly make it a markup surface — reachable from a chat message.
|
|
19
|
+
return <p className="uir-text" data-emphasis={emphasis}>{block.body}</p>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// ───────────────────────────────────────────────────────────────────────────────
|
|
23
|
+
// media — something to look at or listen to
|
|
24
|
+
// ───────────────────────────────────────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* THE PROVIDER ALLOWLIST (DECISIONS MED4, MED5).
|
|
28
|
+
*
|
|
29
|
+
* A page never carries a frame URL. It carries a provider from a closed enum and an opaque id, and
|
|
30
|
+
* THIS TABLE — code, reviewed, versioned — is the only thing that turns those into an origin. That
|
|
31
|
+
* inversion is what closes the three attack classes A2UI's architect names on stage
|
|
32
|
+
* (`docs/research/video-study-UsMDkEsR-ok-2026-07-09.md`):
|
|
33
|
+
*
|
|
34
|
+
* · exfiltration through a rendered resource the agent did not mean to fetch — a page cannot name
|
|
35
|
+
* an origin, so there is nowhere to exfiltrate TO;
|
|
36
|
+
* · a hidden form inside a click target — the page supplies no markup, only an id;
|
|
37
|
+
* · supply chain — the set of reachable origins is fixed by the spec, not by generation.
|
|
38
|
+
*
|
|
39
|
+
* `content_id` is already `^[A-Za-z0-9_-]{1,128}$` in the schema, so it cannot spell a scheme, a
|
|
40
|
+
* path traversal or markup. We encode it anyway. A validator that ran yesterday is not a guarantee
|
|
41
|
+
* about this render, and the same reasoning puts A8's scheme check back in `runAction`.
|
|
42
|
+
*
|
|
43
|
+
* An UNRECOGNISED provider — one a later minor added — draws NOTHING and reports itself. Guessing an
|
|
44
|
+
* origin from a name is the one thing this table exists to make impossible.
|
|
45
|
+
*/
|
|
46
|
+
const EMBED_PROVIDERS = {
|
|
47
|
+
youtube: { origin: "https://www.youtube-nocookie.com", url: (id) => `https://www.youtube-nocookie.com/embed/${id}`, open: (id) => `https://www.youtube.com/watch?v=${id}`, domain: "youtube.com", name: "YouTube" },
|
|
48
|
+
tiktok: { origin: "https://www.tiktok.com", url: (id) => `https://www.tiktok.com/embed/v2/${id}`, open: (id) => `https://www.tiktok.com/player/v1/${id}`, domain: "tiktok.com", name: "TikTok" },
|
|
49
|
+
// An opaque id cannot say track-vs-playlist-vs-episode, so `spotify` resolves to the default
|
|
50
|
+
// surface. Widening that is a `content_type` prop or more enum members; encoding it in the id
|
|
51
|
+
// would be a microsyntax (B2) and would reopen the path surface the pattern closes.
|
|
52
|
+
spotify: { origin: "https://open.spotify.com", url: (id) => `https://open.spotify.com/embed/track/${id}`, open: (id) => `https://open.spotify.com/track/${id}`, domain: "open.spotify.com", name: "Spotify" },
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* `sandbox` without `allow-same-origin` would break every provider's player (they need their own
|
|
57
|
+
* storage and their own origin). Granting it is safe here precisely because the frame is CROSS-origin:
|
|
58
|
+
* "same origin" means the provider's, never ours. What is withheld is what matters — no `allow-forms`,
|
|
59
|
+
* no `allow-popups`, no `allow-top-navigation`: a frame from an allowlisted origin still cannot
|
|
60
|
+
* navigate the page that hosts it or put a form in front of the user.
|
|
61
|
+
*/
|
|
62
|
+
const EMBED_SANDBOX = "allow-scripts allow-same-origin allow-presentation";
|
|
63
|
+
|
|
64
|
+
function ProviderEmbed({ block }) {
|
|
65
|
+
const spec = EMBED_PROVIDERS[block.provider];
|
|
66
|
+
if (!spec) {
|
|
67
|
+
return <BlockError message={`This renderer does not know the media provider \`${block.provider}\`. It will not guess an origin.`} />;
|
|
68
|
+
}
|
|
69
|
+
const src = spec.url(encodeURIComponent(block.content_id));
|
|
70
|
+
const external = spec.open(encodeURIComponent(block.content_id));
|
|
71
|
+
// No `aspect` means landscape. That is a renderer default over an absent optional prop, not an
|
|
72
|
+
// inference from one — a page that says nothing about shape gets the shape most video has.
|
|
73
|
+
const aspect = block.aspect ?? "16:9";
|
|
74
|
+
|
|
75
|
+
return (
|
|
76
|
+
<figure className="uir-media">
|
|
77
|
+
<div className="uir-media__frame" data-aspect={aspect} style={{ aspectRatio: aspect.replace(":", " / ") }}>
|
|
78
|
+
<iframe
|
|
79
|
+
className="uir-media__embed"
|
|
80
|
+
src={src}
|
|
81
|
+
title={block.alt}
|
|
82
|
+
sandbox={EMBED_SANDBOX}
|
|
83
|
+
referrerPolicy="no-referrer"
|
|
84
|
+
allow="encrypted-media; picture-in-picture; clipboard-write"
|
|
85
|
+
allowFullScreen
|
|
86
|
+
loading="lazy"
|
|
87
|
+
/>
|
|
88
|
+
</div>
|
|
89
|
+
{/* `alt` is required on video and embed: the content lives at an origin this renderer cannot
|
|
90
|
+
inspect, so the page is the only thing that can describe it. */}
|
|
91
|
+
<figcaption className="uir-embed-meta">
|
|
92
|
+
<span className="uir-embed-meta__icon" aria-hidden="true">{spec.name[0]}</span>
|
|
93
|
+
<span className="uir-embed-meta__copy">
|
|
94
|
+
<strong>{block.caption ?? block.alt}</strong>
|
|
95
|
+
<span>{spec.domain}</span>
|
|
96
|
+
</span>
|
|
97
|
+
<a href={external} target="_blank" rel="noreferrer">Open <span aria-hidden="true">↗</span></a>
|
|
98
|
+
</figcaption>
|
|
99
|
+
</figure>
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function AudioPlayer({ src, title }) {
|
|
104
|
+
const audio = useRef(null);
|
|
105
|
+
const [playing, setPlaying] = useState(false);
|
|
106
|
+
const [current, setCurrent] = useState(0);
|
|
107
|
+
const [duration, setDuration] = useState(0);
|
|
108
|
+
useEffect(() => { setPlaying(false); setCurrent(0); setDuration(0); }, [src]);
|
|
109
|
+
const toggle = async () => {
|
|
110
|
+
if (!audio.current) return;
|
|
111
|
+
try {
|
|
112
|
+
if (audio.current.paused) await audio.current.play(); else audio.current.pause();
|
|
113
|
+
} catch {
|
|
114
|
+
setPlaying(false);
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
const pct = duration > 0 ? Math.min(100, (current / duration) * 100) : 0;
|
|
118
|
+
return (
|
|
119
|
+
<div className="uir-audio">
|
|
120
|
+
<audio ref={audio} src={src} preload="metadata" onPlay={() => setPlaying(true)} onPause={() => setPlaying(false)}
|
|
121
|
+
onEnded={() => setPlaying(false)} onTimeUpdate={(event) => setCurrent(event.currentTarget.currentTime)}
|
|
122
|
+
onLoadedMetadata={(event) => setDuration(event.currentTarget.duration)} />
|
|
123
|
+
<button type="button" onClick={toggle} aria-label={playing ? `Pause ${title}` : `Play ${title}`}>
|
|
124
|
+
<span aria-hidden="true">{playing ? "Ⅱ" : "▶"}</span>
|
|
125
|
+
</button>
|
|
126
|
+
<div className="uir-audio__body">
|
|
127
|
+
<div className="uir-audio__line"><strong title={title}>{title}</strong><span>{formatAudioTime(current)} / {formatAudioTime(duration)}</span></div>
|
|
128
|
+
<div className="uir-audio__track"><span style={{ width: `${pct}%` }} /></div>
|
|
129
|
+
</div>
|
|
130
|
+
</div>
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function MediaBlock({ block }) {
|
|
135
|
+
const uir = useUIResponse();
|
|
136
|
+
const scope = scopeFor(uir, uir.row);
|
|
137
|
+
|
|
138
|
+
if (block.kind === "video" || block.kind === "embed") return <ProviderEmbed block={block} />;
|
|
139
|
+
|
|
140
|
+
const src = resolveValue(block.src, scope);
|
|
141
|
+
|
|
142
|
+
// A bound `src` is only known at render time, so the literal's schema-level `^https?://` check
|
|
143
|
+
// cannot have run on it. Check it here, at the point of use (VERSIONING §9, media.src).
|
|
144
|
+
if (typeof src !== "string" || !/^https?:\/\//i.test(src)) {
|
|
145
|
+
return <div className="uir-error" role="alert"><span className="uir-error__label">Refused media source</span>Only absolute http(s) URLs may be rendered.</div>;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (block.kind === "audio") {
|
|
149
|
+
return (
|
|
150
|
+
<figure className="uir-media">
|
|
151
|
+
<AudioPlayer src={src} title={block.caption ?? "Audio"} />
|
|
152
|
+
</figure>
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
return (
|
|
156
|
+
<figure className="uir-media">
|
|
157
|
+
{/* `alt` is required on images by the schema: accessibility is semantics, and it is what a
|
|
158
|
+
voice renderer SAYS. */}
|
|
159
|
+
<BlurImage className="uir-media__image" src={src} thumb={thumbSrc(block.src, scope)} alt={block.alt ?? ""} />
|
|
160
|
+
{block.caption && <figcaption className="uir-media__caption">{block.caption}</figcaption>}
|
|
161
|
+
</figure>
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* The blur-up proxy for a BOUND `src`, or null.
|
|
167
|
+
*
|
|
168
|
+
* `media.src` is a binding at a named field of a named dataset. Its proxy is the SAME binding at the
|
|
169
|
+
* conventional sibling field (`artwork_url` → `artwork_thumb_url`, see `core/record.js`) — same
|
|
170
|
+
* dataset, same row selector, so a hero cover picked with `row: "first"` gets the first row's
|
|
171
|
+
* thumbnail and never the wrong record's. If the dataset never declared the sibling, there is no
|
|
172
|
+
* proxy and the image fades in from nothing.
|
|
173
|
+
*
|
|
174
|
+
* A literal `src` has no dataset to ask, and so has no proxy. That is correct: a URL written into a
|
|
175
|
+
* document is one URL, and inventing a second by string surgery would be a request for a file that
|
|
176
|
+
* nobody said exists.
|
|
177
|
+
*/
|
|
178
|
+
function thumbSrc(srcSpec, scope) {
|
|
179
|
+
const bind = srcSpec?.$bind;
|
|
180
|
+
if (!bind?.dataset || !bind.field) return null;
|
|
181
|
+
const want = thumbNameFor(bind.field);
|
|
182
|
+
const declared = scope.dataset(bind.dataset)?.columns ?? [];
|
|
183
|
+
if (!declared.some((c) => c?.name === want)) return null;
|
|
184
|
+
try {
|
|
185
|
+
const v = resolveValue({ $bind: { ...bind, field: want } }, scope);
|
|
186
|
+
return typeof v === "string" && v ? v : null;
|
|
187
|
+
} catch {
|
|
188
|
+
return null; // a proxy that will not resolve is simply absent; the picture is not
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// ───────────────────────────────────────────────────────────────────────────────
|
|
193
|
+
// input — the way a page listens. ONE block, types are props.
|
|
194
|
+
// ───────────────────────────────────────────────────────────────────────────────
|
|
195
|
+
export function InputBlock({ block }) {
|
|
196
|
+
const uir = useUIResponse();
|
|
197
|
+
const scope = scopeFor(uir);
|
|
198
|
+
|
|
199
|
+
// An input's state belongs to a param or to nobody. `value` names the param it reflects; failing
|
|
200
|
+
// that, the param its `on_change` writes.
|
|
201
|
+
const paramName = block.value?.$param ?? (block.on_change?.type === "set_param" ? block.on_change.param : null);
|
|
202
|
+
const current = paramName ? uir.params[paramName] : undefined;
|
|
203
|
+
|
|
204
|
+
const ctx = { ...uir.actionCtx, scope: scopeFor(uir) };
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* `widgetValue` is what `{"$self": true}` resolves to, and it is what each `input_type` promises
|
|
208
|
+
* to write (binding.schema.json#selfRef): a select writes its chosen option's value field, a
|
|
209
|
+
* toggle a boolean, a text field its string, a date its date. `row` is passed only by a select,
|
|
210
|
+
* because only a select has one — and `row: null` is the CLEARED select, which `runInputAction`
|
|
211
|
+
* turns into `set_param(null)` for the older row-binding spelling.
|
|
212
|
+
*/
|
|
213
|
+
const fire = ({ row, widgetValue }) => runInputAction(block.on_change, ctx, { row, widgetValue });
|
|
214
|
+
|
|
215
|
+
if (block.input_type === "select") {
|
|
216
|
+
const bind = block.options?.$bind;
|
|
217
|
+
const ds = bind ? uir.dataset(bind.dataset) : null;
|
|
218
|
+
const rows = ds?.rows ?? [];
|
|
219
|
+
const labelField = block.option_label_field;
|
|
220
|
+
const valueField = block.option_value_field ?? labelField;
|
|
221
|
+
|
|
222
|
+
return (
|
|
223
|
+
<div className="uir-field">
|
|
224
|
+
<span className="uir-eyebrow">{block.label}</span>
|
|
225
|
+
<div className="uir-select-row">
|
|
226
|
+
<select
|
|
227
|
+
aria-label={block.label}
|
|
228
|
+
className="uir-select"
|
|
229
|
+
value={current ?? ""}
|
|
230
|
+
onChange={(e) => {
|
|
231
|
+
const picked = rows.find((r) => String(r[valueField]) === e.target.value);
|
|
232
|
+
// The empty option CLEARS the param. `set_param` to null is how "no filter" is spelled,
|
|
233
|
+
// and a query predicate marked `optional: true` then drops out entirely (R4).
|
|
234
|
+
if (!picked) { fire({ row: null, widgetValue: null }); return; }
|
|
235
|
+
fire({ row: picked, widgetValue: picked[valueField] ?? null });
|
|
236
|
+
}}
|
|
237
|
+
>
|
|
238
|
+
<option value="">All</option>
|
|
239
|
+
{rows.map((row, i) => (
|
|
240
|
+
<option key={i} value={String(row[valueField])}>{row[labelField]}</option>
|
|
241
|
+
))}
|
|
242
|
+
</select>
|
|
243
|
+
{current !== null && current !== undefined && current !== "" && (
|
|
244
|
+
<button type="button" className="uir-select-clear" onClick={() => fire({ row: null, widgetValue: null })}>Clear</button>
|
|
245
|
+
)}
|
|
246
|
+
</div>
|
|
247
|
+
</div>
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (block.input_type === "toggle") {
|
|
252
|
+
return (
|
|
253
|
+
<div className="uir-toggle">
|
|
254
|
+
<span>{block.label}</span>
|
|
255
|
+
<button type="button" role="switch" aria-checked={Boolean(current)} aria-label={block.label}
|
|
256
|
+
onClick={() => fire({ widgetValue: !Boolean(current) })}><span aria-hidden="true" /></button>
|
|
257
|
+
</div>
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
return (
|
|
262
|
+
<label className="uir-field">
|
|
263
|
+
<span className="uir-eyebrow">{block.label}</span>
|
|
264
|
+
<input
|
|
265
|
+
className="uir-input"
|
|
266
|
+
type={block.input_type === "date" ? "date" : "text"}
|
|
267
|
+
placeholder={block.placeholder}
|
|
268
|
+
value={current ?? ""}
|
|
269
|
+
onChange={(e) => fire({ widgetValue: e.target.value })}
|
|
270
|
+
/>
|
|
271
|
+
</label>
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// ───────────────────────────────────────────────────────────────────────────────
|
|
276
|
+
// button — a verb with a border. Its label is the ACTION's label: one source of truth.
|
|
277
|
+
// ───────────────────────────────────────────────────────────────────────────────
|
|
278
|
+
export function ButtonBlock({ block, emphasis }) {
|
|
279
|
+
const uir = useUIResponse();
|
|
280
|
+
return (
|
|
281
|
+
<div>
|
|
282
|
+
<ActionButton
|
|
283
|
+
action={block.on_click}
|
|
284
|
+
ctx={{ ...uir.actionCtx, scope: scopeFor(uir) }}
|
|
285
|
+
variant={emphasis === "hero" || emphasis === "primary" ? "solid" : "ghost"}
|
|
286
|
+
fallbackLabel={block.on_click?.type}
|
|
287
|
+
/>
|
|
288
|
+
</div>
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
export { isBinding, boundColumn };
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { createContext, useContext } from "react";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Everything a block needs and nothing it does not. Note what is ABSENT: no connector, no query, no
|
|
5
|
+
* credential, no fetch. A block cannot reach data except through `dataset(name)`, which holds rows
|
|
6
|
+
* the resolver already returned. Principle 4, enforced by the shape of this object.
|
|
7
|
+
*/
|
|
8
|
+
export const UIResponseContext = createContext(null);
|
|
9
|
+
|
|
10
|
+
export function useUIResponse() {
|
|
11
|
+
const ctx = useContext(UIResponseContext);
|
|
12
|
+
if (!ctx) throw new Error("OBL blocks must render inside <UIResponsePage>.");
|
|
13
|
+
return ctx;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The binding scope for a block: optionally inside a repeating element's `row`, and optionally
|
|
18
|
+
* carrying the value of the control that fired the action (`self`).
|
|
19
|
+
*
|
|
20
|
+
* `self` is left OFF unless a control supplied one — `{"$self": true}` then throws rather than
|
|
21
|
+
* resolving to `undefined`, which is what tells us a page reached the renderer unvalidated.
|
|
22
|
+
*/
|
|
23
|
+
export function scopeFor(uir, row, self) {
|
|
24
|
+
const scope = { dataset: uir.dataset, params: uir.params, now: uir.now, row };
|
|
25
|
+
if (self !== undefined) scope.self = self;
|
|
26
|
+
return scope;
|
|
27
|
+
}
|