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
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
57 changes: 52 additions & 5 deletions ts/a11y/explorer/KeyExplorer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
*
Expand All @@ -211,6 +247,7 @@ export class SpeechExplorer
this.FocusOut(null);
}
this.current = clicked;
this.addSpeech(this.current);
if (!this.triggerLinkMouse()) {
this.Start();
}
Expand All @@ -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();
}
Expand All @@ -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();
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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();
Expand Down
91 changes: 81 additions & 10 deletions ts/a11y/semantic-enrich.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/*==========================================================================*/

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) || ''
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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
*/
Expand Down Expand Up @@ -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
*/
Expand Down Expand Up @@ -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);
}
}

/**
Expand Down
22 changes: 14 additions & 8 deletions ts/a11y/speech/GeneratorPool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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'));
}
Expand All @@ -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;
}

Expand Down Expand Up @@ -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) {
Expand Down
8 changes: 5 additions & 3 deletions ts/output/chtml.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) ]
);
}

/**
Expand Down
2 changes: 2 additions & 0 deletions ts/output/chtml/Wrapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]) {
Expand Down
6 changes: 5 additions & 1 deletion ts/output/chtml/Wrappers/TextNode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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))]
)
);
Expand Down
2 changes: 2 additions & 0 deletions ts/output/svg.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
);
Expand Down