mirror of
https://github.com/google/blockly.git
synced 2026-01-04 15:40:08 +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 #8206 Fixes #8210 Fixes #8213 Fixes #8255 Fixes #8211 Fixes #8212 Fixes #8254 Fixes part of #9301 Fixes part of #9304 ### Proposed Changes This PR completes the remaining ARIA roles and properties needed for all core fields. Specifically: - #8206: A better name needed to be used for the checkbox value, plus there was an ARIA property missing for actually representing the checkbox state. The latter needed to be updated upon toggling the checkbox, as well. These changes bring checkbox fields in compliance with the ARIA checkbox pattern documented here: https://www.w3.org/WAI/ARIA/apg/patterns/checkbox/. - #8210: This one required a lot of changes in order to adapt to the ARIA combobox pattern documented here: https://www.w3.org/WAI/ARIA/apg/patterns/combobox/. Specifically: - Menus needed to have a unique ID that's also exposed in order to link the combobox element to its menu when open. - ARIA's `activedescendant` proved very useful in ensuring that the current dropdown selection is correctly read when the combobox has focus but its menu isn't opened. - The default properties available for options (label and value) aren't very good for readout, so a custom ARIA property was added for much clearer option readouts. This is only demonstrated for the math arithmetic block for now. - The text element is normally hidden for ARIA but it's useful in conjunction with `activedescendant` to represent the current value selection. - Images have been handled here as well (partly as part of #8255) by leveraging their alt text for readouts. This actually seems to work quite well both for current value and selection. - #8213: Much of the improvements here come from the combobox (`FieldDropdown`) improvements explained above. However one additional bit was done to provide an explicit 'Variable <name>' readout for the purpose of clarity. This demonstrates some contextualization of the value of the field which may be a generally useful pattern to copy in other field contexts. - #8255: Image fields have been refined since they were redundantly specifying 'image' when an `image` ARIA role is already being used. Now only the alt text is supplied along with the role context. Note that images need special handling since they can sometimes be navigable (such as when they have click handlers). - #8211: Text input fields have had their labeling improved like all other fields, and the field's value is now exposed via its `text` element since this will show up as a `StaticText` node in the accessibility tree and automatically be read as part of the field's value. - #8212: This gets the same benefits as the previous point since those improvements were included for both text and number input. However, existing `valuemin` and `valuemax` ARIA properties have been removed. It seems these are really only useful when introducing a slider mechanism (see https://www.w3.org/WAI/ARIA/apg/patterns/slider/) and from testing seems to not really be utilized for the basic text input that `FieldNumber` currently uses. It may be the case that this is a better pattern to use in the future, but it's more likely that other custom fields could benefit from more specific patterns like slider rather than `FieldNumber` being changed in that way. - #8254 and part of #9304: Field labels have been completely removed from the accessibility node tree since they can never be navigated to (as #8254 explains all labels will be included as part of the block's ARIA label itself for readout parity with navigation options). Note that it doesn't cover external fields (such as those supplied in blockly-samples), nor does it fully set up the infrastructure work for those. Ultimately that work needs to happen as part of #9301. Beyond the role work above, this PR also introduces some fundamental work for #9301. Specifically: - It demonstrates how block definitions could be used to introduce accessibility label customizations (in this case for the options of the arithmetic operator block's drop-down field, plus the block itself). - It sets up some central label computation for all fields, though more thought is needed on whether this is sufficient for custom fields outside of core Blockly and on how to properly contextualize labels for field values. Core Blockly's fields are fairly simple for representing values which is why that aspect of #9301 didn't need to be solved in this PR. Note that the field labeling here is being used to improve all of the fields above, but also it tries to aggressively fall back to the _next best_ label to be used (though it's possible to run out of options which is why fields still need contextually-specific fallbacks). ### Reason for Changes Generally the initial approach for implementing labels is leveraging as specific ARIA roles as exist to directly represent the element. This PR is completing that work for all of core Blockly's built-in fields, and laying some of the groundwork for generalizing this support for custom fields. Having specific roles does potentially introduce inconsistencies across screen readers (though should improve consistency across sites for a single screen reader), and expectations for behaviors (like shortcuts) that may need to be ignored or only partially supported (#9313 is discussing this). ### Test Coverage Only manual testing has been completed since this is experimental work. Video demonstrating most of the changes: [Screen recording 2025-10-01 4.05.35 PM.webm](https://github.com/user-attachments/assets/c7961caa-eae0-4585-8fd9-87d7cbe65988) ### Documentation N/A -- Experimental work. ### Additional Information This has only been tested on ChromeVox.
405 lines
11 KiB
TypeScript
405 lines
11 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright 2011 Google LLC
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
// Former goog.module ID: Blockly.inject
|
|
|
|
import type {BlocklyOptions} from './blockly_options.js';
|
|
import * as browserEvents from './browser_events.js';
|
|
import * as bumpObjects from './bump_objects.js';
|
|
import * as common from './common.js';
|
|
import * as Css from './css.js';
|
|
import * as dropDownDiv from './dropdowndiv.js';
|
|
import {Grid} from './grid.js';
|
|
import {Options} from './options.js';
|
|
import {ScrollbarPair} from './scrollbar_pair.js';
|
|
import * as Tooltip from './tooltip.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 * as WidgetDiv from './widgetdiv.js';
|
|
import {WorkspaceSvg} from './workspace_svg.js';
|
|
|
|
/**
|
|
* Inject a Blockly editor into the specified container element (usually a div).
|
|
*
|
|
* @param container Containing element, or its ID, or a CSS selector.
|
|
* @param opt_options Optional dictionary of options.
|
|
* @returns Newly created main workspace.
|
|
*/
|
|
export function inject(
|
|
container: Element | string,
|
|
opt_options?: BlocklyOptions,
|
|
): WorkspaceSvg {
|
|
let containerElement: Element | null = null;
|
|
if (typeof container === 'string') {
|
|
containerElement =
|
|
document.getElementById(container) || document.querySelector(container);
|
|
} else {
|
|
containerElement = container;
|
|
}
|
|
// Verify that the container is in document.
|
|
if (
|
|
!document.contains(containerElement) &&
|
|
document !== containerElement?.ownerDocument
|
|
) {
|
|
throw Error('Error: container is not in current document');
|
|
}
|
|
const options = new Options(opt_options || ({} as BlocklyOptions));
|
|
const subContainer = document.createElement('div');
|
|
dom.addClass(subContainer, 'injectionDiv');
|
|
if (opt_options?.rtl) {
|
|
dom.addClass(subContainer, 'blocklyRTL');
|
|
}
|
|
|
|
// Ignore the subcontainer in aria since it is not focusable
|
|
aria.setRole(subContainer, aria.Role.PRESENTATION);
|
|
|
|
containerElement!.appendChild(subContainer);
|
|
const svg = createDom(subContainer, options);
|
|
|
|
const workspace = createMainWorkspace(subContainer, svg, options);
|
|
|
|
init(workspace);
|
|
|
|
// Keep focus on the first workspace so entering keyboard navigation looks
|
|
// correct.
|
|
common.setMainWorkspace(workspace);
|
|
|
|
common.svgResize(workspace);
|
|
|
|
subContainer.addEventListener('focusin', function () {
|
|
common.setMainWorkspace(workspace);
|
|
});
|
|
|
|
browserEvents.conditionalBind(
|
|
subContainer,
|
|
'keydown',
|
|
null,
|
|
common.globalShortcutHandler,
|
|
);
|
|
|
|
// See: https://stackoverflow.com/a/48590836 for a reference.
|
|
const ariaAnnouncementSpan = document.createElement('span');
|
|
ariaAnnouncementSpan.id = 'blocklyAriaAnnounce';
|
|
dom.addClass(ariaAnnouncementSpan, 'hiddenForAria');
|
|
aria.setState(ariaAnnouncementSpan, aria.State.LIVE, 'polite');
|
|
subContainer.appendChild(ariaAnnouncementSpan);
|
|
|
|
return workspace;
|
|
}
|
|
|
|
/**
|
|
* Create the SVG image.
|
|
*
|
|
* @param container Containing element.
|
|
* @param options Dictionary of options.
|
|
* @returns Newly created SVG image.
|
|
*/
|
|
function createDom(container: HTMLElement, options: Options): SVGElement {
|
|
// Sadly browsers (Chrome vs Firefox) are currently inconsistent in laying
|
|
// out content in RTL mode. Therefore Blockly forces the use of LTR,
|
|
// then manually positions content in RTL as needed.
|
|
container.setAttribute('dir', 'LTR');
|
|
|
|
// Load CSS.
|
|
Css.inject(options.hasCss, options.pathToMedia);
|
|
|
|
// Build the SVG DOM.
|
|
/*
|
|
<svg
|
|
xmlns="http://www.w3.org/2000/svg"
|
|
xmlns:html="http://www.w3.org/1999/xhtml"
|
|
xmlns:xlink="http://www.w3.org/1999/xlink"
|
|
version="1.1"
|
|
class="blocklySvg">
|
|
...
|
|
</svg>
|
|
*/
|
|
const svg = dom.createSvgElement(
|
|
Svg.SVG,
|
|
{
|
|
'xmlns': dom.SVG_NS,
|
|
'xmlns:html': dom.HTML_NS,
|
|
'xmlns:xlink': dom.XLINK_NS,
|
|
'version': '1.1',
|
|
'class': 'blocklySvg',
|
|
},
|
|
container,
|
|
);
|
|
/*
|
|
<defs>
|
|
... filters go here ...
|
|
</defs>
|
|
*/
|
|
const defs = dom.createSvgElement(Svg.DEFS, {}, svg);
|
|
// Each filter/pattern needs a unique ID for the case of multiple Blockly
|
|
// instances on a page. Browser behaviour becomes undefined otherwise.
|
|
// https://neil.fraser.name/news/2015/11/01/
|
|
const rnd = String(Math.random()).substring(2);
|
|
|
|
options.gridPattern = Grid.createDom(
|
|
rnd,
|
|
options.gridOptions,
|
|
defs,
|
|
container,
|
|
);
|
|
return svg;
|
|
}
|
|
|
|
/**
|
|
* Create a main workspace and add it to the SVG.
|
|
*
|
|
* @param svg SVG element with pattern defined.
|
|
* @param options Dictionary of options.
|
|
* @returns Newly created main workspace.
|
|
*/
|
|
function createMainWorkspace(
|
|
injectionDiv: HTMLElement,
|
|
svg: SVGElement,
|
|
options: Options,
|
|
): WorkspaceSvg {
|
|
options.parentWorkspace = null;
|
|
const mainWorkspace = new WorkspaceSvg(options);
|
|
const wsOptions = mainWorkspace.options;
|
|
mainWorkspace.scale = wsOptions.zoomOptions.startScale;
|
|
svg.appendChild(
|
|
mainWorkspace.createDom('blocklyMainBackground', injectionDiv),
|
|
);
|
|
|
|
// Set the theme name and renderer name onto the injection div.
|
|
const rendererClassName = mainWorkspace.getRenderer().getClassName();
|
|
if (rendererClassName) {
|
|
dom.addClass(injectionDiv, rendererClassName);
|
|
}
|
|
const themeClassName = mainWorkspace.getTheme().getClassName();
|
|
if (themeClassName) {
|
|
dom.addClass(injectionDiv, themeClassName);
|
|
}
|
|
|
|
if (!wsOptions.hasCategories && wsOptions.languageTree) {
|
|
// Add flyout as an <svg> that is a sibling of the workspace SVG.
|
|
const flyout = mainWorkspace.addFlyout(Svg.SVG);
|
|
dom.insertAfter(flyout, svg);
|
|
}
|
|
if (wsOptions.hasTrashcan) {
|
|
mainWorkspace.addTrashcan();
|
|
}
|
|
if (wsOptions.zoomOptions && wsOptions.zoomOptions.controls) {
|
|
mainWorkspace.addZoomControls();
|
|
}
|
|
// Register the workspace svg as a UI component.
|
|
mainWorkspace
|
|
.getThemeManager()
|
|
.subscribe(svg, 'workspaceBackgroundColour', 'background-color');
|
|
|
|
// A null translation will also apply the correct initial scale.
|
|
mainWorkspace.translate(0, 0);
|
|
|
|
mainWorkspace.addChangeListener(
|
|
bumpObjects.bumpIntoBoundsHandler(mainWorkspace),
|
|
);
|
|
|
|
// The SVG is now fully assembled.
|
|
common.svgResize(mainWorkspace);
|
|
WidgetDiv.createDom();
|
|
dropDownDiv.createDom();
|
|
Tooltip.createDom();
|
|
return mainWorkspace;
|
|
}
|
|
|
|
/**
|
|
* Initialize Blockly with various handlers.
|
|
*
|
|
* @param mainWorkspace Newly created main workspace.
|
|
*/
|
|
function init(mainWorkspace: WorkspaceSvg) {
|
|
const options = mainWorkspace.options;
|
|
const svg = mainWorkspace.getParentSvg();
|
|
|
|
// Suppress the browser's context menu.
|
|
browserEvents.conditionalBind(
|
|
svg.parentNode as Element,
|
|
'contextmenu',
|
|
null,
|
|
function (e: Event) {
|
|
if (!browserEvents.isTargetInput(e)) {
|
|
e.preventDefault();
|
|
}
|
|
},
|
|
);
|
|
|
|
const workspaceResizeHandler = browserEvents.conditionalBind(
|
|
window,
|
|
'resize',
|
|
null,
|
|
function () {
|
|
// Don't hide all the chaff. Leave the dropdown and widget divs open if
|
|
// possible.
|
|
Tooltip.hide();
|
|
mainWorkspace.hideComponents(true);
|
|
dropDownDiv.repositionForWindowResize();
|
|
WidgetDiv.repositionForWindowResize();
|
|
common.svgResize(mainWorkspace);
|
|
bumpObjects.bumpTopObjectsIntoBounds(mainWorkspace);
|
|
},
|
|
);
|
|
mainWorkspace.setResizeHandlerWrapper(workspaceResizeHandler);
|
|
|
|
bindDocumentEvents();
|
|
|
|
if (options.languageTree) {
|
|
const toolbox = mainWorkspace.getToolbox();
|
|
const flyout = mainWorkspace.getFlyout(true);
|
|
if (toolbox) {
|
|
toolbox.init();
|
|
} else if (flyout) {
|
|
// Build a fixed flyout with the root blocks.
|
|
flyout.init(mainWorkspace);
|
|
flyout.show(options.languageTree);
|
|
if (typeof flyout.scrollToStart === 'function') {
|
|
flyout.scrollToStart();
|
|
}
|
|
}
|
|
}
|
|
|
|
if (options.hasTrashcan) {
|
|
mainWorkspace.trashcan!.init();
|
|
}
|
|
if (options.zoomOptions && options.zoomOptions.controls) {
|
|
mainWorkspace.zoomControls_!.init();
|
|
}
|
|
|
|
if (options.moveOptions && options.moveOptions.scrollbars) {
|
|
const horizontalScroll =
|
|
options.moveOptions.scrollbars === true ||
|
|
!!options.moveOptions.scrollbars.horizontal;
|
|
const verticalScroll =
|
|
options.moveOptions.scrollbars === true ||
|
|
!!options.moveOptions.scrollbars.vertical;
|
|
mainWorkspace.scrollbar = new ScrollbarPair(
|
|
mainWorkspace,
|
|
horizontalScroll,
|
|
verticalScroll,
|
|
'blocklyMainWorkspaceScrollbar',
|
|
);
|
|
mainWorkspace.scrollbar.resize();
|
|
} else {
|
|
mainWorkspace.setMetrics({x: 0.5, y: 0.5});
|
|
}
|
|
|
|
// Load the sounds.
|
|
if (options.hasSounds) {
|
|
loadSounds(options.pathToMedia, mainWorkspace);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Whether event handlers have been bound. Document event handlers will only
|
|
* be bound once, even if Blockly is destroyed and reinjected.
|
|
*/
|
|
let documentEventsBound = false;
|
|
|
|
/**
|
|
* Bind document events, but only once. Destroying and reinjecting Blockly
|
|
* should not bind again.
|
|
* Bind events for scrolling the workspace.
|
|
* Most of these events should be bound to the SVG's surface.
|
|
* However, 'mouseup' has to be on the whole document so that a block dragged
|
|
* out of bounds and released will know that it has been released.
|
|
*/
|
|
function bindDocumentEvents() {
|
|
if (!documentEventsBound) {
|
|
browserEvents.conditionalBind(document, 'scroll', null, function () {
|
|
const workspaces = common.getAllWorkspaces();
|
|
for (let i = 0, workspace; (workspace = workspaces[i]); i++) {
|
|
if (workspace instanceof WorkspaceSvg) {
|
|
workspace.updateInverseScreenCTM();
|
|
}
|
|
}
|
|
});
|
|
// longStop needs to run to stop the context menu from showing up. It
|
|
// should run regardless of what other touch event handlers have run.
|
|
browserEvents.bind(document, 'touchend', null, Touch.longStop);
|
|
browserEvents.bind(document, 'touchcancel', null, Touch.longStop);
|
|
}
|
|
documentEventsBound = true;
|
|
}
|
|
|
|
/**
|
|
* Load sounds for the given workspace.
|
|
*
|
|
* @param pathToMedia The path to the media directory.
|
|
* @param workspace The workspace to load sounds for.
|
|
*/
|
|
function loadSounds(pathToMedia: string, workspace: WorkspaceSvg) {
|
|
const audioMgr = workspace.getAudioManager();
|
|
audioMgr.load(
|
|
[
|
|
pathToMedia + 'click.mp3',
|
|
pathToMedia + 'click.wav',
|
|
pathToMedia + 'click.ogg',
|
|
],
|
|
'click',
|
|
);
|
|
audioMgr.load(
|
|
[
|
|
pathToMedia + 'disconnect.wav',
|
|
pathToMedia + 'disconnect.mp3',
|
|
pathToMedia + 'disconnect.ogg',
|
|
],
|
|
'disconnect',
|
|
);
|
|
audioMgr.load(
|
|
[
|
|
pathToMedia + 'delete.mp3',
|
|
pathToMedia + 'delete.ogg',
|
|
pathToMedia + 'delete.wav',
|
|
],
|
|
'delete',
|
|
);
|
|
|
|
// Bind temporary hooks that preload the sounds.
|
|
const soundBinds: browserEvents.Data[] = [];
|
|
/**
|
|
*
|
|
*/
|
|
function unbindSounds() {
|
|
while (soundBinds.length) {
|
|
const oldSoundBinding = soundBinds.pop();
|
|
if (oldSoundBinding) {
|
|
browserEvents.unbind(oldSoundBinding);
|
|
}
|
|
}
|
|
audioMgr.preload();
|
|
}
|
|
|
|
// These are bound on mouse/touch events with
|
|
// Blockly.browserEvents.conditionalBind, so they restrict the touch
|
|
// identifier that will be recognized. But this is really something that
|
|
// happens on a click, not a drag, so that's not necessary.
|
|
|
|
// Android ignores any sound not loaded as a result of a user action.
|
|
soundBinds.push(
|
|
browserEvents.conditionalBind(
|
|
document,
|
|
'pointermove',
|
|
null,
|
|
unbindSounds,
|
|
true,
|
|
),
|
|
);
|
|
soundBinds.push(
|
|
browserEvents.conditionalBind(
|
|
document,
|
|
'touchstart',
|
|
null,
|
|
unbindSounds,
|
|
true,
|
|
),
|
|
);
|
|
}
|