@coreui/vue-pro
Version:
UI Components Library for Vue.js
864 lines (806 loc) • 27.2 kB
text/typescript
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 }