@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,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Actions — the five verbs (actions.schema.json), and what this renderer does with each in v1.
|
|
3
|
+
*
|
|
4
|
+
* set_param ✅ executed. Writes a page parameter; datasets whose query reads it re-resolve.
|
|
5
|
+
* refresh ✅ executed. Re-resolves named datasets, or all of them.
|
|
6
|
+
* open ✅ executed. `url` opens (http/https only, mirroring A8). `record` calls the host's
|
|
7
|
+
* `onOpenRecord` — the renderer does not know where the source record lives.
|
|
8
|
+
* navigate ⛔ rendered DISABLED, wearing its reason. There is no page router in v1: the host
|
|
9
|
+
* owns page-to-page movement, and a navigate that silently did nothing would be
|
|
10
|
+
* worse than one that says it cannot.
|
|
11
|
+
* mutate ⛔ rendered DISABLED, wearing its reason. A write needs an authorizing resolver
|
|
12
|
+
* round-trip and an optimistic-update story; A5 puts authorization outside OBL, and
|
|
13
|
+
* a renderer that faked the write would be lying about the one thing that matters.
|
|
14
|
+
*
|
|
15
|
+
* `then` chains are SEQUENTIAL and ABORT ON FAILURE (A3). A chain whose first verb is disabled does
|
|
16
|
+
* not run its tail — "approve, then refresh" must not refresh without approving.
|
|
17
|
+
*/
|
|
18
|
+
import { isSelfRef } from "./value.js";
|
|
19
|
+
import { evaluateCondition } from "./condition.js";
|
|
20
|
+
|
|
21
|
+
export const DISABLED_VERBS = {
|
|
22
|
+
navigate: "Navigation between pages is the host application's job. This renderer draws one page.",
|
|
23
|
+
mutate: "Writes are disabled in v1. A mutation must be authorized by the resolver, and this renderer will not pretend a write succeeded.",
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const NO_OVERLAY_HOST = "This page asks to be presented in an overlay, but the host supplied no `resolvePage`, so there is no page to present.";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Why this renderer cannot execute this verb, or `null` if it can (DECISIONS A13).
|
|
30
|
+
*
|
|
31
|
+
* `navigate` is the one verb whose answer depends on the ACTION rather than only its type.
|
|
32
|
+
* `mode: "overlay"` says "present the destination without leaving here", and that is a thing this
|
|
33
|
+
* renderer CAN do — it draws a drawer over the page it is already drawing — provided the host handed
|
|
34
|
+
* it a way to obtain the destination document (`resolvePage`). Plain `push`/`replace` still belong to
|
|
35
|
+
* the host: this renderer draws one page and owns no router.
|
|
36
|
+
*
|
|
37
|
+
* Note what is NOT done here: an overlay-mode navigate is never silently downgraded to a push. §4's
|
|
38
|
+
* fallback (`overlay -> push`) is for a renderer that does not KNOW the member. This one knows it,
|
|
39
|
+
* and a renderer that knows a key and quietly does something else is the substitution §4 rule 3 is
|
|
40
|
+
* written against.
|
|
41
|
+
*/
|
|
42
|
+
export function verbReason(action, ctx) {
|
|
43
|
+
if (!action) return null;
|
|
44
|
+
if (action.type === "mutate") return DISABLED_VERBS.mutate;
|
|
45
|
+
if (action.type !== "navigate") return null;
|
|
46
|
+
if (action.mode === "overlay") return ctx?.openOverlay ? null : NO_OVERLAY_HOST;
|
|
47
|
+
return DISABLED_VERBS.navigate;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** @deprecated Kept for hosts that inspect verbs without a context. Prefer `actionGate`. */
|
|
51
|
+
export const isDisabledVerb = (action) => Boolean(action && DISABLED_VERBS[action.type]);
|
|
52
|
+
|
|
53
|
+
/** The user-visible name of an action. Its own `label` — one source of truth (button has no label). */
|
|
54
|
+
export const actionLabel = (action, fallback = "Run") => action?.label ?? fallback;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Is this action currently allowed to run, and if not, why (DECISIONS A12)?
|
|
58
|
+
*
|
|
59
|
+
* Two independent gates, and they are different kinds of thing:
|
|
60
|
+
* · `verbReason` — THIS RENDERER cannot execute the verb. A property of the implementation.
|
|
61
|
+
* · `enabled_when` — THE PAGE says the action does not apply right now. A property of the data.
|
|
62
|
+
*
|
|
63
|
+
* `enabled_when` is a UX AFFORDANCE, NEVER A SECURITY CONTROL. A 1.0 renderer does not know the key,
|
|
64
|
+
* ignores it, and draws an enabled button — a degradation that is a lie. The resolver owns
|
|
65
|
+
* authorization (A5) and is the only thing between a click and a write. We honour it here because a
|
|
66
|
+
* page that says "you cannot do this yet" should look like it means it, not because it protects
|
|
67
|
+
* anything.
|
|
68
|
+
*
|
|
69
|
+
* A condition that cannot be evaluated — an unresolved dataset — disables the action. An action that
|
|
70
|
+
* might not apply must not fire while we are still finding out whether it does.
|
|
71
|
+
*/
|
|
72
|
+
export function actionGate(action, ctx) {
|
|
73
|
+
if (!action) return { disabled: false, reason: null };
|
|
74
|
+
|
|
75
|
+
const unsupported = verbReason(action, ctx);
|
|
76
|
+
if (unsupported) return { disabled: true, reason: unsupported };
|
|
77
|
+
if (!action.enabled_when) return { disabled: false, reason: null };
|
|
78
|
+
|
|
79
|
+
let allowed;
|
|
80
|
+
try {
|
|
81
|
+
allowed = evaluateCondition(action.enabled_when, ctx?.scope);
|
|
82
|
+
} catch {
|
|
83
|
+
allowed = false;
|
|
84
|
+
}
|
|
85
|
+
if (allowed === true) return { disabled: false, reason: null };
|
|
86
|
+
return { disabled: true, reason: action.disabled_reason ?? "This action is not available right now." };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* @param {object} action
|
|
91
|
+
* @param {object} ctx
|
|
92
|
+
* @param {(name:string, value:*) => void} ctx.setParam
|
|
93
|
+
* @param {(names:string[]|"all") => void} ctx.refresh
|
|
94
|
+
* @param {(spec:object) => void} [ctx.onOpenRecord] host decides where a record lives
|
|
95
|
+
* @param {(msg:string) => void} [ctx.onNotice]
|
|
96
|
+
* @param {object} ctx.scope binding scope, including `row` inside a repeating element
|
|
97
|
+
*/
|
|
98
|
+
export async function runAction(action, ctx) {
|
|
99
|
+
if (!action) return;
|
|
100
|
+
|
|
101
|
+
// The gate is checked HERE and not only at the button, because a `then` chain reaches actions no
|
|
102
|
+
// button ever drew — "approve, then mark reviewed" must not mark reviewed when the second action's
|
|
103
|
+
// own `enabled_when` says it does not apply.
|
|
104
|
+
const gate = actionGate(action, ctx);
|
|
105
|
+
if (gate.disabled) {
|
|
106
|
+
ctx.onNotice?.(gate.reason);
|
|
107
|
+
return; // abort the chain: a `then` after a verb that did not happen is a lie.
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
switch (action.type) {
|
|
111
|
+
case "navigate": {
|
|
112
|
+
// Only `overlay` reaches here; `verbReason` disabled the other two. The destination's params
|
|
113
|
+
// are resolved in the CURRENT scope — a row action passing `{"$bind": {…, "scope": "row"}}`
|
|
114
|
+
// means the row the user clicked, and it means it here, before the overlay exists.
|
|
115
|
+
const params = {};
|
|
116
|
+
for (const [key, value] of Object.entries(action.params ?? {})) params[key] = ctx.resolveValue(value, ctx.scope);
|
|
117
|
+
await ctx.openOverlay({ pageId: action.page, params });
|
|
118
|
+
break;
|
|
119
|
+
}
|
|
120
|
+
case "set_param": {
|
|
121
|
+
const value = ctx.resolveValue(action.value, ctx.scope);
|
|
122
|
+
ctx.setParam(action.param, value);
|
|
123
|
+
break;
|
|
124
|
+
}
|
|
125
|
+
case "refresh":
|
|
126
|
+
ctx.refresh(action.datasets === "all" ? "all" : action.datasets);
|
|
127
|
+
break;
|
|
128
|
+
case "open": {
|
|
129
|
+
if (action.url) {
|
|
130
|
+
// A8's scheme restriction, enforced again at the point of use. A page may be assembled from
|
|
131
|
+
// untrusted text, and a validator that ran yesterday is not a guarantee about this click.
|
|
132
|
+
if (!/^https?:\/\//i.test(action.url)) {
|
|
133
|
+
ctx.onNotice?.(`Refused to open a non-http(s) URL.`);
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
window.open(action.url, action.target === "self" ? "_self" : "_blank", "noopener,noreferrer");
|
|
137
|
+
} else if (action.record) {
|
|
138
|
+
const id = ctx.resolveValue(action.record.id, ctx.scope);
|
|
139
|
+
if (!ctx.onOpenRecord) {
|
|
140
|
+
ctx.onNotice?.(`Opening ${action.record.dataset} record "${id}" needs a host. Pass \`onOpenRecord\` to <UIResponsePage>.`);
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
ctx.onOpenRecord({ dataset: action.record.dataset, id, target: action.target ?? "new_window" });
|
|
144
|
+
}
|
|
145
|
+
break;
|
|
146
|
+
}
|
|
147
|
+
default:
|
|
148
|
+
ctx.onNotice?.(`Unknown action verb "${action.type}".`);
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
for (const next of action.then ?? []) await runAction(next, ctx);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* An `input`'s `on_change` — the one action that can name the control's own value, `{"$self": true}`.
|
|
157
|
+
*
|
|
158
|
+
* This function used to be an apology. A `select` could write the chosen option's row, because the
|
|
159
|
+
* page could spell that (`{"$bind": {…, "scope": "row"}}`); a `text`, `date` or `toggle` input wrote
|
|
160
|
+
* something the document had no way to name, so the renderer supplied it and DISCARDED the action's
|
|
161
|
+
* declared `value`. A renderer silently overriding the document it renders.
|
|
162
|
+
*
|
|
163
|
+
* `$self` ends that. The renderer supplies the control's value as `scope.self` and then resolves what
|
|
164
|
+
* the page actually wrote. Nothing is discarded, and a page whose text input declares
|
|
165
|
+
* `value: "hardcoded"` now writes "hardcoded", loudly and correctly, because that is what it says.
|
|
166
|
+
*
|
|
167
|
+
* ONE contract survives, and it is written down rather than inferred: clearing a `select` whose
|
|
168
|
+
* action binds the chosen option's row (the older, still-legal spelling) has no row to bind. Clearing
|
|
169
|
+
* means `set_param` to `null` — which is how "no filter" is spelled and why an unset predicate drops
|
|
170
|
+
* out entirely (DECISIONS R4) — so we say that, rather than resolve a binding against nothing. A
|
|
171
|
+
* select written with `{"$self": true}` needs none of this: its `self` is `null` when cleared.
|
|
172
|
+
*/
|
|
173
|
+
export async function runInputAction(action, ctx, { row, widgetValue }) {
|
|
174
|
+
const scope = { ...ctx.scope, row, self: widgetValue };
|
|
175
|
+
|
|
176
|
+
if (row === null && action?.type === "set_param" && !isSelfRef(action.value)) {
|
|
177
|
+
ctx.setParam(action.param, null);
|
|
178
|
+
for (const next of action.then ?? []) await runAction(next, { ...ctx, scope });
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
await runWithConfirm(action, { ...ctx, scope });
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* A confirm gate. `mutate` with `operation: "delete"` cannot omit one; any verb may carry one.
|
|
187
|
+
*
|
|
188
|
+
* An action that cannot run is not confirmed first. Asking "Void this contract?" and then refusing
|
|
189
|
+
* to void it is a worse experience than the disabled button that should have been drawn instead.
|
|
190
|
+
*/
|
|
191
|
+
export async function runWithConfirm(action, ctx) {
|
|
192
|
+
if (action?.confirm && !actionGate(action, ctx).disabled) {
|
|
193
|
+
const { title, body, confirm_label } = action.confirm;
|
|
194
|
+
const ok = ctx.confirm
|
|
195
|
+
? await ctx.confirm(action.confirm)
|
|
196
|
+
// eslint-disable-next-line no-alert
|
|
197
|
+
: window.confirm([title, body].filter(Boolean).join("\n\n") + (confirm_label ? `\n\n[${confirm_label}]` : ""));
|
|
198
|
+
if (!ok) return;
|
|
199
|
+
}
|
|
200
|
+
await runAction(action, ctx);
|
|
201
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `visible_when` — the condition grammar of `binding.schema.json#condition`, evaluated client-side
|
|
3
|
+
* against resolved data.
|
|
4
|
+
*
|
|
5
|
+
* This is how OBL has empty states, "only show the overdue banner if something is overdue", and
|
|
6
|
+
* permission-shaped UI without any of them becoming a block type (DECISIONS P5). It is also why a
|
|
7
|
+
* review agent cannot statically prove what a user saw — a cost the spec accepted knowingly.
|
|
8
|
+
*
|
|
9
|
+
* A block whose condition cannot yet be evaluated is `pending`, and a `pending` conditional block
|
|
10
|
+
* draws NOTHING — not even a skeleton.
|
|
11
|
+
*
|
|
12
|
+
* This is the opposite of the rule for unconditional blocks, and the asymmetry is the point. A
|
|
13
|
+
* skeleton is a promise that content is coming. For a `table` bound to a dataset, that promise is
|
|
14
|
+
* true. For a `text` block gated on "show this only if the queue is empty", it is a coin flip — and
|
|
15
|
+
* a skeleton that resolves to nothing is a flash of a thing that was never going to be there. The
|
|
16
|
+
* page still says it is loading, in the masthead, where it is saying it once rather than per-block.
|
|
17
|
+
*/
|
|
18
|
+
import { resolveValue, isPresent, UIResponseResolveError } from "./value.js";
|
|
19
|
+
|
|
20
|
+
const asArray = (v) => (Array.isArray(v) ? v : [v]);
|
|
21
|
+
|
|
22
|
+
/** JSON has no total order across types; compare numbers as numbers, dates as time, else strings. */
|
|
23
|
+
function compare(a, b) {
|
|
24
|
+
if (typeof a === "number" && typeof b === "number") return a - b;
|
|
25
|
+
const da = Date.parse(a), db = Date.parse(b);
|
|
26
|
+
if (!Number.isNaN(da) && !Number.isNaN(db)) return da - db;
|
|
27
|
+
const na = Number(a), nb = Number(b);
|
|
28
|
+
if (Number.isFinite(na) && Number.isFinite(nb)) return na - nb;
|
|
29
|
+
return String(a) < String(b) ? -1 : String(a) > String(b) ? 1 : 0;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const eq = (a, b) => (a === b ? true : compare(a, b) === 0 && typeof a === typeof b);
|
|
33
|
+
|
|
34
|
+
function comparison(cond, scope) {
|
|
35
|
+
const left = resolveValue(cond.left, scope);
|
|
36
|
+
|
|
37
|
+
switch (cond.op) {
|
|
38
|
+
// Unary operators take no `right` — the schema enforces that, so we never read one.
|
|
39
|
+
case "is_empty": return !isPresent(left) || left === "" || (Array.isArray(left) && left.length === 0);
|
|
40
|
+
case "is_not_empty": return isPresent(left) && left !== "" && !(Array.isArray(left) && left.length === 0);
|
|
41
|
+
case "is_true": return left === true;
|
|
42
|
+
case "is_false": return left === false;
|
|
43
|
+
default: break;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const right = resolveValue(cond.right, scope);
|
|
47
|
+
|
|
48
|
+
switch (cond.op) {
|
|
49
|
+
case "eq": return eq(left, right);
|
|
50
|
+
case "neq": return !eq(left, right);
|
|
51
|
+
// A comparison against absent data is FALSE, never a coerced 0. `null > 0` must not be true.
|
|
52
|
+
case "gt": return isPresent(left) && isPresent(right) && compare(left, right) > 0;
|
|
53
|
+
case "gte": return isPresent(left) && isPresent(right) && compare(left, right) >= 0;
|
|
54
|
+
case "lt": return isPresent(left) && isPresent(right) && compare(left, right) < 0;
|
|
55
|
+
case "lte": return isPresent(left) && isPresent(right) && compare(left, right) <= 0;
|
|
56
|
+
case "in": return asArray(right).some((r) => eq(left, r));
|
|
57
|
+
case "not_in": return !asArray(right).some((r) => eq(left, r));
|
|
58
|
+
case "contains":
|
|
59
|
+
if (Array.isArray(left)) return left.some((l) => eq(l, right));
|
|
60
|
+
return isPresent(left) && String(left).toLowerCase().includes(String(right).toLowerCase());
|
|
61
|
+
default: throw new UIResponseResolveError(`unknown condition operator "${cond.op}"`);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** @returns {boolean} */
|
|
66
|
+
export function evaluateCondition(cond, scope) {
|
|
67
|
+
if (!cond || typeof cond !== "object") return true;
|
|
68
|
+
if (Array.isArray(cond.all)) return cond.all.every((c) => evaluateCondition(c, scope));
|
|
69
|
+
if (Array.isArray(cond.any)) return cond.any.some((c) => evaluateCondition(c, scope));
|
|
70
|
+
if (cond.not) return !evaluateCondition(cond.not, scope);
|
|
71
|
+
return comparison(cond, scope);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Evaluate a block's `visible_when` in the three states it can be in.
|
|
76
|
+
*
|
|
77
|
+
* @returns {"visible" | "hidden" | "pending"} `pending` means "its data has not arrived": show the
|
|
78
|
+
* block's skeleton, because an absent block and an unloaded one are different claims.
|
|
79
|
+
*/
|
|
80
|
+
export function visibility(block, scope, datasetsReady) {
|
|
81
|
+
if (!block.visible_when) return "visible";
|
|
82
|
+
if (!datasetsReady) return "pending";
|
|
83
|
+
try {
|
|
84
|
+
return evaluateCondition(block.visible_when, scope) ? "visible" : "hidden";
|
|
85
|
+
} catch {
|
|
86
|
+
// A condition that throws (an `only` assertion, a dataset that failed) must not silently hide
|
|
87
|
+
// content. Show the block; its own error state will say what went wrong.
|
|
88
|
+
return "visible";
|
|
89
|
+
}
|
|
90
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `empty` — `binding.schema.json#emptyPolicy`.
|
|
3
|
+
*
|
|
4
|
+
* The most opinionated rule in OBL, and the one this renderer is built to keep:
|
|
5
|
+
*
|
|
6
|
+
* "Rendering an unfiled royalty statement as '£0.00' is a lie the page tells confidently,
|
|
7
|
+
* so `zero` must be typed by a human or chosen deliberately by a model."
|
|
8
|
+
*
|
|
9
|
+
* When `empty` is omitted, the default is `dash` — VISIBLE ABSENCE. Not a blank cell, not zero.
|
|
10
|
+
* A missing number must look missing, and it must look different from a number that happens to be
|
|
11
|
+
* small. That is why absence gets its own typographic treatment in this renderer rather than being
|
|
12
|
+
* the absence of treatment.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** The five dispositions, resolved from a policy that may be absent entirely. */
|
|
16
|
+
export function emptyDisposition(policy) {
|
|
17
|
+
const show = policy?.show ?? "dash";
|
|
18
|
+
return { show, text: policy?.text };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Does this value need the empty policy at all?
|
|
23
|
+
*
|
|
24
|
+
* `0`, `""` and `false` are PRESENT. Only null/undefined are absent, plus NaN, which is what a
|
|
25
|
+
* failed numeric coercion leaves behind and must never render as "NaN".
|
|
26
|
+
*/
|
|
27
|
+
export function isEmptyValue(value) {
|
|
28
|
+
if (value === null || value === undefined) return true;
|
|
29
|
+
if (typeof value === "number" && Number.isNaN(value)) return true;
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Does the whole BLOCK disappear? Only `hide` does that. */
|
|
34
|
+
export const hidesBlock = (policy) => emptyDisposition(policy).show === "hide";
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* What a renderer draws for an absent value.
|
|
38
|
+
* @returns {{kind: "dash"|"zero"|"blank"|"hide"|"text", text?: string}}
|
|
39
|
+
*/
|
|
40
|
+
export function emptyRender(policy) {
|
|
41
|
+
const { show, text } = emptyDisposition(policy);
|
|
42
|
+
if (show === "text") return { kind: "text", text: text ?? "" };
|
|
43
|
+
return { kind: show };
|
|
44
|
+
}
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OBL `format` — the exact semantics of `binding.schema.json#format`, and nothing more.
|
|
3
|
+
*
|
|
4
|
+
* This file is the reason the first-party renderer exists (DECISIONS X4). Compiling OBL to OpenUI
|
|
5
|
+
* Lang dropped `format` entirely, so `1240000` drew as "1240000" where the page said
|
|
6
|
+
* `{as: "currency", currency: "GBP", compact: true}` and meant "£1.2M".
|
|
7
|
+
*
|
|
8
|
+
* Every key here passes the voice-renderer test (Principle 1): `compact: true` is "one point two
|
|
9
|
+
* million", which a speaker can say. There is no pattern string, no locale, no width. We format for
|
|
10
|
+
* the screen; a voice renderer formats the same object into speech from the same five keys.
|
|
11
|
+
*
|
|
12
|
+
* as number | currency | percent | date | relative_time | duration | bytes | text
|
|
13
|
+
* currency ISO 4217. May never contradict the bound column's own code (Phase 2 errors on that).
|
|
14
|
+
* precision fractional digits. Omitted lets us choose from magnitude.
|
|
15
|
+
* compact 1_200_000 -> "1.2M"
|
|
16
|
+
* sign auto | always | never
|
|
17
|
+
* date_style day | month | quarter | year | full | iso
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const COMPACT_STEPS = [
|
|
21
|
+
{ at: 1e12, suffix: "T" },
|
|
22
|
+
{ at: 1e9, suffix: "B" },
|
|
23
|
+
{ at: 1e6, suffix: "M" },
|
|
24
|
+
{ at: 1e3, suffix: "K" },
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
/** A number's "natural" precision when the page did not state one. */
|
|
28
|
+
function inferPrecision(n, { compact, as }) {
|
|
29
|
+
const abs = Math.abs(n);
|
|
30
|
+
if (compact) return abs >= 1e3 && abs < 1e6 ? 0 : 1;
|
|
31
|
+
if (as === "currency") return abs >= 1000 ? 0 : 2;
|
|
32
|
+
if (as === "percent") return abs < 10 ? 1 : 0;
|
|
33
|
+
if (Number.isInteger(n)) return 0;
|
|
34
|
+
return abs < 1 ? 2 : abs < 100 ? 1 : 0;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function applySign(text, n, sign) {
|
|
38
|
+
if (sign === "never") return text.replace(/^-/, "");
|
|
39
|
+
if (sign === "always" && n > 0) return `+${text}`;
|
|
40
|
+
return text;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Group digits without a locale: OBL forbids locale in `format` (it is a rendering-host concern). */
|
|
44
|
+
function group(intPart) {
|
|
45
|
+
return intPart.replace(/\B(?=(\d{3})+(?!\d))/g, ",");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function fixed(n, precision) {
|
|
49
|
+
const s = Math.abs(n).toFixed(precision);
|
|
50
|
+
const [i, f] = s.split(".");
|
|
51
|
+
const body = f ? `${group(i)}.${f}` : group(i);
|
|
52
|
+
return n < 0 ? `-${body}` : body;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function compactNumber(n, precision) {
|
|
56
|
+
const abs = Math.abs(n);
|
|
57
|
+
for (const { at, suffix } of COMPACT_STEPS) {
|
|
58
|
+
if (abs >= at) {
|
|
59
|
+
const scaled = n / at;
|
|
60
|
+
// 1.0M reads worse than 1M; drop a trailing zero the page did not ask for.
|
|
61
|
+
const p = precision ?? (Math.abs(scaled) >= 100 ? 0 : 1);
|
|
62
|
+
const text = scaled.toFixed(p).replace(/\.0$/, "");
|
|
63
|
+
return `${text}${suffix}`;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return fixed(n, precision ?? 0);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const CURRENCY_SYMBOL = { GBP: "£", USD: "$", EUR: "€", JPY: "¥", AUD: "A$", CAD: "C$", CHF: "CHF ", SEK: "kr " };
|
|
70
|
+
const currencyPrefix = (code) => CURRENCY_SYMBOL[code] ?? `${code} `;
|
|
71
|
+
|
|
72
|
+
const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
|
73
|
+
|
|
74
|
+
function toDate(value) {
|
|
75
|
+
if (value instanceof Date) return value;
|
|
76
|
+
if (typeof value === "number") return new Date(value);
|
|
77
|
+
if (typeof value !== "string") return null;
|
|
78
|
+
// A bare `YYYY-MM-DD` is a calendar day, not an instant. Parsing it as UTC and rendering it in
|
|
79
|
+
// the viewer's zone moves a London contract to the previous day in New York. Pin it to noon.
|
|
80
|
+
const bare = /^\d{4}-\d{2}-\d{2}$/.test(value);
|
|
81
|
+
const d = new Date(bare ? `${value}T12:00:00` : value);
|
|
82
|
+
return Number.isNaN(d.getTime()) ? null : d;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function formatDate(value, style) {
|
|
86
|
+
const d = toDate(value);
|
|
87
|
+
if (!d) return String(value);
|
|
88
|
+
const day = d.getDate();
|
|
89
|
+
const mon = MONTHS[d.getMonth()];
|
|
90
|
+
const year = d.getFullYear();
|
|
91
|
+
switch (style) {
|
|
92
|
+
case "iso": return d.toISOString().slice(0, 10);
|
|
93
|
+
case "year": return String(year);
|
|
94
|
+
case "quarter": return `Q${Math.floor(d.getMonth() / 3) + 1} ${year}`;
|
|
95
|
+
case "month": return `${mon} ${year}`;
|
|
96
|
+
case "full": return `${day} ${mon} ${year}`;
|
|
97
|
+
case "day":
|
|
98
|
+
default: return `${day} ${mon}`;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const MINUTE = 60, HOUR = 3600, DAY = 86400;
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* "42 days ago", "in 3 weeks". Relative time is how OBL renders a date the page cares about the
|
|
106
|
+
* AGE of — and B7's whole point is that it cannot be sorted or filtered by, only stated.
|
|
107
|
+
*/
|
|
108
|
+
function formatRelative(value, now) {
|
|
109
|
+
const d = toDate(value);
|
|
110
|
+
if (!d) return String(value);
|
|
111
|
+
const seconds = Math.round((d.getTime() - now.getTime()) / 1000);
|
|
112
|
+
const past = seconds < 0;
|
|
113
|
+
const abs = Math.abs(seconds);
|
|
114
|
+
const say = (n, unit) => `${n} ${unit}${n === 1 ? "" : "s"}`;
|
|
115
|
+
|
|
116
|
+
let phrase;
|
|
117
|
+
if (abs < 45) return "just now";
|
|
118
|
+
else if (abs < 45 * MINUTE) phrase = say(Math.round(abs / MINUTE), "minute");
|
|
119
|
+
else if (abs < 22 * HOUR) phrase = say(Math.round(abs / HOUR), "hour");
|
|
120
|
+
else if (abs < 26 * DAY) phrase = say(Math.round(abs / DAY), "day");
|
|
121
|
+
else if (abs < 320 * DAY) phrase = say(Math.round(abs / (30 * DAY)), "month");
|
|
122
|
+
else phrase = say(Math.round(abs / (365 * DAY)), "year");
|
|
123
|
+
|
|
124
|
+
return past ? `${phrase} ago` : `in ${phrase}`;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** A duration in SECONDS, stated the way a person says it. */
|
|
128
|
+
function formatDuration(seconds, precision) {
|
|
129
|
+
const n = Number(seconds);
|
|
130
|
+
if (!Number.isFinite(n)) return String(seconds);
|
|
131
|
+
const abs = Math.abs(n);
|
|
132
|
+
if (abs < MINUTE) return `${fixed(n, precision ?? 0)}s`;
|
|
133
|
+
if (abs < HOUR) {
|
|
134
|
+
const m = Math.floor(abs / MINUTE);
|
|
135
|
+
const s = Math.round(abs % MINUTE);
|
|
136
|
+
return s ? `${m}m ${s}s` : `${m}m`;
|
|
137
|
+
}
|
|
138
|
+
if (abs < DAY) {
|
|
139
|
+
const h = Math.floor(abs / HOUR);
|
|
140
|
+
const m = Math.round((abs % HOUR) / MINUTE);
|
|
141
|
+
return m ? `${h}h ${m}m` : `${h}h`;
|
|
142
|
+
}
|
|
143
|
+
const d = Math.floor(abs / DAY);
|
|
144
|
+
const h = Math.round((abs % DAY) / HOUR);
|
|
145
|
+
return h ? `${d}d ${h}h` : `${d}d`;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const BYTE_STEPS = ["B", "KB", "MB", "GB", "TB", "PB"];
|
|
149
|
+
function formatBytes(n, precision) {
|
|
150
|
+
let v = Number(n);
|
|
151
|
+
if (!Number.isFinite(v)) return String(n);
|
|
152
|
+
let i = 0;
|
|
153
|
+
while (Math.abs(v) >= 1024 && i < BYTE_STEPS.length - 1) { v /= 1024; i++; }
|
|
154
|
+
return `${fixed(v, precision ?? (i === 0 ? 0 : 1))} ${BYTE_STEPS[i]}`;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Render one resolved value under one `format` object.
|
|
159
|
+
*
|
|
160
|
+
* @param {*} value the resolved value (already collapsed to a scalar by the binding layer)
|
|
161
|
+
* @param {object} [format] `binding.schema.json#format`
|
|
162
|
+
* @param {object} [ctx]
|
|
163
|
+
* @param {object} [ctx.column] the bound column declaration, so a `currency` column formats as
|
|
164
|
+
* money even when the block stated no `format` at all
|
|
165
|
+
* @param {Date} [ctx.now] the instant `relative_time` measures against
|
|
166
|
+
* @returns {string}
|
|
167
|
+
*/
|
|
168
|
+
export function formatValue(value, format, ctx = {}) {
|
|
169
|
+
const now = ctx.now ?? new Date();
|
|
170
|
+
const column = ctx.column;
|
|
171
|
+
|
|
172
|
+
if (value === null || value === undefined) return "";
|
|
173
|
+
|
|
174
|
+
// No `format`? The COLUMN still knows what it is. A currency column drawn with no format is
|
|
175
|
+
// money, and drawing it as a bare number is the lie of omission Phase 2 warns about.
|
|
176
|
+
const as = format?.as ?? (column?.type === "currency" ? "currency" : column?.type === "date" ? "date" : undefined);
|
|
177
|
+
const code = format?.currency ?? column?.currency;
|
|
178
|
+
const precision = format?.precision;
|
|
179
|
+
const compact = format?.compact ?? false;
|
|
180
|
+
const sign = format?.sign ?? "auto";
|
|
181
|
+
|
|
182
|
+
switch (as) {
|
|
183
|
+
case "currency": {
|
|
184
|
+
const n = Number(value);
|
|
185
|
+
if (!Number.isFinite(n)) return String(value);
|
|
186
|
+
const p = precision ?? inferPrecision(n, { compact, as });
|
|
187
|
+
const body = compact ? compactNumber(n, precision) : fixed(n, p);
|
|
188
|
+
const negative = body.startsWith("-");
|
|
189
|
+
const magnitude = negative ? body.slice(1) : body;
|
|
190
|
+
const text = `${negative ? "-" : ""}${currencyPrefix(code ?? "")}${magnitude}`;
|
|
191
|
+
return applySign(text, n, sign);
|
|
192
|
+
}
|
|
193
|
+
case "percent": {
|
|
194
|
+
const n = Number(value);
|
|
195
|
+
if (!Number.isFinite(n)) return String(value);
|
|
196
|
+
const p = precision ?? inferPrecision(n, { compact, as });
|
|
197
|
+
return applySign(`${fixed(n, p)}%`, n, sign);
|
|
198
|
+
}
|
|
199
|
+
case "number": {
|
|
200
|
+
const n = Number(value);
|
|
201
|
+
if (!Number.isFinite(n)) return String(value);
|
|
202
|
+
const p = precision ?? inferPrecision(n, { compact, as });
|
|
203
|
+
return applySign(compact ? compactNumber(n, precision) : fixed(n, p), n, sign);
|
|
204
|
+
}
|
|
205
|
+
case "date": return formatDate(value, format?.date_style ?? (column?.precision === "time" ? "full" : "day"));
|
|
206
|
+
case "relative_time": return formatRelative(value, now);
|
|
207
|
+
case "duration": return formatDuration(value, precision);
|
|
208
|
+
case "bytes": return formatBytes(value, precision);
|
|
209
|
+
case "text": return String(value);
|
|
210
|
+
default:
|
|
211
|
+
if (typeof value === "boolean") return value ? "Yes" : "No";
|
|
212
|
+
if (typeof value === "number") return applySign(compact ? compactNumber(value, precision) : fixed(value, precision ?? inferPrecision(value, { compact })), value, sign);
|
|
213
|
+
return String(value);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Numeric formats want tabular figures and right alignment; prose does not. A renderer decides
|
|
219
|
+
* this, not the page — it is exactly the kind of visual consequence `format.as` is allowed to have.
|
|
220
|
+
*/
|
|
221
|
+
export function isNumericFormat(format, column) {
|
|
222
|
+
const as = format?.as ?? (column?.type === "currency" ? "currency" : undefined);
|
|
223
|
+
return as === "number" || as === "currency" || as === "percent" || as === "bytes" || as === "duration"
|
|
224
|
+
|| (!as && (column?.type === "number" || column?.type === "currency"));
|
|
225
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/** Pure presentation decisions extracted from the approved element-system sheet. */
|
|
2
|
+
|
|
3
|
+
export const TABLE_PAGE_SIZE = 5;
|
|
4
|
+
|
|
5
|
+
export function initialsFor(value) {
|
|
6
|
+
const words = String(value ?? "").trim().split(/\s+/).filter(Boolean);
|
|
7
|
+
if (!words.length) return "—";
|
|
8
|
+
return `${words[0][0]}${words.length > 1 ? words[words.length - 1][0] : words[0][1] ?? ""}`.toUpperCase();
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function paginateRows(rows, page, pageSize = TABLE_PAGE_SIZE) {
|
|
12
|
+
const count = rows?.length ?? 0;
|
|
13
|
+
const pageCount = Math.max(1, Math.ceil(count / pageSize));
|
|
14
|
+
const current = Math.max(0, Math.min(Number.isFinite(page) ? page : 0, pageCount - 1));
|
|
15
|
+
const start = current * pageSize;
|
|
16
|
+
const items = (rows ?? []).slice(start, start + pageSize);
|
|
17
|
+
return { items, page: current, pageCount, start: count ? start + 1 : 0, end: Math.min(start + pageSize, count), count };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const monthLabel = (time) => new Intl.DateTimeFormat("en", { month: "short" }).format(new Date(time));
|
|
21
|
+
|
|
22
|
+
export function timelineLayout(rows, dateField, endDateField, now) {
|
|
23
|
+
const parsed = (rows ?? [])
|
|
24
|
+
.map((row) => ({ row, t: Date.parse(row[dateField]), end: endDateField ? Date.parse(row[endDateField]) : NaN }))
|
|
25
|
+
.filter((item) => Number.isFinite(item.t))
|
|
26
|
+
.sort((a, b) => a.t - b.t);
|
|
27
|
+
if (!parsed.length) return { items: [], ticks: [], today: null };
|
|
28
|
+
|
|
29
|
+
const min = parsed[0].t;
|
|
30
|
+
const max = Math.max(...parsed.map((item) => (Number.isFinite(item.end) ? item.end : item.t)));
|
|
31
|
+
const span = max - min || 1;
|
|
32
|
+
const pct = (time) => 6 + ((time - min) / span) * 88;
|
|
33
|
+
const ticks = [0, 1 / 3, 2 / 3, 1].map((part) => {
|
|
34
|
+
const time = min + span * part;
|
|
35
|
+
return { label: monthLabel(time), left: pct(time) };
|
|
36
|
+
});
|
|
37
|
+
const nowTime = new Date(now).getTime();
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
items: parsed.map((item) => ({
|
|
41
|
+
...item,
|
|
42
|
+
left: pct(item.t),
|
|
43
|
+
width: Number.isFinite(item.end) ? pct(item.end) - pct(item.t) : 0,
|
|
44
|
+
})),
|
|
45
|
+
ticks,
|
|
46
|
+
today: Number.isFinite(nowTime) && nowTime >= min && nowTime <= max ? pct(nowTime) : null,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function formatAudioTime(seconds) {
|
|
51
|
+
if (!Number.isFinite(seconds) || seconds < 0) return "0:00";
|
|
52
|
+
const whole = Math.floor(seconds);
|
|
53
|
+
return `${Math.floor(whole / 60)}:${String(whole % 60).padStart(2, "0")}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export const isDestructiveAction = (action) => Boolean(
|
|
57
|
+
action?.confirm?.destructive || (action?.type === "mutate" && action.operation === "delete"),
|
|
58
|
+
);
|