@inglorious/web 6.9.1 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inglorious/web",
3
- "version": "6.9.1",
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.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,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
+ }
@@ -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
  /**