@ashley-shrok/viewmodel-shell 0.3.10 → 0.3.11

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.
@@ -0,0 +1,24 @@
1
+ import type { ViewNode, ActionEvent, Adapter } from "./index.js";
2
+ export declare class BrowserAdapter implements Adapter {
3
+ private container;
4
+ private fileRegistry;
5
+ constructor(container: HTMLElement);
6
+ render(vm: ViewNode, onAction: (action: ActionEvent) => void): void;
7
+ private node;
8
+ private kids;
9
+ private page;
10
+ private section;
11
+ private list;
12
+ private listItem;
13
+ private form;
14
+ private field;
15
+ private checkbox;
16
+ private button;
17
+ private text;
18
+ private link;
19
+ private statBar;
20
+ private tabs;
21
+ private progress;
22
+ private modal;
23
+ private table;
24
+ }
@@ -0,0 +1,540 @@
1
+ export class BrowserAdapter {
2
+ container;
3
+ fileRegistry = new Map();
4
+ constructor(container) {
5
+ this.container = container;
6
+ }
7
+ render(vm, onAction) {
8
+ const active = document.activeElement;
9
+ const focusId = active?.id || null;
10
+ const selStart = active?.selectionStart ?? null;
11
+ const selEnd = active?.selectionEnd ?? null;
12
+ const scrollMap = new Map();
13
+ this.container.querySelectorAll("[id]").forEach(el => {
14
+ if (el.scrollTop !== 0 || el.scrollLeft !== 0)
15
+ scrollMap.set(el.id, { top: el.scrollTop, left: el.scrollLeft });
16
+ });
17
+ const draftValues = new Map();
18
+ this.container.querySelectorAll("input:not([type=checkbox]):not([type=hidden]):not([type=file]), textarea").forEach(el => { if (el.name)
19
+ draftValues.set(el.name, el.value); });
20
+ this.container.innerHTML = "";
21
+ this.node(vm, this.container, onAction);
22
+ if (draftValues.size > 0) {
23
+ this.container.querySelectorAll("input:not([type=checkbox]):not([type=hidden]):not([type=file]), textarea").forEach(el => {
24
+ if (el.name && !el.value && draftValues.has(el.name))
25
+ el.value = draftValues.get(el.name);
26
+ });
27
+ }
28
+ if (focusId) {
29
+ const el = this.container.querySelector(`#${CSS.escape(focusId)}`);
30
+ if (el) {
31
+ el.focus();
32
+ if (selStart !== null && selEnd !== null) {
33
+ try {
34
+ el.setSelectionRange(selStart, selEnd);
35
+ }
36
+ catch { }
37
+ }
38
+ }
39
+ }
40
+ scrollMap.forEach(({ top, left }, id) => {
41
+ const el = this.container.querySelector(`#${CSS.escape(id)}`);
42
+ if (el) {
43
+ el.scrollTop = top;
44
+ el.scrollLeft = left;
45
+ }
46
+ });
47
+ }
48
+ node(n, parent, on) {
49
+ switch (n.type) {
50
+ case "page": return this.page(n, parent, on);
51
+ case "section": return this.section(n, parent, on);
52
+ case "list": return this.list(n, parent, on);
53
+ case "list-item": return this.listItem(n, parent, on);
54
+ case "form": return this.form(n, parent, on);
55
+ case "field": return this.field(n, parent, on);
56
+ case "checkbox": return this.checkbox(n, parent, on);
57
+ case "button": return this.button(n, parent, on);
58
+ case "text": return this.text(n, parent);
59
+ case "link": return this.link(n, parent);
60
+ case "stat-bar": return this.statBar(n, parent);
61
+ case "tabs": return this.tabs(n, parent, on);
62
+ case "progress": return this.progress(n, parent);
63
+ case "modal": return this.modal(n, parent, on);
64
+ case "table": return this.table(n, parent, on);
65
+ }
66
+ }
67
+ kids(nodes, parent, on) {
68
+ nodes.forEach(n => this.node(n, parent, on));
69
+ }
70
+ page(n, parent, on) {
71
+ const el = document.createElement("div");
72
+ el.className = "vms-page";
73
+ if (n.title) {
74
+ const h = document.createElement("h1");
75
+ h.className = "vms-page__title";
76
+ h.textContent = n.title;
77
+ el.appendChild(h);
78
+ }
79
+ this.kids(n.children, el, on);
80
+ parent.appendChild(el);
81
+ }
82
+ section(n, parent, on) {
83
+ const el = document.createElement("section");
84
+ el.className = "vms-section";
85
+ if (n.heading) {
86
+ const h = document.createElement("h2");
87
+ h.className = "vms-section__heading";
88
+ h.textContent = n.heading;
89
+ el.appendChild(h);
90
+ }
91
+ this.kids(n.children, el, on);
92
+ parent.appendChild(el);
93
+ }
94
+ list(n, parent, on) {
95
+ const ul = document.createElement("ul");
96
+ ul.className = "vms-list";
97
+ if (n.id)
98
+ ul.id = n.id;
99
+ this.kids(n.children, ul, on);
100
+ parent.appendChild(ul);
101
+ }
102
+ listItem(n, parent, on) {
103
+ const li = document.createElement("li");
104
+ li.className = `vms-list-item${n.variant ? ` vms-list-item--${n.variant}` : ""}`;
105
+ if (n.id)
106
+ li.dataset.id = n.id;
107
+ this.kids(n.children, li, on);
108
+ parent.appendChild(li);
109
+ }
110
+ form(n, parent, on) {
111
+ const form = document.createElement("form");
112
+ form.className = "vms-form";
113
+ form.noValidate = true;
114
+ this.kids(n.children, form, on);
115
+ const submit = document.createElement("button");
116
+ submit.type = "submit";
117
+ submit.className = "vms-button vms-button--primary";
118
+ submit.textContent = n.submitLabel ?? "Submit";
119
+ form.appendChild(submit);
120
+ form.addEventListener("submit", (e) => {
121
+ e.preventDefault();
122
+ const ctx = { ...(n.submitAction.context ?? {}) };
123
+ const files = {};
124
+ form.querySelectorAll("input:not([type=checkbox]):not([type=file]), textarea").forEach(el => { if (el.name)
125
+ ctx[el.name] = el.value; });
126
+ // Form-collected checkboxes (FieldNode inputType="checkbox").
127
+ // CheckboxNode renders with .vms-checkbox__input and is excluded so its
128
+ // immediate-dispatch path stays the only way it talks to the server.
129
+ form.querySelectorAll("input.vms-field__input[type=checkbox]").forEach(el => { if (el.name)
130
+ ctx[el.name] = el.checked; });
131
+ form.querySelectorAll("select:not([multiple])").forEach(sel => {
132
+ if (sel.name)
133
+ ctx[sel.name] = sel.value;
134
+ });
135
+ form.querySelectorAll("select[multiple]").forEach(sel => {
136
+ if (sel.name)
137
+ ctx[sel.name] = Array.from(sel.selectedOptions).map(o => o.value).join(",");
138
+ });
139
+ form.querySelectorAll("input[type=file]").forEach(inp => {
140
+ if (inp.name && inp.files?.[0])
141
+ files[inp.name] = inp.files[0];
142
+ });
143
+ const action = { name: n.submitAction.name, context: ctx };
144
+ if (Object.keys(files).length > 0)
145
+ action.files = files;
146
+ on(action);
147
+ });
148
+ parent.appendChild(form);
149
+ }
150
+ field(n, parent, on) {
151
+ if (n.inputType === "hidden") {
152
+ const inp = document.createElement("input");
153
+ inp.type = "hidden";
154
+ inp.name = n.name;
155
+ if (n.value)
156
+ inp.value = n.value;
157
+ parent.appendChild(inp);
158
+ return;
159
+ }
160
+ if (n.inputType === "checkbox") {
161
+ const wrapper = document.createElement("div");
162
+ wrapper.className = "vms-field vms-field--checkbox";
163
+ const inp = document.createElement("input");
164
+ inp.type = "checkbox";
165
+ inp.className = "vms-field__input";
166
+ inp.id = `vms-${n.name}`;
167
+ inp.name = n.name;
168
+ inp.checked = !!n.value && n.value !== "false" && n.value !== "0";
169
+ wrapper.appendChild(inp);
170
+ if (n.label) {
171
+ const lbl = document.createElement("label");
172
+ lbl.className = "vms-field__label";
173
+ lbl.htmlFor = `vms-${n.name}`;
174
+ lbl.textContent = n.label;
175
+ wrapper.appendChild(lbl);
176
+ }
177
+ parent.appendChild(wrapper);
178
+ return;
179
+ }
180
+ const wrapper = document.createElement("div");
181
+ wrapper.className = "vms-field";
182
+ if (n.label) {
183
+ const lbl = document.createElement("label");
184
+ lbl.className = "vms-field__label";
185
+ lbl.htmlFor = `vms-${n.name}`;
186
+ lbl.textContent = n.label;
187
+ wrapper.appendChild(lbl);
188
+ }
189
+ if (n.inputType === "select" || n.inputType === "select-multiple") {
190
+ const sel = document.createElement("select");
191
+ sel.className = "vms-field__input";
192
+ sel.id = `vms-${n.name}`;
193
+ sel.name = n.name;
194
+ sel.multiple = n.inputType === "select-multiple";
195
+ (n.options ?? []).forEach(opt => {
196
+ const o = document.createElement("option");
197
+ o.value = opt.value;
198
+ o.textContent = opt.label;
199
+ o.selected = n.inputType === "select-multiple"
200
+ ? (n.value ?? "").split(",").map(s => s.trim()).includes(opt.value)
201
+ : opt.value === n.value;
202
+ sel.appendChild(o);
203
+ });
204
+ if (n.action) {
205
+ const action = n.action;
206
+ sel.addEventListener("change", () => {
207
+ on({ name: action.name, context: { ...(action.context ?? {}), [n.name]: sel.value } });
208
+ });
209
+ }
210
+ wrapper.appendChild(sel);
211
+ }
212
+ else if (n.inputType === "file") {
213
+ const inp = document.createElement("input");
214
+ inp.type = "file";
215
+ inp.className = "vms-field__input";
216
+ inp.id = `vms-${n.name}`;
217
+ inp.name = n.name;
218
+ const existingFile = this.fileRegistry.get(n.name);
219
+ if (existingFile) {
220
+ try {
221
+ const dt = new DataTransfer();
222
+ dt.items.add(existingFile);
223
+ inp.files = dt.files;
224
+ }
225
+ catch { }
226
+ }
227
+ inp.addEventListener("change", () => {
228
+ const file = inp.files?.[0];
229
+ if (file)
230
+ this.fileRegistry.set(n.name, file);
231
+ else
232
+ this.fileRegistry.delete(n.name);
233
+ });
234
+ wrapper.appendChild(inp);
235
+ }
236
+ else if (n.inputType === "textarea") {
237
+ const ta = document.createElement("textarea");
238
+ ta.className = "vms-field__input";
239
+ ta.id = `vms-${n.name}`;
240
+ ta.name = n.name;
241
+ if (n.placeholder)
242
+ ta.placeholder = n.placeholder;
243
+ if (n.value)
244
+ ta.value = n.value;
245
+ if (n.required)
246
+ ta.required = true;
247
+ wrapper.appendChild(ta);
248
+ }
249
+ else if (n.inputType === "code") {
250
+ // Monospaced editable text. Tab inserts a literal tab instead of moving
251
+ // focus. Apps wanting syntax highlighting attach their own library
252
+ // (CodeMirror, Monaco) using the .vms-field--code-{language} class hook.
253
+ wrapper.classList.add("vms-field--code");
254
+ if (n.language)
255
+ wrapper.classList.add(`vms-field--code-${n.language}`);
256
+ const ta = document.createElement("textarea");
257
+ ta.className = "vms-field__input vms-field__input--code";
258
+ ta.id = `vms-${n.name}`;
259
+ ta.name = n.name;
260
+ ta.spellcheck = false;
261
+ ta.autocapitalize = "off";
262
+ ta.autocomplete = "off";
263
+ ta.setAttribute("autocorrect", "off");
264
+ if (n.placeholder)
265
+ ta.placeholder = n.placeholder;
266
+ if (n.value)
267
+ ta.value = n.value;
268
+ if (n.required)
269
+ ta.required = true;
270
+ ta.addEventListener("keydown", (e) => {
271
+ if (e.key === "Tab") {
272
+ e.preventDefault();
273
+ const start = ta.selectionStart ?? 0;
274
+ const end = ta.selectionEnd ?? 0;
275
+ ta.value = ta.value.slice(0, start) + "\t" + ta.value.slice(end);
276
+ ta.selectionStart = ta.selectionEnd = start + 1;
277
+ }
278
+ });
279
+ wrapper.appendChild(ta);
280
+ }
281
+ else {
282
+ const inp = document.createElement("input");
283
+ inp.className = "vms-field__input";
284
+ inp.id = `vms-${n.name}`;
285
+ inp.type = n.inputType;
286
+ inp.name = n.name;
287
+ if (n.placeholder)
288
+ inp.placeholder = n.placeholder;
289
+ if (n.value)
290
+ inp.value = n.value;
291
+ if (n.required)
292
+ inp.required = true;
293
+ if (n.action) {
294
+ const action = n.action;
295
+ inp.addEventListener("keydown", (e) => {
296
+ if (e.key === "Enter") {
297
+ e.preventDefault();
298
+ on({ name: action.name, context: { ...(action.context ?? {}), [n.name]: inp.value } });
299
+ }
300
+ });
301
+ }
302
+ wrapper.appendChild(inp);
303
+ }
304
+ parent.appendChild(wrapper);
305
+ }
306
+ checkbox(n, parent, on) {
307
+ const lbl = document.createElement("label");
308
+ lbl.className = "vms-checkbox";
309
+ const inp = document.createElement("input");
310
+ inp.type = "checkbox";
311
+ inp.className = "vms-checkbox__input";
312
+ inp.name = n.name;
313
+ inp.checked = n.checked;
314
+ const mark = document.createElement("span");
315
+ mark.className = "vms-checkbox__mark";
316
+ lbl.appendChild(inp);
317
+ lbl.appendChild(mark);
318
+ if (n.label) {
319
+ const span = document.createElement("span");
320
+ span.className = "vms-checkbox__label";
321
+ span.textContent = n.label;
322
+ lbl.appendChild(span);
323
+ }
324
+ if (n.action) {
325
+ const action = n.action;
326
+ inp.addEventListener("change", () => {
327
+ on({ name: action.name, context: { ...(action.context ?? {}), checked: inp.checked } });
328
+ });
329
+ }
330
+ parent.appendChild(lbl);
331
+ }
332
+ button(n, parent, on) {
333
+ const btn = document.createElement("button");
334
+ btn.type = "button";
335
+ btn.className = `vms-button${n.variant ? ` vms-button--${n.variant}` : ""}`;
336
+ btn.textContent = n.label;
337
+ btn.addEventListener("click", () => on(n.action));
338
+ parent.appendChild(btn);
339
+ }
340
+ text(n, parent) {
341
+ const el = document.createElement(n.style === "pre" ? "pre" : "span");
342
+ el.className = `vms-text${n.style ? ` vms-text--${n.style}` : ""}`;
343
+ el.textContent = n.value;
344
+ parent.appendChild(el);
345
+ }
346
+ link(n, parent) {
347
+ const a = document.createElement("a");
348
+ a.className = "vms-link";
349
+ a.href = n.href;
350
+ a.textContent = n.label;
351
+ if (n.external) {
352
+ a.target = "_blank";
353
+ a.rel = "noopener noreferrer";
354
+ }
355
+ parent.appendChild(a);
356
+ }
357
+ statBar(n, parent) {
358
+ const bar = document.createElement("div");
359
+ bar.className = "vms-stat-bar";
360
+ n.stats.forEach(stat => {
361
+ const item = document.createElement("div");
362
+ item.className = "vms-stat-bar__item";
363
+ const val = document.createElement("span");
364
+ val.className = "vms-stat-bar__value";
365
+ val.textContent = String(stat.value);
366
+ const lbl = document.createElement("span");
367
+ lbl.className = "vms-stat-bar__label";
368
+ lbl.textContent = stat.label;
369
+ item.appendChild(val);
370
+ item.appendChild(lbl);
371
+ bar.appendChild(item);
372
+ });
373
+ parent.appendChild(bar);
374
+ }
375
+ tabs(n, parent, on) {
376
+ const nav = document.createElement("nav");
377
+ nav.className = "vms-tabs";
378
+ nav.setAttribute("role", "tablist");
379
+ n.tabs.forEach(tab => {
380
+ const btn = document.createElement("button");
381
+ btn.className = `vms-tabs__tab${tab.value === n.selected ? " vms-tabs__tab--active" : ""}`;
382
+ btn.textContent = tab.label;
383
+ btn.setAttribute("role", "tab");
384
+ btn.setAttribute("aria-selected", String(tab.value === n.selected));
385
+ btn.addEventListener("click", () => {
386
+ on({ name: n.action.name, context: { ...(n.action.context ?? {}), value: tab.value } });
387
+ });
388
+ nav.appendChild(btn);
389
+ });
390
+ parent.appendChild(nav);
391
+ }
392
+ progress(n, parent) {
393
+ const track = document.createElement("div");
394
+ track.className = "vms-progress";
395
+ const bar = document.createElement("div");
396
+ bar.className = "vms-progress__bar";
397
+ bar.style.width = `${n.value}%`;
398
+ track.appendChild(bar);
399
+ parent.appendChild(track);
400
+ }
401
+ modal(n, parent, on) {
402
+ const backdrop = document.createElement("div");
403
+ backdrop.className = "vms-modal-backdrop";
404
+ const modal = document.createElement("div");
405
+ modal.className = `vms-modal${n.size ? ` vms-modal--${n.size}` : ""}`;
406
+ modal.setAttribute("role", "dialog");
407
+ modal.setAttribute("aria-modal", "true");
408
+ const header = document.createElement("div");
409
+ header.className = "vms-modal__header";
410
+ if (n.title) {
411
+ const title = document.createElement("span");
412
+ title.className = "vms-modal__title";
413
+ title.textContent = n.title;
414
+ header.appendChild(title);
415
+ }
416
+ if (n.dismissAction) {
417
+ const action = n.dismissAction;
418
+ const closeBtn = document.createElement("button");
419
+ closeBtn.type = "button";
420
+ closeBtn.className = "vms-modal__close";
421
+ closeBtn.textContent = "✕";
422
+ closeBtn.addEventListener("click", () => on(action));
423
+ header.appendChild(closeBtn);
424
+ }
425
+ modal.appendChild(header);
426
+ const body = document.createElement("div");
427
+ body.className = "vms-modal__body";
428
+ this.kids(n.children, body, on);
429
+ modal.appendChild(body);
430
+ if (n.footer && n.footer.length > 0) {
431
+ const footer = document.createElement("div");
432
+ footer.className = "vms-modal__footer";
433
+ this.kids(n.footer, footer, on);
434
+ modal.appendChild(footer);
435
+ }
436
+ backdrop.appendChild(modal);
437
+ parent.appendChild(backdrop);
438
+ }
439
+ table(n, parent, on) {
440
+ const wrapper = document.createElement("div");
441
+ wrapper.className = "vms-table-wrapper";
442
+ const table = document.createElement("table");
443
+ table.className = "vms-table";
444
+ const thead = document.createElement("thead");
445
+ const headerRow = document.createElement("tr");
446
+ n.columns.forEach(col => {
447
+ const th = document.createElement("th");
448
+ const isSorted = col.key === n.sortColumn;
449
+ const dir = isSorted ? (n.sortDirection ?? "asc") : null;
450
+ let classes = "vms-table__th";
451
+ if (col.sortable)
452
+ classes += " vms-table__th--sortable";
453
+ if (dir === "asc")
454
+ classes += " vms-table__th--asc";
455
+ if (dir === "desc")
456
+ classes += " vms-table__th--desc";
457
+ th.className = classes;
458
+ th.textContent = col.label;
459
+ if (col.sortable && n.sortAction) {
460
+ const sortAction = n.sortAction;
461
+ th.addEventListener("click", () => {
462
+ const nextDir = isSorted && n.sortDirection === "asc" ? "desc" : "asc";
463
+ on({ name: sortAction.name, context: { ...(sortAction.context ?? {}), column: col.key, direction: nextDir } });
464
+ });
465
+ }
466
+ headerRow.appendChild(th);
467
+ });
468
+ thead.appendChild(headerRow);
469
+ const hasFilters = n.columns.some(c => c.filterable) && !!n.filterAction;
470
+ if (hasFilters) {
471
+ const filterAction = n.filterAction;
472
+ const filterRow = document.createElement("tr");
473
+ filterRow.className = "vms-table__filter-row";
474
+ n.columns.forEach(col => {
475
+ const th = document.createElement("th");
476
+ if (col.filterable) {
477
+ const inp = document.createElement("input");
478
+ inp.type = "text";
479
+ inp.className = "vms-table__filter-input";
480
+ inp.dataset.col = col.key;
481
+ inp.value = col.filterValue ?? "";
482
+ inp.placeholder = `Filter…`;
483
+ inp.addEventListener("keydown", (e) => {
484
+ if (e.key === "Enter") {
485
+ const filters = {};
486
+ filterRow.querySelectorAll("[data-col]").forEach(el => {
487
+ filters[el.dataset.col] = el.value;
488
+ });
489
+ on({ name: filterAction.name, context: { ...(filterAction.context ?? {}), column: col.key, value: inp.value, filters } });
490
+ }
491
+ });
492
+ th.appendChild(inp);
493
+ }
494
+ filterRow.appendChild(th);
495
+ });
496
+ thead.appendChild(filterRow);
497
+ }
498
+ table.appendChild(thead);
499
+ const tbody = document.createElement("tbody");
500
+ n.rows.forEach(row => {
501
+ const tr = document.createElement("tr");
502
+ let rowClass = "vms-table__row";
503
+ if (row.variant)
504
+ rowClass += ` vms-table__row--${row.variant}`;
505
+ if (row.action)
506
+ rowClass += " vms-table__row--clickable";
507
+ tr.className = rowClass;
508
+ if (row.id)
509
+ tr.dataset.id = row.id;
510
+ if (row.action) {
511
+ const rowAction = row.action;
512
+ tr.addEventListener("click", () => on(rowAction));
513
+ }
514
+ n.columns.forEach(col => {
515
+ const td = document.createElement("td");
516
+ td.className = "vms-table__td";
517
+ const cellValue = row.cells[col.key] ?? "";
518
+ if (col.linkLabel && cellValue) {
519
+ const a = document.createElement("a");
520
+ a.href = cellValue;
521
+ a.textContent = col.linkLabel;
522
+ a.className = "vms-table__link";
523
+ if (col.linkExternal) {
524
+ a.target = "_blank";
525
+ a.rel = "noopener noreferrer";
526
+ }
527
+ td.appendChild(a);
528
+ }
529
+ else {
530
+ td.textContent = cellValue;
531
+ }
532
+ tr.appendChild(td);
533
+ });
534
+ tbody.appendChild(tr);
535
+ });
536
+ table.appendChild(tbody);
537
+ wrapper.appendChild(table);
538
+ parent.appendChild(wrapper);
539
+ }
540
+ }