@xoong/data 0.0.0-stage → 0.0.35

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/src/view.js ADDED
@@ -0,0 +1,847 @@
1
+ // @ts-check
2
+ /**
3
+ * <xo-view> — declare a screen in HTML (Draft syntax, see docs/data-layer.md).
4
+ *
5
+ * <xo-view get="/users/{id}" p-id="42" live>
6
+ * <p xo-if="$loading">Loading…</p>
7
+ * <p xo-if="$error">{$error.detail}</p>
8
+ * <h2>{name}</h2>
9
+ * <ul><li xo-each="tags">{.}</li></ul>
10
+ * <button xo-click="POST /users/{id}/disable">Disable</button>
11
+ * </xo-view>
12
+ *
13
+ * Syntax (the whole language):
14
+ * {path} text or attribute interpolation. `.` = current item,
15
+ * `$loading`, `$error`, `$index` are view variables.
16
+ * xo-each="path" repeat the element per item (keyed by entity id).
17
+ * xo-if="path" render only when truthy ("!path" negates).
18
+ * xo-click="OP" call an operation on click. OP is "METHOD /path/{param}"
19
+ * or an operationId. Params come from the current scope.
20
+ * xo-submit="OP" same, on form submit; body = the form fields.
21
+ * xo-body="path" (xo-click) take the request body from the scope.
22
+ * xo-optimistic="f=v,g=w" (on xo-click) set these fields on the current item right away; undone if the call fails.
23
+ * xo-keep (xo-submit) keep the field values after a successful send (default: the form is reset).
24
+ * xo-param="name" on an <input>/<select>: set the view param `name` to its value as it changes (empty removes it).
25
+ * xo-param="k=v" on click, set a view param and refetch: `k=v`, `k+=n`, `k-=n` (never below 0),
26
+ * `k~=v` (toggle between `v` and `-v`, for sorting). `{path}` in v is resolved.
27
+ * Variables: `$loading` (a request runs and there is nothing to show yet), `$refreshing` (a request runs and the previous result is still on screen,
28
+ * for example while a sort or a page loads: the view does not collapse), `$error`.
29
+ * xo-refresh on click, fetch this view's data again ("Try again" after an error, a refresh button).
30
+ * Events (bubbling): `xo-done` {detail: result}, `xo-error` {detail: XoongError}.
31
+ */
32
+ import { effect, signal, batch } from '@xoong/core/signals';
33
+ import { XoElement, define } from '@xoong/core';
34
+ import { isRef } from './store.js';
35
+ import { getClient, acquireStream } from './default.js';
36
+ import { XoongError } from './client.js';
37
+ import { idOf } from './store.js';
38
+ import { format } from './format.js';
39
+ import { urlSync } from './url-state.js';
40
+ import './options.js'; // options-op on a combobox or a tag input
41
+
42
+ /** Where `p-id=":id"` finds route parameters (set by the router in @xoong/ui). */
43
+ let paramSource = /** @type {((name: string) => string | undefined) | null} */ (null);
44
+ /** Views that use `p-x=":param"` and started before the router did (element definition order): they start when it is ready. */
45
+ const waitingForRouter = /** @type {(() => void)[]} */ ([]);
46
+ /** @param {(name: string) => string | undefined} fn */
47
+ export function setParamSource(fn) {
48
+ paramSource = fn;
49
+ for (const start of waitingForRouter.splice(0)) start();
50
+ }
51
+
52
+ /** The forms package registers its binder here: any `xo-submit` form whose operation has a request schema then uses the form model. */
53
+ let formEngine = /** @type {{ attach: Function } | null} */ (null);
54
+ /** @param {{ attach: Function }} engine */
55
+ export function setFormEngine(engine) { formEngine = engine; }
56
+
57
+ /** Components that expand into plain markup before binding (used by @xoong/ui). tag -> (el, { host, client }) => Node */
58
+ const templates = new Map();
59
+ /** @param {string} tag @param {(el: Element, env: { host: any, client: any }) => Node} expand */
60
+ export function defineTemplate(tag, expand) { templates.set(tag, expand); }
61
+
62
+ const camel = (s) => s.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
63
+
64
+ /** @typedef {{ value: () => any, parent?: Scope, vars?: Record<string, () => any> }} Scope */
65
+
66
+ /** "a.b", "a[0].b" and "$form.errors['title.vi']" as steps. */
67
+ function splitPath(path) {
68
+ const steps = [];
69
+ for (const m of path.matchAll(/\[(?:'([^']*)'|"([^"]*)"|(\d+))\]|([^.[\]]+)/g)) steps.push(m[1] ?? m[2] ?? m[3] ?? m[4]);
70
+ return steps;
71
+ }
72
+
73
+ function makeResolver(store) {
74
+ const lookup = (scope, path) => {
75
+ if (path === '.' || path === '') return store.deref(scope.value());
76
+ const [head, ...rest] = splitPath(path);
77
+ if (head.startsWith('$')) {
78
+ for (let s = scope; s; s = s.parent) if (s.vars?.[head]) return walk(s.vars[head](), rest);
79
+ return undefined;
80
+ }
81
+ for (let s = scope; s; s = s.parent) {
82
+ const base = store.deref(s.value());
83
+ if (base && typeof base === 'object' && head in base) return walk(base[head], rest);
84
+ }
85
+ return undefined;
86
+ };
87
+ const walk = (v, rest) => {
88
+ for (const k of rest) {
89
+ v = store.deref(v);
90
+ if (v == null) return undefined;
91
+ v = v[k];
92
+ }
93
+ return store.deref(v);
94
+ };
95
+ return lookup;
96
+ }
97
+
98
+ class XoView extends XoElement {
99
+ static props = { get: String };
100
+
101
+ /** @type {Node[] | null} */ #tpl = null;
102
+ /** @type {(() => void)[]} */ #bound = [];
103
+ #params = signal(/** @type {Record<string, any>} */ ({}));
104
+ #release = null;
105
+ /** Fetch the selected query again (xo-refresh). */ refresh = () => {};
106
+ /** @type {AbortController | null} */ #streamCtl = null;
107
+ #stream = signal(/** @type {any[]} */ ([]));
108
+ #streaming = signal(false);
109
+ /** Keys ("User#1") of the rows ticked by xo-select checkboxes. */
110
+ #selected = signal(/** @type {Set<string>} */ (new Set()));
111
+
112
+ /** Refs of the selected rows that still exist. */
113
+ selectedRefs() {
114
+ const store = getClient().store;
115
+ return [...this.#selected.value].map((k) => ({ $ref: k })).filter((r) => store.has(r));
116
+ }
117
+ isSelected(key) { return this.#selected.value.has(key); }
118
+ /** Select or deselect row keys. */
119
+ select(keys, on) {
120
+ const next = new Set(this.#selected.peek());
121
+ for (const k of keys) { if (on) next.add(k); else next.delete(k); }
122
+ this.#selected.value = next;
123
+ }
124
+ clearSelection() { if (this.#selected.peek().size) this.#selected.value = new Set(); }
125
+
126
+ /** Start a streamed call: cancels the previous one, and everything is cancelled when the view goes away. */
127
+ beginStream() {
128
+ this.#streamCtl?.abort();
129
+ this.#streamCtl = new AbortController();
130
+ return this.#streamCtl.signal;
131
+ }
132
+
133
+ /** Collect the events of a streamed call into `$stream` (each: the JSON payload plus `event`, or `{ event, data }`). */
134
+ async consume(events) {
135
+ batch(() => { this.#stream.value = []; this.#streaming.value = true; });
136
+ try {
137
+ for await (const ev of events) {
138
+ const item = ev.json && typeof ev.json === 'object' && !Array.isArray(ev.json) ? { event: ev.event, ...ev.json } : { event: ev.event, data: ev.data };
139
+ this.#stream.value = [...this.#stream.peek(), item];
140
+ }
141
+ } finally { this.#streaming.value = false; }
142
+ }
143
+
144
+ /** Names listed in `url-params`: those params live in the page's query string (shareable, survive reload and Back). */
145
+ #urlNames() { return (this.getAttribute('url-params') ?? '').split(',').map((s) => s.trim()).filter(Boolean); }
146
+ /** How the next change of a url-param is written: 'push' for a click, 'replace' for typing (set by the control, reset after use). */
147
+ urlMode = 'replace';
148
+
149
+ /** Runtime params for the operation (overrides `p-*` attributes). */
150
+ get params() { return this.#params.value; }
151
+ set params(v) { this.#params.value = { ...v }; }
152
+
153
+ connected() {
154
+ // Children may not exist yet when the element is connected (HTML parser,
155
+ // some DOM implementations). Start once the tree is complete.
156
+ if (document.readyState === 'loading') {
157
+ document.addEventListener('DOMContentLoaded', () => this.isConnected && this.#start(), { once: true });
158
+ } else {
159
+ queueMicrotask(() => this.isConnected && this.#start());
160
+ }
161
+ }
162
+
163
+ disconnected() {
164
+ this.#stop();
165
+ }
166
+
167
+ #stop() {
168
+ this.#streamCtl?.abort();
169
+ this.#streamCtl = null;
170
+ this.#bound.splice(0).forEach((d) => d());
171
+ this.#release?.();
172
+ this.#release = null;
173
+ }
174
+
175
+ #start() {
176
+ if (!paramSource && [...this.attributes].some((a) => a.name.startsWith('p-') && a.value.startsWith(':'))) {
177
+ waitingForRouter.push(() => { if (this.isConnected) this.#start(); }); // a route view whose router is not running yet: wait for it
178
+ return;
179
+ }
180
+ this.#stop();
181
+ const client = getClient();
182
+ const store = client.store;
183
+ const lookup = makeResolver(store);
184
+ if (!this.#tpl) {
185
+ // Prerendered pages (@xoong/ssr) keep the original markup in a <template data-xo-template>; the rendered rows around
186
+ // it are only there for the first paint and are replaced when the view starts.
187
+ const saved = this.firstElementChild?.localName === 'template' && this.firstElementChild.hasAttribute('data-xo-template') ? /** @type {HTMLTemplateElement} */ (this.firstElementChild) : null;
188
+ this.#tpl = [...(saved ? saved.content.childNodes : this.childNodes)].map((n) => n.cloneNode(true));
189
+ const initial = {};
190
+ for (const a of this.attributes) {
191
+ if (!a.name.startsWith('p-')) continue;
192
+ // p-id="?id" reads ?id=... from the page URL (public pages that show one item); an absent or empty query parameter is left out
193
+ if (a.value.startsWith(':')) { const v = paramSource?.(a.value.slice(1)); if (v) initial[camel(a.name.slice(2))] = v; continue; } // p-id=":id": a route parameter
194
+ if (a.value.startsWith('?')) { const v = new URLSearchParams(globalThis.location?.search).get(a.value.slice(1)); if (v) initial[camel(a.name.slice(2))] = v; } else initial[camel(a.name.slice(2))] = a.value;
195
+ }
196
+ const q = this.#urlNames().length ? urlSync().read() : null; // url-params: the page URL wins over fixed p-* values
197
+ for (const n of this.#urlNames()) if (q?.get(n)) initial[n] = q.get(n);
198
+ this.#params.value = { ...initial, ...this.#params.peek() };
199
+ }
200
+ const q = signal(/** @type {any} */ (null));
201
+ // While the next query of this view loads (a sort, a page, a filter), the previous result stays on screen: the page does not collapse
202
+ // and jump. `$refreshing` says it is the old data; `$loading` is only for "nothing to show yet". An error clears it.
203
+ /** @type {any} */ let last;
204
+ let lastRef = '';
205
+ const root = () => {
206
+ const r = q.value?.root.value;
207
+ if (r !== undefined) return (last = r);
208
+ return q.value?.status.value === 'loading' ? last : undefined;
209
+ };
210
+ // The view's own params (p-*) are visible to everything inside it: {sid} works in xo-click/xo-submit.
211
+ /** @type {Scope} */
212
+ const paramsScope = { value: () => this.#params.value };
213
+ /** @type {Scope} */
214
+ const scope = {
215
+ value: root,
216
+ parent: paramsScope,
217
+ vars: {
218
+ $loading: () => q.value?.status.value === 'loading' && root() === undefined,
219
+ $refreshing: () => q.value?.status.value === 'loading' && root() !== undefined,
220
+ // Events of the latest streamed call (an operation that answers with server-sent events), and whether it still runs.
221
+ $stream: () => this.#stream.value,
222
+ $streaming: () => this.#streaming.value,
223
+ $selectedCount: () => this.selectedRefs().length,
224
+ $selected: () => this.selectedRefs().map((r) => store.denormalize(r)), // the ticked rows, for xo-body="$selected"
225
+ $error: () => q.value?.error.value,
226
+ // Paging state for lists that take `limit` and `offset` and return `total`.
227
+ $hasPrev: () => Number(this.#params.value.offset) > 0,
228
+ $from: () => Number(this.#params.value.offset ?? 0) + 1,
229
+ $to: () => {
230
+ const { limit, offset = 0 } = this.#params.value;
231
+ const total = root() && store.deref(root())?.total;
232
+ return limit == null ? undefined : typeof total === 'number' ? Math.min(Number(offset) + Number(limit), total) : Number(offset) + Number(limit);
233
+ },
234
+ $hasNext: () => {
235
+ const { limit, offset = 0 } = this.#params.value;
236
+ const total = root() && store.deref(root())?.total;
237
+ return limit != null && typeof total === 'number' && Number(offset) + Number(limit) < total;
238
+ },
239
+ },
240
+ };
241
+ this.replaceChildren(...this.#tpl.map((n) => n.cloneNode(true)));
242
+ const ctx = { client, lookup, host: this };
243
+ for (const child of [...this.childNodes]) bindNode(child, scope, ctx, this.#bound);
244
+ this.#bound.push(effect(() => { this.toggleAttribute('data-refreshing', !!scope.vars?.$refreshing()); })); // for CSS: the old data is on screen while the new loads
245
+
246
+ // Select the query and keep it observed while connected.
247
+ let current = null;
248
+ this.#bound.push(effect(() => {
249
+ const ref = this.get;
250
+ const params = this.#params.value;
251
+ current?.release();
252
+ current = null;
253
+ if (ref !== lastRef) { last = undefined; lastRef = ref ?? ''; } // another operation: the old result is not this view's
254
+ q.value = null;
255
+ this.clearSelection(); // other rows are on screen now
256
+ if (!ref) return;
257
+ try {
258
+ current = client.query(ref.startsWith('/') ? `GET ${ref}` : ref, params); // "/users" or an operationId
259
+ } catch (e) {
260
+ console.error(e); // misconfigured view: report, but do not break the flush loop
261
+ this.emit('xo-error', e);
262
+ return;
263
+ }
264
+ q.value = current;
265
+ current.retain();
266
+ }));
267
+ this.refresh = () => current?.invalidate();
268
+ this.#bound.push(() => { current?.release(); this.refresh = () => {}; });
269
+ const urlNames = this.#urlNames();
270
+ if (urlNames.length) {
271
+ const sync = urlSync();
272
+ let first = true;
273
+ this.#bound.push(effect(() => {
274
+ const params = this.#params.value;
275
+ const next = new URLSearchParams(sync.read());
276
+ for (const n of urlNames) { const v = params[n]; if (v == null || v === '') next.delete(n); else next.set(n, String(v)); }
277
+ const mode = first ? 'replace' : this.urlMode;
278
+ first = false;
279
+ this.urlMode = 'replace';
280
+ sync.write(next, mode);
281
+ }));
282
+ // Back/Forward (or anything else that changes the query): read it into the params
283
+ this.#bound.push(sync.subscribe(() => {
284
+ const query = sync.read();
285
+ const next = { ...this.#params.peek() };
286
+ let changed = false;
287
+ for (const n of urlNames) {
288
+ const v = query.get(n) ?? '';
289
+ if (String(next[n] ?? '') !== v) { changed = true; if (v === '') delete next[n]; else next[n] = v; }
290
+ }
291
+ if (changed) this.#params.value = next;
292
+ }));
293
+ }
294
+ if (this.hasAttribute('live')) this.#release = acquireStream();
295
+ }
296
+ }
297
+ define('xo-view', XoView);
298
+ export { XoView };
299
+ export { setFormat } from './format.js';
300
+ export { setUrlSync } from './url-state.js';
301
+
302
+ /** Interpolate `{path}` in a string. Returns null if there is nothing to interpolate. */
303
+ function parseInterpolation(text) {
304
+ if (!text.includes('{')) return null;
305
+ const parts = text.split(/(\{[^{}]+\})/).filter((p) => p !== '');
306
+ if (!parts.some((p) => /^\{[^{}]+\}$/.test(p))) return null;
307
+ return parts.map((p) => {
308
+ if (!/^\{[^{}]+\}$/.test(p)) return p;
309
+ const [path, fmt] = p.slice(1, -1).split('|').map((s) => s.trim()); // {path} or {path | format}
310
+ return fmt ? { path, fmt } : { path };
311
+ });
312
+ }
313
+ const evalParts = (parts, scope, lookup) =>
314
+ parts.map((p) => {
315
+ if (typeof p === 'string') return p;
316
+ const v = lookup(scope, p.path);
317
+ return p.fmt ? format(p.fmt, v) : v == null ? '' : typeof v === 'object' ? JSON.stringify(v) : String(v);
318
+ }).join('');
319
+
320
+ function bindNode(node, scope, ctx, disposers) {
321
+ const { lookup } = ctx;
322
+ if (node.nodeType === 3) {
323
+ const parts = parseInterpolation(node.data);
324
+ if (parts) disposers.push(effect(() => { node.data = evalParts(parts, scope, lookup); }));
325
+ return;
326
+ }
327
+ if (node.nodeType !== 1) return;
328
+ const el = /** @type {Element} */ (node);
329
+ if (el.localName === 'script' || el.localName === 'style') return;
330
+
331
+ const expand = templates.get(el.localName);
332
+ if (expand) {
333
+ let out;
334
+ try {
335
+ out = expand(el, { host: ctx.host, client: ctx.client });
336
+ } catch (e) {
337
+ console.error(e); // a typo in the screen: say so, leave a marker, keep the rest of the screen alive
338
+ ctx.host.emit('xo-error', e);
339
+ el.replaceWith(document.createComment(`<${el.localName}> failed: ${e.message}`));
340
+ return;
341
+ }
342
+ el.replaceWith(out);
343
+ for (const n of out.nodeType === 11 ? [...out.childNodes] : [out]) bindNode(n, scope, ctx, disposers);
344
+ return;
345
+ }
346
+ if (el.hasAttribute('xo-each')) return bindEach(el, scope, ctx, disposers);
347
+ if (el.hasAttribute('xo-if')) return bindIf(el, scope, ctx, disposers);
348
+
349
+ // xo-t: translate this element; its `{placeholders}` come from the scope. Without @xoong/i18n the text is bound as written.
350
+ let translated = false;
351
+ if (el.hasAttribute('xo-t') && translator) {
352
+ const t = translator;
353
+ translated = t.hasText(el.getAttribute('xo-t') ?? ''); // only a translated *text* replaces the children; attribute-only translations leave them bound
354
+ disposers.push(effect(() => t.apply(el, (name) => ctx.lookup(scope, name))));
355
+ }
356
+ let childScope = scope;
357
+ for (const attr of [...el.attributes]) {
358
+ if (attr.name === 'xo-click' || attr.name === 'xo-submit') {
359
+ const engine = bindAction(el, attr.name === 'xo-click' ? 'click' : 'submit', attr.value, scope, ctx, disposers);
360
+ if (engine) childScope = { value: () => engine.model.value, parent: scope, vars: { $form: () => engine.model } }; // `$form.dirty`, `$form.errors['title.vi']` ... inside the form
361
+ continue;
362
+ }
363
+ if (attr.name === 'xo-add' || attr.name === 'xo-remove' || attr.name === 'xo-move') {
364
+ bindArrayAction(el, attr.name, attr.value, scope, ctx, disposers);
365
+ continue;
366
+ }
367
+ if (attr.name === 'xo-drag') {
368
+ bindDrag(el, attr.value, scope, ctx);
369
+ continue;
370
+ }
371
+ if (attr.name === 'xo-select') {
372
+ bindSelect(el, attr.value, scope, ctx, disposers);
373
+ continue;
374
+ }
375
+ if (attr.name === 'xo-param') {
376
+ bindParam(el, attr.value, scope, ctx, disposers);
377
+ continue;
378
+ }
379
+ if (attr.name === 'xo-refresh') {
380
+ const refresh = () => ctx.host.refresh();
381
+ el.addEventListener('click', refresh);
382
+ disposers.push(() => el.removeEventListener('click', refresh));
383
+ continue;
384
+ }
385
+ if (attr.name === 'xo-body' || attr.name === 'xo-keep' || attr.name === 'xo-optimistic' || attr.name === 'xo-selected' || attr.name === 'xo-with' || attr.name === 'xo-confirm' || attr.name === 'xo-notice' || attr.name === 'xo-t') continue;
386
+ const parts = parseInterpolation(attr.value);
387
+ if (parts) {
388
+ const name = attr.name;
389
+ if (isScriptAttr(name)) {
390
+ console.error(`xoong: refusing to interpolate data into the "${name}" attribute (it executes code)`);
391
+ el.removeAttribute(name);
392
+ continue;
393
+ }
394
+ disposers.push(effect(() => {
395
+ let v = evalParts(parts, scope, lookup);
396
+ if (URL_ATTRS.has(name) && !isSafeUrl(v)) v = 'about:blank';
397
+ if (el.getAttribute(name) !== v) el.setAttribute(name, v);
398
+ }));
399
+ }
400
+ }
401
+ if (!translated) for (const child of [...el.childNodes]) bindNode(child, childScope, ctx, disposers); // a translated element's text is the message, not markup
402
+ }
403
+
404
+ /** Attributes whose value is a URL: data must not smuggle in script schemes. */
405
+ const URL_ATTRS = new Set(['href', 'src', 'action', 'formaction', 'poster', 'xlink:href', 'data', 'cite', 'srcset']);
406
+ const isScriptAttr = (name) => name.startsWith('on') || name === 'srcdoc';
407
+ /** Allow relative URLs and http(s)/mailto/tel; reject javascript:, data:, vbscript: (even if obfuscated with whitespace). */
408
+ function isSafeUrl(value) {
409
+ const compact = value.replace(/[\u0000-\u0020\u007f-\u009f]/g, '').toLowerCase();
410
+ const scheme = /^([a-z][a-z0-9+.-]*):/.exec(compact)?.[1];
411
+ return !scheme || ['http', 'https', 'mailto', 'tel'].includes(scheme);
412
+ }
413
+
414
+ function bindIf(el, scope, ctx, disposers) {
415
+ const expr = el.getAttribute('xo-if').trim();
416
+ // `xo-if="type=image"` / `xo-if="status!=draft"`: compare a path with a literal (the text of the value; quotes optional)
417
+ const cmp = /^(.+?)\s*(!?=)\s*(?:'([^']*)'|"([^"]*)"|(.*?))\s*$/.exec(expr);
418
+ const negate = cmp ? cmp[2] === '!=' : expr.startsWith('!');
419
+ const path = cmp ? cmp[1].trim() : negate ? expr.slice(1).trim() : expr;
420
+ const literal = cmp ? (cmp[3] ?? cmp[4] ?? cmp[5]) : undefined;
421
+ const tpl = el.cloneNode(true);
422
+ tpl.removeAttribute('xo-if');
423
+ const marker = document.createComment('xo-if');
424
+ el.replaceWith(marker);
425
+ /** @type {{ node: Element, stops: (() => void)[] } | null} */
426
+ let shown = null;
427
+ disposers.push(effect(() => {
428
+ const v = ctx.lookup(scope, path);
429
+ const truthy = literal !== undefined ? String(v ?? '') === literal : Array.isArray(v) ? v.length > 0 : !!v;
430
+ const want = negate ? !truthy : truthy;
431
+ if (want && !shown) {
432
+ const node = /** @type {Element} */ (tpl.cloneNode(true));
433
+ const stops = [];
434
+ marker.after(node);
435
+ bindNode(node, scope, ctx, stops);
436
+ shown = { node, stops };
437
+ } else if (!want && shown) {
438
+ shown.stops.forEach((s) => s());
439
+ shown.node.remove();
440
+ shown = null;
441
+ }
442
+ }));
443
+ disposers.push(() => { shown?.stops.forEach((s) => s()); shown?.node.remove(); shown = null; });
444
+ }
445
+
446
+ function bindEach(el, scope, ctx, disposers) {
447
+ const path = el.getAttribute('xo-each').trim();
448
+ const tpl = /** @type {Element} */ (el.cloneNode(true));
449
+ tpl.removeAttribute('xo-each');
450
+ const start = document.createComment('xo-each');
451
+ const end = document.createComment('/xo-each');
452
+ el.replaceWith(start, end);
453
+ /** @type {Map<string, { node: Element, stops: (() => void)[], index: ReturnType<typeof signal>, current: ReturnType<typeof signal> }>} */
454
+ let rows = new Map();
455
+ disposers.push(effect(() => {
456
+ const list = ctx.lookup(scope, path);
457
+ const items = (Array.isArray(list) ? list : []).filter((it) => !isRef(it) || ctx.client.store.has(it)); // drop deleted entities
458
+ // Do not subscribe this effect to row internals: bind rows untracked by their own effects.
459
+ const next = new Map();
460
+ let cursor = start;
461
+ items.forEach((item, i) => {
462
+ const key = isRef(item) ? item.$ref : `#${i}`;
463
+ let row = rows.get(key) ?? next.get(key);
464
+ if (!row) {
465
+ const node = /** @type {Element} */ (tpl.cloneNode(true));
466
+ const index = signal(i);
467
+ const current = signal(item);
468
+ // `$index` is the nearest row; `$i0`, `$i1` ... are the rows of the enclosing `xo-each`s, outermost first (names of nested array fields)
469
+ let depth = 0;
470
+ for (let s = scope; s; s = s.parent) if (s.vars?.$index) depth++;
471
+ const itemScope = { value: () => current.value, parent: scope, vars: { $index: () => index.value, $n: () => index.value + 1, [`$i${depth}`]: () => index.value } };
472
+ const stops = [];
473
+ batch(() => bindNode(node, itemScope, ctx, stops));
474
+ row = { node, stops, index, current };
475
+ } else batch(() => { row.index.value = i; row.current.value = item; }); // reused row follows its new item
476
+ next.set(key, row);
477
+ if (cursor.nextSibling !== row.node) cursor.after(row.node);
478
+ cursor = row.node;
479
+ });
480
+ for (const [key, row] of rows) if (!next.has(key)) { row.stops.forEach((s) => s()); row.node.remove(); }
481
+ rows = next;
482
+ }));
483
+ disposers.push(() => { for (const row of rows.values()) { row.stops.forEach((s) => s()); row.node.remove(); } rows.clear(); });
484
+ }
485
+
486
+ /** `@xoong/i18n` registers how `xo-t` elements are translated (the view supplies the scope for `{placeholders}`). */
487
+ let translator = /** @type {{ apply: (el: Element, get: (name: string) => any) => void, hasText: (spec: string) => boolean } | null} */ (null);
488
+ /** @param {{ apply: (el: Element, get: (name: string) => any) => void, hasText: (spec: string) => boolean }} t */
489
+ export function setTranslator(t) { translator = t; }
490
+
491
+ /** Ask before a destructive action (`xo-confirm="Delete {name}?"`). The default is the browser's confirm(); @xoong/ui replaces it with a native <dialog>. */
492
+ let confirmFn = /** @type {(message: string, context?: { kind: 'action' | 'leave', element?: Element, forms?: Element[] }) => boolean | Promise<boolean>} */ ((message) => (typeof globalThis.confirm === 'function' ? globalThis.confirm(message) : true));
493
+ /**
494
+ * Replace the confirmation dialog (for `xo-confirm` and for leaving a page with unsaved changes) with your own: return, or resolve to, true to go on.
495
+ * The second argument says what is being asked: `{ kind: 'action', element }` or `{ kind: 'leave', forms }`.
496
+ * @param {(message: string, context?: { kind: 'action' | 'leave', element?: Element, forms?: Element[] }) => boolean | Promise<boolean>} fn
497
+ */
498
+ export function setConfirm(fn) { confirmFn = fn; }
499
+ /** Ask the person a yes/no question with whatever confirm is installed. */
500
+ export const ask = (message, context) => Promise.resolve(confirmFn(message, context));
501
+
502
+ /** What a form's call returns when the form is invalid and nothing was sent. */
503
+ const INVALID = { invalid: true, errors: {} };
504
+
505
+ function bindAction(el, eventName, opRef, scope, ctx, disposers) {
506
+ const { client, lookup, host } = ctx;
507
+ let busy = false;
508
+ // A <form xo-submit> whose operation has a request schema gets the form model (ADR-0009): prefill, typed values, validation, dirty tracking.
509
+ // It is attached now, not at the first click, so fields are filled and completed before anyone touches the form.
510
+ let engine = null;
511
+ if (eventName === 'submit' && formEngine) {
512
+ let op0 = null;
513
+ try { op0 = client.op(opRef); } catch { /* reported when the form is submitted */ }
514
+ if (op0?.bodySchema) {
515
+ const bodyPath = el.getAttribute('xo-body');
516
+ // An edit form (the operation has path parameters, or xo-body names a record) starts from that record; a create form starts empty.
517
+ const edits = !!bodyPath || /\{[^}]+\}/.test(op0.path);
518
+ const record = edits ? () => { const plain = client.store.denormalize(lookup(scope, bodyPath ?? '.')); return plain && typeof plain === 'object' ? plain : undefined; } : undefined;
519
+ engine = formEngine.attach(el, { schema: op0.bodySchema, record, hadRecord: edits });
520
+ disposers.push(() => engine.detach());
521
+ }
522
+ }
523
+ const handler = async (event) => {
524
+ if (eventName === 'submit') event.preventDefault();
525
+ if (busy) return;
526
+ const confirmText = el.getAttribute('xo-confirm'); // ask first; {path} names the item ("Delete {title}?")
527
+ if (confirmText !== null && !(await ask(evalParts(parseInterpolation(confirmText) ?? [confirmText], scope, lookup), { kind: 'action', element: el }))) return;
528
+ busy = true;
529
+ el.setAttribute('aria-busy', 'true');
530
+ try {
531
+ const ref = opRef;
532
+ const op = client.op(ref);
533
+ if (!engine) clearFieldErrors(el);
534
+ else el.querySelectorAll?.('[data-xo-summary]').forEach((n) => n.remove()); // the last failure's summary: this is a new attempt
535
+ /** One call, with the params and body taken from `sc` (the button's scope, or one selected row). */
536
+ const callOnce = async (sc) => {
537
+ const params = {};
538
+ for (const m of op.path.matchAll(/\{([^}]+)\}/g)) params[m[1]] = lookup(sc, m[1]);
539
+ for (const q of op.query ?? []) { const v = lookup(sc, q); if (v !== undefined) params[q] = v; }
540
+ let body;
541
+ if (engine && op.body === true) { // the model validates, normalises and sends; an invalid form sends nothing
542
+ const out = await engine.submit((b) => client.mutate(ref, params, b, { optimistic: optimisticOf(el, sc, ctx) }));
543
+ if (!out.ok) { INVALID.errors = out.errors; return INVALID; }
544
+ return out.result;
545
+ }
546
+ if (eventName === 'submit' && op.body === true) body = formBody(/** @type {HTMLFormElement} */ (el), op);
547
+ else if (el.hasAttribute('xo-body')) body = client.store.denormalize(lookup(sc, el.getAttribute('xo-body')));
548
+ const extra = withOf(el, sc, ctx, op);
549
+ const idsField = el.getAttribute('xo-selected'); // "ids": one request carrying the ticked ids, instead of one request per row
550
+ if (idsField) body = { ...(body ?? {}), ...extra, [idsField]: host.selectedRefs().map((r) => selectedId(client, r)) };
551
+ else if (extra) body = { ...(body ?? {}), ...extra };
552
+ if (op.stream) {
553
+ const abort = host.beginStream();
554
+ try { await host.consume(client.stream(ref, params, body, { signal: abort })); } catch (e) { if (abort.aborted) return undefined; throw e; } // view gone: stop quietly
555
+ finally { if (!abort.aborted) client.invalidateAll(); } // the stream may have changed data that is on screen, even if it failed halfway
556
+ return undefined;
557
+ }
558
+ return client.mutate(ref, params, body, { optimistic: optimisticOf(el, sc, ctx) });
559
+ };
560
+ // xo-selected: the same operation once for every ticked row (sequentially; the first failure stops the rest).
561
+ const bulk = el.hasAttribute('xo-selected');
562
+ const oneRequest = !!el.getAttribute('xo-selected');
563
+ const targets = bulk && !oneRequest ? host.selectedRefs().map((r) => ({ value: () => r, parent: scope })) : [scope];
564
+ if (!targets.length || (oneRequest && !host.selectedRefs().length)) return;
565
+ const results = [];
566
+ for (const sc of targets) results.push(await callOnce(sc));
567
+ const notice = el.getAttribute('xo-notice'); // evaluated before the selection is cleared, so {$selectedCount} still counts
568
+ const noticeText = notice === null ? null : evalParts(parseInterpolation(notice) ?? [notice], scope, lookup);
569
+ if (bulk) host.clearSelection();
570
+ if (results[0] === INVALID) { host.emit('xo-invalid', { errors: INVALID.errors }); return; } // nothing was sent; the form showed why
571
+ const result = bulk ? results : results[0];
572
+ if (!engine && eventName === 'submit' && !el.hasAttribute('xo-keep')) /** @type {HTMLFormElement} */ (el).reset?.(); // a sent form starts empty again
573
+ host.emit('xo-done', result);
574
+ if (noticeText) host.emit('xo-notice', { message: noticeText, kind: 'success' }); // shown by <xo-toast>
575
+ } catch (e) {
576
+ // API failures are expected flows (events + global onError). Anything else is a bug in the screen.
577
+ if (!(e instanceof XoongError)) console.error(e);
578
+ else if (engine) { if (!Object.keys(e.fields ?? {}).length) showFieldErrors(el, {}, e.detail ?? e.message); } // field messages are on the paths; others as a summary
579
+ else showFieldErrors(el, e.fields, e.detail ?? e.message);
580
+ host.emit('xo-error', e);
581
+ } finally {
582
+ busy = false;
583
+ el.removeAttribute('aria-busy');
584
+ }
585
+ };
586
+ el.addEventListener(eventName, handler);
587
+ disposers.push(() => el.removeEventListener(eventName, handler));
588
+ return engine;
589
+ }
590
+
591
+ /** The JSON body of a form. With `bodyFields` from the API document, values get their real types. */
592
+ function formBody(form, op) {
593
+ const raw = new FormData(form);
594
+ if (op.multipart) { // files stay files; the client sends it as multipart/form-data
595
+ for (const [name, value] of [...raw]) if (typeof value !== 'string' && !value.name && !value.size) raw.delete(name); // no file chosen
596
+ return raw;
597
+ }
598
+ const body = {};
599
+ const types = new Map((op.bodyFields ?? []).map((f) => [f.name, f.type]));
600
+ for (const [name, value] of raw) {
601
+ if (typeof value !== 'string') continue;
602
+ const type = types.get(name);
603
+ if (type === 'integer' || type === 'number') { if (value !== '') body[name] = Number(value); }
604
+ else if (type === 'boolean') body[name] = true; // an unchecked checkbox is not in FormData
605
+ else if (type && value === '' && !op.bodyFields.find((f) => f.name === name)?.required) continue; // optional and empty: omit
606
+ else body[name] = value;
607
+ }
608
+ for (const [name, type] of types) if (type === 'boolean' && !(name in body)) body[name] = false;
609
+ return body;
610
+ }
611
+
612
+ function clearFieldErrors(form) {
613
+ if (form.localName !== 'form') return;
614
+ form.querySelectorAll('[data-xo-error]').forEach((n) => n.remove());
615
+ form.querySelectorAll('[aria-invalid]').forEach((n) => { n.removeAttribute('aria-invalid'); n.removeAttribute('aria-describedby'); });
616
+ }
617
+
618
+ /** Mark each named field invalid and put the server's message next to it; with no field to blame, show it under the form. */
619
+ function showFieldErrors(form, fields = {}, summary = '') {
620
+ if (form.localName !== 'form') return;
621
+ const note = (message, role) => {
622
+ const el = document.createElement('small');
623
+ el.className = 'xo-error';
624
+ el.dataset.xoError = '';
625
+ el.textContent = message;
626
+ if (role) el.setAttribute('role', role);
627
+ return el;
628
+ };
629
+ let placed = 0;
630
+ for (const [name, message] of Object.entries(fields)) {
631
+ const input = [...form.elements].find((f) => f.name === name);
632
+ if (!input) continue;
633
+ const el = note(message);
634
+ el.id = `xo-err-${name}-${Math.random().toString(36).slice(2, 7)}`;
635
+ input.after(el);
636
+ input.setAttribute('aria-invalid', 'true');
637
+ input.setAttribute('aria-describedby', el.id);
638
+ placed++;
639
+ }
640
+ const unplaced = Object.entries(fields).filter(([name]) => ![...form.elements].some((f) => f.name === name));
641
+ const summarize = (message) => { const n = note(message, 'alert'); n.dataset.xoSummary = ''; form.append(n); };
642
+ if (!placed && summary) summarize(summary);
643
+ else if (unplaced.length) summarize(unplaced.map(([name, message]) => `${name}: ${message}`).join('; ')); // no input for these
644
+ }
645
+
646
+ function bindParam(el, spec, scope, ctx, disposers) {
647
+ if (/^[\w-]+$/.test(spec.trim()) && ['input', 'select', 'textarea'].includes(el.localName)) { // a filter control
648
+ const name = camel(spec.trim());
649
+ let timer;
650
+ const apply = () => {
651
+ const params = { ...ctx.host.params };
652
+ if (el.value === '') delete params[name]; else params[name] = el.value;
653
+ if ('offset' in params) params.offset = 0;
654
+ ctx.host.urlMode = el.localName === 'select' ? 'push' : 'replace'; // typing replaces the history entry, a choice adds one
655
+ ctx.host.params = params;
656
+ };
657
+ const onInput = () => { clearTimeout(timer); timer = setTimeout(apply, el.localName === 'select' ? 0 : 250); }; // wait for the typing to pause
658
+ const type = el.localName === 'select' ? 'change' : 'input';
659
+ el.addEventListener(type, onInput);
660
+ // the control shows the current param (after a reload or Back with url-params), unless the person is typing in it
661
+ disposers.push(effect(() => {
662
+ const v = ctx.host.params[name];
663
+ const text = v == null ? '' : String(v);
664
+ if (el.value !== text && globalThis.document?.activeElement !== el) el.value = text;
665
+ }));
666
+ disposers.push(() => { clearTimeout(timer); el.removeEventListener(type, onInput); });
667
+ return;
668
+ }
669
+ const m = /^\s*([\w-]*\w)\s*(\+=|-=|~=|=)\s*(.*?)\s*$/.exec(spec);
670
+ if (!m) { console.error(`xoong: xo-param="${spec}" is not "name=value", "name+=n", "name-=n" or "name~=value"`); return; }
671
+ const [, rawName, op, rawValue] = m;
672
+ const name = camel(rawName);
673
+ const handler = () => {
674
+ const value = evalParts(parseInterpolation(rawValue) ?? [rawValue], scope, ctx.lookup);
675
+ const params = { ...ctx.host.params };
676
+ const cur = params[name];
677
+ if (op === '=') { if (value === '') delete params[name]; else params[name] = value; } // "k=" clears the param
678
+ else if (op === '~=') params[name] = cur === value ? `-${value}` : value; // asc, then desc, then asc ...
679
+ else params[name] = Math.max(0, (Number(cur) || 0) + (op === '+=' ? 1 : -1) * Number(value));
680
+ if (name !== 'offset' && 'offset' in params) params.offset = 0; // a new filter or sort starts at the first page
681
+ ctx.host.urlMode = 'push';
682
+ ctx.host.params = params;
683
+ };
684
+ el.addEventListener('click', handler);
685
+ // Tabs and segmented filters: the button whose value is the current one is marked (aria-current), "k=" matches "param unset".
686
+ disposers.push(effect(() => {
687
+ const cur = ctx.host.params[name];
688
+ const want = evalParts(parseInterpolation(rawValue) ?? [rawValue], scope, ctx.lookup);
689
+ const active = op === '=' ? String(cur ?? '') === want : op === '~=' ? String(cur ?? '').replace(/^-/, '') === want : false;
690
+ if (active) { el.setAttribute('aria-current', 'true'); if (op === '~=') el.setAttribute('data-direction', String(cur).startsWith('-') ? 'desc' : 'asc'); }
691
+ else { el.toggleAttribute('aria-current', false); el.toggleAttribute('data-direction', false); }
692
+ }));
693
+ disposers.push(() => el.removeEventListener('click', handler));
694
+ }
695
+
696
+ /** `xo-optimistic="status=disabled,note={text}"` -> [{ $ref, patch }] for the current item, or undefined. */
697
+ function optimisticOf(el, scope, ctx) {
698
+ const spec = el.getAttribute('xo-optimistic');
699
+ if (!spec) return undefined;
700
+ const ref = scope.value();
701
+ if (!isRef(ref)) { console.error('xoong: xo-optimistic needs a stored entity as the current item (put the button inside xo-each over entities)'); return undefined; }
702
+ const patch = {};
703
+ for (const pair of spec.split(',')) {
704
+ const m = /^\s*([\w.-]+)\s*=\s*(.*?)\s*$/.exec(pair);
705
+ if (m) patch[m[1]] = evalParts(parseInterpolation(m[2]) ?? [m[2]], scope, ctx.lookup);
706
+ }
707
+ return Object.keys(patch).length ? [{ $ref: ref.$ref, patch }] : undefined;
708
+ }
709
+
710
+ /** `xo-select` on a checkbox in a row ticks that row; `xo-select="all:items"` in the header ticks every row of the list. */
711
+ function bindSelect(el, spec, scope, ctx, disposers) {
712
+ const { host, lookup } = ctx;
713
+ const all = /^all:(.+)$/.exec(spec.trim())?.[1];
714
+ const keyOf = (item) => (isRef(item) ? item.$ref : undefined);
715
+ const keys = () => (all ? (lookup(scope, all) ?? []).map(keyOf).filter(Boolean) : [keyOf(scope.value())].filter(Boolean));
716
+ if (!all && !keys().length) console.error('xoong: xo-select needs a stored entity as the current row (put the checkbox inside xo-each over entities)');
717
+ disposers.push(effect(() => {
718
+ const list = keys();
719
+ el.checked = list.length > 0 && list.every((k) => host.isSelected(k));
720
+ }));
721
+ const onChange = () => host.select(keys(), el.checked);
722
+ el.addEventListener('change', onChange);
723
+ disposers.push(() => el.removeEventListener('change', onChange));
724
+ }
725
+
726
+ /** The id of a ticked row as the API knows it (a number stays a number; a composite id is joined), not the "User#5" store key. */
727
+ function selectedId(client, ref) {
728
+ const key = ref.$ref;
729
+ const def = client.store.defs[key.slice(0, key.indexOf('#'))];
730
+ const record = client.store.read(key);
731
+ return def && record ? idOf(def.id, record) ?? key.slice(key.indexOf('#') + 1) : key.slice(key.indexOf('#') + 1);
732
+ }
733
+
734
+ /** `xo-with="status=published,reason={why}"` -> { status: 'published', reason: ... }: fixed fields added to the request body. */
735
+ function withOf(el, scope, ctx, op) {
736
+ const spec = el.getAttribute('xo-with');
737
+ if (!spec) return undefined;
738
+ const types = new Map((op.bodyFields ?? []).map((f) => [f.name, f.type]));
739
+ const out = {};
740
+ for (const pair of spec.split(',')) {
741
+ const m = /^\s*([\w.-]+)\s*=\s*(.*?)\s*$/.exec(pair);
742
+ if (!m) continue;
743
+ const text = evalParts(parseInterpolation(m[2]) ?? [m[2]], scope, ctx.lookup);
744
+ const type = types.get(m[1]);
745
+ out[m[1]] = type === 'integer' || type === 'number' ? Number(text) : type === 'boolean' ? text === 'true' : text;
746
+ }
747
+ return out;
748
+ }
749
+
750
+ /** The marker comment that opens the xo-each list a row belongs to (null when `node` is not a row). */
751
+ const listStart = (node) => {
752
+ for (let n = node.previousSibling; n; n = n.previousSibling) if (n.nodeType === 8) return n.data === 'xo-each' ? n : null;
753
+ return null;
754
+ };
755
+ /** The rows of the list `row` belongs to, in order. */
756
+ function rowsOf(row) {
757
+ const rows = [];
758
+ for (let n = listStart(row)?.nextSibling; n && !(n.nodeType === 8 && n.data === '/xo-each'); n = n.nextSibling) if (n.nodeType === 1) rows.push(n);
759
+ return rows;
760
+ }
761
+ /** The innermost list row that holds `el`, the rows of its list and its position among them. */
762
+ function rowContext(el) {
763
+ let row = el;
764
+ while (row && !listStart(row)) row = row.parentElement;
765
+ const rows = row ? rowsOf(row) : [];
766
+ return { row, rows, at: row ? rows.indexOf(row) : -1 };
767
+ }
768
+ /** The button with the same action spec inside another row. */
769
+ const sameButton = (row, attr, spec) => [...row.querySelectorAll(`[${attr}]`)].find((b) => b.getAttribute(attr) === spec);
770
+
771
+ /**
772
+ * `xo-drag="blocks[{$index}]"` on a handle inside an array row: drag the row onto another row of the same list to move it. The buttons (xo-move) remain
773
+ * for keyboards and touch; this is the pointer shortcut. The handle describes itself on demand (the index changes as rows move).
774
+ */
775
+ const DRAG = 'application/x-xoong-row';
776
+ function bindDrag(el, spec, scope, ctx) {
777
+ el.draggable = true;
778
+ el.xoDrag = () => ({ text: evalParts(parseInterpolation(spec) ?? [spec], scope, ctx.lookup), model: ctx.lookup(scope, '$form') });
779
+ el.addEventListener('dragstart', (event) => {
780
+ event.dataTransfer?.setData(DRAG, el.xoDrag().text);
781
+ if (event.dataTransfer) event.dataTransfer.effectAllowed = 'move';
782
+ rowContext(el).row?.classList.add('xo-dragging');
783
+ });
784
+ el.addEventListener('dragend', () => document.querySelectorAll('.xo-dragging, .xo-drop').forEach((n) => n.classList.remove('xo-dragging', 'xo-drop')));
785
+ }
786
+ if (typeof document !== 'undefined') {
787
+ const split = (text) => { const m = /^(.*)\[(\d+)\]$/.exec(text); return m ? { list: m[1], index: Number(m[2]) } : null; };
788
+ const handleOf = (target) => rowContext(target).row?.querySelector('[xo-drag]');
789
+ document.addEventListener('dragover', (event) => {
790
+ if (!event.dataTransfer?.types.includes(DRAG)) return;
791
+ const over = handleOf(event.target);
792
+ if (!over) return;
793
+ event.preventDefault(); // this row accepts the drop
794
+ for (const n of document.querySelectorAll('.xo-drop')) if (n !== rowContext(over).row) n.classList.remove('xo-drop');
795
+ rowContext(over).row?.classList.add('xo-drop');
796
+ });
797
+ document.addEventListener('drop', (event) => {
798
+ const from = event.dataTransfer?.getData(DRAG);
799
+ const target = handleOf(/** @type {Element} */ (event.target));
800
+ if (!from || !target) return;
801
+ event.preventDefault();
802
+ const { text, model } = target.xoDrag();
803
+ const a = split(from);
804
+ const b = split(text);
805
+ document.querySelectorAll('.xo-dragging, .xo-drop').forEach((n) => n.classList.remove('xo-dragging', 'xo-drop'));
806
+ if (a && b && a.list === b.list && a.index !== b.index) model?.moveItem(from, b.index - a.index); // only within one list
807
+ });
808
+ }
809
+
810
+ /**
811
+ * Array editing inside a `<form xo-submit>` (the model owns the draft):
812
+ * <button type="button" xo-add="blocks" xo-with="type=image">Add image</button> append (a union item needs its tag)
813
+ * <button type="button" xo-remove="blocks[{$index}]">Remove</button> remove this item
814
+ * <button type="button" xo-move="blocks[{$index}]:up">Up</button> (or :down) move this item
815
+ * Plain buttons: keyboard accessible, no drag needed.
816
+ */
817
+ function bindArrayAction(el, kind, spec, scope, ctx, disposers) {
818
+ const handler = (event) => {
819
+ event.preventDefault();
820
+ const model = ctx.lookup(scope, '$form');
821
+ if (!model) { console.error(`xoong: ${kind} only works inside a <form xo-submit> whose operation has a request schema`); return; }
822
+ const text = evalParts(parseInterpolation(spec) ?? [spec], scope, ctx.lookup);
823
+ if (kind === 'xo-add') {
824
+ const fields = withOf(el, scope, ctx, { bodyFields: [] });
825
+ model.append(text, fields && Object.keys(fields).length ? fields : undefined);
826
+ } else if (kind === 'xo-remove') {
827
+ const { row, rows, at } = rowContext(el);
828
+ const area = row?.closest('fieldset, form');
829
+ model.removeAt(text);
830
+ // the focus must not fall to the page: it goes to the same button of the row now at that position, else the previous one, else the list's add button
831
+ const survivor = rows.find((r) => r.isConnected);
832
+ const left = survivor ? rowsOf(survivor) : [];
833
+ const next = left[at] ?? left[at - 1];
834
+ (next ? sameButton(next, kind, spec) : area?.querySelector('[xo-add]'))?.focus();
835
+ } else {
836
+ const m = /^(.*):(up|down|-?\d+)$/.exec(text);
837
+ if (!m) { console.error(`xoong: xo-move="${text}" is not "path:up" or "path:down"`); return; }
838
+ const delta = m[2] === 'up' ? -1 : m[2] === 'down' ? 1 : Number(m[2]);
839
+ const { rows, at } = rowContext(el);
840
+ model.moveItem(m[1], delta);
841
+ // rows follow their index, so the moved item's row is the one at the new position: keep the focus on the same button there
842
+ if (rows[at + delta]) sameButton(rows[at + delta], kind, spec)?.focus();
843
+ }
844
+ };
845
+ el.addEventListener('click', handler);
846
+ disposers.push(() => el.removeEventListener('click', handler));
847
+ }