@@ -18,7 +18,7 @@ const ROLE_ATTRIBUTE = 'role';
1818 * ARIA state values for LivePriority.
1919 * Copied from Closure's goog.a11y.aria.LivePriority
2020 */
21- export enum LivePriority {
21+ export enum LiveRegionAssertiveness {
2222 // This information has the highest priority and assistive technologies
2323 // SHOULD notify the user immediately. Because an interruption may disorient
2424 // users or cause them to not complete their current task, authors SHOULD NOT
@@ -33,6 +33,23 @@ export enum LivePriority {
3333 POLITE = 'polite' ,
3434}
3535
36+ /**
37+ * Customization options that can be passed when using `announceDynamicAriaState`.
38+ */
39+ export interface DynamicAnnouncementOptions {
40+ /** The custom ARIA `Role` that should be used for the announcement container. */
41+ role ?: Role ;
42+
43+ /**
44+ * How assertive the announcement should be.
45+ *
46+ * Important*: It was found through testing that `ASSERTIVE` announcements are
47+ * often outright ignored by some screen readers, so it's generally recommended
48+ * to always use `POLITE` unless specifically tested across supported readers.
49+ */
50+ assertiveness ?: LiveRegionAssertiveness ;
51+ }
52+
3653/**
3754 * ARIA role values.
3855 * Copied from Closure's goog.a11y.aria.Role
@@ -182,84 +199,74 @@ export function setState(
182199 element . setAttribute ( attrStateName , `${ value } ` ) ;
183200}
184201
202+ let liveRegionElement : HTMLElement | null = null ;
203+
185204/**
186- * Creates the centralized ARIA live region used to announce dynamic state
187- * changes to screen readers. This live region is visually hidden but exposed to
188- * assistive technologies.
189- *
190- * Only one live region should exist per Blockly injection. This function should
191- * be called during workspace/injection setup to create the region inside the
192- * Blockly container.
193- *
194- * See: https://stackoverflow.com/a/48590836 for a reference.
205+ * Creates an ARIA live region under the specified parent Element to be used
206+ * for all dynamic announcements via `announceDynamicAriaState`. This must be
207+ * called only once and before any dynamic announcements can be made.
195208 *
196- * @param container The container element to which the live region will be
197- * appended.
209+ * @param parent The container element to which the live region will be appended.
198210 */
199- export function createLiveRegion ( container : HTMLDivElement ) {
211+ export function initializeGlobalAriaLiveRegion ( parent : HTMLDivElement ) {
212+ if ( liveRegionElement && document . contains ( liveRegionElement ) ) {
213+ return ;
214+ }
200215 const ariaAnnouncementDiv = document . createElement ( 'div' ) ;
201216 ariaAnnouncementDiv . textContent = '' ;
202217 ariaAnnouncementDiv . id = 'blocklyAriaAnnounce' ;
203218 dom . addClass ( ariaAnnouncementDiv , 'hiddenForAria' ) ;
204- setState ( ariaAnnouncementDiv , State . LIVE , LivePriority . POLITE ) ;
205- container . appendChild ( ariaAnnouncementDiv ) ;
219+ setState ( ariaAnnouncementDiv , State . LIVE , LiveRegionAssertiveness . POLITE ) ;
220+ setRole ( ariaAnnouncementDiv , Role . STATUS ) ;
221+ ariaAnnouncementDiv . setAttribute ( 'aria-atomic' , 'true' ) ;
222+ parent . appendChild ( ariaAnnouncementDiv ) ;
223+ liveRegionElement = ariaAnnouncementDiv ;
206224}
207225
208226let ariaAnnounceTimeout : ReturnType < typeof setTimeout > ;
209227let addBreakingSpace = false ;
210228
211229/**
212- * Requests that the specified text be announced to the user via a centrally
213- * managed ARIA live region, if a screen reader is active.
214- *
215- * Announcements are scheduled asynchronously. If this function is called again
216- * before a pending announcement is inserted into the live region, the pending
217- * announcement is canceled and replaced with the new one.
218- *
219- * The live region element must have id `blocklyAriaAnnounce`. Its `aria-live`
220- * politeness setting and optional `role` are updated before the message is
221- * inserted so screen readers announce the content correctly.
230+ * Requests that the specified text be read to the user if a screen reader is
231+ * currently active.
222232 *
223- * A non-breaking space is alternated at the end of the message to ensure that
224- * repeated messages are still announced by screen readers.
233+ * This relies on a centrally managed ARIA live region that is hidden from the
234+ * visual DOM. This live region is designed to try and ensure the text is read,
235+ * including if the same text is issued multiple times consecutively. Note that
236+ * `initializeGlobalAriaLiveRegion` must be called before this can be used.
225237 *
226- * Callers should use this judiciously. Over-announcing can reduce usability,
227- * so this should primarily be used for dynamic states or information that
228- * cannot be conveyed through standard ARIA semantics.
238+ * Callers should use this judiciously. It's often considered bad practice to
239+ * over-announce information that can be inferred from other sources on the page,
240+ * so this ought to be used only when certain context cannot be easily determined
241+ * (such as dynamic states that may not have perfect ARIA representations or
242+ * indications).
229243 *
230- * @param text The text to announce to the user.
231- * @param options Configuration options for the announcement.
232- * @param options.assertiveness The ARIA live region priority
233- * @param options.role Optional ARIA role to apply to the live region before
244+ * @param text The text to read to the user.
245+ * @param options Custom options to configure the announcement. This defaults to no
246+ * custom `Role` and polite assertiveness.
234247 */
248+
235249export function announceDynamicAriaState (
236250 text : string ,
237- options : {
238- assertiveness : LivePriority ;
239- role : Role | null ;
240- } = {
241- assertiveness : LivePriority . POLITE ,
242- role : null ,
243- } ,
251+ options ?: DynamicAnnouncementOptions ,
244252) {
245- const ariaAnnouncementContainer = document . getElementById (
246- 'blocklyAriaAnnounce' ,
247- ) ;
248- if ( ! ariaAnnouncementContainer ) {
249- throw new Error ( 'Expected element with id blocklyAriaAnnounce to exist.' ) ;
253+ if ( ! liveRegionElement ) {
254+ throw new Error ( 'ARIA live region not initialized.' ) ;
250255 }
251- const { assertiveness, role} = options ;
252-
253- // Clear previous content.
254- ariaAnnouncementContainer . replaceChildren ( ) ;
255- setState ( ariaAnnouncementContainer , State . LIVE , assertiveness ) ;
256+ const ariaAnnouncementContainer = liveRegionElement ;
257+ const { assertiveness = LiveRegionAssertiveness . POLITE , role = null } =
258+ options || { } ;
256259
257260 clearTimeout ( ariaAnnounceTimeout ) ;
258261 ariaAnnounceTimeout = setTimeout ( ( ) => {
262+ // Clear previous content.
263+ ariaAnnouncementContainer . replaceChildren ( ) ;
264+ setState ( ariaAnnouncementContainer , State . LIVE , assertiveness ) ;
259265 setRole ( ariaAnnouncementContainer , role ) ;
260- const p = document . createElement ( 'p' ) ;
261- p . textContent = text + ( addBreakingSpace ? '\u00A0' : '' ) ;
266+
267+ const span = document . createElement ( 'span' ) ;
268+ span . textContent = text + ( addBreakingSpace ? '\u00A0' : '' ) ;
262269 addBreakingSpace = ! addBreakingSpace ;
263- ariaAnnouncementContainer . appendChild ( p ) ;
270+ ariaAnnouncementContainer . appendChild ( span ) ;
264271 } , 10 ) ;
265272}
0 commit comments