UNPKG

@empathyco/x-components

Version:
298 lines (295 loc) • 12.3 kB
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