diff --git a/ts/a11y/explorer.ts b/ts/a11y/explorer.ts
index 89c03b0d3..18965b5cd 100644
--- a/ts/a11y/explorer.ts
+++ b/ts/a11y/explorer.ts
@@ -80,6 +80,11 @@ export interface ExplorerMathItem extends HTMLMATHITEM {
*/
none: string;
+ /**
+ * The string to use for when there is no Braille description;
+ */
+ brailleNone: string;
+
/**
* The Explorer objects for this math item
*/
@@ -138,6 +143,11 @@ export function ExplorerMathItemMixin>(
*/
protected static none: string = '\u0091';
+ /**
+ * Braille decription to use when set to none
+ */
+ protected static brailleNone: string = '\u2800';
+
public get ariaRole() {
return (this.constructor as typeof BaseClass).ariaRole;
}
@@ -153,6 +163,10 @@ export function ExplorerMathItemMixin>(
return (this.constructor as typeof BaseClass).none;
}
+ public get brailleNone() {
+ return (this.constructor as typeof BaseClass).brailleNone;
+ }
+
/**
* @override
*/
@@ -351,6 +365,8 @@ export function ExplorerMathDocumentMixin<
treeColoring: false, // tree color expression
viewBraille: false, // display Braille output as subtitles
voicing: false, // switch on speech output
+ brailleSpeech: false, // use aria-label for Braille
+ brailleCombine: false, // combine Braille with speech output
help: true, // include "press h for help" messages on focus
roleDescription: 'math', // the role description to use for math expressions
tabSelects: 'all', // 'all' for whole expression, 'last' for last explored node
diff --git a/ts/a11y/explorer/KeyExplorer.ts b/ts/a11y/explorer/KeyExplorer.ts
index d9b7fb3ec..1f1622f5d 100644
--- a/ts/a11y/explorer/KeyExplorer.ts
+++ b/ts/a11y/explorer/KeyExplorer.ts
@@ -34,6 +34,10 @@ import { InfoDialog } from '../../ui/dialog/InfoDialog.js';
/**********************************************************************/
+const isWindows = context.os === 'Windows';
+
+const BRAILLE_PADDING = Array(40).fill('\u2800').join('');
+
/**
* Interface for keyboard explorers. Adds the necessary keyboard events.
*
@@ -125,11 +129,16 @@ export class SpeechExplorer
/**
* Creates a customized help dialog
*
- * @param {string} title The title to use for the message
- * @param {string} select Additional ways to select the typeset math
- * @returns {string} The customized message
- */
- protected static helpMessage(title: string, select: string): string {
+ * @param {string} title The title to use for the message
+ * @param {string} select Additional ways to select the typeset math
+ * @param {string} braille Additional Braille information
+ * @returns {string} The customized message
+ */
+ protected static helpMessage(
+ title: string,
+ select: string,
+ braille: string
+ ): string {
return `
Exploring expressions ${title}
@@ -205,6 +214,10 @@ export class SpeechExplorer
< cycles through the verbosity levels
for the current rule set.
+ b toggles whether Braille notation is combined
+ with speech text for tactile Braille devices, as discussed
+ below.
+
h produces this help listing.
@@ -218,6 +231,13 @@ export class SpeechExplorer
speech and Braille will disable the expression explorer, its
highlighting, and its help icon.
+ Support for tactile Braille devices varies across screen readers,
+ browsers, and operative systems. If you are using a Braille output
+ device, you may need to select the "Combine with Speech" option in the
+ contextual menu's Braille submenu in order to obtain Nemeth or Euro
+ Braille output rather than the speech text on your Braille
+ device. ${braille}
+
The contextual menu also provides options for viewing or copying a
MathML version of the expression or its original source format,
creating an SVG version of the expression, and viewing various other
@@ -239,12 +259,13 @@ export class SpeechExplorer
/**
* Help for the different OS versions
*/
- protected static helpData: Map = new Map([
+ protected static helpData: Map = new Map([
[
'MacOS',
[
'on MacOS and iOS using VoiceOver',
', or the VoiceOver arrow keys to select an expression',
+ '',
],
],
[
@@ -258,6 +279,8 @@ export class SpeechExplorer
the NVDA or JAWS key plus the arrow keys to explore the expression
even in browse mode, and you can use NVDA+shift+arrow keys to
navigate out of an expression that has the focus in NVDA`,
+ `NVDA users need to select this option, while JAWS users should be able
+ to get Braille output without changing this setting.`,
],
],
[
@@ -267,9 +290,10 @@ export class SpeechExplorer
`, and Orca should enter focus mode automatically. If not, use the
Orca+a key to toggle focus mode on or off. Also note that you can use
Orca+arrow keys to explore expressions even in browse mode`,
+ '',
],
],
- ['unknown', ['with a Screen Reader.', '']],
+ ['unknown', ['with a Screen Reader.', '', '']],
]);
/*
@@ -304,6 +328,7 @@ export class SpeechExplorer
['p', [(explorer) => explorer.prevMark(), false]],
['u', [(explorer) => explorer.clearMarks(), false]],
['s', [(explorer) => explorer.autoVoice(), false]],
+ ['b', [(explorer) => explorer.toggleBraille(), false]],
...[...'0123456789'].map((n) => [
n,
[(explorer: SpeechExplorer) => explorer.numberKey(parseInt(n)), false],
@@ -348,7 +373,18 @@ export class SpeechExplorer
* @returns {string} The string to use for no description
*/
protected get none(): string {
- return this.item.none;
+ return this.document.options.a11y.brailleSpeech
+ ? this.item.brailleNone
+ : this.item.none;
+ }
+
+ /**
+ * Shorthand for the item's "brailleNone" indicator
+ *
+ * @returns {string} The string to use for no description
+ */
+ protected get brailleNone(): string {
+ return this.item.brailleNone;
}
/**
@@ -665,6 +701,7 @@ export class SpeechExplorer
protected escapeKey(): boolean {
this.Stop();
this.focusTop();
+ this.setCurrent(null);
return true;
}
@@ -873,6 +910,15 @@ export class SpeechExplorer
this.Update();
}
+ protected toggleBraille() {
+ const value = !this.document.options.a11y.brailleCombine;
+ if (this.document.menu) {
+ this.document.menu.menu.pool.lookup('brailleCombine').setValue(value);
+ } else {
+ this.document.options.a11y.brailleCombine = value;
+ }
+ }
+
/**
* Get index for cell to jump to.
*
@@ -1018,10 +1064,10 @@ export class SpeechExplorer
return;
}
const CLASS = this.constructor as typeof SpeechExplorer;
- const [title, select] = CLASS.helpData.get(context.os);
+ const [title, select, braille] = CLASS.helpData.get(context.os);
InfoDialog.post({
title: 'MathJax Expression Explorer Help',
- message: CLASS.helpMessage(title, select),
+ message: CLASS.helpMessage(title, select, braille),
node: this.node,
adaptor: this.document.adaptor,
styles: {
@@ -1219,7 +1265,7 @@ export class SpeechExplorer
*/
protected removeSpeech() {
if (this.speech) {
- this.speech.remove();
+ this.unspeak(this.speech);
this.speech = null;
if (this.img) {
this.node.append(this.img);
@@ -1246,28 +1292,56 @@ export class SpeechExplorer
description: string = this.none
) {
const oldspeech = this.speech;
- this.speech = document.createElement('mjx-speech');
- this.speech.setAttribute('role', this.role);
- this.speech.setAttribute('aria-label', speech);
- this.speech.setAttribute(SemAttr.SPEECH, speech);
+ const speechNode = (this.speech = document.createElement('mjx-speech'));
+ speechNode.setAttribute('role', this.role);
+ speechNode.setAttribute('aria-label', speech || this.none);
+ speechNode.setAttribute('aria-roledescription', description || this.none);
+ speechNode.setAttribute(SemAttr.SPEECH, speech);
if (ssml) {
- this.speech.setAttribute(SemAttr.PREFIX_SSML, ssml[0] || '');
- this.speech.setAttribute(SemAttr.SPEECH_SSML, ssml[1] || '');
- this.speech.setAttribute(SemAttr.POSTFIX_SSML, ssml[2] || '');
+ speechNode.setAttribute(SemAttr.PREFIX_SSML, ssml[0] || '');
+ speechNode.setAttribute(SemAttr.SPEECH_SSML, ssml[1] || '');
+ speechNode.setAttribute(SemAttr.POSTFIX_SSML, ssml[2] || '');
}
if (braille) {
- this.speech.setAttribute('aria-braillelabel', braille);
+ if (this.document.options.a11y.brailleSpeech) {
+ speechNode.setAttribute('aria-label', braille);
+ speechNode.setAttribute('aria-roledescription', this.brailleNone);
+ }
+ speechNode.setAttribute('aria-braillelabel', braille);
+ speechNode.setAttribute('aria-brailleroledescription', this.brailleNone);
+ if (this.document.options.a11y.brailleCombine) {
+ speechNode.setAttribute(
+ 'aria-label',
+ braille + BRAILLE_PADDING + speech
+ );
+ }
+ }
+ speechNode.setAttribute('tabindex', '0');
+ if (isWindows) {
+ const container = document.createElement('mjx-speech-container');
+ container.setAttribute('role', 'application');
+ container.setAttribute('aria-roledescription', this.none);
+ container.setAttribute('aria-brailleroledescription', this.brailleNone);
+ container.append(speechNode);
+ this.node.append(container);
+ speechNode.setAttribute('role', 'img');
+ } else {
+ this.node.append(speechNode);
}
- this.speech.setAttribute('aria-roledescription', description);
- this.speech.setAttribute('tabindex', '0');
- this.node.append(this.speech);
this.focusSpeech = true;
- this.speech.focus();
+ speechNode.focus();
this.focusSpeech = false;
this.Update();
if (oldspeech) {
- setTimeout(() => oldspeech.remove(), 100);
+ setTimeout(() => this.unspeak(oldspeech), 100);
+ }
+ }
+
+ public unspeak(node: HTMLElement) {
+ if (isWindows) {
+ node = node.parentElement;
}
+ node.remove();
}
/**
@@ -1292,6 +1366,18 @@ export class SpeechExplorer
role: 'img',
'aria-roledescription': item.none,
});
+ const braille = container.getAttribute(SemAttr.BRAILLE);
+ if (braille) {
+ if (this.document.options.a11y.brailleSpeech) {
+ this.img.setAttribute('aria-label', braille);
+ this.img.setAttribute('aria-roledescription', this.brailleNone);
+ }
+ this.img.setAttribute('aria-braillelabel', braille);
+ this.img.setAttribute('aria-brailleroledescription', this.brailleNone);
+ if (this.document.options.a11y.brailleCombine) {
+ this.img.setAttribute('aria-label', braille + BRAILLE_PADDING + speech);
+ }
+ }
container.appendChild(this.img);
this.adjustAnchors();
}
@@ -1779,10 +1865,6 @@ export class SpeechExplorer
*/
public Stop() {
if (this.active) {
- const description = this.description;
- if (this.node.getAttribute('aria-roledescription') !== description) {
- this.node.setAttribute('aria-roledescription', description);
- }
this.node.classList.remove('mjx-explorer-active');
if (this.document.options.enableExplorerHelp) {
this.document.infoIcon.remove();
diff --git a/ts/ui/menu/Menu.ts b/ts/ui/menu/Menu.ts
index acfeec0fc..8a017926d 100644
--- a/ts/ui/menu/Menu.ts
+++ b/ts/ui/menu/Menu.ts
@@ -91,6 +91,8 @@ export interface MenuSettings {
backgroundOpacity: string;
braille: boolean;
brailleCode: string;
+ brailleSpeech: boolean;
+ brailleCombine: boolean;
foregroundColor: string;
foregroundOpacity: string;
highlight: string;
@@ -156,6 +158,8 @@ export class Menu {
speech: true,
braille: true,
brailleCode: 'nemeth',
+ brailleSpeech: false,
+ brailleCombine: false,
speechRules: 'clearspeak-default',
roleDescription: 'math',
tabSelects: 'all',
@@ -619,6 +623,12 @@ export class Menu {
this.variable('brailleCode', (code) =>
this.setBrailleCode(code)
),
+ this.a11yVar('brailleSpeech', (speech) =>
+ this.setBrailleSpeech(speech)
+ ),
+ this.a11yVar('brailleCombine', (speech) =>
+ this.setBrailleCombine(speech)
+ ),
this.a11yVar('highlight', (value) => this.setHighlight(value)),
this.a11yVar('backgroundColor'),
this.a11yVar('backgroundOpacity', (value) =>
@@ -804,6 +814,14 @@ export class Menu {
this.submenu('Braille', '\xA0 \xA0 Braille', [
this.checkbox('Generate', 'Generate', 'braille'),
this.checkbox('Subtitles', 'Show Subtitles', 'viewBraille'),
+ this.checkbox('BrailleSpeech', 'Replace Speech', 'brailleSpeech', {
+ hidden: true,
+ }),
+ this.checkbox(
+ 'BrailleCombine',
+ 'Combine with Speech',
+ 'brailleCombine'
+ ),
this.rule(),
this.label('Code', 'Code Format:'),
this.radioGroup('brailleCode', [
@@ -1246,10 +1264,17 @@ export class Menu {
protected setSpeech(speech: boolean) {
this.enableAccessibilityItems('Speech', speech);
this.document.options.enableSpeech = speech;
- if (speech && this.settings.assistiveMml) {
- this.noRerender(() =>
- this.menu.pool.lookup('assistiveMml').setValue(false)
- );
+ if (speech) {
+ if (this.settings.assistiveMml) {
+ this.noRerender(() =>
+ this.menu.pool.lookup('assistiveMml').setValue(false)
+ );
+ }
+ if (this.settings.brailleSpeech) {
+ this.noRerender(() =>
+ this.menu.pool.lookup('brailleSpeech').setValue(false)
+ );
+ }
}
if (!speech || MathJax._?.a11y?.explorer) {
this.rerender(STATE.COMPILED);
@@ -1284,6 +1309,32 @@ export class Menu {
this.rerender(STATE.COMPILED);
}
+ /**
+ * @param {boolean} speech Whether to use aria-label for Braille
+ */
+ protected setBrailleSpeech(speech: boolean) {
+ if (speech && this.settings.speech) {
+ this.noRerender(() => this.menu.pool.lookup('speech').setValue(false));
+ } else {
+ this.enableAccessibilityItems('Speech', true);
+ }
+ this.settings.brailleCombine = this.document.options.a11y.brailleCombine =
+ false;
+ this.rerender(STATE.COMPILED);
+ }
+
+ /**
+ * @param {boolean} _speech Whether to combine Braille into aria-label
+ */
+ protected setBrailleCombine(_speech: boolean) {
+ if (this.settings.brailleSpeech) {
+ this.menu.pool.lookup('brailleSpeech').setValue(false);
+ }
+ this.settings.brailleSpeech = this.document.options.a11y.brailleSpeech =
+ false;
+ this.rerender(STATE.COMPILED);
+ }
+
/**
* @param {string} locale The speech locale
*/