@lightningjs/blits 2.8.8 → 2.9.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/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  # Changelog
2
2
 
3
+ ## 2.9.0
4
+
5
+ _9 sep 2026_
6
+
7
+ - Added DOM based Aria announcer implementation
8
+
9
+
10
+ ## 2.8.9
11
+
12
+ _12 aug 2026_
13
+
14
+ - Fixed focus issue related to multiple named router views
15
+
3
16
  ## 2.8.8
4
17
 
5
18
  _11 aug 2026_
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lightningjs/blits",
3
- "version": "2.8.8",
3
+ "version": "2.9.0",
4
4
  "description": "Blits: The Lightning 3 App Development Framework",
5
5
  "bin": "bin/index.js",
6
6
  "exports": {
@@ -36,6 +36,10 @@
36
36
  "types": "./src/platforms/webOS/announcer.d.ts",
37
37
  "default": "./src/platforms/webOS/announcer.js"
38
38
  },
39
+ "./platforms/dom/announcer": {
40
+ "types": "./src/platforms/dom/announcer.d.ts",
41
+ "default": "./src/platforms/dom/announcer.js"
42
+ },
39
43
  "./testing": {
40
44
  "types": "./src/testing/index.d.ts",
41
45
  "default": "./src/testing/index.js"
@@ -81,15 +81,30 @@ const addToQueue = (message, politeness, delay = false, options = {}) => {
81
81
 
82
82
  // setup a promise to allow developer to chain functionality
83
83
  // when specific utterances are done
84
- let resolveFn
84
+ let resolvePromise
85
+ let settled = false
85
86
  const done = new Promise((resolve) => {
86
- resolveFn = resolve
87
+ resolvePromise = resolve
87
88
  })
88
89
 
90
+ const resolveFn = (status) => {
91
+ if (settled === true) return
92
+ settled = true
93
+ resolvePromise(status)
94
+ }
95
+
89
96
  // augment the promise with a cancel / remove function
90
97
  done.remove = done.cancel = () => {
91
98
  const index = queue.findIndex((item) => item.id === id)
92
- if (index !== -1) queue.splice(index, 1)
99
+ if (index !== -1) {
100
+ queue.splice(index, 1)
101
+ } else if (id === currentId && debounce !== null) {
102
+ clearDebounceTimer()
103
+ currentId = null
104
+ currentResolveFn = null
105
+ isProcessing = false
106
+ processQueue()
107
+ }
93
108
  Log.debug(`Announcer - removed from queue: "${message}" (id: ${id})`)
94
109
  resolveFn('canceled')
95
110
  }
@@ -20,7 +20,7 @@ import { state } from '../router/router.js'
20
20
  import Settings from '../settings.js'
21
21
  import { DEFAULT_HOLD_TIMEOUT_MS } from '../constants.js'
22
22
  import { Log } from '../lib/log.js'
23
- import { getAncestors } from './helpers.js'
23
+ import { getAncestors, isInAliveComponentTree } from './helpers.js'
24
24
  import { platform } from '../platform.js'
25
25
 
26
26
  let focusedComponent = null
@@ -41,7 +41,7 @@ export default {
41
41
  return focusedComponent
42
42
  },
43
43
  set(component, event) {
44
- if (component === undefined || component.eol === true) return
44
+ if (component === undefined || isInAliveComponentTree(component) === false) return
45
45
 
46
46
  clearTimeout(setFocusTimeout)
47
47
 
@@ -29,3 +29,22 @@ export const getAncestors = (components) => {
29
29
  }
30
30
  return components
31
31
  }
32
+
33
+ /**
34
+ * Checks whether a component and its ancestors are alive. When root is provided,
35
+ * the component must also belong to that root's tree.
36
+ * @param {Object|undefined|null} component
37
+ * @param {Object|undefined|null} [root]
38
+ * @returns {boolean}
39
+ */
40
+ export const isInAliveComponentTree = (component, root) => {
41
+ let current = component
42
+
43
+ while (current !== undefined && current !== null) {
44
+ if (current.eol === true) return false
45
+ if (root !== undefined && current === root) return true
46
+ current = current[symbols.parent]
47
+ }
48
+
49
+ return root === undefined
50
+ }
@@ -0,0 +1,28 @@
1
+ import type { AnnouncerDriver } from '@lightningjs/blits'
2
+
3
+ export interface DOMAnnouncerOptions {
4
+ /**
5
+ * Document in which the live region is created. Defaults to the global document.
6
+ */
7
+ document?: Document | null
8
+ /**
9
+ * Element that receives the live region. Defaults to document.body.
10
+ */
11
+ container?: HTMLElement
12
+ /**
13
+ * ID used to find or create the single live region.
14
+ *
15
+ * @default 'blits-announcer'
16
+ */
17
+ id?: string
18
+ /**
19
+ * Delay between clearing the region and inserting the next message.
20
+ *
21
+ * @default 0
22
+ */
23
+ updateDelay?: number
24
+ }
25
+
26
+ declare function createAnnouncer(options?: DOMAnnouncerOptions): AnnouncerDriver
27
+
28
+ export default createAnnouncer
@@ -0,0 +1,111 @@
1
+ /*
2
+ * Copyright 2026 Comcast Cable Communications Management, LLC
3
+ * Licensed under the Apache License, Version 2.0 (the "License");
4
+ * you may not use this file except in compliance with the License.
5
+ * You may obtain a copy of the License at
6
+ *
7
+ * http://www.apache.org/licenses/LICENSE-2.0
8
+ *
9
+ * Unless required by applicable law or agreed to in writing, software
10
+ * distributed under the License is distributed on an "AS IS" BASIS,
11
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ * See the License for the specific language governing permissions and
13
+ * limitations under the License.
14
+ *
15
+ * SPDX-License-Identifier: Apache-2.0
16
+ */
17
+
18
+ const globalScope = globalThis
19
+ const defaultId = 'blits-announcer'
20
+
21
+ const visuallyHiddenStyles = {
22
+ position: 'absolute',
23
+ width: '1px',
24
+ height: '1px',
25
+ padding: '0',
26
+ margin: '-1px',
27
+ overflow: 'hidden',
28
+ clip: 'rect(0, 0, 0, 0)',
29
+ whiteSpace: 'nowrap',
30
+ border: '0',
31
+ }
32
+
33
+ const createAnnouncer = (options = {}) => {
34
+ const documentRef = Object.prototype.hasOwnProperty.call(options, 'document')
35
+ ? options.document
36
+ : globalScope.document
37
+ const id = options.id || defaultId
38
+ const updateDelay = options.updateDelay === undefined ? 0 : options.updateDelay
39
+
40
+ let region
41
+ let pendingUpdate
42
+
43
+ const getRegion = () => {
44
+ if (!documentRef || typeof documentRef.createElement !== 'function') return
45
+ if (region && region.isConnected !== false) return region
46
+
47
+ region = typeof documentRef.getElementById === 'function' && documentRef.getElementById(id)
48
+
49
+ if (!region) {
50
+ const parent = options.container || documentRef.body || documentRef.documentElement
51
+ if (!parent || typeof parent.appendChild !== 'function') return
52
+
53
+ region = documentRef.createElement('div')
54
+ region.id = id
55
+ parent.appendChild(region)
56
+ }
57
+
58
+ Object.assign(region.style, visuallyHiddenStyles)
59
+ region.setAttribute('role', 'alert')
60
+ region.setAttribute('aria-live', 'assertive')
61
+ region.setAttribute('aria-atomic', 'true')
62
+
63
+ return region
64
+ }
65
+
66
+ const cancelPendingUpdate = (error = 'canceled') => {
67
+ if (pendingUpdate === undefined) return
68
+ clearTimeout(pendingUpdate.timer)
69
+ pendingUpdate.reject({ error })
70
+ pendingUpdate = undefined
71
+ }
72
+
73
+ // Create the empty region before the first announcement so assistive
74
+ // technology can observe subsequent content changes reliably.
75
+ getRegion()
76
+
77
+ return {
78
+ speak(announcement) {
79
+ if (!announcement || announcement.message === undefined || announcement.message === null) {
80
+ return Promise.reject({ error: 'Missing message' })
81
+ }
82
+
83
+ const liveRegion = getRegion()
84
+ if (!liveRegion) {
85
+ return Promise.reject({ error: 'unavailable' })
86
+ }
87
+
88
+ cancelPendingUpdate()
89
+ liveRegion.textContent = ''
90
+
91
+ return new Promise((resolve, reject) => {
92
+ const update = {
93
+ reject,
94
+ timer: setTimeout(() => {
95
+ if (pendingUpdate !== update) return
96
+ liveRegion.textContent = String(announcement.message)
97
+ pendingUpdate = undefined
98
+ resolve()
99
+ }, updateDelay),
100
+ }
101
+ pendingUpdate = update
102
+ })
103
+ },
104
+ cancel() {
105
+ cancelPendingUpdate()
106
+ if (region) region.textContent = ''
107
+ },
108
+ }
109
+ }
110
+
111
+ export default createAnnouncer
@@ -22,6 +22,7 @@ import symbols from '../lib/symbols.js'
22
22
  import { Log } from '../lib/log.js'
23
23
  import { stage } from '../launch.js'
24
24
  import Focus from '../focus/focus.js'
25
+ import { isInAliveComponentTree } from '../focus/helpers.js'
25
26
  import Announcer from '../announcer/announcer.js'
26
27
  import Settings from '../settings.js'
27
28
  import { platform } from '../platform.js'
@@ -80,6 +81,20 @@ export const state = reactive(
80
81
  const history = []
81
82
  const routerViews = new Set()
82
83
  let singleRouterView = null
84
+ let activeNavigations = 0
85
+
86
+ const startNavigation = () => {
87
+ if (activeNavigations++ === 0) {
88
+ state.navigating = true
89
+ }
90
+ }
91
+
92
+ const finishNavigation = () => {
93
+ activeNavigations--
94
+ if (activeNavigations === 0) {
95
+ state.navigating = false
96
+ }
97
+ }
83
98
 
84
99
  // Per-RouterView navigation state keyed by router view name.
85
100
  // When multiple router views exist, each view must have its own navigation
@@ -124,9 +139,17 @@ export const navigate = async function () {
124
139
  if (!this[symbols.parent][symbols.routes] || this[symbols.parent][symbols.routes].length === 0)
125
140
  return
126
141
 
127
- if (this.history === undefined) this.history = []
142
+ startNavigation()
128
143
 
129
- state.navigating = true
144
+ try {
145
+ await performNavigation.call(this, viewState)
146
+ } finally {
147
+ finishNavigation()
148
+ }
149
+ }
150
+
151
+ const performNavigation = async function (viewState) {
152
+ if (this.history === undefined) this.history = []
130
153
 
131
154
  Announcer.stop()
132
155
  Announcer.clear()
@@ -142,8 +165,6 @@ export const navigate = async function () {
142
165
 
143
166
  // early return when route not found
144
167
  if (route === false) {
145
- state.navigating = false
146
-
147
168
  Log.error(`Route ${hash.hash} not found`)
148
169
  const routerHooks = this[symbols.parent][symbols.routerHooks]
149
170
  if (routerHooks && typeof routerHooks.error === 'function') {
@@ -154,7 +175,6 @@ export const navigate = async function () {
154
175
 
155
176
  // sameRouteObject is too simple check for now!
156
177
  if (this.currentRoute !== undefined && sameRouteObject(route, this.currentRoute)) {
157
- state.navigating = false
158
178
  return
159
179
  }
160
180
 
@@ -176,7 +196,6 @@ export const navigate = async function () {
176
196
  )
177
197
  if (beforeEachResult === false) {
178
198
  viewState.preventHashChangeNavigation = false
179
- state.navigating = false
180
199
  return
181
200
  }
182
201
 
@@ -193,7 +212,6 @@ export const navigate = async function () {
193
212
  )
194
213
  if (beforeResult === false) {
195
214
  viewState.preventHashChangeNavigation = false
196
- state.navigating = false
197
215
  return
198
216
  }
199
217
 
@@ -313,7 +331,9 @@ export const navigate = async function () {
313
331
 
314
332
  // set focus to the view that we're routing to (unless explicitly disabling passing focus)
315
333
  if (route.options.passFocus !== false) {
316
- focus ? focus.$focus() : /** @type {BlitsComponent} */ (view).$focus()
334
+ isInAliveComponentTree(focus, view)
335
+ ? /** @type {BlitsComponent} */ (focus).$focus()
336
+ : /** @type {BlitsComponent} */ (view).$focus()
317
337
  }
318
338
 
319
339
  // apply starting state of transition
@@ -400,7 +420,6 @@ export const navigate = async function () {
400
420
 
401
421
  // reset navigating indicators
402
422
  viewState.navigatingBack = false
403
- state.navigating = false
404
423
  viewState.preventHashChangeNavigation = false
405
424
  }
406
425
 
@@ -451,7 +470,6 @@ const executeBeforeHook = async function (
451
470
  platform.historyBack()
452
471
 
453
472
  viewState.navigatingBack = false
454
- state.navigating = false
455
473
  }
456
474
  return false
457
475
  }
@@ -468,7 +486,6 @@ const executeBeforeHook = async function (
468
486
  viewState.preventHashChangeNavigation = true
469
487
  platform.historyBack()
470
488
  viewState.navigatingBack = false
471
- state.navigating = false
472
489
  }
473
490
  return false
474
491
  }