mirror of
https://github.com/google/blockly.git
synced 2026-01-15 12:57:12 +01:00
## The basics - [x] I [validated my changes](https://developers.google.com/blockly/guides/contribute/core#making_and_verifying_a_change) ## The details ### Resolves Fixes part of #8207 Fixes part of #3370 ### Proposed Changes This introduces initial broad ARIA integration in order to enable at least basic screen reader support when using keyboard navigation. Largely this involves introducing ARIA roles and labels in a bunch of places, sometimes done in a way to override normal built-in behaviors of the accessibility node tree in order to get a richer first-class output for Blockly (such as for blocks and workspaces). ### Reason for Changes ARIA is the fundamental basis for configuring how focusable nodes in Blockly are represented to the user when using a screen reader. As such, all focusable nodes requires labels and roles in order to correctly communicate their contexts. The specific approach taken in this PR is to simply add labels and roles to all nodes where obvious with some extra work done for `WorkspaceSvg` and `BlockSvg` in order to represent blocks as a tree (since that seems to be the best fitting ARIA role per those available: https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Reference/Roles). The custom work specifically for blocks includes: - Overriding the role description to be 'block' rather than 'tree item' (which is the default). - Overriding the position, level, and number of sibling counts since those are normally determined based on the DOM tree and blocks are not laid out in the tree the same way they are visually or logically (so these computations were incorrect). This is also the reason for a bunch of extra computation logic being introduced. One note on some of the labels being nonsensical (e.g. 'DoNotOverride?'): this was done intentionally to try and ensure _all_ focusable nodes (that can be focused) have labels, even when the specifics of what that label should be aren't yet clear. More components had these temporary labels until testing revealed how exactly they would behave from a screen reader perspective (at which point their roles and labels were updated as needed). The temporary labels act as an indicator when navigating through the UI, and some of the nodes can't easily be reached (for reasons) and thus may never actually need a label. More work is needed in understanding both what components need labels and what those labels should be, but that will be done beyond this PR. ### Test Coverage No tests are added to this as it's experimental and not a final implementation. The keyboard navigation tests are failing due to a visibility expansion of `connectionCandidate` in `BlockDragStrategy`. There's no way to avoid this breakage, unfortunately. Instead, this PR will be merged and then https://github.com/google/blockly-keyboard-experimentation/pull/684 will be finalized and merged to fix it. There's some additional work that will happen both in that branch and in a later PR in core Blockly to integrate the two experimentation branches as part of #9283 so that CI passes correctly for both branches. ### Documentation No documentation is needed at this time. ### Additional Information This work is experimental and is meant to serve two purposes: - Provide a foundation for testing and iterating the core screen reader experience in Blockly. - Provide a reference point for designing a long-term solution that accounts for all requirements collected during user testing. This code should never be merged into `develop` as it stands. Instead, it will be redesigned with maintainability, testing, and correctness in mind at a future date (see https://github.com/google/blockly-keyboard-experimentation/discussions/673).
439 lines
11 KiB
TypeScript
439 lines
11 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright 2016 Google LLC
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
/**
|
|
* Class for a button in the flyout.
|
|
*
|
|
* @class
|
|
*/
|
|
// Former goog.module ID: Blockly.FlyoutButton
|
|
|
|
import * as browserEvents from './browser_events.js';
|
|
import * as Css from './css.js';
|
|
import type {IBoundedElement} from './interfaces/i_bounded_element.js';
|
|
import type {IFocusableNode} from './interfaces/i_focusable_node.js';
|
|
import type {IFocusableTree} from './interfaces/i_focusable_tree.js';
|
|
import type {IRenderedElement} from './interfaces/i_rendered_element.js';
|
|
import {idGenerator} from './utils.js';
|
|
import * as aria from './utils/aria.js';
|
|
import {Coordinate} from './utils/coordinate.js';
|
|
import * as dom from './utils/dom.js';
|
|
import * as parsing from './utils/parsing.js';
|
|
import {Rect} from './utils/rect.js';
|
|
import * as style from './utils/style.js';
|
|
import {Svg} from './utils/svg.js';
|
|
import type * as toolbox from './utils/toolbox.js';
|
|
import type {WorkspaceSvg} from './workspace_svg.js';
|
|
|
|
/**
|
|
* Class for a button or label in the flyout.
|
|
*/
|
|
export class FlyoutButton
|
|
implements IBoundedElement, IRenderedElement, IFocusableNode
|
|
{
|
|
/** The horizontal margin around the text in the button. */
|
|
static TEXT_MARGIN_X = 5;
|
|
|
|
/** The vertical margin around the text in the button. */
|
|
static TEXT_MARGIN_Y = 2;
|
|
|
|
/** The radius of the flyout button's borders. */
|
|
static BORDER_RADIUS = 4;
|
|
|
|
private readonly text: string;
|
|
private readonly position: Coordinate;
|
|
private readonly callbackKey: string;
|
|
private readonly cssClass: string | null;
|
|
|
|
/** Mouse up event data. */
|
|
private onMouseDownWrapper: browserEvents.Data;
|
|
private onMouseUpWrapper: browserEvents.Data;
|
|
info: toolbox.ButtonOrLabelInfo;
|
|
|
|
/** The width of the button's rect. */
|
|
width = 0;
|
|
|
|
/** The height of the button's rect. */
|
|
height = 0;
|
|
|
|
/** The root SVG group for the button or label. */
|
|
private svgGroup: SVGGElement;
|
|
|
|
/** The SVG element with the text of the label or button. */
|
|
private svgText: SVGTextElement | null = null;
|
|
|
|
/**
|
|
* Holds the cursors svg element when the cursor is attached to the button.
|
|
* This is null if there is no cursor on the button.
|
|
*/
|
|
cursorSvg: SVGElement | null = null;
|
|
|
|
/** The unique ID for this FlyoutButton. */
|
|
private id: string;
|
|
|
|
/**
|
|
* @param workspace The workspace in which to place this button.
|
|
* @param targetWorkspace The flyout's target workspace.
|
|
* @param json The JSON specifying the label/button.
|
|
* @param isFlyoutLabel Whether this button should be styled as a label.
|
|
* @internal
|
|
*/
|
|
constructor(
|
|
private readonly workspace: WorkspaceSvg,
|
|
private readonly targetWorkspace: WorkspaceSvg,
|
|
json: toolbox.ButtonOrLabelInfo,
|
|
private readonly isFlyoutLabel: boolean,
|
|
) {
|
|
this.text = json['text'];
|
|
|
|
this.position = new Coordinate(0, 0);
|
|
|
|
/** The key to the function called when this button is clicked. */
|
|
this.callbackKey =
|
|
(json as AnyDuringMigration)[
|
|
'callbackKey'
|
|
] /* Check the lower case version
|
|
too to satisfy IE */ ||
|
|
(json as AnyDuringMigration)['callbackkey'];
|
|
|
|
/** If specified, a CSS class to add to this button. */
|
|
this.cssClass = (json as AnyDuringMigration)['web-class'] || null;
|
|
|
|
/** The JSON specifying the label / button. */
|
|
this.info = json;
|
|
let cssClass = this.isFlyoutLabel
|
|
? 'blocklyFlyoutLabel'
|
|
: 'blocklyFlyoutButton';
|
|
if (this.cssClass) {
|
|
cssClass += ' ' + this.cssClass;
|
|
}
|
|
|
|
this.id = idGenerator.getNextUniqueId();
|
|
this.svgGroup = dom.createSvgElement(
|
|
Svg.G,
|
|
{'id': this.id, 'class': cssClass},
|
|
this.workspace.getCanvas(),
|
|
);
|
|
|
|
aria.setRole(this.svgGroup, aria.Role.BUTTON);
|
|
aria.setState(this.svgGroup, aria.State.LABEL, 'Button');
|
|
|
|
let shadow;
|
|
if (!this.isFlyoutLabel) {
|
|
// Shadow rectangle (light source does not mirror in RTL).
|
|
shadow = dom.createSvgElement(
|
|
Svg.RECT,
|
|
{
|
|
'class': 'blocklyFlyoutButtonShadow',
|
|
'rx': FlyoutButton.BORDER_RADIUS,
|
|
'ry': FlyoutButton.BORDER_RADIUS,
|
|
'x': 1,
|
|
'y': 1,
|
|
},
|
|
this.svgGroup!,
|
|
);
|
|
}
|
|
// Background rectangle.
|
|
const rect = dom.createSvgElement(
|
|
Svg.RECT,
|
|
{
|
|
'class': this.isFlyoutLabel
|
|
? 'blocklyFlyoutLabelBackground'
|
|
: 'blocklyFlyoutButtonBackground',
|
|
'rx': FlyoutButton.BORDER_RADIUS,
|
|
'ry': FlyoutButton.BORDER_RADIUS,
|
|
},
|
|
this.svgGroup!,
|
|
);
|
|
|
|
const svgText = dom.createSvgElement(
|
|
Svg.TEXT,
|
|
{
|
|
'class': this.isFlyoutLabel ? 'blocklyFlyoutLabelText' : 'blocklyText',
|
|
'x': 0,
|
|
'y': 0,
|
|
'text-anchor': 'middle',
|
|
},
|
|
this.svgGroup!,
|
|
);
|
|
let text = parsing.replaceMessageReferences(this.text);
|
|
if (this.workspace.RTL) {
|
|
// Force text to be RTL by adding an RLM.
|
|
text += '\u200F';
|
|
}
|
|
svgText.textContent = text;
|
|
if (this.isFlyoutLabel) {
|
|
this.svgText = svgText;
|
|
this.workspace
|
|
.getThemeManager()
|
|
.subscribe(this.svgText, 'flyoutForegroundColour', 'fill');
|
|
}
|
|
|
|
const fontSize = style.getComputedStyle(svgText, 'fontSize');
|
|
const fontWeight = style.getComputedStyle(svgText, 'fontWeight');
|
|
const fontFamily = style.getComputedStyle(svgText, 'fontFamily');
|
|
this.width = dom.getFastTextWidthWithSizeString(
|
|
svgText,
|
|
fontSize,
|
|
fontWeight,
|
|
fontFamily,
|
|
);
|
|
const fontMetrics = dom.measureFontMetrics(
|
|
text,
|
|
fontSize,
|
|
fontWeight,
|
|
fontFamily,
|
|
);
|
|
this.height = this.height || fontMetrics.height;
|
|
|
|
if (!this.isFlyoutLabel) {
|
|
this.width += 2 * FlyoutButton.TEXT_MARGIN_X;
|
|
this.height += 2 * FlyoutButton.TEXT_MARGIN_Y;
|
|
shadow?.setAttribute('width', String(this.width));
|
|
shadow?.setAttribute('height', String(this.height));
|
|
}
|
|
rect.setAttribute('width', String(this.width));
|
|
rect.setAttribute('height', String(this.height));
|
|
|
|
svgText.setAttribute('x', String(this.width / 2));
|
|
svgText.setAttribute(
|
|
'y',
|
|
String(this.height / 2 - fontMetrics.height / 2 + fontMetrics.baseline),
|
|
);
|
|
|
|
this.updateTransform();
|
|
|
|
this.onMouseDownWrapper = browserEvents.conditionalBind(
|
|
this.svgGroup,
|
|
'pointerdown',
|
|
this,
|
|
this.onMouseDown,
|
|
);
|
|
this.onMouseUpWrapper = browserEvents.conditionalBind(
|
|
this.svgGroup,
|
|
'pointerup',
|
|
this,
|
|
this.onMouseUp,
|
|
);
|
|
}
|
|
|
|
createDom(): SVGElement {
|
|
// No-op, now handled in constructor. Will be removed in followup refactor
|
|
// PR that updates the flyout classes to use inflaters.
|
|
return this.svgGroup;
|
|
}
|
|
|
|
/** Correctly position the flyout button and make it visible. */
|
|
show() {
|
|
this.updateTransform();
|
|
this.svgGroup!.setAttribute('display', 'block');
|
|
}
|
|
|
|
/** Update SVG attributes to match internal state. */
|
|
private updateTransform() {
|
|
this.svgGroup!.setAttribute(
|
|
'transform',
|
|
'translate(' + this.position.x + ',' + this.position.y + ')',
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Move the button to the given x, y coordinates.
|
|
*
|
|
* @param x The new x coordinate.
|
|
* @param y The new y coordinate.
|
|
*/
|
|
moveTo(x: number, y: number) {
|
|
this.position.x = x;
|
|
this.position.y = y;
|
|
this.updateTransform();
|
|
}
|
|
|
|
/**
|
|
* Move the element by a relative offset.
|
|
*
|
|
* @param dx Horizontal offset in workspace units.
|
|
* @param dy Vertical offset in workspace units.
|
|
* @param _reason Why is this move happening? 'user', 'bump', 'snap'...
|
|
*/
|
|
moveBy(dx: number, dy: number, _reason?: string[]) {
|
|
this.moveTo(this.position.x + dx, this.position.y + dy);
|
|
}
|
|
|
|
/** @returns Whether or not the button is a label. */
|
|
isLabel(): boolean {
|
|
return this.isFlyoutLabel;
|
|
}
|
|
|
|
/**
|
|
* Location of the button.
|
|
*
|
|
* @returns x, y coordinates.
|
|
* @internal
|
|
*/
|
|
getPosition(): Coordinate {
|
|
return this.position;
|
|
}
|
|
|
|
/**
|
|
* Returns the coordinates of a bounded element describing the dimensions of
|
|
* the element. Coordinate system: workspace coordinates.
|
|
*
|
|
* @returns Object with coordinates of the bounded element.
|
|
*/
|
|
getBoundingRectangle() {
|
|
return new Rect(
|
|
this.position.y,
|
|
this.position.y + this.height,
|
|
this.position.x,
|
|
this.position.x + this.width,
|
|
);
|
|
}
|
|
|
|
/** @returns Text of the button. */
|
|
getButtonText(): string {
|
|
return this.text;
|
|
}
|
|
|
|
/**
|
|
* Get the button's target workspace.
|
|
*
|
|
* @returns The target workspace of the flyout where this button resides.
|
|
*/
|
|
getTargetWorkspace(): WorkspaceSvg {
|
|
return this.targetWorkspace;
|
|
}
|
|
|
|
/**
|
|
* Get the button's workspace.
|
|
*
|
|
* @returns The workspace in which to place this button.
|
|
*/
|
|
getWorkspace(): WorkspaceSvg {
|
|
return this.workspace;
|
|
}
|
|
|
|
/** Dispose of this button. */
|
|
dispose() {
|
|
browserEvents.unbind(this.onMouseDownWrapper);
|
|
browserEvents.unbind(this.onMouseUpWrapper);
|
|
if (this.svgGroup) {
|
|
dom.removeNode(this.svgGroup);
|
|
}
|
|
if (this.svgText) {
|
|
this.workspace.getThemeManager().unsubscribe(this.svgText);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Add the cursor SVG to this buttons's SVG group.
|
|
*
|
|
* @param cursorSvg The SVG root of the cursor to be added to the button SVG
|
|
* group.
|
|
*/
|
|
setCursorSvg(cursorSvg: SVGElement) {
|
|
if (!cursorSvg) {
|
|
this.cursorSvg = null;
|
|
return;
|
|
}
|
|
if (this.svgGroup) {
|
|
this.svgGroup.appendChild(cursorSvg);
|
|
this.cursorSvg = cursorSvg;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Do something when the button is clicked.
|
|
*
|
|
* @param e Pointer up event.
|
|
*/
|
|
private onMouseUp(e: PointerEvent) {
|
|
const gesture = this.targetWorkspace.getGesture(e);
|
|
if (gesture) {
|
|
gesture.cancel();
|
|
}
|
|
|
|
if (this.isFlyoutLabel && this.callbackKey) {
|
|
console.warn(
|
|
'Labels should not have callbacks. Label text: ' + this.text,
|
|
);
|
|
} else if (
|
|
!this.isFlyoutLabel &&
|
|
!(
|
|
this.callbackKey &&
|
|
this.targetWorkspace.getButtonCallback(this.callbackKey)
|
|
)
|
|
) {
|
|
console.warn('Buttons should have callbacks. Button text: ' + this.text);
|
|
} else if (!this.isFlyoutLabel) {
|
|
const callback = this.targetWorkspace.getButtonCallback(this.callbackKey);
|
|
if (callback) {
|
|
callback(this);
|
|
}
|
|
}
|
|
}
|
|
|
|
private onMouseDown(e: PointerEvent) {
|
|
const gesture = this.targetWorkspace.getGesture(e);
|
|
const flyout = this.targetWorkspace.getFlyout();
|
|
if (gesture && flyout) {
|
|
gesture.handleFlyoutStart(e, flyout);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @returns The root SVG element of this rendered element.
|
|
*/
|
|
getSvgRoot() {
|
|
return this.svgGroup;
|
|
}
|
|
|
|
/** See IFocusableNode.getFocusableElement. */
|
|
getFocusableElement(): HTMLElement | SVGElement {
|
|
return this.svgGroup;
|
|
}
|
|
|
|
/** See IFocusableNode.getFocusableTree. */
|
|
getFocusableTree(): IFocusableTree {
|
|
return this.workspace;
|
|
}
|
|
|
|
/** See IFocusableNode.onNodeFocus. */
|
|
onNodeFocus(): void {}
|
|
|
|
/** See IFocusableNode.onNodeBlur. */
|
|
onNodeBlur(): void {}
|
|
|
|
/** See IFocusableNode.canBeFocused. */
|
|
canBeFocused(): boolean {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
/** CSS for buttons and labels. See css.js for use. */
|
|
Css.register(`
|
|
.blocklyFlyoutButton {
|
|
fill: #888;
|
|
cursor: default;
|
|
}
|
|
|
|
.blocklyFlyoutButtonShadow {
|
|
fill: #666;
|
|
}
|
|
|
|
.blocklyFlyoutButton:hover {
|
|
fill: #aaa;
|
|
}
|
|
|
|
.blocklyFlyoutLabel {
|
|
cursor: default;
|
|
}
|
|
|
|
.blocklyFlyoutLabelBackground {
|
|
opacity: 0;
|
|
}
|
|
`);
|