Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
128 changes: 128 additions & 0 deletions core/block_svg.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ import * as blocks from './serialization/blocks.js';
import type {BlockStyle} from './theme.js';
import * as Tooltip from './tooltip.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 {Rect} from './utils/rect.js';
Expand Down Expand Up @@ -168,6 +169,8 @@ export class BlockSvg
/** Whether this block is currently being dragged. */
private dragging = false;

public currentConnectionCandidate: RenderedConnection | null = null;

/**
* The location of the top left of this block (in workspace coordinates)
* relative to either its parent block, or the workspace origin if it has no
Expand Down Expand Up @@ -215,9 +218,69 @@ export class BlockSvg
// The page-wide unique ID of this Block used for focusing.
svgPath.id = idGenerator.getNextUniqueId();

aria.setState(svgPath, aria.State.ROLEDESCRIPTION, 'block');
aria.setRole(svgPath, aria.Role.TREEITEM);
this.recomputeAriaLabel();
svgPath.tabIndex = -1;
this.currentConnectionCandidate = null;

this.doInit_();
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The aria bit needs to be after doInit_() for the block to have fields.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice catch. The dataflow of when things happen has changed each time the code was moved, so I missed this and somehow missed the voice output being incomplete for blocks.

}

private recomputeAriaLabel() {
aria.setState(
this.getFocusableElement(),
aria.State.LABEL,
this.computeAriaLabel(),
);
}

private computeAriaLabel(): string {
// Guess the block's aria label based on its field labels.
if (this.isShadow()) {
// TODO: Shadows may have more than one field.
// Shadow blocks are best represented directly by their field since they
// effectively operate like a field does for keyboard navigation purposes.
const field = Array.from(this.getFields())[0];
return (
aria.getState(field.getFocusableElement(), aria.State.LABEL) ??
'Unknown?'
);
}

const fieldLabels = [];
for (const field of this.getFields()) {
if (field instanceof FieldLabel) {
fieldLabels.push(field.getText());
}
}
return fieldLabels.join(' ');
}

collectSiblingBlocks(surroundParent: BlockSvg | null): BlockSvg[] {
// NOTE TO DEVELOPERS: it's very important that these are NOT sorted. The
// returned list needs to be relatively stable for consistency block indexes
// read out to users via screen readers.
if (surroundParent) {
// Start from the first sibling and iterate in navigation order.
const firstSibling: BlockSvg = surroundParent.getChildren(false)[0];
const siblings: BlockSvg[] = [firstSibling];
let nextSibling: BlockSvg | null = firstSibling;
while ((nextSibling = nextSibling.getNextBlock())) {
siblings.push(nextSibling);
}
return siblings;
} else {
// For top-level blocks, simply return those from the workspace.
return this.workspace.getTopBlocks(false);
}
}

computeLevelInWorkspace(): number {
const surroundParent = this.getSurroundParent();
return surroundParent ? surroundParent.computeLevelInWorkspace() + 1 : 0;
}

/**
* Create and initialize the SVG representation of the block.
* May be called more than once.
Expand Down Expand Up @@ -266,12 +329,14 @@ export class BlockSvg
select() {
this.addSelect();
common.fireSelectedEvent(this);
aria.setState(this.getFocusableElement(), aria.State.SELECTED, true);
}

/** Unselects this block. Unhighlights the block visually. */
unselect() {
this.removeSelect();
common.fireSelectedEvent(null);
aria.setState(this.getFocusableElement(), aria.State.SELECTED, false);
}

/**
Expand Down Expand Up @@ -342,6 +407,8 @@ export class BlockSvg
}

this.applyColour();

this.workspace.recomputeAriaTree();
}

/**
Expand Down Expand Up @@ -1773,21 +1840,32 @@ export class BlockSvg
/** Starts a drag on the block. */
startDrag(e?: PointerEvent): void {
this.dragStrategy.startDrag(e);
const dragStrategy = this.dragStrategy as BlockDragStrategy;
const candidate = dragStrategy.connectionCandidate?.neighbour ?? null;
this.currentConnectionCandidate = candidate;
this.announceDynamicAriaState(true, false);
}

/** Drags the block to the given location. */
drag(newLoc: Coordinate, e?: PointerEvent): void {
this.dragStrategy.drag(newLoc, e);
const dragStrategy = this.dragStrategy as BlockDragStrategy;
const candidate = dragStrategy.connectionCandidate?.neighbour ?? null;
this.currentConnectionCandidate = candidate;
this.announceDynamicAriaState(true, false, newLoc);
}

