From 423ed2739b7844e8d3682939a2fb8b6e7c5fea85 Mon Sep 17 00:00:00 2001 From: Oliver Foster Date: Mon, 13 Apr 2026 14:24:52 +0100 Subject: [PATCH 1/6] Fix: Maximum stack exceeded error --- js/a11y/browserFocus.js | 24 ++++++++++++++++-------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/js/a11y/browserFocus.js b/js/a11y/browserFocus.js index 6be3de38..9eb05a3e 100644 --- a/js/a11y/browserFocus.js +++ b/js/a11y/browserFocus.js @@ -62,18 +62,21 @@ export default class BrowserFocus extends Backbone.Controller { return; } const $element = $(event.target); - if ($element.is('[data-a11y-force-focus]')) { + const finish = () => { + const hasFocus = ($element[0] === this.a11y.currentActiveElement); + if (hasFocus) return; + if (!$element.is('[data-a11y-force-focus]')) return; $element.removeAttr('tabindex data-a11y-force-focus'); - } + }; // From here, only check source elements if (event.target !== event.currentTarget) { - return; + return finish(); } // Do not auto next if the focus isn't returning to the body or html element // or if we're not losing focus const isNotBodyHTMLOrLostFocus = (!$(event.relatedTarget).is('body, html') && event.relatedTarget !== null); if (isNotBodyHTMLOrLostFocus) { - return; + return finish(); } // Check if element is losing focus // due to the addition of a disabled class, display none, visibility hidden, @@ -84,10 +87,12 @@ export default class BrowserFocus extends Backbone.Controller { // This can happen when JAWS screen reader on `role="group"` takes enter click // when the focus was on the input element this._refocusCurrentActiveElement(); + finish(); return; } // Move focus to next readable element this.a11y.focusNext($element); + finish(); } /** @@ -120,17 +125,20 @@ export default class BrowserFocus extends Backbone.Controller { } const $element = $(event.target); const $stack = $([...$element.toArray(), ...$element.parents().toArray()]); - const $focusable = $stack.filter(config._options._tabbableElements); + const $focusable = $stack.filter((i, el) => { + const $el = $(el); + // Allow label clicks to focus their associated control + return $el.is(config._options._tabbableElements) || $el.is('label[for]'); + }); if (!$focusable.length) { this._refocusCurrentActiveElement(); return; } - const $closestFocusable = $element.closest(config._options._tabbableElements); // Force focus for screen reader enter / space press - if ($closestFocusable[0] !== document.activeElement) { + if ($focusable[0] !== document.activeElement) { // Focus on the nearest focusable element if not already with focus this.a11y._isForcedFocus = true; - $closestFocusable[0].focus(); + $focusable[0].focus(); this.a11y._isForcedFocus = false; } if (!config._options._isClickDelayedAfterFocusEnabled) return; From 6ff091f1708d45ebf23cb28569a19e046fec618c Mon Sep 17 00:00:00 2001 From: Brad Simpson Date: Thu, 21 May 2026 11:03:04 -0600 Subject: [PATCH 2/6] Fix #867 - preserve user text selection in click handler removeAllRanges() introduced in #844 also fires from _onClick via _refocusCurrentActiveElement, wiping user drag-selections on mouseup. Only clear the selection when it is collapsed, so the stray Firefox anchor is removed but genuine ranges are preserved. Co-Authored-By: Claude Opus 4.7 --- js/a11y/browserFocus.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/js/a11y/browserFocus.js b/js/a11y/browserFocus.js index 6be3de38..87cf920a 100644 --- a/js/a11y/browserFocus.js +++ b/js/a11y/browserFocus.js @@ -101,8 +101,10 @@ export default class BrowserFocus extends Backbone.Controller { this.a11y.focus(element, { preventScroll: true }); // Firefox sets a persistent selection anchor when focus is assigned // programmatically, causing text to be unexpectedly selected on subsequent - // clicks. Clear any selection to prevent this. - window.getSelection()?.removeAllRanges(); + // clicks. Clear only when the selection is collapsed (a stray anchor with + // no range) so genuine user drag-selections are preserved. + const selection = window.getSelection(); + if (selection?.isCollapsed) selection.removeAllRanges(); } /** From d77583d6d9b4b52ecaec46808e85104084a7f51d Mon Sep 17 00:00:00 2001 From: Brad Simpson Date: Thu, 21 May 2026 11:04:38 -0600 Subject: [PATCH 3/6] Fix #867 - restrict removeAllRanges to blur path The isCollapsed guard was insufficient - _refocusCurrentActiveElement is also invoked from _onClick on every click of non-focusable body text, and the click event fires after mouseup with the user's drag selection already committed. Move removeAllRanges out of the shared helper and call it only on the blur path, where the Firefox stray anchor bug actually originates. The click path now leaves the selection untouched. Co-Authored-By: Claude Opus 4.7 --- js/a11y/browserFocus.js | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/js/a11y/browserFocus.js b/js/a11y/browserFocus.js index 87cf920a..fbcd33cc 100644 --- a/js/a11y/browserFocus.js +++ b/js/a11y/browserFocus.js @@ -84,6 +84,11 @@ export default class BrowserFocus extends Backbone.Controller { // This can happen when JAWS screen reader on `role="group"` takes enter click // when the focus was on the input element this._refocusCurrentActiveElement(); + // Firefox sets a persistent selection anchor when focus is assigned + // programmatically during blur, causing text to be unexpectedly selected + // on subsequent clicks. Clear it here only - the click path must not + // touch the selection or it wipes user drag-selections on mouseup. + window.getSelection()?.removeAllRanges(); return; } // Move focus to next readable element @@ -99,12 +104,6 @@ export default class BrowserFocus extends Backbone.Controller { const element = this.a11y.currentActiveElement; if (!element) return; this.a11y.focus(element, { preventScroll: true }); - // Firefox sets a persistent selection anchor when focus is assigned - // programmatically, causing text to be unexpectedly selected on subsequent - // clicks. Clear only when the selection is collapsed (a stray anchor with - // no range) so genuine user drag-selections are preserved. - const selection = window.getSelection(); - if (selection?.isCollapsed) selection.removeAllRanges(); } /** From fbe4ba554673d63eb59436f5c8407cb2a0d825fd Mon Sep 17 00:00:00 2001 From: Brad Simpson Date: Thu, 21 May 2026 11:15:18 -0600 Subject: [PATCH 4/6] Fix #867 - skip blur refocus during user drag-selection Track mousedown/mouseup state on body and skip the JAWS refocus-on-blur logic while the mouse button is held down. The blur fires between mousedown and mouseup on every click of non-focusable text, and the programmatic focus call aborts the in-progress text selection (and in Firefox plants a stray selection anchor, which was the original #843 symptom). Removing the refocus during active mouse interaction addresses both the original #843 Firefox selection bug and the all-browser selection-blocked regression from #844, without needing removeAllRanges(). JAWS interactions are keyboard-driven, so the group-element refocus behaviour is preserved. Co-Authored-By: Claude Opus 4.7 --- js/a11y/browserFocus.js | 33 ++++++++++++++++++++++++--------- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/js/a11y/browserFocus.js b/js/a11y/browserFocus.js index fbcd33cc..ec35704c 100644 --- a/js/a11y/browserFocus.js +++ b/js/a11y/browserFocus.js @@ -29,6 +29,9 @@ export default class BrowserFocus extends Backbone.Controller { this.a11y = a11y; this._onBlur = this._onBlur.bind(this); this._onClick = this._onClick.bind(this); + this._onMouseDown = this._onMouseDown.bind(this); + this._onMouseUp = this._onMouseUp.bind(this); + this._isMouseDown = false; this.$body = $('body'); this.listenTo(Adapt, { 'accessibility:ready': this._attachEventListeners @@ -36,18 +39,30 @@ export default class BrowserFocus extends Backbone.Controller { } /** - * Attaches blur and click event listeners to the document body. + * Attaches blur, click and mouse tracking event listeners to the document body. * Uses event capturing for click to intercept before bubbling. + * Mouse state is tracked so that focus is not stolen back to the previously + * active element while the user is mid drag-selection. * @private */ _attachEventListeners() { this.$body .on('blur', '*', this._onBlur) - .on('blur', this._onBlur); + .on('blur', this._onBlur) + .on('mousedown', this._onMouseDown) + .on('mouseup', this._onMouseUp); // 'Capture' event attachment for click this.$body[0].addEventListener('click', this._onClick, true); } + _onMouseDown() { + this._isMouseDown = true; + } + + _onMouseUp() { + this._isMouseDown = false; + } + /** * Handles blur events to manage focus transitions. * Removes `data-a11y-force-focus` attribute when element loses focus, @@ -82,13 +97,13 @@ export default class BrowserFocus extends Backbone.Controller { if (isNotDisabledHiddenOrDetached) { // The element is still available, refocus // This can happen when JAWS screen reader on `role="group"` takes enter click - // when the focus was on the input element - this._refocusCurrentActiveElement(); - // Firefox sets a persistent selection anchor when focus is assigned - // programmatically during blur, causing text to be unexpectedly selected - // on subsequent clicks. Clear it here only - the click path must not - // touch the selection or it wipes user drag-selections on mouseup. - window.getSelection()?.removeAllRanges(); + // when the focus was on the input element. + // Skip while the user is mid drag-selection - stealing focus back during + // mousedown sets a programmatic selection anchor (Firefox) and aborts the + // in-progress text selection. + if (!this._isMouseDown) { + this._refocusCurrentActiveElement(); + } return; } // Move focus to next readable element From c95deee5a685109340ba388d830bb05278e8c0a9 Mon Sep 17 00:00:00 2001 From: Brad Simpson Date: Thu, 21 May 2026 11:16:37 -0600 Subject: [PATCH 5/6] Fix #867 - preserve selection in click path refocus _onClick fires after mouseup. When the click lands on non-focusable body text, _refocusCurrentActiveElement runs and moves focus to the previously active element, which drops the user's just-formed text selection. Skip the refocus when a non-collapsed selection exists so the user can copy text. Co-Authored-By: Claude Opus 4.7 --- js/a11y/browserFocus.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/js/a11y/browserFocus.js b/js/a11y/browserFocus.js index ec35704c..ddcbbce2 100644 --- a/js/a11y/browserFocus.js +++ b/js/a11y/browserFocus.js @@ -138,7 +138,12 @@ export default class BrowserFocus extends Backbone.Controller { const $stack = $([...$element.toArray(), ...$element.parents().toArray()]); const $focusable = $stack.filter(config._options._tabbableElements); if (!$focusable.length) { - this._refocusCurrentActiveElement(); + // Preserve user text selection - refocusing here moves focus away + // from the selected text and the browser drops the selection. + const selection = window.getSelection(); + if (!selection || selection.isCollapsed) { + this._refocusCurrentActiveElement(); + } return; } const $closestFocusable = $element.closest(config._options._tabbableElements); From ead9c43323628734a81d8bf24eacdbbd00bfd74f Mon Sep 17 00:00:00 2001 From: Brad Simpson Date: Thu, 21 May 2026 12:01:19 -0600 Subject: [PATCH 6/6] Fix #867 - harden mouse state tracking against stuck flag Move mousedown/mouseup listeners from body to document, and also reset _isMouseDown on document mouseleave and window blur. This prevents the flag from getting stuck true when the user releases the pointer outside the body or switches window/iframe mid drag, which would otherwise suppress the JAWS refocus-on-blur safety net until the next mousedown/mouseup cycle inside body. Co-Authored-By: Claude Opus 4.7 --- js/a11y/browserFocus.js | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/js/a11y/browserFocus.js b/js/a11y/browserFocus.js index ddcbbce2..d84aede5 100644 --- a/js/a11y/browserFocus.js +++ b/js/a11y/browserFocus.js @@ -48,9 +48,15 @@ export default class BrowserFocus extends Backbone.Controller { _attachEventListeners() { this.$body .on('blur', '*', this._onBlur) - .on('blur', this._onBlur) + .on('blur', this._onBlur); + // Mouse state is tracked at the document/window level so that a + // pointer released outside the body, or focus leaving the window + // mid drag, cannot leave _isMouseDown stuck true. + $(document) .on('mousedown', this._onMouseDown) - .on('mouseup', this._onMouseUp); + .on('mouseup', this._onMouseUp) + .on('mouseleave', this._onMouseUp); + $(window).on('blur', this._onMouseUp); // 'Capture' event attachment for click this.$body[0].addEventListener('click', this._onClick, true); }