@coreui/vue-pro
Version:
UI Components Library for Vue.js
857 lines (792 loc) • 26.6 kB
text/typescript
import {
computed,
defineComponent,
h,
nextTick,
onUnmounted,
PropType,
ref,
useId,
watch,
} from 'vue'
import { createPopper, Instance as PopperInstance, Placement } from '@popperjs/core'
import { CConditionalTeleport } from '../conditional-teleport'
import { CFormControlWrapper } from '../form/CFormControlWrapper'
import { CAutocompleteOptions } from './CAutocompleteOptions'
import type { Option, OptionsGroup, Search } from './types'
import {
filterOptions,
flattenOptionsArray,
getOptionLabel,
getFirstOptionByLabel,
isGlobalSearch,
getFirstOptionByValue,
isExternalSearch,
} from './utils'
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] as PropType<HTMLElement | (() => HTMLElement) | 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 as PropType<(Option | OptionsGroup | string)[]>,
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] as PropType<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] as PropType<Search>,
/**
* 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] as PropType<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 as PropType<'sm' | 'lg'>,
/**
* 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] as PropType<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] as PropType<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<HTMLDivElement>()
const dropdownRef = ref<HTMLDivElement>()
const togglerRef = ref<HTMLDivElement>()
const inputRef = ref<HTMLInputElement>()
const inputHintRef = ref<HTMLInputElement>()
const hint = ref<Option | undefined>()
const searchValue = ref('')
const selected = ref<Option | null>(null)
const visible = ref(props.visible)
const uniqueId = props.id ?? useId()
let popperInstance: PopperInstance | null = null
const filteredOptions = computed(() =>
isExternalSearch(props.search)
? props.options
: filterOptions(props.options, searchValue.value)
)
const popperConfig = computed(() => ({
placement: 'bottom-start' as Placement,
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: KeyboardEvent) => {
if (
isGlobalSearch(props.search) &&
inputRef.value &&
(event.key.length === 1 || event.key === 'Backspace' || event.key === 'Delete')
) {
inputRef.value.focus()
}
}
const handleInputChange = (event: Event) => {
const value = (event.target as HTMLInputElement).value
handleSearch(value)
if (selected.value !== null) {
emit('change', null)
selected.value = null
}
}
const handleInputKeyDown = (event: KeyboardEvent) => {
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 as HTMLElement
const firstOption = target.parentElement?.parentElement?.querySelectorAll(
'.autocomplete-option'
)[0] as HTMLElement | null
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 as HTMLInputElement
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: KeyboardEvent) => {
if (event.key === 'Escape') {
handleDropdownHide()
}
if (autoCompleteRef.value && !autoCompleteRef.value.contains(event.target as HTMLElement)) {
handleDropdownHide()
}
}
const handleMouseUp = (event: Event) => {
if (autoCompleteRef.value && autoCompleteRef.value.contains(event.target as HTMLElement)) {
return
}
handleDropdownHide()
}
const handleOptionClick = (option: Option) => {
handleSelect(option)
}
const handleSearch = (search: string) => {
emit('input', search)
searchValue.value = search
}
const handleSelect = (option?: Option | string) => {
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 as HTMLElement).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: 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) => {
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) => {
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) => {
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: 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 }