Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Changes to support VoiceOver #1132

Draft
wants to merge 1 commit into
base: develop
Choose a base branch
from
Draft
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
Changes to support VoiceOver
dpvc committed Sep 29, 2024
commit b4340887a193d0deebb8a7d711e4482420aa1c7f
57 changes: 52 additions & 5 deletions ts/a11y/explorer/KeyExplorer.ts
Original file line number Diff line number Diff line change
@@ -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();
91 changes: 81 additions & 10 deletions ts/a11y/semantic-enrich.ts
Original file line number Diff line number Diff line change
@@ -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<N, T, D> extends MathItem<N, T, D> {
*/
generatorPool: GeneratorPool<N, T, D>;

/**
* The MathML serializer
*/
toMathML: (node: MmlNode, math: MathItem<N, T, D>) => 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<N, T, D>();

/**
* 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<N, T, D>
): MathDocumentConstructor<EnrichedMathDocument<N, T, D>> & 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<AbstractMathItem<N, T, D>>
>(this.options.MathItem, MmlJax, toMathML);
if ('addStyles' in this) {
(this as any).addStyles((this.constructor as typeof BaseClass).speechStyles);
}
}

/**
22 changes: 14 additions & 8 deletions ts/a11y/speech/GeneratorPool.ts
Original file line number Diff line number Diff line change
@@ -217,7 +217,7 @@ export class GeneratorPool<N, T, D> {
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<N, T, D> {
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<N, T, D> {
* @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<N, T, D> {
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) {
8 changes: 5 additions & 3 deletions ts/output/chtml.ts
Original file line number Diff line number Diff line change
@@ -312,9 +312,11 @@ export class CHTML<N, T, D> 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) ]
);
}

/**
2 changes: 2 additions & 0 deletions ts/output/chtml/Wrapper.ts
Original file line number Diff line number Diff line change
@@ -315,6 +315,8 @@ export class ChtmlWrapper<N, T, D> 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]) {
6 changes: 5 additions & 1 deletion ts/output/chtml/Wrappers/TextNode.ts
Original file line number Diff line number Diff line change
@@ -186,7 +186,11 @@ export const ChtmlTextNode = (function <N, T, D>(): 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))]
)
);
2 changes: 2 additions & 0 deletions ts/output/svg.ts
Original file line number Diff line number Diff line change
@@ -537,6 +537,8 @@ export class SVG<N, T, D> 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)]
);