UNPKG

@coreui/vue-pro

Version:

UI Components Library for Vue.js

864 lines (806 loc) 27.2 kB
import { computed, defineComponent, h, PropType, provide, ref, useId, watch } from 'vue' import { CFormControlWrapper } from './../form/CFormControlWrapper' import { CConditionalTeleport } from '../conditional-teleport' import { CMultiSelectNativeSelect } from './CMultiSelectNativeSelect' import { CMultiSelectOptions } from './CMultiSelectOptions' import { CMultiSelectSelection } from './CMultiSelectSelection' import { useDropdownWithPopper } from '../../composables' import { getNextActiveElement, isEqual } from '../../utils' import { createOption, filterOptionsList, flattenOptionsArray, getOptionsList, isExternalSearch, isGlobalSearch, selectOptions, } from './utils' import type { Option, OptionsGroup, Search, SelectedOption } from './types' const CMultiSelect = defineComponent({ name: 'CMultiSelect', props: { /** * Allow users to create options if they are not in the list of options. * * @since 4.9.0 */ allowCreateOptions: Boolean, /** * A string that provides an accessible label for the cleaner button. This label is read by screen readers to describe the action associated with the button. * * @since 5.13.0 */ ariaCleanerLabel: { type: String, default: 'Clear all selections', }, /** * A string that provides an accessible label for the indicator button. This label is read by screen readers to describe the action associated with the button. * * @since 5.7.0 */ ariaIndicatorLabel: { type: String, default: 'Toggle dropdown', }, /** * Enables selection cleaner element. * * @default true */ cleaner: { type: Boolean, default: true, }, /** * Appends the dropdown to a specific element. You can pass an HTML element or function that returns a single element. * * @since 5.7.0 */ container: { type: [Object, String] as PropType<HTMLElement | (() => HTMLElement) | string>, default: 'body', }, /** * Clear current search on selecting an item. * * @since 4.9.0 */ clearSearchOnSelect: Boolean, /** * Toggle the disabled state for the component. */ disabled: Boolean, /** * Provide valuable, actionable feedback. * * @since 4.6.0 */ feedback: String, /** * Provide valuable, actionable feedback. * * @since 4.6.0 */ feedbackInvalid: String, /** * Provide valuable, actionable invalid feedback when using standard HTML form validation which applied two CSS pseudo-classes, `:invalid` and `:valid`. * * @since 4.6.0 */ feedbackValid: String, /** * Set the id attribute for the native select element. * * **[Deprecated since v5.3.0]** The name attribute for the native select element is generated based on the `id` property: * - `<select name="\{id\}-multi-select" />` */ id: String, /** * Set component validation state to invalid. * * @since 4.6.0 */ invalid: Boolean, /** * Add a caption for a component. * * @since 4.6.0 */ label: String, /** * When set, the options list will have a loading style: loading spinner and reduced opacity. * * @since 4.9.0 */ loading: Boolean, /** * It specifies that multiple options can be selected at once. * * @default true */ multiple: { type: Boolean, default: true, }, /** * The name attribute for the select element. * * @since 5.3.0 */ name: String, /** * List of option elements. */ options: { type: Array as PropType<(Option | OptionsGroup)[]>, default: () => [], }, /** * Sets maxHeight of options list. * * @default 'auto' */ optionsMaxHeight: { type: [Number, String], default: 'auto', }, /** * Sets option style. * * @values 'checkbox', 'text' * @default 'checkbox' */ optionsStyle: { type: String, default: 'checkbox', validator: (value: string) => { return ['checkbox', 'text'].includes(value) }, }, /** * Specifies a short hint that is visible in the search input. * * @default 'Select...'' */ placeholder: { type: String, default: 'Select...', }, /** * When it is present, it indicates that the user must choose a value before submitting the form. */ required: Boolean, /** * Determines whether the selected options should be cleared when the options list is updated. When set to 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. * * @since 5.3.0 */ resetSelectionOnOptionsChange: Boolean, /** * The `search` prop determines how the search input element is enabled and behaves. It accepts multiple types to provide flexibility in configuring search behavior: * * - `true` : Enables the default search input element with standard behavior. * - `'external'`: Enables an external search mechanism, possibly integrating with external APIs or services. * - `'global'`: When set, the user can perform searches across the entire component, regardless of where their focus is within the component. * - `{ external?: boolean; global?: boolean }`: Allows for granular control over the search behavior by specifying individual properties. It is useful when you also want to use external and global search. */ search: { type: [Boolean, String, Object] as PropType<Search>, default: true, validator: (value: boolean | object | string) => { if (typeof value == 'boolean') { return true } if (typeof value == 'string') { return ['external', 'global'].includes(value) } if (typeof value === 'object' && value !== null) { // Ensure that all keys are either 'external' or 'global' const validKeys = ['external', 'global'] const keys = Object.keys(value) const allKeysValid = keys.every((key) => validKeys.includes(key)) if (!allKeysValid) { return false } // Ensure that all values corresponding to the keys are boolean const allValuesBoolean = keys.every( (key) => typeof value[key as keyof typeof value] === 'boolean' ) return allValuesBoolean } return false }, }, /** * Sets the label for no results when filtering. */ searchNoResultsLabel: { type: String, default: 'no items', }, /** * Enables select all button. * * @default true */ selectAll: { type: Boolean, default: true, }, /** * Sets the select all button label. * * @default 'Select all options' */ selectAllLabel: { type: String, default: 'Select all options', }, /** * Sets the selection style. * * @values 'counter', 'tags', 'text' * @default 'tags' */ selectionType: { type: String, default: 'tags', validator: (value: string) => { return ['counter', 'tags', 'text'].includes(value) }, }, /** * Sets the counter selection label. * * @default 'item(s) selected' */ selectionTypeCounterText: { type: String, default: 'item(s) selected', }, /** * Size the component small or large. * * @values 'sm', 'lg' */ size: { type: String, validator: (value: string) => { return ['sm', 'lg'].includes(value) }, }, /** * Generates dropdown menu using Teleport. * * @since 5.7.0 */ teleport: { type: [Boolean], default: false, }, /** * Add helper text to the component. * * @since 4.6.0 */ text: String, /** * Display validation feedback in a styled tooltip. * * @since 4.6.0 */ tooltipFeedback: Boolean, /** * Set component validation state to valid. * * @since 4.6.0 */ valid: Boolean, /** * Sets the initially selected values for the multi-select component. * * @since 5.11.0 */ value: [String, Number, Array] as PropType<string | number | (string | number)[]>, /** * Enable virtual scroller for the options list. * * @since 4.8.0 */ virtualScroller: Boolean, /** * Toggle the visibility of multi select dropdown. * * @default false */ visible: Boolean, /** * * Amount of visible items when virtualScroller is set to `true`. * * @since 4.8.0 */ visibleItems: { type: Number, default: 10, }, }, emits: [ /** * Execute a function when a user changes the selected option. [docs] */ 'change', /** * Execute a function when the filter value changed. * * @since 4.7.0 */ 'filterChange', /** * The callback is fired when the Multi Select component requests to be hidden. */ 'hide', /** * The callback is fired when the Multi Select component requests to be shown. */ 'show', ], setup(props, { attrs, emit, slots }) { const multiSelectRef = ref<HTMLDivElement>() const nativeSelectRef = ref<HTMLSelectElement>() const searchRef = ref<HTMLInputElement>() const searchValue = ref('') const selected = ref<SelectedOption[]>([]) const userOptions = ref<Option[]>([]) const uniqueId = useId() const { dropdownMenuElement, dropdownRefElement, isOpen, closeDropdown, openDropdown, toggleDropdown, updatePopper, } = useDropdownWithPopper() provide('nativeSelectRef', nativeSelectRef) const filteredOptions = computed(() => flattenOptionsArray( isExternalSearch(props.search) ? [...props.options, ...filterOptionsList(searchValue.value, userOptions.value)] : filterOptionsList(searchValue.value, [...props.options, ...userOptions.value]), true ) ) const flattenedOptions = computed(() => { return flattenOptionsArray(props.options).map((option) => { if (props.value && Array.isArray(props.value)) { return { ...option, selected: props.value.includes(option.value), } } if (props.value === option.value) { return { ...option, selected: true, } } return option }) }) const userOption = computed(() => { if ( props.allowCreateOptions && filteredOptions.value.some( (option) => option.label && option.label.toLowerCase() === searchValue.value.toLowerCase() ) ) { return false } return searchRef.value && createOption(String(searchValue.value), flattenedOptions.value) }) watch( flattenedOptions, () => { if (props.resetSelectionOnOptionsChange) { selected.value = [] return } const _selected = flattenedOptions.value.filter( (option: Option | OptionsGroup) => option.selected === true ) const deselected = flattenedOptions.value.filter( (option: Option | OptionsGroup) => option.selected === false ) as Option[] if (_selected.length > 0) { const newSelectedValue = selectOptions( props.multiple, _selected, selected.value, deselected ) if (!isEqual(newSelectedValue, selected.value)) { selected.value = newSelectedValue } } }, { immediate: true } ) watch(selected, () => { nativeSelectRef.value?.dispatchEvent(new Event('change', { bubbles: true })) updatePopper() }) watch( () => props.visible, (visible) => { if (visible) { openDropdown() } else { closeDropdown() } }, { immediate: true, } ) watch(isOpen, () => { if (isOpen.value) { emit('show') if (props.teleport && dropdownMenuElement.value && dropdownRefElement.value) { dropdownMenuElement.value.style.minWidth = `${(dropdownRefElement.value as HTMLElement).offsetWidth}px` } searchRef.value?.focus() return } emit('hide') searchValue.value = '' if (searchRef.value) { searchRef.value.value = '' } }) const handleSearchChange = (event: InputEvent) => { const target = event.target as HTMLInputElement searchValue.value = target.value.toLowerCase() emit('filterChange', target.value) } const handleSearchKeyDown = (event: KeyboardEvent) => { if (!isOpen.value) { openDropdown() } if ( event.key === 'ArrowDown' && dropdownMenuElement.value && searchRef.value && searchRef.value.value.length === searchRef.value.selectionStart ) { event.preventDefault() const items = getOptionsList(dropdownMenuElement.value) const target = event.target as HTMLDivElement getNextActiveElement( items, target, event.key === 'ArrowDown', !items.includes(target) ).focus() return } if (event.key === 'Enter' && searchValue.value && props.allowCreateOptions) { event.preventDefault() if (!userOption.value) { selected.value = [ ...selected.value, filteredOptions.value.find( (option) => String(option.label).toLowerCase() === searchValue.value.toLowerCase() ) as Option, ] } if (userOption.value) { selected.value = [...selected.value, ...userOption.value] userOptions.value = [...userOptions.value, ...userOption.value] } searchValue.value = '' if (searchRef.value) { searchRef.value.value = '' } return } if (searchValue.value.length > 0) { return } if (event.key === 'Backspace' || event.key === 'Delete') { const last = selected.value.filter((option: Option) => !option.disabled).pop() if (last) { selected.value = selected.value.filter((option: Option) => option.value !== last.value) } } } const handleTogglerKeyDown = (event: KeyboardEvent) => { if (!isOpen.value && (event.key === 'Enter' || event.key === 'ArrowDown')) { event.preventDefault() openDropdown() return } if (isOpen && dropdownMenuElement.value && event.key === 'ArrowDown') { event.preventDefault() const items = getOptionsList(dropdownMenuElement.value) const target = event.target as HTMLDivElement getNextActiveElement( items, target, event.key === 'ArrowDown', !items.includes(target) ).focus() } } const handleGlobalSearch = (event: KeyboardEvent) => { if ( isGlobalSearch(props.search) && searchRef.value && (event.key.length === 1 || event.key === 'Backspace' || event.key === 'Delete') ) { searchRef.value.focus() } } const handleOnOptionClick = (option: Option) => { if (!props.multiple) { selected.value = [option] as SelectedOption[] closeDropdown() if (searchRef.value) { searchRef.value.value = '' } return } if (option.custom && !userOptions.value.some((_option) => _option.value === option.value)) { userOptions.value = [...userOptions.value, option] } if (props.clearSearchOnSelect || option.custom) { searchValue.value = '' if (searchRef.value) { searchRef.value.value = '' searchRef.value.focus() } } if (selected.value.some((_option) => _option.value === option.value)) { selected.value = selected.value.filter((_option) => _option.value !== option.value) } else { selected.value = [...selected.value, option] as SelectedOption[] } } const handleSelectAll = () => { selected.value = selectOptions( props.multiple, [ ...flattenedOptions.value.filter((option: Option | OptionsGroup) => !option.disabled), ...userOptions.value, ], selected.value ) } const handleDeselectAll = () => { selected.value = selected.value.filter((option) => option.disabled) } return () => [ h( CFormControlWrapper, { ...(typeof attrs['aria-describedby'] === 'string' && { describedby: attrs['aria-describedby'], }), feedback: props.feedback, feedbackInvalid: props.feedbackInvalid, feedbackValid: props.feedbackValid, id: props.id ?? uniqueId, invalid: props.invalid, label: props.label, text: props.text, tooltipFeedback: props.tooltipFeedback, valid: props.valid, }, { default: () => [ h(CMultiSelectNativeSelect, { id: props.id ?? uniqueId, multiple: props.multiple, name: props.name ?? uniqueId, options: selected.value, required: props.required, value: props.multiple ? selected.value.map((option: SelectedOption) => option.value.toString()) : selected.value.map((option: SelectedOption) => option.value)[0], onChange: () => emit('change', selected.value), }), h( 'div', { class: [ 'form-multi-select', { disabled: props.disabled, [`form-multi-select-${props.size}`]: props.size, 'is-invalid': props.invalid, 'is-valid': props.valid, show: isOpen.value, }, ], onKeydown: handleGlobalSearch, role: 'combobox', 'aria-haspopup': 'listbox', 'aria-expanded': isOpen.value, ...(props.teleport && { 'aria-owns': `multi-select-listbox-${uniqueId}` }), ref: multiSelectRef, }, { default: () => [ h( 'div', { class: 'form-multi-select-input-group', ...(!props.search && !props.disabled && { tabIndex: 0 }), onClick: () => { if (!props.disabled) { openDropdown() } }, onKeydown: handleTogglerKeyDown, ref: dropdownRefElement, }, { default: () => [ h( CMultiSelectSelection, { disabled: props.disabled, multiple: props.multiple, placeholder: props.placeholder, onRemove: (option: Option) => !props.disabled && handleOnOptionClick(option), search: props.search, selected: selected.value, selectionType: props.selectionType, selectionTypeCounterText: props.selectionTypeCounterText, }, { default: () => props.search ? h('input', { type: 'text', class: 'form-multi-select-search', disabled: props.disabled, id: `search-${props.id ?? uniqueId}`, name: `search-${props.name ?? uniqueId}`, autocomplete: 'off', onInput: (event: InputEvent) => handleSearchChange(event), onKeydown: (event: KeyboardEvent) => handleSearchKeyDown(event), ...(selected.value.length === 0 && { placeholder: props.placeholder, }), ...(selected.value.length > 0 && props.selectionType === 'counter' && { placeholder: `${selected.value.length} ${props.selectionTypeCounterText}`, }), ...(selected.value.length > 0 && !props.multiple && { placeholder: selected.value.map( (option) => option.label )[0], }), ...(props.multiple && selected.value.length > 0 && props.selectionType !== 'counter' && { size: searchValue.value.length + 2, }), ref: searchRef, }) : selected.value.length === 0 && h( 'span', { class: 'form-multi-select-placeholder', }, { default: () => props.placeholder, } ), } ), h( 'div', { class: 'form-multi-select-buttons' }, { default: () => [ !props.disabled && props.cleaner && selected.value.length > 0 && h('button', { class: 'form-multi-select-cleaner', onClick: () => handleDeselectAll(), type: 'button', 'aria-label': props.ariaCleanerLabel, }), h('button', { class: 'form-multi-select-indicator', onClick: (event: Event) => { event.preventDefault() event.stopPropagation() if (!props.disabled) { toggleDropdown() } }, type: 'button', 'aria-label': props.ariaIndicatorLabel, ...(props.disabled && { tabIndex: -1 }), }), ], } ), ], } ), h( CConditionalTeleport, { container: props.container, teleport: props.teleport, }, { default: () => h( 'div', { class: [ 'form-multi-select-dropdown', { show: props.teleport && isOpen.value, }, ], id: `multi-select-listbox-${uniqueId}`, onKeydown: handleGlobalSearch, role: 'listbox', 'aria-labelledby': props.id ?? uniqueId, 'aria-multiselectable': props.multiple, ref: dropdownMenuElement, }, { default: () => [ props.multiple && props.selectAll && h( 'button', { class: 'form-multi-select-all', onClick: () => handleSelectAll(), type: 'button', }, props.selectAllLabel ), h(CMultiSelectOptions, { loading: props.loading, onOptionClick: (option: Option) => handleOnOptionClick(option), options: filteredOptions.value.length === 0 && props.allowCreateOptions ? userOption.value || [] : filteredOptions.value, optionsMaxHeight: props.optionsMaxHeight, optionsStyle: props.optionsStyle, scopedSlots: slots, searchNoResultsLabel: props.searchNoResultsLabel, selected: selected.value, virtualScroller: props.virtualScroller, visibleItems: props.visibleItems, }), ], } ), } ), ], } ), ], } ), ] }, }) export { CMultiSelect }