66
77// Former goog.module ID: Blockly.utils.aria
88
9+ import * as dom from './dom.js' ;
10+
911/** ARIA states/properties prefix. */
1012const ARIA_PREFIX = 'aria-' ;
1113
1214/** ARIA role attribute. */
1315const ROLE_ATTRIBUTE = 'role' ;
1416
17+ /**
18+ * ARIA state values for LivePriority.
19+ * Copied from Closure's goog.a11y.aria.LivePriority
20+ */
21+ export enum LiveRegionAssertiveness {
22+ // This information has the highest priority and assistive technologies
23+ // SHOULD notify the user immediately. Because an interruption may disorient
24+ // users or cause them to not complete their current task, authors SHOULD NOT
25+ // use the assertive value unless the interruption is imperative.
26+ ASSERTIVE = 'assertive' ,
27+ // Updates to the region will not be presented to the user unless the
28+ // assistive technology is currently focused on that region.
29+ OFF = 'off' ,
30+ // (Background change) Assistive technologies SHOULD announce the updates at
31+ // the next graceful opportunity, such as at the end of speaking the current
32+ // sentence or when the users pauses typing.
33+ POLITE = 'polite' ,
34+ }
35+
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+
1553/**
1654 * ARIA role values.
1755 * Copied from Closure's goog.a11y.aria.Role
@@ -56,6 +94,8 @@ export enum Role {
5694 STATUS = 'status' ,
5795}
5896
97+ const DEFAULT_LIVE_REGION_ROLE = Role . STATUS ;
98+
5999/**
60100 * ARIA states and properties.
61101 * Copied from Closure's goog.a11y.aria.State
@@ -64,6 +104,9 @@ export enum State {
64104 // ARIA property for setting the currently active descendant of an element,
65105 // for example the selected item in a list box. Value: ID of an element.
66106 ACTIVEDESCENDANT = 'activedescendant' ,
107+ // ARIA property that, if true, indicates that all of a changed region should
108+ // be presented, instead of only parts. Value: one of {true, false}.
109+ ATOMIC = 'atomic' ,
67110 // ARIA property defines the total number of columns in a table, grid, or
68111 // treegrid.
69112 // Value: integer.
@@ -124,15 +167,32 @@ export enum State {
124167}
125168
126169/**
127- * Sets the role of an element.
170+ * Removes the ARIA role from an element.
128171 *
129- * Similar to Closure's goog.a11y.aria
172+ * Similar to Closure's goog.a11y.aria.removeRole
173+ *
174+ * @param element DOM element to remove the role from.
175+ */
176+ export function removeRole ( element : Element ) {
177+ element . removeAttribute ( ROLE_ATTRIBUTE ) ;
178+ }
179+
180+ /**
181+ * Sets the ARIA role of an element. If `roleName` is null,
182+ * the role is removed.
183+ *
184+ * Similar to Closure's goog.a11y.aria.setRole
130185 *
131186 * @param element DOM node to set role of.
132- * @param roleName Role name.
187+ * @param roleName Role name, or null to remove the role .
133188 */
134- export function setRole ( element : Element , roleName : Role ) {
135- element . setAttribute ( ROLE_ATTRIBUTE , roleName ) ;
189+ export function setRole ( element : Element , roleName : Role | null ) {
190+ if ( ! roleName ) {
191+ console . log ( 'Removing role from element' , element , roleName ) ;
192+ removeRole ( element ) ;
193+ } else {
194+ element . setAttribute ( ROLE_ATTRIBUTE , roleName ) ;
195+ }
136196}
137197
138198/**
@@ -156,3 +216,81 @@ export function setState(
156216 const attrStateName = ARIA_PREFIX + stateName ;
157217 element . setAttribute ( attrStateName , `${ value } ` ) ;
158218}
219+
220+ let liveRegionElement : HTMLElement | null = null ;
221+
222+ /**
223+ * Creates an ARIA live region under the specified parent Element to be used
224+ * for all dynamic announcements via `announceDynamicAriaState`. This must be
225+ * called only once and before any dynamic announcements can be made.
226+ *
227+ * @param parent The container element to which the live region will be appended.
228+ */
229+ export function initializeGlobalAriaLiveRegion ( parent : HTMLDivElement ) {
230+ if ( liveRegionElement && document . contains ( liveRegionElement ) ) {
231+ return ;
232+ }
233+ const ariaAnnouncementDiv = document . createElement ( 'div' ) ;
234+ ariaAnnouncementDiv . textContent = '' ;
235+ ariaAnnouncementDiv . id = 'blocklyAriaAnnounce' ;
236+ dom . addClass ( ariaAnnouncementDiv , 'hiddenForAria' ) ;
237+ setState ( ariaAnnouncementDiv , State . LIVE , LiveRegionAssertiveness . POLITE ) ;
238+ setRole ( ariaAnnouncementDiv , DEFAULT_LIVE_REGION_ROLE ) ;
239+ setState ( ariaAnnouncementDiv , State . ATOMIC , true ) ;
240+ parent . appendChild ( ariaAnnouncementDiv ) ;
241+ liveRegionElement = ariaAnnouncementDiv ;
242+ }
243+
244+ let ariaAnnounceTimeout : ReturnType < typeof setTimeout > ;
245+ let addBreakingSpace = false ;
246+
247+ /**
248+ * Requests that the specified text be read to the user if a screen reader is
249+ * currently active.
250+ *
251+ * This relies on a centrally managed ARIA live region that is hidden from the
252+ * visual DOM. This live region is designed to try and ensure the text is read,
253+ * including if the same text is issued multiple times consecutively. Note that
254+ * `initializeGlobalAriaLiveRegion` must be called before this can be used.
255+ *
256+ * Callers should use this judiciously. It's often considered bad practice to
257+ * over-announce information that can be inferred from other sources on the page,
258+ * so this ought to be used only when certain context cannot be easily determined
259+ * (such as dynamic states that may not have perfect ARIA representations or
260+ * indications).
261+ *
262+ * @param text The text to read to the user.
263+ * @param options Custom options to configure the announcement. This defaults to
264+ * the status role and polite assertiveness.
265+ */
266+ export function announceDynamicAriaState (
267+ text : string ,
268+ options ?: DynamicAnnouncementOptions ,
269+ ) {
270+ if ( ! liveRegionElement ) {
271+ throw new Error ( 'ARIA live region not initialized.' ) ;
272+ }
273+ const ariaAnnouncementContainer = liveRegionElement ;
274+ const {
275+ assertiveness = LiveRegionAssertiveness . POLITE ,
276+ role = DEFAULT_LIVE_REGION_ROLE ,
277+ } = options || { } ;
278+
279+ // We use a short delay so rapid successive calls collapse into a single
280+ // announcement, and to ensure assistive technologies reliably detect the
281+ // DOM change.
282+ clearTimeout ( ariaAnnounceTimeout ) ;
283+ ariaAnnounceTimeout = setTimeout ( ( ) => {
284+ // Clear previous content.
285+ ariaAnnouncementContainer . replaceChildren ( ) ;
286+ setState ( ariaAnnouncementContainer , State . LIVE , assertiveness ) ;
287+ setRole ( ariaAnnouncementContainer , role ) ;
288+
289+ const span = document . createElement ( 'span' ) ;
290+ // The non-breaking space toggle ensures otherwise identical consecutive
291+ // messages are still announced.
292+ span . textContent = text + ( addBreakingSpace ? '\u00A0' : '' ) ;
293+ addBreakingSpace = ! addBreakingSpace ;
294+ ariaAnnouncementContainer . appendChild ( span ) ;
295+ } , 10 ) ;
296+ }
0 commit comments