diff --git a/ts/a11y/explorer/KeyExplorer.ts b/ts/a11y/explorer/KeyExplorer.ts index d0f3a2ee8..a3a486f16 100644 --- a/ts/a11y/explorer/KeyExplorer.ts +++ b/ts/a11y/explorer/KeyExplorer.ts @@ -79,9 +79,8 @@ export interface KeyExplorer extends Explorer { /** * Selectors for walking. */ -const roles = ['tree', 'group', 'treeitem']; -const nav = roles.map((x) => `[role="${x}"]`).join(','); -const prevNav = roles.map((x) => `[tabindex="0"][role="${x}"]`).join(','); +const nav = '[data-speech-node]'; +const prevNav = `[tabindex="0"]${nav}`; /** * Predicate to check if element is a MJX container. @@ -188,6 +187,43 @@ export class SpeechExplorer document.getSelection()?.removeAllRanges(); } + /** + * Makes the speech and Braille available, sets the role, and hides the children. + * + * @param {Element} node The node to set up for speech + */ + protected addSpeech(node: Element) { + if (this.generators.options.enableSpeech && node.hasAttribute('data-semantic-label')) { + node.setAttribute('aria-label', node.getAttribute('data-semantic-label')); + } + if (this.generators.options.enableBraille && node.hasAttribute('data-semantic-braille')) { + node.setAttribute('aria-braillelabel', node.getAttribute('data-semantic-braille')); + } + node.removeAttribute('aria-hidden'); + node.setAttribute('role', this.item.ariaRole); + node.setAttribute('aria-roledescription', this.item.roleDescription); + for (const child of Array.from(node.childNodes) as HTMLElement[]) { + child.setAttribute('aria-hidden', 'true'); + } + } + + /** + * Removes the speech, Braille, and role, and unhides the children. + * + * @param {Element} node The node to clean up for speech + */ + protected removeSpeech(node: Element) { + node.removeAttribute('aria-label'); + node.removeAttribute('aria-braillelabel'); + node.removeAttribute('role'); + node.removeAttribute('aria-roledescription'); + for (const child of Array.from(node.childNodes) as HTMLElement[]) { + if (!child.hasAttribute('data-keep-hidden')) { + child.removeAttribute('aria-hidden'); + } + } + } + /** * Moves on mouse click to the closest clicked element. * @@ -211,6 +247,7 @@ export class SpeechExplorer this.FocusOut(null); } this.current = clicked; + this.addSpeech(this.current); if (!this.triggerLinkMouse()) { this.Start(); } @@ -226,7 +263,7 @@ export class SpeechExplorer this.mousedown = false; return; } - this.current = this.current || this.node.querySelector('[role="treeitem"]'); + this.current = this.current || this.node.querySelector(nav); this.Start(); event.preventDefault(); } @@ -239,6 +276,7 @@ export class SpeechExplorer // This guard is to FF and Safari, where focus in fired only once on // keyboard. if (!this.active) return; + this.removeSpeech(this.current); this.generators.CleanUp(this.current); if (!this.move) { this.Stop(); @@ -447,6 +485,8 @@ export class SpeechExplorer if (next) { target.removeAttribute('tabindex'); next.setAttribute('tabindex', '0'); + this.removeSpeech(this.current); + this.addSpeech(next); next.focus(); this.current = next; this.move = false; @@ -538,6 +578,9 @@ export class SpeechExplorer if (this.generators.update(options)) { promise = promise.then(() => this.Speech()); } + this.removeSpeech(this.node); + this.addSpeech(this.current); + this.item.speechNode.setAttribute('aria-hidden', 'true'); this.current.setAttribute('tabindex', '0'); this.current.focus(); super.Start(); @@ -618,7 +661,7 @@ export class SpeechExplorer } if (!this.active) { if (!this.current) { - this.current = this.node.querySelector('[role="treeitem"]'); + this.current = this.node.querySelector(nav); } this.Start(); this.stopEvent(event); @@ -692,6 +735,10 @@ export class SpeechExplorer */ public Stop() { if (this.active) { + this.addSpeech(this.node); + this.node.removeAttribute('role'); + this.item.speechNode.removeAttribute('aria-hidden'); + this.current = null; // re-enter at top node this.pool.unhighlight(); this.magnifyRegion.Hide(); this.region.Hide(); diff --git a/ts/a11y/semantic-enrich.ts b/ts/a11y/semantic-enrich.ts index ca7af2e30..05d3e72dc 100644 --- a/ts/a11y/semantic-enrich.ts +++ b/ts/a11y/semantic-enrich.ts @@ -41,6 +41,7 @@ import { OptionList, expandable } from '../util/Options.js'; import { Sre } from './sre.js'; import { buildSpeech } from './speech/SpeechUtil.js'; import { GeneratorPool } from './speech/GeneratorPool.js'; +import { StyleList } from '../util/StyleList.js'; /*==========================================================================*/ @@ -119,6 +120,26 @@ export interface EnrichedMathItem extends MathItem { */ generatorPool: GeneratorPool; + /** + * The MathML serializer + */ + toMathML: (node: MmlNode, math: MathItem) => string; + + /** + * The visually hidden speech string + */ + speechNode: N; + + /** + * The rol to use for navigation + */ + ariaRole: string; + + /** + * The term to use for the role description + */ + roleDescription: string; + /** * @param {MathDocument} document The document where enrichment is occurring * @param {boolean} force True to force the enrichment even if not enabled @@ -166,10 +187,29 @@ export function EnrichedMathItemMixin< public generatorPool = new GeneratorPool(); /** - * The MathML serializer + * @override */ public toMathML = toMathML; + /** + * @override + */ + public speechNode: N; + + /** + * @override + */ + public get ariaRole() { + return 'math'; // use 'button' or 'img' to avoid voicing of role description + } + + /** + * @override + */ + public get roleDescription() { + return 'MathJax expression'; + } + /** * @param {any} node The node to be serialized * @returns {string} The serialized version of node @@ -266,7 +306,7 @@ export function EnrichedMathItemMixin< */ protected existingSpeech(): [string, string] { const attributes = this.root.attributes; - let speech = attributes.get('aria-label') as string; + let speech = (attributes.get('aria-label') || attributes.get('data-semantic-label')) as string; if (!speech) { speech = buildSpeech( (attributes.get('data-semantic-speech') as string) || '' @@ -311,17 +351,31 @@ export function EnrichedMathItemMixin< if (!speech && !braille) return; const adaptor = document.adaptor; const node = this.typesetRoot; + adaptor.setAttribute(node, 'has-speech', 'true'); + for (const child of adaptor.childNodes(node) as N[]) { + adaptor.setAttribute(child, 'aria-hidden', 'true'); + } + this.speechNode = adaptor.node( + 'mjx-speech', + { role: this.ariaRole, 'aria-roledescription': this.roleDescription } + ); + adaptor.insert(this.speechNode, adaptor.firstChild(node)); if (speech && options.enableSpeech) { - adaptor.setAttribute(node, 'aria-label', speech as string); - this.root.attributes.set('aria-label', speech); + adaptor.setAttribute(this.speechNode, 'aria-label', speech); + this.root.attributes.set('data-aria-label', speech); } if (braille && options.enableBraille) { - adaptor.setAttribute(node, 'aria-braillelabel', braille as string); - this.root.attributes.set('aria-braillelabel', braille); - } - for (const child of adaptor.childNodes(node) as N[]) { - adaptor.setAttribute(child, 'aria-hidden', 'true'); + adaptor.setAttribute(this.speechNode, 'aria-braillelabel', braille); + this.root.attributes.set('data-aria-braille', braille); } +for (const child of (node as any).querySelectorAll('[role="treeitem"]')) { + adaptor.setAttribute(child, 'data-speech-node', 'true'); + adaptor.removeAttribute(child, 'role'); + adaptor.removeAttribute(child, 'aria-posinset'); + adaptor.removeAttribute(child, 'aria-level'); + adaptor.removeAttribute(child, 'aria-owns'); + adaptor.removeAttribute(child, 'aria-setsize'); +} this.outputData.speech = speech; this.outputData.braille = braille; } @@ -397,7 +451,7 @@ export function EnrichedMathDocumentMixin< BaseDocument: B, MmlJax: MathML ): MathDocumentConstructor> & B { - return class extends BaseDocument { + return class BaseClass extends BaseDocument { /** * @override */ @@ -443,6 +497,20 @@ export function EnrichedMathDocumentMixin< }), }; + public static speechStyles: StyleList = { + 'mjx-container[has-speech="true"]': { + position: 'relative' + }, + 'mjx-speech': { + position: 'absolute', + 'z-index': -1, + left: 0, + top: 0, + bottom: 0, + right: 0 + } + } + /** * The list of MathItems that need to be processed for speech */ @@ -483,6 +551,9 @@ export function EnrichedMathDocumentMixin< D, Constructor> >(this.options.MathItem, MmlJax, toMathML); + if ('addStyles' in this) { + (this as any).addStyles((this.constructor as typeof BaseClass).speechStyles); + } } /** diff --git a/ts/a11y/speech/GeneratorPool.ts b/ts/a11y/speech/GeneratorPool.ts index 8145bb5d1..117bdcca0 100644 --- a/ts/a11y/speech/GeneratorPool.ts +++ b/ts/a11y/speech/GeneratorPool.ts @@ -217,7 +217,7 @@ export class GeneratorPool { this.adaptor.setAttribute( node, 'aria-label', - buildSpeech(this.getLabel(node))[0] + this.adaptor.getAttribute(node, 'data-semantic-label') ); } this.lastMove = InPlace.NONE; @@ -265,9 +265,14 @@ export class GeneratorPool { speechRegion: LiveRegion, brailleRegion: LiveRegion ) { + const adaptor = this.adaptor; const speech = this.getLabel(node, this.lastSpeech); speechRegion.Update(speech); - this.adaptor.setAttribute(node, 'aria-label', buildSpeech(speech)[0]); + const label = buildSpeech(speech)[0]; + adaptor.setAttribute(node, 'data-semantic-label', label); + if (adaptor.hasAttribute(node, 'aria-label')) { + adaptor.setAttribute(node, 'aria-label', label); + } this.lastSpeech = ''; brailleRegion.Update(this.adaptor.getAttribute(node, 'aria-braillelabel')); } @@ -279,11 +284,15 @@ export class GeneratorPool { * @returns {string} The aria label to speak. */ public updateSpeech(node: N): string { + const adaptor = this.adaptor; const xml = this.prepareXml(node); const speech = this.speechGenerator.getSpeech(xml, this.element); this.setAria(node, xml, this.options.sre.locale); const label = buildSpeech(speech)[0]; - this.adaptor.setAttribute(node, 'aria-label', label); + adaptor.setAttribute(node, 'data-semantic-label', label); + if (adaptor.hasAttribute(node, 'aria-label')) { + adaptor.setAttribute(node, 'aria-label', label); + } return label; } @@ -404,11 +413,8 @@ export class GeneratorPool { if (this.options.a11y.speech) { const speech = this.getLabel(node); if (speech) { - this.adaptor.setAttribute( - node, - 'aria-label', - buildSpeech(speech, locale)[0] - ); + const label = buildSpeech(speech, locale)[0]; + this.adaptor.setAttribute(node, 'data-semantic-label', label); } } if (this.options.a11y.braille) { diff --git a/ts/output/chtml.ts b/ts/output/chtml.ts index d35b798c1..cd6a1cd02 100644 --- a/ts/output/chtml.ts +++ b/ts/output/chtml.ts @@ -312,9 +312,11 @@ export class CHTML extends CommonOutputJax< styles.width = this.fixed(width * this.math.metrics.scale) + 'em'; } // - return this.html('mjx-utext', { variant: variant, style: styles }, [ - this.text(text), - ]); + return this.html( + 'mjx-utext', + { variant: variant, style: styles, 'aria-hidden': 'true', 'data-keep-hidden': 'true' }, + [ this.text(text) ] + ); } /** diff --git a/ts/output/chtml/Wrapper.ts b/ts/output/chtml/Wrapper.ts index 8b9de8bcc..38362d429 100644 --- a/ts/output/chtml/Wrapper.ts +++ b/ts/output/chtml/Wrapper.ts @@ -315,6 +315,8 @@ export class ChtmlWrapper extends CommonWrapper< : { style: `letter-spacing: ${this.em(dimen - 1)}` }, [adaptor.text(' ')] ); + adaptor.setAttribute(node, 'aria-hidden', 'true'); + adaptor.setAttribute(node, 'data-keep-hidden', 'true'); adaptor.insert(node, this.dom[i]); } else if (dimen) { if (SPACE[space]) { diff --git a/ts/output/chtml/Wrappers/TextNode.ts b/ts/output/chtml/Wrappers/TextNode.ts index fc18c8481..3478f6b02 100644 --- a/ts/output/chtml/Wrappers/TextNode.ts +++ b/ts/output/chtml/Wrappers/TextNode.ts @@ -186,7 +186,11 @@ export const ChtmlTextNode = (function (): ChtmlTextNodeClass< parent, this.html( 'mjx-c', - { class: this.char(n) + (font ? ' ' + font : '') }, + { + class: this.char(n) + (font ? ' ' + font : ''), + 'aria-hidden': 'true', + 'data-keep-hidden': 'true' + }, [this.text(data.c || String.fromCodePoint(n))] ) ); diff --git a/ts/output/svg.ts b/ts/output/svg.ts index 6a1f0d11b..4a4e82fbf 100644 --- a/ts/output/svg.ts +++ b/ts/output/svg.ts @@ -537,6 +537,8 @@ export class SVG extends CommonOutputJax< 'data-variant': variant, transform: 'scale(1,-1)', 'font-size': this.fixed(scale, 1) + 'px', + 'aria-hidden': 'true', + 'data-keep-hidden': 'true' }, [this.text(text)] );