@inglorious/web 6.9.0 → 6.10.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 +16 -12
- package/package.json +2 -2
- package/src/mount.js +17 -1
- package/src/router/accessible-navigation.js +83 -0
- package/src/router/handlers.js +10 -0
- package/types/mount.d.ts +10 -0
package/README.md
CHANGED
|
@@ -89,7 +89,7 @@ It's that simple — and surprisingly fast in practice.
|
|
|
89
89
|
- You want UI to be fully controlled by your entity-based store
|
|
90
90
|
- You want to stay entirely in **JavaScript**, without DSLs or compilers
|
|
91
91
|
- You want **React-like declarative UI** but without the cost and overhead of React
|
|
92
|
-
- You want
|
|
92
|
+
- You want **pre-rendered sites with SSX**, static or server-rendered on request — same entity patterns, HTML from the server, and client hydration
|
|
93
93
|
|
|
94
94
|
This framework is ideal for both small apps and large business UIs.
|
|
95
95
|
|
|
@@ -289,7 +289,7 @@ import { createStore } from "@inglorious/store"
|
|
|
289
289
|
import { html } from "@inglorious/web"
|
|
290
290
|
|
|
291
291
|
const types = {
|
|
292
|
-
|
|
292
|
+
Counter: {
|
|
293
293
|
increment(entity) {
|
|
294
294
|
entity.value++
|
|
295
295
|
},
|
|
@@ -1282,6 +1282,7 @@ api.notify("geolocationUnwatch")
|
|
|
1282
1282
|
|
|
1283
1283
|
```javascript
|
|
1284
1284
|
import { mount, html } from "@inglorious/web"
|
|
1285
|
+
import { when } from "@inglorious/web/directives/when"
|
|
1285
1286
|
import { store } from "./store.js"
|
|
1286
1287
|
|
|
1287
1288
|
const renderApp = (api) => {
|
|
@@ -1295,12 +1296,13 @@ const renderApp = (api) => {
|
|
|
1295
1296
|
<p>Loading: ${geolocation.isLoading ? "yes" : "no"}</p>
|
|
1296
1297
|
<p>
|
|
1297
1298
|
Position:
|
|
1298
|
-
${
|
|
1299
|
-
position
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1299
|
+
${when(
|
|
1300
|
+
position,
|
|
1301
|
+
() =>
|
|
1302
|
+
html`${position.coords.latitude.toFixed(4)},
|
|
1303
|
+
${position.coords.longitude.toFixed(4)}`,
|
|
1304
|
+
() => "unknown",
|
|
1305
|
+
)}
|
|
1304
1306
|
</p>
|
|
1305
1307
|
<p>Error: ${geolocation.error ? geolocation.error.message : "none"}</p>
|
|
1306
1308
|
<button
|
|
@@ -1461,11 +1463,13 @@ Events:
|
|
|
1461
1463
|
|
|
1462
1464
|
```javascript
|
|
1463
1465
|
const renderUpdate = (api) =>
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
+
when(
|
|
1467
|
+
api.getEntity("pwaUpdate").isUpdateReady,
|
|
1468
|
+
() =>
|
|
1469
|
+
html`<button @click=${() => api.notify("#pwaUpdate:pwaUpdateApply")}>
|
|
1466
1470
|
Update now
|
|
1467
|
-
</button
|
|
1468
|
-
|
|
1471
|
+
</button>`,
|
|
1472
|
+
)
|
|
1469
1473
|
```
|
|
1470
1474
|
|
|
1471
1475
|
Inglorious UI's `Snackbar` is the usual place for it. `watchPwaUpdate` takes `{ id, checkEveryMs }` as a third argument, to announce to an entity with another id or to check more or less often, and returns a function that stops watching.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@inglorious/web",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.10.0",
|
|
4
4
|
"description": "A new web framework that leverages the power of the Inglorious Store combined with the performance and simplicity of lit-html.",
|
|
5
5
|
"author": "Matteo Antony Mistretta <antony.mistretta@gmail.com> (https://ingloriouscoderz.it)",
|
|
6
6
|
"contributors": [
|
|
@@ -114,7 +114,7 @@
|
|
|
114
114
|
"lit-html": "^3.3.3"
|
|
115
115
|
},
|
|
116
116
|
"peerDependencies": {
|
|
117
|
-
"@inglorious/store": "12.
|
|
117
|
+
"@inglorious/store": "12.1.0"
|
|
118
118
|
},
|
|
119
119
|
"devDependencies": {
|
|
120
120
|
"@inglorious/eslint-config": "1.2.0",
|
package/src/mount.js
CHANGED
|
@@ -6,17 +6,25 @@
|
|
|
6
6
|
import { html, render } from "lit-html"
|
|
7
7
|
|
|
8
8
|
import { isNative } from "./mobile/index.js"
|
|
9
|
+
import { createAccessibleNavigation } from "./router/accessible-navigation.js"
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
12
|
* Mounts a lit-html template to the DOM and subscribes to a store for re-rendering.
|
|
12
13
|
* @param {Store} store - The application state store.
|
|
13
14
|
* @param {(api: Api) => TemplateResult | null} renderFn - The root render function.
|
|
14
15
|
* @param {HTMLElement | DocumentFragment} element - The DOM element to mount the template to.
|
|
15
|
-
*
|
|
16
|
+
* In an app with the `Router` type, each navigation is also announced to screen readers
|
|
17
|
+
* and moves focus back to the top of the new page, as a full page load would.
|
|
18
|
+
*
|
|
19
|
+
* @param {Object} [options] - Root error-boundary and navigation options.
|
|
16
20
|
* @param {(error: unknown, api: Api) => void} [options.onError] - Called when the
|
|
17
21
|
* root render throws. Defaults to `console.error`.
|
|
18
22
|
* @param {(error: unknown, api: Api) => TemplateResult | null} [options.fallback] -
|
|
19
23
|
* Optional fallback template rendered in place of the failed root render.
|
|
24
|
+
* @param {boolean} [options.hasAccessibleNavigation=true] - Whether navigations are
|
|
25
|
+
* announced and move focus. Only apps with the `Router` type navigate.
|
|
26
|
+
* @param {() => string | null} [options.getPageName] - The name to announce for the
|
|
27
|
+
* current page, or `null` while it is not known yet; by default the document title.
|
|
20
28
|
* @returns {() => void} An unsubscribe function
|
|
21
29
|
*/
|
|
22
30
|
export async function mount(store, renderFn, element, options = {}) {
|
|
@@ -24,6 +32,8 @@ export async function mount(store, renderFn, element, options = {}) {
|
|
|
24
32
|
onError = defaultOnError,
|
|
25
33
|
fallback,
|
|
26
34
|
hydrate: allowHydrate = true,
|
|
35
|
+
hasAccessibleNavigation = true,
|
|
36
|
+
getPageName,
|
|
27
37
|
} = options
|
|
28
38
|
|
|
29
39
|
// Tells CSS the native app from a browser tab, which env() cannot: the
|
|
@@ -48,6 +58,11 @@ export async function mount(store, renderFn, element, options = {}) {
|
|
|
48
58
|
}
|
|
49
59
|
}
|
|
50
60
|
|
|
61
|
+
const afterRender =
|
|
62
|
+
hasAccessibleNavigation && store.getType("Router")
|
|
63
|
+
? createAccessibleNavigation({ getPageName })
|
|
64
|
+
: null
|
|
65
|
+
|
|
51
66
|
const unsubscribe = store.subscribe(() => {
|
|
52
67
|
const result = renderRoot(renderFn, api, onError, fallback)
|
|
53
68
|
if (result.ok) {
|
|
@@ -56,6 +71,7 @@ export async function mount(store, renderFn, element, options = {}) {
|
|
|
56
71
|
} else if (fallback) {
|
|
57
72
|
render(result.fallback, element)
|
|
58
73
|
}
|
|
74
|
+
afterRender?.()
|
|
59
75
|
})
|
|
60
76
|
|
|
61
77
|
store.notify("init")
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
const ANNOUNCER_ID = "iw-route-announcer"
|
|
2
|
+
const VISUALLY_HIDDEN =
|
|
3
|
+
"position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip:rect(0 0 0 0);clip-path:inset(50%);white-space:nowrap;border:0"
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Makes client-side navigation do for assistive technology what a full page load does:
|
|
7
|
+
* the new page is announced to screen readers, and focus moves back to the top of the
|
|
8
|
+
* document, so the next Tab starts from the beginning of the new page. An element with
|
|
9
|
+
* `autofocus` on the new page gets the focus instead.
|
|
10
|
+
*
|
|
11
|
+
* A navigation leaves focus where it is when its history state has `isFocusKept`,
|
|
12
|
+
* which a link sets with `data-is-focus-kept`. The page is still announced.
|
|
13
|
+
*
|
|
14
|
+
* `mount` calls the function this returns after each render, so the new page is in
|
|
15
|
+
* the document by the time focus moves.
|
|
16
|
+
*
|
|
17
|
+
* @param {Object} [options]
|
|
18
|
+
* @param {() => string | null} [options.getPageName] - The name of the current page, or
|
|
19
|
+
* `null` to be asked again after the next render; by default the document title.
|
|
20
|
+
* @returns {() => void} The function to call after each render.
|
|
21
|
+
*/
|
|
22
|
+
export function createAccessibleNavigation({
|
|
23
|
+
getPageName = () => document.title,
|
|
24
|
+
} = {}) {
|
|
25
|
+
// Screen readers only announce changes made after the live region is in the document.
|
|
26
|
+
const announcer = getAnnouncer()
|
|
27
|
+
let currentPath = window.location.pathname
|
|
28
|
+
let hasPendingAnnouncement = false
|
|
29
|
+
|
|
30
|
+
return function afterRender() {
|
|
31
|
+
const path = window.location.pathname
|
|
32
|
+
if (path !== currentPath) {
|
|
33
|
+
currentPath = path
|
|
34
|
+
hasPendingAnnouncement = true
|
|
35
|
+
|
|
36
|
+
if (!window.history.state?.isFocusKept) {
|
|
37
|
+
resetFocus()
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (!hasPendingAnnouncement) return
|
|
42
|
+
|
|
43
|
+
const name = getPageName()
|
|
44
|
+
if (name == null) return
|
|
45
|
+
|
|
46
|
+
hasPendingAnnouncement = false
|
|
47
|
+
announcer.textContent =
|
|
48
|
+
name || document.querySelector("h1")?.textContent || path
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function getAnnouncer() {
|
|
53
|
+
const existing = document.getElementById(ANNOUNCER_ID)
|
|
54
|
+
if (existing) return existing
|
|
55
|
+
|
|
56
|
+
const announcer = document.createElement("div")
|
|
57
|
+
announcer.id = ANNOUNCER_ID
|
|
58
|
+
announcer.setAttribute("aria-live", "assertive")
|
|
59
|
+
announcer.setAttribute("aria-atomic", "true")
|
|
60
|
+
announcer.style.cssText = VISUALLY_HIDDEN
|
|
61
|
+
document.body.append(announcer)
|
|
62
|
+
return announcer
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Focusing the body makes the next Tab start from the top of the new page, rather
|
|
66
|
+
// than from the link that was clicked, which may no longer exist.
|
|
67
|
+
function resetFocus() {
|
|
68
|
+
const autofocus = document.querySelector("[autofocus]")
|
|
69
|
+
if (autofocus) {
|
|
70
|
+
autofocus.focus()
|
|
71
|
+
return
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const { body } = document
|
|
75
|
+
const tabIndex = body.getAttribute("tabindex")
|
|
76
|
+
body.tabIndex = -1
|
|
77
|
+
body.focus({ preventScroll: true })
|
|
78
|
+
if (tabIndex === null) {
|
|
79
|
+
body.removeAttribute("tabindex")
|
|
80
|
+
} else {
|
|
81
|
+
body.setAttribute("tabindex", tabIndex)
|
|
82
|
+
}
|
|
83
|
+
}
|
package/src/router/handlers.js
CHANGED
|
@@ -86,6 +86,16 @@ export function createHandlers(routeConfig) {
|
|
|
86
86
|
event.preventDefault()
|
|
87
87
|
|
|
88
88
|
const path = link.pathname + link.search + link.hash
|
|
89
|
+
|
|
90
|
+
// Stored in the history entry, for whatever moves focus after navigating.
|
|
91
|
+
if (link.hasAttribute("data-is-focus-kept")) {
|
|
92
|
+
api.notify(`#${entityId}:navigate`, {
|
|
93
|
+
to: path,
|
|
94
|
+
state: { isFocusKept: true },
|
|
95
|
+
})
|
|
96
|
+
return
|
|
97
|
+
}
|
|
98
|
+
|
|
89
99
|
api.notify(`#${entityId}:navigate`, path)
|
|
90
100
|
})
|
|
91
101
|
},
|
package/types/mount.d.ts
CHANGED
|
@@ -57,6 +57,16 @@ export interface MountOptions<
|
|
|
57
57
|
error: unknown,
|
|
58
58
|
api: Api<TEntity, TState>,
|
|
59
59
|
) => TemplateResult | null
|
|
60
|
+
/**
|
|
61
|
+
* Whether navigations are announced to screen readers and move focus to the top of the
|
|
62
|
+
* new page. Only apps with the `Router` type navigate. Defaults to `true`.
|
|
63
|
+
*/
|
|
64
|
+
hasAccessibleNavigation?: boolean
|
|
65
|
+
/**
|
|
66
|
+
* The name to announce for the current page, or `null` while it is not known yet.
|
|
67
|
+
* Defaults to the document title.
|
|
68
|
+
*/
|
|
69
|
+
getPageName?: () => string | null
|
|
60
70
|
}
|
|
61
71
|
|
|
62
72
|
/**
|