@inglorious/web 6.9.1 → 6.10.1
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/package.json +2 -2
- package/src/mount.js +17 -1
- package/src/router/accessible-navigation.js +95 -0
- package/src/router/handlers.js +10 -0
- package/types/mount.d.ts +10 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@inglorious/web",
|
|
3
|
-
"version": "6.
|
|
3
|
+
"version": "6.10.1",
|
|
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,95 @@
|
|
|
1
|
+
const ANNOUNCER_ID = "iw-route-announcer"
|
|
2
|
+
// Long enough for screen readers to notice the live region emptied first.
|
|
3
|
+
const ANNOUNCE_DELAY_MS = 100
|
|
4
|
+
const VISUALLY_HIDDEN =
|
|
5
|
+
"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"
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Makes client-side navigation do for assistive technology what a full page load does:
|
|
9
|
+
* the new page is announced to screen readers, and focus moves back to the top of the
|
|
10
|
+
* document, so the next Tab starts from the beginning of the new page. An element with
|
|
11
|
+
* `autofocus` on the new page gets the focus instead.
|
|
12
|
+
*
|
|
13
|
+
* A navigation leaves focus where it is when its history state has `isFocusKept`,
|
|
14
|
+
* which a link sets with `data-is-focus-kept`. The page is still announced.
|
|
15
|
+
*
|
|
16
|
+
* `mount` calls the function this returns after each render, so the new page is in
|
|
17
|
+
* the document by the time focus moves.
|
|
18
|
+
*
|
|
19
|
+
* @param {Object} [options]
|
|
20
|
+
* @param {() => string | null} [options.getPageName] - The name of the current page, or
|
|
21
|
+
* `null` to be asked again after the next render; by default the document title.
|
|
22
|
+
* @returns {() => void} The function to call after each render.
|
|
23
|
+
*/
|
|
24
|
+
export function createAccessibleNavigation({
|
|
25
|
+
getPageName = () => document.title,
|
|
26
|
+
} = {}) {
|
|
27
|
+
// Screen readers only announce changes made after the live region is in the document.
|
|
28
|
+
const announcer = getAnnouncer()
|
|
29
|
+
let currentPath = window.location.pathname
|
|
30
|
+
let hasPendingAnnouncement = false
|
|
31
|
+
let announceTimeout = null
|
|
32
|
+
|
|
33
|
+
// Screen readers speak only when the live region changes, so it is emptied first and
|
|
34
|
+
// filled on a later task: two pages with the same name are each announced.
|
|
35
|
+
function announce(message) {
|
|
36
|
+
announcer.textContent = ""
|
|
37
|
+
clearTimeout(announceTimeout)
|
|
38
|
+
announceTimeout = setTimeout(() => {
|
|
39
|
+
announcer.textContent = message
|
|
40
|
+
}, ANNOUNCE_DELAY_MS)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return function afterRender() {
|
|
44
|
+
const path = window.location.pathname
|
|
45
|
+
if (path !== currentPath) {
|
|
46
|
+
currentPath = path
|
|
47
|
+
hasPendingAnnouncement = true
|
|
48
|
+
|
|
49
|
+
if (!window.history.state?.isFocusKept) {
|
|
50
|
+
resetFocus()
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (!hasPendingAnnouncement) return
|
|
55
|
+
|
|
56
|
+
const name = getPageName()
|
|
57
|
+
if (name == null) return
|
|
58
|
+
|
|
59
|
+
hasPendingAnnouncement = false
|
|
60
|
+
announce(name || document.querySelector("h1")?.textContent || path)
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function getAnnouncer() {
|
|
65
|
+
const existing = document.getElementById(ANNOUNCER_ID)
|
|
66
|
+
if (existing) return existing
|
|
67
|
+
|
|
68
|
+
const announcer = document.createElement("div")
|
|
69
|
+
announcer.id = ANNOUNCER_ID
|
|
70
|
+
announcer.setAttribute("aria-live", "assertive")
|
|
71
|
+
announcer.setAttribute("aria-atomic", "true")
|
|
72
|
+
announcer.style.cssText = VISUALLY_HIDDEN
|
|
73
|
+
document.body.append(announcer)
|
|
74
|
+
return announcer
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Focusing the body makes the next Tab start from the top of the new page, rather
|
|
78
|
+
// than from the link that was clicked, which may no longer exist.
|
|
79
|
+
function resetFocus() {
|
|
80
|
+
const autofocus = document.querySelector("[autofocus]")
|
|
81
|
+
if (autofocus) {
|
|
82
|
+
autofocus.focus()
|
|
83
|
+
return
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const { body } = document
|
|
87
|
+
const tabIndex = body.getAttribute("tabindex")
|
|
88
|
+
body.tabIndex = -1
|
|
89
|
+
body.focus({ preventScroll: true })
|
|
90
|
+
if (tabIndex === null) {
|
|
91
|
+
body.removeAttribute("tabindex")
|
|
92
|
+
} else {
|
|
93
|
+
body.setAttribute("tabindex", tabIndex)
|
|
94
|
+
}
|
|
95
|
+
}
|
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
|
/**
|