diff --git a/public/combobox-autocomplete.js b/public/combobox-autocomplete.js index 6dd765c..c159f09 100644 --- a/public/combobox-autocomplete.js +++ b/public/combobox-autocomplete.js @@ -198,15 +198,18 @@ 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 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; } 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). + // 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' }); @@ -219,6 +222,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 46b7c6c..47f99f3 100644 --- a/public/css/autocomplete.css +++ b/public/css/autocomplete.css @@ -52,6 +52,16 @@ form input.st-search-input { text-align: left; } +/* 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); + 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 65ea6e0..f342648 100644 --- a/public/jquery.swiftype.autocomplete.js +++ b/public/jquery.swiftype.autocomplete.js @@ -169,7 +169,18 @@ $this.registerResult = function($element, data) { $element.data('swiftype-item', data); - $element.click($this.selectedCallback(data)).mouseover(function () { + // 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; + } + if (event) { + $this.lastPointerX = event.clientX; + $this.lastPointerY = event.clientY; + } $this.listResults().removeClass(config.activeItemClass); $element.addClass(config.activeItemClass); }); @@ -230,20 +241,12 @@ 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 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(); - if ($active.length === 0) { - $this.listResults().first().addClass(config.activeItemClass); - } else if ($active != $this.listResults().last()) { - $this.nextResult(); - } break; case 27: $this.hideList();