@tiaforge-ui/sinter 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/README.md +39 -12
  2. package/dist/check/index.d.ts +4 -1
  3. package/dist/check/index.js +9 -8
  4. package/dist/check/shadow.d.ts +3 -1
  5. package/dist/check/shadow.js +53 -5
  6. package/dist/cli.js +2 -2
  7. package/dist/compiler/check-unit.d.ts +26 -2
  8. package/dist/compiler/compile.d.ts +4 -0
  9. package/dist/compiler/compile.js +314 -30
  10. package/dist/compiler/diagnostic-docs.js +20 -5
  11. package/dist/compiler/diagnostics.d.ts +3 -0
  12. package/dist/compiler/diagnostics.js +3 -0
  13. package/dist/compiler/expr.d.ts +24 -0
  14. package/dist/compiler/expr.js +53 -8
  15. package/dist/compiler/index.d.ts +3 -2
  16. package/dist/compiler/index.js +70 -16
  17. package/dist/compiler/vocabulary.d.ts +1 -0
  18. package/dist/compiler/vocabulary.js +9 -3
  19. package/dist/each.d.ts +3 -0
  20. package/dist/each.js +121 -0
  21. package/dist/index.d.ts +1 -1
  22. package/dist/ir.d.ts +30 -1
  23. package/dist/listbox.d.ts +3 -0
  24. package/dist/listbox.js +66 -0
  25. package/dist/lsp/typescript.js +1 -1
  26. package/dist/lsp/workspace.js +3 -2
  27. package/dist/paginate.js +18 -4
  28. package/dist/project.d.ts +6 -0
  29. package/dist/project.js +22 -19
  30. package/dist/runtime/app.d.ts +35 -1
  31. package/dist/runtime/app.js +178 -40
  32. package/dist/runtime/config.d.ts +10 -3
  33. package/dist/runtime/config.js +10 -3
  34. package/dist/runtime/request.d.ts +1 -1
  35. package/dist/runtime/request.js +6 -1
  36. package/dist/runtime/store.d.ts +7 -4
  37. package/dist/runtime/store.js +154 -41
  38. package/dist/sse.js +8 -5
  39. package/dist/testing/index.d.ts +2 -0
  40. package/dist/testing/index.js +17 -2
  41. package/dist/vite.js +4 -2
  42. package/docs/diagnostics.md +35 -5
  43. package/html-custom-data.json +46 -2
  44. package/llms.txt +112 -6
  45. package/package.json +6 -1
package/dist/ir.d.ts CHANGED
@@ -8,7 +8,11 @@ export interface Paginate {
8
8
  /** keyset/append: object literal evaluated with the boundary item as `$.$p` */
9
9
  cursor: number | null;
10
10
  directionParam: string;
11
+ /** keyset/append: the value sent for each direction, where it differs from the direction's name */
12
+ directionValues?: Partial<Record<'first' | 'prev' | 'next' | 'last', string>>;
11
13
  pageParam: string;
14
+ /** keyset: offset parameter that lets `page(name, n)` jump; the server skips (n - 1) * size items */
15
+ offsetParam?: string;
12
16
  }
