Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(autocomplete): close dropdown on enter or tab key press #761

Merged
merged 6 commits into from
Feb 5, 2024
Merged
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
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 @@
nextTick,
ref,
watch,
onBeforeUpdate,
useAttrs,
toRaw,
onMounted,
Expand All @@ -18,7 +17,7 @@

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 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 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 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 @@
(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);

Check warning on line 377 in packages/oruga-next/src/components/autocomplete/Autocomplete.vue

View check run for this annotation

Codecov / codecov/patch

packages/oruga-next/src/components/autocomplete/Autocomplete.vue#L377

Added line #L377 was not covered by tests

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(() => {

Check warning on line 379 in packages/oruga-next/src/components/autocomplete/Autocomplete.vue

View check run for this annotation

Codecov / codecov/patch

packages/oruga-next/src/components/autocomplete/Autocomplete.vue#L379

Added line #L379 was not covered by tests
// Close dropdown if data is empty
if (isEmpty.value && !slots.empty) isActive.value = false;

Check warning on line 381 in packages/oruga-next/src/components/autocomplete/Autocomplete.vue

View check run for this annotation

Codecov / codecov/patch

packages/oruga-next/src/components/autocomplete/Autocomplete.vue#L381

Added line #L381 was not covered by tests
// Close dropdown if input is clear or else open it
else if (isFocused.value && (!props.openOnFocus || value))
isActive.value = !!value;

Check warning on line 384 in packages/oruga-next/src/components/autocomplete/Autocomplete.vue

View check run for this annotation

Codecov / codecov/patch

packages/oruga-next/src/components/autocomplete/Autocomplete.vue#L383-L384

Added lines #L383 - L384 were not covered by tests
});
}
},
);

Expand All @@ -388,9 +403,9 @@
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);

Check warning on line 408 in packages/oruga-next/src/components/autocomplete/Autocomplete.vue

View check run for this annotation

Codecov / codecov/patch

packages/oruga-next/src/components/autocomplete/Autocomplete.vue#L406-L408

Added lines #L406 - L408 were not covered by tests
}
},
);
Expand Down Expand Up @@ -472,7 +487,16 @@
/** 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;

Check warning on line 499 in packages/oruga-next/src/components/autocomplete/Autocomplete.vue

View check run for this annotation

Codecov / codecov/patch

packages/oruga-next/src/components/autocomplete/Autocomplete.vue#L497-L499

Added lines #L497 - L499 were not covered by tests
}

/**
Expand Down Expand Up @@ -505,33 +529,32 @@
if (nonEmptyElements.length) {
const option = nonEmptyElements[0].items[0];
setHovered(option);
setHoveredIdToIndex(0);

Check warning on line 532 in packages/oruga-next/src/components/autocomplete/Autocomplete.vue

View check run for this annotation

Codecov / codecov/patch

packages/oruga-next/src/components/autocomplete/Autocomplete.vue#L532

Added line #L532 was not covered by tests
} 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)

Check warning on line 547 in packages/oruga-next/src/components/autocomplete/Autocomplete.vue

View check run for this annotation

Codecov / codecov/patch

packages/oruga-next/src/components/autocomplete/Autocomplete.vue#L547

Added line #L547 was not covered by tests
) {
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)

Check warning on line 555 in packages/oruga-next/src/components/autocomplete/Autocomplete.vue

View check run for this annotation

Codecov / codecov/patch

packages/oruga-next/src/components/autocomplete/Autocomplete.vue#L555

Added line #L555 was not covered by tests
) {
emits("select-footer", event);
footerHovered.value = false;
if (origin) setHovered(null);
if (closeDropdown) isActive.value = false;
}
Expand Down Expand Up @@ -569,12 +592,10 @@
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);

Check warning on line 598 in packages/oruga-next/src/components/autocomplete/Autocomplete.vue

View check run for this annotation

Codecov / codecov/patch

packages/oruga-next/src/components/autocomplete/Autocomplete.vue#L598

Added line #L598 was not covered by tests
else setHovered(data[index] !== undefined ? data[index] : null);

// get items from input
Expand All @@ -587,6 +608,9 @@
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 @@
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);

Check warning on line 651 in packages/oruga-next/src/components/autocomplete/Autocomplete.vue

View check run for this annotation

Codecov / codecov/patch

packages/oruga-next/src/components/autocomplete/Autocomplete.vue#L651

Added line #L651 was not covered by tests
return;
}
setSelected(hoveredOption.value, closeDropdown, event);
Expand All @@ -648,6 +672,15 @@
onFocus(event);
}

/**
* Blur listener.
* Close on blur.
*/
function handleBlur(event: Event): void {
isActive.value = false;
onBlur(event);

Check warning on line 681 in packages/oruga-next/src/components/autocomplete/Autocomplete.vue

View check run for this annotation

Codecov / codecov/patch

packages/oruga-next/src/components/autocomplete/Autocomplete.vue#L679-L681

Added lines #L679 - L681 were not covered by tests
}

/** emit input change event */
function onInput(value: string | number): void {
const currentValue = getValue(selectedOption.value);
Expand Down Expand Up @@ -767,12 +800,17 @@
<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 @@
: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 @@
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="

Check warning on line 868 in packages/oruga-next/src/components/autocomplete/Autocomplete.vue

View check run for this annotation

Codecov / codecov/patch

packages/oruga-next/src/components/autocomplete/Autocomplete.vue#L868

Added line #L868 was not covered by tests
(v, e) => selectHeaderOrFooterByClick(e, SpecialOption.Header)
">
<!--
@slot Define an additional header
-->
Expand All @@ -833,6 +879,7 @@
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 @@
<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 @@
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="

Check warning on line 947 in packages/oruga-next/src/components/autocomplete/Autocomplete.vue

View check run for this annotation

Codecov / codecov/patch

packages/oruga-next/src/components/autocomplete/Autocomplete.vue#L947

Added line #L947 was not covered by tests
(v, e) => selectHeaderOrFooterByClick(e, SpecialOption.Footer)
">
<!--
@slot Define an additional footer
-->
Expand Down
Loading
Loading