Skip to content

Commit

Permalink
fix(autocomplete): close dropdown on enter or tab key press (#761)
Browse files Browse the repository at this point in the history
* fix(autocomplete): close after selection

When an autocomplete value is selected via the autocomplete's
`setSelected()` method, the autocomplete's v-model value will usually be
updated, and the dropdown will usually scheduled to be closed on the
next tick. However, updating the v-model value triggers the v-model
watcher, which would then schedule the dropdown to be open again on the
next tick.

This change updates the v-model watcher to not do anything when the new
value is the same as the currently selected value.

* fix(autocomplete): close on blur

If the autocomplete looses focus without a mouse click (eg because the
user tabbed away from it), the dropdown would remain open.

This change extends the standard onBlur handler to first close the
dropdown (and then run the standard onBlur handling).

* fix(autocomplete): remove menu from tab sequence

Pressing the tab key when the autocomplete input was focused moved the
focus to autocomplete menu.

This change removes the autocomplete menu and all of its options from
the tab sequence.

* fix(autocomplete): clear itemRefs on re-render

Using the up & down keys to change the "hovered" state triggers a
re-render of the autocomplete menu options -- but not of the
autocomplete component itself. The autocomplete's own `onBeforeUpdate`
handler is not called when this happens; and so using it to clear the
`itemRefs` array resulted in the previous set of options not being
removed.

The main manifestation of this bug was that it was not possible to use
the down key to access a selectable-footer option.

This change clears the `itemRefs` array whenever the `setItemRef()`
function is called for the first menu option; now the `itemRefs` array
will be cleared both whenever the full autocomplete component is
re-rendered, and whenever just the individual options are re-rendered.

* fix(autocomplete): select head/foot exclusively

When the `selectable-header` (or `selectable-footer` prop) is true, it
was possible to use the up & down keys to turn on the visual "hovered"
state for both the header (or footer) option and another regular option
at the same time.

This change centralizes the logic of setting the `hoveredOption`,
`headerHovered`, and `footerHovered` refs in the `setHovered()`
function, ensuring that only one can be set at a time.

* fix(autocomplete): apply aria combox pattern

The ARIA attributes used by the autocomplete menu and options
represented the menu as if it were a standalone modal dialog.

The change applies the Combobox Pattern from the ARIA Authoring
Practices Guide to the autocomplete input, menu, and options -- which
should allow assistive technologies to recognize the menu as a list of
options available for the input, and to identify the currently "hovered"
option.

See https://www.w3.org/WAI/ARIA/apg/patterns/combobox/ for details.
  • Loading branch information
justinludwig authored Feb 5, 2024
1 parent 0a7f9b4 commit 796ed9f
Show file tree
Hide file tree
Showing 5 changed files with 112 additions and 45 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,8 @@ const inspectData = [
:data="filtered"
icon="search"
clearable
selectable-header
selectable-footer
@select="(option) => (selected = option)">
<template #empty>No results found</template>
<template #header>Header slot (optional)</template>
Expand Down
4 changes: 3 additions & 1 deletion packages/docs-next/components/Dropdown.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ title: Dropdown
| ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------- | ------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| active | The active state of the dropdown, use v-model:active to make it two-way binding. | boolean | - | <code style='white-space: nowrap; padding: 0;'>false</code> |
| animation | Custom animation (transition name) | string | - | <div><small>From <b>config</b>:</small></div><code style='white-space: nowrap; padding: 0;'>dropdown: {<br>&nbsp;&nbsp;animation: "fade"<br>}</code> |
| ariaRole | Role attribute to be passed to the list container for better accessibility.<br/>Use menu only in situations where your dropdown is related to a navigation menu. | string | `list`, `menu`, `dialog` | <div><small>From <b>config</b>:</small></div><code style='white-space: nowrap; padding: 0;'>dropdown: {<br>&nbsp;&nbsp;ariaRole: "list"<br>}</code> |
| ariaRole | Role attribute to be passed to the list container for better accessibility.<br/>Use menu only in situations where your dropdown is related to a navigation menu. | string | `list`, `listbox`, `menu`, `dialog` | <div><small>From <b>config</b>:</small></div><code style='white-space: nowrap; padding: 0;'>dropdown: {<br>&nbsp;&nbsp;ariaRole: "list"<br>}</code> |
| checkScroll | Makes the component check if menu reached scroll start or end and emit scroll events. | boolean | - | <div><small>From <b>config</b>:</small></div><code style='white-space: nowrap; padding: 0;'>dropdown: {<br>&nbsp;&nbsp;checkScroll: false<br>}</code> |
| closeable | Dropdown close options (pressing escape, clicking the content or outside) | string[] \| boolean | `true`, `false`, `escape`, `outside`, `content` | <div><small>From <b>config</b>:</small></div><code style='white-space: nowrap; padding: 0;'>dropdown: {<br>&nbsp;&nbsp;closeable: ["escape","outside","content"]<br>}</code> |
| delay | Dropdown delay before it appears (number in ms) | number | - | |
Expand All @@ -50,6 +50,8 @@ title: Dropdown
| inline | Dropdown content (items) are shown inline, trigger is removed | boolean | - | <code style='white-space: nowrap; padding: 0;'>false</code> |
| label | Trigger label, unnecessary when trgger slot is used | string | - | |
| maxHeight | Max height of dropdown content | string\|number | - | <div><small>From <b>config</b>:</small></div><code style='white-space: nowrap; padding: 0;'>dropdown: {<br>&nbsp;&nbsp;maxHeight: 200<br>}</code> |
| menuId | HTML element ID of dropdown menu element. | string | - | <code style='white-space: nowrap; padding: 0;'>null</code> |
| menuTabindex | Tabindex of dropdown menu element. | number | - | <code style='white-space: nowrap; padding: 0;'>null</code> |
| menuTag | Dropdown menu tag name | DynamicComponent | - | <div><small>From <b>config</b>:</small></div><code style='white-space: nowrap; padding: 0;'>dropdown: {<br>&nbsp;&nbsp;menuTag: "div"<br>}</code> |
| mobileBreakpoint | Mobile breakpoint as max-width value | string | - | <div><small>From <b>config</b>:</small></div><code style='white-space: nowrap; padding: 0;'>dropdown: {<br>&nbsp;&nbsp;mobileBreakpoint: undefined<br>}</code> |
| mobileModal | Dropdown content (items) are shown into a modal on mobile | boolean | - | <div><small>From <b>config</b>:</small></div><code style='white-space: nowrap; padding: 0;'>dropdown: {<br>&nbsp;&nbsp;mobileModal: true<br>}</code> |
Expand Down
133 changes: 93 additions & 40 deletions packages/oruga-next/src/components/autocomplete/Autocomplete.vue
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import {
nextTick,
ref,
watch,
onBeforeUpdate,
useAttrs,
toRaw,
onMounted,
Expand All @@ -18,7 +17,7 @@ import ODropdownItem from "../dropdown/DropdownItem.vue";
import { baseComponentProps } from "@/utils/SharedProps";
import { getOption } from "@/utils/config";
import { getValueByPath } from "@/utils/helpers";
import { getValueByPath, uuid } from "@/utils/helpers";
import { isClient } from "@/utils/ssr";
import {
unrefElement,
Expand All @@ -31,6 +30,16 @@ import {
import type { ComponentClass, DynamicComponent, ClassBind } from "@/types";
enum SpecialOption {
Header,
Footer,
}
/** True if the specified option is a special option. */
function isSpecialOption(option: any): option is SpecialOption {
return option in SpecialOption;
}
/**
* Extended input that provide suggestions while the user types
* @displayName Autocomplete
Expand Down Expand Up @@ -327,14 +336,15 @@ const footerRef = ref<HTMLElement>();
const headerRef = ref<HTMLElement>();
const itemRefs = ref([]);
function setItemRef(el: HTMLElement | Component): void {
function setItemRef(
el: HTMLElement | Component,
groupIndex: number,
itemIndex: number,
): void {
if (groupIndex === 0 && itemIndex === 0) itemRefs.value.splice(0);
if (el) itemRefs.value.push(el);
}
onBeforeUpdate(() => {
itemRefs.value = [];
});
// use form input functionalities
const { checkHtml5Validity, onInvalid, onFocus, onBlur, isFocused } =
useInputHandler(inputRef, emits, props);
Expand All @@ -350,6 +360,9 @@ const hoveredOption = ref(null);
const headerHovered = ref(false);
const footerHovered = ref(false);
const hoveredId = ref(null);
const menuId = uuid();
/**
* When updating input's value
* 1. If value isn't the same as selected, set null
Expand All @@ -360,15 +373,17 @@ watch(
(value) => {
// Check if selected is invalid
const currentValue = getValue(selectedOption.value);
if (currentValue && currentValue !== value) setSelected(null, false);
if (currentValue && currentValue !== value) {
setSelected(null, false);
nextTick(() => {
// Close dropdown if data is empty
if (isEmpty.value && !slots.empty) isActive.value = false;
// Close dropdown if input is clear or else open it
else if (isFocused.value && (!props.openOnFocus || value))
isActive.value = !!value;
});
nextTick(() => {
// Close dropdown if data is empty
if (isEmpty.value && !slots.empty) isActive.value = false;
// Close dropdown if input is clear or else open it
else if (isFocused.value && (!props.openOnFocus || value))
isActive.value = !!value;
});
}
},
);
Expand All @@ -388,9 +403,9 @@ watch(
const data = computedData.value
.map((d) => d.items)
.reduce((a, b) => [...a, ...b], []);
if (!data.some((d) => getValue(d) === hoveredValue)) {
setHovered(null);
}
const index = data.findIndex((d) => getValue(d) === hoveredValue);
if (index >= 0) nextTick(() => setHoveredIdToIndex(index));
else setHovered(null);
}
},
);
Expand Down Expand Up @@ -472,7 +487,16 @@ function getValue(option: unknown): string {
/** Set which option is currently hovered. */
function setHovered(option: unknown): void {
if (option === undefined) return;
hoveredOption.value = option;
hoveredOption.value = isSpecialOption(option) ? null : option;
headerHovered.value = option === SpecialOption.Header;
footerHovered.value = option === SpecialOption.Footer;
hoveredId.value = null;
}
/** Set which option is the aria-activedescendant by index. */
function setHoveredIdToIndex(index: number): void {
const element = unrefElement(itemRefs.value[index]);
hoveredId.value = element ? element.id : null;
}
/**
Expand Down Expand Up @@ -505,33 +529,32 @@ function selectFirstOption(): void {
if (nonEmptyElements.length) {
const option = nonEmptyElements[0].items[0];
setHovered(option);
setHoveredIdToIndex(0);
} else {
setHovered(null);
}
});
}
/** Check if header or footer was selected. */
function selectHeaderOrFoterByClick(
function selectHeaderOrFooterByClick(
event: Event,
origin?: "header" | "footer",
origin?: SpecialOption,
closeDropdown = true,
): void {
if (
props.selectableHeader &&
(headerHovered.value || origin === "header")
(headerHovered.value || origin === SpecialOption.Header)
) {
emits("select-header", event);
headerHovered.value = false;
if (origin) setHovered(null);
if (closeDropdown) isActive.value = false;
}
if (
props.selectableFooter &&
(footerHovered.value || origin === "footer")
(footerHovered.value || origin === SpecialOption.Footer)
) {
emits("select-footer", event);
footerHovered.value = false;
if (origin) setHovered(null);
if (closeDropdown) isActive.value = false;
}
Expand Down Expand Up @@ -569,12 +592,10 @@ function navigateItem(direction: 1 | -1): void {
index = index < 0 ? 0 : index;
// set hover state
footerHovered.value = false;
headerHovered.value = false;
if (footerRef.value && props.selectableFooter && index === data.length - 1)
footerHovered.value = true;
setHovered(SpecialOption.Footer);
else if (headerRef.value && props.selectableHeader && index === 0)
headerHovered.value = true;
setHovered(SpecialOption.Header);
else setHovered(data[index] !== undefined ? data[index] : null);
// get items from input
Expand All @@ -587,6 +608,9 @@ function navigateItem(direction: 1 | -1): void {
const element = unrefElement(items[index]);
if (!element) return;
// set aria-activedescendant
hoveredId.value = element.id;
// define scroll position
const dropdownMenu = unrefElement(dropdownRef.value.$content);
const visMin = dropdownMenu.scrollTop;
Expand Down Expand Up @@ -624,7 +648,7 @@ function onKeydown(event: KeyboardEvent): void {
if (hoveredOption.value === null) {
// header and footer uses headerHovered && footerHovered. If header or footer
// was selected then fire event otherwise just return so a value isn't selected
selectHeaderOrFoterByClick(event, null, closeDropdown);
selectHeaderOrFooterByClick(event, null, closeDropdown);
return;
}
setSelected(hoveredOption.value, closeDropdown, event);
Expand All @@ -648,6 +672,15 @@ function handleFocus(event: Event): void {
onFocus(event);
}
/**
* Blur listener.
* Close on blur.
*/
function handleBlur(event: Event): void {
isActive.value = false;
onBlur(event);
}
/** emit input change event */
function onInput(value: string | number): void {
const currentValue = getValue(selectedOption.value);
Expand Down Expand Up @@ -767,12 +800,17 @@ function itemOptionClasses(option): ClassBind[] {
<template>
<o-dropdown
ref="dropdownRef"
v-model="selectedOption"
v-model:active="isActive"
data-oruga="autocomplete"
:class="rootClasses"
:menu-id="menuId"
:menu-tabindex="-1"
:menu-tag="menuTag"
scrollable
aria-role="listbox"
:tabindex="-1"
:trap-focus="false"
:triggers="[]"
:disabled="disabled"
:closeable="closeableOptions"
Expand All @@ -799,13 +837,17 @@ function itemOptionClasses(option): ClassBind[] {
:maxlength="maxlength"
:autocomplete="autocomplete"
:use-html5-validation="false"
role="combobox"
:aria-activedescendant="hoveredId"
:aria-autocomplete="keepFirst ? 'both' : 'list'"
:aria-controls="menuId"
:aria-expanded="isActive"
:expanded="expanded"
:disabled="disabled"
:status-icon="statusIcon"
@update:model-value="onInput"
@focus="handleFocus"
@blur="onBlur"
@blur="handleBlur"
@invalid="onInvalid"
@keydown="onKeydown"
@keydown.up.prevent="navigateItem(-1)"
Expand All @@ -818,10 +860,14 @@ function itemOptionClasses(option): ClassBind[] {
v-if="$slots.header"
ref="headerRef"
:tag="itemTag"
aria-role="button"
:tabindex="0"
:id="`${menuId}-header`"
aria-role="option"
:aria-selected="headerHovered"
:tabindex="-1"
:class="[...itemClasses, ...itemHeaderClasses]"
@click="(v, e) => selectHeaderOrFoterByClick(e, 'header')">
@click="
(v, e) => selectHeaderOrFooterByClick(e, SpecialOption.Header)
">
<!--
@slot Define an additional header
-->
Expand All @@ -833,6 +879,7 @@ function itemOptionClasses(option): ClassBind[] {
v-if="element.group"
:key="groupindex + 'group'"
:tag="itemTag"
:tabindex="-1"
:class="[...itemClasses, ...itemGroupClasses]">
<!--
@slot Override the option grpup
Expand All @@ -852,12 +899,14 @@ function itemOptionClasses(option): ClassBind[] {
<o-dropdown-item
v-for="(option, index) in element.items"
:key="groupindex + ':' + index"
:ref="setItemRef"
:ref="(el) => setItemRef(el, groupindex, index)"
:id="`${menuId}-${groupindex}-${index}`"
:value="option"
:tag="itemTag"
:class="itemOptionClasses(option)"
aria-role="button"
:tabindex="0"
aria-role="option"
:aria-selected="toRaw(option) === toRaw(hoveredOption)"
:tabindex="-1"
@click="(value, event) => setSelected(value, !keepOpen, event)">
<!--
@slot Override the select option
Expand Down Expand Up @@ -890,10 +939,14 @@ function itemOptionClasses(option): ClassBind[] {
v-if="$slots.footer"
ref="footerRef"
:tag="itemTag"
aria-role="button"
:tabindex="0"
:id="`${menuId}-footer`"
aria-role="option"
:aria-selected="footerHovered"
:tabindex="-1"
:class="[...itemClasses, ...itemFooterClasses]"
@click="(v, e) => selectHeaderOrFoterByClick(e, 'footer')">
@click="
(v, e) => selectHeaderOrFooterByClick(e, SpecialOption.Footer)
">
<!--
@slot Define an additional footer
-->
Expand Down
Loading

0 comments on commit 796ed9f

Please sign in to comment.