Skip to content
Open
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
22 changes: 14 additions & 8 deletions packages/blockly/core/block_aria_composer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,14 +57,21 @@ export function computeAriaLabel(
block: BlockSvg,
verbosity = Verbosity.STANDARD,
) {
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),
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 @@ -123,7 +130,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 @@ -181,7 +188,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 @@ -204,11 +214,7 @@ export function getInputLabels(
): string[] {
return block.inputList
.filter((input) => input.isVisible())
.map((input) =>
input.getAriaLabelText() !== null
? input.getAriaLabelText()!
: input.getLabel(verbosity),
);
.map((input) => input.getLabel(verbosity));
}

/**
Expand Down
6 changes: 6 additions & 0 deletions packages/blockly/core/block_svg.ts
Original file line number Diff line number Diff line change
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 Down Expand Up @@ -1998,6 +2003,7 @@ export class BlockSvg
* Updates the ARIA label, role and roledescription for this block.
*/
private recomputeAriaAttributes() {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Elsewhere, these methods are called recomputeAriaContext. Worth renaming?

if (this.getFullBlockField()) return;
aria.setState(
this.getFocusableElement(),
aria.State.LABEL,
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 init yet
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nit: Capitalize and add period if this will be around long-term.

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.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nit: Add capital letters

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
28 changes: 11 additions & 17 deletions packages/blockly/core/field_image.ts
Original file line number Diff line number Diff line change
Expand Up @@ -316,30 +316,24 @@ export class FieldImage extends Field<string> {
}

/**
* Recomputes the ARIA role and label for this field.
* Customizes 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;

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

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. The presentation role is used to
// prevent screen readers from reading the content or its descendants.
// Only clickable image fields are navigable.
aria.setRole(
focusableElement,
this.isClickable() ? aria.Role.BUTTON : aria.Role.PRESENTATION,
);

const label = this.computeAriaLabel(true);
aria.setState(focusableElement, aria.State.LABEL, label);
if (!this.isClickable()) {
aria.setRole(focusableElement, aria.Role.PRESENTATION);
aria.clearState(focusableElement, aria.State.LABEL);
return false;
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Are we no longer using the button role for clickable images? It possible for an image field to become clickable after it's made? If so, would the outdated 'presentation' role need to be cleared?
If we are changing things here, we should probably also update or simplify the comment above.

return true;
}
}

Expand Down
24 changes: 7 additions & 17 deletions packages/blockly/core/field_input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -837,29 +837,19 @@ export abstract class FieldInput<T extends InputTypes> extends Field<
}

/**
* Recomputes the ARIA role and label for this field.
* Customizes the label for this field to include "editable" if it applies.
*/
protected recomputeAriaContext(): void {
const focusableElement = this.getClickTarget_();
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();

let label = this.computeAriaLabel(true);

if (this.isCurrentlyEditable?.()) {
if (this.isCurrentlyEditable() && !this.getSourceBlock()?.isInFlyout) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

If we're in a flyout, would we have already returned false earlier above? Not sure we need to check .isInFlyout again.

label = Msg['FIELD_LABEL_EDIT_PREFIX'].replace('%1', label);
}

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

Expand Down
19 changes: 1 addition & 18 deletions packages/blockly/tests/mocha/aria_test.js
Original file line number Diff line number Diff line change
Expand Up @@ -425,25 +425,8 @@ suite('ARIA', function () {
assert.include(label, 'collapsed');
});

test('Shadow blocks indicate that in their label', function () {
const block = this.makeBlock('text_print');
const text = this.makeBlock('text');
text.outputConnection.connect(block.inputList[0].connection);
let label = Blockly.utils.aria.getState(
text.getFocusableElement(),
Blockly.utils.aria.State.LABEL,
);
assert.notInclude(label, 'replaceable');
text.setShadow(true);
label = Blockly.utils.aria.getState(
text.getFocusableElement(),
Blockly.utils.aria.State.LABEL,
);
assert.include(label, 'replaceable');
});

test('Blocks without inputs are properly labeled', function () {
const block = this.makeBlock('math_random_float');
const block = this.makeBlock('logic_boolean');
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Any particular reason for this change?

const label = Blockly.utils.aria.getState(
block.getFocusableElement(),
Blockly.utils.aria.State.LABEL,
Expand Down
Loading
Loading