@empathyco/x-components
Version:
Empathy X Components
298 lines (295 loc) • 12.3 kB
JavaScript
import { defineComponent, ref, computed, watch, nextTick, onBeforeUnmount } from 'vue';
import { AnimationProp } from '../types/animation-prop.js';
import { debounce } from '../utils/debounce.js';
import { getTargetElement } from '../utils/html.js';
import { normalizeString } from '../utils/normalize.js';
import '../utils/storage.js';
import './animations/animate-clip-path/animate-clip-path.style.scss.js';
import './animations/animate-scale/animate-scale.style.scss.js';
import './animations/animate-translate/animate-translate.style.scss.js';
import './animations/animate-width.vue2.js';
import './animations/animate-width.vue3.js';
import './animations/change-height.vue2.js';
import './animations/collapse-height.vue2.js';
import './animations/collapse-height.vue3.js';
import './animations/collapse-width.vue2.js';
import './animations/collapse-width.vue3.js';
import './animations/cross-fade.vue2.js';
import './animations/cross-fade.vue3.js';
import './animations/fade-and-slide.vue2.js';
import './animations/fade-and-slide.vue3.js';
import './animations/fade.vue2.js';
import './animations/fade.vue3.js';
import _sfc_main$1 from './animations/no-animation.vue.js';
import './animations/staggered-fade-and-slide.vue2.js';
import './animations/staggered-fade-and-slide.vue3.js';
let dropdownCount = 0;
/**
* Dropdown component that mimics a Select element behavior, but with the option
* to customize the toggle button and each item contents.
*/
var _sfc_main = defineComponent({
name: 'BaseDropdown',
props: {
/** List of items to display.*/
items: {
type: Array,
required: true,
},
/** The selected item. */
modelValue: {
type: null,
validator: (v) => typeof v === 'string' || typeof v === 'number' || typeof v === 'object' || v === null,
required: true,
},
/** Description of what the dropdown is used for. */
ariaLabel: String,
/**
* Animation component to use for expanding the dropdown. This is a single element animation,
* so only `<transition>` components are allowed.
*/
animation: {
type: AnimationProp,
default: () => _sfc_main$1,
},
/** Time to wait without receiving any keystroke before resetting the items search query. */
searchTimeoutMs: {
type: Number,
default: 1000,
},
},
emits: ['update:modelValue'],
setup(props, { emit, slots }) {
const rootRef = ref();
/** The button that opens and closes the list of options. */
const toggleButtonRef = ref();
/** Array containing the dropdown list buttons HTMLElements. */
const itemsButtonRefs = ref([]);
/** Property to check whether the dropdown is expanded or closed. */
const isOpen = ref(false);
/** Index of the element that has the focus in the list. -1 means no element has focus. */
const highlightedItemIndex = ref(-1);
/** String to search for the first element that starts with it. */
const searchBuffer = ref('');
/** Resets the search buffer after a certain time has passed. */
let restartResetSearchTimeout;
/* Unique ID to identify the dropdown. */
const listId = `x-dropdown-${dropdownCount++}`;
/**
* Dynamic CSS classes to add to the dropdown root element.
*
* @returns An object containing the CSS classes to add to the dropdown root element.
*/
const dropdownCSSClasses = computed(() => ({ 'x-dropdown--is-open': isOpen }));
/**
* Dynamic CSS classes to add to each one of the items.
*
* @returns An object containing the CSS classes to add to each item.
*/
const itemsCSSClasses = computed(() => props.items.map((item, index) => ({
'x-dropdown__item--is-selected': props.modelValue === item,
'x-dropdown__item--is-highlighted': highlightedItemIndex.value === index,
})));
/* Opens the dropdown. */
const open = () => (isOpen.value = true);
/* Closes the dropdown. */
const close = () => (isOpen.value = false);
/* Toggles the dropdown. */
const toggle = () => (isOpen.value = !isOpen.value);
/**
* Closes the modal and focuses the toggle button.
*/
function closeAndFocusToggleButton() {
close();
toggleButtonRef.value?.focus();
}
/**
* Emits the event that the selected item has changed.
*
* @param item - The new selected item.
*/
function emitSelectedItemChanged(item) {
emit('update:modelValue', item);
closeAndFocusToggleButton();
}
/**
* Highlights the item after the one that is currently highlighted.
*/
function highlightNextItem() {
open();
highlightedItemIndex.value = (highlightedItemIndex.value + 1) % props.items.length;
}
/**
* Highlights the item before the one that is currently highlighted.
*/
function highlightPreviousItem() {
const currentIndex = highlightedItemIndex.value;
open();
highlightedItemIndex.value = currentIndex > 0 ? currentIndex - 1 : props.items.length - 1;
}
/**
* Highlights the first of the provided items.
*/
function highlightFirstItem() {
highlightedItemIndex.value = 0;
}
/**
* Highlights the last of the provided items.
*/
function highlightLastItem() {
highlightedItemIndex.value = props.items.length - 1;
}
/**
* Updates the variable that is used to search in the filters.
*
* @param event - The event coming from the user typing.
*/
function updateSearchBuffer(event) {
if (/^\w$/.test(event.key)) {
const key = event.key;
searchBuffer.value += key;
restartResetSearchTimeout();
}
}
/**
* Resets the search buffer.
*/
function resetSearchBuffer() {
searchBuffer.value = '';
}
/**
* Closes the dropdown if the passed event has happened on an element out of the dropdown.
*
* @param event - The event to check if it has happened out of the dropdown component.
*/
function closeIfEventIsOutOfDropdown(event) {
if (!rootRef.value?.contains(getTargetElement(event))) {
close();
}
}
/**
* Adds listeners to the document element to detect if the focus has moved out from the
* dropdown.
*/
function addDocumentCloseListeners() {
document.addEventListener('mousedown', closeIfEventIsOutOfDropdown);
document.addEventListener('touchstart', closeIfEventIsOutOfDropdown);
document.addEventListener('focusin', closeIfEventIsOutOfDropdown);
}
/**
* Removes the listeners of the document element to detect if the focus has moved out from the
* dropdown.
*/
function removeDocumentCloseListeners() {
document.removeEventListener('mousedown', closeIfEventIsOutOfDropdown);
document.removeEventListener('touchstart', closeIfEventIsOutOfDropdown);
document.removeEventListener('focusin', closeIfEventIsOutOfDropdown);
}
/**
* Highlights the item that matches the search buffer. To do so it checks the list buttons
* text content. It highlights items following this priority:
* - If an element is already highlighted, it starts searching from that element.
* - If no element is found starting from the previously highlighted, it returns the first one.
* - If no element is found matching the search query it highlights the first element.
*
* @param search - The search string to find in the HTML.
*/
watch(searchBuffer, search => {
if (search) {
const normalizedSearch = normalizeString(search);
const matchingIndices = itemsButtonRefs?.value?.reduce((matchingIndices, button, index) => {
const safeButtonWordCharacters = button.textContent.replace(/\W/g, '');
const normalizedButtonText = normalizeString(safeButtonWordCharacters);
if (normalizedButtonText.startsWith(normalizedSearch)) {
matchingIndices.push(index);
}
return matchingIndices;
}, []);
highlightedItemIndex.value =
// First matching item starting to search from the current highlighted element
matchingIndices?.find(index => index >= highlightedItemIndex.value) ??
// First matching item
matchingIndices?.[0] ??
// First item
0;
}
});
/**
* Updates the debounced function to reset the search.
*
* @param searchTimeoutMs - The new milliseconds that have to pass without typing before
* resetting the search.
*/
watch(() => props.searchTimeoutMs, searchTimeoutMs => {
restartResetSearchTimeout = debounce(resetSearchBuffer, searchTimeoutMs);
}, { immediate: true });
/**
* Focuses the DOM element which matches the `highlightedItemIndex`.
*
* @param highlightedItemIndex - The index of the HTML element to focus.
*/
watch(highlightedItemIndex, highlightedItemIndex => {
nextTick(() => itemsButtonRefs.value[highlightedItemIndex]?.focus());
}, { immediate: true });
/**
* When the dropdown is open it sets the focused element to the one that is selected.
*
* @param isOpen - True if the dropdown is open, false otherwise.
*/
watch(isOpen, isOpen => {
highlightedItemIndex.value = isOpen
? props.modelValue === null
? 0
: props.items.indexOf(props.modelValue)
: -1;
});
/**
* Adds and removes listeners to close the dropdown when it loses the focus.
*
* @param isOpen - True if the dropdown is open, false otherwise.
*/
watch(isOpen, isOpen => {
/*
* Because there is an issue with Firefox in macOS and Safari that doesn't focus the target
* element of the `mousedown` events, the `focusout` event `relatedTarget` property can't be
* used to detect whether the user has blurred the dropdown. The hack here is to use
* document listeners that have the side effect of losing the focus.
*/
if (isOpen) {
addDocumentCloseListeners();
}
else {
removeDocumentCloseListeners();
}
});
/**
* If the dropdown is destroyed before removing the document listeners, it ensures that they
* are removed too.
*/
onBeforeUnmount(() => {
removeDocumentCloseListeners();
});
return {
hasToggleSlot: !!slots.toggle,
closeAndFocusToggleButton,
dropdownCSSClasses,
emitSelectedItemChanged,
highlightFirstItem,
highlightLastItem,
highlightNextItem,
highlightPreviousItem,
highlightedItemIndex,
isOpen,
itemsButtonRefs,
itemsCSSClasses,
listId,
open,
rootRef,
toggle,
toggleButtonRef,
updateSearchBuffer,
};
},
});
export { _sfc_main as default };
//# sourceMappingURL=base-dropdown.vue2.js.map