Skip to content

Commit a20aa47

Browse files
authored
refactor(delay): update how LiveRegionElement throttles announcements for better ordering of announcements (#41)
* refactor(delay): update how LiveRegionElement throttles announcements for better ordering of announcements * chore: add changeset
1 parent 49850c1 commit a20aa47

File tree

3 files changed

+81
-94
lines changed

3 files changed

+81
-94
lines changed

.changeset/empty-feet-rhyme.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@primer/live-region-element": minor
3+
---
4+
5+
Update how LiveRegion throttles announcements for better ordering of announcements

packages/live-region-element/src/live-region-element.ts

Lines changed: 76 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import {MinHeap} from './MinHeap'
22
import {Ordering, type Order} from './order'
3-
import {throttle, type Throttle} from './throttle'
43

54
type Politeness = 'polite' | 'assertive'
65

@@ -26,7 +25,7 @@ type AnnounceOptions = {
2625
type Message = {
2726
contents: string
2827
politeness: Politeness
29-
scheduled: number | 'immediate'
28+
scheduled: number
3029
}
3130

3231
/**
@@ -40,11 +39,25 @@ type Cancel = () => void
4039
const DEFAULT_THROTTLE_DELAY_MS = 500
4140

4241
class LiveRegionElement extends HTMLElement {
42+
/**
43+
* A flag to indicate if a message has been announced and we are currently
44+
* waiting for the delay to pass before announcing the next message.
45+
*/
46+
#pending: boolean
47+
48+
/**
49+
* A priority queue to store messages to be announced by the live region.
50+
*/
4351
#queue: MinHeap<Message>
52+
53+
/**
54+
* The id for a timeout being used by the live region to either wait until the
55+
* next message or wait until the delay has passed before announcing the next
56+
* message
57+
*/
4458
#timeoutId: number | null
45-
updateContainerWithMessage: Throttle<(message: Message) => void>
4659

47-
constructor({waitMs}: {waitMs?: number} = {}) {
60+
constructor() {
4861
super()
4962

5063
if (!this.shadowRoot) {
@@ -53,24 +66,27 @@ class LiveRegionElement extends HTMLElement {
5366
shadowRoot.appendChild(template.content.cloneNode(true))
5467
}
5568

69+
this.#pending = false
5670
this.#timeoutId = null
5771
this.#queue = new MinHeap({
5872
compareFn: compareMessages,
5973
})
74+
}
6075

61-
this.updateContainerWithMessage = throttle((message: Message) => {
62-
const {contents, politeness} = message
63-
const container = this.shadowRoot?.getElementById(politeness)
64-
if (!container) {
65-
throw new Error(`Unable to find container for message. Expected a container with id="${politeness}"`)
66-
}
76+
/**
77+
* The delay in milliseconds to wait between announcements. This helps to
78+
* prevent announcements getting dropped if multiple are made at the same time.
79+
*/
80+
get delay(): number {
81+
const value = this.getAttribute('delay')
82+
if (value) {
83+
return parseInt(value, 10)
84+
}
85+
return DEFAULT_THROTTLE_DELAY_MS
86+
}
6787

68-
if (container.textContent === contents) {
69-
container.textContent = `${contents}\u00A0`
70-
} else {
71-
container.textContent = contents
72-
}
73-
}, waitMs ?? DEFAULT_THROTTLE_DELAY_MS)
88+
set delay(value: number) {
89+
this.setAttribute('delay', `${value}`)
7490
}
7591

7692
/**
@@ -79,13 +95,14 @@ class LiveRegionElement extends HTMLElement {
7995
*/
8096
public announce(message: string, options: AnnounceOptions = {}): Cancel {
8197
const {delayMs, politeness = 'polite'} = options
98+
const now = Date.now()
8299
const item: Message = {
83100
politeness,
84101
contents: message,
85-
scheduled: delayMs !== undefined ? Date.now() + delayMs : 'immediate',
102+
scheduled: delayMs !== undefined ? now + delayMs : now,
86103
}
87104
this.#queue.insert(item)
88-
this.performWork()
105+
this.#performWork()
89106
return () => {
90107
this.#queue.delete(item)
91108
}
@@ -103,7 +120,11 @@ class LiveRegionElement extends HTMLElement {
103120
return noop
104121
}
105122

106-
performWork() {
123+
#performWork() {
124+
if (this.#pending) {
125+
return
126+
}
127+
107128
let message = this.#queue.peek()
108129
if (!message) {
109130
return
@@ -114,29 +135,20 @@ class LiveRegionElement extends HTMLElement {
114135
this.#timeoutId = null
115136
}
116137

117-
if (message.scheduled === 'immediate') {
118-
message = this.#queue.pop()
119-
if (message) {
120-
this.updateContainerWithMessage(message)
121-
}
122-
this.performWork()
123-
return
124-
}
125-
126138
const now = Date.now()
127139
if (message.scheduled <= now) {
128140
message = this.#queue.pop()
129141
if (message) {
130-
this.updateContainerWithMessage(message)
142+
this.#updateContainerWithMessage(message)
131143
}
132-
this.performWork()
144+
this.#performWork()
133145
return
134146
}
135147

136148
const timeout = message.scheduled > now ? message.scheduled - now : 0
137149
this.#timeoutId = window.setTimeout(() => {
138150
this.#timeoutId = null
139-
this.performWork()
151+
this.#performWork()
140152
}, timeout)
141153
}
142154

@@ -148,16 +160,46 @@ class LiveRegionElement extends HTMLElement {
148160
return container.textContent
149161
}
150162

163+
#updateContainerWithMessage(message: Message) {
164+
// Prevent any other announcements from being made while we are updating the
165+
// contents to trigger an announcement
166+
this.#pending = true
167+
168+
const {contents, politeness} = message
169+
const container = this.shadowRoot?.getElementById(politeness)
170+
if (!container) {
171+
this.#pending = false
172+
throw new Error(`Unable to find container for message. Expected a container with id="${politeness}"`)
173+
}
174+
175+
if (container.textContent === contents) {
176+
container.textContent = `${contents}\u00A0`
177+
} else {
178+
container.textContent = contents
179+
}
180+
181+
if (this.#timeoutId !== null) {
182+
clearTimeout(this.#timeoutId)
183+
}
184+
185+
// Wait the set delay amount before announcing the next message. This should
186+
// help to make sure that announcements are only made once every delay
187+
// amount.
188+
this.#timeoutId = window.setTimeout(() => {
189+
this.#timeoutId = null
190+
this.#pending = false
191+
this.#performWork()
192+
}, this.delay)
193+
}
194+
151195
/**
152-
* Stop any pending messages from being announced by the live region
196+
* Prevent pending messages from being announced by the live region.
153197
*/
154198
public clear(): void {
155199
if (this.#timeoutId !== null) {
156200
clearTimeout(this.#timeoutId)
157201
this.#timeoutId = null
158202
}
159-
160-
this.updateContainerWithMessage.cancel()
161203
this.#queue.clear()
162204
}
163205
}
@@ -210,16 +252,6 @@ function compareMessages(a: Message, b: Message): Order {
210252
return Ordering.Equal
211253
}
212254

213-
// Schedule a before b
214-
if (a.scheduled === 'immediate' && b.scheduled !== 'immediate') {
215-
return Ordering.Less
216-
}
217-
218-
// Schedule a after b
219-
if (a.scheduled !== 'immediate' && b.scheduled === 'immediate') {
220-
return Ordering.Greater
221-
}
222-
223255
// Schedule a before b
224256
if (a.scheduled < b.scheduled) {
225257
return Ordering.Less

packages/live-region-element/src/throttle.ts

Lines changed: 0 additions & 50 deletions
This file was deleted.

0 commit comments

Comments
 (0)