Skip to content

Commit 80660bd

Browse files
authored
feat: Add verbosity shortcuts (experimental) (#9481)
## The basics - [x] I [validated my changes](https://developers.google.com/blockly/guides/contribute/core#making_and_verifying_a_change) ## The details ### Resolves Fixes part of RaspberryPiFoundation/blockly-keyboard-experimentation#764 Fixes part of #9450 (infrastructure needs) ### Proposed Changes Introduces support for two new "where am I?" shortcuts for helping to provide location context for users: - `I`: re-reads the current selected block with full verbosity (i.e. also includes the block's field types with their values in the readout). - `shift+I`: reads the current selected block's parent with full verbosity. Note that this includes some functional changes to `Field` to allow for more powerful customization of a field's ARIA representation (by splitting up value and type), though a field's value defaults potentially to null which will be ignored in the final ARIA computed label. This seems necessary per the discussion here: https://github.com/RaspberryPiFoundation/blockly/pull/9470/files#r2541508565 but more consideration may be needed here as part of #9307. Some limitations in the new shortcuts: - They will not read out anything if a block is not selected (e.g. for fields and icons). - They read out input blocks when the input block is selected. - They cannot read out anything while in move mode (due to the behavior here in the plugin which automatically cancels moves if an unknown shortcut is pressed: https://github.com/RaspberryPiFoundation/blockly-keyboard-experimentation/blob/a36f3662b05c2ddcd18bde8745777fff8dc3df31/src/actions/mover.ts#L166-L191). - The readout is limited by the problems of dynamic ARIA announcements (per #9460). ### Reason for Changes RaspberryPiFoundation/blockly-keyboard-experimentation#764 provides context on the specific needs addressed here. ### Test Coverage Self tested. No new automated tests needed for experimental work. ### Documentation No new documentation needed for experimental work. ### Additional Information This was spun out of #9470 with the intent of getting shortcuts initially working checked in even if the entirety of the experience is incomplete.
1 parent 74e81ce commit 80660bd

File tree

8 files changed

+161
-29
lines changed

8 files changed

+161
-29
lines changed

blocks/math.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ export const blocks = createBlockDefinitionsFromJsonArray([
3232
'type': 'field_number',
3333
'name': 'NUM',
3434
'value': 0,
35-
'ariaName': 'Number',
35+
'ariaTypeName': 'Number',
3636
},
3737
],
3838
'output': 'Number',
@@ -55,7 +55,7 @@ export const blocks = createBlockDefinitionsFromJsonArray([
5555
{
5656
'type': 'field_dropdown',
5757
'name': 'OP',
58-
'ariaName': 'Arithmetic operation',
58+
'ariaTypeName': 'Arithmetic operation',
5959
'options': [
6060
['%{BKY_MATH_ADDITION_SYMBOL}', 'ADD', 'Plus'],
6161
['%{BKY_MATH_SUBTRACTION_SYMBOL}', 'MINUS', 'Minus'],

core/block_svg.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -242,8 +242,11 @@ export class BlockSvg
242242
);
243243
}
244244

245-
private computeAriaLabel(): string {
246-
const {commaSeparatedSummary, inputCount} = buildBlockSummary(this);
245+
computeAriaLabel(verbose: boolean = false): string {
246+
const {commaSeparatedSummary, inputCount} = buildBlockSummary(
247+
this,
248+
verbose,
249+
);
247250
let inputSummary = '';
248251
if (inputCount > 1) {
249252
inputSummary = 'has inputs';
@@ -2029,7 +2032,7 @@ interface BlockSummary {
20292032
inputCount: number;
20302033
}
20312034

2032-
function buildBlockSummary(block: BlockSvg): BlockSummary {
2035+
function buildBlockSummary(block: BlockSvg, verbose: boolean): BlockSummary {
20332036
let inputCount = 0;
20342037

20352038
// Produce structured segments
@@ -2059,7 +2062,7 @@ function buildBlockSummary(block: BlockSvg): BlockSummary {
20592062
return true;
20602063
})
20612064
.map((field) => {
2062-
const text = field.getText() ?? field.getValue();
2065+
const text = field.computeAriaLabel(verbose);
20632066
// If the block is a full block field, we only want to know if it's an
20642067
// editable field if we're not directly on it.
20652068
if (field.EDITABLE && !field.isFullBlockField() && !isNestedInput) {

core/field.ts

Lines changed: 62 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -271,8 +271,67 @@ export abstract class Field<T = any>
271271
}
272272
}
273273

274-
getAriaName(): string | null {
275-
return this.config?.ariaName ?? null;
274+
/**
275+
* Gets a an ARIA-friendly label representation of this field's type.
276+
*
277+
* @returns An ARIA representation of the field's type or null if it is
278+
* unspecified.
279+
*/
280+
getAriaTypeName(): string | null {
281+
return this.config?.ariaTypeName ?? null;
282+
}
283+
284+
/**
285+
* Gets a an ARIA-friendly label representation of this field's value.
286+
*
287+
* Note that implementations should generally always override this value to
288+
* ensure a non-null value is returned since the default implementation relies
289+
* on 'getValue' which may return null, and a null return value for this
290+
* function will prompt ARIA label generation to skip the field's value
291+
* entirely when there may be a better contextual placeholder to use, instead,
292+
* specific to the field.
293+
*
294+
* @returns An ARIA representation of the field's value, or null if no value
295+
* is currently defined or known for the field.
296+
*/
297+
getAriaValue(): string | null {
298+
const currentValue = this.getValue();
299+
return currentValue !== null ? String(currentValue) : null;
300+
}
301+
302+
/**
303+
* Computes a descriptive ARIA label to represent this field with configurable
304+
* verbosity.
305+
*
306+
* A 'verbose' label includes type information, if available, whereas a
307+
* non-verbose label only contains the field's value.
308+
*
309+
* Note that this will always return the latest representation of the field's
310+
* label which may differ from any previously set ARIA label for the field
311+
* itself. Implementations are largely responsible for ensuring that the
312+
* field's ARIA label is set correctly at relevant moments in the field's
313+
* lifecycle (such as when its value changes).
314+
*
315+
* Finally, it is never guaranteed that implementations use the label returned
316+
* by this method for their actual ARIA label. Some implementations may rely
317+
* on other context to convey information like the field's value. Example:
318+
* checkboxes represent their checked/non-checked status (i.e. value) through
319+
* a separate ARIA property.
320+
*
321+
* It's possible this returns an empty string if the field doesn't supply type
322+
* or value information for certain cases (such as a null value). This will
323+
* lead to the field being potentially COMPLETELY HIDDEN for screen reader
324+
* navigation.
325+
*
326+
* @param verbose Whether to include the field's type information in the
327+
* returned label, if available.
328+
*/
329+
computeAriaLabel(verbose: boolean = false): string {
330+
const components: Array<string | null> = [this.getAriaValue()];
331+
if (verbose) {
332+
components.push(this.getAriaTypeName());
333+
}
334+
return components.filter((item) => item !== null).join(', ');
276335
}
277336

278337
/**
@@ -1426,7 +1485,7 @@ export interface FieldConfig {
14261485
type: string;
14271486
name?: string;
14281487
tooltip?: string;
1429-
ariaName?: string;
1488+
ariaTypeName?: string;
14301489
}
14311490

14321491
/**

core/field_checkbox.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,10 +116,18 @@ export class FieldCheckbox extends Field<CheckboxBool> {
116116
this.recomputeAria();
117117
}
118118

119+
override getAriaValue(): string {
120+
return this.value_ ? 'checked' : 'not checked';
121+
}
122+
119123
private recomputeAria() {
120124
const element = this.getFocusableElement();
121125
aria.setRole(element, aria.Role.CHECKBOX);
122-
aria.setState(element, aria.State.LABEL, this.getAriaName() ?? 'Checkbox');
126+
aria.setState(
127+
element,
128+
aria.State.LABEL,
129+
this.getAriaTypeName() ?? 'Checkbox',
130+
);
123131
aria.setState(element, aria.State.CHECKED, !!this.value_);
124132
}
125133

core/field_dropdown.ts

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,10 @@ export class FieldDropdown extends Field<string> {
202202
this.recomputeAria();
203203
}
204204

205+
override getAriaValue(): string {
206+
return this.computeLabelForOption(this.selectedOption);
207+
}
208+
205209
protected recomputeAria() {
206210
if (!this.fieldGroup_) return; // There's no element to set currently.
207211
const element = this.getFocusableElement();
@@ -214,14 +218,7 @@ export class FieldDropdown extends Field<string> {
214218
aria.clearState(element, aria.State.CONTROLS);
215219
}
216220

217-
const label = [
218-
this.computeLabelForOption(this.selectedOption),
219-
this.getAriaName(),
220-
]
221-
.filter((item) => !!item)
222-
.join(', ');
223-
224-
aria.setState(element, aria.State.LABEL, label);
221+
aria.setState(element, aria.State.LABEL, super.computeAriaLabel(true));
225222
}
226223

227224
/**

core/field_image.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,10 @@ export class FieldImage extends Field<string> {
132132
}
133133
}
134134

135+
override getAriaValue(): string {
136+
return this.altText;
137+
}
138+
135139
/**
136140
* Create the block UI for this image.
137141
*/
@@ -159,11 +163,7 @@ export class FieldImage extends Field<string> {
159163
if (this.isClickable()) {
160164
this.imageElement.style.cursor = 'pointer';
161165
aria.setRole(element, aria.Role.BUTTON);
162-
163-
const label = [this.altText, this.getAriaName()]
164-
.filter((item) => !!item)
165-
.join(', ');
166-
aria.setState(element, aria.State.LABEL, label);
166+
aria.setState(element, aria.State.LABEL, super.computeAriaLabel(true));
167167
} else {
168168
// The field isn't navigable unless it's clickable.
169169
aria.setRole(element, aria.Role.PRESENTATION);

core/field_input.ts

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -188,13 +188,8 @@ export abstract class FieldInput<T extends InputTypes> extends Field<
188188
*/
189189
protected recomputeAriaLabel() {
190190
if (!this.fieldGroup_) return;
191-
192191
const element = this.getFocusableElement();
193-
const label = [this.getValue(), this.getAriaName()]
194-
.filter((item) => item !== null)
195-
.join(', ');
196-
197-
aria.setState(element, aria.State.LABEL, label);
192+
aria.setState(element, aria.State.LABEL, super.computeAriaLabel());
198193
}
199194

200195
override isFullBlockField(): boolean {

core/shortcut_items.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {isDeletable as isIDeletable} from './interfaces/i_deletable.js';
1616
import {isDraggable} from './interfaces/i_draggable.js';
1717
import {IFocusableNode} from './interfaces/i_focusable_node.js';
1818
import {KeyboardShortcut, ShortcutRegistry} from './shortcut_registry.js';
19+
import {aria} from './utils.js';
1920
import {Coordinate} from './utils/coordinate.js';
2021
import {KeyCodes} from './utils/keycodes.js';
2122
import {Rect} from './utils/rect.js';
@@ -33,6 +34,8 @@ export enum names {
3334
PASTE = 'paste',
3435
UNDO = 'undo',
3536
REDO = 'redo',
37+
READ_FULL_BLOCK_SUMMARY = 'read_full_block_summary',
38+
READ_BLOCK_PARENT_SUMMARY = 'read_block_parent_summary',
3639
}
3740

3841
/**
@@ -386,6 +389,71 @@ export function registerRedo() {
386389
ShortcutRegistry.registry.register(redoShortcut);
387390
}
388391

392+
/**
393+
* Registers a keyboard shortcut for re-reading the current selected block's
394+
* summary with additional verbosity to help provide context on where the user
395+
* is currently navigated (for screen reader users only).
396+
*/
397+
export function registerReadFullBlockSummary() {
398+
const i = ShortcutRegistry.registry.createSerializedKey(KeyCodes.I, null);
399+
const readFullBlockSummaryShortcut: KeyboardShortcut = {
400+
name: names.READ_FULL_BLOCK_SUMMARY,
401+
preconditionFn(workspace) {
402+
return (
403+
!workspace.isDragging() &&
404+
!getFocusManager().ephemeralFocusTaken() &&
405+
!!getFocusManager().getFocusedNode() &&
406+
getFocusManager().getFocusedNode() instanceof BlockSvg
407+
);
408+
},
409+
callback(_, e) {
410+
const selectedBlock = getFocusManager().getFocusedNode() as BlockSvg;
411+
const blockSummary = selectedBlock.computeAriaLabel(true);
412+
aria.announceDynamicAriaState(`Current block: ${blockSummary}`);
413+
e.preventDefault();
414+
return true;
415+
},
416+
keyCodes: [i],
417+
};
418+
ShortcutRegistry.registry.register(readFullBlockSummaryShortcut);
419+
}
420+
421+
/**
422+
* Registers a keyboard shortcut for re-reading the current selected block's
423+
* parent block summary with additional verbosity to help provide context on
424+
* where the user is currently navigated (for screen reader users only).
425+
*/
426+
export function registerReadBlockParentSummary() {
427+
const shiftI = ShortcutRegistry.registry.createSerializedKey(KeyCodes.I, [
428+
KeyCodes.SHIFT,
429+
]);
430+
const readBlockParentSummaryShortcut: KeyboardShortcut = {
431+
name: names.READ_BLOCK_PARENT_SUMMARY,
432+
preconditionFn(workspace) {
433+
return (
434+
!workspace.isDragging() &&
435+
!getFocusManager().ephemeralFocusTaken() &&
436+
!!getFocusManager().getFocusedNode() &&
437+
getFocusManager().getFocusedNode() instanceof BlockSvg
438+
);
439+
},
440+
callback(_, e) {
441+
const selectedBlock = getFocusManager().getFocusedNode() as BlockSvg;
442+
const parentBlock = selectedBlock.getParent();
443+
if (parentBlock) {
444+
const blockSummary = parentBlock.computeAriaLabel(true);
445+
aria.announceDynamicAriaState(`Parent block: ${blockSummary}`);
446+
} else {
447+
aria.announceDynamicAriaState('Current block has no parent');
448+
}
449+
e.preventDefault();
450+
return true;
451+
},
452+
keyCodes: [shiftI],
453+
};
454+
ShortcutRegistry.registry.register(readBlockParentSummaryShortcut);
455+
}
456+
389457
/**
390458
* Registers all default keyboard shortcut item. This should be called once per
391459
* instance of KeyboardShortcutRegistry.
@@ -400,6 +468,8 @@ export function registerDefaultShortcuts() {
400468
registerPaste();
401469
registerUndo();
402470
registerRedo();
471+
registerReadFullBlockSummary();
472+
registerReadBlockParentSummary();
403473
}
404474

405475
registerDefaultShortcuts();

0 commit comments

Comments
 (0)