From 088876bb682d88f84661ccef60144d5273e85dd1 Mon Sep 17 00:00:00 2001 From: Samuel Wanjohi Date: Tue, 7 Jul 2026 12:58:16 +0300 Subject: [PATCH 1/2] fix(a11y): keep keyboard-focused search suggestion visible (WCAG 2.4.11) The navbar search box was driven by two keyboard navigators at once (legacy Swiftype and the ARIA ComboboxAutocomplete), and the suggestion list had no height cap so the whole page scrolled to reveal lower options. Together these let the keyboard-focused suggestion be obscured. Cap the suggestion list to the viewport and scroll it internally (the standard ARIA combobox pattern) so the page never scrolls and the input stays in view, and make the combobox the single navigator that keeps the visible highlight in sync with aria-selected. Enter still selects the highlighted suggestion. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- public/combobox-autocomplete.js | 17 +++++++++-- public/css/autocomplete.css | 14 +++++++++ public/jquery.swiftype.autocomplete.js | 39 +++++++++++++++++--------- 3 files changed, 54 insertions(+), 16 deletions(-) diff --git a/public/combobox-autocomplete.js b/public/combobox-autocomplete.js index 6dd765cf..1e3bf03c 100644 --- a/public/combobox-autocomplete.js +++ b/public/combobox-autocomplete.js @@ -198,15 +198,25 @@ ComboboxAutocomplete.prototype.setCurrentOptionStyle = function (option) { var opt = this.filteredOptions[i]; if (opt === option) { opt.setAttribute('aria-selected', 'true'); + // Keep the visible highlight ('active') on the same option the combobox + // treats as current. The legacy Swiftype key handler also toggles 'active' + // and drifts out of sync at the list boundaries (it clears the highlight + // instead of wrapping), which would leave the highlighted suggestion + // different from the one scrolled into view. Making the combobox the single + // authority keeps the focused suggestion both highlighted and visible. + opt.classList.add('active'); if ((this.listboxNode.scrollTop + this.listboxNode.offsetHeight) < (opt.offsetTop + opt.offsetHeight)) { this.listboxNode.scrollTop = opt.offsetTop + opt.offsetHeight - this.listboxNode.offsetHeight; } else if (this.listboxNode.scrollTop > (opt.offsetTop + 2)) { this.listboxNode.scrollTop = opt.offsetTop; } - // Focus stays on the input (aria-activedescendant), and the listbox is not an - // internal scroll container, so the browser will not auto-scroll to the active - // option. Scroll it into view so it is never obscured (WCAG 2.4.11). + // Focus stays on the input (aria-activedescendant). The listbox is capped to the + // viewport height and scrolls internally (see #search-results in autocomplete.css), + // so the scrollTop adjustment above keeps the focused suggestion visible without + // scrolling the whole page (WCAG 2.4.11). scrollIntoView is a minimal fallback for + // any browser that has not applied the internal scroll; block:'nearest' only moves + // the option the least amount needed and is a no-op once it is already visible. if (typeof opt.scrollIntoView === 'function') { try { opt.scrollIntoView({ block: 'nearest', inline: 'nearest' }); @@ -219,6 +229,7 @@ ComboboxAutocomplete.prototype.setCurrentOptionStyle = function (option) { } else { opt.removeAttribute('aria-selected'); + opt.classList.remove('active'); } } }; diff --git a/public/css/autocomplete.css b/public/css/autocomplete.css index 46b7c6c8..d038fe12 100644 --- a/public/css/autocomplete.css +++ b/public/css/autocomplete.css @@ -52,6 +52,20 @@ form input.st-search-input { text-align: left; } +/* WCAG 2.4.11 Focus Not Obscured (Minimum): keep the keyboard-focused suggestion visible. + Cap the suggestion list to the viewport height and let it scroll internally instead of + growing to its full content height and scrolling the whole page. 'position: relative' makes + each option's offsetTop relative to this list, so ComboboxAutocomplete.setCurrentOptionStyle + can bring the focused option into view via listboxNode.scrollTop. Page-level scrolling let the + focused suggestion be pushed off-screen (and, on some browsers, fought the browser's own + focus-scroll of the input); internal scrolling keeps the input and the focused suggestion on + screen together. When all suggestions fit within the viewport, no scrollbar appears. */ +#search-results { + position: relative; + max-height: calc(100vh - 60px); + overflow-y: auto; +} + .swiftype-widget .autocomplete li > a:hover { text-decoration: none; } diff --git a/public/jquery.swiftype.autocomplete.js b/public/jquery.swiftype.autocomplete.js index 65ea6e0c..9dd5baf0 100644 --- a/public/jquery.swiftype.autocomplete.js +++ b/public/jquery.swiftype.autocomplete.js @@ -169,7 +169,23 @@ $this.registerResult = function($element, data) { $element.data('swiftype-item', data); - $element.click($this.selectedCallback(data)).mouseover(function () { + // Drive the hover highlight from real pointer movement only. During keyboard + // navigation the suggestion list scrolls internally to keep the focused + // suggestion visible; that scrolling slides suggestions under a stationary cursor + // and makes browsers dispatch pointer events ('mouseover' in all browsers, and + // 'mousemove' in WebKit). Honoring those would steal the highlight away from the + // keyboard-focused option and leave it obscured (WCAG 2.4.11). Listen for + // 'mousemove' and ignore any event whose viewport coordinates have not changed + // since the last one, which filters out scroll-induced events while still + // reacting to genuine mouse motion. + $element.click($this.selectedCallback(data)).mousemove(function (event) { + if (event && event.clientX === $this.lastPointerX && event.clientY === $this.lastPointerY) { + return; + } + if (event) { + $this.lastPointerX = event.clientX; + $this.lastPointerY = event.clientY; + } $this.listResults().removeClass(config.activeItemClass); $element.addClass(config.activeItemClass); }); @@ -230,20 +246,17 @@ suppressKey = true; break; case 38: - event.preventDefault(); - if ($active.length === 0) { - $this.listResults().last().addClass(config.activeItemClass); - } else { - $this.prevResult(); - } - break; case 40: + // Up/Down arrows: suggestion navigation is owned exclusively by + // ComboboxAutocomplete (the ARIA combobox in combobox-autocomplete.js). + // It keeps the visible highlight ('active'), aria-selected and + // scroll-into-view in sync so the focused suggestion is never obscured + // (WCAG 2.4.11). This legacy handler used a separate, non-wrapping + // 'active' pointer over a possibly stale result list; running it here + // moved the highlight independently and desynchronized it from the + // scrolled-to option, leaving the highlighted suggestion off-screen. + // Only prevent the default caret movement and let the combobox navigate. event.preventDefault(); - if ($active.length === 0) { - $this.listResults().first().addClass(config.activeItemClass); - } else if ($active != $this.listResults().last()) { - $this.nextResult(); - } break; case 27: $this.hideList(); From 97383b7a3aa025f343ad610f8428b9ded923a01d Mon Sep 17 00:00:00 2001 From: Samuel Wanjohi Date: Tue, 7 Jul 2026 13:14:24 +0300 Subject: [PATCH 2/2] nit --- public/combobox-autocomplete.js | 17 +++++------------ public/css/autocomplete.css | 12 ++++-------- public/jquery.swiftype.autocomplete.js | 26 ++++++++------------------ 3 files changed, 17 insertions(+), 38 deletions(-) diff --git a/public/combobox-autocomplete.js b/public/combobox-autocomplete.js index 1e3bf03c..c159f09a 100644 --- a/public/combobox-autocomplete.js +++ b/public/combobox-autocomplete.js @@ -198,12 +198,8 @@ ComboboxAutocomplete.prototype.setCurrentOptionStyle = function (option) { var opt = this.filteredOptions[i]; if (opt === option) { opt.setAttribute('aria-selected', 'true'); - // Keep the visible highlight ('active') on the same option the combobox - // treats as current. The legacy Swiftype key handler also toggles 'active' - // and drifts out of sync at the list boundaries (it clears the highlight - // instead of wrapping), which would leave the highlighted suggestion - // different from the one scrolled into view. Making the combobox the single - // authority keeps the focused suggestion both highlighted and visible. + // Keep the visible highlight ('active') on the option the combobox marks current; + // the legacy Swiftype handler otherwise drifts out of sync at the list boundaries. opt.classList.add('active'); if ((this.listboxNode.scrollTop + this.listboxNode.offsetHeight) < (opt.offsetTop + opt.offsetHeight)) { this.listboxNode.scrollTop = opt.offsetTop + opt.offsetHeight - this.listboxNode.offsetHeight; @@ -211,12 +207,9 @@ ComboboxAutocomplete.prototype.setCurrentOptionStyle = function (option) { else if (this.listboxNode.scrollTop > (opt.offsetTop + 2)) { this.listboxNode.scrollTop = opt.offsetTop; } - // Focus stays on the input (aria-activedescendant). The listbox is capped to the - // viewport height and scrolls internally (see #search-results in autocomplete.css), - // so the scrollTop adjustment above keeps the focused suggestion visible without - // scrolling the whole page (WCAG 2.4.11). scrollIntoView is a minimal fallback for - // any browser that has not applied the internal scroll; block:'nearest' only moves - // the option the least amount needed and is a no-op once it is already visible. + // The listbox is capped and scrolls internally (see #search-results in + // autocomplete.css), so the scrollTop above keeps the focused suggestion visible + // (WCAG 2.4.11). scrollIntoView is a no-op-when-visible fallback. if (typeof opt.scrollIntoView === 'function') { try { opt.scrollIntoView({ block: 'nearest', inline: 'nearest' }); diff --git a/public/css/autocomplete.css b/public/css/autocomplete.css index d038fe12..47f99f3e 100644 --- a/public/css/autocomplete.css +++ b/public/css/autocomplete.css @@ -52,14 +52,10 @@ form input.st-search-input { text-align: left; } -/* WCAG 2.4.11 Focus Not Obscured (Minimum): keep the keyboard-focused suggestion visible. - Cap the suggestion list to the viewport height and let it scroll internally instead of - growing to its full content height and scrolling the whole page. 'position: relative' makes - each option's offsetTop relative to this list, so ComboboxAutocomplete.setCurrentOptionStyle - can bring the focused option into view via listboxNode.scrollTop. Page-level scrolling let the - focused suggestion be pushed off-screen (and, on some browsers, fought the browser's own - focus-scroll of the input); internal scrolling keeps the input and the focused suggestion on - screen together. When all suggestions fit within the viewport, no scrollbar appears. */ +/* WCAG 2.4.11 Focus Not Obscured: cap the suggestion list to the viewport and scroll it + internally instead of scrolling the whole page, so the keyboard-focused suggestion stays + visible. 'position: relative' makes each option's offsetTop relative to this list so + setCurrentOptionStyle's listboxNode.scrollTop can reach it. No scrollbar when all fit. */ #search-results { position: relative; max-height: calc(100vh - 60px); diff --git a/public/jquery.swiftype.autocomplete.js b/public/jquery.swiftype.autocomplete.js index 9dd5baf0..f342648d 100644 --- a/public/jquery.swiftype.autocomplete.js +++ b/public/jquery.swiftype.autocomplete.js @@ -169,15 +169,10 @@ $this.registerResult = function($element, data) { $element.data('swiftype-item', data); - // Drive the hover highlight from real pointer movement only. During keyboard - // navigation the suggestion list scrolls internally to keep the focused - // suggestion visible; that scrolling slides suggestions under a stationary cursor - // and makes browsers dispatch pointer events ('mouseover' in all browsers, and - // 'mousemove' in WebKit). Honoring those would steal the highlight away from the - // keyboard-focused option and leave it obscured (WCAG 2.4.11). Listen for - // 'mousemove' and ignore any event whose viewport coordinates have not changed - // since the last one, which filters out scroll-induced events while still - // reacting to genuine mouse motion. + // Highlight on real pointer movement only. Internal list scrolling slides + // suggestions under a stationary cursor and fires pointer events that would + // otherwise steal the highlight from the keyboard-focused option (WCAG 2.4.11); + // ignore events whose coordinates have not changed since the last one. $element.click($this.selectedCallback(data)).mousemove(function (event) { if (event && event.clientX === $this.lastPointerX && event.clientY === $this.lastPointerY) { return; @@ -247,15 +242,10 @@ break; case 38: case 40: - // Up/Down arrows: suggestion navigation is owned exclusively by - // ComboboxAutocomplete (the ARIA combobox in combobox-autocomplete.js). - // It keeps the visible highlight ('active'), aria-selected and - // scroll-into-view in sync so the focused suggestion is never obscured - // (WCAG 2.4.11). This legacy handler used a separate, non-wrapping - // 'active' pointer over a possibly stale result list; running it here - // moved the highlight independently and desynchronized it from the - // scrolled-to option, leaving the highlighted suggestion off-screen. - // Only prevent the default caret movement and let the combobox navigate. + // Up/Down navigation is owned solely by ComboboxAutocomplete + // (combobox-autocomplete.js); running this legacy handler too moved a + // second highlight and desynced it from the scrolled-to option + // (WCAG 2.4.11). Only prevent the default caret movement. event.preventDefault(); break; case 27: