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
25 changes: 24 additions & 1 deletion packages/blockly/core/block.ts
Original file line number Diff line number Diff line change
Expand Up @@ -967,7 +967,30 @@ export class Block {
}

/**
* @returns True if this block is a value block with a single editable field.
* Determines and returns the full-block field for this block, or null if there isn't one
* and this block can't be considered a singleton field block.
*
* Note that this method is unreliable if a block contains a single field that
* hasn't been initialized/rendered yet.
*
* @returns The full-block field this block contains, or null if it doesn't contain one.
* @internal
*/
getFullBlockField(): Field<any> | null {
if (!this.isSimpleReporter()) return null;
const field = this.inputList[0]?.fieldRow[0];
return field?.isFullBlockField() ? field : null;
}

/**
* A block is a simple reporter if it has an output connection and exactly one field.
* In some renderers, simple reporters are rendered differently from other blocks.
* Being a simple reporter block is a prerequisite to the single field rendering itself
* as a "full-block field", but it is not sufficient, as not all fields or renderers use
* this special rendering. Use `getFullBlockField` to determine if the block is rendered
* as a "full-block field block".
*
* @returns True if this block is a value block with a single field.
* @internal
*/
isSimpleReporter(): boolean {
Expand Down
35 changes: 30 additions & 5 deletions packages/blockly/core/block_aria_composer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,20 +52,31 @@ export enum ConnectionPreposition {
* @internal
* @param block The block for which an ARIA representation should be created.
* @param verbosity How much detail to include in the description.
* @param useCustomInputLabels Whether to use custom labels for inputs, if they
* exist. We don't want to do this when just reading a block's label, but do
* want to in other scenarios such as move mode.
* @returns The ARIA representation for the specified block.
*/
export function computeAriaLabel(
block: BlockSvg,
verbosity = Verbosity.STANDARD,
useCustomInputLabels = true,
) {
if (block.isSimpleReporter()) {
// special case for full-block field blocks.
const field = block.getFullBlockField();
if (field) {
return field.computeAriaLabel(verbosity >= Verbosity.STANDARD);
}
}
return [
verbosity >= Verbosity.STANDARD && getBeginStackLabel(block),
getParentInputLabel(block),
...getInputLabels(block, verbosity),
...getInputLabels(block, verbosity, useCustomInputLabels),
verbosity === Verbosity.LOQUACIOUS && getParentToolboxCategoryLabel(block),
verbosity >= Verbosity.STANDARD && getDisabledLabel(block),
verbosity >= Verbosity.STANDARD && getCollapsedLabel(block),
verbosity >= Verbosity.STANDARD && getShadowBlockLabel(block),
verbosity >= Verbosity.LOQUACIOUS && getShadowBlockLabel(block),
verbosity >= Verbosity.STANDARD && getInputCountLabel(block),
]
.filter((label) => !!label)
Expand Down Expand Up @@ -124,7 +135,7 @@ export function computeFieldRowLabel(
lookback: boolean,
verbosity = Verbosity.STANDARD,
): string[] {
const includeTypeInfo = verbosity >= Verbosity.STANDARD;
const includeTypeInfo = verbosity >= Verbosity.LOQUACIOUS;
const fieldRowLabel = input.fieldRow
.filter((field) => field.isVisible())
.map((field) => field.computeAriaLabel(includeTypeInfo));
Expand Down Expand Up @@ -182,7 +193,10 @@ function getParentInputLabel(block: BlockSvg) {
* does not.
*/
function getBeginStackLabel(block: BlockSvg) {
return !block.workspace.isFlyout && block.getRootBlock() === block
// Don't include the "begin stack" label for blocks that are moving
// or blocks in the flyout
if (block.isInFlyout || block.workspace.isDragging()) return undefined;
return block.getRootBlock() === block
? Msg['BLOCK_LABEL_BEGIN_STACK']
: undefined;
}
Expand All @@ -195,17 +209,28 @@ function getBeginStackLabel(block: BlockSvg) {
* their contents are returned as a single item in the array per top-level
* input.
*
* Generally, if a custom label for an input is provided, that is preferred.
* However, we do not surface the custom labels when simply reading the text of
* the block. They are used as supplementary information for situations like
* move mode or when an input itself is focused.
*
* @internal
* @param block The block to retrieve a list of field/input labels for.
* @param verbosity
* @param useCustomLabels whether to use the custom label for an input, if it's present.
* @returns A list of field/input labels for the given block.
*/
export function getInputLabels(
block: BlockSvg,
verbosity = Verbosity.STANDARD,
useCustomLabels = true,
): string[] {
return block.inputList
.filter((input) => input.isVisible())
.map((input) => input.getAriaLabelText() ?? input.getLabel(verbosity));
.map((input) => {
const customLabel = useCustomLabels ? input.getAriaLabelText() : null;
return customLabel ?? input.getLabel(verbosity);
});
}

/**
Expand Down
22 changes: 14 additions & 8 deletions packages/blockly/core/block_svg.ts
Original file line number Diff line number Diff line change
Expand Up @@ -246,7 +246,7 @@ export class BlockSvg
if (!svg.parentNode) {
this.workspace.getCanvas().appendChild(svg);
}
this.recomputeAriaAttributes();
this.recomputeAriaContext();
this.initialized = true;
}

Expand Down Expand Up @@ -609,7 +609,7 @@ export class BlockSvg
this.getInput(collapsedInputName) ||
this.appendDummyInput(collapsedInputName);
input.appendField(new FieldLabel(text), collapsedFieldName);
this.recomputeAriaAttributes();
this.recomputeAriaContext();
}

/**
Expand Down Expand Up @@ -846,7 +846,7 @@ export class BlockSvg
override setShadow(shadow: boolean) {
super.setShadow(shadow);
this.applyColour();
this.recomputeAriaAttributes();
this.recomputeAriaContext();
}

/**
Expand Down Expand Up @@ -1067,7 +1067,7 @@ export class BlockSvg
for (const child of this.getChildren(false)) {
child.updateDisabled();
}
this.recomputeAriaAttributes();
this.recomputeAriaContext();
}

/**
Expand Down Expand Up @@ -1881,6 +1881,11 @@ export class BlockSvg

/** See IFocusableNode.getFocusableElement. */
getFocusableElement(): HTMLElement | SVGElement {
// For full-block fields, we focus the field itself
const fullBlockField = this.getFullBlockField();
if (fullBlockField) {
return fullBlockField.getFocusableElement();
}
return this.pathObject.svgPath;
}

Expand All @@ -1891,7 +1896,7 @@ export class BlockSvg

/** See IFocusableNode.onNodeFocus. */
onNodeFocus(): void {
this.recomputeAriaAttributes();
this.recomputeAriaContext();
this.select();
const focusedNode = getFocusManager().getFocusedNode();
if (focusedNode && focusedNode !== this) {
Expand Down Expand Up @@ -2001,7 +2006,8 @@ export class BlockSvg
/**
* Updates the ARIA label, role and roledescription for this block.
*/
private recomputeAriaAttributes() {
private recomputeAriaContext() {
if (this.getFullBlockField()) return;
aria.setState(
this.getFocusableElement(),
aria.State.LABEL,
Expand All @@ -2018,7 +2024,7 @@ export class BlockSvg
* @returns An accessibility description of this block.
*/
getAriaLabel(verbosity: aria.Verbosity) {
return computeAriaLabel(this, verbosity);
return computeAriaLabel(this, verbosity, false);
}

/**
Expand All @@ -2035,7 +2041,7 @@ export class BlockSvg
block = block.getNextBlock();
}
if (count <= 1) {
return this.getAriaLabel(aria.Verbosity.TERSE);
return computeAriaLabel(this, aria.Verbosity.TERSE);
}

const labelTemplate = Msg['BLOCK_LABEL_STACK_BLOCKS'];
Expand Down
79 changes: 73 additions & 6 deletions packages/blockly/core/field.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import {Msg} from './msg.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 @@ -275,7 +276,6 @@ export abstract class Field<T = any>
`problems with focus: ${block.id}.`,
);
}
this.id_ = `${block.id}_field_${idGenerator.getNextUniqueId()}`;
}

/**
Expand Down Expand Up @@ -398,11 +398,7 @@ export abstract class Field<T = any>
// Field has already been initialized once.
return;
}
const id = this.id_;
if (!id) throw new Error('Expected ID to be defined prior to init.');
this.fieldGroup_ = dom.createSvgElement(Svg.G, {
'id': id,
});
this.fieldGroup_ = dom.createSvgElement(Svg.G, {});
if (!this.isVisible()) {
this.fieldGroup_.style.display = 'none';
}
Expand All @@ -414,6 +410,15 @@ export abstract class Field<T = any>
this.bindEvents_();
this.initModel();
this.applyColour();

// Since full-block fields can be focused from the workspace's tree,
// they need IDs in the format that the workspace is expecting.
if (this.isFullBlockField()) {
this.id_ = idGenerator.getNextUniqueId();
} else {
this.id_ = `${sourceBlockSvg.id}_field_${idGenerator.getNextUniqueId()}`;
}
this.fieldGroup_.setAttribute('id', this.id_);
}

/**
Expand Down Expand Up @@ -1492,6 +1497,68 @@ export abstract class Field<T = any>
this.showEditor();
}

/**
* Recomputes the aria state and label for this field. Fields are generally hidden
* when in blocks in the flyout (except for top-level full-block fields), and
* otherwise set to a role of button (indicating they can be clicked to edit)
* and given the label returned from their `computeAriaLabel` method.
*
* Subclasses can override this in order to change the role or label, but they must
* ensure they keep the correct behavior for fields in flyout blocks.
*
* This method will return a boolean indicating if the element is displayed in the
* aria tree or not. This can be used by subclasses to determine whether or not
* to continue customizing the role and label (hidden elements should not have labels).
*
* @returns true if the element is in the accessibility tree, false if the aria state is hidden
*/
protected recomputeAriaContext(): boolean {
let focusableElement;
try {
focusableElement = this.getFocusableElement();
} catch {
// Just return because the field hasn't been initialized yet.
return false;
}

if (!focusableElement) return false;

if (this.getSourceBlock()?.isInFlyout) {
const isTopLevelFullBlockField =
this.getSourceBlock()?.getFullBlockField() &&
!this.getSourceBlock()?.getParent();
if (!isTopLevelFullBlockField) {
// Fields in the flyout are not generally focusable, so they should
// be hidden. An exception is full-block field blocks that don't have
// parents, since the block itself defers to the field's focusable element.
aria.setState(focusableElement, aria.State.HIDDEN, true);
return false;
} else {
// Top-level full-block fields in the flyout need to have their
// roledescription set. This can't happen in the flyout code because
// the field hasn't been initialized yet then.
// These blocks should also have the rest of the state in this method set.
const roleDescription =
this.getSourceBlock()?.getAriaRoleDescription() ||
Msg['BLOCK_LABEL_VALUE'];
aria.setState(
focusableElement,
aria.State.ROLEDESCRIPTION,
roleDescription,
);
}
}

aria.clearState(focusableElement, aria.State.HIDDEN);
// The button role is intended to indicate to users that the field has an
// editing mode that can be activated.
aria.setRole(focusableElement, aria.Role.BUTTON);

const label = this.computeAriaLabel(true);
aria.setState(focusableElement, aria.State.LABEL, label);
return true;
}

/**
* Subclasses should reimplement this method to construct their Field
* subclass from a JSON arg object.
Expand Down
14 changes: 6 additions & 8 deletions packages/blockly/core/field_checkbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -267,16 +267,13 @@ export class FieldCheckbox extends Field<CheckboxBool> {
}

/**
* Recomputes the ARIA role and label for this field.
* Customizes the label and sets additional aria state.
*/
protected recomputeAriaContext(): void {
const focusableElement = this.getClickTarget_();
if (!focusableElement) return;
override recomputeAriaContext(): boolean {
const shouldCustomize = super.recomputeAriaContext();
if (!shouldCustomize) return false;

if (this.getSourceBlock()?.isInFlyout) {
aria.setState(focusableElement, aria.State.HIDDEN, true);
return;
}
const focusableElement = this.getFocusableElement();

aria.setState(focusableElement, aria.State.HIDDEN, false);
aria.setRole(focusableElement, aria.Role.CHECKBOX);
Expand All @@ -289,6 +286,7 @@ export class FieldCheckbox extends Field<CheckboxBool> {
const label = this.getAriaTypeName();

aria.setState(focusableElement, aria.State.LABEL, label);
return true;
}
}

Expand Down
20 changes: 6 additions & 14 deletions packages/blockly/core/field_dropdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -918,27 +918,19 @@ export class FieldDropdown extends Field<string> {
}

/**
* Recomputes the ARIA role and label for this field.
* Overrides the default label and sets additional aria state.
*/
protected recomputeAriaContext(): void {
const focusableElement = this.getFocusableElement();
if (!focusableElement) return;

if (this.getSourceBlock()?.isInFlyout) {
aria.setState(focusableElement, aria.State.HIDDEN, true);
return;
}

aria.setState(focusableElement, aria.State.HIDDEN, false);
// The button role is intended to indicate to users that the field has an
// editing mode that can be activated.
aria.setRole(focusableElement, aria.Role.BUTTON);
override recomputeAriaContext(): boolean {
const shouldCustomize = super.recomputeAriaContext();
if (!shouldCustomize) return false;

const focusableElement = this.getFocusableElement();
const label = this.computeAriaLabel(true);

aria.setState(focusableElement, aria.State.LABEL, label);
aria.setState(focusableElement, aria.State.HASPOPUP, 'listbox');
aria.setState(focusableElement, aria.State.EXPANDED, !!this.menu_);
return true;
}

/**
Expand Down
Loading