diff --git a/core/block_svg.ts b/core/block_svg.ts index ea5dd7da7ed..57cc8f8ab72 100644 --- a/core/block_svg.ts +++ b/core/block_svg.ts @@ -214,6 +214,10 @@ export class BlockSvg // The page-wide unique ID of this Block used for focusing. svgPath.id = idGenerator.getNextUniqueId(); + svgPath.setAttribute( + 'aria-label', + this.type ? '"' + this.type + '" block' : 'Block', + ); this.doInit_(); } diff --git a/core/blockly.ts b/core/blockly.ts index 14383a947a3..e8693f2031b 100644 --- a/core/blockly.ts +++ b/core/blockly.ts @@ -143,8 +143,14 @@ import { } from './interfaces/i_draggable.js'; import {IDragger} from './interfaces/i_dragger.js'; import {IFlyout} from './interfaces/i_flyout.js'; -import {IFocusableNode} from './interfaces/i_focusable_node.js'; -import {IFocusableTree} from './interfaces/i_focusable_tree.js'; +import { + IFocusableNode, + isFocusableNode, +} from './interfaces/i_focusable_node.js'; +import { + IFocusableTree, + isFocusableTree, +} from './interfaces/i_focusable_tree.js'; import {IHasBubble, hasBubble} from './interfaces/i_has_bubble.js'; import {IIcon, isIcon} from './interfaces/i_icon.js'; import {IKeyboardAccessible} from './interfaces/i_keyboard_accessible.js'; @@ -624,6 +630,8 @@ export { isCopyable, isDeletable, isDraggable, + isFocusableNode, + isFocusableTree, isIcon, isObservable, isPaster, diff --git a/core/css.ts b/core/css.ts index 6b5e19a585b..95983ea3db8 100644 --- a/core/css.ts +++ b/core/css.ts @@ -147,8 +147,8 @@ let content = ` .blocklyHighlightedConnectionPath { fill: none; - stroke: #fc3; - stroke-width: 4px; + // stroke: #fc3; + // stroke-width: 4px; } .blocklyPathLight { @@ -157,10 +157,6 @@ let content = ` stroke-width: 1; } -.blocklySelected>.blocklyPathLight { - display: none; -} - .blocklyDraggable { cursor: grab; cursor: -webkit-grab; diff --git a/core/dropdowndiv.ts b/core/dropdowndiv.ts index 894724d4448..4ed2287127b 100644 --- a/core/dropdowndiv.ts +++ b/core/dropdowndiv.ts @@ -641,6 +641,10 @@ export function hide() { animateOutTimer = setTimeout(function () { hideWithoutAnimation(); }, ANIMATION_TIME * 1000); + if (returnEphemeralFocus) { + returnEphemeralFocus(); + returnEphemeralFocus = null; + } if (onHide) { onHide(); onHide = null; @@ -656,6 +660,10 @@ export function hideWithoutAnimation() { clearTimeout(animateOutTimer); } + if (returnEphemeralFocus) { + returnEphemeralFocus(); + returnEphemeralFocus = null; + } if (onHide) { onHide(); onHide = null; diff --git a/core/field.ts b/core/field.ts index f7e01527e5d..23c56cae43b 100644 --- a/core/field.ts +++ b/core/field.ts @@ -314,6 +314,7 @@ export abstract class Field this.fieldGroup_ = dom.createSvgElement(Svg.G, { 'tabindex': '-1', 'id': id, + 'aria-label': 'Field ' + this.name, }); if (!this.isVisible()) { this.fieldGroup_.style.display = 'none'; @@ -1389,6 +1390,29 @@ export abstract class Field return true; } + /** See IFocusableNode.getFocusableElement. */ + getFocusableElement(): HTMLElement | SVGElement { + if (!this.fieldGroup_) { + throw Error('This field currently has no representative DOM element.'); + } + return this.fieldGroup_; + } + + /** See IFocusableNode.getFocusableTree. */ + getFocusableTree(): IFocusableTree { + const block = this.getSourceBlock(); + if (!block) { + throw new UnattachedFieldError(); + } + return block.workspace as WorkspaceSvg; + } + + /** See IFocusableNode.onNodeFocus. */ + onNodeFocus(): void {} + + /** See IFocusableNode.onNodeBlur. */ + onNodeBlur(): void {} + /** * Subclasses should reimplement this method to construct their Field * subclass from a JSON arg object. diff --git a/core/flyout_base.ts b/core/flyout_base.ts index 9f94ec30905..8ce087c5e4f 100644 --- a/core/flyout_base.ts +++ b/core/flyout_base.ts @@ -309,6 +309,7 @@ export abstract class Flyout this.svgGroup_ = dom.createSvgElement(tagName, { 'class': 'blocklyFlyout', 'tabindex': '0', + 'aria-label': 'Flyout', }); this.svgGroup_.style.display = 'none'; this.svgBackground_ = dom.createSvgElement( diff --git a/core/gesture.ts b/core/gesture.ts index f9b435c67d9..a146ed83edd 100644 --- a/core/gesture.ts +++ b/core/gesture.ts @@ -727,6 +727,7 @@ export class Gesture { if (this.targetBlock) { this.bringBlockToFront(); this.targetBlock.workspace.hideChaff(!!this.flyout); + getFocusManager().focusNode(this.targetBlock); this.targetBlock.showContextMenu(e); } else if (this.startBubble) { this.startBubble.showContextMenu(e); @@ -929,6 +930,9 @@ export class Gesture { 'block', ); eventUtils.fire(event); + if (this.targetBlock) { + getFocusManager().focusNode(this.targetBlock); + } } this.bringBlockToFront(); eventUtils.setGroup(false); diff --git a/core/rendered_connection.ts b/core/rendered_connection.ts index 84905eeccc2..311ce177185 100644 --- a/core/rendered_connection.ts +++ b/core/rendered_connection.ts @@ -595,6 +595,14 @@ export class RenderedConnection return this; } + private findHighlightSvg(): SVGElement | null { + // This cast is valid as TypeScript's definition is wrong. See: + // https://github.com/microsoft/TypeScript/issues/60996. + return document.getElementById(this.id) as + | unknown + | null as SVGElement | null; + } + /** * Handles showing the context menu when it is opened on a connection. * Note that typically the context menu can't be opened with the mouse diff --git a/core/renderers/common/constants.ts b/core/renderers/common/constants.ts index c5a7a759c5c..c3ac17ca30b 100644 --- a/core/renderers/common/constants.ts +++ b/core/renderers/common/constants.ts @@ -1196,16 +1196,10 @@ export class ConstantProvider { `font-weight: ${this.FIELD_TEXT_FONTWEIGHT};`, `}`, - // Selection highlight. - `${selector} .blocklySelected>.blocklyPath {`, - `stroke: #fc3;`, - `stroke-width: 3px;`, - `}`, - // Connection highlight. - `${selector} .blocklyHighlightedConnectionPath {`, - `stroke: #fc3;`, - `}`, + // `${selector} .blocklyHighlightedConnectionPath {`, + // `stroke: #fc3;`, + // `}`, // Replaceable highlight. `${selector} .blocklyReplaceable .blocklyPath {`, diff --git a/core/renderers/common/drawer.ts b/core/renderers/common/drawer.ts index 7046406adc7..84766b90f57 100644 --- a/core/renderers/common/drawer.ts +++ b/core/renderers/common/drawer.ts @@ -439,6 +439,13 @@ export class Drawer { if (highlightSvg) { highlightSvg.style.display = elem.highlighted ? '' : 'none'; } + // if (elem.highlighted) { + // this.drawConnectionHighlightPath(elem); + // } else { + // this.block_.pathObject.removeConnectionHighlight?.( + // elem.connectionModel, + // ); + // } } } } diff --git a/core/renderers/common/path_object.ts b/core/renderers/common/path_object.ts index 7efc6318a31..ef5b977752b 100644 --- a/core/renderers/common/path_object.ts +++ b/core/renderers/common/path_object.ts @@ -105,13 +105,17 @@ export class PathObject implements IPathObject { * removed. */ protected setClass_(className: string, add: boolean) { + this.setClassOnElem_(this.svgRoot, className, add); + } + + private setClassOnElem_(root: SVGElement, className: string, add: boolean) { if (!className) { return; } if (add) { - dom.addClass(this.svgRoot, className); + dom.addClass(root, className); } else { - dom.removeClass(this.svgRoot, className); + dom.removeClass(root, className); } } @@ -161,7 +165,7 @@ export class PathObject implements IPathObject { * @param enable True if selection is enabled, false otherwise. */ updateSelected(enable: boolean) { - this.setClass_('blocklySelected', enable); + this.setClassOnElem_(this.svgPath, 'blocklySelected', enable); } /** @@ -245,6 +249,8 @@ export class PathObject implements IPathObject { }, this.svgRoot, ); + // TODO: Do this in a cleaner way. One possibility: create the path without 'd' or 'transform' in RenderedConnection, then just update it here (and keep registrations). + (highlight as any).renderedConnection = connection; this.connectionHighlights.set(connection, highlight); return highlight; } diff --git a/core/toolbox/category.ts b/core/toolbox/category.ts index fc7d1aa03cf..f213f75b623 100644 --- a/core/toolbox/category.ts +++ b/core/toolbox/category.ts @@ -192,6 +192,7 @@ export class ToolboxCategory aria.setRole(this.htmlDiv_, aria.Role.TREEITEM); aria.setState(this.htmlDiv_, aria.State.SELECTED, false); aria.setState(this.htmlDiv_, aria.State.LEVEL, this.level_ + 1); + this.htmlDiv_.setAttribute('aria-label', 'Category ' + this.name_); this.rowDiv_ = this.createRowContainer_(); this.rowDiv_.style.pointerEvents = 'auto'; diff --git a/core/toolbox/separator.ts b/core/toolbox/separator.ts index 44ae358cf53..08602033399 100644 --- a/core/toolbox/separator.ts +++ b/core/toolbox/separator.ts @@ -61,6 +61,7 @@ export class ToolboxSeparator extends ToolboxItem { dom.addClass(container, className); } this.htmlDiv = container; + this.htmlDiv.setAttribute('aria-label', 'Separator'); return container; } diff --git a/core/toolbox/toolbox.ts b/core/toolbox/toolbox.ts index 0fbb231dc56..df2b8f3a910 100644 --- a/core/toolbox/toolbox.ts +++ b/core/toolbox/toolbox.ts @@ -146,6 +146,7 @@ export class Toolbox this.flyout = this.createFlyout_(); this.HtmlDiv = this.createDom_(this.workspace_); + this.HtmlDiv.setAttribute('aria-label', 'Toolbox'); const flyoutDom = this.flyout.createDom('svg'); dom.addClass(flyoutDom, 'blocklyToolboxFlyout'); dom.insertAfter(flyoutDom, svg); @@ -965,7 +966,7 @@ export class Toolbox * * @returns True if a parent category was selected, false otherwise. */ - private selectParent(): boolean { + selectParent(): boolean { if (!this.selectedItem_) { return false; } @@ -993,7 +994,7 @@ export class Toolbox * * @returns True if a child category was selected, false otherwise. */ - private selectChild(): boolean { + selectChild(): boolean { if (!this.selectedItem_ || !this.selectedItem_.isCollapsible()) { return false; } @@ -1012,7 +1013,7 @@ export class Toolbox * * @returns True if a next category was selected, false otherwise. */ - private selectNext(): boolean { + selectNext(): boolean { if (!this.selectedItem_) { return false; } @@ -1037,7 +1038,7 @@ export class Toolbox * * @returns True if a previous category was selected, false otherwise. */ - private selectPrevious(): boolean { + selectPrevious(): boolean { if (!this.selectedItem_) { return false; } diff --git a/core/workspace_svg.ts b/core/workspace_svg.ts index 3e8731afd4b..c80ae95898a 100644 --- a/core/workspace_svg.ts +++ b/core/workspace_svg.ts @@ -765,6 +765,7 @@ export class WorkspaceSvg // Only the top-level workspace should be tabbable. 'tabindex': injectionDiv ? '0' : '-1', 'id': this.id, + 'aria-label': 'Workspace', }); if (injectionDiv) { aria.setState(