/** Ends the drag on the block. */
endDrag(e?: PointerEvent): void {
this.dragStrategy.endDrag(e);
this.currentConnectionCandidate = null;
this.announceDynamicAriaState(false, false);
}

/** Moves the block back to where it was at the start of a drag. */
revertDrag(): void {
this.dragStrategy.revertDrag();
this.announceDynamicAriaState(false, true);
}

/**
Expand Down Expand Up @@ -1852,4 +1930,54 @@ export class BlockSvg
canBeFocused(): boolean {
return true;
}

/**
* Announces the current dynamic state of the specified block, if any.
*
* An example of dynamic state is whether the block is currently being moved,
* and in what way. These states aren't represented through ARIA directly, so
* they need to be determined and announced using an ARIA live region
* (see aria.announceDynamicAriaState).
*
* @param block The block whose dynamic state should maybe be announced.
* @param isMoving Whether the specified block is currently being moved.
* @param isCanceled Whether the previous movement operation has been canceled.
* @param newLoc The new location the block is moving to (if unconstrained).
*/
private announceDynamicAriaState(
isMoving: boolean,
isCanceled: boolean,
newLoc?: Coordinate,
) {
if (isCanceled) {
aria.announceDynamicAriaState('Canceled movement');
return;
}
if (!isMoving) return;
if (this.currentConnectionCandidate) {
// TODO: Figure out general detachment.
// TODO: Figure out how to deal with output connections.
const surroundParent = this.currentConnectionCandidate.sourceBlock_;
const announcementContext = [];
announcementContext.push('Moving'); // TODO: Specialize for inserting?
// NB: Old code here doesn't seem to handle parents correctly.
if (this.currentConnectionCandidate.type === ConnectionType.INPUT_VALUE) {
announcementContext.push('to', 'input');
} else {
announcementContext.push('to', 'child');
}
if (surroundParent) {
announcementContext.push('of', surroundParent.computeAriaLabel());
}

// If the block is currently being moved, announce the new block label so that the user understands where it is now.
// TODO: Figure out how much recomputeAriaTreeItemDetailsRecursively needs to anticipate position if it won't be reannounced, and how much of that context should be included in the liveannouncement.
aria.announceDynamicAriaState(announcementContext.join(' '));
} else if (newLoc) {
// The block is being freely dragged.
aria.announceDynamicAriaState(
`Moving unconstrained to coordinate x ${Math.round(newLoc.x)} and y ${Math.round(newLoc.y)}.`,
);
}
}
}
4 changes: 4 additions & 0 deletions core/comments/comment_view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type {IFocusableNode} from '../interfaces/i_focusable_node';
import {IRenderedElement} from '../interfaces/i_rendered_element.js';
import * as layers from '../layers.js';
import * as touch from '../touch.js';
import * as aria from '../utils/aria.js';
import {Coordinate} from '../utils/coordinate.js';
import * as dom from '../utils/dom.js';
import * as drag from '../utils/drag.js';
Expand Down Expand Up @@ -108,6 +109,9 @@ export class CommentView implements IRenderedElement {
'class': 'blocklyComment blocklyEditable blocklyDraggable',
});

aria.setRole(this.svgRoot, aria.Role.TEXTBOX);
aria.setState(this.svgRoot, aria.State.LABEL, 'DoNotOverride?');

this.highlightRect = this.createHighlightRect(this.svgRoot);

