From b4d21b2f9487d8dec63d86bdc42ca9148782a2cb Mon Sep 17 00:00:00 2001 From: Michael Harvey <43474485+mikeharv@users.noreply.github.com> Date: Wed, 8 Apr 2026 10:38:20 -0400 Subject: [PATCH] feat: aria utility additions / improvements (#9690) --- packages/blockly/core/utils/aria.ts | 357 ++++++++++++++------ packages/blockly/tests/mocha/aria_test.js | 377 +++++++++++++--------- 2 files changed, 484 insertions(+), 250 deletions(-) diff --git a/packages/blockly/core/utils/aria.ts b/packages/blockly/core/utils/aria.ts index 6e50ff161..5837adead 100644 --- a/packages/blockly/core/utils/aria.ts +++ b/packages/blockly/core/utils/aria.ts @@ -51,119 +51,229 @@ export interface DynamicAnnouncementOptions { } /** - * ARIA role values. - * Copied from Closure's goog.a11y.aria.Role + * A valid ARIA role for a Blockly DOM element. See also setRole() and getRole(). + * + * This should be used instead of directly setting an element's role attribute. */ export enum Role { - // ARIA role for an interactive control of tabular data. + /** See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Roles/application_role. */ + APPLICATION = 'application', + /** See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Roles/button_role. */ + BUTTON = 'button', + /** See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Roles/checkbox_role. */ + CHECKBOX = 'checkbox', + /** See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Roles/dialog_role. */ + DIALOG = 'dialog', + /** See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Roles/figure_role. */ + FIGURE = 'figure', + /** See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Roles/generic_role. */ + GENERIC = 'generic', + /** See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Roles/grid_role. */ GRID = 'grid', - - // ARIA role for a cell in a grid. + /** See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Roles/gridcell_role. */ GRIDCELL = 'gridcell', - // ARIA role for a group of related elements like tree item siblings. + /** See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Roles/group_role. */ GROUP = 'group', - - // ARIA role for a listbox. + /** See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Roles/list_role. */ + LIST = 'list', + /** See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Roles/listbox_role. */ LISTBOX = 'listbox', - - // ARIA role for a popup menu. + /** See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Roles/listitem_role. */ + LISTITEM = 'listitem', + /** See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Roles/menu_role. */ MENU = 'menu', - - // ARIA role for menu item elements. + /** See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Roles/menuitem_role. */ MENUITEM = 'menuitem', - // ARIA role for a checkbox box element inside a menu. - MENUITEMCHECKBOX = 'menuitemcheckbox', - // ARIA role for option items that are children of combobox, listbox, menu, - // radiogroup, or tree elements. + /** See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Roles/option_role. */ OPTION = 'option', - // ARIA role for ignorable cosmetic elements with no semantic significance. + /** See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Roles/presentation_role. */ PRESENTATION = 'presentation', - - // ARIA role for a row of cells in a grid. + /** See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Roles/region_role. */ + REGION = 'region', + /** See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Roles/row_role. */ ROW = 'row', - // ARIA role for a tree. - TREE = 'tree', - - // ARIA role for a tree item that sometimes may be expanded or collapsed. - TREEITEM = 'treeitem', - - // ARIA role for a visual separator in e.g. a menu. + /** See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Roles/separator_role. */ SEPARATOR = 'separator', - - // ARIA role for a live region providing information. + /** See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Roles/status_role. */ STATUS = 'status', + /** See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Roles/textbox_role. */ + TEXTBOX = 'textbox', + /** See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Roles/tree_role. */ + TREE = 'tree', + /** See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Roles/treeitem_role. */ + TREEITEM = 'treeitem', } const DEFAULT_LIVE_REGION_ROLE = Role.STATUS; /** - * ARIA states and properties. - * Copied from Closure's goog.a11y.aria.State + * A possible ARIA attribute state for a Blockly DOM element. See also setState() and getState(). + * + * This should be used instead of directly setting aria-* attributes on elements. */ export enum State { - // ARIA property for setting the currently active descendant of an element, - // for example the selected item in a list box. Value: ID of an element. + /** + * See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-activedescendant. + * + * Value: ID of a DOM element. + */ ACTIVEDESCENDANT = 'activedescendant', - // ARIA property that, if true, indicates that all of a changed region should - // be presented, instead of only parts. Value: one of {true, false}. - ATOMIC = 'atomic', - // ARIA property defines the total number of columns in a table, grid, or - // treegrid. - // Value: integer. + /** + * See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-atomic. + * + * Value: one of {true, false}. + */ + ATOMIC = 'ATOMIC', + /** + * See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-checked. + * + * Value: one of {true, false, mixed, undefined}. + */ + CHECKED = 'checked', + /** + * See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-colcount. + * + * Value: an integer representing the number of columns in a grid. + */ COLCOUNT = 'colcount', - // ARIA state for a disabled item. Value: one of {true, false}. + /** + * See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-controls. + * + * Value: an array of element IDs. + */ + CONTROLS = 'controls', + /** + * See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-disabled. + * + * Value: one of {true, false}. + */ DISABLED = 'disabled', - - // ARIA state for setting whether the element like a tree node is expanded. - // Value: one of {true, false, undefined}. + /** + * See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-expanded. + * + * Value: one of {true, false, undefined}. + */ EXPANDED = 'expanded', - - // ARIA state indicating that the entered value does not conform. Value: - // one of {false, true, 'grammar', 'spelling'} - INVALID = 'invalid', - - // ARIA property that provides a label to override any other text, value, or - // contents used to describe this element. Value: string. - LABEL = 'label', - // ARIA property for setting the element which labels another element. - // Value: space-separated IDs of elements. - LABELLEDBY = 'labelledby', - - // ARIA property for setting the level of an element in the hierarchy. - // Value: integer. - LEVEL = 'level', - // ARIA property indicating if the element is horizontal or vertical. - // Value: one of {'vertical', 'horizontal'}. - ORIENTATION = 'orientation', - - // ARIA property that defines an element's number of position in a list. - // Value: integer. - POSINSET = 'posinset', - - // ARIA property defines the total number of rows in a table, grid, or - // treegrid. - // Value: integer. - ROWCOUNT = 'rowcount', - - // ARIA state for setting the currently selected item in the list. - // Value: one of {true, false, undefined}. - SELECTED = 'selected', - // ARIA property defining the number of items in a list. Value: integer. - SETSIZE = 'setsize', - - // ARIA property for slider maximum value. Value: number. - VALUEMAX = 'valuemax', - - // ARIA property for slider minimum value. Value: number. - VALUEMIN = 'valuemin', - - // ARIA property for live region chattiness. - // Value: one of {polite, assertive, off}. - LIVE = 'live', - - // ARIA property for removing elements from the accessibility tree. - // Value: one of {true, false, undefined}. + /** + * See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-haspopup. + * + * Value: one of {true, false, menu, listbox, tree, grid, dialog}. + */ + HASPOPUP = 'haspopup', + /** + * See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-hidden. + * + * Value: one of {true, false,undefined}. + */ HIDDEN = 'hidden', + /** + * See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-invalid. + * + * Value: one of {true, false, grammar, spelling}. + */ + INVALID = 'invalid', + /** + * See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-label. + * + * Value: a string. + */ + LABEL = 'label', + /** + * See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-labelledby. + * + * Value: an array of element IDs. + */ + LABELLEDBY = 'labelledby', + /** + * See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-level. + * + * Value: an integer. + */ + LEVEL = 'level', + /** + * See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-live. + * + * Value: one of {polite, assertive, off}. + */ + LIVE = 'live', + /** + * See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-orientation. + * + * Value: one of {horizontal, vertical, undefined}. + */ + ORIENTATION = 'orientation', + /** + * See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-posinset. + * + * Value: an integer representing the position of the element within a set of related elements. + */ + POSINSET = 'posinset', + /** + * See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-pressed. + * + * Value: one of {true, false, mixed, undefined}. + */ + PRESSED = 'pressed', + /** + * See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-readonly. + * + * Value: one of {true, false}. + */ + READONLY = 'readonly', + /** + * See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-required. + * + * Value: one of {true, false}. + */ + REQUIRED = 'required', + /** + * See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-roledescription. + * + * Value: a string. + */ + ROLEDESCRIPTION = 'roledescription', + /** + * See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-rowcount. + * + * Value: an integer representing the number of rows in a grid or table. + */ + ROWCOUNT = 'rowcount', + /** + * See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-rowindex. + * + * Value: an integer representing the index of the element within a set of related elements. + */ + ROWINDEX = 'rowindex', + /** + * See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-rowspan. + * + * Value: an integer representing the number of rows a cell spans in a grid or table. + */ + ROWSPAN = 'rowspan', + /** + * See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-selected. + * + * Value:one of {true, false, undefined}. + */ + SELECTED = 'selected', + /** + * See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-setsize. + * + * Value: an integer representing the total number of elements in a set of related elements. + */ + SETSIZE = 'setsize', + /** + * See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-valuemax. + * + * Value: a number representing the maximum allowed value for a range widget. + */ + VALUEMAX = 'valuemax', + /** + * See https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Attributes/aria-valuemin. + * + * Value: a number representing the minimum allowed value for a range widget. + */ + VALUEMIN = 'valuemin', } /** @@ -178,13 +288,11 @@ export function removeRole(element: Element) { } /** - * Sets the ARIA role of an element. If `roleName` is null, - * the role is removed. + * Updates the specific role for the specified element. * - * Similar to Closure's goog.a11y.aria.setRole - * - * @param element DOM node to set role of. - * @param roleName Role name, or null to remove the role. + * @param element The element whose ARIA role should be changed. + * @param roleName The new role for the specified element, or null if its role + * should be cleared. */ export function setRole(element: Element, roleName: Role | null) { if (!roleName) { @@ -195,14 +303,31 @@ export function setRole(element: Element, roleName: Role | null) { } /** - * Sets the state or property of an element. - * Copied from Closure's goog.a11y.aria + * Returns the ARIA role of the specified element, or null if it either doesn't + * have a designated role or if that role is unknown. * - * @param element DOM node where we set state. - * @param stateName State attribute being set. - * Automatically adds prefix 'aria-' to the state name if the attribute is - * not an extra attribute. - * @param value Value for the state attribute. + * @param element The element from which to retrieve its ARIA role. + * @returns The ARIA role of the element, or null if undefined or unknown. + */ +export function getRole(element: Element): Role | null { + const role = element.getAttribute(ROLE_ATTRIBUTE); + if (role && Object.values(Role).includes(role as Role)) { + return role as Role; + } + return null; +} + +/** + * Sets the specified ARIA state by its name and value for the specified + * element. + * + * Note that the type of value is not validated against the specific type of + * state being changed, so it's up to callers to ensure the correct value is + * used for the given state. + * + * @param element The element whose ARIA state may be changed. + * @param stateName The state to change. + * @param value The new value to specify for the provided state. */ export function setState( element: Element, @@ -216,6 +341,36 @@ export function setState( element.setAttribute(attrStateName, `${value}`); } +/** + * Clears the specified ARIA state by removing any related attributes from the + * specified element that have been set using setState(). + * + * @param element The element whose ARIA state may be changed. + * @param stateName The state to clear from the provided element. + */ +export function clearState(element: Element, stateName: State) { + const attrStateName = ARIA_PREFIX + stateName; + element.removeAttribute(attrStateName); +} + +/** + * Returns a string representation of the specified state for the specified + * element, or null if it's not defined or specified. + * + * Note that an explicit set state of 'null' will return the 'null' string, not + * the value null. + * + * @param element The element whose state is being retrieved. + * @param stateName The state to retrieve. + * @returns The string representation of the requested state for the specified + * element, or null if not defined. + */ +export function getState(element: Element, stateName: State): string | null { + const attrStateName = ARIA_PREFIX + stateName; + const value = element.getAttribute(attrStateName); + return value ? value : null; +} + let liveRegionElement: HTMLElement | null = null; /** diff --git a/packages/blockly/tests/mocha/aria_test.js b/packages/blockly/tests/mocha/aria_test.js index 960f8e69b..806b5d7c0 100644 --- a/packages/blockly/tests/mocha/aria_test.js +++ b/packages/blockly/tests/mocha/aria_test.js @@ -21,167 +21,246 @@ suite('Aria', function () { sharedTestTeardown.call(this); }); - test('live region is created', function () { - assert.isNotNull(this.liveRegion); - }); - - test('live region has polite aria-live', function () { - assert.equal(this.liveRegion.getAttribute('aria-live'), 'polite'); - }); - - test('live region has atomic true', function () { - assert.equal(this.liveRegion.getAttribute('aria-atomic'), 'true'); - }); - - test('live region has status role by default', function () { - assert.equal(this.liveRegion.getAttribute('role'), 'status'); - }); - - test('live region is rendered for screen readers but visually hidden', function () { - const style = window.getComputedStyle(this.liveRegion); - - // Still rendered for screen readers - assert.notEqual(style.display, 'none'); - - // Visually hidden via hiddenForAria class - assert.equal(style.position, 'absolute'); - assert.equal(style.left, '-9999px'); - assert.equal(style.width, '1px'); - assert.equal(style.height, '1px'); - assert.equal(style.overflow, 'hidden'); - }); - - test('createLiveRegion only creates one region (singleton)', function () { - // Calling again should not create a duplicate. - Blockly.utils.aria.initializeGlobalAriaLiveRegion( - this.workspace.getInjectionDiv(), - ); - - const regions = this.workspace - .getInjectionDiv() - .querySelectorAll('#blocklyAriaAnnounce'); - - assert.equal(regions.length, 1); - }); - - test('announcement is delayed', function () { - Blockly.utils.aria.announceDynamicAriaState('Hello world'); - - assert.equal(this.liveRegion.textContent, ''); - - // Advance past the delay in announceDynamicAriaState. - this.clock.tick(11); - assert.include(this.liveRegion.textContent, 'Hello world'); - }); - - test('repeated announcements are unique', function () { - Blockly.utils.aria.announceDynamicAriaState('Block moved'); - this.clock.tick(11); - - const first = this.liveRegion.textContent; - - Blockly.utils.aria.announceDynamicAriaState('Block moved'); - this.clock.tick(11); - - const second = this.liveRegion.textContent; - - assert.notEqual(first, second); - }); - - test('last write wins when called rapidly', function () { - Blockly.utils.aria.announceDynamicAriaState('First message'); - Blockly.utils.aria.announceDynamicAriaState('Second message'); - Blockly.utils.aria.announceDynamicAriaState('Final message'); - - this.clock.tick(11); - - assert.include(this.liveRegion.textContent, 'Final message'); - }); - - test('assertive option sets aria-live assertive', function () { - Blockly.utils.aria.announceDynamicAriaState('Warning', { - assertiveness: Blockly.utils.aria.LiveRegionAssertiveness.ASSERTIVE, - role: null, + suite('Live Region', function () { + test('live region is created', function () { + assert.isNotNull(this.liveRegion); }); - this.clock.tick(11); - - assert.equal(this.liveRegion.getAttribute('aria-live'), 'assertive'); - }); - - test('role option updates role attribute', function () { - Blockly.utils.aria.announceDynamicAriaState('Alert message', { - assertiveness: Blockly.utils.aria.LiveRegionAssertiveness.POLITE, - role: Blockly.utils.aria.Role.GROUP, + test('live region has polite aria-live', function () { + assert.equal(this.liveRegion.getAttribute('aria-live'), 'polite'); }); - this.clock.tick(11); - - assert.equal(this.liveRegion.getAttribute('role'), 'group'); - }); - - test('role and text update after delay', function () { - // Initial announcement to establish baseline role + text. - Blockly.utils.aria.announceDynamicAriaState('Initial message', { - assertiveness: Blockly.utils.aria.LiveRegionAssertiveness.POLITE, - role: Blockly.utils.aria.Role.STATUS, - }); - this.clock.tick(11); - - assert.equal(this.liveRegion.getAttribute('role'), 'status'); - const initialText = this.liveRegion.textContent; - - // Now announce with different role. - Blockly.utils.aria.announceDynamicAriaState('Group message', { - assertiveness: Blockly.utils.aria.LiveRegionAssertiveness.POLITE, - role: Blockly.utils.aria.Role.GROUP, + test('live region has atomic true', function () { + assert.equal(this.liveRegion.getAttribute('aria-atomic'), 'true'); }); - // Before delay: role and text should not have changed yet. - this.clock.tick(5); - assert.equal(this.liveRegion.getAttribute('role'), 'status'); - assert.equal(this.liveRegion.textContent, initialText); - - // After delay: both should update. - this.clock.tick(6); - assert.equal(this.liveRegion.getAttribute('role'), 'group'); - assert.include(this.liveRegion.textContent, 'Group message'); - }); - test('missing role does not clear default status role', function () { - Blockly.utils.aria.announceDynamicAriaState('Hello world'); - - this.clock.tick(11); - - assert.equal(this.liveRegion.getAttribute('role'), 'status'); - }); - test('custom role overrides default status role', function () { - Blockly.utils.aria.announceDynamicAriaState('Group message', { - assertiveness: Blockly.utils.aria.LiveRegionAssertiveness.POLITE, - role: Blockly.utils.aria.Role.GROUP, + test('live region has status role by default', function () { + assert.equal(this.liveRegion.getAttribute('role'), 'status'); }); - this.clock.tick(11); + test('live region is rendered for screen readers but visually hidden', function () { + const style = window.getComputedStyle(this.liveRegion); - assert.equal(this.liveRegion.getAttribute('role'), 'group'); - }); - test('role reverts to status after custom role when role not provided', function () { - // First: default - Blockly.utils.aria.announceDynamicAriaState('Normal message'); - this.clock.tick(11); - assert.equal(this.liveRegion.getAttribute('role'), 'status'); + // Still rendered for screen readers + assert.notEqual(style.display, 'none'); - // Second: custom role - Blockly.utils.aria.announceDynamicAriaState('Group message', { - assertiveness: Blockly.utils.aria.LiveRegionAssertiveness.POLITE, - role: Blockly.utils.aria.Role.GROUP, + // Visually hidden via hiddenForAria class + assert.equal(style.position, 'absolute'); + assert.equal(style.left, '-9999px'); + assert.equal(style.width, '1px'); + assert.equal(style.height, '1px'); + assert.equal(style.overflow, 'hidden'); }); - this.clock.tick(11); - assert.equal(this.liveRegion.getAttribute('role'), 'group'); - // Third: no role provided should revert to default status. - Blockly.utils.aria.announceDynamicAriaState('Back to normal'); - this.clock.tick(11); + test('createLiveRegion only creates one region (singleton)', function () { + // Calling again should not create a duplicate. + Blockly.utils.aria.initializeGlobalAriaLiveRegion( + this.workspace.getInjectionDiv(), + ); - assert.equal(this.liveRegion.getAttribute('role'), 'status'); + const regions = this.workspace + .getInjectionDiv() + .querySelectorAll('#blocklyAriaAnnounce'); + + assert.equal(regions.length, 1); + }); + + test('announcement is delayed', function () { + Blockly.utils.aria.announceDynamicAriaState('Hello world'); + + assert.equal(this.liveRegion.textContent, ''); + + // Advance past the delay in announceDynamicAriaState. + this.clock.tick(11); + assert.include(this.liveRegion.textContent, 'Hello world'); + }); + + test('repeated announcements are unique', function () { + Blockly.utils.aria.announceDynamicAriaState('Block moved'); + this.clock.tick(11); + + const first = this.liveRegion.textContent; + + Blockly.utils.aria.announceDynamicAriaState('Block moved'); + this.clock.tick(11); + + const second = this.liveRegion.textContent; + + assert.notEqual(first, second); + }); + + test('last write wins when called rapidly', function () { + Blockly.utils.aria.announceDynamicAriaState('First message'); + Blockly.utils.aria.announceDynamicAriaState('Second message'); + Blockly.utils.aria.announceDynamicAriaState('Final message'); + + this.clock.tick(11); + + assert.include(this.liveRegion.textContent, 'Final message'); + }); + + test('assertive option sets aria-live assertive', function () { + Blockly.utils.aria.announceDynamicAriaState('Warning', { + assertiveness: Blockly.utils.aria.LiveRegionAssertiveness.ASSERTIVE, + role: null, + }); + + this.clock.tick(11); + + assert.equal(this.liveRegion.getAttribute('aria-live'), 'assertive'); + }); + + test('role option updates role attribute', function () { + Blockly.utils.aria.announceDynamicAriaState('Alert message', { + assertiveness: Blockly.utils.aria.LiveRegionAssertiveness.POLITE, + role: Blockly.utils.aria.Role.GROUP, + }); + + this.clock.tick(11); + + assert.equal(this.liveRegion.getAttribute('role'), 'group'); + }); + + test('role and text update after delay', function () { + // Initial announcement to establish baseline role + text. + Blockly.utils.aria.announceDynamicAriaState('Initial message', { + assertiveness: Blockly.utils.aria.LiveRegionAssertiveness.POLITE, + role: Blockly.utils.aria.Role.STATUS, + }); + this.clock.tick(11); + + assert.equal(this.liveRegion.getAttribute('role'), 'status'); + const initialText = this.liveRegion.textContent; + + // Now announce with different role. + Blockly.utils.aria.announceDynamicAriaState('Group message', { + assertiveness: Blockly.utils.aria.LiveRegionAssertiveness.POLITE, + role: Blockly.utils.aria.Role.GROUP, + }); + + // Before delay: role and text should not have changed yet. + this.clock.tick(5); + assert.equal(this.liveRegion.getAttribute('role'), 'status'); + assert.equal(this.liveRegion.textContent, initialText); + + // After delay: both should update. + this.clock.tick(6); + assert.equal(this.liveRegion.getAttribute('role'), 'group'); + assert.include(this.liveRegion.textContent, 'Group message'); + }); + test('missing role does not clear default status role', function () { + Blockly.utils.aria.announceDynamicAriaState('Hello world'); + + this.clock.tick(11); + + assert.equal(this.liveRegion.getAttribute('role'), 'status'); + }); + test('custom role overrides default status role', function () { + Blockly.utils.aria.announceDynamicAriaState('Group message', { + assertiveness: Blockly.utils.aria.LiveRegionAssertiveness.POLITE, + role: Blockly.utils.aria.Role.GROUP, + }); + + this.clock.tick(11); + + assert.equal(this.liveRegion.getAttribute('role'), 'group'); + }); + test('role reverts to status after custom role when role not provided', function () { + // First: default + Blockly.utils.aria.announceDynamicAriaState('Normal message'); + this.clock.tick(11); + assert.equal(this.liveRegion.getAttribute('role'), 'status'); + + // Second: custom role + Blockly.utils.aria.announceDynamicAriaState('Group message', { + assertiveness: Blockly.utils.aria.LiveRegionAssertiveness.POLITE, + role: Blockly.utils.aria.Role.GROUP, + }); + this.clock.tick(11); + assert.equal(this.liveRegion.getAttribute('role'), 'group'); + + // Third: no role provided should revert to default status. + Blockly.utils.aria.announceDynamicAriaState('Back to normal'); + this.clock.tick(11); + + assert.equal(this.liveRegion.getAttribute('role'), 'status'); + }); + }); + suite('Utils', function () { + let element; + + setup(function () { + element = document.createElement('div'); + document.body.appendChild(element); + }); + + teardown(function () { + element.remove(); + }); + + test('getRole returns null for element with no role', function () { + assert.isNull(Blockly.utils.aria.getRole(element)); + }); + + test('getRole returns correct role if set', function () { + element.setAttribute('role', 'button'); + assert.equal( + Blockly.utils.aria.getRole(element), + Blockly.utils.aria.Role.BUTTON, + ); + }); + + test('getRole returns null for unknown role', function () { + element.setAttribute('role', 'foobar'); + assert.isNull(Blockly.utils.aria.getRole(element)); + }); + + test('setState sets aria state as attribute', function () { + Blockly.utils.aria.setState( + element, + Blockly.utils.aria.State.DISABLED, + true, + ); + assert.equal(element.getAttribute('aria-disabled'), 'true'); + }); + + test('getState retrieves previously set state', function () { + Blockly.utils.aria.setState( + element, + Blockly.utils.aria.State.HIDDEN, + false, + ); + assert.equal( + Blockly.utils.aria.getState(element, Blockly.utils.aria.State.HIDDEN), + 'false', + ); + }); + + test('getState returns null for state not set', function () { + assert.isNull( + Blockly.utils.aria.getState(element, Blockly.utils.aria.State.SELECTED), + ); + }); + + test('clearState removes previously set attribute', function () { + Blockly.utils.aria.setState( + element, + Blockly.utils.aria.State.CHECKED, + true, + ); + assert.equal(element.getAttribute('aria-checked'), 'true'); + + Blockly.utils.aria.clearState(element, Blockly.utils.aria.State.CHECKED); + assert.isNull(element.getAttribute('aria-checked')); + }); + + test('setState handles array values correctly', function () { + Blockly.utils.aria.setState(element, Blockly.utils.aria.State.LABEL, [ + 'one', + 'two', + 'three', + ]); + assert.equal(element.getAttribute('aria-label'), 'one two three'); + }); }); });