UNPKG

@coreui/vue-pro

Version:

UI Components Library for Vue.js

724 lines (721 loc) 28.3 kB
import { defineComponent, ref, useId, computed, watch, onUnmounted, h, nextTick } from 'vue'; import { createPopper } from '@popperjs/core'; import { CConditionalTeleport } from '../conditional-teleport/CConditionalTeleport.js'; import { CFormControlWrapper } from '../form/CFormControlWrapper.js'; import { CAutocompleteOptions } from './CAutocompleteOptions.js'; import { filterOptions, isExternalSearch, getFirstOptionByValue, flattenOptionsArray, getOptionLabel, isGlobalSearch, getFirstOptionByLabel } from './utils.js'; const CAutocomplete = defineComponent({ name: 'CAutocomplete', props: { /** * Only allow selection of predefined options. * When `true`, users cannot enter custom values that are not in the options list. * When `false`, users can enter and select custom values. * * @default false */ allowOnlyDefinedOptions: { type: Boolean, default: false, }, /** * Enables selection cleaner element. * When `true`, displays a clear button that allows users to reset the selection. * The cleaner button is only shown when there is a selection and the component is not disabled or read-only. * * @default false */ cleaner: { type: Boolean, default: false, }, /** * Whether to clear the internal search state after selecting an option. * * When set to `true`, the internal search value used for filtering options is cleared * after a selection is made. This affects only the component's internal logic. * * Note: This does **not** clear the visible input field if the component is using external search * or is controlled via the `search-value` prop. In such cases, clearing must be handled externally. * * @default true */ clearSearchOnSelect: { type: Boolean, default: true, }, /** * Specifies the container element for positioning the dropdown. * - `HTMLElement`: Direct reference to a DOM element * - `Function`: Function that returns a DOM element * - `string`: CSS selector string to identify the container element * Used in conjunction with the teleport prop to control dropdown positioning. * * @default 'body' */ container: { type: [Object, String], default: 'body', }, /** * Toggle the disabled state for the component. * When `true`, the Vue.js autocomplete is non-interactive and appears visually disabled. * Users cannot type, select options, or trigger the dropdown. */ disabled: { type: Boolean, default: false, }, /** * Provide valuable, actionable feedback to your users with HTML5 form validation feedback. */ feedback: String, /** * Provide valuable, actionable invalid feedback when using standard HTML form validation which applied `invalid` prop. */ feedbackInvalid: String, /** * Provide valuable, actionable valid feedback when using standard HTML form validation which applied `valid` prop. */ feedbackValid: String, /** * Highlight options that match the search criteria. * When `true`, matching portions of option labels are visually highlighted * based on the current search input value. * * @default false */ highlightOptionsOnSearch: { type: Boolean, default: false, }, /** * Set the id attribute for the native input element. * This id is used for accessibility purposes and form associations. * If not provided, a unique id may be generated automatically. */ id: String, /** * Show dropdown indicator/arrow button. * When `true`, displays a dropdown arrow button that can be clicked * to manually show or hide the options dropdown. */ indicator: Boolean, /** * Set component validation state to invalid. */ invalid: Boolean, /** * Add a caption for a component. */ label: String, /** * When set, the options list will have a loading style: loading spinner and reduced opacity. * Use this to indicate that options are being fetched asynchronously. * The dropdown remains functional but shows visual loading indicators. */ loading: Boolean, /** * The name attribute for the input element. * Used for form submission and identification in form data. * Important for proper form handling and accessibility. */ name: String, /** * List of option elements. * Can contain Option objects, OptionsGroup objects, or plain strings. * Plain strings are converted to simple Option objects internally. * This is a required prop - the Vue.js autocomplete needs options to function. */ options: { type: Array, required: true, }, /** * Sets maxHeight of options list. * Controls the maximum height of the dropdown options container. * Can be a number (pixels) or a CSS length string (e.g., '200px', '10rem'). * When content exceeds this height, a scrollbar will appear. * * @default 'auto' */ optionsMaxHeight: { type: [Number, String], default: 'auto', }, /** * Specifies a short hint that is visible in the search input. * Displayed when the input is empty to guide user interaction. * Standard HTML input placeholder behavior. */ placeholder: String, /** * Toggle the readonly state for the component. * When `true`, users can view and interact with the dropdown but cannot * type in the search input or modify the selection through typing. * Selection via clicking options may still be possible. */ readOnly: { type: Boolean, default: false, }, /** * When it is present, it indicates that the user must choose a value before submitting the form. * Adds HTML5 form validation requirement. The form will not submit * until a valid selection is made. */ required: { type: Boolean, default: false, }, /** * Determines whether the selected options should be cleared when the options list is updated. * When `true`, any previously selected options will be reset whenever the options * list undergoes a change. This ensures that outdated selections are not retained * when new options are provided. * * @default false */ resetSelectionOnOptionsChange: { type: Boolean, default: false, }, /** * Enables and configures search functionality. * - `'external'`: Search is handled externally, filtering is not applied internally * - `'global'`: Enables global keyboard search when dropdown is closed * - Object with `external` and `global` boolean properties for fine-grained control */ search: [String, Object], /** * Sets the label for no results when filtering. * - `false`: Don't show any message when no results found * - `true`: Show default "No results found" message * - `string`: Show custom text message * * @default false */ searchNoResultsLabel: { type: [Boolean, String], default: false, }, /** * Show hint options based on the current input value. * When `true`, displays a preview/hint of the first matching option * as semi-transparent text in the input field, similar to browser autocomplete. * * @default false */ showHints: { type: Boolean, default: false, }, /** * Size the component small or large. * - `'sm'`: Small size variant * - `'lg'`: Large size variant * - `undefined`: Default/medium size */ size: String, /** * Enable teleportation of the dropdown to a different container. * When `true`, the dropdown is rendered in the container specified by the `container` prop * instead of being rendered inline. This is useful for avoiding z-index issues and positioning * problems when the autocomplete is inside elements with overflow constraints. * * @default false */ teleport: { type: Boolean, default: false, }, /** * Add helper text to a form control. */ text: String, /** * Display validation feedback in a styled tooltip. */ tooltipFeedback: Boolean, /** * Set component validation state to valid. */ valid: Boolean, /** * The model value for v-model support. * Can be a string (matched against option labels) or number (matched against option values). * Used for two-way data binding with v-model. */ modelValue: [Number, String], /** * Sets the initially selected value for the Vue.js autocomplete component. * Can be a string (matched against option labels) or number (matched against option values). * The component will attempt to find and select the matching option on mount. */ value: [Number, String], /** * Enable virtual scroller for the options list. * When `true`, only visible options are rendered in the DOM for better performance * with large option lists. Works in conjunction with `visible-items` prop. * * @default false */ virtualScroller: { type: Boolean, default: false, }, /** * Toggle the visibility of autocomplete dropdown. * Controls whether the dropdown is initially visible. * The dropdown visibility can still be toggled through user interaction. * * @default false */ visible: { type: Boolean, default: false, }, /** * Amount of visible items when virtualScroller is enabled. * Determines how many option items are rendered at once when virtual scrolling is active. * Higher values show more items but use more memory. Lower values improve performance. * * @default 10 */ visibleItems: { type: Number, default: 10, }, }, emits: [ /** * Execute a function when a user changes the selected option. * Called with the selected option object or `null` when cleared. * * @property {function(option: Option | null): void} */ 'change', /** * Execute a function when the filter/search value changes. * Called whenever the user types in the search input. * * @property {function(value: string): void} */ 'input', /** * The callback is fired when the dropdown requests to be hidden. * Called when the dropdown closes due to user interaction, clicks outside, escape key, or programmatic changes. * * @property {function(): void} */ 'hide', /** * The callback is fired when the dropdown requests to be shown. * Called when the dropdown opens due to user interaction, focus, or programmatic changes. * * @property {function(): void} */ 'show', /** * Update the model value for v-model support. * Emitted when the selected value changes to support two-way data binding. * * @property {function(value: number | string | null): void} */ 'update:modelValue', ], setup(props, { emit, attrs, slots }) { const autoCompleteRef = ref(); const dropdownRef = ref(); const togglerRef = ref(); const inputRef = ref(); const inputHintRef = ref(); const hint = ref(); const searchValue = ref(''); const selected = ref(null); const visible = ref(props.visible); const uniqueId = props.id ?? useId(); let popperInstance = null; const filteredOptions = computed(() => isExternalSearch(props.search) ? props.options : filterOptions(props.options, searchValue.value)); const popperConfig = computed(() => ({ placement: 'bottom-start', modifiers: [ { name: 'preventOverflow', options: { boundary: 'clippingParents', }, }, { name: 'offset', options: { offset: [0, 2], }, }, ], })); const handleClear = () => { if (inputRef.value) { inputRef.value.value = ''; } searchValue.value = ''; selected.value = null; emit('change', null); emit('update:modelValue', null); }; const handleGlobalSearch = (event) => { if (isGlobalSearch(props.search) && inputRef.value && (event.key.length === 1 || event.key === 'Backspace' || event.key === 'Delete')) { inputRef.value.focus(); } }; const handleInputChange = (event) => { const value = event.target.value; handleSearch(value); if (selected.value !== null) { emit('change', null); selected.value = null; } }; const handleInputKeyDown = (event) => { if (event.key === 'Escape') { handleDropdownHide(); return; } if ((event.key === 'Down' || event.key === 'ArrowDown') && inputRef.value?.value.length === inputRef.value?.selectionStart) { event.preventDefault(); handleDropdownShow(); nextTick(() => { const target = event.target; const firstOption = target.parentElement?.parentElement?.querySelectorAll('.autocomplete-option')[0]; if (firstOption) { firstOption.focus(); } }); return; } if (props.showHints && hint.value && event.key === 'Tab') { event.preventDefault(); handleSelect(hint.value); return; } if (event.key === 'Enter') { const input = event.target; const foundOptions = getFirstOptionByLabel(input.value, filteredOptions.value); if (foundOptions) { handleSelect(foundOptions); } else { if (!props.allowOnlyDefinedOptions) { handleSelect(input.value); } } handleDropdownHide(); return; } if (event.key === 'Backspace' || event.key === 'Delete') { if (selected.value !== null) { selected.value = null; emit('change', null); } return; } }; const handleKeyUp = (event) => { if (event.key === 'Escape') { handleDropdownHide(); } if (autoCompleteRef.value && !autoCompleteRef.value.contains(event.target)) { handleDropdownHide(); } }; const handleMouseUp = (event) => { if (autoCompleteRef.value && autoCompleteRef.value.contains(event.target)) { return; } handleDropdownHide(); }; const handleOptionClick = (option) => { handleSelect(option); }; const handleSearch = (search) => { emit('input', search); searchValue.value = search; }; const handleSelect = (option) => { if (option && typeof option === 'object' && option.disabled) { return; } if (inputRef.value) { inputRef.value.value = option ? getOptionLabel(option) : ''; } selected.value = option ?? null; emit('change', option ?? null); // Emit v-model update if (typeof option === 'string') { emit('update:modelValue', option); } else if (option && typeof option === 'object') { emit('update:modelValue', option.value); } else { emit('update:modelValue', null); } if (props.clearSearchOnSelect) { handleSearch(''); } else { hint.value = undefined; return; } handleDropdownHide(); }; const handleDropdownShow = () => { if (props.disabled || props.readOnly || visible.value) { return; } if (!isExternalSearch(props.search) && filteredOptions.value.length === 0 && props.searchNoResultsLabel === false) { return; } if (props.teleport && dropdownRef.value && togglerRef.value) { dropdownRef.value.style.minWidth = `${togglerRef.value.offsetWidth}px`; } visible.value = true; emit('show'); window.addEventListener('mouseup', handleMouseUp); window.addEventListener('keyup', handleKeyUp); if (togglerRef.value && dropdownRef.value) { popperInstance = createPopper(togglerRef.value, dropdownRef.value, popperConfig.value); } nextTick(() => { inputRef.value?.focus(); }); }; const handleDropdownHide = () => { visible.value = false; emit('hide'); window.removeEventListener('mouseup', handleMouseUp); window.removeEventListener('keyup', handleKeyUp); if (popperInstance) { popperInstance.destroy(); popperInstance = null; } nextTick(() => { inputRef.value?.focus(); }); }; watch(() => props.options, () => { if (props.resetSelectionOnOptionsChange) { handleClear(); } }); watch(() => [props.options, props.value, props.modelValue, inputRef.value], () => { if (inputRef.value === undefined) { return; } // Handle modelValue prop for v-model support const currentValue = props.modelValue ?? props.value; if (currentValue && typeof currentValue === 'string') { handleSelect(currentValue); return; } if (currentValue && typeof currentValue === 'number') { const foundOption = getFirstOptionByValue(currentValue, props.options); if (foundOption) { handleSelect(foundOption); } return; } const _selected = flattenOptionsArray(filteredOptions.value).find((option) => typeof option !== 'string' && option.selected === true); if (_selected) { handleSelect(_selected); } }); watch(() => [filteredOptions.value, searchValue.value, props.showHints], () => { if (!props.showHints) { return; } const findOption = searchValue.value.length > 0 ? filteredOptions.value.find((option) => getOptionLabel(option).toLowerCase().startsWith(searchValue.value.toLowerCase())) : undefined; hint.value = findOption; }); watch(() => filteredOptions.value, () => { if (!props.searchNoResultsLabel && searchValue.value !== '' && filteredOptions.value.length === 0) { if (visible.value) { handleDropdownHide(); } return; } if (searchValue.value.length > 0) { handleDropdownShow(); } }); watch(() => props.visible, (newVisible) => { if (newVisible) { handleDropdownShow(); } else { handleDropdownHide(); } }); onUnmounted(() => { window.removeEventListener('mouseup', handleMouseUp); window.removeEventListener('keyup', handleKeyUp); if (popperInstance) { popperInstance.destroy(); } }); return () => h(CFormControlWrapper, { ...(typeof attrs['aria-describedby'] === 'string' && { describedby: attrs['aria-describedby'], }), feedback: props.feedback, feedbackInvalid: props.feedbackInvalid, feedbackValid: props.feedbackValid, id: uniqueId, invalid: props.invalid, label: props.label, text: props.text, tooltipFeedback: props.tooltipFeedback, valid: props.valid, }, () => h('div', { class: [ 'autocomplete', { [`autocomplete-${props.size}`]: props.size, disabled: props.disabled, 'is-invalid': props.invalid, 'is-valid': props.valid, show: visible.value, }, ], onKeydown: handleGlobalSearch, ref: autoCompleteRef, }, [ h('div', { class: 'autocomplete-input-group', onClick: () => handleDropdownShow(), ref: togglerRef, }, [ props.showHints && searchValue.value !== '' && h('input', { class: 'autocomplete-input autocomplete-input-hint', id: `hint-${uniqueId}`, autocomplete: 'off', readonly: true, tabindex: -1, 'aria-hidden': 'true', value: hint.value ? `${searchValue.value}${getOptionLabel(hint.value).slice(searchValue.value.length)}` : '', ref: inputHintRef, }), h('input', { type: 'text', class: 'autocomplete-input', disabled: props.disabled, id: uniqueId, name: props.name || uniqueId, onBlur: (event) => { event.preventDefault(); event.stopPropagation(); if (props.allowOnlyDefinedOptions && selected.value === null && filteredOptions.value.length === 0) { handleClear(); } }, onInput: handleInputChange, onKeydown: handleInputKeyDown, placeholder: props.placeholder, autocomplete: 'off', required: props.required, 'aria-autocomplete': 'list', 'aria-expanded': visible.value, 'aria-haspopup': 'listbox', ...(props.teleport && { 'aria-owns': `autocomplete-listbox-${uniqueId}` }), readonly: props.readOnly, role: 'combobox', ref: inputRef, }), (props.cleaner || props.indicator) && h('div', { class: 'autocomplete-buttons' }, [ !props.disabled && !props.readOnly && props.cleaner && selected.value && h('button', { type: 'button', class: 'autocomplete-cleaner', onClick: (event) => { event.preventDefault(); event.stopPropagation(); handleClear(); }, }), props.indicator && h('button', { type: 'button', class: 'autocomplete-indicator', disabled: !(props.searchNoResultsLabel || filteredOptions.value.length > 0) && !isExternalSearch(props.search), onClick: (event) => { event.preventDefault(); event.stopPropagation(); if (visible.value) { handleDropdownHide(); } else { handleDropdownShow(); } }, }), ]), ]), h(CConditionalTeleport, { container: props.container, teleport: props.teleport, }, { default: () => h('div', { class: [ 'autocomplete-dropdown', { show: props.teleport && visible.value, }, ], id: `autocomplete-listbox-${uniqueId}`, role: 'listbox', 'aria-labelledby': uniqueId, ref: dropdownRef, }, [ h(CAutocompleteOptions, { highlightOptionsOnSearch: props.highlightOptionsOnSearch, loading: props.loading, onOptionClick: (option) => !props.disabled && !props.readOnly && handleOptionClick(option), options: filteredOptions.value, optionsMaxHeight: props.optionsMaxHeight, scopedSlots: slots, searchNoResultsLabel: props.searchNoResultsLabel, searchValue: searchValue.value, selected: selected.value, virtualScroller: props.virtualScroller, visible: visible.value, visibleItems: props.visibleItems, }), ]), }), ])); }, }); export { CAutocomplete }; //# sourceMappingURL=CAutocomplete.js.map