Skip to content
Draft
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
2 changes: 2 additions & 0 deletions core/focus_manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

import type {IFocusableNode} from './interfaces/i_focusable_node.js';
import type {IFocusableTree} from './interfaces/i_focusable_tree.js';
import * as aria from './utils/aria.js';
import * as dom from './utils/dom.js';
import {FocusableTreeTraverser} from './utils/focusable_tree_traverser.js';

Expand Down Expand Up @@ -579,6 +580,7 @@ export class FocusManager {

this.setNodeToVisualActiveFocus(node);
elem.focus({preventScroll: true});
aria.maybeAnnounceFocusedNode(elem);
}

/**
Expand Down
30 changes: 30 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 * as aria from './utils/aria.js';
import {Coordinate} from './utils/coordinate.js';
import {KeyCodes} from './utils/keycodes.js';
import {Rect} from './utils/rect.js';
Expand All @@ -33,6 +34,7 @@ export enum names {
PASTE = 'paste',
UNDO = 'undo',
REDO = 'redo',
TOGGLE_SYNTHESIS_MODE = 'toggle_synthesis_mode',
}

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

function registerToggleSynthesisMode() {
const ctrlAltS = ShortcutRegistry.registry.createSerializedKey(KeyCodes.S, [
KeyCodes.CTRL,
KeyCodes.ALT,
]);
const metaAltS = ShortcutRegistry.registry.createSerializedKey(KeyCodes.S, [
KeyCodes.META,
KeyCodes.ALT,
]);
const toggleSynthesisModeShortcut: KeyboardShortcut = {
name: names.TOGGLE_SYNTHESIS_MODE,
preconditionFn(workspace) {
return (
!workspace.isDragging() &&
!workspace.isReadOnly() &&
!getFocusManager().ephemeralFocusTaken()
);
},
callback() {
aria.toggleSynthesisMode();
return true;
},
keyCodes: [ctrlAltS, metaAltS],
};
ShortcutRegistry.registry.register(toggleSynthesisModeShortcut);
}

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

registerDefaultShortcuts();
91 changes: 85 additions & 6 deletions core/utils/aria.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,9 @@
CHECKED = 'checked',
}

const trackedElements: Set<Element> = new Set();
var bypassScreenreaderPrefix = "";

Check failure on line 123 in core/utils/aria.ts

View workflow job for this annotation

GitHub Actions / lint

Replace `""` with `''`

Check failure on line 123 in core/utils/aria.ts

View workflow job for this annotation

GitHub Actions / lint

Unexpected var, use let or const instead

/**
* Updates the specific role for the specified element.
*
Expand All @@ -127,9 +130,23 @@
* should be cleared.
*/
export function setRole(element: Element, roleName: Role | null) {
trackedElements.add(element);
if (roleName) {
element.setAttribute(ROLE_ATTRIBUTE, roleName);
} else element.removeAttribute(ROLE_ATTRIBUTE);
element.setAttribute(maybePrefixAttribute(ROLE_ATTRIBUTE), roleName);
if (isInSynthesisMode()) {
element.setAttribute(ROLE_ATTRIBUTE, Role.PRESENTATION);
}
} else element.removeAttribute(maybePrefixAttribute(ROLE_ATTRIBUTE));
}

export function maybeAnnounceFocusedNode(element: Element, interrupt: boolean = true) {

Check failure on line 142 in core/utils/aria.ts

View workflow job for this annotation

GitHub Actions / lint

Replace `element:·Element,·interrupt:·boolean·=·true` with `⏎··element:·Element,⏎··interrupt:·boolean·=·true,⏎`

Check warning on line 142 in core/utils/aria.ts

View workflow job for this annotation

GitHub Actions / lint

Missing JSDoc comment
// TODO: Add role-specific announcements here.
// TODO: This requires a lot more to do correctly such as considering active descendants, labelled by, etc.
const label = getState(element, State.LABEL);
if (isInSynthesisMode() && label) {
if (interrupt) speechSynthesis.cancel();
speechSynthesis.speak(new SpeechSynthesisUtterance(label));
}
}

/**
Expand All @@ -142,7 +159,7 @@
export function getRole(element: Element): Role | null {
// This is an unsafe cast which is why it needs to be checked to ensure that
// it references a valid role.
const currentRoleName = element.getAttribute(ROLE_ATTRIBUTE) as Role;
const currentRoleName = element.getAttribute(maybePrefixAttribute(ROLE_ATTRIBUTE)) as Role;

Check failure on line 162 in core/utils/aria.ts

View workflow job for this annotation

GitHub Actions / lint

Replace `maybePrefixAttribute(ROLE_ATTRIBUTE)` with `⏎····maybePrefixAttribute(ROLE_ATTRIBUTE),⏎··`
if (Object.values(Role).includes(currentRoleName)) {
return currentRoleName;
}
Expand All @@ -166,11 +183,12 @@
stateName: State,
value: string | boolean | number | string[],
) {
trackedElements.add(element);
if (Array.isArray(value)) {
value = value.join(' ');
}
const attrStateName = ARIA_PREFIX + stateName;
element.setAttribute(attrStateName, `${value}`);
element.setAttribute(maybePrefixAttribute(attrStateName), `${value}`);
}

/**
Expand All @@ -181,7 +199,7 @@
* @param stateName The state to clear from the provided element.
*/
export function clearState(element: Element, stateName: State) {
element.removeAttribute(ARIA_PREFIX + stateName);
element.removeAttribute(maybePrefixAttribute(ARIA_PREFIX + stateName));
}

