Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions public/combobox-autocomplete.js
Original file line number Diff line number Diff line change
Expand Up @@ -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' });
Expand All @@ -219,6 +222,7 @@ ComboboxAutocomplete.prototype.setCurrentOptionStyle = function (option) {
}
else {
opt.removeAttribute('aria-selected');
opt.classList.remove('active');
}
}
};
Expand Down
10 changes: 10 additions & 0 deletions public/css/autocomplete.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
29 changes: 16 additions & 13 deletions public/jquery.swiftype.autocomplete.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Expand Down Expand Up @@ -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();
Expand Down