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
4 changes: 2 additions & 2 deletions blocks/math.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ export const blocks = createBlockDefinitionsFromJsonArray([
'type': 'field_number',
'name': 'NUM',
'value': 0,
'ariaName': 'Number',
'ariaTypeName': 'Number',
},
],
'output': 'Number',
Expand All @@ -55,7 +55,7 @@ export const blocks = createBlockDefinitionsFromJsonArray([
{
'type': 'field_dropdown',
'name': 'OP',
'ariaName': 'Arithmetic operation',
'ariaTypeName': 'Arithmetic operation',
'options': [
['%{BKY_MATH_ADDITION_SYMBOL}', 'ADD', 'Plus'],
['%{BKY_MATH_SUBTRACTION_SYMBOL}', 'MINUS', 'Minus'],
Expand Down
8 changes: 4 additions & 4 deletions core/block_svg.ts
Original file line number Diff line number Diff line change
Expand Up @@ -245,8 +245,8 @@ export class BlockSvg
);
}

private computeAriaLabel(): string {
const {blockSummary, inputCount} = buildBlockSummary(this);
computeAriaLabel(verbose: boolean = false): string {
const {blockSummary, inputCount} = buildBlockSummary(this, verbose);
const inputSummary = inputCount
? ` ${inputCount} ${inputCount > 1 ? 'inputs' : 'input'}`
: '';
Expand Down Expand Up @@ -2052,7 +2052,7 @@ interface BlockSummary {
inputCount: number;
}

function buildBlockSummary(block: BlockSvg): BlockSummary {
function buildBlockSummary(block: BlockSvg, verbose: boolean): BlockSummary {
let inputCount = 0;
function recursiveInputSummary(
block: BlockSvg,
Expand All @@ -2074,7 +2074,7 @@ function buildBlockSummary(block: BlockSvg): BlockSummary {
if (field.EDITABLE && !field.isFullBlockField() && !isNestedInput) {
inputCount++;
}
return [field.getText() ?? field.getValue()];
return field.computeAriaLabel(verbose);
});
if (
input.isVisible() &&
Expand Down
65 changes: 62 additions & 3 deletions core/field.ts
Original file line number Diff line number Diff line change
Expand Up @@ -271,8 +271,67 @@ export abstract class Field<T = any>
}
}

getAriaName(): string | null {
return this.config?.ariaName ?? null;
/**
* Gets a an ARIA-friendly label representation of this field's type.
*
* @returns An ARIA representation of the field's type or null if it is
* unspecified.
*/
getAriaTypeName(): string | null {
return this.config?.ariaTypeName ?? null;
}

/**
* Gets a an ARIA-friendly label representation of this field's value.
*
* Note that implementations should generally always override this value to
* ensure a non-null value is returned since the default implementation relies
* on 'getValue' which may return null, and a null return value for this
* function will prompt ARIA label generation to skip the field's value
* entirely when there may be a better contextual placeholder to use, instead,
* specific to the field.
*
* @returns An ARIA representation of the field's value, or null if no value
* is currently defined or known for the field.
*/
getAriaValue(): string | null {
const currentValue = this.getValue();
return currentValue !== null ? String(currentValue) : null;
}

/**
* Computes a descriptive ARIA label to represent this field with configurable
* verbosity.
*
* A 'verbose' label includes type information, if available, whereas a
* non-verbose label only contains the field's value.
*
* Note that this will always return the latest representation of the field's
* label which may differ from any previously set ARIA label for the field
* itself. Implementations are largely responsible for ensuring that the
* field's ARIA label is set correctly at relevant moments in the field's
* lifecycle (such as when its value changes).
*
* Finally, it is never guaranteed that implementations use the label returned
* by this method for their actual ARIA label. Some implementations may rely
* on other context to convey information like the field's value. Example:
* checkboxes represent their checked/non-checked status (i.e. value) through
* a separate ARIA property.
*
* It's possible this returns an empty string if the field doesn't supply type
* or value information for certain cases (such as a null value). This will
* lead to the field being potentially COMPLETELY HIDDEN for screen reader
* navigation.
*
* @param verbose Whether to include the field's type information in the
* returned label, if available.
*/
computeAriaLabel(verbose: boolean = false): string {
const components: Array<string | null> = [this.getAriaValue()];
if (verbose) {
components.push(this.getAriaTypeName());
}
return components.filter((item) => item !== null).join(', ');
}

/**
Expand Down Expand Up @@ -1429,7 +1488,7 @@ export interface FieldConfig {
type: string;
name?: string;
tooltip?: string;
ariaName?: string;
ariaTypeName?: string;
}

/**
Expand Down
10 changes: 9 additions & 1 deletion core/field_checkbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,10 +116,18 @@ export class FieldCheckbox extends Field<CheckboxBool> {
this.recomputeAria();
}

override getAriaValue(): string {
return this.value_ ? 'checked' : 'not checked';
}

private recomputeAria() {
const element = this.getFocusableElement();
aria.setRole(element, aria.Role.CHECKBOX);
aria.setState(element, aria.State.LABEL, this.getAriaName() ?? 'Checkbox');
aria.setState(
element,
aria.State.LABEL,
this.getAriaTypeName() ?? 'Checkbox',
);
aria.setState(element, aria.State.CHECKED, !!this.value_);
}

Expand Down
13 changes: 5 additions & 8 deletions core/field_dropdown.ts
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,10 @@ export class FieldDropdown extends Field<string> {
this.recomputeAria();
}

override getAriaValue(): string {
return this.computeLabelForOption(this.selectedOption);
}

protected recomputeAria() {
if (!this.fieldGroup_) return; // There's no element to set currently.
const element = this.getFocusableElement();
Expand All @@ -214,14 +218,7 @@ export class FieldDropdown extends Field<string> {
aria.clearState(element, aria.State.CONTROLS);
}

const label = [
this.computeLabelForOption(this.selectedOption),
this.getAriaName(),
]
.filter((item) => !!item)
.join(', ');

aria.setState(element, aria.State.LABEL, label);
aria.setState(element, aria.State.LABEL, super.computeAriaLabel(true));
}

/**
Expand Down
10 changes: 5 additions & 5 deletions core/field_image.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,10 @@ export class FieldImage extends Field<string> {
}
}

override getAriaValue(): string {
return this.altText;
}

/**
* Create the block UI for this image.
*/
Expand Down Expand Up @@ -159,11 +163,7 @@ export class FieldImage extends Field<string> {
if (this.isClickable()) {
this.imageElement.style.cursor = 'pointer';
aria.setRole(element, aria.Role.BUTTON);

const label = [this.altText, this.getAriaName()]
.filter((item) => !!item)
.join(', ');
aria.setState(element, aria.State.LABEL, label);
aria.setState(element, aria.State.LABEL, super.computeAriaLabel(true));
} else {
// The field isn't navigable unless it's clickable.
aria.setRole(element, aria.Role.PRESENTATION);
Expand Down
7 changes: 1 addition & 6 deletions core/field_input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,13 +188,8 @@ export abstract class FieldInput<T extends InputTypes> extends Field<
*/
protected recomputeAriaLabel() {
if (!this.fieldGroup_) return;

const element = this.getFocusableElement();
const label = [this.getValue(), this.getAriaName()]
.filter((item) => item !== null)
.join(', ');

aria.setState(element, aria.State.LABEL, label);
aria.setState(element, aria.State.LABEL, super.computeAriaLabel());
}

override isFullBlockField(): boolean {
Expand Down
70 changes: 70 additions & 0 deletions core/shortcut_items.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {isDeletable as isIDeletable} from './interfaces/i_deletable.js';
import {isDraggable} from './interfaces/i_draggable.js';
import {IFocusableNode} from './interfaces/i_focusable_node.js';
import {KeyboardShortcut, ShortcutRegistry} from './shortcut_registry.js';
import {aria} from './utils.js';
import {Coordinate} from './utils/coordinate.js';
import {KeyCodes} from './utils/keycodes.js';
import {Rect} from './utils/rect.js';
Expand All @@ -33,6 +34,8 @@ export enum names {
PASTE = 'paste',
UNDO = 'undo',
REDO = 'redo',
READ_FULL_BLOCK_SUMMARY = 'read_full_block_summary',
READ_BLOCK_PARENT_SUMMARY = 'read_block_parent_summary',
}

/**
Expand Down Expand Up @@ -386,6 +389,71 @@ export function registerRedo() {
ShortcutRegistry.registry.register(redoShortcut);
}

/**
* Registeres a keyboard shortcut for re-reading the current selected block's
* summary with additional verbosity to help provide context on where the user
* is currently navigated (for screen reader users only).
*/
export function registerReadFullBlockSummary() {
const i = ShortcutRegistry.registry.createSerializedKey(KeyCodes.I, null);
const readFullBlockSummaryShortcut: KeyboardShortcut = {
name: names.READ_FULL_BLOCK_SUMMARY,
preconditionFn(workspace) {
return (
!workspace.isDragging() &&
!getFocusManager().ephemeralFocusTaken() &&
!!getFocusManager().getFocusedNode() &&
getFocusManager().getFocusedNode() instanceof BlockSvg
);
},
callback(_, e) {
const selectedBlock = getFocusManager().getFocusedNode() as BlockSvg;
const blockSummary = selectedBlock.computeAriaLabel(true);
aria.announceDynamicAriaState(`Current block: ${blockSummary}`);
e.preventDefault();
return true;
},
keyCodes: [i],
};
ShortcutRegistry.registry.register(readFullBlockSummaryShortcut);
}

/**
* Registeres a keyboard shortcut for re-reading the current selected block's
* parent block summary with additional verbosity to help provide context on
* where the user is currently navigated (for screen reader users only).
*/
export function registerReadBlockParentSummary() {
const shiftI = ShortcutRegistry.registry.createSerializedKey(KeyCodes.I, [
KeyCodes.SHIFT,
]);
const readBlockParentSummaryShortcut: KeyboardShortcut = {
name: names.READ_BLOCK_PARENT_SUMMARY,
preconditionFn(workspace) {
return (
!workspace.isDragging() &&
!getFocusManager().ephemeralFocusTaken() &&
!!getFocusManager().getFocusedNode() &&
getFocusManager().getFocusedNode() instanceof BlockSvg
);
},
callback(_, e) {
const selectedBlock = getFocusManager().getFocusedNode() as BlockSvg;
const parentBlock = selectedBlock.getParent();
if (parentBlock) {
const blockSummary = parentBlock.computeAriaLabel(true);
aria.announceDynamicAriaState(`Parent block: ${blockSummary}`);
} else {
aria.announceDynamicAriaState('Current block has no parent');
}
e.preventDefault();
return true;
},
keyCodes: [shiftI],
};
ShortcutRegistry.registry.register(readBlockParentSummaryShortcut);
}

/**
* Registers all default keyboard shortcut item. This should be called once per
* instance of KeyboardShortcutRegistry.
Expand All @@ -400,6 +468,8 @@ export function registerDefaultShortcuts() {
registerPaste();
registerUndo();
registerRedo();
registerReadFullBlockSummary();
registerReadBlockParentSummary();
}

registerDefaultShortcuts();
Loading