/**
Expand All @@ -198,7 +216,12 @@
*/
export function getState(element: Element, stateName: State): string | null {
const attrStateName = ARIA_PREFIX + stateName;
return element.getAttribute(attrStateName);
return element.getAttribute(maybePrefixAttribute(attrStateName));
}

function getStates(element: Element): Map<State, string> {
const states = Object.values(State).filter((stateName) => element.hasAttribute(maybePrefixAttribute(ARIA_PREFIX + stateName)));

Check failure on line 223 in core/utils/aria.ts

View workflow job for this annotation

GitHub Actions / lint

Replace `·element.hasAttribute(maybePrefixAttribute(ARIA_PREFIX·+·stateName))` with `⏎····element.hasAttribute(maybePrefixAttribute(ARIA_PREFIX·+·stateName)),⏎··`
return new Map(states.map((stateName) => [stateName, element.getAttribute(maybePrefixAttribute(ARIA_PREFIX + stateName))!]));

Check failure on line 224 in core/utils/aria.ts

View workflow job for this annotation

GitHub Actions / lint

Replace `states.map((stateName)·=>·[stateName,·element.getAttribute(maybePrefixAttribute(ARIA_PREFIX·+·stateName))!])` with `⏎····states.map((stateName)·=>·[⏎······stateName,⏎······element.getAttribute(maybePrefixAttribute(ARIA_PREFIX·+·stateName))!,⏎····]),⏎··`
}

/**
Expand All @@ -224,3 +247,59 @@
}
ariaAnnouncementSpan.innerHTML = text;
}

function isInSynthesisMode() {
return bypassScreenreaderPrefix.length !== 0;
}

export function toggleSynthesisMode() {

Check warning on line 255 in core/utils/aria.ts

View workflow job for this annotation

GitHub Actions / lint

Missing JSDoc comment
if (!isInSynthesisMode()) {
resetSynthesisMode(true);
} else resetSynthesisMode(false);
}

function maybePrefixAttribute(attributeName: string, inSynthesisMode: boolean = isInSynthesisMode()): string {

Check failure on line 261 in core/utils/aria.ts

View workflow job for this annotation

GitHub Actions / lint

Replace `attributeName:·string,·inSynthesisMode:·boolean·=·isInSynthesisMode()` with `⏎··attributeName:·string,⏎··inSynthesisMode:·boolean·=·isInSynthesisMode(),⏎`
return inSynthesisMode ? bypassScreenreaderPrefix + attributeName : attributeName;

Check failure on line 262 in core/utils/aria.ts

View workflow job for this annotation

GitHub Actions / lint

Replace `·?·bypassScreenreaderPrefix·+·attributeName` with `⏎····?·bypassScreenreaderPrefix·+·attributeName⏎···`
}

function resetSynthesisMode(isEnabling: boolean) {
trimTrackedElements();
for (const element of trackedElements) {
// Replace the element's old role and ARIA states with the new ones.
updateSynthesisPrefix(!isEnabling);
const role = getRole(element);
const states = getStates(element);
setRole(element, null);
for (const stateName of states.keys()) clearState(element, stateName);
updateSynthesisPrefix(isEnabling);
setRole(element, role);
for (const [stateName, value] of states) setState(element, stateName, value);

Check failure on line 276 in core/utils/aria.ts

View workflow job for this annotation

GitHub Actions / lint

Insert `⏎·····`
}
updateSynthesisPrefix(isEnabling);
if (isEnabling) {
speechSynthesis.speak(new SpeechSynthesisUtterance("Enabling Blockly speech synthesis."));

Check failure on line 280 in core/utils/aria.ts

View workflow job for this annotation

GitHub Actions / lint

Replace `new·SpeechSynthesisUtterance("Enabling·Blockly·speech·synthesis.")` with `⏎······new·SpeechSynthesisUtterance('Enabling·Blockly·speech·synthesis.'),⏎····`
if (document.activeElement && trackedElements.has(document.activeElement)) {
maybeAnnounceFocusedNode(document.activeElement, false);
}
} else {
speechSynthesis.speak(new SpeechSynthesisUtterance("Disabling Blockly speech synthesis."));
}
}

function updateSynthesisPrefix(isEnabling: boolean) {
if (isEnabling) {
bypassScreenreaderPrefix = "blockly-synthesis-";
} else bypassScreenreaderPrefix = "";
}

function trimTrackedElements() {
const elementsToRemove = [];
for (const element of trackedElements) {
if (!element.parentNode) {
elementsToRemove.push(element);
}
}
for (const element of elementsToRemove) {
trackedElements.delete(element);
}
}
Loading