UNPKG

@coreui/vue-pro

Version:

UI Components Library for Vue.js

709 lines (705 loc) 29.9 kB
'use strict'; var vue = require('vue'); var CFormControlWrapper = require('../form/CFormControlWrapper.js'); var CConditionalTeleport = require('../conditional-teleport/CConditionalTeleport.js'); var CMultiSelectNativeSelect = require('./CMultiSelectNativeSelect.js'); var CMultiSelectOptions = require('./CMultiSelectOptions.js'); var CMultiSelectSelection = require('./CMultiSelectSelection.js'); var useDropdownWithPopper = require('../../composables/useDropdownWithPopper.js'); require('@popperjs/core'); var getNextActiveElement = require('../../utils/getNextActiveElement.js'); var isEqual = require('../../utils/isEqual.js'); var utils = require('./utils.js'); const CMultiSelect = vue.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], 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, 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) => { 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], default: true, validator: (value) => { 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] === '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) => { 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) => { 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], /** * 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 = vue.ref(); const nativeSelectRef = vue.ref(); const searchRef = vue.ref(); const searchValue = vue.ref(''); const selected = vue.ref([]); const userOptions = vue.ref([]); const uniqueId = vue.useId(); const { dropdownMenuElement, dropdownRefElement, isOpen, closeDropdown, openDropdown, toggleDropdown, updatePopper, } = useDropdownWithPopper.useDropdownWithPopper(); vue.provide('nativeSelectRef', nativeSelectRef); const filteredOptions = vue.computed(() => utils.flattenOptionsArray(utils.isExternalSearch(props.search) ? [...props.options, ...utils.filterOptionsList(searchValue.value, userOptions.value)] : utils.filterOptionsList(searchValue.value, [...props.options, ...userOptions.value]), true)); const flattenedOptions = vue.computed(() => { return utils.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 = vue.computed(() => { if (props.allowCreateOptions && filteredOptions.value.some((option) => option.label && option.label.toLowerCase() === searchValue.value.toLowerCase())) { return false; } return searchRef.value && utils.createOption(String(searchValue.value), flattenedOptions.value); }); vue.watch(flattenedOptions, () => { if (props.resetSelectionOnOptionsChange) { selected.value = []; return; } const _selected = flattenedOptions.value.filter((option) => option.selected === true); const deselected = flattenedOptions.value.filter((option) => option.selected === false); if (_selected.length > 0) { const newSelectedValue = utils.selectOptions(props.multiple, _selected, selected.value, deselected); if (!isEqual.default(newSelectedValue, selected.value)) { selected.value = newSelectedValue; } } }, { immediate: true }); vue.watch(selected, () => { nativeSelectRef.value?.dispatchEvent(new Event('change', { bubbles: true })); updatePopper(); }); vue.watch(() => props.visible, (visible) => { if (visible) { openDropdown(); } else { closeDropdown(); } }, { immediate: true, }); vue.watch(isOpen, () => { if (isOpen.value) { emit('show'); if (props.teleport && dropdownMenuElement.value && dropdownRefElement.value) { dropdownMenuElement.value.style.minWidth = `${dropdownRefElement.value.offsetWidth}px`; } searchRef.value?.focus(); return; } emit('hide'); searchValue.value = ''; if (searchRef.value) { searchRef.value.value = ''; } }); const handleSearchChange = (event) => { const target = event.target; searchValue.value = target.value.toLowerCase(); emit('filterChange', target.value); }; const handleSearchKeyDown = (event) => { if (!isOpen.value) { openDropdown(); } if (event.key === 'ArrowDown' && dropdownMenuElement.value && searchRef.value && searchRef.value.value.length === searchRef.value.selectionStart) { event.preventDefault(); const items = utils.getOptionsList(dropdownMenuElement.value); const target = event.target; getNextActiveElement.default(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()), ]; } 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.disabled).pop(); if (last) { selected.value = selected.value.filter((option) => option.value !== last.value); } } }; const handleTogglerKeyDown = (event) => { if (!isOpen.value && (event.key === 'Enter' || event.key === 'ArrowDown')) { event.preventDefault(); openDropdown(); return; } if (isOpen && dropdownMenuElement.value && event.key === 'ArrowDown') { event.preventDefault(); const items = utils.getOptionsList(dropdownMenuElement.value); const target = event.target; getNextActiveElement.default(items, target, event.key === 'ArrowDown', !items.includes(target)).focus(); } }; const handleGlobalSearch = (event) => { if (utils.isGlobalSearch(props.search) && searchRef.value && (event.key.length === 1 || event.key === 'Backspace' || event.key === 'Delete')) { searchRef.value.focus(); } }; const handleOnOptionClick = (option) => { if (!props.multiple) { selected.value = [option]; 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]; } }; const handleSelectAll = () => { selected.value = utils.selectOptions(props.multiple, [ ...flattenedOptions.value.filter((option) => !option.disabled), ...userOptions.value, ], selected.value); }; const handleDeselectAll = () => { selected.value = selected.value.filter((option) => option.disabled); }; return () => [ vue.h(CFormControlWrapper.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: () => [ vue.h(CMultiSelectNativeSelect.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) => option.value.toString()) : selected.value.map((option) => option.value)[0], onChange: () => emit('change', selected.value), }), vue.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: () => [ vue.h('div', { class: 'form-multi-select-input-group', ...(!props.search && !props.disabled && { tabIndex: 0 }), onClick: () => { if (!props.disabled) { openDropdown(); } }, onKeydown: handleTogglerKeyDown, ref: dropdownRefElement, }, { default: () => [ vue.h(CMultiSelectSelection.CMultiSelectSelection, { disabled: props.disabled, multiple: props.multiple, placeholder: props.placeholder, onRemove: (option) => !props.disabled && handleOnOptionClick(option), search: props.search, selected: selected.value, selectionType: props.selectionType, selectionTypeCounterText: props.selectionTypeCounterText, }, { default: () => props.search ? vue.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) => handleSearchChange(event), onKeydown: (event) => 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 && vue.h('span', { class: 'form-multi-select-placeholder', }, { default: () => props.placeholder, }), }), vue.h('div', { class: 'form-multi-select-buttons' }, { default: () => [ !props.disabled && props.cleaner && selected.value.length > 0 && vue.h('button', { class: 'form-multi-select-cleaner', onClick: () => handleDeselectAll(), type: 'button', 'aria-label': props.ariaCleanerLabel, }), vue.h('button', { class: 'form-multi-select-indicator', onClick: (event) => { event.preventDefault(); event.stopPropagation(); if (!props.disabled) { toggleDropdown(); } }, type: 'button', 'aria-label': props.ariaIndicatorLabel, ...(props.disabled && { tabIndex: -1 }), }), ], }), ], }), vue.h(CConditionalTeleport.CConditionalTeleport, { container: props.container, teleport: props.teleport, }, { default: () => vue.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 && vue.h('button', { class: 'form-multi-select-all', onClick: () => handleSelectAll(), type: 'button', }, props.selectAllLabel), vue.h(CMultiSelectOptions.CMultiSelectOptions, { loading: props.loading, onOptionClick: (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, }), ], }), }), ], }), ], }), ]; }, }); exports.CMultiSelect = CMultiSelect; //# sourceMappingURL=CMultiSelect.js.map