From 84b1df61d29996e64cb183c074a512125a893657 Mon Sep 17 00:00:00 2001 From: Stephen Clower Date: Thu, 12 Mar 2026 11:17:06 -0400 Subject: [PATCH 1/4] Prepopulate ARIA live region with nonbreaking space THis fixes a problem where screen readers weren't registerying the first region change made to a math field, e.g. when typing. --- src/controller.ts | 5 +++++ src/services/aria.ts | 17 ++++++++++++++++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/src/controller.ts b/src/controller.ts index 2f9e72800..1c3bb68c0 100644 --- a/src/controller.ts +++ b/src/controller.ts @@ -50,6 +50,11 @@ class ControllerBase { this.options = options; this.aria = new Aria(this.getControllerSelf()); + // Attach the aria element immediately to ensure it's in the DOM before first use + // This prevents the first alert from being lost + if (container) { + this.aria.attach(); + } this.ariaLabel = 'Math Input'; this.ariaPostLabel = ''; diff --git a/src/services/aria.ts b/src/services/aria.ts index 77e18cd5a..6030fde7f 100755 --- a/src/services/aria.ts +++ b/src/services/aria.ts @@ -27,9 +27,13 @@ class Aria { this.controller = controller; } + private firstMessageSent = false; + attach() { const container = this.controller.container; if (this.span.parentNode !== container) { + // Append the element empty first to ensure screen readers detect the live region + // before any content is added. This fixes the issue where the first alert isn't announced. domFrag(container).prepend(domFrag(this.span)); } } @@ -89,7 +93,18 @@ class Aria { if (this.controller.options.logAriaAlerts && this.msg) { console.log(this.msg); } - this.span.textContent = this.msg; + + // For the first message only, use a 50ms delay to ensure the empty live region + // is registered with screen readers before adding content. Screen readers need + // actual time (not just a different execution context) to process the empty element. + if (!this.firstMessageSent) { + this.firstMessageSent = true; + setTimeout(() => { + this.span.textContent = this.msg; + }, 50); + } else { + this.span.textContent = this.msg; + } } } return this.clear(); From 445aeef1a670b14ebcc95c5578a8bc1703d18315 Mon Sep 17 00:00:00 2001 From: Stephen Clower Date: Thu, 12 Mar 2026 14:10:37 -0400 Subject: [PATCH 2/4] Attach the aria live region after saving original content but before setting up math field --- src/controller.ts | 5 ----- src/publicapi.ts | 4 ++++ 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/src/controller.ts b/src/controller.ts index 1c3bb68c0..2f9e72800 100644 --- a/src/controller.ts +++ b/src/controller.ts @@ -50,11 +50,6 @@ class ControllerBase { this.options = options; this.aria = new Aria(this.getControllerSelf()); - // Attach the aria element immediately to ensure it's in the DOM before first use - // This prevents the first alert from being lost - if (container) { - this.aria.attach(); - } this.ariaLabel = 'Math Input'; this.ariaPostLabel = ''; diff --git a/src/publicapi.ts b/src/publicapi.ts index 7a82fbe0c..25db0c1b3 100644 --- a/src/publicapi.ts +++ b/src/publicapi.ts @@ -293,6 +293,10 @@ function getInterface(v: number): MathQuill.v3.API | MathQuill.v1.API { var contents = domFrag(el).addClass(classNames).children().detach(); + // Attach the aria element after saving original contents but before setting up the math field + // This ensures the live region exists before any alerts are triggered + ctrlr.aria.attach(); + root.setDOM( domFrag(h('span', { class: 'mq-root-block', 'aria-hidden': true })) .appendTo(el) From 18b74411ba8f7e5bb469d59280f8cee947ad3251 Mon Sep 17 00:00:00 2001 From: Stephen Clower Date: Fri, 13 Mar 2026 12:32:04 -0400 Subject: [PATCH 3/4] DOn't attach ARIA live region prematurely --- src/publicapi.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/publicapi.ts b/src/publicapi.ts index 25db0c1b3..7a82fbe0c 100644 --- a/src/publicapi.ts +++ b/src/publicapi.ts @@ -293,10 +293,6 @@ function getInterface(v: number): MathQuill.v3.API | MathQuill.v1.API { var contents = domFrag(el).addClass(classNames).children().detach(); - // Attach the aria element after saving original contents but before setting up the math field - // This ensures the live region exists before any alerts are triggered - ctrlr.aria.attach(); - root.setDOM( domFrag(h('span', { class: 'mq-root-block', 'aria-hidden': true })) .appendTo(el) From 7ea7a7a83b2c711a4646aa99ca93c75f9097c2f6 Mon Sep 17 00:00:00 2001 From: Stephen Clower Date: Fri, 13 Mar 2026 16:14:51 -0400 Subject: [PATCH 4/4] Debounce initial rapid ARIA live region changes --- src/services/aria.ts | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/services/aria.ts b/src/services/aria.ts index 6030fde7f..b9694298f 100755 --- a/src/services/aria.ts +++ b/src/services/aria.ts @@ -28,6 +28,7 @@ class Aria { } private firstMessageSent = false; + private firstMessageTimeout: number | null = null; attach() { const container = this.controller.container; @@ -94,12 +95,18 @@ class Aria { console.log(this.msg); } - // For the first message only, use a 50ms delay to ensure the empty live region + // For the first message, use a 50ms delay to ensure the empty live region // is registered with screen readers before adding content. Screen readers need // actual time (not just a different execution context) to process the empty element. + // We debounce to ensure that if multiple messages arrive within the first 50ms, + // only the final message is announced (avoiding out-of-order announcements). if (!this.firstMessageSent) { - this.firstMessageSent = true; - setTimeout(() => { + if (this.firstMessageTimeout !== null) { + clearTimeout(this.firstMessageTimeout); + } + this.firstMessageTimeout = setTimeout(() => { + this.firstMessageSent = true; + this.firstMessageTimeout = null; this.span.textContent = this.msg; }, 50); } else {