Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
129 changes: 129 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,7 +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);
svgPath.tabIndex = -1;
this.currentConnectionCandidate = null;

this.doInit_();

// Note: This must be done after initialization of the block's fields.
this.recomputeAriaLabel();
}

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;
}

/**
Expand Down Expand Up @@ -266,12 +331,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 +409,8 @@ export class BlockSvg
}

this.applyColour();

this.workspace.recomputeAriaTree();
}

/**
Expand Down Expand Up @@ -1773,21 +1842,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 +1932,53 @@ 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 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)}.`,
);
}
}
}
3 changes: 3 additions & 0 deletions core/bubbles/bubble.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import type {IHasBubble} from '../interfaces/i_has_bubble.js';
import {ISelectable} from '../interfaces/i_selectable.js';
import {ContainerRegion} from '../metrics_manager.js';
import {Scrollbar} from '../scrollbar.js';
import * as aria from '../utils/aria.js';
import {Coordinate} from '../utils/coordinate.js';
import * as dom from '../utils/dom.js';
import * as idGenerator from '../utils/idgenerator.js';
Expand Down Expand Up @@ -142,6 +143,8 @@ export abstract class Bubble implements IBubble, ISelectable, IFocusableNode {

this.focusableElement = overriddenFocusableElement ?? this.svgRoot;
this.focusableElement.setAttribute('id', this.id);
aria.setRole(this.focusableElement, aria.Role.GROUP);
aria.setState(this.focusableElement, aria.State.LABEL, 'Bubble');

browserEvents.conditionalBind(
this.background,
Expand Down
6 changes: 6 additions & 0 deletions core/comments/collapse_comment_bar_button.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

import * as browserEvents from '../browser_events.js';
import * as touch from '../touch.js';
import * as aria from '../utils/aria.js';
import * as dom from '../utils/dom.js';
import {Svg} from '../utils/svg.js';
import type {WorkspaceSvg} from '../workspace_svg.js';
Expand Down Expand Up @@ -69,6 +70,11 @@ export class CollapseCommentBarButton extends CommentBarButton {
browserEvents.unbind(this.bindId);
}

override initAria(): void {
aria.setRole(this.icon, aria.Role.BUTTON);
aria.setState(this.icon, aria.State.LABEL, 'DoNotDefine?');
}

/**
* Adjusts the positioning of this button within its container.
*/
Expand Down
2 changes: 2 additions & 0 deletions core/comments/comment_bar_button.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ export abstract class CommentBarButton implements IFocusableNode {
return comment;
}

abstract initAria(): void;

/** Adjusts the position of this button within its parent container. */
abstract reposition(): void;

Expand Down
3 changes: 3 additions & 0 deletions core/comments/comment_editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {getFocusManager} from '../focus_manager.js';
import {IFocusableNode} from '../interfaces/i_focusable_node.js';
import {IFocusableTree} from '../interfaces/i_focusable_tree.js';
import * as touch from '../touch.js';
import * as aria from '../utils/aria.js';
import * as dom from '../utils/dom.js';
import {Size} from '../utils/size.js';
import {Svg} from '../utils/svg.js';
Expand Down Expand Up @@ -54,6 +55,8 @@ export class CommentEditor implements IFocusableNode {
) as HTMLTextAreaElement;
this.textArea.setAttribute('tabindex', '-1');
this.textArea.setAttribute('dir', this.workspace.RTL ? 'RTL' : 'LTR');
aria.setRole(this.textArea, aria.Role.TEXTBOX);
aria.setState(this.textArea, aria.State.LABEL, 'DoNotDefine?');
dom.addClass(this.textArea, 'blocklyCommentText');
dom.addClass(this.textArea, 'blocklyTextarea');
dom.addClass(this.textArea, 'blocklyText');
Expand Down
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
6 changes: 6 additions & 0 deletions core/comments/delete_comment_bar_button.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import * as browserEvents from '../browser_events.js';
import {getFocusManager} from '../focus_manager.js';
import * as touch from '../touch.js';
import * as aria from '../utils/aria.js';
import * as dom from '../utils/dom.js';
import {Svg} from '../utils/svg.js';
import type {WorkspaceSvg} from '../workspace_svg.js';
Expand Down Expand Up @@ -69,6 +70,11 @@ export class DeleteCommentBarButton extends CommentBarButton {
browserEvents.unbind(this.bindId);
}

override initAria(): void {
aria.setRole(this.icon, aria.Role.BUTTON);
aria.setState(this.icon, aria.State.LABEL, 'DoNotDefine?');
}

/**
* Adjusts the positioning of this button within its container.
*/
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
Loading
Loading