({
Expand Down
8 changes: 8 additions & 0 deletions core/css.ts
Original file line number Diff line number Diff line change
Expand Up @@ -507,4 +507,12 @@ input[type=number] {
) {
outline: none;
}

#blocklyAriaAnnounce {
position: absolute;
left: -9999px;
width: 1px;
height: px;
overflow: hidden;
}
`;
2 changes: 1 addition & 1 deletion core/dragging/block_drag_strategy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ export class BlockDragStrategy implements IDragStrategy {

private startLoc: Coordinate | null = null;

private connectionCandidate: ConnectionCandidate | null = null;
public connectionCandidate: ConnectionCandidate | null = null;

private connectionPreviewer: IConnectionPreviewer | null = null;

Expand Down
2 changes: 2 additions & 0 deletions core/field.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import {ISerializable} from './interfaces/i_serializable.js';
import type {ConstantProvider} from './renderers/common/constants.js';
import type {KeyboardShortcut} from './shortcut_registry.js';
import * as Tooltip from './tooltip.js';
import * as aria from './utils/aria.js';
import type {Coordinate} from './utils/coordinate.js';
import * as dom from './utils/dom.js';
import * as idGenerator from './utils/idgenerator.js';
Expand Down Expand Up @@ -403,6 +404,7 @@ export abstract class Field<T = any>
}
this.textContent_ = document.createTextNode('');
this.textElement_.appendChild(this.textContent_);
aria.setState(this.textElement_, aria.State.HIDDEN, true);
}

/**
Expand Down
9 changes: 9 additions & 0 deletions core/field_checkbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import './events/events_block_change.js';

import {Field, FieldConfig, FieldValidator} from './field.js';
import * as fieldRegistry from './field_registry.js';
import * as aria from './utils/aria.js';
import * as dom from './utils/dom.js';

type BoolString = 'TRUE' | 'FALSE';
Expand Down Expand Up @@ -111,6 +112,14 @@ export class FieldCheckbox extends Field<CheckboxBool> {
const textElement = this.getTextElement();
dom.addClass(this.fieldGroup_!, 'blocklyCheckboxField');
textElement.style.display = this.value_ ? 'block' : 'none';

const element = this.getFocusableElement();
aria.setRole(element, aria.Role.CHECKBOX);
aria.setState(
element,
aria.State.LABEL,
this.name ? `Checkbox ${this.name}` : 'Checkbox',
);
}

override render_() {
Expand Down
8 changes: 8 additions & 0 deletions core/field_dropdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,14 @@ export class FieldDropdown extends Field<string> {
dom.addClass(this.fieldGroup_, 'blocklyField');
dom.addClass(this.fieldGroup_, 'blocklyDropdownField');
}

const element = this.getFocusableElement();
aria.setRole(element, aria.Role.LISTBOX);
aria.setState(
element,
aria.State.LABEL,
this.name ? `Item ${this.name}` : 'Item',
);
}

/**
Expand Down
9 changes: 9 additions & 0 deletions core/field_image.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

import {Field, FieldConfig} from './field.js';
import * as fieldRegistry from './field_registry.js';
import * as aria from './utils/aria.js';
import * as dom from './utils/dom.js';
import * as parsing from './utils/parsing.js';
import {Size} from './utils/size.js';
Expand Down Expand Up @@ -157,6 +158,14 @@ export class FieldImage extends Field<string> {
if (this.clickHandler) {
this.imageElement.style.cursor = 'pointer';
}

const element = this.getFocusableElement();
aria.setRole(element, aria.Role.IMAGE);
aria.setState(
element,
aria.State.LABEL,
this.name ? `Image ${this.name}` : 'Image',
);
}

override updateSize_() {}
Expand Down
8 changes: 8 additions & 0 deletions core/field_input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,14 @@ export abstract class FieldInput<T extends InputTypes> extends Field<
if (this.fieldGroup_) {
dom.addClass(this.fieldGroup_, 'blocklyInputField');
}

const element = this.getFocusableElement();
aria.setRole(element, aria.Role.TEXTBOX);
aria.setState(
element,
aria.State.LABEL,
this.name ? `Text ${this.name}` : 'Text',
);
}

override isFullBlockField(): boolean {
Expand Down
14 changes: 14 additions & 0 deletions core/field_label.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@

import {Field, FieldConfig} from './field.js';
import * as fieldRegistry from './field_registry.js';
import * as aria from './utils/aria.js';
import * as dom from './utils/dom.js';
import * as parsing from './utils/parsing.js';

Expand Down Expand Up @@ -77,6 +78,12 @@ export class FieldLabel extends Field<string> {
if (this.fieldGroup_) {
dom.addClass(this.fieldGroup_, 'blocklyLabelField');
}

this.recomputeAriaLabel();
}

private recomputeAriaLabel() {
aria.setState(this.getFocusableElement(), aria.State.LABEL, this.getText());
}

/**
Expand Down Expand Up @@ -111,6 +118,13 @@ export class FieldLabel extends Field<string> {
this.class = cssClass;
}

override setValue(newValue: any, fireChangeEvent?: boolean): void {
super.setValue(newValue, fireChangeEvent);
if (this.fieldGroup_) {
this.recomputeAriaLabel();
}
}

/**
* Construct a FieldLabel from a JSON arg object,
* dereferencing any string table references.
Expand Down
4 changes: 4 additions & 0 deletions core/flyout_button.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ 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';
Expand Down Expand Up @@ -117,6 +118,9 @@ export class FlyoutButton
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).
Expand Down
Loading
Loading