@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inglorious/web",
3
- "version": "6.9.1",
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.0.0"
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
- * @param {Object} [options] - Root error-boundary options.
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
+ }
@@ -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
  /**