@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.
@@ -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));
@@ -5,6 +5,7 @@ export interface AttrDoc {
5
5
  }
6
6
  export declare const ATTRIBUTES: AttrDoc[];
7
7
  export declare const ON_DOC: AttrDoc;
8
+ export declare const DEBOUNCE_ON_DOC: AttrDoc;
8
9
  export declare const ELEMENTS: Array<{
9
10
  name: string;
10
11
  attrs: string[];
@@ -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
- url(r.src, `${at}.src`);
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
- url(a.url, `${at}.url`);
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
  });
@@ -0,0 +1,3 @@
1
+ import type { Feature } from './runtime/app.js';
2
+ declare const listbox: Feature;
3
+ export default listbox;
@@ -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
- else if (target === 'next')
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
- from = move.to != null ? move.to + 1 : null;
145
- to = from != null ? from + n - 1 : null;
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;
@@ -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;
@@ -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) => (e instanceof SinterError ? e : new SinterError(null, e, e?.message || String(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) { config.locale?.set(v); app.mark('$locale'); },
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
- addCleanup: (fn) => void cleanups.push(fn),
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
- timers.set(rb, env.setTimeout(() => { timers.delete(rb); app.busy--; handle(rb, rb.b.f, e, rb.b.w); }, rb.b.ms, 'debounce'));
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
- body = a.body != null ? fns[a.body](s) : a.form && !get && a.method != 'DELETE' ? serialize(rb.el) : undefined;
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
- const drop = a.optimistic != null ? app.ext.optimistic.begin(a.optimistic, s) : () => { };
936
- if (get) {
937
- st.c?.abort();
938
- return void send(rb, a, st, url, body, drop, (st.c = new AbortController()));
939
- }
940
- const run = () => void send(rb, a, st, url, body, drop);
941
- if (!st.n)
942
- run();
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
- else {
948
- app.busy++;
949
- st.q.push(run);
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('#'), [base, query] = path.split('?'), q = new URLSearchParams(query);
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
- segs.forEach((s, i) => (o = o[s] ??= /^\d+$/.test(segs[i + 1] ?? k) ? [] : Object.create(null)));
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
@@ -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
- constructor(status: number | null, body: unknown, message: string);
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:";