@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,551 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The data-heavy leaves: `table`, `list`, `detail`, `timeline`.
|
|
3
|
+
*
|
|
4
|
+
* All four bind ROWS and name columns of that one dataset — the v1 invariant that makes every
|
|
5
|
+
* field-ref resolvable (`x-uir-field-ref`). Actions inside them carry `scope: "row"` bindings, which
|
|
6
|
+
* resolve against the row the user actually clicked, never against the dataset (DECISIONS R7).
|
|
7
|
+
*/
|
|
8
|
+
import React, { useEffect, useMemo, useState } from "react";
|
|
9
|
+
import { useUIResponse, scopeFor } from "./context.js";
|
|
10
|
+
import { Value, ActionButton, BlurImage, InitialsChip, numericish } from "./primitives.jsx";
|
|
11
|
+
import { formatValue } from "../core/format.js";
|
|
12
|
+
import { resolveBinding, resolveValue, boundColumn, UIResponseResolveError } from "../core/value.js";
|
|
13
|
+
import { runWithConfirm } from "../core/actions.js";
|
|
14
|
+
import { statusTone, isHttpUrl, isImageSrc, linkText, splitRecordFields, imageColumn, thumbColumn, defaultColumnSpecs, posterMode, staggerIndex } from "../core/record.js";
|
|
15
|
+
import { BlockError } from "./primitives.jsx";
|
|
16
|
+
import { paginateRows, timelineLayout } from "../core/presentation.js";
|
|
17
|
+
|
|
18
|
+
/** Every column the dataset declared, in declared order — what `columns`/`fields` mean when omitted. */
|
|
19
|
+
const allColumns = defaultColumnSpecs;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* "This opens something." The design sheet gives a row action exactly one mark: a small chevron at
|
|
23
|
+
* the trailing edge, in the faint ink. Not a button — the row is the button — and not a colour
|
|
24
|
+
* change, which a status column has already spent.
|
|
25
|
+
*/
|
|
26
|
+
const Chevron = () => (
|
|
27
|
+
<svg className="uir-chevron" width="7" height="11" viewBox="0 0 7 11" aria-hidden="true" focusable="false">
|
|
28
|
+
<polyline points="1.5,1.5 5.5,5.5 1.5,9.5" fill="none" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
|
|
29
|
+
</svg>
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* ARRIVAL, ROW BY ROW.
|
|
34
|
+
*
|
|
35
|
+
* A block used to appear whole: skeleton, then a finished table, stamped. The blocks were streaming
|
|
36
|
+
* and the datasets were resolving independently, and none of that was visible — so a page that was
|
|
37
|
+
* plainly working looked like a page that had frozen and then blinked.
|
|
38
|
+
*
|
|
39
|
+
* The stagger is the block's own `uir-fold` motion, applied per row with a small offset. It is CSS:
|
|
40
|
+
* one custom property per row, and `@media (prefers-reduced-motion: reduce)` turns the whole thing
|
|
41
|
+
* off. The cap matters — a 200-row table must not take seven seconds to finish arriving, and past
|
|
42
|
+
* about a dozen rows the eye has stopped reading them as individuals anyway.
|
|
43
|
+
*/
|
|
44
|
+
const staggerVar = (i) => ({ "--uir-i": staggerIndex(i) });
|
|
45
|
+
|
|
46
|
+
const columnDecl = (columns, field) => columns.find((c) => c.name === field);
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* `column.label` "Defaults to a title-cased `name`" (data.schema.json). That default is the
|
|
50
|
+
* renderer's to apply, and applying it is the difference between a column headed "Waiting on" and
|
|
51
|
+
* one headed `WAITING_ON`, which is a database talking to itself in front of a label manager.
|
|
52
|
+
*/
|
|
53
|
+
const titleCase = (name) => name.replace(/_/g, " ").replace(/^./, (c) => c.toUpperCase());
|
|
54
|
+
const headingFor = (spec, col) => spec.label ?? col?.label ?? (col?.name ? titleCase(col.name) : titleCase(spec.field));
|
|
55
|
+
|
|
56
|
+
function useRowsBinding(block, prop) {
|
|
57
|
+
const uir = useUIResponse();
|
|
58
|
+
const bind = block[prop]?.$bind;
|
|
59
|
+
const ds = bind ? uir.dataset(bind.dataset) : null;
|
|
60
|
+
return { uir, bind, rows: ds?.rows ?? [], columns: ds?.columns ?? [] };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** An action attached to a repeating element, run in that row's scope. */
|
|
64
|
+
function useRowRunner(uir) {
|
|
65
|
+
return (action, row) => runWithConfirm(action, { ...uir.actionCtx, scope: scopeFor(uir, row) });
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ───────────────────────────────────────────────────────────────────────────────
|
|
69
|
+
// table — the working list
|
|
70
|
+
// ───────────────────────────────────────────────────────────────────────────────
|
|
71
|
+
export function TableBlock({ block }) {
|
|
72
|
+
const { uir, rows, columns } = useRowsBinding(block, "rows");
|
|
73
|
+
const runRow = useRowRunner(uir);
|
|
74
|
+
const specs = block.columns ?? allColumns(columns);
|
|
75
|
+
const rowActions = block.row_actions ?? [];
|
|
76
|
+
const clickable = Boolean(block.row_action);
|
|
77
|
+
const [density, setDensity] = useState(uir.density ?? "comfortable");
|
|
78
|
+
const [page, setPage] = useState(0);
|
|
79
|
+
// A thumbnail is an image too, and it arrives the same way: blurred proxy first, if the dataset
|
|
80
|
+
// declared one alongside its image column.
|
|
81
|
+
const thumbCol = thumbColumn(columns, imageColumn(columns));
|
|
82
|
+
const imageCol = imageColumn(columns);
|
|
83
|
+
const nameSpec = !imageCol ? specs.find((spec) => columnDecl(columns, spec.field)?.role === "name") : null;
|
|
84
|
+
const paged = paginateRows(rows, page);
|
|
85
|
+
useEffect(() => { if (paged.page !== page) setPage(paged.page); }, [page, paged.page]);
|
|
86
|
+
|
|
87
|
+
return (
|
|
88
|
+
<>
|
|
89
|
+
{/* The design's table tile carries its own title above the column heads. `title` is an
|
|
90
|
+
envelope key every block already has; nothing else in the vocabulary changes. */}
|
|
91
|
+
<div className="uir-table__head">
|
|
92
|
+
{block.title && <h3 className="uir-table__title">{block.title}</h3>}
|
|
93
|
+
<div className="uir-density" role="group" aria-label="Table density">
|
|
94
|
+
{[["comfortable", "Comfortable"], ["compact", "Compact"]].map(([value, label]) => (
|
|
95
|
+
<button key={value} type="button" aria-pressed={density === value} onClick={() => setDensity(value)}>{label}</button>
|
|
96
|
+
))}
|
|
97
|
+
</div>
|
|
98
|
+
</div>
|
|
99
|
+
<div className="uir-table-wrap">
|
|
100
|
+
<div className="uir-table__scroll">
|
|
101
|
+
<table className="uir-table" data-density={density}>
|
|
102
|
+
<thead>
|
|
103
|
+
<tr>
|
|
104
|
+
{specs.map((spec) => {
|
|
105
|
+
const col = columnDecl(columns, spec.field);
|
|
106
|
+
return (
|
|
107
|
+
<th key={spec.field} data-numeric={numericish(spec.format, col) || undefined} scope="col">
|
|
108
|
+
{headingFor(spec, col)}
|
|
109
|
+
</th>
|
|
110
|
+
);
|
|
111
|
+
})}
|
|
112
|
+
{rowActions.length > 0 && <th className="uir-table__actions-cell" scope="col" aria-label="Row actions" />}
|
|
113
|
+
</tr>
|
|
114
|
+
</thead>
|
|
115
|
+
<tbody>
|
|
116
|
+
{paged.items.map((row, i) => (
|
|
117
|
+
<tr
|
|
118
|
+
key={i}
|
|
119
|
+
style={staggerVar(i)}
|
|
120
|
+
data-clickable={clickable || undefined}
|
|
121
|
+
tabIndex={clickable ? 0 : undefined}
|
|
122
|
+
role={clickable ? "button" : undefined}
|
|
123
|
+
// A clickable row takes focus when it is clicked. Browsers focus buttons and links on
|
|
124
|
+
// click, and nothing else — so without this a row activated by mouse leaves focus on
|
|
125
|
+
// `document.body`, and an overlay opened from it has nowhere to send focus back to.
|
|
126
|
+
// The row already carries `tabIndex` and `role="button"`; this makes it behave like one.
|
|
127
|
+
onClick={clickable ? (e) => { e.currentTarget.focus(); runRow(block.row_action, row); } : undefined}
|
|
128
|
+
onKeyDown={clickable ? (e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); runRow(block.row_action, row); } } : undefined}
|
|
129
|
+
>
|
|
130
|
+
{specs.map((spec) => {
|
|
131
|
+
const col = columnDecl(columns, spec.field);
|
|
132
|
+
return (
|
|
133
|
+
<td key={spec.field} data-numeric={numericish(spec.format, col) || undefined}>
|
|
134
|
+
{nameSpec === spec ? (
|
|
135
|
+
<span className="uir-name-cell">
|
|
136
|
+
<InitialsChip value={row[spec.field]} />
|
|
137
|
+
<Value
|
|
138
|
+
value={row[spec.field]}
|
|
139
|
+
format={spec.format}
|
|
140
|
+
empty={spec.empty}
|
|
141
|
+
column={col}
|
|
142
|
+
now={uir.now}
|
|
143
|
+
spec={spec}
|
|
144
|
+
/>
|
|
145
|
+
</span>
|
|
146
|
+
) : (
|
|
147
|
+
<Value
|
|
148
|
+
value={row[spec.field]}
|
|
149
|
+
format={spec.format}
|
|
150
|
+
empty={spec.empty}
|
|
151
|
+
column={col}
|
|
152
|
+
now={uir.now}
|
|
153
|
+
thumb={col?.role === "image" && thumbCol ? row[thumbCol.name] : undefined}
|
|
154
|
+
spec={spec}
|
|
155
|
+
/>
|
|
156
|
+
)}
|
|
157
|
+
</td>
|
|
158
|
+
);
|
|
159
|
+
})}
|
|
160
|
+
{rowActions.length > 0 && (
|
|
161
|
+
<td className="uir-table__actions-cell">
|
|
162
|
+
<div className="uir-table__actions">
|
|
163
|
+
{rowActions.map((action, k) => (
|
|
164
|
+
<ActionButton
|
|
165
|
+
key={k}
|
|
166
|
+
action={action}
|
|
167
|
+
small
|
|
168
|
+
ctx={{ ...uir.actionCtx, scope: scopeFor(uir, row) }}
|
|
169
|
+
fallbackLabel={action.type}
|
|
170
|
+
/>
|
|
171
|
+
))}
|
|
172
|
+
</div>
|
|
173
|
+
</td>
|
|
174
|
+
)}
|
|
175
|
+
</tr>
|
|
176
|
+
))}
|
|
177
|
+
</tbody>
|
|
178
|
+
</table>
|
|
179
|
+
</div>
|
|
180
|
+
{paged.count > 0 && (
|
|
181
|
+
<div className="uir-pagination">
|
|
182
|
+
<span>{paged.start}–{paged.end} of {paged.count}</span>
|
|
183
|
+
<div>
|
|
184
|
+
<button type="button" aria-label="Previous page" disabled={paged.page === 0} onClick={() => setPage((value) => value - 1)}>‹</button>
|
|
185
|
+
<button type="button" aria-label="Next page" disabled={paged.page >= paged.pageCount - 1} onClick={() => setPage((value) => value + 1)}>›</button>
|
|
186
|
+
</div>
|
|
187
|
+
</div>
|
|
188
|
+
)}
|
|
189
|
+
</div>
|
|
190
|
+
</>
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ───────────────────────────────────────────────────────────────────────────────
|
|
195
|
+
// list — rows where the row is a thing, not a record
|
|
196
|
+
// ───────────────────────────────────────────────────────────────────────────────
|
|
197
|
+
/**
|
|
198
|
+
* THE SLEEVE.
|
|
199
|
+
*
|
|
200
|
+
* The prompt has always told the model that a list "pairs with photos", and the schema has always let
|
|
201
|
+
* a dataset declare a `role: image` column. A `table` drew that column as a thumbnail. A `list` drew
|
|
202
|
+
* nothing at all — it rendered a title and a subtitle and dropped the picture on the floor. Every
|
|
203
|
+
* "show me the releases" answer was therefore a column of text about records nobody could see.
|
|
204
|
+
*
|
|
205
|
+
* So when the bound dataset declares an image column, a list draws POSTERS: the art at full width of
|
|
206
|
+
* its card, the name under it, and `subtitle_field` as one quiet caption line. There is no card
|
|
207
|
+
* chrome — no border, no tile, no shadow. Framing a picture is what makes a page look like a
|
|
208
|
+
* dashboard; a magazine puts the picture on the paper. The only chrome is a hairline ring INSIDE the
|
|
209
|
+
* art's edge, so that a cover on a white ground does not dissolve into the page. That is a real
|
|
210
|
+
* problem sleeves have, not a decoration.
|
|
211
|
+
*
|
|
212
|
+
* ARRANGEMENT IS THE RENDERER'S (R5 deleted `layout.columns` for exactly this freedom). Four or more
|
|
213
|
+
* posters read as a rail you push through, the way a shelf does; three or fewer read as a row that
|
|
214
|
+
* happens to be short, and scrolling them would be a lie about how much there is. The threshold is a
|
|
215
|
+
* claim about content, and content is the only thing a renderer may make claims from.
|
|
216
|
+
*/
|
|
217
|
+
function PosterList({ block, uir, rows, columns, imageCol }) {
|
|
218
|
+
const runRow = useRowRunner(uir);
|
|
219
|
+
const clickable = Boolean(block.item_action);
|
|
220
|
+
const subCol = block.subtitle_field ? columnDecl(columns, block.subtitle_field) : null;
|
|
221
|
+
const thumbCol = thumbColumn(columns, imageCol);
|
|
222
|
+
const Item = clickable ? "button" : "div";
|
|
223
|
+
|
|
224
|
+
return (
|
|
225
|
+
<div className="uir-posters" data-mode={posterMode(rows.length)}>
|
|
226
|
+
{rows.map((row, i) => {
|
|
227
|
+
const src = row[imageCol.name];
|
|
228
|
+
return (
|
|
229
|
+
<Item
|
|
230
|
+
key={i}
|
|
231
|
+
type={clickable ? "button" : undefined}
|
|
232
|
+
className="uir-poster"
|
|
233
|
+
style={staggerVar(i)}
|
|
234
|
+
data-clickable={clickable || undefined}
|
|
235
|
+
onClick={clickable ? (e) => { e.currentTarget.focus(); runRow(block.item_action, row); } : undefined}
|
|
236
|
+
>
|
|
237
|
+
{/* A release with no art keeps its place in the shelf. An empty plate says "no cover yet";
|
|
238
|
+
a missing card would say "no release", which is a different and false thing. */}
|
|
239
|
+
<span className="uir-poster__art" data-empty={isImageSrc(src) ? undefined : "true"}>
|
|
240
|
+
{isImageSrc(src) && <BlurImage src={src} thumb={thumbCol ? row[thumbCol.name] : null} alt="" />}
|
|
241
|
+
</span>
|
|
242
|
+
<span className="uir-poster__title">{row[block.title_field]}</span>
|
|
243
|
+
{block.subtitle_field && (
|
|
244
|
+
<span className="uir-poster__caption">
|
|
245
|
+
<Value value={row[block.subtitle_field]} column={subCol} now={uir.now} />
|
|
246
|
+
</span>
|
|
247
|
+
)}
|
|
248
|
+
</Item>
|
|
249
|
+
);
|
|
250
|
+
})}
|
|
251
|
+
</div>
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
export function ListBlock({ block }) {
|
|
256
|
+
const { uir, rows, columns } = useRowsBinding(block, "items");
|
|
257
|
+
const runRow = useRowRunner(uir);
|
|
258
|
+
const clickable = Boolean(block.item_action);
|
|
259
|
+
const subCol = block.subtitle_field ? columnDecl(columns, block.subtitle_field) : null;
|
|
260
|
+
|
|
261
|
+
const imageCol = imageColumn(columns);
|
|
262
|
+
if (imageCol && rows.length) return <PosterList block={block} uir={uir} rows={rows} columns={columns} imageCol={imageCol} />;
|
|
263
|
+
|
|
264
|
+
return (
|
|
265
|
+
<div className="uir-list">
|
|
266
|
+
{rows.map((row, i) => {
|
|
267
|
+
const Item = clickable ? "button" : "div";
|
|
268
|
+
return (
|
|
269
|
+
<Item
|
|
270
|
+
key={i}
|
|
271
|
+
type={clickable ? "button" : undefined}
|
|
272
|
+
className="uir-list__item"
|
|
273
|
+
style={staggerVar(i)}
|
|
274
|
+
data-clickable={clickable || undefined}
|
|
275
|
+
// Same reason as the table row above. A `<button>` focuses on click in Chrome and Firefox
|
|
276
|
+
// but not in Safari, so this is not redundant.
|
|
277
|
+
onClick={clickable ? (e) => { e.currentTarget.focus(); runRow(block.item_action, row); } : undefined}
|
|
278
|
+
>
|
|
279
|
+
<InitialsChip value={row[block.title_field]} shape="square" />
|
|
280
|
+
<span className="uir-list__copy">
|
|
281
|
+
<span className="uir-list__title">{row[block.title_field]}</span>
|
|
282
|
+
{block.subtitle_field && (
|
|
283
|
+
<span className="uir-list__subtitle">
|
|
284
|
+
<Value value={row[block.subtitle_field]} column={subCol} now={uir.now} />
|
|
285
|
+
</span>
|
|
286
|
+
)}
|
|
287
|
+
</span>
|
|
288
|
+
{clickable && <Chevron />}
|
|
289
|
+
</Item>
|
|
290
|
+
);
|
|
291
|
+
})}
|
|
292
|
+
</div>
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// ───────────────────────────────────────────────────────────────────────────────
|
|
297
|
+
// detail — ONE record, described
|
|
298
|
+
// ───────────────────────────────────────────────────────────────────────────────
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* A record has a NAME, a STATE, and then some facts. A flat label/value list says all three in the
|
|
302
|
+
* same voice, which is how "what is the next release" came back looking like a database dump: the
|
|
303
|
+
* title of the thing set in the same 12px grey as the month it was created.
|
|
304
|
+
*
|
|
305
|
+
* Hierarchy here is not decoration — it is read out of `column.role`, which the dataset already
|
|
306
|
+
* declares, and which exists precisely so "a model can assemble a good page rather than merely a
|
|
307
|
+
* valid one: bind `name` to the row title, `status` to a badge" (data.schema.json#columnRole). This
|
|
308
|
+
* block was the one place reading that sentence and drawing a `<dt>` anyway.
|
|
309
|
+
*
|
|
310
|
+
* NO NEW SCHEMA. The decisions live in `core/record.js`, free of React, and are tested there.
|
|
311
|
+
*/
|
|
312
|
+
const roleOf = (col) => col?.role;
|
|
313
|
+
|
|
314
|
+
export function DetailBlock({ block, emphasis = "normal" }) {
|
|
315
|
+
const { uir, rows, columns } = useRowsBinding(block, "source");
|
|
316
|
+
const select = block.select ?? "only";
|
|
317
|
+
|
|
318
|
+
// `only` is an assertion, not a preference: ≠1 row fails loudly rather than describing the wrong
|
|
319
|
+
// contract (DECISIONS B6, BLK6). The renderer honours it exactly as a resolver would.
|
|
320
|
+
if (select === "only" && rows.length !== 1) {
|
|
321
|
+
return <BlockError message={`\`select: "only"\` requires exactly one row; the dataset resolved ${rows.length}.`} />;
|
|
322
|
+
}
|
|
323
|
+
const row = select === "last" ? rows[rows.length - 1] : rows[0];
|
|
324
|
+
if (!row) return <BlockError message="The dataset resolved no rows to describe." />;
|
|
325
|
+
|
|
326
|
+
const specs = block.fields ?? allColumns(columns);
|
|
327
|
+
const withCols = specs.map((spec) => ({ spec, col: columnDecl(columns, spec.field) }));
|
|
328
|
+
const { head, badges, facts } = splitRecordFields(withCols, row);
|
|
329
|
+
|
|
330
|
+
return (
|
|
331
|
+
<div className="uir-detail" data-emphasis={emphasis} data-headed={head ? "true" : undefined}>
|
|
332
|
+
{head && (
|
|
333
|
+
<div className="uir-detail__head">
|
|
334
|
+
<h3 className="uir-detail__name">
|
|
335
|
+
<Value value={row[head.spec.field]} format={head.spec.format} empty={head.spec.empty} column={head.col} now={uir.now} spec={head.spec} />
|
|
336
|
+
</h3>
|
|
337
|
+
{badges.length > 0 && (
|
|
338
|
+
<div className="uir-detail__badges">
|
|
339
|
+
{badges.map(({ spec, col }) => (
|
|
340
|
+
<span className="uir-badge" data-tone={statusTone(row[spec.field])} key={spec.field}>
|
|
341
|
+
<span className="uir-badge__label">{headingFor(spec, col)}</span>
|
|
342
|
+
{String(row[spec.field])}
|
|
343
|
+
</span>
|
|
344
|
+
))}
|
|
345
|
+
</div>
|
|
346
|
+
)}
|
|
347
|
+
</div>
|
|
348
|
+
)}
|
|
349
|
+
|
|
350
|
+
{facts.length > 0 && (
|
|
351
|
+
<dl className="uir-detail__facts">
|
|
352
|
+
{facts.map(({ spec, col }) => (
|
|
353
|
+
<div className="uir-detail__row" key={spec.field}>
|
|
354
|
+
<dt className="uir-eyebrow">{headingFor(spec, col)}</dt>
|
|
355
|
+
<dd className="uir-detail__value" data-numeric={numericish(spec.format, col) || undefined}>
|
|
356
|
+
{/* `role: url` is a place to GO, and a place to go is a link. The renderer checks the
|
|
357
|
+
scheme at the point of use for the same reason `media` does: a bound value never
|
|
358
|
+
passed the schema's literal check. Anything else is drawn as the value it is. */}
|
|
359
|
+
{roleOf(col) === "url" && isHttpUrl(row[spec.field]) ? (
|
|
360
|
+
<a className="uir-detail__link" href={row[spec.field]} target="_blank" rel="noreferrer noopener">
|
|
361
|
+
{linkText(row[spec.field])}
|
|
362
|
+
</a>
|
|
363
|
+
) : (
|
|
364
|
+
<Value value={row[spec.field]} format={spec.format} empty={spec.empty} column={col} now={uir.now} spec={spec} />
|
|
365
|
+
)}
|
|
366
|
+
</dd>
|
|
367
|
+
</div>
|
|
368
|
+
))}
|
|
369
|
+
</dl>
|
|
370
|
+
)}
|
|
371
|
+
</div>
|
|
372
|
+
);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// ───────────────────────────────────────────────────────────────────────────────
|
|
376
|
+
// timeline — rows against calendar time
|
|
377
|
+
// ───────────────────────────────────────────────────────────────────────────────
|
|
378
|
+
/**
|
|
379
|
+
* The one thing this block asserts is that the DISTANCE between two items reflects the distance
|
|
380
|
+
* between their dates. Spacing them evenly would be a lie about the only claim it makes, so the
|
|
381
|
+
* positions are computed from the dates and nothing else.
|
|
382
|
+
*/
|
|
383
|
+
export function TimelineBlock({ block }) {
|
|
384
|
+
const { uir, rows, columns } = useRowsBinding(block, "items");
|
|
385
|
+
const dateCol = columnDecl(columns, block.date_field);
|
|
386
|
+
|
|
387
|
+
const layout = useMemo(
|
|
388
|
+
() => timelineLayout(rows, block.date_field, block.end_date_field, uir.now),
|
|
389
|
+
[rows, block.date_field, block.end_date_field, uir.now],
|
|
390
|
+
);
|
|
391
|
+
const { items, ticks, today } = layout;
|
|
392
|
+
|
|
393
|
+
if (!items.length) return null;
|
|
394
|
+
|
|
395
|
+
return (
|
|
396
|
+
<div className="uir-timeline">
|
|
397
|
+
<div className="uir-timeline__track">
|
|
398
|
+
<div className="uir-timeline__axis" />
|
|
399
|
+
{today !== null && <><div className="uir-timeline__today-line" style={{ left: `${today}%` }} /><span className="uir-timeline__today" style={{ left: `${today}%` }}>today</span></>}
|
|
400
|
+
{items.map(({ row, left, width }, i) => (
|
|
401
|
+
// Captions rotate through FOUR lanes — two above the axis, two below. Alternating only two
|
|
402
|
+
// sides was not enough: eight releases across twelve weeks put four captions inside one
|
|
403
|
+
// hundred pixels and they printed over each other. The dots never move, because their
|
|
404
|
+
// positions are the only thing this block asserts; only the captions are given room.
|
|
405
|
+
<div className="uir-timeline__item" key={i} style={{ left: `${left}%` }}
|
|
406
|
+
data-side={i % 2 === 0 ? "above" : "below"} data-lane={i % 4}>
|
|
407
|
+
{width > 0 && <div className="uir-timeline__span" style={{ width: `${width}%` }} />}
|
|
408
|
+
<div className="uir-timeline__stem" />
|
|
409
|
+
<div className="uir-timeline__dot" />
|
|
410
|
+
<div className="uir-timeline__caption">
|
|
411
|
+
<div className="uir-timeline__label">{row[block.label_field]}</div>
|
|
412
|
+
<div className="uir-timeline__date">
|
|
413
|
+
{formatValue(row[block.date_field], { as: "date", date_style: "day" }, { column: dateCol, now: uir.now })}
|
|
414
|
+
</div>
|
|
415
|
+
</div>
|
|
416
|
+
</div>
|
|
417
|
+
))}
|
|
418
|
+
<div className="uir-timeline__ticks">
|
|
419
|
+
{ticks.map((tick, index) => <span key={index} style={{ left: `${tick.left}%` }}>{tick.label}</span>)}
|
|
420
|
+
</div>
|
|
421
|
+
</div>
|
|
422
|
+
</div>
|
|
423
|
+
);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// ───────────────────────────────────────────────────────────────────────────────
|
|
427
|
+
// calendar — rows placed on a date grid
|
|
428
|
+
// ───────────────────────────────────────────────────────────────────────────────
|
|
429
|
+
/**
|
|
430
|
+
* The claim this block makes, and the only one, is that a date belongs to a NAMED, BOUNDED PERIOD the
|
|
431
|
+
* reader already holds in their head. So the grid is drawn whole — every day of the period, including
|
|
432
|
+
* the empty ones and the leading/trailing days that make the weeks line up — because a month with its
|
|
433
|
+
* empty days removed is not a month, it is a list.
|
|
434
|
+
*
|
|
435
|
+
* Weeks begin on Monday. That is a locale decision this RENDERER makes and the document does not: it
|
|
436
|
+
* is exactly the kind of thing Principle 1 keeps out of the schema, and exactly the kind of thing a
|
|
437
|
+
* renderer must decide rather than leave to chance.
|
|
438
|
+
*
|
|
439
|
+
* Dates are read as CALENDAR days in the viewer's zone, never as instants. `Date.parse("2026-07-03")`
|
|
440
|
+
* is UTC midnight, which is the 2nd across the Americas — every item would land on the wrong day for
|
|
441
|
+
* half the world. So a bare `YYYY-MM-DD` is split, not parsed.
|
|
442
|
+
*/
|
|
443
|
+
const WEEKDAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
|
|
444
|
+
const dayKey = (y, m, d) => `${y}-${String(m + 1).padStart(2, "0")}-${String(d).padStart(2, "0")}`;
|
|
445
|
+
/** Monday = 0. JS says Sunday = 0, which puts the weekend in the middle of the week. */
|
|
446
|
+
const mondayIndex = (jsDay) => (jsDay + 6) % 7;
|
|
447
|
+
|
|
448
|
+
function calendarDay(value) {
|
|
449
|
+
if (typeof value === "string") {
|
|
450
|
+
const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(value);
|
|
451
|
+
if (m) return { y: +m[1], m: +m[2] - 1, d: +m[3] };
|
|
452
|
+
}
|
|
453
|
+
if (value === null || value === undefined) return null;
|
|
454
|
+
const parsed = value instanceof Date ? value : new Date(value);
|
|
455
|
+
if (Number.isNaN(parsed.getTime())) return null;
|
|
456
|
+
return { y: parsed.getFullYear(), m: parsed.getMonth(), d: parsed.getDate() };
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
export function CalendarBlock({ block }) {
|
|
460
|
+
const { rows } = useRowsBinding(block, "items");
|
|
461
|
+
const span = block.span === "week" ? "week" : "month";
|
|
462
|
+
|
|
463
|
+
const { cells, title, count } = useMemo(() => {
|
|
464
|
+
const placed = [];
|
|
465
|
+
for (const row of rows) {
|
|
466
|
+
const day = calendarDay(row[block.date_field]);
|
|
467
|
+
// A row with no date is not placed. Absence is not a date, and a grid has no cell for it.
|
|
468
|
+
if (day) placed.push({ day, label: row[block.label_field] });
|
|
469
|
+
}
|
|
470
|
+
if (!placed.length) return { cells: [], title: null, count: 0 };
|
|
471
|
+
|
|
472
|
+
const byDay = new Map();
|
|
473
|
+
for (const p of placed) {
|
|
474
|
+
const k = dayKey(p.day.y, p.day.m, p.day.d);
|
|
475
|
+
if (!byDay.has(k)) byDay.set(k, []);
|
|
476
|
+
byDay.get(k).push(p.label);
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// The grid shows the period ITS DATA is in. `span` names the period's UNIT; it does not select
|
|
480
|
+
// one. A page narrows to the month it means with a query filter, which is where narrowing lives.
|
|
481
|
+
const sorted = [...placed].sort((a, b) => new Date(a.day.y, a.day.m, a.day.d) - new Date(b.day.y, b.day.m, b.day.d));
|
|
482
|
+
const anchor = sorted[0].day;
|
|
483
|
+
|
|
484
|
+
let first;
|
|
485
|
+
let length;
|
|
486
|
+
if (span === "week") {
|
|
487
|
+
const at = new Date(anchor.y, anchor.m, anchor.d);
|
|
488
|
+
at.setDate(at.getDate() - mondayIndex(at.getDay()));
|
|
489
|
+
first = at;
|
|
490
|
+
length = 7;
|
|
491
|
+
} else {
|
|
492
|
+
const monthStart = new Date(anchor.y, anchor.m, 1);
|
|
493
|
+
const lead = mondayIndex(monthStart.getDay());
|
|
494
|
+
first = new Date(anchor.y, anchor.m, 1 - lead);
|
|
495
|
+
const daysInMonth = new Date(anchor.y, anchor.m + 1, 0).getDate();
|
|
496
|
+
length = Math.ceil((lead + daysInMonth) / 7) * 7;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
const out = [];
|
|
500
|
+
for (let i = 0; i < length; i++) {
|
|
501
|
+
const at = new Date(first.getFullYear(), first.getMonth(), first.getDate() + i);
|
|
502
|
+
const k = dayKey(at.getFullYear(), at.getMonth(), at.getDate());
|
|
503
|
+
out.push({
|
|
504
|
+
key: k,
|
|
505
|
+
date: at.getDate(),
|
|
506
|
+
inPeriod: span === "week" || at.getMonth() === anchor.m,
|
|
507
|
+
labels: byDay.get(k) ?? [],
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
const periodName = span === "week"
|
|
512
|
+
? `Week of ${first.toLocaleDateString(undefined, { month: "long", day: "numeric" })}`
|
|
513
|
+
: new Date(anchor.y, anchor.m, 1).toLocaleDateString(undefined, { month: "long", year: "numeric" });
|
|
514
|
+
|
|
515
|
+
return { cells: out, title: periodName, count: placed.length };
|
|
516
|
+
}, [rows, block.date_field, block.label_field, span]);
|
|
517
|
+
|
|
518
|
+
if (!cells.length) return null;
|
|
519
|
+
|
|
520
|
+
return (
|
|
521
|
+
<div className="uir-calendar" data-span={span}>
|
|
522
|
+
{/* The period, then its occupancy — which is what a voice renderer reads, and in that order.
|
|
523
|
+
A list of dates would be a `timeline` drawn as squares, wearing another block's claim. */}
|
|
524
|
+
<div className="uir-calendar__head">
|
|
525
|
+
<span className="uir-eyebrow">{title}</span>
|
|
526
|
+
<span className="uir-calendar__count">{count} {count === 1 ? "item" : "items"}</span>
|
|
527
|
+
</div>
|
|
528
|
+
<div className="uir-calendar__grid" role="grid" aria-label={`${title}: ${count} items`}>
|
|
529
|
+
{WEEKDAYS.map((d) => (
|
|
530
|
+
<div className="uir-calendar__weekday" key={d} role="columnheader">{d}</div>
|
|
531
|
+
))}
|
|
532
|
+
{cells.map((cell) => (
|
|
533
|
+
<div
|
|
534
|
+
className="uir-calendar__cell"
|
|
535
|
+
key={cell.key}
|
|
536
|
+
role="gridcell"
|
|
537
|
+
data-outside={!cell.inPeriod || undefined}
|
|
538
|
+
data-occupied={cell.labels.length > 0 || undefined}
|
|
539
|
+
>
|
|
540
|
+
<span className="uir-calendar__date">{cell.date}</span>
|
|
541
|
+
{cell.labels.map((label, i) => (
|
|
542
|
+
<span className="uir-calendar__item" key={i} title={String(label)}>{label}</span>
|
|
543
|
+
))}
|
|
544
|
+
</div>
|
|
545
|
+
))}
|
|
546
|
+
</div>
|
|
547
|
+
</div>
|
|
548
|
+
);
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
export { resolveBinding, resolveValue, boundColumn, UIResponseResolveError };
|