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 */