@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,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 };