Files
blockly/core/utils/aria.ts
Ben Henning bb342f9644 feat: Make Flyout an ARIA list (experimental) (#9528)
## The basics

- [x] I [validated my changes](https://developers.google.com/blockly/guides/contribute/core#making_and_verifying_a_change)

## The details
### Resolves

Fixes #9495

### Proposed Changes

Changes a bunch of ARIA role & label management to ensure that `Flyout` acts like a list rather than a tree.

### Reason for Changes

`Flyout`s are always hierarchically flat so it doesn't make sense to model them as a tree. Instead, a menu is likely a better fit per https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Roles/menu_role:

> A `menu` generally represents a grouping of common actions or functions that the user can invoke. The `menu` role is appropriate when a list of menu items is presented in a manner similar to a menu on a desktop application. Submenus, also known as pop-up menus, also have the role `menu`.

However, there are important caveats that need to be considered and addressed:
- As discussed below, menus introduce some unexpected compatibility issues with VoiceOver so this PR presently uses `list` and `listitem`s as a slightly more generic enumerating alternative for menus.
- Menus (and to some extent lists) are stricter\* than trees in that they seem to impose a requirement that `menuitem`s cannot contain interactive elements (they are expected to be interactive themselves). This has led to a few specific changes:
  - Block children are now hidden when in the flyout (since they aren't navigable anyway).
  - Flyout buttons are themselves now the `menuitem` rather than their container parent, and they no longer use the role button.
- Menus aren't really expected to contain labels but it isn't inherently disallowed. This is less of an issue with lists.
- Because everything must be a `listitem` (or a few more specific alternatives) both blocks and buttons lack some context. Since not all `Flyout` items can be expected to be interactive, buttons and blocks have both had their labels updated to include an explicit indicator that they are buttons and blocks, respectively. Note that this does possibly go against convention for buttons in particular but it seems fine since this is an unusual (but seemingly correct) utilization of a `list` element.
- To further provide context on blocks, the generated label for blocks in the `Flyout` is now its verbose label rather than the more compact form.

\* This is largely a consequence of a few specific attributes of `menuitem` and `menu`s as a whole and very likely also applies to `tree`s and `treeitem`s (and `list`s and `listitems`s). However, now seemed like a good time to fix this especially in case some screen readers get confused rather than ignore nested interactive controls/follow semantic cloaking per the spec.

Demo of it working on VoiceOver (per @gonfunko -- note this was the `menu` variant rather than the `list` variant of the PR):

![Screen Recording 2025-12-11 at 2 50 30 PM](https://github.com/user-attachments/assets/24c4389f-73c7-4cb5-96ce-d9666841cdd8)

### Test Coverage

This has been manually tested with ChromeVox. No automated tests are needed as part of this experimental change.

### Documentation

No new documentation changes are needed for this experimental change.

### Additional Information

None.
2025-12-15 10:13:23 -08:00

231 lines
7.1 KiB
TypeScript

/**
* @license
* Copyright 2019 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
// Former goog.module ID: Blockly.utils.aria
/** ARIA states/properties prefix. */
const ARIA_PREFIX = 'aria-';
/** ARIA role attribute. */
const ROLE_ATTRIBUTE = 'role';
/**
* ARIA role values.
* Copied from Closure's goog.a11y.aria.Role
*/
export enum Role {
// ARIA role for a group of related elements like tree item siblings.
GROUP = 'group',
// ARIA role for a listbox.
LISTBOX = 'listbox',
// ARIA role for a popup menu.
MENU = 'menu',
// ARIA role for menu item elements.
MENUITEM = 'menuitem',
// ARIA role for option items that are children of combobox, listbox, menu,
// radiogroup, or tree elements.
OPTION = 'option',
// ARIA role for ignorable cosmetic elements with no semantic significance.
PRESENTATION = 'presentation',
// 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.
SEPARATOR = 'separator',
// ARIA role for a live region providing information.
STATUS = 'status',
IMAGE = 'image',
FIGURE = 'figure',
BUTTON = 'button',
CHECKBOX = 'checkbox',
TEXTBOX = 'textbox',
COMBOBOX = 'combobox',
SPINBUTTON = 'spinbutton',
REGION = 'region',
GENERIC = 'generic',
LIST = 'list',
LISTITEM = 'listitem',
}
/**
* ARIA states and properties.
* Copied from Closure's goog.a11y.aria.State
*/
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.
ACTIVEDESCENDANT = 'activedescendant',
// ARIA state for a disabled item. 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}.
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 that defines an element's number of position in a list.
// Value: integer.
POSINSET = 'posinset',
// 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',
VALUENOW = 'valuenow',
// 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}.
HIDDEN = 'hidden',
ROLEDESCRIPTION = 'roledescription',
OWNS = 'owns',
HASPOPUP = 'haspopup',
CONTROLS = 'controls',
CHECKED = 'checked',
}
/**
* Updates the specific role for the specified element.
*
* @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) {
element.setAttribute(ROLE_ATTRIBUTE, roleName);
} else element.removeAttribute(ROLE_ATTRIBUTE);
}
/**
* 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 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 {
// This is an unsafe cast which is why it needs to be checked to ensure that
// it references a valid role.
const currentRoleName = element.getAttribute(ROLE_ATTRIBUTE) as Role;
if (Object.values(Role).includes(currentRoleName)) {
return currentRoleName;
}
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,
stateName: State,
value: string | boolean | number | string[],
) {
if (Array.isArray(value)) {
value = value.join(' ');
}
const attrStateName = ARIA_PREFIX + stateName;
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) {
element.removeAttribute(ARIA_PREFIX + stateName);
}
/**
* 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;
return element.getAttribute(attrStateName);
}
/**
* Assertively requests that the specified text be read to the user if a screen
* reader is currently active.
*
* This relies on a centrally managed ARIA live region that is hidden from the
* visual DOM. This live region is assertive, meaning it will interrupt other
* text being read.
*
* Callers should use this judiciously. It's often considered bad practice to
* over-announce information that can be inferred from other sources on the
* page, so this ought to be used only when certain context cannot be easily
* determined (such as dynamic states that may not have perfect ARIA
* representations or indications).
*
* @param text The text to read to the user.
*/
export function announceDynamicAriaState(text: string) {
const ariaAnnouncementSpan = document.getElementById('blocklyAriaAnnounce');
if (!ariaAnnouncementSpan) {
throw new Error('Expected element with id blocklyAriaAnnounce to exist.');
}
ariaAnnouncementSpan.innerHTML = text;
}