Skip to content

Commit 83efcdf

Browse files
rtibblesbotclaude
andcommitted
feat(a11y): localize mathlive screen reader announcements (#5743)
Monkey-patch mathlive's hardcoded English screen reader announcements to support localization for all Studio-supported locales. This is a temporary workaround until the upstream fix lands (arnog/mathlive#2948). - Add MathLiveA11yStrings.js with translatable strings for all known mathlive announcement patterns (prefixes and relation names) - Add mathLiveA11yLocalize.js with regex-based post-processing to replace English patterns with translated equivalents - Add useMathLiveA11yAnnounce.js composable using MutationObserver to intercept and localize aria-live region updates - Wire composable into FormulasMenu.vue alongside existing locale setup - Add comprehensive unit tests for localization logic and observer behavior Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent e19fac7 commit 83efcdf

6 files changed

Lines changed: 625 additions & 0 deletions

File tree

contentcuration/contentcuration/frontend/shared/views/TipTapEditor/TipTapEditor/components/math/FormulasMenu.vue

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,7 @@
112112
import { getTipTapEditorStrings } from '../../TipTapEditorStrings';
113113
import { useMathLiveLocale } from '../../composables/useMathLiveLocale';
114114
import { getFormulasStrings } from './FormulasStrings';
115+
import { useMathLiveA11yAnnounce } from './useMathLiveA11yAnnounce';
115116
import symbolsData from './symbols.json';
116117
117118
export default defineComponent({
@@ -252,6 +253,10 @@
252253
const currentLocale = ref(navigator.language || 'en');
253254
useMathLiveLocale(currentLocale);
254255
256+
// TEMPORARY WORKAROUND: Localize mathlive screen reader announcements
257+
// Remove when upstream fix lands: https://github.com/arnog/mathlive/issues/2948
258+
useMathLiveA11yAnnounce(mathfieldEl);
259+
255260
return {
256261
rootEl,
257262
mathfieldEl,
Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
import { createTranslator } from 'shared/i18n';
2+
3+
// TEMPORARY WORKAROUND: These strings localize mathlive's hardcoded English
4+
// screen reader announcements. Remove when upstream fix lands:
5+
// https://github.com/arnog/mathlive/issues/2948
6+
7+
const ACTION_MESSAGES = {
8+
deleted: {
9+
message: 'deleted: ',
10+
context:
11+
'Screen reader announcement prefix when a math element is deleted. The trailing space and colon are intentional formatting.',
12+
},
13+
selected: {
14+
message: 'selected: ',
15+
context:
16+
'Screen reader announcement prefix when math content is selected. The trailing space and colon are intentional formatting.',
17+
},
18+
startOf: {
19+
message: 'start of {relationName}: ',
20+
context:
21+
'Screen reader announcement when cursor enters the beginning of a math structure (e.g. "start of fraction: "). {relationName} is the name of the math structure.',
22+
},
23+
endOf: {
24+
message: '{spokenText}; end of {relationName}',
25+
context:
26+
'Screen reader announcement when cursor reaches the end of a math structure (e.g. "2; end of fraction"). {spokenText} is the current element, {relationName} is the structure name.',
27+
},
28+
outOf: {
29+
message: 'out of {relationName};',
30+
context:
31+
'Screen reader announcement when cursor exits a math structure (e.g. "out of fraction;"). {relationName} is the name of the math structure being exited.',
32+
},
33+
endOfMathfield: {
34+
message: '{spokenText}; end of mathfield',
35+
context:
36+
'Screen reader announcement when cursor reaches the end of the entire math input field. {spokenText} is the current element.',
37+
},
38+
};
39+
40+
// Relation names from mathlive's relationName function.
41+
// Keys are the camelCase version of the English relation name string.
42+
const RELATION_MESSAGES = {
43+
accented: {
44+
message: 'accented',
45+
context: 'Screen reader name for an accented math element',
46+
},
47+
array: {
48+
message: 'array',
49+
context: 'Screen reader name for a math array/matrix',
50+
},
51+
box: {
52+
message: 'box',
53+
context: 'Screen reader name for a boxed math element',
54+
},
55+
chemicalFormula: {
56+
message: 'chemical formula',
57+
context: 'Screen reader name for a chemical formula element',
58+
},
59+
delimiter: {
60+
message: 'delimiter',
61+
context: 'Screen reader name for a math delimiter (parentheses, brackets, etc.)',
62+
},
63+
crossOut: {
64+
message: 'cross out',
65+
context: 'Screen reader name for a crossed-out/enclosed math element',
66+
},
67+
extensibleSymbol: {
68+
message: 'extensible symbol',
69+
context: 'Screen reader name for an extensible math symbol',
70+
},
71+
error: {
72+
message: 'error',
73+
context: 'Screen reader name for a math error element',
74+
},
75+
first: {
76+
message: 'first',
77+
context: 'Screen reader name for the first element in a math structure',
78+
},
79+
fraction: {
80+
message: 'fraction',
81+
context: 'Screen reader name for a fraction element',
82+
},
83+
group: {
84+
message: 'group',
85+
context: 'Screen reader name for a grouped math element',
86+
},
87+
latex: {
88+
message: 'LaTeX',
89+
context: 'Screen reader name for a raw LaTeX element',
90+
},
91+
line: {
92+
message: 'line',
93+
context: 'Screen reader name for a line element',
94+
},
95+
subscriptSuperscript: {
96+
message: 'subscript-superscript',
97+
context: 'Screen reader name for a combined subscript-superscript element',
98+
},
99+
operator: {
100+
message: 'operator',
101+
context: 'Screen reader name for a math operator',
102+
},
103+
overUnder: {
104+
message: 'over-under',
105+
context: 'Screen reader name for an over-under math element',
106+
},
107+
placeholder: {
108+
message: 'placeholder',
109+
context: 'Screen reader name for a placeholder element in a math template',
110+
},
111+
rule: {
112+
message: 'rule',
113+
context: 'Screen reader name for a rule/line element',
114+
},
115+
space: {
116+
message: 'space',
117+
context: 'Screen reader name for a space element',
118+
},
119+
spacing: {
120+
message: 'spacing',
121+
context: 'Screen reader name for a spacing element',
122+
},
123+
squareRoot: {
124+
message: 'square root',
125+
context: 'Screen reader name for a square root element',
126+
},
127+
text: {
128+
message: 'text',
129+
context: 'Screen reader name for a text element within math',
130+
},
131+
prompt: {
132+
message: 'prompt',
133+
context: 'Screen reader name for a prompt element',
134+
},
135+
mathField: {
136+
message: 'math field',
137+
context: 'Screen reader name for the math field root element',
138+
},
139+
// "mathfield" (one word) is also used by mathlive as an alias
140+
mathfield: {
141+
message: 'math field',
142+
context: 'Screen reader name for the math field root element (one-word variant)',
143+
},
144+
parent: {
145+
message: 'parent',
146+
context: 'Screen reader name for a generic parent math element',
147+
},
148+
numerator: {
149+
message: 'numerator',
150+
context: 'Screen reader name for the numerator of a fraction',
151+
},
152+
denominator: {
153+
message: 'denominator',
154+
context: 'Screen reader name for the denominator of a fraction',
155+
},
156+
index: {
157+
message: 'index',
158+
context: 'Screen reader name for the index of a root/radical',
159+
},
160+
superscript: {
161+
message: 'superscript',
162+
context: 'Screen reader name for a superscript element',
163+
},
164+
subscript: {
165+
message: 'subscript',
166+
context: 'Screen reader name for a subscript element',
167+
},
168+
radicand: {
169+
message: 'radicand',
170+
context: 'Screen reader name for the body content inside a square root',
171+
},
172+
superscriptAndSubscript: {
173+
message: 'superscript and subscript',
174+
context: 'Screen reader name for a combined superscript-and-subscript element',
175+
},
176+
};
177+
178+
// English relation name strings derived from RELATION_MESSAGES, sorted longest-first
179+
// to prevent partial regex matches (e.g. "superscript and subscript" before "superscript")
180+
export const RELATION_NAMES = Object.values(RELATION_MESSAGES)
181+
.map(m => m.message)
182+
.filter((v, i, a) => a.indexOf(v) === i) // dedupe ("math field" appears twice)
183+
.sort((a, b) => b.length - a.length);
184+
185+
const MESSAGES = { ...ACTION_MESSAGES, ...RELATION_MESSAGES };
186+
187+
export default createTranslator('MathLiveA11yStrings', MESSAGES);
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
/**
2+
* TEMPORARY WORKAROUND: Localizes mathlive's hardcoded English screen reader
3+
* announcements by post-processing the announcement text.
4+
*
5+
* Remove when upstream fix lands:
6+
* https://github.com/arnog/mathlive/issues/2948
7+
*/
8+
9+
import translator, { RELATION_NAMES } from './MathLiveA11yStrings';
10+
11+
/**
12+
* Convert an English relation name to its camelCase translator key.
13+
* e.g. "square root" -> "squareRoot$", "subscript-superscript" -> "subscriptSuperscript$"
14+
*/
15+
function toTranslatorKey(str) {
16+
return (
17+
str
18+
.split(/[\s-]+/)
19+
.map((word, i) =>
20+
i === 0 ? word.toLowerCase() : word.charAt(0).toUpperCase() + word.slice(1).toLowerCase(),
21+
)
22+
.join('') + '$'
23+
);
24+
}
25+
26+
// Build regex that matches any English relation name
27+
const RELATION_NAMES_PATTERN = RELATION_NAMES.map(name =>
28+
name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'),
29+
).join('|');
30+
31+
// Pre-compiled regexes for hot-path use in localizeAnnouncement()
32+
const OUT_OF_REGEX = new RegExp(`out of (${RELATION_NAMES_PATTERN});`, 'g');
33+
const START_OF_REGEX = new RegExp(`start of (${RELATION_NAMES_PATTERN}):[ ]?`);
34+
const END_OF_MATHFIELD_REGEX = /^(.*?); end of mathfield$/;
35+
const END_OF_REGEX = new RegExp(`(.*?); end of (${RELATION_NAMES_PATTERN})$`);
36+
37+
function translateRelationName(englishName) {
38+
const key = toTranslatorKey(englishName);
39+
if (translator[key]) {
40+
return translator[key]();
41+
}
42+
return englishName;
43+
}
44+
45+
// Mathlive appends alternating " \u00A0 " / " \u202F " to force aria-live change detection
46+
const MATHLIVE_HACK_REGEX = / [\u00A0\u202F] $/;
47+
48+
/**
49+
* Localize a mathlive announcement string by replacing known English patterns
50+
* with their translated equivalents. Preserves mathlive's trailing aria-live
51+
* change hack if present.
52+
*
53+
* @param {string} text - The English announcement text from mathlive
54+
* @returns {string} The localized announcement text
55+
*/
56+
export function localizeAnnouncement(text) {
57+
if (!text) return text;
58+
59+
const hackMatch = text.match(MATHLIVE_HACK_REGEX);
60+
const cleanText = hackMatch ? text.slice(0, -hackMatch[0].length) : text;
61+
if (!cleanText) return text;
62+
63+
let result = cleanText;
64+
65+
// Pattern: "deleted: <content>" -> translated prefix + content
66+
result = result.replace(/^deleted: /, () => translator.deleted$());
67+
68+
// Pattern: "selected: <content>" -> translated prefix + content
69+
result = result.replace(/^selected: /, () => translator.selected$());
70+
71+
// Pattern: "<spoken>; end of mathfield" (must check before generic "end of")
72+
const endOfMathfieldMatch = result.match(END_OF_MATHFIELD_REGEX);
73+
if (endOfMathfieldMatch) {
74+
const localized = translator.endOfMathfield$({ spokenText: endOfMathfieldMatch[1] });
75+
return hackMatch ? localized + hackMatch[0] : localized;
76+
}
77+
78+
// Pattern: "out of <relation>;" (can appear multiple times)
79+
OUT_OF_REGEX.lastIndex = 0;
80+
result = result.replace(OUT_OF_REGEX, (_match, name) => {
81+
return translator.outOf$({ relationName: translateRelationName(name) });
82+
});
83+
84+
// Pattern: "start of <relation>: "
85+
result = result.replace(START_OF_REGEX, (_match, name) => {
86+
return translator.startOf$({ relationName: translateRelationName(name) });
87+
});
88+
89+
// Pattern: "<spoken>; end of <relation>"
90+
result = result.replace(END_OF_REGEX, (_match, spoken, name) => {
91+
return translator.endOf$({
92+
spokenText: spoken,
93+
relationName: translateRelationName(name),
94+
});
95+
});
96+
97+
if (result === cleanText) return text;
98+
return hackMatch ? result + hackMatch[0] : result;
99+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
/**
2+
* TEMPORARY WORKAROUND: Intercepts mathlive's aria-live region writes and
3+
* localizes the hardcoded English screen reader text in-place.
4+
*
5+
* Mathlive's defaultAnnounceHook writes English text directly to the aria-live
6+
* element's textContent. We intercept the textContent setter so the English
7+
* text is localized before it ever appears in the DOM — screen readers only
8+
* see the translated text.
9+
*
10+
* Remove when upstream fix lands:
11+
* https://github.com/arnog/mathlive/issues/2948
12+
*/
13+
14+
import { onBeforeUnmount, watch } from 'vue';
15+
import { localizeAnnouncement } from './mathLiveA11yLocalize';
16+
17+
const NODE_TEXT_CONTENT = Object.getOwnPropertyDescriptor(Node.prototype, 'textContent');
18+
const WHITESPACE_ONLY_REGEX = /^[\s\u00A0\u202F]+$/;
19+
20+
/**
21+
* Install a textContent interceptor on the math-field's aria-live element
22+
* that localizes mathlive's English announcements before they reach the DOM.
23+
*
24+
* @param {HTMLElement} mathfield - The <math-field> element
25+
* @returns {Function} cleanup - Call to restore original textContent behavior
26+
*/
27+
export function setupA11yAnnounceInterceptor(mathfield) {
28+
const ariaLiveEl = mathfield.shadowRoot?.querySelector('[aria-live]');
29+
if (!ariaLiveEl) return () => {};
30+
31+
Object.defineProperty(ariaLiveEl, 'textContent', {
32+
set(value) {
33+
if (typeof value === 'string' && value && !WHITESPACE_ONLY_REGEX.test(value)) {
34+
value = localizeAnnouncement(value);
35+
}
36+
NODE_TEXT_CONTENT.set.call(this, value);
37+
},
38+
get() {
39+
return NODE_TEXT_CONTENT.get.call(this);
40+
},
41+
configurable: true,
42+
});
43+
44+
return () => delete ariaLiveEl.textContent;
45+
}
46+
47+
/**
48+
* Vue composable that sets up announcement localization for a math-field element.
49+
*
50+
* @param {import('vue').Ref<HTMLElement|null>} mathfieldRef - Ref to the <math-field> element
51+
*/
52+
export function useMathLiveA11yAnnounce(mathfieldRef) {
53+
let cleanup = null;
54+
55+
const teardown = () => {
56+
if (cleanup) {
57+
cleanup();
58+
cleanup = null;
59+
}
60+
};
61+
62+
const setup = () => {
63+
teardown();
64+
const mathfield = mathfieldRef.value;
65+
if (!mathfield) return;
66+
cleanup = setupA11yAnnounceInterceptor(mathfield);
67+
};
68+
69+
watch(mathfieldRef, setup, { immediate: true });
70+
onBeforeUnmount(teardown);
71+
}

0 commit comments

Comments
 (0)