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/README.md
CHANGED
|
@@ -21,6 +21,8 @@ No Webpack. No Babel. No compiler. Just `<script type="module">` and a browser.
|
|
|
21
21
|
|
|
22
22
|
**Target audience:** Teams building internal tools, dashboards, embedded WebViews, or native mobile hybrid apps who want a structured, class-based frontend architecture without the npm/webpack/babel ecosystem overhead.
|
|
23
23
|
|
|
24
|
+
**Next.js teams:** a JOPS SPA can also run inside a Next.js page, next to React, with Next.js as its backend. See [Next.js Integration](#nextjs-integration).
|
|
25
|
+
|
|
24
26
|
---
|
|
25
27
|
|
|
26
28
|
## JOPS Demos
|
|
@@ -74,7 +76,14 @@ npm install jops-core
|
|
|
74
76
|
npx jops-core init
|
|
75
77
|
```
|
|
76
78
|
|
|
77
|
-
`npx jops-core init` vendors the library to `/lib/jops-core.min.js`, configures `index.html` with the import map, and injects build and postinstall scripts into `package.json`.
|
|
79
|
+
`npx jops-core init` vendors the library to `/lib/jops-core.min.js`, configures `index.html` with the import map, and injects build and postinstall scripts into `package.json`. It first asks which template to start from:
|
|
80
|
+
|
|
81
|
+
| Choice | Template |
|
|
82
|
+
| --- | --- |
|
|
83
|
+
| 1 | Empty JOPS project: the basic skeleton of a JOPS app |
|
|
84
|
+
| 2 | Hello World JOPS project: an example app using every feature |
|
|
85
|
+
| 3 | Next.js JOPS Embedded SPA: a Next.js project hosting a JOPS SPA (see [Next.js Integration](#nextjs-integration)) |
|
|
86
|
+
| 4 | None: only the library, `index.html` and scripts |
|
|
78
87
|
|
|
79
88
|
### Updating the library
|
|
80
89
|
|
|
@@ -277,6 +286,137 @@ A `cordova_plugins.js` "not found" error in the console is expected when the pro
|
|
|
277
286
|
|
|
278
287
|
---
|
|
279
288
|
|
|
289
|
+
## Next.js Integration
|
|
290
|
+
|
|
291
|
+
A JOPS SPA can run inside a page of a Next.js (App Router) app. Next.js keeps what it does best (server rendering, data, Route Handlers, its own routing and React ecosystem) and the JOPS app adds class-based views with an Android-style lifecycle on the pages that need them. Both render on the same page:
|
|
292
|
+
|
|
293
|
+
- **Next.js owns the path** (`/quotes`), **JOPS owns the hash** (`/quotes#details?id=q2`).
|
|
294
|
+
- The JOPS app's files live in `public/` and are served as-is: they are never bundled by Turbopack or webpack.
|
|
295
|
+
- The backend is Next.js: the JOPS app calls Route Handlers with `fetch()`.
|
|
296
|
+
|
|
297
|
+
### Quick start: the Next.js template
|
|
298
|
+
|
|
299
|
+
In an empty folder (or a fresh npm project):
|
|
300
|
+
|
|
301
|
+
```bash
|
|
302
|
+
npx jops-core init
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
and pick **3. Next.js JOPS Embedded SPA**. `init` writes the project into the current folder, merges its dependencies and scripts into an existing `package.json` (existing entries are kept), vendors the library and runs `npm install`. Then:
|
|
306
|
+
|
|
307
|
+
```bash
|
|
308
|
+
npm run dev
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
| Path | What it is |
|
|
312
|
+
| --- | --- |
|
|
313
|
+
| `src/app/about/page.tsx` | A React-only page (no JOPS) |
|
|
314
|
+
| `src/app/jops-spa/page.tsx` | Renders `<JopsApp app="/jops-spa/App.js" />`, which launches the JOPS SPA |
|
|
315
|
+
| `src/app/api/quotes/route.ts` | `GET /api/quotes[?status=…]`: the SPA's backend |
|
|
316
|
+
| `public/jops-spa/` | The JOPS SPA: a Router with a mock `#login` screen and a `#quotes` list with status filters |
|
|
317
|
+
| `public/lib/jops-core.min.js` | The JOPS library, vendored |
|
|
318
|
+
|
|
319
|
+
`init` refuses to run in a folder that already has a Next.js project (`next.config.*`, `tsconfig.json`, `src/app`) and changes nothing there; to add JOPS to an existing Next.js app, follow the manual setup below.
|
|
320
|
+
|
|
321
|
+
### Manual setup in an existing Next.js app
|
|
322
|
+
|
|
323
|
+
**1. Install JOPS and vendor the library** into `public/lib/` (served as `/lib/jops-core.min.js`):
|
|
324
|
+
|
|
325
|
+
```bash
|
|
326
|
+
npm i jops-core
|
|
327
|
+
npx jops-core update
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
`update` detects a Next.js project (`next.config.*`) and copies the library to `public/lib/jops-core.min.js`. Run it again after every `npm i jops-core@latest`; adding `"postinstall": "jops-core update"` to `package.json` does it automatically.
|
|
331
|
+
|
|
332
|
+
**2. Put the JOPS app in `public/`**, e.g. `public/my-app/App.js`, `public/my-app/layout/main.html` and its views. `App.js` is the same entry module as in a standalone app:
|
|
333
|
+
|
|
334
|
+
```js
|
|
335
|
+
import { View } from "jops-core";
|
|
336
|
+
|
|
337
|
+
export default class App {
|
|
338
|
+
constructor() {
|
|
339
|
+
const root = new View();
|
|
340
|
+
root.loadLayout("/my-app/layout/main.html").then(() => root.render());
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
```
|
|
344
|
+
|
|
345
|
+
**3. Render `JopsApp` on the page** that hosts the app:
|
|
346
|
+
|
|
347
|
+
```tsx
|
|
348
|
+
// src/app/my-app/page.tsx
|
|
349
|
+
import JopsApp from "jops-core/nextjs";
|
|
350
|
+
|
|
351
|
+
export default function MyAppPage() {
|
|
352
|
+
return <JopsApp app="/my-app/App.js" id="my-app-root" />;
|
|
353
|
+
}
|
|
354
|
+
```
|
|
355
|
+
|
|
356
|
+
`JopsApp` is a Client Component (`"use client"` is built in), so it can be used from a Server Component page. It renders an empty `<div>` on the server; in the browser it loads `/lib/jops-core.min.js` once per page, adds the `"jops-core"` import map to `<head>` and mounts the app into its `<div>`. You don't add an import map or script tag to the root layout.
|
|
357
|
+
|
|
358
|
+
| Prop | Description |
|
|
359
|
+
| --- | --- |
|
|
360
|
+
| `app` | **Required.** URL of the app's entry module (`/my-app/App.js`); its default export is instantiated |
|
|
361
|
+
| `id` | `id` of the root `<div>` (CSS, tests) |
|
|
362
|
+
| `className` | `className` of the root `<div>` |
|
|
363
|
+
| `lib` | URL of the jops-core library, default `/lib/jops-core.min.js`. The import map uses the same URL, so the page always has a single jops-core instance |
|
|
364
|
+
|
|
365
|
+
The root `<div>` has `position: relative`, which the Router's slide and fade animations need.
|
|
366
|
+
|
|
367
|
+
### App lifecycle inside Next.js
|
|
368
|
+
|
|
369
|
+
- **Leaving the page ends the app completely**, like closing a standalone app: a Next.js link to another page, or Back past the entry the app was launched from. The Router and every view are destroyed, the `<div>` is emptied, `Store` and `EventBus` are cleared, and the history methods JOPS wrapped are restored. Nothing of the app is left running.
|
|
370
|
+
- **Coming back launches it again** (a link, or Forward) and restores the screen from the hash.
|
|
371
|
+
- **Links to the app's own page don't relaunch it.** A Next.js `<Link href="/my-app">` while the app runs shows the default (first) route; `<Link href="/my-app#details?id=7">` shows that route. Stacked `navigate()` views are closed in both cases.
|
|
372
|
+
- Next.js history state (`history.state`) is kept in every entry JOPS writes, so Next.js Back/Forward keep working.
|
|
373
|
+
|
|
374
|
+
### Conventions
|
|
375
|
+
|
|
376
|
+
- **Root-absolute paths** for everything JOPS loads: `src="/my-app/Quotes.js"`, `layout="/my-app/layout/quotes.html"`, `loadLayout("/my-app/…")`. ES module `import` statements inside your `.js` files resolve relative to the file as usual.
|
|
377
|
+
- **Vendored library** (`public/lib/`), not the CDN: with the CDN, JOPS turns `/…` paths into page-relative ones.
|
|
378
|
+
- **Next.js owns the CSS.** `setStyle()` has no effect inside Next.js (a console message says so once). Tailwind CSS v4 also scans the files in `public/`, so Tailwind classes in JOPS layouts and views are generated by the Next.js build. Write class names as complete literal strings (`"bg-sky-50"`, never `` `bg-${color}-50` ``). Put other CSS in `globals.css` or a CSS file imported by the page, scoped under the app's root class (`.my-app table td { … }`), since it applies to the whole site.
|
|
379
|
+
- **Backend calls:** `fetch("/api/…")` to Route Handlers on the same origin; the session cookie is sent automatically. A JOPS app can't call Server Actions.
|
|
380
|
+
- **One JOPS app per page.** `Router`, `Store` and `EventBus` are page-wide singletons.
|
|
381
|
+
|
|
382
|
+
### Without React: hosted mode
|
|
383
|
+
|
|
384
|
+
`JopsApp` uses hosted mode, which any host page can use directly. Load the library with `data-host` and no `data-app`: nothing starts automatically, and `window.JOPS` exposes `mount()` and `unmount()` (a `jops:ready` event fires when it is available):
|
|
385
|
+
|
|
386
|
+
```html
|
|
387
|
+
<script type="importmap">{ "imports": { "jops-core": "/lib/jops-core.min.js" } }</script>
|
|
388
|
+
<script type="module" src="/lib/jops-core.min.js" data-host></script>
|
|
389
|
+
<div id="app" style="position: relative"></div>
|
|
390
|
+
<script type="module">
|
|
391
|
+
const start = () => window.JOPS.mount({ app: "/my-app/App.js", root: document.getElementById("app") });
|
|
392
|
+
window.JOPS ? start() : window.addEventListener("jops:ready", start, { once: true });
|
|
393
|
+
</script>
|
|
394
|
+
```
|
|
395
|
+
|
|
396
|
+
| Function | Description |
|
|
397
|
+
| --- | --- |
|
|
398
|
+
| `mount({ app, root })` | Imports `app` and creates its default export; the root view renders into `root` (an `HTMLElement`) instead of `<body>`. Returns a promise of the App instance |
|
|
399
|
+
| `unmount()` | Ends the app completely (see "App lifecycle" above). Safe to call more than once; `mount()` can start an app again afterwards |
|
|
400
|
+
|
|
401
|
+
Both are also exported by the module (`import { mount, unmount } from "jops-core"`) for modules running on the same page. In hosted mode the Android back button integration is off.
|
|
402
|
+
|
|
403
|
+
A **standalone** app (started by `data-app`) can render into an element instead of `<body>` with `data-root`:
|
|
404
|
+
|
|
405
|
+
```html
|
|
406
|
+
<div id="app"></div>
|
|
407
|
+
<script type="module" src="/lib/jops-core.min.js" data-app="./src/App.js" data-root="app"></script>
|
|
408
|
+
```
|
|
409
|
+
|
|
410
|
+
### Deploying
|
|
411
|
+
|
|
412
|
+
The result is a standard Next.js app: `npm run build` produces `.next/`, and it runs on a Node.js server when it uses server features such as Route Handlers. Deploy it like any Next.js app (see the [Next.js deployment docs](https://nextjs.org/docs/app/getting-started/deploying)). Points specific to JOPS:
|
|
413
|
+
|
|
414
|
+
- **Deploy `public/` with the app**: the whole JOPS app and the library live there. With `output: "standalone"`, copy `public/` (and `.next/static/`) into `.next/standalone/` yourself.
|
|
415
|
+
- **Caching:** files in `public/` are not content-hashed, so a CDN or the browser may keep serving an old `jops-core.min.js` or view after a deploy. Use short cache headers for those paths, or versioned paths.
|
|
416
|
+
- **Library version:** run `npx jops-core update` after updating the package and before `npm run build`.
|
|
417
|
+
|
|
418
|
+
---
|
|
419
|
+
|
|
280
420
|
## Class Reference
|
|
281
421
|
|
|
282
422
|
### `View` — Base Class
|
|
@@ -289,30 +429,106 @@ import { View } from "jops-core";
|
|
|
289
429
|
export default class MyView extends View { ... }
|
|
290
430
|
```
|
|
291
431
|
|
|
432
|
+
#### Constructor
|
|
433
|
+
|
|
434
|
+
| Constructor | Description |
|
|
435
|
+
| --------------------------- | ----------- |
|
|
436
|
+
| `new View(layout?, props?)` | `layout` (optional) is passed to `loadLayout(layout)` when the view is created: a `.js` layout module path, an `.html` file path, or an inline HTML string. An inline string is assigned to `this.layout` immediately. `props` (optional) sets the view's [input props](#tag-props-input-properties) for a view created in code. |
|
|
437
|
+
|
|
438
|
+
Usually the layout comes from the view's `layout=` attribute or from an overridden `loadLayout()`, so subclasses call `super()` with no argument. Pass an inline layout when a view builds its markup from data, for example one row per record:
|
|
439
|
+
|
|
440
|
+
```js
|
|
441
|
+
import { View, layout } from "jops-core";
|
|
442
|
+
|
|
443
|
+
export default class QuoteRow extends View {
|
|
444
|
+
constructor(quote) {
|
|
445
|
+
super(layout`<tr><td>${quote.number}</td><td>${quote.companyName}</td></tr>`);
|
|
446
|
+
this.quote = quote;
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// in the parent view:
|
|
451
|
+
await this.addSubView("rows", new QuoteRow(quote));
|
|
452
|
+
```
|
|
453
|
+
|
|
292
454
|
#### Lifecycle (called by the framework in order)
|
|
293
455
|
|
|
294
456
|
| Method | When called |
|
|
295
457
|
| ------------------------- | ------------------------------------------------------------------------------ |
|
|
296
|
-
| `async loadLayout(path?)` | Override to load a layout `.js` module or `.html` file
|
|
458
|
+
| `async loadLayout(path?)` | Override to load a layout `.js` module or `.html` file. When you pass a path, `await` (or `return`) `super.loadLayout(…)` — see [Overriding `loadLayout()`](#overriding-loadlayout). Loads once per view |
|
|
297
459
|
| `inflate()` | Converts `this.layout` string into `this.domNode` |
|
|
298
460
|
| `async onLayout()` | DOM is live — add subviews, read initial data |
|
|
299
|
-
| `async onResume()` | View
|
|
461
|
+
| `async onResume()` | View comes to the front — on first display, on every return navigation, and when the same route is shown again with new parameters. A Router route is resumed only when it is shown |
|
|
300
462
|
| `bindEvents()` | Wires `on*` attributes on `[jops-event-bind]` elements to class methods |
|
|
301
|
-
|
|
463
|
+
|
|
464
|
+
Optional hooks, called by the Router:
|
|
465
|
+
|
|
466
|
+
| Method | When called |
|
|
467
|
+
| ----------------- | ----------- |
|
|
468
|
+
| `onPause()` | A different view comes to the front and this one stays alive: another route is shown, a stacked view is opened on top of it with `navigate()`, or `replace()` shows another view. Always runs before the incoming view's `onResume()`. Not called when the view is destroyed instead (Back from a stacked view, a stack closed by a route link, leaving the app) |
|
|
469
|
+
| `onBackPressed()` | The user navigates back from this view (browser back, `Router.back()`, Android back button in hybrid apps) — never on forward navigation. For a route it runs before `onPause()`; for a stacked view it is the last call before the view is destroyed, so put closing logic here |
|
|
470
|
+
|
|
471
|
+
`onPause()` and `onResume()` are not passed on to child views. The root view (the one whose `render()` is called first) gets the full lifecycle too, after its declared children.
|
|
302
472
|
|
|
303
473
|
#### Instance Methods
|
|
304
474
|
|
|
305
475
|
| Method | Description |
|
|
306
476
|
| ------------------------------- | -------------------------------------------------------------------------------------------------------------- |
|
|
307
|
-
| `get(id)` | Returns an `Element` wrapper for the
|
|
308
|
-
| `
|
|
477
|
+
| `get(id)` | Returns an `Element` wrapper for the first element with the given `id`, searching every top-level element of the layout |
|
|
478
|
+
| `find(id)` | Returns an array of `Element` wrappers for every element with the given `id` across the layout (sub-views' DOM included), in page order |
|
|
479
|
+
| `find(condition)` | Depth-first search across full subview subtree; returns flat array of all views matching the predicate |
|
|
480
|
+
| `props` | The view's read-only [input props](#tag-props-input-properties) |
|
|
481
|
+
| `setStyle(style)` | Injects a `.css` file link or inline style tag into `<head>`, deduped. No effect when the app runs inside Next.js (the host owns the CSS) |
|
|
309
482
|
| `addSubView(containerId, view)` | Appends `view` into the child element with `id="containerId"`, runs its full lifecycle |
|
|
310
483
|
| `insertSubView(view, index)` | Inserts `view` at position `index` within `this.domNode`, runs its full lifecycle |
|
|
311
484
|
| `removeSubView(view)` | Removes `view` from DOM and `this.subViews`, calls `view.destroy()` |
|
|
312
|
-
| `
|
|
313
|
-
| `
|
|
314
|
-
| `
|
|
315
|
-
|
|
485
|
+
| `show()` | Shows the view again (removes the inline `display` override) |
|
|
486
|
+
| `hide()` | Hides the view (`display: none`) |
|
|
487
|
+
| `destroy()` | Recursively destroys subtree — calls `destroy()` on all subviews, removes the view's elements from the DOM, removes its owned `EventBus` subscriptions, nulls references |
|
|
488
|
+
|
|
489
|
+
- **`show()` / `hide()` are visual only**: they don't call `onPause()` / `onResume()`. Refresh or pause a view toggled this way yourself.
|
|
490
|
+
- **A removed view is final.** `removeSubView()` destroys it; adding the same instance again logs a warning and does nothing. Keep the data outside the view (the `Store`, the parent's fields, the server) and create a new instance to show it again. `addSubView()` also warns and adds nothing when the container id doesn't exist or the layout failed to load.
|
|
491
|
+
- **The root view's container is borrowed.** The root view uses its container (`<body>`, the `data-root` element, or the element passed to `mount()`) as its `domNode`; destroying the root empties the container instead of removing it.
|
|
492
|
+
|
|
493
|
+
#### Tag props (input properties)
|
|
494
|
+
|
|
495
|
+
Attributes on a declared `jops` tag are passed to its view as **props**, like Android's `AttributeSet` or React's props:
|
|
496
|
+
|
|
497
|
+
```html
|
|
498
|
+
<Card jops src="/src/Card.js" title="Monthly report" item-id="42" variant="compact"></Card>
|
|
499
|
+
```
|
|
500
|
+
|
|
501
|
+
```js
|
|
502
|
+
export default class Card extends View {
|
|
503
|
+
constructor() {
|
|
504
|
+
super();
|
|
505
|
+
// props are available right after super(), in loadLayout() and in onLayout()
|
|
506
|
+
this.layout = layout`<article class="card ${this.props.variant}"><h3>${this.props.title}</h3></article>`;
|
|
507
|
+
this.inflate();
|
|
508
|
+
}
|
|
509
|
+
onLayout() {
|
|
510
|
+
console.log(this.props.itemId); // "42"
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
```
|
|
514
|
+
|
|
515
|
+
- Names are converted to camelCase (`item-id` → `itemId`); values are always strings.
|
|
516
|
+
- `this.props` is read-only (a frozen object), and `{}` when there are none.
|
|
517
|
+
- `jops`, `type="jops"`, `src`, `layout`, `path` and `jops-event-bind` belong to JOPS and are not props. `id`, `class` and `on*` attributes are props only: they are not copied to the view's element, and an `onclick` prop is not bound.
|
|
518
|
+
- The view's constructor needs no parameters for this: props reach declared views whatever their constructor looks like.
|
|
519
|
+
- A view created in code can get props through the constructor: `new View(layout, { title: "Hi" })`, or `super(undefined, props)` in a subclass.
|
|
520
|
+
- Props come from tags in static layouts processed by `render()`: the root layout, the Router's route declarations, and the layouts of declared views. Tags inside the layout of a view added with `addSubView()`, or inside a route's own layout, are not created as views; add those children in code.
|
|
521
|
+
|
|
522
|
+
#### Layouts with several top-level elements
|
|
523
|
+
|
|
524
|
+
A layout may have one or more top-level elements:
|
|
525
|
+
|
|
526
|
+
```html
|
|
527
|
+
<h2 id="title">Quotes</h2>
|
|
528
|
+
<table><tbody id="rows"></tbody></table>
|
|
529
|
+
```
|
|
530
|
+
|
|
531
|
+
`this.domNode` is the first one and `this.domNodes` holds all of them. `get()`, `find(id)`, `bindEvents()`, `show()`, `hide()`, `addSubView()`, `insertSubView()` and `destroy()` act on all of them, in order. Top-level text is ignored. The Router's `slide` and `fade` animations need single-root views: a stacked view with several top-level elements switches instantly.
|
|
316
532
|
|
|
317
533
|
#### Declarative Event Binding
|
|
318
534
|
|
|
@@ -326,22 +542,50 @@ Mark any element with `jops-event-bind` and add `on*` attributes whose values ar
|
|
|
326
542
|
onSave(event) { ... }
|
|
327
543
|
```
|
|
328
544
|
|
|
545
|
+
`bindEvents()` wires the markup of the view's layout when the view is shown. HTML you generate later (with `Element.set()` or `innerHTML`) is not bound — the browser would treat `onclick="onSave"` as an inline script. Wire generated markup yourself, ideally with one listener on its container (event delegation):
|
|
546
|
+
|
|
547
|
+
```js
|
|
548
|
+
// layout: <tbody id="lines" jops-event-bind onclick="onLineAction"></tbody>
|
|
549
|
+
// generated rows: <button data-action="remove" data-index="3">✕</button>
|
|
550
|
+
onLineAction(event) {
|
|
551
|
+
const button = event.target.closest("[data-action]");
|
|
552
|
+
if (button?.dataset.action === "remove") this.removeLine(Number(button.dataset.index));
|
|
553
|
+
}
|
|
554
|
+
```
|
|
555
|
+
|
|
329
556
|
---
|
|
330
557
|
|
|
331
558
|
### `Element` — DOM Accessor (returned by `View.get()`)
|
|
332
559
|
|
|
333
|
-
Returned by `view.get(id)
|
|
560
|
+
Returned by `view.get(id)` (and, as an array, by `view.find(id)`). Wraps a single DOM element for reading and writing.
|
|
334
561
|
|
|
335
|
-
| Method
|
|
336
|
-
|
|
|
337
|
-
| `set(
|
|
338
|
-
| `value
|
|
562
|
+
| Method / property | Description |
|
|
563
|
+
| ------------------- | ----------- |
|
|
564
|
+
| `set(html)` | Sets the `innerHTML` of the element (plain text or HTML). Returns the wrapper, for chaining. **Not escaped**: pass values from users or the network through `layout` |
|
|
565
|
+
| `attr(name, value)` | Sets an attribute; `null` or `undefined` removes it, `""` sets a boolean attribute (`disabled`, `hidden`, `required`). Returns the wrapper, for chaining |
|
|
566
|
+
| `value()` | Returns the current `innerHTML` of the element |
|
|
567
|
+
| `element` | The underlying DOM element, for anything else (`classList`, `textContent`, properties). `null` when no element has that id |
|
|
568
|
+
| `Element.from(element, id)` | Static. Wraps an element you already have |
|
|
569
|
+
|
|
570
|
+
`set()`, `attr()` and `value()` never throw. When no element has the id, they do nothing (`value()` returns `""`) and log a warning with the id — usually a typo. On a destroyed view (for example after an async request finished when the user had already left), they do nothing silently. Code that uses `element` or `this.domNode` directly has no such guard.
|
|
571
|
+
|
|
572
|
+
> `set()` replaces plain markup. Don't use it to clear a container that holds sub-views: call `removeSubView()` for each of them first, otherwise they stay in the parent's `subViews`.
|
|
339
573
|
|
|
340
574
|
```js
|
|
341
575
|
this.get("username").set("Alice");
|
|
342
576
|
const current = this.get("username").value();
|
|
577
|
+
|
|
578
|
+
// chaining, with escaped data from the API
|
|
579
|
+
this.get("quote_id")
|
|
580
|
+
.set(layout`${quote.number}`)
|
|
581
|
+
.attr("href", `#quote?id=${quote.id}`);
|
|
582
|
+
|
|
583
|
+
this.get("save").attr("disabled", ""); // disable
|
|
584
|
+
this.get("save").attr("disabled", null); // enable again
|
|
343
585
|
```
|
|
344
586
|
|
|
587
|
+
> `attr()` can't inject HTML, but URL attributes (`href`, `src`) still accept `javascript:` URLs. Don't put untrusted values into them unchecked.
|
|
588
|
+
|
|
345
589
|
---
|
|
346
590
|
|
|
347
591
|
### `layout` — Tagged Template Function
|
|
@@ -372,6 +616,33 @@ await root.loadLayout("/src/layout/mainlayout.html");
|
|
|
372
616
|
root.render();
|
|
373
617
|
```
|
|
374
618
|
|
|
619
|
+
#### Overriding `loadLayout()`
|
|
620
|
+
|
|
621
|
+
When a view class overrides `loadLayout()` and passes a **path**, it must `await` (or `return`) `super.loadLayout(…)`:
|
|
622
|
+
|
|
623
|
+
```js
|
|
624
|
+
class QuoteRow extends View {
|
|
625
|
+
loadLayout() {
|
|
626
|
+
return super.loadLayout("/quotes/layout/quote_row.html"); // ✓ returns the promise
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// Equivalent; handy when the override does more after loading:
|
|
631
|
+
class QuoteRow extends View {
|
|
632
|
+
async loadLayout() {
|
|
633
|
+
await super.loadLayout("/quotes/layout/quote_row.html"); // ✓ waits, then resolves
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
```
|
|
637
|
+
|
|
638
|
+
The framework waits for the promise returned by `loadLayout()` before calling `inflate()`. Loading a path is asynchronous (`fetch()` for `.html`, a dynamic `import()` for `.js`), so without `await` the layout arrives too late and the view renders empty.
|
|
639
|
+
|
|
640
|
+
| Passed to `super.loadLayout()` | Loading | `await` needed |
|
|
641
|
+
| --- | --- | --- |
|
|
642
|
+
| An `.html` path | `fetch()` | **yes** |
|
|
643
|
+
| A `.js` path | dynamic `import()` | **yes** |
|
|
644
|
+
| A string (inline HTML, or a layout module imported at the top of the file) | synchronous | no |
|
|
645
|
+
|
|
375
646
|
`mainlayout.html` is a plain HTML fragment:
|
|
376
647
|
|
|
377
648
|
```html
|
|
@@ -388,6 +659,8 @@ root.render();
|
|
|
388
659
|
|
|
389
660
|
> **Note:** `.html` layouts are fetched at runtime and cached in memory — identical dedup behaviour to the browser's module cache for `.js` layouts. Each unique path is fetched once per page load.
|
|
390
661
|
|
|
662
|
+
`loadLayout()` loads **once per view**: calls made while a load is in progress (with or without a path) wait for it, and once a layout is loaded, later calls return at once. A failed load (for example a 404) leaves the view without a layout, so it can be retried. This also makes `new View("/path/to/layout.html")` followed by `addSubView()` work: `addSubView()` waits for the layout the constructor started loading.
|
|
663
|
+
|
|
391
664
|
---
|
|
392
665
|
|
|
393
666
|
### `raw(html)` — Function
|
|
@@ -427,9 +700,24 @@ Hash-based SPA router. Singleton. Declared in the layout as `<Router jops animat
|
|
|
427
700
|
| `navigate(path, ClassDef?)` | Creates a new view instance for `path`, pushes onto the nav stack, animates forward. Updates address bar to `#path?params`. `ClassDef` required for dynamic routes not in the static route map |
|
|
428
701
|
| `back()` | Navigates back — pops the nav stack with animation if non-empty, otherwise calls `history.back()` |
|
|
429
702
|
| `canGoBack()` | Returns `true` if there is an earlier JOPS history entry to go back to (like checking the back stack in Android) |
|
|
430
|
-
| `replace(path, ClassDef?)` | Clears the nav stack and navigates to `path`. Uses existing flat route instance if available, otherwise creates a new one from `ClassDef`
|
|
703
|
+
| `replace(path, ClassDef?)` | Clears the nav stack and navigates to `path`. Uses existing flat route instance if available, otherwise creates a new one from `ClassDef`. Replaces the current history entry and keeps its existing `history.state` |
|
|
431
704
|
| `getParams()` | Returns the current URL query params as a plain object. Works for both static (`href="#path?k=v"`) and dynamic (`navigate("/path?k=v")`) routes |
|
|
432
705
|
|
|
706
|
+
#### Navigation and the lifecycle
|
|
707
|
+
|
|
708
|
+
| Navigation | Outgoing view | Incoming view |
|
|
709
|
+
| --- | --- | --- |
|
|
710
|
+
| Route link (`href="#users"`), Forward to a route | `onPause()` | `onResume()` |
|
|
711
|
+
| Back to the previous route | `onBackPressed()`, `onPause()` | `onResume()` |
|
|
712
|
+
| Same route with new params (`#details?id=1` → `#details?id=2`) | — | `onResume()` |
|
|
713
|
+
| `navigate()` (stacked view) | `onPause()` | new instance: `onLayout()` → `onResume()` |
|
|
714
|
+
| Back from a stacked view | `onBackPressed()`, then destroyed | `onResume()` |
|
|
715
|
+
| Route link while stacked views are open | stacked views destroyed (no `onPause()`) | `onResume()` |
|
|
716
|
+
|
|
717
|
+
- The first route declared in `<Router>` is the default: it is shown on startup when the URL has no known route hash, and whenever the hash becomes empty (`href="#"`, a link to the page itself).
|
|
718
|
+
- Each route is resumed only when it is shown; at startup only the selected route gets `onResume()`. Every route gets `onLayout()` at startup.
|
|
719
|
+
- **Screen rule:** put screen views in `<Router>`. A route's own layout is not scanned for declared views, so a screen adds its child views in code with `addSubView()`.
|
|
720
|
+
|
|
433
721
|
#### Animation
|
|
434
722
|
|
|
435
723
|
Set via the `animation` attribute on the `<Router>` element:
|
|
@@ -440,7 +728,7 @@ Set via the `animation` attribute on the `<Router>` element:
|
|
|
440
728
|
| `"slide"` | Forward: incoming slides in from right. Back: outgoing slides out to right |
|
|
441
729
|
| `"fade"` | Crossfade between outgoing and incoming |
|
|
442
730
|
|
|
443
|
-
Animation only occurs on `navigate()` (forward) and `back()` when the nav stack is non-empty. Flat tab switching is always instant.
|
|
731
|
+
Animation only occurs on `navigate()` (forward) and `back()` when the nav stack is non-empty. Flat tab switching is always instant, and so is a transition involving a view whose layout has several top-level elements.
|
|
444
732
|
|
|
445
733
|
#### Layout Declaration
|
|
446
734
|
|
|
@@ -498,18 +786,46 @@ Store.singleton().clear();
|
|
|
498
786
|
|
|
499
787
|
Stateless cross-view event broadcasting. All methods are static — no instance needed.
|
|
500
788
|
|
|
501
|
-
| Method
|
|
502
|
-
|
|
|
503
|
-
| `EventBus.subscribe(event, callback)` | Registers a listener
|
|
504
|
-
| `EventBus.
|
|
505
|
-
| `EventBus.
|
|
789
|
+
| Method | Description |
|
|
790
|
+
| ---------------------------------------------- | ---------------------------------------------------------------------- |
|
|
791
|
+
| `EventBus.subscribe(owner, event, callback)` | Registers a listener owned by `owner` (usually a view, `this`). It is removed automatically when the owner is destroyed |
|
|
792
|
+
| `EventBus.subscribe(event, callback)` | Registers a global listener. It stays until you unsubscribe it |
|
|
793
|
+
| `EventBus.send(event, data?)` | Fires the named event to all subscribers with an optional data payload |
|
|
794
|
+
| `EventBus.unsubscribe(event, callback)` | Removes a specific listener (owned or global) by reference |
|
|
795
|
+
| `EventBus.unsubscribeAll(owner)` | Removes every subscription of `owner` (called by `View.destroy()`) |
|
|
796
|
+
| `EventBus.clear()` | Removes every listener of every event |
|
|
506
797
|
|
|
507
798
|
```js
|
|
508
|
-
import { EventBus } from "jops-core";
|
|
799
|
+
import { View, EventBus } from "jops-core";
|
|
800
|
+
|
|
801
|
+
class Header extends View {
|
|
802
|
+
onLayout() {
|
|
803
|
+
// owned: removed when this view is destroyed (removeSubView, Router, unmount)
|
|
804
|
+
EventBus.subscribe(this, "user:login", (user) => this.showUser(user));
|
|
805
|
+
}
|
|
806
|
+
}
|
|
509
807
|
|
|
510
|
-
EventBus.subscribe("user:login", (data) => console.log(data.name));
|
|
511
808
|
EventBus.send("user:login", { name: "Alice" });
|
|
512
|
-
|
|
809
|
+
|
|
810
|
+
// global: keep the function reference to be able to unsubscribe it
|
|
811
|
+
const onLogin = (user) => console.log(user.name);
|
|
812
|
+
EventBus.subscribe("user:login", onLogin);
|
|
813
|
+
EventBus.unsubscribe("user:login", onLogin);
|
|
814
|
+
```
|
|
815
|
+
|
|
816
|
+
Prefer the owned form inside views. A global subscription made by a view keeps that view in memory and keeps calling it after it is destroyed, until you unsubscribe it (in an overridden `destroy()` that calls `super.destroy()`, or in `onPause()` with a new subscription in `onResume()`).
|
|
817
|
+
|
|
818
|
+
---
|
|
819
|
+
|
|
820
|
+
### `mount()` / `unmount()` — Functions
|
|
821
|
+
|
|
822
|
+
Start and end an app in a host page (hosted mode). Used by `JopsApp` in Next.js; see [Without React: hosted mode](#without-react-hosted-mode). The page must load jops-core with `data-host` (otherwise it starts `data-app` / `./src/App.js` by itself); `window.JOPS.mount` / `window.JOPS.unmount` are the same functions.
|
|
823
|
+
|
|
824
|
+
```js
|
|
825
|
+
import { mount, unmount } from "jops-core";
|
|
826
|
+
|
|
827
|
+
await mount({ app: "/my-app/App.js", root: document.getElementById("app") });
|
|
828
|
+
unmount(); // destroys the Router and all views, empties the root, clears Store and EventBus
|
|
513
829
|
```
|
|
514
830
|
|
|
515
831
|
---
|
|
@@ -554,17 +870,19 @@ t.terminate();
|
|
|
554
870
|
|
|
555
871
|
| Command | Description |
|
|
556
872
|
| ---------------------- | -------------------------------------------------------------------------- |
|
|
557
|
-
| `npx jops-core init` | Vendor library, configure `index.html`, inject scripts into `package.json`. Detects Cordova and Capacitor projects (see [Hybrid Mobile Apps](#hybrid-mobile-apps-cordova-ionic--capacitor)) |
|
|
558
|
-
| `npx jops-core update` | Copy the latest `jops-core.min.js` from `node_modules` to `lib/`
|
|
873
|
+
| `npx jops-core init` | Vendor library, configure `index.html`, inject scripts into `package.json`. Detects Cordova and Capacitor projects (see [Hybrid Mobile Apps](#hybrid-mobile-apps-cordova-ionic--capacitor)). Template 3 creates a Next.js project instead (see [Next.js Integration](#nextjs-integration)) |
|
|
874
|
+
| `npx jops-core update` | Copy the latest `jops-core.min.js` from `node_modules` to `lib/` (`public/lib/` in a Next.js project) |
|
|
559
875
|
| `npx jops-core build` | Generate production-ready `dist/` folder (skipped in Cordova/Capacitor projects) |
|
|
560
876
|
|
|
561
877
|
`init` and `update` accept `--cordova`, `--capacitor` or `--web` to override the detected project type.
|
|
562
878
|
|
|
879
|
+
After each command the CLI checks npm for a newer jops-core and prints a notice if one is available (at most 1.5 s, silent when offline). `build` also warns if `lib/jops-core.min.js` doesn't match the installed version. Turn the check off with `--no-update-check` or `JOPS_NO_UPDATE_CHECK=1`; it is skipped automatically when `CI` is set.
|
|
880
|
+
|
|
563
881
|
---
|
|
564
882
|
|
|
565
883
|
## Source & Contributing
|
|
566
884
|
|
|
567
|
-
JOPS is distributed as a single minified ES module (`lib/jops-core.min.js`) together with full TypeScript declarations (`lib/jops-core.d.ts`). The source repository is private.
|
|
885
|
+
JOPS is distributed as a single minified ES module (`lib/jops-core.min.js`) together with full TypeScript declarations (`lib/jops-core.d.ts`), plus the `JopsApp` React component for Next.js (`lib/nextjs/`, imported as `jops-core/nextjs`). The source repository is private.
|
|
568
886
|
|
|
569
887
|
This is a deliberate choice. JOPS is maintained by one developer at [UIDB](https://ui-db.com), and keeping the codebase in one pair of hands keeps it small, consistent, and fast to change.
|
|
570
888
|
|