UNPKG

@coreui/vue-pro

Version:

UI Components Library for Vue.js

707 lines (650 loc) 20.8 kB
import { computed, defineComponent, h, onBeforeUnmount, PropType, provide, ref, watch } from 'vue' import { createPopper, Instance } from '@popperjs/core' import { CFormControlWrapper } from './../form/CFormControlWrapper' import { CMultiSelectNativeSelect } from './CMultiSelectNativeSelect' import { CMultiSelectOptions } from './CMultiSelectOptions' import { CMultiSelectSelection } from './CMultiSelectSelection' import { isRTL } from '../../utils' import { createOption, filterOptionsList, flattenOptionsArray, selectOptions } from './utils' import type { Option, OptionsGroup, 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, /** * Enables selection cleaner element. * * @default true */ cleaner: { type: Boolean, default: true, }, /** * 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, /** * Enables search input element. */ search: { type: [Boolean, String], default: true, validator: (value: boolean | string) => { if (typeof value == 'string') { return ['external'].includes(value) } if (typeof value == 'boolean') { return true } 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) }, }, /** * 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, /** * 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 dropdownRef = ref<HTMLDivElement>() const nativeSelectRef = ref<HTMLSelectElement>() const togglerRef = ref<HTMLDivElement>() const searchRef = ref<HTMLInputElement>() const options = ref<(Option | OptionsGroup)[]>(props.options) const popper = ref<Instance>() const searchValue = ref('') const selected = ref<SelectedOption[]>([]) const userOptions = ref<Option[]>([]) const visible = ref<boolean>(props.visible) provide('nativeSelectRef', nativeSelectRef) const filteredOptions = computed(() => flattenOptionsArray( props.search === 'external' ? [...options.value, ...filterOptionsList(searchValue.value, userOptions.value)] : filterOptionsList(searchValue.value, [...options.value, ...userOptions.value]), true, ), ) const flattenedOptions = computed(() => flattenOptionsArray(props.options)) 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( () => props.options, (newValue, oldValue) => { if (JSON.stringify(newValue) !== JSON.stringify(oldValue)) { options.value = newValue 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) { selected.value = selectOptions(_selected, selected.value, deselected) } } }, { immediate: true }, ) watch(selected, () => { nativeSelectRef.value && nativeSelectRef.value.dispatchEvent(new Event('change', { bubbles: true })) if (popper.value) { popper.value.update() } }) watch(visible, () => { if (visible.value) { emit('show') window.addEventListener('mouseup', handleMouseUp) window.addEventListener('keyup', handleKeyUp) initPopper() // TODO: find better solution setTimeout(() => { searchRef.value && searchRef.value.focus() }, 100) return } emit('hide') searchValue.value = '' if (searchRef.value) { searchRef.value.value = '' } window.removeEventListener('mouseup', handleMouseUp) window.removeEventListener('keyup', handleKeyUp) destroyPopper() }) onBeforeUnmount(() => { window.removeEventListener('mouseup', handleMouseUp) window.removeEventListener('keyup', handleKeyUp) }) const initPopper = () => { if (togglerRef.value && dropdownRef.value) { popper.value = createPopper(togglerRef.value, dropdownRef.value, { placement: isRTL() ? 'bottom-end' : 'bottom-start', modifiers: [ { name: 'preventOverflow', options: { boundary: 'clippingParents', }, }, { name: 'offset', options: { offset: [0, 2], }, }, ], }) } } const destroyPopper = () => { if (popper.value) { popper.value.destroy() } popper.value = undefined } const handleKeyUp = (event: KeyboardEvent) => { if (event.key === 'Escape') { visible.value = false } } const handleMouseUp = (event: Event) => { if (multiSelectRef.value && multiSelectRef.value.contains(event.target as HTMLElement)) { return } visible.value = false } const handleSearchChange = (event: InputEvent) => { const target = event.target as HTMLInputElement searchValue.value = target.value.toLowerCase() emit('filterChange', target.value) } const handleSearchKeyDown = (event: KeyboardEvent) => { 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 handleOptionClick = (option: Option) => { if (!props.multiple) { selected.value = [option] as SelectedOption[] visible.value = false 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( [ ...flattenedOptions.value.filter((option: Option | OptionsGroup) => !option.disabled), ...userOptions.value, ], selected.value, ) } const handleDeselectAll = () => { selected.value = selected.value.filter((option) => option.disabled) } return () => [ h(CMultiSelectNativeSelect, { id: props.id, multiple: props.multiple, name: props.name, 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( CFormControlWrapper, { ...(typeof attrs['aria-describedby'] === 'string' && { describedby: attrs['aria-describedby'], }), feedback: props.feedback, feedbackInvalid: props.feedbackInvalid, feedbackValid: props.feedbackValid, id: props.id, invalid: props.invalid, label: props.label, text: props.text, tooltipFeedback: props.tooltipFeedback, valid: props.valid, }, { default: () => 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: visible.value, }, ], 'aria-expanded': visible.value, ref: multiSelectRef, }, { default: () => [ h( 'div', { class: 'form-multi-select-input-group', onClick: () => { visible.value = true }, ref: togglerRef, }, { default: () => [ h( CMultiSelectSelection, { multiple: props.multiple, placeholder: props.placeholder, onRemove: (option: Option) => !props.disabled && handleOptionClick(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, 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, }), }, ), h( 'div', { class: 'form-multi-select-buttons' }, { default: () => [ h('button', { class: 'form-multi-select-cleaner', onClick: () => handleDeselectAll(), type: 'button', }), h('button', { class: 'form-multi-select-indicator', onClick: (event: Event) => { event.preventDefault() event.stopPropagation() visible.value = !visible.value }, type: 'button', }), ], }, ), ], }, ), h( 'div', { class: 'form-multi-select-dropdown', role: 'menu', ref: dropdownRef, }, { 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) => handleOptionClick(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 }