jops-core 1.0.14 → 1.1.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/README.md +346 -28
- package/bin/init.js +214 -21
- package/lib/jops-core.d.ts +251 -15
- package/lib/jops-core.min.js +1 -1
- package/lib/nextjs/JopsApp.d.ts +35 -0
- package/lib/nextjs/JopsApp.js +50 -0
- package/package.json +33 -5
- package/templates/nextjs-jops-spa/README.md +34 -0
- package/templates/nextjs-jops-spa/data/quotes.json +62 -0
- package/templates/nextjs-jops-spa/gitignore +21 -0
- package/templates/nextjs-jops-spa/next.config.ts +15 -0
- package/templates/nextjs-jops-spa/package.json +25 -0
- package/templates/nextjs-jops-spa/public/jops-spa/App.js +10 -0
- package/templates/nextjs-jops-spa/public/jops-spa/Login.js +26 -0
- package/templates/nextjs-jops-spa/public/jops-spa/Quotes.js +102 -0
- package/templates/nextjs-jops-spa/public/jops-spa/api.js +15 -0
- package/templates/nextjs-jops-spa/public/jops-spa/badge.js +8 -0
- package/templates/nextjs-jops-spa/public/jops-spa/format.js +5 -0
- package/templates/nextjs-jops-spa/public/jops-spa/layout/login.html +34 -0
- package/templates/nextjs-jops-spa/public/jops-spa/layout/main.html +17 -0
- package/templates/nextjs-jops-spa/public/jops-spa/layout/quotes.html +50 -0
- package/templates/nextjs-jops-spa/public/jops-spa/layout/quotes_row.html +8 -0
- package/templates/nextjs-jops-spa/public/jops-spa/skeleton.js +7 -0
- package/templates/nextjs-jops-spa/src/app/about/page.tsx +59 -0
- package/templates/nextjs-jops-spa/src/app/api/quotes/route.ts +11 -0
- package/templates/nextjs-jops-spa/src/app/globals.css +6 -0
- package/templates/nextjs-jops-spa/src/app/jops-spa/page.tsx +11 -0
- package/templates/nextjs-jops-spa/src/app/layout.tsx +27 -0
- package/templates/nextjs-jops-spa/src/app/page.tsx +5 -0
- package/templates/nextjs-jops-spa/tsconfig.json +21 -0
package/lib/jops-core.d.ts
CHANGED
|
@@ -1,3 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Starts a JOPS app inside a given element instead of `<body>`. Used by hosts
|
|
3
|
+
* that control when and where the app runs, such as `<JopsApp>` in Next.js
|
|
4
|
+
* (jops-core loaded with `data-host`, which exposes `window.JOPS.mount`).
|
|
5
|
+
* @param {{ app: string, root: HTMLElement }} options - `app`: URL of the app's
|
|
6
|
+
* entry module (default export is the App class); `root`: element the app's
|
|
7
|
+
* root view renders into.
|
|
8
|
+
* @returns {Promise<object>} The App instance.
|
|
9
|
+
* @example
|
|
10
|
+
* await mount({ app: "/quotes/App.js", root: document.getElementById("quotes") });
|
|
11
|
+
*/
|
|
12
|
+
export declare function mount({ app, root }: {
|
|
13
|
+
app: string;
|
|
14
|
+
root: HTMLElement;
|
|
15
|
+
}): Promise<object>;
|
|
16
|
+
/**
|
|
17
|
+
* Ends the hosted app completely, like closing a standalone app: destroys the
|
|
18
|
+
* Router (listeners, singleton, routes, stacked views) and every view, empties
|
|
19
|
+
* the host root, clears the Store and the EventBus, and restores the host's
|
|
20
|
+
* history methods. The current URL and history entry are left alone: they
|
|
21
|
+
* belong to the host page. Safe to call more than once.
|
|
22
|
+
*
|
|
23
|
+
* Called by the Router itself when history leaves the app's page (Back past
|
|
24
|
+
* the entry the app was launched from, or a host link to another page), and
|
|
25
|
+
* by hosts such as `<JopsApp>` when they remove the app.
|
|
26
|
+
* @example
|
|
27
|
+
* window.JOPS.unmount();
|
|
28
|
+
*/
|
|
29
|
+
export declare function unmount(): void;
|
|
1
30
|
declare class RawHTML {
|
|
2
31
|
html: any;
|
|
3
32
|
constructor(html: any);
|
|
@@ -26,13 +55,43 @@ export declare function layout(strings: TemplateStringsArray, ...values: any[]):
|
|
|
26
55
|
* Wraps a single child element for reading and writing.
|
|
27
56
|
*/
|
|
28
57
|
declare class Element {
|
|
29
|
-
|
|
30
|
-
|
|
58
|
+
#private;
|
|
59
|
+
id: string;
|
|
60
|
+
inert: boolean;
|
|
61
|
+
element: globalThis.Element;
|
|
31
62
|
/**
|
|
32
|
-
*
|
|
33
|
-
* @param {string}
|
|
63
|
+
* @param {HTMLElement[]} roots - The view's root elements, searched in order.
|
|
64
|
+
* @param {string} id - The id to look up; the first root that has it wins.
|
|
34
65
|
*/
|
|
35
|
-
|
|
66
|
+
constructor(roots: HTMLElement[], id: string);
|
|
67
|
+
/**
|
|
68
|
+
* Wraps an element that was already found (used by `View.find(id)`).
|
|
69
|
+
* @param {HTMLElement} element
|
|
70
|
+
* @param {string} id
|
|
71
|
+
* @returns {Element}
|
|
72
|
+
*/
|
|
73
|
+
static from(element: HTMLElement, id: string): Element;
|
|
74
|
+
/**
|
|
75
|
+
* Sets the `innerHTML` of the element. The value is not escaped: pass values
|
|
76
|
+
* from users or the network through the `layout` tag.
|
|
77
|
+
* @param {string} html - Plain text or HTML string.
|
|
78
|
+
* @returns {Element} This wrapper, for chaining.
|
|
79
|
+
* @example
|
|
80
|
+
* this.get("title").set(layout`${quote.number}`);
|
|
81
|
+
*/
|
|
82
|
+
set(html: string): Element;
|
|
83
|
+
/**
|
|
84
|
+
* Sets an attribute, or removes it when `value` is `null` or `undefined`.
|
|
85
|
+
* An empty string sets a boolean attribute (`disabled`, `hidden`, `required`).
|
|
86
|
+
* @param {string} name - Attribute name, e.g. "href".
|
|
87
|
+
* @param {string|number|boolean|null} [value] - Attribute value.
|
|
88
|
+
* @returns {Element} This wrapper, for chaining.
|
|
89
|
+
* @example
|
|
90
|
+
* this.get("quote_id").set(layout`${q.number}`).attr("href", `#quote?id=${q.id}`);
|
|
91
|
+
* this.get("save").attr("disabled", ""); // set
|
|
92
|
+
* this.get("save").attr("disabled", null); // remove
|
|
93
|
+
*/
|
|
94
|
+
attr(name: string, value?: string | number | boolean | null): Element;
|
|
36
95
|
/**
|
|
37
96
|
* Returns the current `innerHTML` of the element.
|
|
38
97
|
* @returns {string}
|
|
@@ -46,9 +105,16 @@ declare class Element {
|
|
|
46
105
|
* 1. {@link View#loadLayout} — load or assign `this.layout`
|
|
47
106
|
* 2. {@link View#inflate} — parse `this.layout` into `this.domNode`
|
|
48
107
|
* 3. {@link View#onLayout} — DOM is live; add subviews, read initial data
|
|
49
|
-
* 4. {@link View#onResume} — view becomes visible; called again on every return navigation
|
|
108
|
+
* 4. {@link View#onResume} — view becomes visible; called again on every return navigation,
|
|
109
|
+
* and when the same route is shown again with new parameters
|
|
50
110
|
* 5. {@link View#bindEvents} — wire `on*` attributes to class methods
|
|
51
111
|
*
|
|
112
|
+
* Optional hooks, also called by the framework:
|
|
113
|
+
* - `onPause()` — a different view comes to the front and this one stays alive
|
|
114
|
+
* (hidden route, or covered by a stacked view). Not called when the view is destroyed
|
|
115
|
+
* (Back from a stacked view, a stack closed by a flat link, unmount()).
|
|
116
|
+
* - `onBackPressed()` — Back navigation from this view (called before onPause() for routes).
|
|
117
|
+
*
|
|
52
118
|
* @example
|
|
53
119
|
* import { View, layout } from "jops-core";
|
|
54
120
|
*
|
|
@@ -63,17 +129,35 @@ declare class Element {
|
|
|
63
129
|
*/
|
|
64
130
|
export declare class View {
|
|
65
131
|
#private;
|
|
132
|
+
_generation: number;
|
|
66
133
|
subViews: any[];
|
|
67
134
|
layout: any;
|
|
68
135
|
style: string;
|
|
136
|
+
domNodes: any[];
|
|
69
137
|
domNode: any;
|
|
138
|
+
_destroyed: boolean;
|
|
139
|
+
_borrowedDomNode: boolean;
|
|
70
140
|
/**
|
|
71
141
|
* @param {string} [layout] - Optional layout path or inline HTML string.
|
|
142
|
+
* @param {Record<string, string>} [props] - Input properties. A view declared
|
|
143
|
+
* in a layout (`<Card jops src="…" title="Hi">`) gets them from the tag's
|
|
144
|
+
* attributes automatically; a view created in code may pass them here.
|
|
72
145
|
*/
|
|
73
|
-
constructor(layout?: string);
|
|
146
|
+
constructor(layout?: string, props?: Record<string, string>);
|
|
147
|
+
/**
|
|
148
|
+
* The view's input properties (read-only): the attributes of its declared
|
|
149
|
+
* jops tag (except `jops`, `type="jops"`, `src`, `layout`, `path`,
|
|
150
|
+
* `jops-event-bind`), names in camelCase, values as strings; or the props
|
|
151
|
+
* passed to the constructor. `{}` when there are none.
|
|
152
|
+
* @returns {Readonly<Record<string, string>>}
|
|
153
|
+
*/
|
|
154
|
+
get props(): Readonly<Record<string, string>>;
|
|
74
155
|
/**
|
|
75
156
|
* Override to load a layout file or set `this.layout` inline.
|
|
76
157
|
* Called automatically by the framework before `inflate()`.
|
|
158
|
+
* Loads once per view: overlapping calls (with or without a path) wait for
|
|
159
|
+
* the load in progress, and once a layout is set later calls return at once.
|
|
160
|
+
* A failed load leaves the view unloaded, so it can be retried.
|
|
77
161
|
* @param {string} [layout] - Path to a `.js` layout module, a `.html` file, or an inline HTML string.
|
|
78
162
|
* `.js` — dynamically imported as an ES module; must export a `layout\`...\`` string as default.
|
|
79
163
|
* `.html` — fetched and cached for the page lifetime; plain HTML fragment, no import/export needed.
|
|
@@ -83,6 +167,7 @@ export declare class View {
|
|
|
83
167
|
loadLayout(layout?: string): Promise<void>;
|
|
84
168
|
/**
|
|
85
169
|
* Injects a `.css` file link or an inline `<style>` tag into `<head>`, deduped.
|
|
170
|
+
* No effect in hosted mode (e.g. Next.js through `<JopsApp>`): the host owns the CSS.
|
|
86
171
|
* @param {string} style - Path to a `.css` file, or a raw CSS string.
|
|
87
172
|
*/
|
|
88
173
|
setStyle(style: string): void;
|
|
@@ -94,6 +179,19 @@ export declare class View {
|
|
|
94
179
|
* this.get("username").set("Alice");
|
|
95
180
|
*/
|
|
96
181
|
get(id: string): Element;
|
|
182
|
+
/**
|
|
183
|
+
* The view's root elements: every top-level element of its layout (a layout
|
|
184
|
+
* may have more than one), or the borrowed container of a root view.
|
|
185
|
+
* @returns {HTMLElement[]}
|
|
186
|
+
* @protected
|
|
187
|
+
*/
|
|
188
|
+
protected _roots(): HTMLElement[];
|
|
189
|
+
/**
|
|
190
|
+
* Hides or shows every root element (used by the Router).
|
|
191
|
+
* @param {boolean} hidden
|
|
192
|
+
* @protected
|
|
193
|
+
*/
|
|
194
|
+
protected _setHidden(hidden: boolean): void;
|
|
97
195
|
/**
|
|
98
196
|
* Wires `on*` HTML attributes on `[jops-event-bind]` elements to
|
|
99
197
|
* same-named methods on this view instance.
|
|
@@ -108,6 +206,7 @@ export declare class View {
|
|
|
108
206
|
/**
|
|
109
207
|
* Appends `view` into the child element with `id="parentNode"` and runs its
|
|
110
208
|
* full lifecycle (`onLayout` → `onResume` → `bindEvents`).
|
|
209
|
+
* A removed (destroyed) view is final: create a new instance to show it again.
|
|
111
210
|
* @param {string} parentNode - The `id` of the container element inside `this.domNode`.
|
|
112
211
|
* @param {View} view - The subview to mount.
|
|
113
212
|
* @returns {Promise<void>}
|
|
@@ -121,14 +220,50 @@ export declare class View {
|
|
|
121
220
|
* Removes the inline `display` override, restoring `this.domNode` visibility.
|
|
122
221
|
*/
|
|
123
222
|
show(): void;
|
|
223
|
+
/**
|
|
224
|
+
* Every element with the given id across all root elements of the layout
|
|
225
|
+
* (and the DOM of its sub-views), in page order.
|
|
226
|
+
* @overload
|
|
227
|
+
* @param {string} id - Element id.
|
|
228
|
+
* @returns {Element[]}
|
|
229
|
+
* @example
|
|
230
|
+
* this.find("quote_id").forEach((el) => el.attr("tabindex", 0));
|
|
231
|
+
*/
|
|
124
232
|
/**
|
|
125
233
|
* Depth-first search across the full subview subtree.
|
|
126
|
-
* @
|
|
234
|
+
* @overload
|
|
235
|
+
* @param {(view: View) => boolean} condition - Predicate to match against each view.
|
|
127
236
|
* @returns {View[]} Flat array of all matching views.
|
|
128
237
|
* @example
|
|
129
238
|
* const forms = root.find(v => v instanceof FormView);
|
|
130
239
|
*/
|
|
131
|
-
|
|
240
|
+
/**
|
|
241
|
+
* @param {string | ((view: View) => boolean)} arg
|
|
242
|
+
* @returns {Element[] | View[]}
|
|
243
|
+
*/
|
|
244
|
+
find(id: string): Element[];
|
|
245
|
+
/**
|
|
246
|
+
* Every element with the given id across all root elements of the layout
|
|
247
|
+
* (and the DOM of its sub-views), in page order.
|
|
248
|
+
* @overload
|
|
249
|
+
* @param {string} id - Element id.
|
|
250
|
+
* @returns {Element[]}
|
|
251
|
+
* @example
|
|
252
|
+
* this.find("quote_id").forEach((el) => el.attr("tabindex", 0));
|
|
253
|
+
*/
|
|
254
|
+
/**
|
|
255
|
+
* Depth-first search across the full subview subtree.
|
|
256
|
+
* @overload
|
|
257
|
+
* @param {(view: View) => boolean} condition - Predicate to match against each view.
|
|
258
|
+
* @returns {View[]} Flat array of all matching views.
|
|
259
|
+
* @example
|
|
260
|
+
* const forms = root.find(v => v instanceof FormView);
|
|
261
|
+
*/
|
|
262
|
+
/**
|
|
263
|
+
* @param {string | ((view: View) => boolean)} arg
|
|
264
|
+
* @returns {Element[] | View[]}
|
|
265
|
+
*/
|
|
266
|
+
find(condition: (view: View) => boolean): View[];
|
|
132
267
|
/**
|
|
133
268
|
* Recursively destroys the subview subtree: calls `destroy()` on all
|
|
134
269
|
* subviews, removes `this.domNode` from the DOM, and nulls references.
|
|
@@ -141,11 +276,21 @@ export declare class View {
|
|
|
141
276
|
removeSubView(view: View): void;
|
|
142
277
|
/**
|
|
143
278
|
* Inserts `view` at `index` within `this.domNode` and runs its full lifecycle.
|
|
279
|
+
* A removed (destroyed) view is final: create a new instance to show it again.
|
|
144
280
|
* @param {View} view - The subview to insert.
|
|
145
281
|
* @param {number} index - Zero-based insertion position.
|
|
146
282
|
* @returns {Promise<void>}
|
|
147
283
|
*/
|
|
148
284
|
insertSubView(view: View, index: number): Promise<void>;
|
|
285
|
+
/**
|
|
286
|
+
* Final lifecycle steps for a sub-view declared in this view's layout,
|
|
287
|
+
* called by `render()` after its `onLayout()`. Router overrides it so a
|
|
288
|
+
* route is resumed only when it is shown.
|
|
289
|
+
* @param {View} subView
|
|
290
|
+
* @returns {Promise<void>}
|
|
291
|
+
* @protected
|
|
292
|
+
*/
|
|
293
|
+
protected _startSubView(subView: View): Promise<void>;
|
|
149
294
|
/**
|
|
150
295
|
* Mounts the view to the DOM, resolves all nested `[jops]` view
|
|
151
296
|
* elements and `<Router>` declarations, and runs each one's full lifecycle.
|
|
@@ -159,30 +304,83 @@ export declare class View {
|
|
|
159
304
|
* Static pub/sub event bus for cross-view communication.
|
|
160
305
|
* All methods are static — no instance needed.
|
|
161
306
|
*
|
|
307
|
+
* A subscription with an owner (a view) is removed automatically when the
|
|
308
|
+
* owner is destroyed; a global subscription (no owner) stays until it is
|
|
309
|
+
* unsubscribed (the developer's responsibility).
|
|
310
|
+
*
|
|
162
311
|
* @example
|
|
163
|
-
* EventBus.subscribe("
|
|
312
|
+
* EventBus.subscribe(this, "quotes:changed", (q) => this.refresh(q)); // owned
|
|
313
|
+
* EventBus.subscribe("user:login", onLogin); // global
|
|
164
314
|
* EventBus.send("user:login", { name: "Alice" });
|
|
165
315
|
*/
|
|
166
316
|
export declare class EventBus {
|
|
167
317
|
#private;
|
|
168
318
|
/**
|
|
169
|
-
* Registers a listener
|
|
319
|
+
* Registers a global listener; unsubscribe it with `unsubscribe()`.
|
|
320
|
+
* @overload
|
|
321
|
+
* @param {string} event - Event name.
|
|
322
|
+
* @param {(data: any) => void} callback - Handler called with the event payload.
|
|
323
|
+
* @returns {void}
|
|
324
|
+
*/
|
|
325
|
+
/**
|
|
326
|
+
* Registers a listener owned by `owner` (usually a view): it is removed
|
|
327
|
+
* automatically when the owner is destroyed.
|
|
328
|
+
* @overload
|
|
329
|
+
* @param {object} owner - The object owning the subscription, e.g. `this`.
|
|
330
|
+
* @param {string} event - Event name.
|
|
331
|
+
* @param {(data: any) => void} callback - Handler called with the event payload.
|
|
332
|
+
* @returns {void}
|
|
333
|
+
*/
|
|
334
|
+
/**
|
|
335
|
+
* @param {...*} args - `(event, callback)` or `(owner, event, callback)`.
|
|
336
|
+
* @returns {void}
|
|
337
|
+
*/
|
|
338
|
+
static subscribe(event: string, callback: (data: any) => void): void;
|
|
339
|
+
/**
|
|
340
|
+
* Registers a global listener; unsubscribe it with `unsubscribe()`.
|
|
341
|
+
* @overload
|
|
342
|
+
* @param {string} event - Event name.
|
|
343
|
+
* @param {(data: any) => void} callback - Handler called with the event payload.
|
|
344
|
+
* @returns {void}
|
|
345
|
+
*/
|
|
346
|
+
/**
|
|
347
|
+
* Registers a listener owned by `owner` (usually a view): it is removed
|
|
348
|
+
* automatically when the owner is destroyed.
|
|
349
|
+
* @overload
|
|
350
|
+
* @param {object} owner - The object owning the subscription, e.g. `this`.
|
|
170
351
|
* @param {string} event - Event name.
|
|
171
|
-
* @param {
|
|
352
|
+
* @param {(data: any) => void} callback - Handler called with the event payload.
|
|
353
|
+
* @returns {void}
|
|
172
354
|
*/
|
|
173
|
-
static subscribe(event: string, callback: Function): void;
|
|
174
355
|
/**
|
|
175
|
-
*
|
|
356
|
+
* @param {...*} args - `(event, callback)` or `(owner, event, callback)`.
|
|
357
|
+
* @returns {void}
|
|
358
|
+
*/
|
|
359
|
+
static subscribe(owner: object, event: string, callback: (data: any) => void): void;
|
|
360
|
+
/**
|
|
361
|
+
* Fires the named event to all subscribers. Subscriptions of destroyed
|
|
362
|
+
* owners are skipped and removed (a safety net: `destroy()` normally
|
|
363
|
+
* removes them first).
|
|
176
364
|
* @param {string} event - Event name.
|
|
177
365
|
* @param {*} [data] - Optional payload passed to every subscriber.
|
|
178
366
|
*/
|
|
179
367
|
static send(event: string, data?: any): void;
|
|
180
368
|
/**
|
|
181
|
-
* Removes a specific listener by reference.
|
|
369
|
+
* Removes a specific listener by reference (global or owned).
|
|
182
370
|
* @param {string} event - Event name.
|
|
183
371
|
* @param {function(*): void} callback - The exact function reference passed to `subscribe`.
|
|
184
372
|
*/
|
|
185
373
|
static unsubscribe(event: string, callback: Function): void;
|
|
374
|
+
/**
|
|
375
|
+
* Removes every subscription owned by `owner`, across all events (called by
|
|
376
|
+
* `View.destroy()`). Global subscriptions are not touched.
|
|
377
|
+
* @param {object} owner - The owner passed to `subscribe(owner, event, callback)`.
|
|
378
|
+
*/
|
|
379
|
+
static unsubscribeAll(owner: object): void;
|
|
380
|
+
/**
|
|
381
|
+
* Removes every listener of every event (called by `unmount()`).
|
|
382
|
+
*/
|
|
383
|
+
static clear(): void;
|
|
186
384
|
}
|
|
187
385
|
/**
|
|
188
386
|
* App-wide runtime state container. Singleton. No reactivity.
|
|
@@ -244,7 +442,10 @@ export declare class Router extends View {
|
|
|
244
442
|
routes: {};
|
|
245
443
|
_navStack: any[];
|
|
246
444
|
_baseView: any;
|
|
445
|
+
_defaultPath: string;
|
|
446
|
+
_pathname: string;
|
|
247
447
|
_idx: any;
|
|
448
|
+
_historyListener: () => void;
|
|
248
449
|
_currentView: any;
|
|
249
450
|
/**
|
|
250
451
|
* Returns the single Router instance after it has been rendered.
|
|
@@ -253,6 +454,15 @@ export declare class Router extends View {
|
|
|
253
454
|
static singleton(): Router;
|
|
254
455
|
constructor(routerDomNode: any);
|
|
255
456
|
onLayout(): Promise<void>;
|
|
457
|
+
/**
|
|
458
|
+
* Routes only get their events bound while the Router renders; `onResume()`
|
|
459
|
+
* runs when a route is shown (`_switchTo()` and the transitions), so hidden
|
|
460
|
+
* routes are not resumed at startup.
|
|
461
|
+
* @param {View} subView
|
|
462
|
+
* @returns {Promise<void>}
|
|
463
|
+
* @protected
|
|
464
|
+
*/
|
|
465
|
+
protected _startSubView(subView: View): Promise<void>;
|
|
256
466
|
initRouter(): void;
|
|
257
467
|
/**
|
|
258
468
|
* Single entry point for popstate and hashchange. Decides the direction
|
|
@@ -261,7 +471,33 @@ export declare class Router extends View {
|
|
|
261
471
|
_onHistoryChange(): void;
|
|
262
472
|
_onBack(): void;
|
|
263
473
|
_onForward(): void;
|
|
474
|
+
/** Destroys every stacked navigate() view (switching to a flat route). */
|
|
475
|
+
_closeStack(): void;
|
|
476
|
+
/**
|
|
477
|
+
* onPause() on the view leaving the front: a different view is coming to
|
|
478
|
+
* the front and the current one stays alive (hidden or covered). Not used
|
|
479
|
+
* when a view is destroyed (stack pop on Back, _closeStack(), unmount()).
|
|
480
|
+
* @param {View} incoming
|
|
481
|
+
*/
|
|
482
|
+
_pauseCurrent(incoming: View): void;
|
|
264
483
|
_switchToHash(): void;
|
|
484
|
+
/**
|
|
485
|
+
* Replaces the current entry's empty hash with the default (first) route,
|
|
486
|
+
* keeping the existing history state, and shows that route.
|
|
487
|
+
*/
|
|
488
|
+
_switchToDefault(): void;
|
|
489
|
+
/**
|
|
490
|
+
* Removes the history listeners, destroys the routes and stacked views and
|
|
491
|
+
* clears the singleton. Called by `unmount()`; safe to call more than once.
|
|
492
|
+
*/
|
|
493
|
+
destroy(): void;
|
|
494
|
+
/** pushState/replaceState marked as JOPS's own (see installHostHistoryHook). */
|
|
495
|
+
_history(method: any, state: any, url: any): void;
|
|
496
|
+
/**
|
|
497
|
+
* Hosted mode: called after the host changed the URL with pushState or
|
|
498
|
+
* replaceState (no browser event fires for those).
|
|
499
|
+
*/
|
|
500
|
+
_onHostNavigation(): void;
|
|
265
501
|
_animateTransition(outgoing: any, incoming: any, direction: any): Promise<void>;
|
|
266
502
|
_slideTransition(outgoing: any, incoming: any, direction: any): Promise<void>;
|
|
267
503
|
_fadeTransition(outgoing: any, incoming: any): Promise<void>;
|
package/lib/jops-core.min.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
var R=Object.defineProperty;var d=(o,t)=>R(o,"name",{value:t,configurable:!0});var v=new URL(import.meta.url).origin!==location.origin,w=new Map;function f(o){if(!o)return o;if(v){if(o.startsWith("/"))return"."+o}else if(o.startsWith("./"))return o.slice(1);return o}d(f,"normalizePath");(()=>{console.log("JOPS initialized");let o=document.querySelector("script[data-app]"),t=o?.dataset.app??"./src/App.js";import(new URL(f(t),document.baseURI).href).then(e=>new e.default).catch(e=>console.error("JOPS: App.js not found or failed to load",e)),o?.dataset.nativeBack!=="off"&&V()})();function V(){document.addEventListener("deviceready",()=>{let o=window.Capacitor;o?.isNativePlatform?.()&&o.getPlatform()==="android"&&!o.isPluginAvailable("App")&&console.warn("JOPS: install @capacitor/app so the Android back button navigates instead of closing the app.");let t=0;document.addEventListener("backbutton",()=>{let e=Date.now();if(e-t<300)return;t=e;let s=m.singleton();s?.canGoBack()?s.back():!s&&window.navigation?.canGoBack?history.back():navigator.app.exitApp()})},{once:!0})}d(V,"installNativeBackButton");var p=class{static{d(this,"RawHTML")}constructor(t){this.html=t}},E=d(o=>new p(o),"raw");function P(o,...t){return o.reduce((e,s,i)=>{if(i>=t.length)return e+s;let n=t[i];return n==null?e+s:n instanceof p?e+s+n.html:e+s+String(n).replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""")},"")}d(P,"layout");var y=class{static{d(this,"Element")}constructor(t,e){this.element=t?.id===e?t:t.querySelector(`#${e}`)}set(t){this.element.innerHTML=t}value(){return this.element.innerHTML}},b=class o{static{d(this,"View")}#t=!1;#e=!1;constructor(t){this.loadLayout(t),this.subViews=[]}async loadLayout(t){if(t)if(t.endsWith(".js"))try{let e=await import(new URL(f(t),document.baseURI).href);this.layout=e.default}catch(e){console.error("JOPS: failed to load layout:",t,e)}else if(t.endsWith(".html")||t.endsWith(".htm"))try{let e=new URL(f(t),document.baseURI).href;if(w.has(e))this.layout=w.get(e);else{let s=await fetch(e);if(!s.ok)throw new Error(`HTTP ${s.status}`);let i=await s.text();w.set(e,i),this.layout=i}}catch(e){console.error("JOPS: failed to load layout:",t,e)}else this.layout=t}setStyle(t){if(t)if(this.style=t,t.endsWith(".css")){if(document.querySelector(`link[href="${t}"]`))return;let e=document.createElement("link");e.rel="stylesheet",e.href=t,document.head.appendChild(e)}else{if(document.querySelector(`style[data-jops="${t}"]`))return;let e=document.createElement("style");e.setAttribute("data-jops",t),e.textContent=t,document.head.appendChild(e)}}get(t){return new y(this.domNode,t)}bindEvents(){if(!this.domNode)return;let t=[...this.domNode.querySelectorAll("[jops-event-bind], [type='jops-event-bind']")];(this.domNode.hasAttribute("jops-event-bind")||this.domNode.getAttribute("type")==="jops-event-bind")&&t.unshift(this.domNode);for(let e of t)for(let s of e.attributes){if(!s.name.startsWith("on"))continue;let i=s.value.trim();typeof this[i]=="function"&&(e[s.name]=n=>this[i](n))}}inflate(){if(!this.layout||this.#t)return;this.#t=!0;let t=document.createElement("template");t.innerHTML=this.layout;let s=t.content.cloneNode(!0).firstElementChild;this.domNode=s}async addSubView(t,e){this.subViews.push(e),e.domNode||(await e.loadLayout(),e.inflate()),this.domNode.querySelector(`#${t}`).appendChild(e.domNode),await e?.onLayout?.(),await e?.onResume?.(),e?.bindEvents?.()}hide(){this.domNode.style.display="none"}show(){this.domNode.style.display=""}find(t){let e=[];for(let s of this.subViews)t(s)&&e.push(s),e.push(...s.find?.(t)??[]);return e}destroy(){this.subViews.forEach(t=>t.destroy?.()),this.subViews=[],this.domNode?.remove(),this.domNode=null,this.layout=null}removeSubView(t){t.destroy?.(),this.subViews=this.subViews.filter(e=>e!==t)}async insertSubView(t,e){t.domNode||(await t.loadLayout(),t.inflate());let s=this.domNode.children[e]??null;this.domNode.insertBefore(t.domNode,s),this.subViews.splice(e,0,t),await t?.onLayout?.(),await t?.onResume?.(),t?.bindEvents?.()}async render(){if(!this.layout||this.#e)return;this.#e=!0;let t=this.domNode??document.body;t===document.body&&(t.innerHTML=this.layout);let e=Array.from(t.querySelectorAll("[jops], [type='jops']")).filter(s=>s.tagName==="ROUTER"||!s.closest("router")||s.closest("router")===t);e.length!==0&&await Promise.all(e.map(async s=>{if(s.tagName==="ROUTER"){let c=new m(s);await c.render(),await c.onLayout();return}let i=s.getAttribute("layout"),n=s.getAttribute("src");if(!n&&!i)return;let r;if(n){let l=(await import(new URL(f(n),document.baseURI).href)).default;r=new l}else r=new o;this.subViews.push(r),await r.loadLayout(i);let a=document.createElement("template");a.innerHTML=r.layout??"";let h=a.content.cloneNode(!0);if(s.children.length>0){let c=h.firstElementChild??h;Array.from(s.children).forEach(l=>c.appendChild(l))}let u=h.firstElementChild;r.routePath=s.getAttribute("path"),s.replaceWith(h),r.domNode=u,await r.render(),await r?.onLayout?.(),await r?.onResume?.(),r?.bindEvents?.()}))}},_=class o{static{d(this,"EventBus")}static#t={};static subscribe(t,e){o.#t[t]||(o.#t[t]=[]),o.#t[t].push(e)}static send(t,e){o.#t[t]?.forEach(s=>s(e))}static unsubscribe(t,e){o.#t[t]&&(o.#t[t]=o.#t[t].filter(s=>s!==e))}},S=class o{static{d(this,"Store")}static#t=null;static singleton(){return o.#t||(o.#t=new o),o.#t}constructor(){this._state={}}get(t){return t.split(".").reduce((e,s)=>e?.[s],this._state)}set(t,e){let s=t.split("."),i=s.pop(),n=s.reduce((r,a)=>(r[a]||(r[a]={}),r[a]),this._state);n[i]=e}clear(){this._state={}}},m=class o extends b{static{d(this,"Router")}static#t=null;static singleton(){return o.#t}constructor(t){super(),o.#t=this,this.rootNode=t.parentNode,this.routerDomNode=t,this.domNode=t,this.layout=t.innerHTML,this.animation=t.getAttribute("animation")??"none"}async onLayout(){let t=document.createDocumentFragment();for(;this.routerDomNode.firstChild;)t.appendChild(this.routerDomNode.firstChild);this.routerDomNode.replaceWith(t),this.domNode=this.rootNode,this.initRouter()}initRouter(){this.routes={};for(let r of this.subViews)if(r.routePath){let a=r.routePath.slice(1).split("?")[0];this.routes[a]=r}this._navStack=[],this._baseView=null;for(let r of Object.values(this.routes))r.domNode.hidden=!0;let t=Object.keys(this.routes)[0],e=window.location.hash.slice(1),s=e.split("?")[0],i=s&&this.routes[s]?e:t;this._idx=history.state?.jopsIdx??0,history.replaceState({...history.state,jopsIdx:this._idx},"","#"+i);let n=d(()=>this._onHistoryChange(),"onHistoryChange");window.addEventListener("popstate",n),window.addEventListener("hashchange",n),this._switchTo(i)}_onHistoryChange(){let t=history.state?.jopsIdx;if(t===this._idx)return;if(t===void 0){this._idx+=1,history.replaceState({...history.state,jopsIdx:this._idx},"",location.href),this._onForward();return}let e=t<this._idx;this._idx=t,e?this._onBack():this._onForward()}_onBack(){if(this._navStack.length>0){let e=this._navStack.pop(),s=this._navStack.at(-1)??this._baseView;e?.onBackPressed?.(),this._animateTransition(e,s,"back").then(()=>e.destroy());return}let t=window.location.hash.slice(1).split("?")[0];if(!this.routes[t]){history.back();return}this._currentView?.onBackPressed?.(),this._switchToHash()}_onForward(){this._navStack.length>0&&(this._navStack.forEach(t=>t.destroy?.()),this._navStack=[],this._baseView=null),this._switchToHash()}_switchToHash(){let t=window.location.hash.slice(1),e=t.split("?")[0];e&&this.routes[e]&&this._switchTo(t)}async _animateTransition(t,e,s){this.animation==="slide"?await this._slideTransition(t,e,s):this.animation==="fade"?await this._fadeTransition(t,e):(t.domNode.hidden=!0,e.domNode.hidden=!1,this._currentView=e,e?.onResume?.())}async _slideTransition(t,e,s){let i=this.domNode,n=s==="forward",r=i.getBoundingClientRect(),a=t.domNode.getBoundingClientRect(),h=a.left-r.left,u=a.top-r.top,c=a.width,l=i.offsetWidth-h,N=`position:absolute;left:${h}px;top:${u}px;width:${c}px`;i.style.cssText=`position:relative;overflow:hidden;height:${i.offsetHeight}px`,t.domNode.style.cssText=`${N};z-index:${n?1:2}`,t.domNode.hidden=!1,e.domNode.style.cssText=`${N};z-index:${n?2:1}`,e.domNode.hidden=!1;let T=n?e.domNode:t.domNode,g=n?`${l}px`:"0px",L=n?"0px":`${l}px`,x=T.animate([{transform:`translateX(${g})`},{transform:`translateX(${L})`}],{duration:300,easing:"ease",fill:"forwards"});await x.finished,x.cancel(),t.domNode.hidden=!0,t.domNode.style.cssText="",e.domNode.style.cssText="",i.style.cssText="",this._currentView=e,e?.onResume?.()}async _fadeTransition(t,e){let s=this.domNode,i=s.getBoundingClientRect(),n=t.domNode.getBoundingClientRect(),r=n.left-i.left,a=n.top-i.top,h=n.width,u=`position:absolute;left:${r}px;top:${a}px;width:${h}px`;s.style.cssText=`position:relative;overflow:hidden;height:${s.offsetHeight}px`,t.domNode.style.cssText=u,t.domNode.hidden=!1,e.domNode.style.cssText=u,e.domNode.hidden=!1;let c=t.domNode.animate([{opacity:1},{opacity:0}],{duration:450,easing:"ease",fill:"forwards"}),l=e.domNode.animate([{opacity:0},{opacity:1}],{duration:450,easing:"ease",fill:"forwards"});await Promise.all([c.finished,l.finished]),c.cancel(),l.cancel(),t.domNode.hidden=!0,t.domNode.style.cssText="",e.domNode.style.cssText="",s.style.cssText="",this._currentView=e,e?.onResume?.()}_switchTo(t){let e=t.split("?")[0];for(let[s,i]of Object.entries(this.routes)){let n=s===e;i.domNode.hidden=!n,n&&(this._currentView=i,i?.onResume?.())}}back(){history.back()}canGoBack(){return this._idx>0}getParams(){let t=window.location.hash.split("?")[1];return t?Object.fromEntries(new URLSearchParams(t)):{}}async navigate(t,e){let[s,i]=t.replace(/^\//,"").split("?"),n=s,r=e??this.routes[n]?.constructor;if(!r){console.error(`JOPS Router: no class found for path "${t}"`);return}this._navStack.length===0&&(this._baseView=this._currentView);let a=new r;await a.loadLayout(),a.inflate(),this.domNode.appendChild(a.domNode),history.pushState({jopsNav:!0,jopsIdx:++this._idx},"","#"+n+(i?"?"+i:"")),await a.onLayout?.(),await a.onResume?.(),a.bindEvents?.(),this._navStack.push(a),this._animateTransition(this._currentView,a,"forward")}async replace(t,e){this._navStack.forEach(a=>a.destroy?.()),this._navStack=[];let[s,i]=t.replace(/^\//,"").split("?"),n=s,r="#"+n+(i?"?"+i:"");if(this.routes[n])history.replaceState({jopsIdx:this._idx},"",r),this._switchTo(n);else if(e){let a=new e;await a.loadLayout(),a.inflate(),this.domNode.appendChild(a.domNode),await a.onLayout?.(),await a.onResume?.(),a.bindEvents?.(),this._currentView&&(this._currentView.domNode.hidden=!0),this._currentView=a,history.replaceState({jopsIdx:this._idx},"",r)}else console.error(`JOPS Router: no instance or class found for path "${t}"`)}},k=class{static{d(this,"Thread")}#t=null;run(t){}onMessage(t){}start(t){if(typeof Worker>"u"){console.warn("JOPS Thread: Web Workers are not supported in this environment.");return}let e=this.run.toString();!e.startsWith("function")&&!e.startsWith("(")&&!e.startsWith("async")&&(e="function "+e);let s=new Blob([`const run = ${e};`,"self.onmessage = (e) => { run(e.data); };"],{type:"application/javascript"}),i=URL.createObjectURL(s);this.#t=new Worker(i),URL.revokeObjectURL(i),this.#t.onmessage=n=>this.onMessage(n),this.#t.postMessage(t)}terminate(){if(!this.#t){console.warn("JOPS Thread: no active worker to terminate.");return}this.#t.terminate(),this.#t=null}};export{_ as EventBus,m as Router,S as Store,k as Thread,b as View,P as layout,E as raw};
|
|
1
|
+
var W=Object.defineProperty;var h=(o,t)=>W(o,"name",{value:t,configurable:!0});var B=new URL(import.meta.url).origin!==location.origin,k=new Map;function b(o){if(!o)return o;if(B){if(o.startsWith("/"))return"."+o}else if(o.startsWith("./"))return o.slice(1);return o}h(b,"normalizePath");var u=null,S=!1,m=0,v=new Set,_=null,I=new Set(["jops","src","layout","path","jops-event-bind"]);function H(o){let t={};for(let{name:e,value:s}of o.attributes)I.has(e)||e==="type"&&s==="jops"||(t[e.replace(/-([a-z])/g,(n,i)=>i.toUpperCase())]=s);return t}h(H,"tagProps");var j=new Set,O=!1;(()=>{if(console.log("JOPS initialized"),Array.from(document.querySelectorAll("script[src]")).find(s=>s.src===import.meta.url)?.dataset.host!==void 0){S=!0,window.JOPS={mount:D,unmount:V},window.dispatchEvent(new Event("jops:ready"));return}let t=document.querySelector("script[data-app]"),e=t?.dataset.app??"./src/App.js";t?.dataset.root&&(u=document.getElementById(t.dataset.root),u||console.warn(`JOPS: data-root element #${t.dataset.root} not found, rendering into <body>`)),import(new URL(b(e),document.baseURI).href).then(s=>new s.default).catch(s=>console.error("JOPS: App.js not found or failed to load",s)),t?.dataset.nativeBack!=="off"&&K()})();var T=!1,N=!1,C={},$={};function M(){if(!N){N=!0;for(let o of["pushState","replaceState"]){let t=history[o],e=h(function(...s){let n=t.apply(this,s);return T||queueMicrotask(()=>y.singleton()?._onHostNavigation()),n},"wrapper");C[o]=t,$[o]=e,history[o]=e}}}h(M,"installHostHistoryHook");function q(){if(!N)return;let o=!0;for(let t of["pushState","replaceState"])history[t]===$[t]?history[t]=C[t]:o=!1;o&&(N=!1)}h(q,"uninstallHostHistoryHook");async function D({app:o,root:t}){if(!(t instanceof HTMLElement))throw new TypeError("JOPS mount(): root must be an HTMLElement");u=t,M();let e=m,s=await import(new URL(b(o),document.baseURI).href);return e!==m?null:new s.default}h(D,"mount");function V(){m+=1,y.singleton()?.destroy(),v.forEach(o=>o.destroy()),v.clear(),u&&(u.innerHTML=""),u=null,P.singleton().clear(),L.clear(),q()}h(V,"unmount");function K(){document.addEventListener("deviceready",()=>{let o=window.Capacitor;o?.isNativePlatform?.()&&o.getPlatform()==="android"&&!o.isPluginAvailable("App")&&console.warn("JOPS: install @capacitor/app so the Android back button navigates instead of closing the app.");let t=0;document.addEventListener("backbutton",()=>{let e=Date.now();if(e-t<300)return;t=e;let s=y.singleton();s?.canGoBack()?s.back():!s&&window.navigation?.canGoBack?history.back():navigator.app.exitApp()})},{once:!0})}h(K,"installNativeBackButton");var x=class{static{h(this,"RawHTML")}constructor(t){this.html=t}},z=h(o=>new x(o),"raw");function G(o,...t){return o.reduce((e,s,n)=>{if(n>=t.length)return e+s;let i=t[n];return i==null?e+s:i instanceof x?e+s+i.html:e+s+String(i).replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""")},"")}h(G,"layout");var g=class o{static{h(this,"Element")}constructor(t,e){this.id=e,this.inert=t.length===0,this.element=null;for(let s of t)if(this.element=s.id===e?s:s.querySelector(`[id="${CSS.escape(e)}"]`),this.element)break}static from(t,e){let s=new o([],e);return s.inert=!1,s.element=t,s}set(t){return this.element?(this.element.innerHTML=t,this):this.#t("set")}attr(t,e){return this.element?(e==null?this.element.removeAttribute(t):this.element.setAttribute(t,String(e)),this):this.#t("attr")}value(){return this.element?this.element.innerHTML:(this.#t("value"),"")}#t(t){return this.inert||console.warn(`JOPS: ${t}() on #${this.id}: no such element in the view`),this}},R=class o{static{h(this,"View")}#t=!1;#e=!1;#o=!1;#s=null;#i;constructor(t,e){this._generation=m;let s=_&&_.ClassDef===new.target?_.props:null;this.#i=Object.freeze({...e??s??{}}),this.loadLayout(t),this.subViews=[]}get props(){return this.#i}async loadLayout(t){if(this.#s)return this.#s;if(!(!t||this.#o))return this.#s=this.#n(t).finally(()=>{this.#o=this.layout!=null,this.#s=null}),this.#s}async#n(t){if(t.endsWith(".js"))try{let e=await import(new URL(b(t),document.baseURI).href);this.layout=e.default}catch(e){console.error("JOPS: failed to load layout:",t,e)}else if(t.endsWith(".html")||t.endsWith(".htm"))try{let e=new URL(b(t),document.baseURI).href;if(k.has(e))this.layout=k.get(e);else{let s=await fetch(e);if(!s.ok)throw new Error(`HTTP ${s.status}`);let n=await s.text();k.set(e,n),this.layout=n}}catch(e){console.error("JOPS: failed to load layout:",t,e)}else this.layout=t}setStyle(t){if(t){if(this.style=t,S){O||(O=!0,console.info("JOPS: setStyle() is ignored in hosted mode; the host provides the CSS."));return}if(t.endsWith(".css")){if(document.querySelector(`link[href="${CSS.escape(t)}"]`))return;let e=document.createElement("link");e.rel="stylesheet",e.href=t,document.head.appendChild(e)}else{if(j.has(t))return;j.add(t);let e=document.createElement("style");e.setAttribute("data-jops",""),e.textContent=t,document.head.appendChild(e)}}}get(t){return new g(this._roots(),t)}_roots(){return this.domNodes??(this.domNode?[this.domNode]:[])}_setHidden(t){for(let e of this._roots())e.hidden=t}bindEvents(){let t=this._roots().flatMap(e=>[...e.hasAttribute("jops-event-bind")||e.getAttribute("type")==="jops-event-bind"?[e]:[],...e.querySelectorAll("[jops-event-bind], [type='jops-event-bind']")]);for(let e of t)for(let s of e.attributes){if(!s.name.startsWith("on"))continue;let n=s.value.trim();typeof this[n]=="function"&&(e[s.name]=i=>this[n](i))}}inflate(){if(!this.layout||this.#t)return;this.#t=!0;let t=document.createElement("template");t.innerHTML=this.layout;let e=t.content.cloneNode(!0);this.domNodes=[...e.children],this.domNode=this.domNodes[0]}async addSubView(t,e){if(!this.domNode)return;if(e._destroyed){console.warn("JOPS: addSubView(): this view was destroyed; create a new instance");return}this.subViews.push(e),e.domNode||(await e.loadLayout(),e.inflate());let s=this.domNode?.querySelector(`#${t}`);if(!e.domNode||!s){this.subViews=this.subViews.filter(n=>n!==e),this.domNode&&console.warn(e.domNode?`JOPS: addSubView(): no element #${t} in the view`:"JOPS: addSubView(): the view has no layout");return}s.append(...e._roots()),await e?.onLayout?.(),await e?.onResume?.(),e?.bindEvents?.()}hide(){for(let t of this._roots())t.style.display="none"}show(){for(let t of this._roots())t.style.display=""}find(t){if(typeof t=="string")return this._roots().flatMap(s=>[...s.id===t?[s]:[],...s.querySelectorAll(`[id="${CSS.escape(t)}"]`)]).map(s=>g.from(s,t));let e=[];for(let s of this.subViews)t(s)&&e.push(s),e.push(...s.find?.(t)??[]);return e}destroy(){this._destroyed=!0,L.unsubscribeAll(this),this.subViews.forEach(e=>e.destroy?.()),this.subViews=[],this._borrowedDomNode||this.domNode&&(this.domNode===u||this.domNode===document.body)?this.domNode.innerHTML="":this._roots().forEach(e=>e.remove()),this.domNode=null,this.domNodes=null,this.layout=null}removeSubView(t){t.destroy?.(),this.subViews=this.subViews.filter(e=>e!==t)}async insertSubView(t,e){if(!this.domNode)return;if(t._destroyed){console.warn("JOPS: insertSubView(): this view was destroyed; create a new instance");return}if(t.domNode||(await t.loadLayout(),t.inflate()),!this.domNode)return;if(!t.domNode){console.warn("JOPS: insertSubView(): the view has no layout");return}let s=this.domNode.children[e]??null;for(let n of t._roots())this.domNode.insertBefore(n,s);this.subViews.splice(e,0,t),await t?.onLayout?.(),await t?.onResume?.(),t?.bindEvents?.()}async _startSubView(t){await t?.onResume?.(),t?.bindEvents?.()}async render(){if(!this.layout||this.#e||this._generation!==m)return;let t=!this.domNode;if(t&&S&&!u)return;this.#e=!0;let e=this.domNode??u??document.body;t&&(e.innerHTML=this.layout,v.add(this),this.domNode=e,this._borrowedDomNode=!0);let s=h(()=>this._generation!==m,"ended"),n=Array.from(e.querySelectorAll("[jops], [type='jops']")).filter(i=>i.tagName==="ROUTER"||!i.closest("router")||i.closest("router")===e);if(n.length>0&&await this.#r(n,e,s),!s()&&t){if(await this.onLayout?.(),s()||(await this.onResume?.(),s()))return;this.bindEvents()}}async#r(t,e,s){let n=new Array(t.length);await Promise.all(t.map(async(i,a)=>{if(i.tagName==="ROUTER"){let l=new y(i);if(await l.render(),s())return;await l.onLayout();return}let r=i.getAttribute("layout"),f=i.getAttribute("src");if(!f&&!r)return;let c;if(f){let l=await import(new URL(b(f),document.baseURI).href);if(s())return;let w=l.default;_={ClassDef:w,props:H(i)};try{c=new w}finally{_=null}}else c=new o(void 0,H(i));if(n[a]=c,await c.loadLayout(r),s())return;let p=document.createElement("template");p.innerHTML=c.layout??"";let d=p.content.cloneNode(!0);if(i.children.length>0){let l=d.firstElementChild??d;Array.from(i.children).forEach(w=>l.appendChild(w))}c.domNodes=[...d.children],c.routePath=i.getAttribute("path"),i.replaceWith(d),c.domNode=c.domNodes[0],await c.render(),!s()&&(await c?.onLayout?.(),!s()&&await this._startSubView(c))})),!s()&&this.subViews.push(...n.filter(Boolean))}},L=class o{static{h(this,"EventBus")}static#t={};static subscribe(...t){let[e,s,n]=typeof t[0]=="string"?[null,...t]:t;(o.#t[s]??=[]).push({callback:n,owner:e})}static send(t,e){let s=o.#t[t];if(s){o.#t[t]=s.filter(n=>!n.owner?._destroyed);for(let n of[...o.#t[t]])n.callback(e)}}static unsubscribe(t,e){o.#t[t]&&(o.#t[t]=o.#t[t].filter(s=>s.callback!==e))}static unsubscribeAll(t){for(let e of Object.keys(o.#t))o.#t[e]=o.#t[e].filter(s=>s.owner!==t)}static clear(){o.#t={}}},P=class o{static{h(this,"Store")}static#t=null;static singleton(){return o.#t||(o.#t=new o),o.#t}constructor(){this._state={}}get(t){return t.split(".").reduce((e,s)=>e?.[s],this._state)}set(t,e){let s=t.split("."),n=s.pop(),i=s.reduce((a,r)=>(a[r]||(a[r]={}),a[r]),this._state);i[n]=e}clear(){this._state={}}},y=class o extends R{static{h(this,"Router")}static#t=null;static#e=!1;static singleton(){return o.#t}constructor(t){super(),o.#t=this,this.rootNode=t.parentNode,this.routerDomNode=t,this.domNode=t,this.layout=t.innerHTML,this.animation=t.getAttribute("animation")??"none"}async onLayout(){let t=document.createDocumentFragment();for(;this.routerDomNode.firstChild;)t.appendChild(this.routerDomNode.firstChild);this.routerDomNode.replaceWith(t),this.domNode=this.rootNode,this.initRouter()}async _startSubView(t){t?.bindEvents?.()}initRouter(){if(this._generation!==m)return;this.routes={};for(let i of this.subViews)if(i.routePath){let a=i.routePath.slice(1).split("?")[0];this.routes[a]=i}this._navStack=[],this._baseView=null;for(let i of Object.values(this.routes))i._setHidden(!0);let t=Object.keys(this.routes)[0];this._defaultPath=t,this._pathname=location.pathname;let e=window.location.hash.slice(1),s=e.split("?")[0],n=s&&this.routes[s]?e:t;this._idx=history.state?.jopsIdx??0,this._history("replaceState",{...history.state,jopsIdx:this._idx},"#"+n),this._historyListener=()=>this._onHistoryChange(),window.addEventListener("popstate",this._historyListener),window.addEventListener("hashchange",this._historyListener),this._switchTo(n)}_onHistoryChange(){if(this._destroyed)return;if(location.pathname!==this._pathname){S&&V();return}let t=history.state?.jopsIdx;if(t===this._idx)return;if(t===void 0){this._idx+=1,this._history("replaceState",{...history.state,jopsIdx:this._idx},location.href),this._onForward();return}let e=t<this._idx;this._idx=t,e?this._onBack():this._onForward()}_onBack(){if(this._navStack.length>0){let e=this._navStack.pop(),s=this._navStack.at(-1)??this._baseView;e?.onBackPressed?.(),this._animateTransition(e,s,"back").then(()=>e.destroy());return}let t=window.location.hash.slice(1).split("?")[0];if(!this.routes[t]){history.back();return}this._currentView?.onBackPressed?.(),this._switchToHash()}_onForward(){this._closeStack(),this._switchToHash()}_closeStack(){this._navStack.length!==0&&(this._navStack.includes(this._currentView)&&(this._currentView=null),this._navStack.forEach(t=>t.destroy?.()),this._navStack=[],this._baseView=null)}_pauseCurrent(t){this._currentView&&this._currentView!==t&&this._currentView.onPause?.()}_switchToHash(){let t=window.location.hash.slice(1),e=t.split("?")[0];e?this.routes[e]&&this._switchTo(t):this._switchToDefault()}_switchToDefault(){this._history("replaceState",{...history.state,jopsIdx:this._idx},"#"+this._defaultPath),this._switchTo(this._defaultPath)}destroy(){this._destroyed||(this._destroyed=!0,this._historyListener&&(window.removeEventListener("popstate",this._historyListener),window.removeEventListener("hashchange",this._historyListener),this._historyListener=null),this._navStack?.forEach(t=>t.destroy?.()),this._navStack=[],this._baseView=null,this._currentView=null,this.routes={},o.#t===this&&(o.#t=null),super.destroy())}_history(t,e,s){T=!0;try{history[t](e,"",s)}finally{T=!1}}_onHostNavigation(){if(this._destroyed||!this.routes)return;if(location.pathname!==this._pathname){V();return}let t=location.hash.slice(1).split("?")[0],e=history.state?.jopsIdx===void 0;t?e&&this.routes[t]&&(this._idx+=1,this._history("replaceState",{...history.state,jopsIdx:this._idx},location.href),this._onForward()):(e&&(this._idx+=1),this._closeStack(),this._switchToDefault())}async _animateTransition(t,e,s){let n=s==="forward",i=t._roots().length>1||e._roots().length>1;i&&this.animation!=="none"&&!o.#e&&(o.#e=!0,console.info("JOPS: Router animations need single-root views; views with several root elements switch without animation.")),this.animation==="slide"&&!i?await this._slideTransition(t,e,s):this.animation==="fade"&&!i?await this._fadeTransition(t,e):(t._setHidden(!0),e._setHidden(!1),this._currentView=e),n||e?.onResume?.()}async _slideTransition(t,e,s){let n=this.domNode,i=s==="forward",a=n.getBoundingClientRect(),r=t.domNode.getBoundingClientRect(),f=r.left-a.left,c=r.top-a.top,p=r.width,d=n.offsetWidth-f,l=`position:absolute;left:${f}px;top:${c}px;width:${p}px`;n.style.cssText=`position:relative;overflow:hidden;height:${n.offsetHeight}px`,t.domNode.style.cssText=`${l};z-index:${i?1:2}`,t.domNode.hidden=!1,e.domNode.style.cssText=`${l};z-index:${i?2:1}`,e.domNode.hidden=!1;let w=i?e.domNode:t.domNode,J=i?`${d}px`:"0px",U=i?"0px":`${d}px`,E=w.animate([{transform:`translateX(${J})`},{transform:`translateX(${U})`}],{duration:300,easing:"ease",fill:"forwards"});await E.finished,E.cancel(),t.domNode.hidden=!0,t.domNode.style.cssText="",e.domNode.style.cssText="",n.style.cssText="",this._currentView=e}async _fadeTransition(t,e){let s=this.domNode,n=s.getBoundingClientRect(),i=t.domNode.getBoundingClientRect(),a=i.left-n.left,r=i.top-n.top,f=i.width,c=`position:absolute;left:${a}px;top:${r}px;width:${f}px`;s.style.cssText=`position:relative;overflow:hidden;height:${s.offsetHeight}px`,t.domNode.style.cssText=c,t.domNode.hidden=!1,e.domNode.style.cssText=c,e.domNode.hidden=!1;let p=t.domNode.animate([{opacity:1},{opacity:0}],{duration:450,easing:"ease",fill:"forwards"}),d=e.domNode.animate([{opacity:0},{opacity:1}],{duration:450,easing:"ease",fill:"forwards"});await Promise.all([p.finished,d.finished]),p.cancel(),d.cancel(),t.domNode.hidden=!0,t.domNode.style.cssText="",e.domNode.style.cssText="",s.style.cssText="",this._currentView=e}_switchTo(t){let e=t.split("?")[0],s=this.routes[e];s&&this._pauseCurrent(s);for(let[n,i]of Object.entries(this.routes)){let a=n===e;i._setHidden(!a),a&&(this._currentView=i,i?.onResume?.())}}back(){history.back()}canGoBack(){return this._idx>0}getParams(){let t=window.location.hash.split("?")[1];return t?Object.fromEntries(new URLSearchParams(t)):{}}async navigate(t,e){let[s,n]=t.replace(/^\//,"").split("?"),i=s,a=e??this.routes[i]?.constructor;if(!a){console.error(`JOPS Router: no class found for path "${t}"`);return}this._navStack.length===0&&(this._baseView=this._currentView);let r=new a;await r.loadLayout(),r.inflate(),this.domNode.append(...r._roots()),this._history("pushState",{jopsNav:!0,jopsIdx:++this._idx},"#"+i+(n?"?"+n:"")),this._pauseCurrent(r),await r.onLayout?.(),await r.onResume?.(),r.bindEvents?.(),this._navStack.push(r),this._animateTransition(this._currentView,r,"forward")}async replace(t,e){this._closeStack();let[s,n]=t.replace(/^\//,"").split("?"),i=s,a="#"+i+(n?"?"+n:"");if(this.routes[i])this._history("replaceState",{...history.state,jopsIdx:this._idx},a),this._switchTo(i);else if(e){let r=new e;await r.loadLayout(),r.inflate(),this.domNode.append(...r._roots()),this._pauseCurrent(r),await r.onLayout?.(),await r.onResume?.(),r.bindEvents?.(),this._currentView&&this._currentView._setHidden(!0),this._currentView=r,this._history("replaceState",{...history.state,jopsIdx:this._idx},a)}else console.error(`JOPS Router: no instance or class found for path "${t}"`)}},A=class{static{h(this,"Thread")}#t=null;run(t){}onMessage(t){}start(t){if(typeof Worker>"u"){console.warn("JOPS Thread: Web Workers are not supported in this environment.");return}let e=this.run.toString();!e.startsWith("function")&&!e.startsWith("(")&&!e.startsWith("async")&&(e="function "+e);let s=new Blob([`const run = ${e};`,"self.onmessage = (e) => { run(e.data); };"],{type:"application/javascript"}),n=URL.createObjectURL(s);this.#t=new Worker(n),URL.revokeObjectURL(n),this.#t.onmessage=i=>this.onMessage(i),this.#t.postMessage(t)}terminate(){if(!this.#t){console.warn("JOPS Thread: no active worker to terminate.");return}this.#t.terminate(),this.#t=null}};export{L as EventBus,y as Router,P as Store,A as Thread,R as View,G as layout,D as mount,z as raw,V as unmount};
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
export type JopsAppProps = {
|
|
2
|
+
/** URL of the JOPS app's entry module, e.g. "/quotes/App.js". */
|
|
3
|
+
app: string;
|
|
4
|
+
/** id for the root <div> (CSS, tests, debugging). Does not choose the mount target. */
|
|
5
|
+
id?: string;
|
|
6
|
+
/** className for the root <div>. */
|
|
7
|
+
className?: string;
|
|
8
|
+
/** jops-core library URL; also used for the import map. Defaults to "/lib/jops-core.min.js". */
|
|
9
|
+
lib?: string;
|
|
10
|
+
};
|
|
11
|
+
type JopsGlobal = {
|
|
12
|
+
mount(options: {
|
|
13
|
+
app: string;
|
|
14
|
+
root: HTMLElement;
|
|
15
|
+
}): Promise<unknown>;
|
|
16
|
+
/** Ends the app completely (Router, views, Store, EventBus, history hook). */
|
|
17
|
+
unmount(): void;
|
|
18
|
+
};
|
|
19
|
+
declare global {
|
|
20
|
+
interface Window {
|
|
21
|
+
JOPS?: JopsGlobal;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Hosts a JOPS SPA inside a Next.js page. Renders an empty <div> that becomes
|
|
26
|
+
* the JOPS root; React never renders children into it, so JOPS owns its DOM.
|
|
27
|
+
*
|
|
28
|
+
* @example
|
|
29
|
+
* import JopsApp from "jops-core/nextjs";
|
|
30
|
+
* export default function Page() {
|
|
31
|
+
* return <JopsApp app="/quotes/App.js" id="jops-quotes-root" />;
|
|
32
|
+
* }
|
|
33
|
+
*/
|
|
34
|
+
export default function JopsApp({ app, id, className, lib }: JopsAppProps): import("react").JSX.Element;
|
|
35
|
+
export {};
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx } from "react/jsx-runtime";
|
|
3
|
+
import { useEffect, useRef } from "react";
|
|
4
|
+
const DEFAULT_LIB = "/lib/jops-core.min.js";
|
|
5
|
+
const SCRIPT_ID = "jops-core";
|
|
6
|
+
const IMPORT_MAP_ID = "jops-core-importmap";
|
|
7
|
+
function ensureImportMap(lib) {
|
|
8
|
+
if (document.getElementById(IMPORT_MAP_ID)) return;
|
|
9
|
+
const map = document.createElement("script");
|
|
10
|
+
map.type = "importmap";
|
|
11
|
+
map.id = IMPORT_MAP_ID;
|
|
12
|
+
map.textContent = JSON.stringify({ imports: { "jops-core": lib } });
|
|
13
|
+
document.head.appendChild(map);
|
|
14
|
+
}
|
|
15
|
+
function loadJops(lib) {
|
|
16
|
+
if (window.JOPS) return Promise.resolve(window.JOPS);
|
|
17
|
+
return new Promise((resolve, reject) => {
|
|
18
|
+
window.addEventListener("jops:ready", () => resolve(window.JOPS), { once: true });
|
|
19
|
+
if (!document.getElementById(SCRIPT_ID)) {
|
|
20
|
+
ensureImportMap(lib);
|
|
21
|
+
const script = document.createElement("script");
|
|
22
|
+
script.type = "module";
|
|
23
|
+
script.src = lib;
|
|
24
|
+
script.id = SCRIPT_ID;
|
|
25
|
+
script.dataset.host = "next";
|
|
26
|
+
script.onerror = () => reject(new Error(`JOPS: failed to load ${lib}`));
|
|
27
|
+
document.head.appendChild(script);
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
function JopsApp({ app, id, className, lib = DEFAULT_LIB }) {
|
|
32
|
+
const rootRef = useRef(null);
|
|
33
|
+
useEffect(() => {
|
|
34
|
+
let cancelled = false;
|
|
35
|
+
let jops = null;
|
|
36
|
+
loadJops(lib).then((loaded) => {
|
|
37
|
+
if (cancelled || !rootRef.current) return;
|
|
38
|
+
jops = loaded;
|
|
39
|
+
return loaded.mount({ app, root: rootRef.current });
|
|
40
|
+
}).catch((err) => console.error(err));
|
|
41
|
+
return () => {
|
|
42
|
+
cancelled = true;
|
|
43
|
+
jops?.unmount();
|
|
44
|
+
};
|
|
45
|
+
}, [app, lib]);
|
|
46
|
+
return /* @__PURE__ */ jsx("div", { ref: rootRef, id, className, "data-jops-root": "", style: { position: "relative" } });
|
|
47
|
+
}
|
|
48
|
+
export {
|
|
49
|
+
JopsApp as default
|
|
50
|
+
};
|
package/package.json
CHANGED
|
@@ -1,9 +1,18 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "jops-core",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "A no-bundler, OOP-first SPA framework with Android-style view composition",
|
|
5
5
|
"type": "module",
|
|
6
|
-
"exports":
|
|
6
|
+
"exports": {
|
|
7
|
+
".": {
|
|
8
|
+
"types": "./lib/jops-core.d.ts",
|
|
9
|
+
"default": "./lib/jops-core.min.js"
|
|
10
|
+
},
|
|
11
|
+
"./nextjs": {
|
|
12
|
+
"types": "./lib/nextjs/JopsApp.d.ts",
|
|
13
|
+
"default": "./lib/nextjs/JopsApp.js"
|
|
14
|
+
}
|
|
15
|
+
},
|
|
7
16
|
"browser": "./lib/jops-core.min.js",
|
|
8
17
|
"types": "./lib/jops-core.d.ts",
|
|
9
18
|
"bin": {
|
|
@@ -12,11 +21,13 @@
|
|
|
12
21
|
"files": [
|
|
13
22
|
"lib/jops-core.min.js",
|
|
14
23
|
"lib/jops-core.d.ts",
|
|
24
|
+
"lib/nextjs/",
|
|
15
25
|
"bin/init.js",
|
|
16
26
|
"templates/"
|
|
17
27
|
],
|
|
18
28
|
"scripts": {
|
|
19
|
-
"build": "esbuild
|
|
29
|
+
"build:nextjs": "esbuild jops-nextjs/JopsApp.tsx --format=esm --jsx=automatic --target=es2022 --outdir=lib/nextjs && tsc -p tsconfig.nextjs.json",
|
|
30
|
+
"build": "esbuild src/jops-core.js --bundle --format=esm --minify --keep-names --outfile=lib/jops-core.min.js && tsc -p tsconfig.types.json && npm run build:nextjs && npm pack"
|
|
20
31
|
},
|
|
21
32
|
"homepage": "https://cdn.ui-db.com",
|
|
22
33
|
"repository": {
|
|
@@ -52,13 +63,30 @@
|
|
|
52
63
|
"ios",
|
|
53
64
|
"hybrid-app",
|
|
54
65
|
"phonegap",
|
|
55
|
-
"hybrid-mobile"
|
|
66
|
+
"hybrid-mobile",
|
|
67
|
+
"nextjs",
|
|
68
|
+
"next-js",
|
|
69
|
+
"next.js",
|
|
70
|
+
"ssr"
|
|
56
71
|
],
|
|
57
72
|
"author": "arik@ui-db.com",
|
|
58
73
|
"license": "MIT",
|
|
59
74
|
"devDependencies": {
|
|
75
|
+
"@types/react": "^19.3.0",
|
|
60
76
|
"esbuild": "^0.25.12",
|
|
61
77
|
"typescript": "^7.0.2"
|
|
62
78
|
},
|
|
63
|
-
"packageManager": "npm@12.0.2+sha512.b885e890b9418fa1693544d05f53e64f9a73ec194837d4258b15fecdd692347b1dd2a517b1b0cbaf9d31cd8e92c3b70956bd2ecc72833a57b4b3098f5bfa7943"
|
|
79
|
+
"packageManager": "npm@12.0.2+sha512.b885e890b9418fa1693544d05f53e64f9a73ec194837d4258b15fecdd692347b1dd2a517b1b0cbaf9d31cd8e92c3b70956bd2ecc72833a57b4b3098f5bfa7943",
|
|
80
|
+
"peerDependencies": {
|
|
81
|
+
"react": ">=18",
|
|
82
|
+
"next": ">=15"
|
|
83
|
+
},
|
|
84
|
+
"peerDependenciesMeta": {
|
|
85
|
+
"react": {
|
|
86
|
+
"optional": true
|
|
87
|
+
},
|
|
88
|
+
"next": {
|
|
89
|
+
"optional": true
|
|
90
|
+
}
|
|
91
|
+
}
|
|
64
92
|
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# Next.js + JOPS embedded SPA
|
|
2
|
+
|
|
3
|
+
A Next.js (App Router) project that hosts a [JOPS](https://github.com/uidb-dev/jops-core) single-page app on one of its pages. Created by `npx jops-core init` (template 3) in the current folder: in an empty folder, or merged into a fresh npm project (an existing `package.json` keeps its entries, an installed `jops-core` included).
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npm run dev
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
Open http://localhost:3000/about and http://localhost:3000/jops-spa.
|
|
10
|
+
|
|
11
|
+
## What's inside
|
|
12
|
+
|
|
13
|
+
| Path | What it is |
|
|
14
|
+
|---|---|
|
|
15
|
+
| `src/app/about/page.tsx` | A React-only page (no JOPS). |
|
|
16
|
+
| `src/app/jops-spa/page.tsx` | Renders `<JopsApp app="/jops-spa/App.js" />` from `jops-core/nextjs`, which launches the JOPS SPA. |
|
|
17
|
+
| `src/app/api/quotes/route.ts` | `GET /api/quotes[?status=…]`: the SPA's backend, reading `data/quotes.json`. |
|
|
18
|
+
| `public/jops-spa/` | The JOPS SPA: plain ES modules and HTML layouts, served as-is. A Router with `#login` (mock login, no request) and `#quotes` (quotes list with status filters). |
|
|
19
|
+
| `public/lib/jops-core.min.js` | The JOPS library, vendored. |
|
|
20
|
+
|
|
21
|
+
## Conventions
|
|
22
|
+
|
|
23
|
+
- **Absolute paths** for everything JOPS loads (`/jops-spa/Quotes.js`, `/jops-spa/layout/quotes.html`).
|
|
24
|
+
- **Vendored library:** `public/lib/jops-core.min.js`, which must match the `lib` URL used by `JopsApp` (default `/lib/jops-core.min.js`). After updating the `jops-core` package, run `npm run jops:update` to copy the new version into `public/lib/`.
|
|
25
|
+
- **One stylesheet:** Tailwind is generated by Next.js and also covers the classes used in `public/jops-spa/`. `setStyle()` has no effect in an embedded JOPS app.
|
|
26
|
+
- **Lifecycle:** leaving `/jops-spa` (a link or Back) ends the JOPS app completely; returning launches it again. The mock login is kept in the JOPS `Store`, so it is cleared too.
|
|
27
|
+
|
|
28
|
+
## Deploying
|
|
29
|
+
|
|
30
|
+
This is a standard Next.js app: `npm run build` produces `.next/` (not a static `dist/` folder), and it runs on a Node.js server because of the Route Handler (`/api/quotes`). Deploy it like any Next.js app; see the [Next.js deployment docs](https://nextjs.org/docs/app/getting-started/deploying). Three points specific to the embedded JOPS app:
|
|
31
|
+
|
|
32
|
+
- **Deploy `public/` with the app.** The whole JOPS SPA and the library live there. With `output: "standalone"`, Next.js leaves `public/` and `.next/static/` out of `.next/standalone/` on purpose; copy both in, or the JOPS app is missing.
|
|
33
|
+
- **Caching:** files in `public/` are not content-hashed, so a CDN or the browser can keep serving an old `/jops-spa/*.js` or `/lib/jops-core.min.js` after a deploy. Use short cache headers for those paths, or versioned paths.
|
|
34
|
+
- **Library version:** after updating the `jops-core` package, run `npm run jops:update` before `npm run build`, so `public/lib/jops-core.min.js` matches the installed version.
|