@ashley-shrok/viewmodel-shell 0.3.8 → 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/index.ts DELETED
@@ -1,335 +0,0 @@
1
- // ─── Action ──────────────────────────────────────────────────────────────────
2
-
3
- export interface ActionEvent {
4
- name: string;
5
- context?: Record<string, unknown>;
6
- files?: Record<string, File>;
7
- }
8
-
9
- // ─── Adapter interface ────────────────────────────────────────────────────────
10
- // Implement this to target a new platform (browser, mobile, terminal, …).
11
- // The core never references HTMLElement, document, or any platform type.
12
-
13
- export interface Adapter {
14
- render(vm: ViewNode, onAction: (action: ActionEvent) => void): void;
15
- }
16
-
17
- // ─── Node types ───────────────────────────────────────────────────────────────
18
-
19
- export type ViewNode =
20
- | PageNode
21
- | SectionNode
22
- | ListNode
23
- | ListItemNode
24
- | FormNode
25
- | FieldNode
26
- | CheckboxNode
27
- | ButtonNode
28
- | TextNode
29
- | LinkNode
30
- | StatBarNode
31
- | TabsNode
32
- | ProgressNode
33
- | ModalNode
34
- | TableNode;
35
-
36
- export interface PageNode {
37
- type: "page";
38
- title?: string;
39
- children: ViewNode[];
40
- }
41
-
42
- export interface SectionNode {
43
- type: "section";
44
- heading?: string;
45
- children: ViewNode[];
46
- }
47
-
48
- export interface ListNode {
49
- type: "list";
50
- id?: string;
51
- children: ViewNode[];
52
- }
53
-
54
- export interface ListItemNode {
55
- type: "list-item";
56
- id?: string;
57
- /** Appended as a BEM modifier: vms-list-item--{variant} */
58
- variant?: string;
59
- children: ViewNode[];
60
- }
61
-
62
- export interface FormNode {
63
- type: "form";
64
- submitAction: ActionEvent;
65
- submitLabel?: string;
66
- children: ViewNode[];
67
- }
68
-
69
- export interface FieldNode {
70
- type: "field";
71
- name: string;
72
- inputType:
73
- | "text" | "email" | "password" | "number"
74
- | "date" | "time" | "datetime-local"
75
- | "textarea" | "hidden" | "file"
76
- | "select" | "select-multiple" | "checkbox"
77
- | "code";
78
- label?: string;
79
- placeholder?: string;
80
- value?: string;
81
- required?: boolean;
82
- options?: Array<{ value: string; label: string }>;
83
- /** Optional language hint for `inputType: "code"`. Emitted as a class
84
- * (`vms-field--code-{language}`) so apps can attach a syntax-highlighter
85
- * library (CodeMirror, Monaco, etc.) — the framework only ships
86
- * monospaced editable text, no coloring. */
87
- language?: string;
88
- /** Dispatched when Enter is pressed (text-like inputs only). Adapter merges { [name]: value } into context. */
89
- action?: ActionEvent;
90
- }
91
-
92
- export interface CheckboxNode {
93
- type: "checkbox";
94
- name: string;
95
- checked: boolean;
96
- label?: string;
97
- /** Dispatched immediately on change. Adapter merges { checked: boolean } into context. */
98
- action?: ActionEvent;
99
- }
100
-
101
- export interface ButtonNode {
102
- type: "button";
103
- label: string;
104
- action: ActionEvent;
105
- variant?: "primary" | "secondary" | "danger";
106
- }
107
-
108
- export interface TextNode {
109
- type: "text";
110
- value: string;
111
- style?: "heading" | "subheading" | "body" | "muted" | "strikethrough" | "error" | "pre";
112
- }
113
-
114
- export interface LinkNode {
115
- type: "link";
116
- label: string;
117
- href: string;
118
- /** true = open outside current app context (browser: new tab + noopener) */
119
- external?: boolean;
120
- }
121
-
122
- export interface StatBarNode {
123
- type: "stat-bar";
124
- stats: Array<{ label: string; value: string | number }>;
125
- }
126
-
127
- export interface TabsNode {
128
- type: "tabs";
129
- selected: string;
130
- /** Base action. Adapter merges { value: tab.value } into context on click. */
131
- action: ActionEvent;
132
- tabs: Array<{ value: string; label: string }>;
133
- }
134
-
135
- export interface ProgressNode {
136
- type: "progress";
137
- value: number; // 0–100
138
- }
139
-
140
- export interface ModalNode {
141
- type: "modal";
142
- title?: string;
143
- children: ViewNode[];
144
- /** Optional footer row (typically holds action buttons). Rendered as an inline row at the bottom of the modal. */
145
- footer?: ViewNode[];
146
- /** Dispatched when the close button is clicked. If omitted, no close button is rendered. */
147
- dismissAction?: ActionEvent;
148
- /** Width variant. Default is "medium" (~520px). "wide" (~800px) suits tables/dashboards;
149
- * "fullscreen" (~95vw/95vh) for content that needs the whole viewport. */
150
- size?: "narrow" | "medium" | "wide" | "fullscreen";
151
- }
152
-
153
- export interface TableColumn {
154
- key: string;
155
- label: string;
156
- sortable?: boolean;
157
- filterable?: boolean;
158
- filterValue?: string;
159
- /** If set, cell values render as <a href={value}>{linkLabel}</a> */
160
- linkLabel?: string;
161
- /** true = open outside current app context (browser: new tab + noopener) */
162
- linkExternal?: boolean;
163
- }
164
-
165
- export interface TableRow {
166
- id?: string;
167
- cells: Record<string, string>;
168
- action?: ActionEvent;
169
- variant?: string;
170
- }
171
-
172
- export interface TableNode {
173
- type: "table";
174
- columns: TableColumn[];
175
- rows: TableRow[];
176
- sortColumn?: string;
177
- sortDirection?: "asc" | "desc";
178
- /** Base action. Adapter merges { column, direction } into context on header click. */
179
- sortAction?: ActionEvent;
180
- /** Base action. Adapter merges { column, value, filters } into context on Enter. */
181
- filterAction?: ActionEvent;
182
- }
183
-
184
- // ─── Shell ────────────────────────────────────────────────────────────────────
185
- // Owns the fetch → render → action → fetch cycle.
186
- // fetch is universal (browsers, Node 18+, Deno) so it belongs in the core.
187
-
188
- export interface ShellOptions {
189
- endpoint: string;
190
- actionEndpoint: string;
191
- adapter: Adapter;
192
- onError?: (err: Error) => void;
193
- onLoading?: (loading: boolean) => void;
194
- /** Called before each dispatch — merge the returned headers into every POST request. */
195
- getRequestHeaders?: () => Record<string, string> | Promise<Record<string, string>>;
196
- /** Called when the server responds with a redirect URL. Defaults to window.location.href = url. */
197
- onRedirect?: (url: string) => void;
198
- /** When set, the shell dispatches a "poll" action at this interval (ms) after every load/dispatch.
199
- * The server can override the next interval via ShellResponse.nextPollIn, or stop polling by
200
- * omitting nextPollIn when no pollInterval is configured. */
201
- pollInterval?: number;
202
- }
203
-
204
- export interface ShellSideEffect {
205
- /** "set-local-storage" | "set-session-storage" — unknown types are silently ignored. */
206
- type: string;
207
- key?: string;
208
- value?: string;
209
- }
210
-
211
- export interface ShellResponse {
212
- vm: ViewNode;
213
- state: unknown;
214
- /** When set, the shell navigates to this URL instead of re-rendering. */
215
- redirect?: string;
216
- /** Applied in order before redirect or re-render. */
217
- sideEffects?: ShellSideEffect[];
218
- /** When set, schedules the next poll at this delay (ms). Overrides pollInterval for one tick. */
219
- nextPollIn?: number;
220
- }
221
-
222
- export class ViewModelShell {
223
- private currentVm: ViewNode | null = null;
224
- private currentState: unknown = null;
225
- private dispatching = false;
226
- private pollTimer: ReturnType<typeof setTimeout> | null = null;
227
-
228
- constructor(private options: ShellOptions) {}
229
-
230
- async load(params?: Record<string, string>): Promise<void> {
231
- const { endpoint, adapter, onError, onLoading } = this.options;
232
- this.stopPolling();
233
- try {
234
- onLoading?.(true);
235
- const url = params ? `${endpoint}?${new URLSearchParams(params)}` : endpoint;
236
- const extraHeaders = this.options.getRequestHeaders ? await this.options.getRequestHeaders() : {};
237
- const res = await fetch(url, { headers: { Accept: "application/json", ...extraHeaders } });
238
- if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
239
- const body = (await res.json()) as ShellResponse;
240
- this.currentVm = body.vm;
241
- this.currentState = body.state;
242
- adapter.render(body.vm, (action) => this.dispatch(action));
243
- this.schedulePoll(body.nextPollIn);
244
- } catch (err) {
245
- const error = err instanceof Error ? err : new Error(String(err));
246
- onError ? onError(error) : console.error("[ViewModelShell]", error);
247
- } finally {
248
- onLoading?.(false);
249
- }
250
- }
251
-
252
- async dispatch(action: ActionEvent, silent = false): Promise<void> {
253
- if (this.dispatching) return;
254
- const { actionEndpoint, onError, onLoading } = this.options;
255
- if (this.currentState === null) {
256
- const err = new Error(
257
- `Cannot dispatch '${action.name}' before initial load completes. ` +
258
- `Call shell.load() and wait for it before allowing user interaction.`
259
- );
260
- onError ? onError(err) : console.error("[ViewModelShell]", err);
261
- return;
262
- }
263
- try {
264
- this.dispatching = true;
265
- if (!silent) onLoading?.(true);
266
- const form = new FormData();
267
- form.append("_action", JSON.stringify({ name: action.name, context: action.context ?? {} }));
268
- form.append("_state", JSON.stringify(this.currentState));
269
- if (action.files) {
270
- for (const [name, file] of Object.entries(action.files)) {
271
- form.append(name, file);
272
- }
273
- }
274
- const extraHeaders = this.options.getRequestHeaders ? await this.options.getRequestHeaders() : {};
275
- const res = await fetch(actionEndpoint, {
276
- method: "POST",
277
- headers: { Accept: "application/json", ...extraHeaders },
278
- body: form,
279
- });
280
- if (!res.ok) throw new Error(`Action '${action.name}' failed: ${res.status}`);
281
- this.processResponse((await res.json()) as ShellResponse);
282
- } catch (err) {
283
- const error = err instanceof Error ? err : new Error(String(err));
284
- onError ? onError(error) : console.error("[ViewModelShell]", error);
285
- } finally {
286
- this.dispatching = false;
287
- if (!silent) onLoading?.(false);
288
- }
289
- }
290
-
291
- /** Feed a pre-parsed ShellResponse into the shell — for SSE/WebSocket integrations. */
292
- push(response: ShellResponse): void {
293
- if (this.dispatching) return;
294
- this.processResponse(response);
295
- }
296
-
297
- stopPolling(): void {
298
- if (this.pollTimer) { clearTimeout(this.pollTimer); this.pollTimer = null; }
299
- }
300
-
301
- getCurrentVm(): ViewNode | null { return this.currentVm; }
302
- getCurrentState(): unknown { return this.currentState; }
303
-
304
- private processResponse(body: ShellResponse): void {
305
- for (const effect of body.sideEffects ?? []) {
306
- if (effect.type === "set-local-storage" && effect.key != null) {
307
- localStorage.setItem(effect.key, effect.value ?? "");
308
- } else if (effect.type === "set-session-storage" && effect.key != null) {
309
- sessionStorage.setItem(effect.key, effect.value ?? "");
310
- }
311
- }
312
- if (body.redirect) {
313
- if (this.options.onRedirect) {
314
- this.options.onRedirect(body.redirect);
315
- } else {
316
- window.location.href = body.redirect;
317
- }
318
- return;
319
- }
320
- this.currentVm = body.vm!;
321
- this.currentState = body.state;
322
- this.options.adapter.render(body.vm!, (a) => this.dispatch(a));
323
- this.schedulePoll(body.nextPollIn);
324
- }
325
-
326
- private schedulePoll(nextPollIn?: number): void {
327
- const delay = nextPollIn ?? this.options.pollInterval;
328
- if (delay == null) return;
329
- if (this.pollTimer) clearTimeout(this.pollTimer);
330
- this.pollTimer = setTimeout(() => {
331
- this.pollTimer = null;
332
- this.dispatch({ name: "poll" }, true);
333
- }, delay);
334
- }
335
- }