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