13
17
  export interface ResourceDef {
14
18
  name: string;
@@ -29,6 +33,14 @@ export interface ResourceDef {
29
33
  data: number | null;
30
34
  /** `cache`: the oldest cached data to show, in ms (0: any age); absent when not cached */
31
35
  cache?: number;
36
+ /** `call="ns.fn(args)"`: loaded from an action function instead of `src` (then empty) */
37
+ call?: CallDef;
38
+ /** `each="a in accounts"`: one request per list item (src evaluated with `item` in scope), merged into one collection */
39
+ each?: {
40
+ item: string;
41
+ list: number;
42
+ of?: string;
43
+ };
32
44
  }
33
45
  export interface EventDef {
34
46
  name: string;
@@ -42,6 +54,8 @@ export interface EventDef {
42
54
  export interface SyncDef {
43
55
  src: UrlTemplate;
44
56
  events: EventDef[];
57
+ /** index of the node (in `roots`) whose scope `src` and the events' expressions are evaluated in, -1 (or absent) for page scope */
58
+ scope?: number;
45
59
  }
46
60
  export type BindingKind = 'text' | 'attr' | 'class' | 'style' | 'show' | 'model' | 'prop' | 'for' | 'if' | 'else' | 'on';
47
61
  export interface Binding {
@@ -66,6 +80,8 @@ export interface Binding {
66
80
  e?: 1;
67
81
  /** the expression reads a loop index, so it re-runs when only the row's position changed */
68
82
  i?: 1;
83
+ /** handler on an element without a method: `data-sn-debounce` milliseconds */
84
+ ms?: number;
69
85
  }
70
86
  export interface NodeDef {
71
87
  /** `data-sn` id (roots) */
@@ -80,6 +96,8 @@ export interface NodeDef {
80
96
  sid?: number;
81
97
  /** persist key for the whole state object */
82
98
  persist?: string;
99
+ /** the element is a `<dialog>` or inside one of its block, so its bindings wait while that dialog is closed */
100
+ dl?: 1;
83
101
  b: Binding[];
84
102
  }
85
103
  export interface TemplateDef {
@@ -95,9 +113,16 @@ export interface TemplateDef {
95
113
  /** for: function that finds the bound elements of one cloned row: (first content node) => elements, in `nodes` order */
96
114
  find?: number;
97
115
  }
116
+ /** a local action call: `args` evaluates the arguments (an array), `run(args, ctx)` calls the function */
117
+ export interface CallDef {
118
+ args: number;
119
+ run: number;
120
+ }
98
121
  export interface ActionDef {
99
- method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
122
+ /** `CALL`: `data-sn-call`, with `call` set and an empty `url` */
123
+ method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'CALL';
100
124
  url: UrlTemplate;
125
+ call?: CallDef;
101
126
  body: number | null;
102
127
  trigger: string;
103
128
  debounce: number;
@@ -109,6 +134,10 @@ export interface ActionDef {
109
134
  w: string[];
110
135
  /** element is a form (serialize, disable in block mode) */
111
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';
112
141
  }
113
142
  export interface IR {
114
143
  v: 1;
@@ -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;
@@ -59,7 +59,7 @@ export class TypeScriptBridge {
59
59
  this.filesChanged([support.declarations, ...support.openapi]);
60
60
  this.support = support;
61
61
  for (const [page, unit] of units) {
62
- const s = shadowOf(unit, support, this.root, project.helpers);
62
+ const s = shadowOf(unit, support, this.root, project.helpers, project.actions);
63
63
  const prev = this.shadows.get(page);
64
64
  if (prev?.text === s.text) {
65
65
  prev.unit = unit;
@@ -48,13 +48,14 @@ export class Workspace {
48
48
  }
49
49
  const p = project(root, options);
50
50
  const inputs = new Set(VITE_CONFIGS.map((c) => join(root, c)));
51
- for (const f of [p.helpersFile, p.types, ...p.openapi.map((s) => s.file)])
51
+ for (const f of [p.helpersFile, p.actionsFile, p.types, ...p.openapi.map((s) => s.file)])
52
52
  if (f)
53
53
  inputs.add(f);
54
54
  for (const spec of options.openapi == null ? [] : [].concat(options.openapi))
55
55
  inputs.add(resolve(root, spec));
56
56
  for (const ext of ['.js', '.ts']) {
57
57
  inputs.add(join(root, 'src/sinter.helpers' + ext));
58
+ inputs.add(join(root, 'src/sinter.actions' + ext));
58
59
  inputs.add(join(root, 'src/sinter.types' + ext));
59
60
  }
60
61
  inputs.add(join(root, 'src/sinter.types.d.ts'));
@@ -166,7 +167,7 @@ export class Workspace {
166
167
  }
167
168
  try {
168
169
  const out = compile(html, {
169
- file: page, root: state.root, dev: false, helpers: p.helpers, config: p.config, readFile, check: true, openapi: p.openapi,
170
+ file: page, root: state.root, dev: false, helpers: p.helpers, actions: p.actions, config: p.config, readFile, check: true, openapi: p.openapi,
170
171
  });
171
172
  state.includes.set(page, out.files.map((f) => resolve(f)).filter((f) => f !== page));
172
173
  state.diagnostics.set(page, out.diagnostics);
package/dist/paginate.js CHANGED
@@ -7,14 +7,14 @@ class PagerImpl {
7
7
  this.cfg = loader.def.paginate;
8
8
  this.move = this.first();
9
9
  const m = loader.res.meta;
10
- if (this.cfg.mode === 'offset')
10
+ if (this.cfg.mode === 'offset' || this.cfg.offsetParam)
11
11
  m.page = 1;
12
12
  Object.assign(m, { pages: null, from: null, to: null, hasPrev: false, hasNext: false });
13
13
  }
14
14
  base(dir, size = this.cfg.size) {
15
15
  const p = { [this.cfg.sizeParam]: String(size) };
16
16
  if (dir)
17
- p[this.cfg.directionParam] = dir;
17
+ p[this.cfg.directionParam] = this.cfg.directionValues?.[dir] ?? dir;
18
18
  return p;
19
19
  }
20
20
  first() {
@@ -49,8 +49,10 @@ class PagerImpl {
49
49
  scope.$p = item;
50
50
  const c = this.loader.app.fns[this.cfg.cursor](scope);
51
51
  const out = {};
52
+ // like a URL template's query parameters: null and undefined fields are left out
52
53
  for (const [k, v] of Object.entries(c ?? {}))
53
- out[k] = v == null ? '' : String(v);
54
+ if (v != null)
55
+ out[k] = String(v);
54
56
  return out;
55
57
  };
56
58
  let move;
@@ -80,7 +82,16 @@ class PagerImpl {
80
82
  }
81
83
  else {
82
84
  if (typeof target === 'number') {
83
- this.loader.app.warn('keyset pagination cannot jump to a page number; use paginate="offset"');
85
+ if (!this.cfg.offsetParam) {
86
+ this.loader.app.warn('keyset pagination cannot jump to a page number; add offset-param or use paginate="offset"');
87
+ return;
88
+ }
89
+ // the server skips (n - 1) pages; prev/next continue by cursor from there
90
+ let n = Math.max(1, Math.floor(target));
91
+ if (meta.pages != null)
92
+ n = Math.min(n, meta.pages);
93
+ move = { dir: 'page', page: n, params: { [this.cfg.offsetParam]: String((n - 1) * this.cfg.size), ...this.base('first') }, from, to };
94
+ void this.loader.load(move);
84
95
  return;
85
96
  }
86
97
  if (this.cfg.mode === 'append' && target !== 'next')
@@ -158,6 +169,9 @@ class PagerImpl {
158
169
  hasPrev = full;
159
170
  break;
160
171
  }
172
+ // keyset with jumps: the page that holds the last item shown (after `last`, the last page)
173
+ if (this.cfg.offsetParam && move.dir !== 'page')
174
+ meta.page = to != null ? Math.max(1, Math.ceil(to / size)) : null;
161
175
  Object.assign(meta, { pages, from, to, hasPrev, hasNext });
162
176
  this.loader.res.onDirty('meta');
163
177
  }
package/dist/project.d.ts CHANGED
@@ -3,6 +3,8 @@ import type { OpenApiSpec } from './compiler/openapi.js';
3
3
  export interface ProjectOptions {
4
4
  /** helpers module, relative to the root (default `src/sinter.helpers.js`, or `.ts`) */
5
5
  helpers?: string;
6
+ /** actions module for `data-sn-call` and `<sn-resource call>` (default `src/sinter.actions.js`, or `.ts`) */
7
+ actions?: string;
6
8
  /** app configuration module imported before mount (default `src/sinter.config.js`, or `.ts`) */
7
9
  config?: string;
8
10
  /** module whose exported types `type="…"` on `<sn-resource>` may name (default `src/sinter.types.ts`, `.d.ts` or `.js`) */
@@ -15,6 +17,10 @@ export interface Project {
15
17
  helpers: HelperManifest;
16
18
  /** absolute path of the helpers module, or null */
17
19
  helpersFile: string | null;
20
+ /** the actions module's functions and namespaces (same shape as helpers) */
21
+ actions: HelperManifest;
22
+ /** absolute path of the actions module, or null */
23
+ actionsFile: string | null;
18
24
  /** root-relative URL of the config module (`/src/sinter.config.js`), or null */
19
25
  config: string | null;
20
26
  /** absolute path of the types module, or null */
package/dist/project.js CHANGED
@@ -20,25 +20,28 @@ export function project(root, options = {}) {
20
20
  return readFileSync(real, 'utf8');
21
21
  };
22
22
  const url = (rel) => '/' + rel.split(sep).join('/');
23
- let helpers = { module: null, names: [], namespaces: {} };
24
- const helpersRel = find(options.helpers, 'src/sinter.helpers');
25
- const helpersFile = helpersRel ? resolve(root, helpersRel) : null;
26
- if (helpersRel && helpersFile) {
27
- helpers = readHelperManifest(readFileSync(helpersFile, 'utf8'), {
28
- module: url(helpersRel),
29
- file: helpersFile,
30
- readFile,
31
- resolve: (spec, from) => {
32
- if (!spec.startsWith('.'))
23
+ const manifest = (given, base) => {
24
+ const rel = find(given, base);
25
+ const file = rel ? resolve(root, rel) : null;
26
+ if (!rel || !file)
27
+ return [{ module: null, names: [], namespaces: {} }, null];
28
+ return [readHelperManifest(readFileSync(file, 'utf8'), {
29
+ module: url(rel),
30
+ file,
31
+ readFile,
32
+ resolve: (spec, from) => {
33
+ if (!spec.startsWith('.'))
34
+ return null;
35
+ const b = resolve(dirname(from), spec);
36
+ for (const q of [b, b.replace(/\.js$/, '.ts'), `${b}.js`, `${b}.ts`])
37
+ if (existsSync(q))
38
+ return q;
33
39
  return null;
34
- const base = resolve(dirname(from), spec);
35
- for (const p of [base, base.replace(/\.js$/, '.ts'), `${base}.js`, `${base}.ts`])
36
- if (existsSync(p))
37
- return p;
38
- return null;
39
- },
40
- });
41
- }
40
+ },
41
+ }), file];
42
+ };
43
+ const [helpers, helpersFile] = manifest(options.helpers, 'src/sinter.helpers');
44
+ const [actions, actionsFile] = manifest(options.actions, 'src/sinter.actions');
42
45
  const configRel = find(options.config, 'src/sinter.config');
43
46
  const typesRel = find(options.types, 'src/sinter.types', ['.ts', '.d.ts', '.js']);
44
47
  const openapi = [];
@@ -70,7 +73,7 @@ export function project(root, options = {}) {
70
73
  openapi.push({ file, doc });
71
74
  }
72
75
  return {
73
- root, helpers, helpersFile, readFile, openapi, diagnostics,
76
+ root, helpers, helpersFile, actions, actionsFile, readFile, openapi, diagnostics,
74
77
  config: configRel ? url(configRel) : null,
75
78
  types: typesRel ? resolve(root, typesRel) : null,
76
79
  };
@@ -47,18 +47,42 @@ export interface Pager {
47
47
  appends(move: unknown): boolean;
48
48
  received(items: unknown[], total: number | null, move: unknown): void;
49
49
  }
50
+ /** The last argument of an action function (`data-sn-call`, `<sn-resource call>`). */
51
+ export interface CallContext {
52
+ /** aborts when the page is hidden (pagehide) or, for a resource, when its inputs change */
53
+ signal: AbortSignal;
54
+ /** a request through the app's middleware chain (401 handling, origins, timeout) */
55
+ request(url: string, init?: SendInit): Promise<unknown>;
56
+ /** the element whose action this is; null for a resource */
57
+ el: Element | null;
58
+ }
50
59
  export interface ResourceLoader {
51
60
  def: ResourceDef;
52
61
  res: Resource;
53
62
  app: App;
54
63
  pager: Pager | null;
64
+ /** first load (or embedded/cached data) */
65
+ start(): void;
66
+ /** `src`, `when` (or an `each` list) may have changed */
67
+ changed(): void;
55
68
  load(move?: unknown): Promise<void>;
56
- refetch(): Promise<void>;
69
+ refetch(hint?: object): Promise<void>;
70
+ /** for feature modules: the scope `src` and `when` are evaluated in */
71
+ scope(): any;
72
+ /** for feature modules: a shared GET (concurrent loads of one URL share one request) */
73
+ get(url: string, signal: AbortSignal): Promise<unknown>;
74
+ /** for feature modules: the collection items of a response body (`items` path, checked, cleaned) */
75
+ items(body: unknown): any[];
76
+ /** for feature modules: a failed load: `$meta.error`, then the unhandled-error path */
77
+ failed(e: unknown): void;
78
+ loading(on: boolean): void;
57
79
  }
58
80
  export interface App {
59
81
  readonly ir: IR;
60
82
  readonly fns: Fn[];
61
83
  readonly env: Env;
84
+ /** the element or document this instance is mounted on */
85
+ readonly root: Document | Element;
62
86
  readonly req: RequestLayer;
63
87
  /** the base scope: resources, status, magic names */
64
88
  readonly page: any;
@@ -67,6 +91,9 @@ export interface App {
67
91
  optimistic?: {
68
92
  begin(fn: number, scope: any): () => void;
69
93
  };
94
+ each?: {
95
+ fanOut(loader: ResourceLoader): void;
96
+ };
70
97
  paginate?: {
71
98
  pager(loader: ResourceLoader): Pager;
72
99
  page(name: string, target: string | number): void;
@@ -108,6 +135,8 @@ export interface App {
108
135
  state<T = any>(el: Element): StateHandle<T>;
109
136
  destroy(): void;
110
137
  url(tpl: UrlTemplate, scope: any): string;
138
+ /** the scope of root node `i` (its state), or the page scope for -1 */
139
+ scopeOf(i: number): any;
111
140
  mark(path: string, scope?: any): void;
112
141
  flush(): void;
113
142
  record(change: () => void): () => void;
@@ -122,4 +151,9 @@ export declare function withParams(url: string, params: Record<string, string> |
122
151
  /** Indexes (into `seq`) of a longest increasing subsequence, ignoring -1 entries. */
123
152
  export declare function lis(seq: number[]): Set<number>;
124
153
  /** Serializes a form's named controls to nested JSON, or FormData if it has a file input. */
154
+ /**
155
+ * A body as form fields (`data-sn-encoding="form"`): a form's own fields, or an object's keys with
156
+ * arrays as repeated keys; null and undefined values are left out. Files stay multipart.
157
+ */
158
+ export declare function formBody(body: unknown): URLSearchParams | FormData | null;
125
159
  export declare function serialize(form: HTMLFormElement): unknown;