@tiaforge-ui/sinter 0.2.0 → 0.3.1
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/README.md +3 -2
- package/dist/cache.js +9 -2
- package/dist/check/index.js +2 -1
- package/dist/check/shadow.d.ts +1 -1
- package/dist/check/shadow.js +18 -4
- package/dist/compiler/check-unit.d.ts +14 -2
- package/dist/compiler/compile.js +120 -13
- package/dist/compiler/diagnostic-docs.js +13 -3
- package/dist/compiler/diagnostics.d.ts +2 -0
- package/dist/compiler/diagnostics.js +2 -0
- package/dist/compiler/expr.d.ts +16 -0
- package/dist/compiler/expr.js +56 -4
- package/dist/compiler/vocabulary.d.ts +1 -0
- package/dist/compiler/vocabulary.js +6 -1
- package/dist/ir.d.ts +4 -0
- package/dist/ir.js +21 -3
- package/dist/listbox.d.ts +3 -0
- package/dist/listbox.js +66 -0
- package/dist/paginate.js +11 -4
- package/dist/persist.js +4 -1
- package/dist/runtime/app.d.ts +9 -0
- package/dist/runtime/app.js +96 -27
- package/dist/runtime/config.d.ts +8 -1
- package/dist/runtime/config.js +9 -2
- package/dist/runtime/helpers.js +1 -1
- package/dist/runtime/request.d.ts +1 -1
- package/dist/runtime/request.js +10 -3
- package/dist/runtime/store.js +2 -1
- package/dist/sse.js +6 -3
- package/dist/testing/index.js +2 -1
- package/docs/diagnostics.md +23 -3
- package/html-custom-data.json +29 -1
- package/llms.txt +8 -3
- package/package.json +3 -1
package/dist/compiler/expr.js
CHANGED
|
@@ -5,7 +5,7 @@ import * as acorn from 'acorn';
|
|
|
5
5
|
import { didYouMean } from './diagnostics.js';
|
|
6
6
|
const EVERYWHERE_MAGIC = ['$el', '$url', '$now', '$locale', '$meta'];
|
|
7
7
|
export const ALL_MAGIC = ['$event', '$el', '$url', '$now', '$locale', '$result', '$error', '$meta'];
|
|
8
|
-
export const STORE_FNS = ['refresh', 'upsert', 'patch', 'remove', 'page', 'close', 'reset', 'emit'];
|
|
8
|
+
export const STORE_FNS = ['refresh', 'upsert', 'patch', 'remove', 'page', 'showModal', 'close', 'reset', 'emit'];
|
|
9
9
|
export const BUILTIN_HELPERS = ['date', 'time', 'datetime', 'relative', 'number', 'bytes'];
|
|
10
10
|
export const META_FIELDS = ['loading', 'error', 'loadedAt', 'pending', 'total', 'page', 'pages', 'from', 'to', 'hasPrev', 'hasNext'];
|
|
11
11
|
const PAGE_FIELDS = ['page', 'pages', 'from', 'to', 'hasPrev', 'hasNext'];
|
|
@@ -40,6 +40,8 @@ const READ_ONLY = new Set([
|
|
|
40
40
|
'getMinutes', 'getSeconds', 'test', 'exec',
|
|
41
41
|
]);
|
|
42
42
|
const MUTATORS = new Set(['push', 'pop', 'splice', 'sort', 'reverse', 'shift', 'unshift', 'fill', 'copyWithin', 'add', 'delete', 'clear', 'set']);
|
|
43
|
+
/** `Object.<static>(target, …)` calls that change their first argument */
|
|
44
|
+
const OBJECT_MUTATORS = new Set(['assign', 'defineProperty', 'defineProperties', 'setPrototypeOf']);
|
|
43
45
|
/** callbacks of these methods receive elements of the receiver as first parameter */
|
|
44
46
|
const ELEMENT_CALLBACKS = new Set(['forEach', 'map', 'filter', 'find', 'findIndex', 'findLast', 'findLastIndex', 'some', 'every', 'flatMap', 'toSorted', 'sort']);
|
|
45
47
|
export function analyze(src, scope, page, opts) {
|
|
@@ -96,7 +98,7 @@ export function analyze(src, scope, page, opts) {
|
|
|
96
98
|
return fail();
|
|
97
99
|
}
|
|
98
100
|
function fail() {
|
|
99
|
-
return { ok: false, code: '() => undefined', deps: [], writes: [], usesEl: false, usesIndex: false, path: null, resource: null, ast: null };
|
|
101
|
+
return { ok: false, code: '() => undefined', inner: 'undefined', deps: [], writes: [], usesEl: false, usesIndex: false, path: null, resource: null, ast: null };
|
|
100
102
|
}
|
|
101
103
|
// ---- resolution
|
|
102
104
|
function resolve(name, locals, offset, member) {
|
|
@@ -127,6 +129,12 @@ export function analyze(src, scope, page, opts) {
|
|
|
127
129
|
}
|
|
128
130
|
}
|
|
129
131
|
if (!opts.payload) {
|
|
132
|
+
// a let cannot share a name with a loop or state in its scope (SN209), so lets resolve first
|
|
133
|
+
const lets = scope.lets ?? [];
|
|
134
|
+
for (let i = lets.length - 1; i >= 0; i--) {
|
|
135
|
+
if (lets[i].name === name)
|
|
136
|
+
return { kind: 'let', entry: lets[i] };
|
|
137
|
+
}
|
|
130
138
|
for (let i = scope.loops.length - 1; i >= 0; i--) {
|
|
131
139
|
if (scope.loops[i].name === name)
|
|
132
140
|
return { kind: 'loop', entry: scope.loops[i] };
|
|
@@ -170,7 +178,7 @@ export function analyze(src, scope, page, opts) {
|
|
|
170
178
|
return { kind: 'payload' };
|
|
171
179
|
}
|
|
172
180
|
const candidates = [
|
|
173
|
-
...scope.loops.map((l) => l.name), ...scope.states.flatMap((s) => [...s.names]),
|
|
181
|
+
...(scope.lets ?? []).map((l) => l.name), ...scope.loops.map((l) => l.name), ...scope.states.flatMap((s) => [...s.names]),
|
|
174
182
|
...page.resources.keys(), ...page.helpers, ...page.namespaces.keys(), ...STORE_FNS, ...BUILTIN_HELPERS,
|
|
175
183
|
];
|
|
176
184
|
report('SN201', `\`${name}\` is not defined here.${didYouMean(name, candidates)}`, offset);
|
|
@@ -183,6 +191,7 @@ export function analyze(src, scope, page, opts) {
|
|
|
183
191
|
case 'global':
|
|
184
192
|
case 'helper': return name;
|
|
185
193
|
case 'loop': return `$.${name}`;
|
|
194
|
+
case 'let': return `(${res.entry.code})`;
|
|
186
195
|
case 'state': return `$.$s${sid}.${name}`;
|
|
187
196
|
case 'resource': return `$.$r.${name}`;
|
|
188
197
|
case 'magic': return `$.${name}`;
|
|
@@ -202,6 +211,7 @@ export function analyze(src, scope, page, opts) {
|
|
|
202
211
|
return { segs: res.entry.alias ? [...res.entry.alias] : null, resource: res.entry.resource };
|
|
203
212
|
case 'local':
|
|
204
213
|
return { segs: res.info.alias ? [...res.info.alias] : null, resource: res.info.resource };
|
|
214
|
+
case 'let': return { segs: null, resource: res.entry.resource };
|
|
205
215
|
default: return { segs: null, resource: null };
|
|
206
216
|
}
|
|
207
217
|
}
|
|
@@ -315,6 +325,15 @@ export function analyze(src, scope, page, opts) {
|
|
|
315
325
|
sid = res.sid;
|
|
316
326
|
if (res.kind === 'loop' && res.entry.kind === 'index')
|
|
317
327
|
usesIndex = true;
|
|
328
|
+
if (res.kind === 'let') {
|
|
329
|
+
// the inlined expression reads what the let reads
|
|
330
|
+
for (const d of res.entry.deps)
|
|
331
|
+
deps.add(d);
|
|
332
|
+
if (res.entry.usesEl)
|
|
333
|
+
usesEl = true;
|
|
334
|
+
if (res.entry.usesIndex)
|
|
335
|
+
usesIndex = true;
|
|
336
|
+
}
|
|
318
337
|
const text = emitName(n.name, res, sid);
|
|
319
338
|
if (text !== n.name || shorthand)
|
|
320
339
|
edits.push({ start: n.start, end: n.end, text: shorthand ? `${n.name}: ${text}` : text });
|
|
@@ -472,6 +491,10 @@ export function analyze(src, scope, page, opts) {
|
|
|
472
491
|
report('SN203', 'only state variables and their properties can be assigned', at);
|
|
473
492
|
return;
|
|
474
493
|
}
|
|
494
|
+
if (cp.res.kind === 'let') {
|
|
495
|
+
report('SN203', `\`${cp.root}\` is derived (\`data-sn-let\`) and cannot be assigned; assign the state it is computed from`, at);
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
475
498
|
if (cp.resource) {
|
|
476
499
|
report('SN205', `\`${src.slice(target.start, target.end)}\` mutates a resource. Use \`patch('${cp.resource}', …, {…})\``, at, 'resources are read-only in expressions; copy with structuredClone() to edit');
|
|
477
500
|
return;
|
|
@@ -527,6 +550,26 @@ export function analyze(src, scope, page, opts) {
|
|
|
527
550
|
}
|
|
528
551
|
}
|
|
529
552
|
}
|
|
553
|
+
// `Object.assign(target, …)` and friends write to their first argument like `target.x = …` would;
|
|
554
|
+
// a forbidden key named literally in an `Object.*` call reaches what `.constructor` is refused for
|
|
555
|
+
if (m.object.type === 'Identifier' && m.object.name === 'Object' && !locals.has('Object') && method) {
|
|
556
|
+
for (const a of n.arguments) {
|
|
557
|
+
if (a.type === 'Literal' && FORBIDDEN_KEYS.has(String(a.value)))
|
|
558
|
+
report('SN207', `\`${a.raw}\` is not allowed as an argument of \`Object.${method}()\``, a.start);
|
|
559
|
+
}
|
|
560
|
+
const target = n.arguments[0];
|
|
561
|
+
const cp = OBJECT_MUTATORS.has(method) && target && (target.type === 'Identifier' || target.type === 'MemberExpression') ? chainPath(target, locals) : null;
|
|
562
|
+
if (cp) {
|
|
563
|
+
if (cp.resource)
|
|
564
|
+
report('SN205', `\`Object.${method}(${src.slice(target.start, target.end)}, …)\` mutates a resource. Use \`patch('${cp.resource}', …)\` or copy with structuredClone()`, n.start);
|
|
565
|
+
else if (cp.segs && cp.res.kind === 'state') {
|
|
566
|
+
if (mode === 'value')
|
|
567
|
+
report('SN203', `value expressions cannot change state; move \`Object.${method}()\` to \`data-sn-on-*\``, n.start);
|
|
568
|
+
else if (mode === 'handler')
|
|
569
|
+
writes.add(track(cp, cp.segs, n).join('.'));
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
}
|
|
530
573
|
// time and randomness
|
|
531
574
|
if (mode === 'value' && m.object.type === 'Identifier' && !m.computed) {
|
|
532
575
|
const o = m.object.name, p = m.property.name;
|
|
@@ -570,6 +613,15 @@ export function analyze(src, scope, page, opts) {
|
|
|
570
613
|
return;
|
|
571
614
|
}
|
|
572
615
|
const first = n.arguments[0];
|
|
616
|
+
if (name === 'showModal' || (name === 'close' && first)) {
|
|
617
|
+
if (!first || first.type !== 'Literal' || typeof first.value !== 'string') {
|
|
618
|
+
report('SN309', `\`${name}()\` takes the dialog's \`id\` as a string literal`, n.start);
|
|
619
|
+
}
|
|
620
|
+
else if (!page.dialogs.has(first.value)) {
|
|
621
|
+
report('SN309', `no \`<dialog id="${first.value}">\` on this page.${didYouMean(first.value, page.dialogs)}`, first.start);
|
|
622
|
+
}
|
|
623
|
+
return;
|
|
624
|
+
}
|
|
573
625
|
if (['refresh', 'upsert', 'patch', 'remove', 'page'].includes(name)) {
|
|
574
626
|
if (!first || first.type !== 'Literal' || typeof first.value !== 'string') {
|
|
575
627
|
report('SN301', `\`${name}()\` takes the resource name as a string literal`, n.start);
|
|
@@ -842,7 +894,7 @@ export function analyze(src, scope, page, opts) {
|
|
|
842
894
|
}
|
|
843
895
|
}
|
|
844
896
|
}
|
|
845
|
-
return { ok, code, deps: [...deps].sort(), writes: [...writes].sort(), usesEl, usesIndex, entries, path, resource, ast, ...(opts.payload ? { payloadNames: [...payloadNames].sort(), payloadStates: [...payloadStates].sort() } : {}) };
|
|
897
|
+
return { ok, code, inner: out, deps: [...deps].sort(), writes: [...writes].sort(), usesEl, usesIndex, entries, path, resource, ast, ...(opts.payload ? { payloadNames: [...payloadNames].sort(), payloadStates: [...payloadStates].sort() } : {}) };
|
|
846
898
|
}
|
|
847
899
|
export function applyEdits(src, edits) {
|
|
848
900
|
const sorted = [...edits].sort((a, b) => b.start - a.start || (b.end - b.start) - (a.end - a.start));
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// The compiler, llms.txt and html-custom-data.json are all built from it.
|
|
3
3
|
export const ATTRIBUTES = [
|
|
4
4
|
{ name: 'state', value: 'object literal', doc: 'declares local reactive state for the element and its subtree' },
|
|
5
|
+
{ name: 'let', value: 'object literal {name: expression}', doc: 'derived names for the element and its subtree, computed where they are read; later names may use earlier ones' },
|
|
5
6
|
{ name: 'text', value: 'value expression', doc: 'sets textContent; never HTML' },
|
|
6
7
|
{ name: 'attr', value: 'object literal {href: …}', doc: 'literal keys; null/false removes, true sets empty; URL values are checked' },
|
|
7
8
|
{ name: 'class', value: 'object literal {unread: …}', doc: 'toggles each class' },
|
|
@@ -13,6 +14,7 @@ export const ATTRIBUTES = [
|
|
|
13
14
|
{ name: 'key', value: 'value expression', doc: 'row identity; defaults to the resource key or object identity' },
|
|
14
15
|
{ name: 'if', value: 'value expression', doc: 'renders a <template> while truthy' },
|
|
15
16
|
{ name: 'else', value: '', doc: 'on a <template> directly after an if template' },
|
|
17
|
+
{ name: 'listbox', value: 'id of the suggestion list, or empty', doc: 'on a text input: arrow keys mark a [role=option] of that list (empty: the nearest [role=listbox] around the input), Enter or Tab clicks it, Escape fires `dismiss`' },
|
|
16
18
|
{ name: 'persist', value: 'storage key', doc: 'persists the model or state in localStorage under sn:' },
|
|
17
19
|
{ name: 'get', value: 'URL template', doc: 'GET action; latest wins' },
|
|
18
20
|
{ name: 'post', value: 'URL template', doc: 'POST action' },
|
|
@@ -26,11 +28,14 @@ export const ATTRIBUTES = [
|
|
|
26
28
|
{ name: 'concurrency', value: 'block | queue | latest', doc: 'mutations only; default block for forms, queue otherwise' },
|
|
27
29
|
{ name: 'optimistic', value: 'handler expression', doc: 'patch/upsert/remove calls applied before the request, rolled back on failure' },
|
|
28
30
|
{ name: 'then', value: 'handler expression', doc: 'runs after a 2xx response; $result = parsed body' },
|
|
29
|
-
{ name: 'catch', value: 'handler expression', doc: 'runs after an error; $error = {status, body, message}' },
|
|
31
|
+
{ name: 'catch', value: 'handler expression', doc: 'runs after an error; $error = {status, body, message, detail}' },
|
|
32
|
+
{ name: 'confirm', value: 'value expression', doc: 'asks this question before the action runs (configure({ confirm }), window.confirm by default); no question when falsy' },
|
|
33
|
+
{ name: 'encoding', value: 'json | form', doc: 'form: the body (data-sn-body, arrays as repeated keys, or the form) goes as application/x-www-form-urlencoded' },
|
|
30
34
|
{ name: 'path', value: 'URL patterns /a/:b …', doc: 'on <html>: the path shapes this page is served for; `:name` segments appear read-only in $url' },
|
|
31
35
|
{ name: 'data', value: 'resource name', doc: 'on <script type="application/json"> in <head>: initial data for a resource' },
|
|
32
36
|
];
|
|
33
37
|
export const ON_DOC = { name: 'on-<event>', value: 'handler expression', doc: 'plain event handler, no request; e.g. data-sn-on-click' };
|
|
38
|
+
export const DEBOUNCE_ON_DOC = { name: 'debounce-<event>', value: 'milliseconds', doc: 'delays only that event\'s data-sn-on-<event> handler (it runs with the last event); e.g. data-sn-debounce-input' };
|
|
34
39
|
export const ELEMENTS = [
|
|
35
40
|
{ name: 'sn-resource', attrs: ['name', 'src', 'call', 'each', 'key', 'items', 'total', 'order', 'when', 'paginate', 'page-size', 'size-param', 'cursor', 'direction-param', 'direction-values', 'page-param', 'offset-param', 'type', 'cache'], doc: 'a named, page-wide cache of server data; `cache` (or `cache="10m"`) shows the data from the last page load at once, then revalidates' },
|
|
36
41
|
{ name: 'sn-sync', attrs: ['src'], doc: 'one server event stream' },
|
package/dist/ir.d.ts
CHANGED
|
@@ -134,6 +134,10 @@ export interface ActionDef {
|
|
|
134
134
|
w: string[];
|
|
135
135
|
/** element is a form (serialize, disable in block mode) */
|
|
136
136
|
form: boolean;
|
|
137
|
+
/** `data-sn-confirm`: the question asked before the action runs (none when it is falsy) */
|
|
138
|
+
confirm?: number;
|
|
139
|
+
/** `data-sn-encoding="form"`: the body goes as application/x-www-form-urlencoded */
|
|
140
|
+
enc?: 'form';
|
|
137
141
|
}
|
|
138
142
|
export interface IR {
|
|
139
143
|
v: 1;
|
package/dist/ir.js
CHANGED
|
@@ -35,7 +35,14 @@ export function validateIR(ir, fnCount) {
|
|
|
35
35
|
if (names.has(r.name))
|
|
36
36
|
errs.push(`${at}: duplicate name ${r.name}`);
|
|
37
37
|
names.add(r.name);
|
|
38
|
-
|
|
38
|
+
if (r.call) {
|
|
39
|
+
fn(r.call.args, `${at}.call.args`, false);
|
|
40
|
+
fn(r.call.run, `${at}.call.run`, false);
|
|
41
|
+
if (r.src.length)
|
|
42
|
+
errs.push(`${at}: a call resource has no src`);
|
|
43
|
+
}
|
|
44
|
+
else
|
|
45
|
+
url(r.src, `${at}.src`);
|
|
39
46
|
fn(r.when, `${at}.when`);
|
|
40
47
|
if (r.paginate) {
|
|
41
48
|
if (!['keyset', 'offset', 'append'].includes(r.paginate.mode))
|
|
@@ -109,11 +116,22 @@ export function validateIR(ir, fnCount) {
|
|
|
109
116
|
});
|
|
110
117
|
o.actions.forEach((a, i) => {
|
|
111
118
|
const at = `actions[${i}]`;
|
|
112
|
-
if (!['GET', 'POST', 'PUT', 'PATCH', 'DELETE'].includes(a.method))
|
|
119
|
+
if (!['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'CALL'].includes(a.method))
|
|
113
120
|
errs.push(`${at}: bad method`);
|
|
114
121
|
if (!['block', 'queue', 'latest'].includes(a.concurrency))
|
|
115
122
|
errs.push(`${at}: bad concurrency`);
|
|
116
|
-
|
|
123
|
+
if (a.method === 'CALL') {
|
|
124
|
+
if (!a.call)
|
|
125
|
+
errs.push(`${at}: CALL needs call`);
|
|
126
|
+
else {
|
|
127
|
+
fn(a.call.args, `${at}.call.args`, false);
|
|
128
|
+
fn(a.call.run, `${at}.call.run`, false);
|
|
129
|
+
}
|
|
130
|
+
if (a.url.length)
|
|
131
|
+
errs.push(`${at}: a call action has no url`);
|
|
132
|
+
}
|
|
133
|
+
else
|
|
134
|
+
url(a.url, `${at}.url`);
|
|
117
135
|
for (const k of ['body', 'optimistic', 'then', 'catch'])
|
|
118
136
|
fn(a[k], `${at}.${k}`);
|
|
119
137
|
});
|
package/dist/listbox.js
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/** the input's list: `aria-controls`, else the nearest listbox around it */
|
|
2
|
+
const listOf = (input) => {
|
|
3
|
+
const id = input.getAttribute('aria-controls');
|
|
4
|
+
if (id)
|
|
5
|
+
return input.ownerDocument.getElementById(id);
|
|
6
|
+
for (let p = input.parentElement; p; p = p.parentElement) {
|
|
7
|
+
const list = p.querySelector('[role="listbox"]');
|
|
8
|
+
if (list)
|
|
9
|
+
return list;
|
|
10
|
+
}
|
|
11
|
+
return null;
|
|
12
|
+
};
|
|
13
|
+
const options = (input) => {
|
|
14
|
+
const list = listOf(input);
|
|
15
|
+
if (!list || list.closest('[hidden]'))
|
|
16
|
+
return [];
|
|
17
|
+
return [...list.querySelectorAll('[role="option"]')].filter((o) => !o.closest('[hidden]'));
|
|
18
|
+
};
|
|
19
|
+
const mark = (all, next) => {
|
|
20
|
+
for (const o of all)
|
|
21
|
+
o.setAttribute('aria-selected', String(o == next));
|
|
22
|
+
next?.scrollIntoView?.({ block: 'nearest' });
|
|
23
|
+
};
|
|
24
|
+
const listbox = {
|
|
25
|
+
name: 'listbox',
|
|
26
|
+
install(app) {
|
|
27
|
+
const root = app.root;
|
|
28
|
+
root.addEventListener('keydown', (e) => {
|
|
29
|
+
const input = e.target.closest?.('[role="combobox"]');
|
|
30
|
+
if (!input || e.isComposing)
|
|
31
|
+
return;
|
|
32
|
+
const all = options(input);
|
|
33
|
+
if (!all.length)
|
|
34
|
+
return;
|
|
35
|
+
const at = all.findIndex((o) => o.getAttribute('aria-selected') == 'true');
|
|
36
|
+
const key = e.key;
|
|
37
|
+
if (key == 'ArrowDown' || key == 'ArrowUp') {
|
|
38
|
+
e.preventDefault();
|
|
39
|
+
mark(all, key == 'ArrowDown' ? all[Math.min(at + 1, all.length - 1)] : at > 0 ? all[at - 1] : null);
|
|
40
|
+
}
|
|
41
|
+
else if ((key == 'Enter' && at >= 0) || (key == 'Tab' && (at >= 0 || all.length == 1))) {
|
|
42
|
+
e.preventDefault();
|
|
43
|
+
mark(all, null);
|
|
44
|
+
all[Math.max(at, 0)].click();
|
|
45
|
+
}
|
|
46
|
+
else if (key == 'Escape') {
|
|
47
|
+
e.preventDefault();
|
|
48
|
+
mark(all, null);
|
|
49
|
+
input.dispatchEvent(new CustomEvent('dismiss', { bubbles: true }));
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
root.addEventListener('input', (e) => {
|
|
53
|
+
const input = e.target.closest?.('[role="combobox"]');
|
|
54
|
+
if (input)
|
|
55
|
+
mark(options(input), null);
|
|
56
|
+
});
|
|
57
|
+
root.addEventListener('mousedown', (e) => {
|
|
58
|
+
// only in lists that belong to a combobox: keep the focus in the input
|
|
59
|
+
const option = e.target.closest?.('[role="option"]');
|
|
60
|
+
const list = option?.closest('[role="listbox"]');
|
|
61
|
+
if (list && [...root.querySelectorAll('[role="combobox"]')].some((c) => listOf(c) == list))
|
|
62
|
+
e.preventDefault();
|
|
63
|
+
});
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
export default listbox;
|
package/dist/paginate.js
CHANGED
|
@@ -64,10 +64,16 @@ class PagerImpl {
|
|
|
64
64
|
n = target;
|
|
65
65
|
else if (target === 'first')
|
|
66
66
|
n = 1;
|
|
67
|
-
else if (target === 'prev')
|
|
67
|
+
else if (target === 'prev') {
|
|
68
|
+
if (page <= 1)
|
|
69
|
+
return;
|
|
68
70
|
n = page - 1;
|
|
69
|
-
|
|
71
|
+
}
|
|
72
|
+
else if (target === 'next') {
|
|
73
|
+
if (meta.pages != null && page >= meta.pages)
|
|
74
|
+
return;
|
|
70
75
|
n = page + 1;
|
|
76
|
+
}
|
|
71
77
|
else if (target === 'last') {
|
|
72
78
|
if (meta.pages == null) {
|
|
73
79
|
this.loader.app.warn(`page('${this.loader.def.name}', 'last') needs a known total`);
|
|
@@ -141,8 +147,9 @@ class PagerImpl {
|
|
|
141
147
|
hasNext = full;
|
|
142
148
|
break;
|
|
143
149
|
}
|
|
144
|
-
|
|
145
|
-
|
|
150
|
+
// past the end: nothing to count (`to` would come before `from`)
|
|
151
|
+
from = n && move.to != null ? move.to + 1 : n ? null : 0;
|
|
152
|
+
to = n ? (from != null ? from + n - 1 : null) : 0;
|
|
146
153
|
hasPrev = true;
|
|
147
154
|
hasNext = full;
|
|
148
155
|
break;
|
package/dist/persist.js
CHANGED
|
@@ -47,9 +47,12 @@ const persist = {
|
|
|
47
47
|
};
|
|
48
48
|
function overlaps(a, b) {
|
|
49
49
|
const n = Math.min(a.length, b.length);
|
|
50
|
-
for (let i = 0; i < n; i++)
|
|
50
|
+
for (let i = 0; i < n; i++) {
|
|
51
|
+
if (b[i] === '**')
|
|
52
|
+
return true;
|
|
51
53
|
if (a[i] !== b[i] && a[i] !== '*' && b[i] !== '*')
|
|
52
54
|
return false;
|
|
55
|
+
}
|
|
53
56
|
return true;
|
|
54
57
|
}
|
|
55
58
|
export default persist;
|
package/dist/runtime/app.d.ts
CHANGED
|
@@ -81,6 +81,8 @@ export interface App {
|
|
|
81
81
|
readonly ir: IR;
|
|
82
82
|
readonly fns: Fn[];
|
|
83
83
|
readonly env: Env;
|
|
84
|
+
/** the element or document this instance is mounted on */
|
|
85
|
+
readonly root: Document | Element;
|
|
84
86
|
readonly req: RequestLayer;
|
|
85
87
|
/** the base scope: resources, status, magic names */
|
|
86
88
|
readonly page: any;
|
|
@@ -110,6 +112,8 @@ export interface App {
|
|
|
110
112
|
} | null;
|
|
111
113
|
/** a load (or embedded data) stored `items`: cache them, and the resource's later changes */
|
|
112
114
|
loaded(def: ResourceDef, r: Resource, url: string, items: unknown, total: number | null, appended: boolean): void;
|
|
115
|
+
/** the resource was emptied (`when` is false): nothing to write until the next load */
|
|
116
|
+
cleared(def: ResourceDef): void;
|
|
113
117
|
};
|
|
114
118
|
};
|
|
115
119
|
readonly beforeFlush: Array<(dirty: string[][]) => void>;
|
|
@@ -149,4 +153,9 @@ export declare function withParams(url: string, params: Record<string, string> |
|
|
|
149
153
|
/** Indexes (into `seq`) of a longest increasing subsequence, ignoring -1 entries. */
|
|
150
154
|
export declare function lis(seq: number[]): Set<number>;
|
|
151
155
|
/** Serializes a form's named controls to nested JSON, or FormData if it has a file input. */
|
|
156
|
+
/**
|
|
157
|
+
* A body as form fields (`data-sn-encoding="form"`): a form's own fields, or an object's keys with
|
|
158
|
+
* arrays as repeated keys; null and undefined values are left out. Files stay multipart.
|
|
159
|
+
*/
|
|
160
|
+
export declare function formBody(body: unknown): URLSearchParams | FormData | null;
|
|
152
161
|
export declare function serialize(form: HTMLFormElement): unknown;
|
package/dist/runtime/app.js
CHANGED
|
@@ -55,8 +55,10 @@ export function mount(root, o) {
|
|
|
55
55
|
const types = new Set(), selects = new Set(), deferred = new Set();
|
|
56
56
|
const listeners = new Map(), cleanups = [];
|
|
57
57
|
const shared = new Map();
|
|
58
|
+
/** `showModal(id)`, `close(id)`: the dialog of this mount root with that id */
|
|
59
|
+
const dialog = (id) => [...root.querySelectorAll('dialog')].find((d) => d.id === id);
|
|
58
60
|
const rootRbs = [], rootScopes = [], found = new Map();
|
|
59
|
-
let dirty = [], cur = [], scheduled = false, flushing = false, round = 0;
|
|
61
|
+
let dirty = [], cur = [], scheduled = false, flushing = false, round = 0, destroyed = false;
|
|
60
62
|
const res = (name) => {
|
|
61
63
|
const r = resources.get(name);
|
|
62
64
|
if (!r)
|
|
@@ -90,7 +92,12 @@ export function mount(root, o) {
|
|
|
90
92
|
}
|
|
91
93
|
}
|
|
92
94
|
/** a failed call: SinterErrors as they are, anything else with status null */
|
|
93
|
-
const callError = (e) =>
|
|
95
|
+
const callError = (e) => {
|
|
96
|
+
if (e instanceof SinterError)
|
|
97
|
+
return e;
|
|
98
|
+
const detail = (e instanceof Error ? e.message : typeof e == 'string' ? e : '') || null;
|
|
99
|
+
return new SinterError(null, e, detail ?? String(e), detail);
|
|
100
|
+
};
|
|
94
101
|
/** Per flush round: how dirty paths reach a template's rows (0 not, 1 only marked elements, 2 any row). */
|
|
95
102
|
let rowHits = new Map();
|
|
96
103
|
/**
|
|
@@ -98,16 +105,19 @@ export function mount(root, o) {
|
|
|
98
105
|
* absent) and dirty path `d` are prefixes of each other; `*` matches any segment. A change
|
|
99
106
|
* that ends above a row's own element, or only changes the list structure (`#`), does not
|
|
100
107
|
* reach it: reconcile re-evaluates rows whose item or index changed. `~` reaches only rows
|
|
101
|
-
* whose element was marked.
|
|
108
|
+
* whose element was marked. A path ending in `**` (an island's `update()`) reaches everything
|
|
109
|
+
* under its prefix, at any depth.
|
|
102
110
|
*/
|
|
103
111
|
const overlaps = (dep, d, s) => reach(dep, d, s) > 0;
|
|
104
112
|
/** 0: no overlap; 1: only through a marked element (`~`) of the row; 2: overlap. */
|
|
105
113
|
function reach({ p, n, a, t }, d, s) {
|
|
106
|
-
if (a >= 0 && d.length <= a)
|
|
114
|
+
if (a >= 0 && d.length <= a && d[d.length - 1] != '**')
|
|
107
115
|
return 0;
|
|
108
116
|
let via = 2;
|
|
109
117
|
for (let i = 0, len = Math.min(p.length, d.length); i < len; i++) {
|
|
110
118
|
const y = d[i];
|
|
119
|
+
if (y == '**')
|
|
120
|
+
return 2;
|
|
111
121
|
// a list's structure dependency: reached by changes to the list itself or a whole element, not element fields
|
|
112
122
|
if (p[i] == '#')
|
|
113
123
|
return d.length <= i + 1 ? 2 : 0;
|
|
@@ -142,7 +152,7 @@ export function mount(root, o) {
|
|
|
142
152
|
return false;
|
|
143
153
|
};
|
|
144
154
|
const app = {
|
|
145
|
-
ir, fns, env, req, transport: o.transport ?? (() => undefined), ext: {},
|
|
155
|
+
ir, fns, env, req, root, transport: o.transport ?? (() => undefined), ext: {},
|
|
146
156
|
beforeFlush: [], afterFlush: [], onListen: [], states: [], busy: 0,
|
|
147
157
|
get isDirty() { return dirty.length > 0 || scheduled; },
|
|
148
158
|
dev: false,
|
|
@@ -151,7 +161,14 @@ export function mount(root, o) {
|
|
|
151
161
|
$r: {}, $meta: {}, $url: readUrl(), $now: new Date(env.now()),
|
|
152
162
|
$m: (el, v) => (marked.add(el), v),
|
|
153
163
|
get $locale() { return config.locale?.get(); },
|
|
154
|
-
set $locale(v) {
|
|
164
|
+
set $locale(v) {
|
|
165
|
+
config.locale?.set(v);
|
|
166
|
+
app.mark('$locale');
|
|
167
|
+
// ordered lists sort strings by locale
|
|
168
|
+
for (const def of ir.resources)
|
|
169
|
+
if (def.order)
|
|
170
|
+
app.mark('$r.' + def.name);
|
|
171
|
+
},
|
|
155
172
|
},
|
|
156
173
|
resource: res,
|
|
157
174
|
request: (url, init) => req.send(url, init),
|
|
@@ -175,12 +192,14 @@ export function mount(root, o) {
|
|
|
175
192
|
state(el) {
|
|
176
193
|
for (let e = el; e; e = e.parentElement) {
|
|
177
194
|
const s = e[STATE];
|
|
195
|
+
// `**`: the island may have changed anything under the state, rows included
|
|
178
196
|
if (s)
|
|
179
|
-
return { get: () => s[1], update: (fn) => { fn(s[1]); app.mark('$s' + s[0]); } };
|
|
197
|
+
return { get: () => s[1], update: (fn) => { fn(s[1]); app.mark('$s' + s[0] + '.**'); } };
|
|
180
198
|
}
|
|
181
199
|
throw Error('sinter: no data-sn-state scope');
|
|
182
200
|
},
|
|
183
201
|
destroy() {
|
|
202
|
+
destroyed = true;
|
|
184
203
|
for (const c of cleanups.splice(0))
|
|
185
204
|
c();
|
|
186
205
|
instances.delete(root);
|
|
@@ -267,12 +286,14 @@ export function mount(root, o) {
|
|
|
267
286
|
} };
|
|
268
287
|
},
|
|
269
288
|
storeFns: (el) => ({
|
|
289
|
+
showModal: (id) => { const d = dialog(id); if (d && !d.open)
|
|
290
|
+
d.showModal(); },
|
|
270
291
|
refresh: (n) => res(n).refetch(),
|
|
271
292
|
upsert: forward('upsert'),
|
|
272
293
|
patch: forward('patch'),
|
|
273
294
|
remove: forward('remove'),
|
|
274
295
|
page: (n, t) => app.ext.paginate.page(n, t),
|
|
275
|
-
close: () => el?.closest('dialog')?.close(),
|
|
296
|
+
close: (id) => (id ? dialog(id) : el?.closest('dialog'))?.close(),
|
|
276
297
|
reset: () => {
|
|
277
298
|
const form = el?.closest('form');
|
|
278
299
|
if (!form)
|
|
@@ -295,7 +316,8 @@ export function mount(root, o) {
|
|
|
295
316
|
console.error(e);
|
|
296
317
|
}
|
|
297
318
|
},
|
|
298
|
-
|
|
319
|
+
// after destroy(), nothing waits for the next one: run it now
|
|
320
|
+
addCleanup: (fn) => void (destroyed ? fn() : cleanups.push(fn)),
|
|
299
321
|
};
|
|
300
322
|
app.page.$f = app.storeFns(null);
|
|
301
323
|
instances.set(root, app);
|
|
@@ -892,7 +914,9 @@ export function mount(root, o) {
|
|
|
892
914
|
app.busy--;
|
|
893
915
|
}
|
|
894
916
|
app.busy++;
|
|
895
|
-
|
|
917
|
+
// the element may have left the page (its row removed) while the handler waited
|
|
918
|
+
timers.set(rb, env.setTimeout(() => { timers.delete(rb); app.busy--; if (rb.el.isConnected)
|
|
919
|
+
handle(rb, rb.b.f, e, rb.b.w); }, rb.b.ms, 'debounce'));
|
|
896
920
|
}
|
|
897
921
|
// ---- actions
|
|
898
922
|
function trigger(rb, e) {
|
|
@@ -926,27 +950,43 @@ export function mount(root, o) {
|
|
|
926
950
|
body = fns[a.call.args](s);
|
|
927
951
|
else {
|
|
928
952
|
url = app.url(a.url, s);
|
|
929
|
-
|
|
953
|
+
const fields = a.form && !get && a.method != 'DELETE';
|
|
954
|
+
// form encoding: the fields as the browser would submit them (FormData), then as one of its bodies
|
|
955
|
+
body = a.body != null ? fns[a.body](s) : fields ? (a.enc == 'form' ? new FormData(rb.el) : serialize(rb.el)) : undefined;
|
|
956
|
+
if (a.enc == 'form')
|
|
957
|
+
body = formBody(body);
|
|
930
958
|
}
|
|
931
959
|
}
|
|
932
960
|
catch (err) {
|
|
933
961
|
return unhandled(toError(err), rb.el);
|
|
934
962
|
}
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
else if (a.concurrency == 'latest') {
|
|
944
|
-
st.w ? st.w.drop() : app.busy++;
|
|
945
|
-
st.w = { run, drop };
|
|
963
|
+
// a question first: the action runs only when the user agrees
|
|
964
|
+
const question = a.confirm != null ? fns[a.confirm](s) : null;
|
|
965
|
+
if (question) {
|
|
966
|
+
const yes = config.confirm(String(question));
|
|
967
|
+
if (yes instanceof Promise)
|
|
968
|
+
return void yes.then((y) => y && go());
|
|
969
|
+
if (!yes)
|
|
970
|
+
return;
|
|
946
971
|
}
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
972
|
+
go();
|
|
973
|
+
function go() {
|
|
974
|
+
const drop = a.optimistic != null ? app.ext.optimistic.begin(a.optimistic, s) : () => { };
|
|
975
|
+
if (get) {
|
|
976
|
+
st.c?.abort();
|
|
977
|
+
return void send(rb, a, st, url, body, drop, (st.c = new AbortController()));
|
|
978
|
+
}
|
|
979
|
+
const run = () => void send(rb, a, st, url, body, drop);
|
|
980
|
+
if (!st.n)
|
|
981
|
+
run();
|
|
982
|
+
else if (a.concurrency == 'latest') {
|
|
983
|
+
st.w ? st.w.drop() : app.busy++;
|
|
984
|
+
st.w = { run, drop };
|
|
985
|
+
}
|
|
986
|
+
else {
|
|
987
|
+
app.busy++;
|
|
988
|
+
st.q.push(run);
|
|
989
|
+
}
|
|
950
990
|
}
|
|
951
991
|
}
|
|
952
992
|
async function send(rb, a, st, url, body, drop, c) {
|
|
@@ -1127,6 +1167,8 @@ export function mount(root, o) {
|
|
|
1127
1167
|
return void l.load();
|
|
1128
1168
|
c?.abort();
|
|
1129
1169
|
r.replace(r.isCollection ? [] : null);
|
|
1170
|
+
if (def.cache != null)
|
|
1171
|
+
app.ext.cache?.cleared(def);
|
|
1130
1172
|
loading(false);
|
|
1131
1173
|
},
|
|
1132
1174
|
/** Repeats the current request; several calls in one task share one request. */
|
|
@@ -1204,7 +1246,7 @@ export function mount(root, o) {
|
|
|
1204
1246
|
export function withParams(url, params) {
|
|
1205
1247
|
if (!params)
|
|
1206
1248
|
return url;
|
|
1207
|
-
const [path, hash] = url.split('#'),
|
|
1249
|
+
const [path, hash] = url.split('#'), i = path.indexOf('?'), base = i < 0 ? path : path.slice(0, i), q = new URLSearchParams(i < 0 ? '' : path.slice(i + 1));
|
|
1208
1250
|
for (const k in params)
|
|
1209
1251
|
q.set(k, params[k]);
|
|
1210
1252
|
return `${base}?${q}${hash ? '#' + hash : ''}`;
|
|
@@ -1229,6 +1271,32 @@ export function lis(seq) {
|
|
|
1229
1271
|
return keep;
|
|
1230
1272
|
}
|
|
1231
1273
|
/** Serializes a form's named controls to nested JSON, or FormData if it has a file input. */
|
|
1274
|
+
/**
|
|
1275
|
+
* A body as form fields (`data-sn-encoding="form"`): a form's own fields, or an object's keys with
|
|
1276
|
+
* arrays as repeated keys; null and undefined values are left out. Files stay multipart.
|
|
1277
|
+
*/
|
|
1278
|
+
export function formBody(body) {
|
|
1279
|
+
if (body == null)
|
|
1280
|
+
return null;
|
|
1281
|
+
if (body instanceof FormData) {
|
|
1282
|
+
const out = new URLSearchParams();
|
|
1283
|
+
for (const [k, v] of body) {
|
|
1284
|
+
if (typeof v != 'string')
|
|
1285
|
+
return body;
|
|
1286
|
+
out.append(k, v);
|
|
1287
|
+
}
|
|
1288
|
+
return out;
|
|
1289
|
+
}
|
|
1290
|
+
if (body instanceof URLSearchParams)
|
|
1291
|
+
return body;
|
|
1292
|
+
const out = new URLSearchParams();
|
|
1293
|
+
for (const [k, v] of Object.entries(body)) {
|
|
1294
|
+
for (const x of Array.isArray(v) ? v : [v])
|
|
1295
|
+
if (x != null)
|
|
1296
|
+
out.append(k, typeof x == 'object' ? JSON.stringify(x) : String(x));
|
|
1297
|
+
}
|
|
1298
|
+
return out;
|
|
1299
|
+
}
|
|
1232
1300
|
export function serialize(form) {
|
|
1233
1301
|
const controls = [...form.elements];
|
|
1234
1302
|
if (controls.some((c) => c.type == 'file'))
|
|
@@ -1243,7 +1311,8 @@ export function serialize(form) {
|
|
|
1243
1311
|
continue;
|
|
1244
1312
|
const k = segs.pop();
|
|
1245
1313
|
let o = out;
|
|
1246
|
-
|
|
1314
|
+
// a container at each step; a scalar written there earlier (`a`, then `a.b`) is replaced
|
|
1315
|
+
segs.forEach((s, i) => (o = typeof o[s] == 'object' && o[s] ? o[s] : (o[s] = /^\d+$/.test(segs[i + 1] ?? k) ? [] : Object.create(null))));
|
|
1247
1316
|
if (type == 'checkbox' && boxes[name] > 1)
|
|
1248
1317
|
(o[k] ??= []).push(...(c.checked ? [c.value] : []));
|
|
1249
1318
|
else
|
package/dist/runtime/config.d.ts
CHANGED
|
@@ -13,6 +13,8 @@ export interface Config {
|
|
|
13
13
|
origins: string[];
|
|
14
14
|
/** the transport for a `<sn-sync>` stream URL; `undefined` (or no function) means EventSource */
|
|
15
15
|
transport: ((url: string) => Transport | undefined) | null;
|
|
16
|
+
/** `data-sn-confirm`: asks the question; the action runs on true (window.confirm by default) */
|
|
17
|
+
confirm: (question: string) => boolean | Promise<boolean>;
|
|
16
18
|
env: Env;
|
|
17
19
|
}
|
|
18
20
|
export declare const config: Config;
|
|
@@ -23,7 +25,12 @@ export declare function configure(options: Partial<Omit<Config, 'env'>> & {
|
|
|
23
25
|
export declare class SinterError extends Error {
|
|
24
26
|
readonly status: number | null;
|
|
25
27
|
readonly body: unknown;
|
|
26
|
-
|
|
28
|
+
readonly detail: string | null;
|
|
29
|
+
/**
|
|
30
|
+
* @param detail what went wrong in words: the server's message or text, or a thrown error's
|
|
31
|
+
* message; null when there is none (`message` then falls back to the HTTP status)
|
|
32
|
+
*/
|
|
33
|
+
constructor(status: number | null, body: unknown, message: string, detail?: string | null);
|
|
27
34
|
}
|
|
28
35
|
/** page-load cache entries (`cache` on `<sn-resource>`), in sessionStorage */
|
|
29
36
|
export declare const CACHE_PREFIX = "sn:c:";
|