@ashley-shrok/viewmodel-shell 0.2.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.
- package/LICENSE +21 -0
- package/README.md +49 -0
- package/package.json +43 -0
- package/src/browser.ts +542 -0
- package/src/index.ts +270 -0
- package/styles/default.css +403 -0
- package/styles/themes/dark-amber.css +6 -0
- package/styles/themes/dark-blue.css +6 -0
- package/styles/themes/dark-green.css +6 -0
- package/styles/themes/dark-rose.css +6 -0
- package/styles/themes/dark-teal.css +6 -0
- package/styles/themes/light-amber.css +21 -0
- package/styles/themes/light-blue.css +21 -0
- package/styles/themes/light-green.css +21 -0
- package/styles/themes/light-purple.css +24 -0
- package/styles/themes/light-rose.css +21 -0
- package/styles/themes/light-teal.css +21 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ashley-shrok
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# @ashley-shrok/viewmodel-shell
|
|
2
|
+
|
|
3
|
+
Server-driven UI framework. The server returns a JSON tree of typed nodes; a thin TypeScript adapter renders it to DOM. The browser never owns application state — every interaction dispatches a semantic action to a single POST endpoint and the server returns the next state plus a fresh view.
|
|
4
|
+
|
|
5
|
+
The frontend is backend-agnostic: it speaks a small JSON contract over a single POST endpoint that takes `multipart/form-data` (with `_action` and `_state` fields). A .NET reference backend ships in the repo, but any language can produce the same contract.
|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install @ashley-shrok/viewmodel-shell
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Use
|
|
14
|
+
|
|
15
|
+
```ts
|
|
16
|
+
import "@ashley-shrok/viewmodel-shell/styles.css";
|
|
17
|
+
import { ViewModelShell } from "@ashley-shrok/viewmodel-shell";
|
|
18
|
+
import { BrowserAdapter } from "@ashley-shrok/viewmodel-shell/browser";
|
|
19
|
+
|
|
20
|
+
const container = document.getElementById("app")!;
|
|
21
|
+
const shell = new ViewModelShell({
|
|
22
|
+
endpoint: "/api/tasks",
|
|
23
|
+
actionEndpoint: "/api/tasks/action",
|
|
24
|
+
adapter: new BrowserAdapter(container),
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
shell.load();
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
If your backend is .NET: copy `demo/Tasks/AspNetCore/ViewModels.cs` from the [GitHub repo](https://github.com/ashley-shrok/ViewModelShell) into your project and update the namespace — that file is the full backend record set (`ViewNode`, `PageNode`, `FormNode`, `ShellResponse<TState>`, `ActionPayload<TState>`, etc.).
|
|
31
|
+
|
|
32
|
+
For other backends, implement the same JSON shape: a `GET` returning `{ vm, state }`, and a `POST` that takes `multipart/form-data` with `_action` and `_state` form fields and returns the next `{ vm, state }`. See [AGENTS.md](https://github.com/ashley-shrok/ViewModelShell/blob/main/AGENTS.md) for the full wire format.
|
|
33
|
+
|
|
34
|
+
## Themes
|
|
35
|
+
|
|
36
|
+
The base stylesheet ships a dark-purple theme. To override, import a theme file on top:
|
|
37
|
+
|
|
38
|
+
```ts
|
|
39
|
+
import "@ashley-shrok/viewmodel-shell/styles.css";
|
|
40
|
+
import "@ashley-shrok/viewmodel-shell/themes/dark-blue.css";
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Available themes:
|
|
44
|
+
`dark-blue`, `dark-green`, `dark-rose`, `dark-amber`, `dark-teal`,
|
|
45
|
+
`light-purple`, `light-blue`, `light-green`, `light-rose`, `light-amber`, `light-teal`.
|
|
46
|
+
|
|
47
|
+
## Docs
|
|
48
|
+
|
|
49
|
+
Full framework docs, architecture details, demo apps, and the C# backend pattern are in the [GitHub repository's AGENTS.md](https://github.com/ashley-shrok/ViewModelShell/blob/main/AGENTS.md).
|
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@ashley-shrok/viewmodel-shell",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Server-driven UI framework. Server returns a JSON tree of typed nodes; a thin TypeScript adapter renders it to DOM. Backend-agnostic — a .NET reference backend ships with the repo, but any language can produce the JSON contract.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"author": "ashley-shrok",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/ashley-shrok/ViewModelShell.git"
|
|
11
|
+
},
|
|
12
|
+
"homepage": "https://github.com/ashley-shrok/ViewModelShell#readme",
|
|
13
|
+
"bugs": "https://github.com/ashley-shrok/ViewModelShell/issues",
|
|
14
|
+
"keywords": [
|
|
15
|
+
"viewmodel",
|
|
16
|
+
"server-driven-ui",
|
|
17
|
+
"sdui",
|
|
18
|
+
"framework",
|
|
19
|
+
"typescript"
|
|
20
|
+
],
|
|
21
|
+
"files": [
|
|
22
|
+
"src",
|
|
23
|
+
"styles",
|
|
24
|
+
"README.md",
|
|
25
|
+
"LICENSE"
|
|
26
|
+
],
|
|
27
|
+
"exports": {
|
|
28
|
+
".": "./src/index.ts",
|
|
29
|
+
"./browser": "./src/browser.ts",
|
|
30
|
+
"./styles.css": "./styles/default.css",
|
|
31
|
+
"./themes/dark-blue.css": "./styles/themes/dark-blue.css",
|
|
32
|
+
"./themes/dark-green.css": "./styles/themes/dark-green.css",
|
|
33
|
+
"./themes/dark-rose.css": "./styles/themes/dark-rose.css",
|
|
34
|
+
"./themes/dark-amber.css": "./styles/themes/dark-amber.css",
|
|
35
|
+
"./themes/dark-teal.css": "./styles/themes/dark-teal.css",
|
|
36
|
+
"./themes/light-purple.css": "./styles/themes/light-purple.css",
|
|
37
|
+
"./themes/light-blue.css": "./styles/themes/light-blue.css",
|
|
38
|
+
"./themes/light-green.css": "./styles/themes/light-green.css",
|
|
39
|
+
"./themes/light-rose.css": "./styles/themes/light-rose.css",
|
|
40
|
+
"./themes/light-amber.css": "./styles/themes/light-amber.css",
|
|
41
|
+
"./themes/light-teal.css": "./styles/themes/light-teal.css"
|
|
42
|
+
}
|
|
43
|
+
}
|
package/src/browser.ts
ADDED
|
@@ -0,0 +1,542 @@
|
|
|
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 {
|
|
270
|
+
const inp = document.createElement("input");
|
|
271
|
+
inp.className = "vms-field__input";
|
|
272
|
+
inp.id = `vms-${n.name}`;
|
|
273
|
+
inp.type = n.inputType;
|
|
274
|
+
inp.name = n.name;
|
|
275
|
+
if (n.placeholder) inp.placeholder = n.placeholder;
|
|
276
|
+
if (n.value) inp.value = n.value;
|
|
277
|
+
if (n.required) inp.required = true;
|
|
278
|
+
if (n.action) {
|
|
279
|
+
const action = n.action;
|
|
280
|
+
inp.addEventListener("keydown", (e) => {
|
|
281
|
+
if (e.key === "Enter") {
|
|
282
|
+
e.preventDefault();
|
|
283
|
+
on({ name: action.name, context: { ...(action.context ?? {}), [n.name]: inp.value } });
|
|
284
|
+
}
|
|
285
|
+
});
|
|
286
|
+
}
|
|
287
|
+
wrapper.appendChild(inp);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
parent.appendChild(wrapper);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
private checkbox(n: CheckboxNode, parent: HTMLElement, on: (a: ActionEvent) => void): void {
|
|
294
|
+
const lbl = document.createElement("label");
|
|
295
|
+
lbl.className = "vms-checkbox";
|
|
296
|
+
const inp = document.createElement("input");
|
|
297
|
+
inp.type = "checkbox";
|
|
298
|
+
inp.className = "vms-checkbox__input";
|
|
299
|
+
inp.name = n.name;
|
|
300
|
+
inp.checked = n.checked;
|
|
301
|
+
const mark = document.createElement("span");
|
|
302
|
+
mark.className = "vms-checkbox__mark";
|
|
303
|
+
lbl.appendChild(inp);
|
|
304
|
+
lbl.appendChild(mark);
|
|
305
|
+
if (n.label) {
|
|
306
|
+
const span = document.createElement("span");
|
|
307
|
+
span.className = "vms-checkbox__label";
|
|
308
|
+
span.textContent = n.label;
|
|
309
|
+
lbl.appendChild(span);
|
|
310
|
+
}
|
|
311
|
+
if (n.action) {
|
|
312
|
+
const action = n.action;
|
|
313
|
+
inp.addEventListener("change", () => {
|
|
314
|
+
on({ name: action.name, context: { ...(action.context ?? {}), checked: inp.checked } });
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
parent.appendChild(lbl);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
private button(n: ButtonNode, parent: HTMLElement, on: (a: ActionEvent) => void): void {
|
|
321
|
+
const btn = document.createElement("button");
|
|
322
|
+
btn.type = "button";
|
|
323
|
+
btn.className = `vms-button${n.variant ? ` vms-button--${n.variant}` : ""}`;
|
|
324
|
+
btn.textContent = n.label;
|
|
325
|
+
btn.addEventListener("click", () => on(n.action));
|
|
326
|
+
parent.appendChild(btn);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
private text(n: TextNode, parent: HTMLElement): void {
|
|
330
|
+
const el = document.createElement(n.style === "pre" ? "pre" : "span");
|
|
331
|
+
el.className = `vms-text${n.style ? ` vms-text--${n.style}` : ""}`;
|
|
332
|
+
el.textContent = n.value;
|
|
333
|
+
parent.appendChild(el);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
private link(n: LinkNode, parent: HTMLElement): void {
|
|
337
|
+
const a = document.createElement("a");
|
|
338
|
+
a.className = "vms-link";
|
|
339
|
+
a.href = n.href;
|
|
340
|
+
a.textContent = n.label;
|
|
341
|
+
if (n.external) {
|
|
342
|
+
a.target = "_blank";
|
|
343
|
+
a.rel = "noopener noreferrer";
|
|
344
|
+
}
|
|
345
|
+
parent.appendChild(a);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
private statBar(n: StatBarNode, parent: HTMLElement): void {
|
|
349
|
+
const bar = document.createElement("div");
|
|
350
|
+
bar.className = "vms-stat-bar";
|
|
351
|
+
n.stats.forEach(stat => {
|
|
352
|
+
const item = document.createElement("div");
|
|
353
|
+
item.className = "vms-stat-bar__item";
|
|
354
|
+
const val = document.createElement("span");
|
|
355
|
+
val.className = "vms-stat-bar__value";
|
|
356
|
+
val.textContent = String(stat.value);
|
|
357
|
+
const lbl = document.createElement("span");
|
|
358
|
+
lbl.className = "vms-stat-bar__label";
|
|
359
|
+
lbl.textContent = stat.label;
|
|
360
|
+
item.appendChild(val);
|
|
361
|
+
item.appendChild(lbl);
|
|
362
|
+
bar.appendChild(item);
|
|
363
|
+
});
|
|
364
|
+
parent.appendChild(bar);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
private tabs(n: TabsNode, parent: HTMLElement, on: (a: ActionEvent) => void): void {
|
|
368
|
+
const nav = document.createElement("nav");
|
|
369
|
+
nav.className = "vms-tabs";
|
|
370
|
+
nav.setAttribute("role", "tablist");
|
|
371
|
+
n.tabs.forEach(tab => {
|
|
372
|
+
const btn = document.createElement("button");
|
|
373
|
+
btn.className = `vms-tabs__tab${tab.value === n.selected ? " vms-tabs__tab--active" : ""}`;
|
|
374
|
+
btn.textContent = tab.label;
|
|
375
|
+
btn.setAttribute("role", "tab");
|
|
376
|
+
btn.setAttribute("aria-selected", String(tab.value === n.selected));
|
|
377
|
+
btn.addEventListener("click", () => {
|
|
378
|
+
on({ name: n.action.name, context: { ...(n.action.context ?? {}), value: tab.value } });
|
|
379
|
+
});
|
|
380
|
+
nav.appendChild(btn);
|
|
381
|
+
});
|
|
382
|
+
parent.appendChild(nav);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
private progress(n: ProgressNode, parent: HTMLElement): void {
|
|
386
|
+
const track = document.createElement("div");
|
|
387
|
+
track.className = "vms-progress";
|
|
388
|
+
const bar = document.createElement("div");
|
|
389
|
+
bar.className = "vms-progress__bar";
|
|
390
|
+
bar.style.width = `${n.value}%`;
|
|
391
|
+
track.appendChild(bar);
|
|
392
|
+
parent.appendChild(track);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
private modal(n: ModalNode, parent: HTMLElement, on: (a: ActionEvent) => void): void {
|
|
396
|
+
const backdrop = document.createElement("div");
|
|
397
|
+
backdrop.className = "vms-modal-backdrop";
|
|
398
|
+
|
|
399
|
+
const modal = document.createElement("div");
|
|
400
|
+
modal.className = "vms-modal";
|
|
401
|
+
modal.setAttribute("role", "dialog");
|
|
402
|
+
modal.setAttribute("aria-modal", "true");
|
|
403
|
+
|
|
404
|
+
const header = document.createElement("div");
|
|
405
|
+
header.className = "vms-modal__header";
|
|
406
|
+
|
|
407
|
+
if (n.title) {
|
|
408
|
+
const title = document.createElement("span");
|
|
409
|
+
title.className = "vms-modal__title";
|
|
410
|
+
title.textContent = n.title;
|
|
411
|
+
header.appendChild(title);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
if (n.dismissAction) {
|
|
415
|
+
const action = n.dismissAction;
|
|
416
|
+
const closeBtn = document.createElement("button");
|
|
417
|
+
closeBtn.type = "button";
|
|
418
|
+
closeBtn.className = "vms-modal__close";
|
|
419
|
+
closeBtn.textContent = "✕";
|
|
420
|
+
closeBtn.addEventListener("click", () => on(action));
|
|
421
|
+
header.appendChild(closeBtn);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
modal.appendChild(header);
|
|
425
|
+
|
|
426
|
+
const body = document.createElement("div");
|
|
427
|
+
body.className = "vms-modal__body";
|
|
428
|
+
this.kids(n.children, body, on);
|
|
429
|
+
modal.appendChild(body);
|
|
430
|
+
|
|
431
|
+
if (n.footer && n.footer.length > 0) {
|
|
432
|
+
const footer = document.createElement("div");
|
|
433
|
+
footer.className = "vms-modal__footer";
|
|
434
|
+
this.kids(n.footer, footer, on);
|
|
435
|
+
modal.appendChild(footer);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
backdrop.appendChild(modal);
|
|
439
|
+
parent.appendChild(backdrop);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
private table(n: TableNode, parent: HTMLElement, on: (a: ActionEvent) => void): void {
|
|
443
|
+
const wrapper = document.createElement("div");
|
|
444
|
+
wrapper.className = "vms-table-wrapper";
|
|
445
|
+
|
|
446
|
+
const table = document.createElement("table");
|
|
447
|
+
table.className = "vms-table";
|
|
448
|
+
|
|
449
|
+
const thead = document.createElement("thead");
|
|
450
|
+
const headerRow = document.createElement("tr");
|
|
451
|
+
n.columns.forEach(col => {
|
|
452
|
+
const th = document.createElement("th");
|
|
453
|
+
const isSorted = col.key === n.sortColumn;
|
|
454
|
+
const dir = isSorted ? (n.sortDirection ?? "asc") : null;
|
|
455
|
+
let classes = "vms-table__th";
|
|
456
|
+
if (col.sortable) classes += " vms-table__th--sortable";
|
|
457
|
+
if (dir === "asc") classes += " vms-table__th--asc";
|
|
458
|
+
if (dir === "desc") classes += " vms-table__th--desc";
|
|
459
|
+
th.className = classes;
|
|
460
|
+
th.textContent = col.label;
|
|
461
|
+
if (col.sortable && n.sortAction) {
|
|
462
|
+
const sortAction = n.sortAction;
|
|
463
|
+
th.addEventListener("click", () => {
|
|
464
|
+
const nextDir = isSorted && n.sortDirection === "asc" ? "desc" : "asc";
|
|
465
|
+
on({ name: sortAction.name, context: { ...(sortAction.context ?? {}), column: col.key, direction: nextDir } });
|
|
466
|
+
});
|
|
467
|
+
}
|
|
468
|
+
headerRow.appendChild(th);
|
|
469
|
+
});
|
|
470
|
+
thead.appendChild(headerRow);
|
|
471
|
+
|
|
472
|
+
const hasFilters = n.columns.some(c => c.filterable) && !!n.filterAction;
|
|
473
|
+
if (hasFilters) {
|
|
474
|
+
const filterAction = n.filterAction!;
|
|
475
|
+
const filterRow = document.createElement("tr");
|
|
476
|
+
filterRow.className = "vms-table__filter-row";
|
|
477
|
+
n.columns.forEach(col => {
|
|
478
|
+
const th = document.createElement("th");
|
|
479
|
+
if (col.filterable) {
|
|
480
|
+
const inp = document.createElement("input");
|
|
481
|
+
inp.type = "text";
|
|
482
|
+
inp.className = "vms-table__filter-input";
|
|
483
|
+
inp.dataset.col = col.key;
|
|
484
|
+
inp.value = col.filterValue ?? "";
|
|
485
|
+
inp.placeholder = `Filter…`;
|
|
486
|
+
inp.addEventListener("keydown", (e) => {
|
|
487
|
+
if (e.key === "Enter") {
|
|
488
|
+
const filters: Record<string, string> = {};
|
|
489
|
+
filterRow.querySelectorAll<HTMLInputElement>("[data-col]").forEach(el => {
|
|
490
|
+
filters[el.dataset.col!] = el.value;
|
|
491
|
+
});
|
|
492
|
+
on({ name: filterAction.name, context: { ...(filterAction.context ?? {}), column: col.key, value: inp.value, filters } });
|
|
493
|
+
}
|
|
494
|
+
});
|
|
495
|
+
th.appendChild(inp);
|
|
496
|
+
}
|
|
497
|
+
filterRow.appendChild(th);
|
|
498
|
+
});
|
|
499
|
+
thead.appendChild(filterRow);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
table.appendChild(thead);
|
|
503
|
+
|
|
504
|
+
const tbody = document.createElement("tbody");
|
|
505
|
+
n.rows.forEach(row => {
|
|
506
|
+
const tr = document.createElement("tr");
|
|
507
|
+
let rowClass = "vms-table__row";
|
|
508
|
+
if (row.variant) rowClass += ` vms-table__row--${row.variant}`;
|
|
509
|
+
if (row.action) rowClass += " vms-table__row--clickable";
|
|
510
|
+
tr.className = rowClass;
|
|
511
|
+
if (row.id) tr.dataset.id = row.id;
|
|
512
|
+
if (row.action) {
|
|
513
|
+
const rowAction = row.action;
|
|
514
|
+
tr.addEventListener("click", () => on(rowAction));
|
|
515
|
+
}
|
|
516
|
+
n.columns.forEach(col => {
|
|
517
|
+
const td = document.createElement("td");
|
|
518
|
+
td.className = "vms-table__td";
|
|
519
|
+
const cellValue = row.cells[col.key] ?? "";
|
|
520
|
+
if (col.linkLabel && cellValue) {
|
|
521
|
+
const a = document.createElement("a");
|
|
522
|
+
a.href = cellValue;
|
|
523
|
+
a.textContent = col.linkLabel;
|
|
524
|
+
a.className = "vms-table__link";
|
|
525
|
+
if (col.linkExternal) {
|
|
526
|
+
a.target = "_blank";
|
|
527
|
+
a.rel = "noopener noreferrer";
|
|
528
|
+
}
|
|
529
|
+
td.appendChild(a);
|
|
530
|
+
} else {
|
|
531
|
+
td.textContent = cellValue;
|
|
532
|
+
}
|
|
533
|
+
tr.appendChild(td);
|
|
534
|
+
});
|
|
535
|
+
tbody.appendChild(tr);
|
|
536
|
+
});
|
|
537
|
+
table.appendChild(tbody);
|
|
538
|
+
|
|
539
|
+
wrapper.appendChild(table);
|
|
540
|
+
parent.appendChild(wrapper);
|
|
541
|
+
}
|
|
542
|
+
}
|