UNPKG

@coreui/vue

Version:

UI Components Library for Vue.js

452 lines (401 loc) 12.4 kB
import { computed, defineComponent, h, ref, watch, PropType } from 'vue' import { CChip } from '../chip/CChip' type ChipClassName = string | ((value: string) => string) const uniqueValues = (values: string[]): string[] => [ ...new Set(values.map((value) => value.trim()).filter(Boolean)), ] const resolveChipClassName = ( chipClassName: ChipClassName | undefined, value: string, ): string | undefined => { if (!chipClassName) { return undefined } if (typeof chipClassName === 'function') { const resolvedClassName = chipClassName(value) return typeof resolvedClassName === 'string' ? resolvedClassName : undefined } return chipClassName } const CChipInput = defineComponent({ name: 'CChipInput', props: { /** * Adds custom classes to chips rendered by the component. Accepts a static className or a resolver function based on chip value. */ chipClassName: { type: [String, Function] as PropType<ChipClassName>, default: undefined, }, /** * Creates a new chip when the component loses focus with a pending value. */ createOnBlur: { type: Boolean, default: true, }, /** * Sets the initial uncontrolled values rendered by the component. */ defaultValue: { type: Array as PropType<string[]>, default: () => [], }, /** * Toggle the disabled state for the component. */ disabled: Boolean, /** * Sets the `id` of the internal text input rendered by the component. */ id: String, /** * Renders an inline label inside the component container. */ label: [String, Object], /** * Sets the maximum number of chips that can be created in the component. */ maxChips: { type: Number, default: null, }, /** * The default name for a value passed using v-model. */ modelValue: { type: Array as PropType<string[]>, default: undefined, }, /** * Sets the name of the hidden input used by the component for form submission. */ name: String, /** * Sets placeholder text for the internal input of the component. */ placeholder: { type: String, default: '', }, /** * Toggle the readonly state for the component. */ readOnly: Boolean, /** * Displays remove buttons on chips managed by the component. */ removable: { type: Boolean, default: true, }, /** * Enables chip selection behavior in the component. */ selectable: Boolean, /** * Sets the separator character used to create chips while typing or pasting in the component. */ separator: { type: String, default: ',', }, /** * Size the component small or large. * * @values 'sm', 'lg' */ size: { type: String, validator: (value: string) => { return ['sm', 'lg'].includes(value) }, }, }, emits: [ /** * Event occurs when the component adds a new chip. */ 'add', /** * Event occurs when the value list changes. */ 'change', /** * Event occurs when the internal text input value changes. */ 'input', /** * Event occurs when the component removes a chip. */ 'remove', /** * Event occurs when the selected chip values change. */ 'select', /** * Emit the new value whenever there's a change. */ 'update:modelValue', ], setup(props, { attrs, emit, expose }) { const internalValues = ref<string[]>(uniqueValues(props.defaultValue)) const inputValue = ref('') const selectedValues = ref<string[]>([]) const rootRef = ref<HTMLDivElement>() const inputRef = ref<HTMLInputElement>() const values = computed(() => props.modelValue !== undefined ? uniqueValues(props.modelValue as string[]) : uniqueValues(internalValues.value) ) watch(values, (newValues) => { selectedValues.value = selectedValues.value.filter((item) => newValues.includes(item)) }) const emitValuesChange = (nextValues: string[]): void => { if (props.modelValue === undefined) { internalValues.value = nextValues } emit('update:modelValue', nextValues) emit('change', nextValues) } const canAddMore = computed( () => props.maxChips === null || values.value.length < props.maxChips, ) const add = (rawValue: string): boolean => { if (props.disabled || props.readOnly) { return false } const normalizedValue = String(rawValue).trim() if (!normalizedValue || values.value.includes(normalizedValue) || !canAddMore.value) { return false } const nextValues = [...values.value, normalizedValue] emitValuesChange(nextValues) emit('add', normalizedValue) return true } const remove = (valueToRemove: string): boolean => { if (props.disabled || props.readOnly) { return false } if (!values.value.includes(valueToRemove)) { return false } const nextValues = values.value.filter((item) => item !== valueToRemove) emitValuesChange(nextValues) selectedValues.value = selectedValues.value.filter((item) => { const wasSelected = item === valueToRemove if (wasSelected && selectedValues.value.length !== nextValues.length) { emit('select', selectedValues.value.filter((v) => v !== valueToRemove)) } return item !== valueToRemove }) emit('remove', valueToRemove) return true } const createFromInput = (): void => { if (add(inputValue.value)) { inputValue.value = '' } } const focusLastChip = (): void => { if (!rootRef.value) { return } const focusableChips = [ ...rootRef.value.querySelectorAll<HTMLElement>( '[data-coreui-chip-focusable="true"]:not(.disabled)', ), ] if (focusableChips.length === 0) { return } focusableChips[focusableChips.length - 1].focus() } const handleInputKeydown = (event: KeyboardEvent): void => { switch (event.key) { case 'Enter': { event.preventDefault() createFromInput() break } case 'Backspace': case 'Delete': { if (inputValue.value === '') { event.preventDefault() focusLastChip() } break } case 'ArrowLeft': { const target = event.currentTarget as HTMLInputElement if (target.selectionStart === 0 && target.selectionEnd === 0) { event.preventDefault() focusLastChip() } break } case 'Escape': { inputValue.value = '' ;(event.currentTarget as HTMLInputElement).blur() break } // No default } } const handleInputChange = (value: string): void => { if (props.disabled || props.readOnly) { return } if (props.separator && value.includes(props.separator)) { const parts = value.split(props.separator) const chipsToAdd = uniqueValues(parts.slice(0, -1)) const newChips = chipsToAdd.filter(chip => !values.value.includes(chip)) const availableSlots = props.maxChips !== null ? props.maxChips - values.value.length : Infinity const chipsToEmit = newChips.slice(0, availableSlots) if (chipsToEmit.length > 0) { const nextValues = [...values.value, ...chipsToEmit] chipsToEmit.forEach(chip => emit('add', chip)) emitValuesChange(nextValues) } const tail = parts[parts.length - 1] || '' inputValue.value = tail emit('input', tail) return } inputValue.value = value emit('input', value) } const handlePaste = (event: ClipboardEvent): void => { if (props.disabled || props.readOnly || !props.separator) { return } const pastedData = event.clipboardData?.getData('text') if (!pastedData?.includes(props.separator)) { return } event.preventDefault() const chipsToAdd = uniqueValues(pastedData.split(props.separator)) const newChips = chipsToAdd.filter(chip => !values.value.includes(chip)) const availableSlots = props.maxChips !== null ? props.maxChips - values.value.length : Infinity const chipsToEmit = newChips.slice(0, availableSlots) if (chipsToEmit.length > 0) { const nextValues = [...values.value, ...chipsToEmit] chipsToEmit.forEach(chip => emit('add', chip)) emitValuesChange(nextValues) } inputValue.value = '' emit('input', '') } const handleInputBlur = (event: FocusEvent): void => { if (!props.createOnBlur) { return } if ((event.relatedTarget as HTMLElement | null)?.closest('.chip')) { return } createFromInput() } const handleContainerKeydown = (event: KeyboardEvent): void => { if (event.target === inputRef.value) { return } if (event.key.length === 1) { inputRef.value?.focus() } } const handleContainerClick = (event: MouseEvent): void => { if (event.target === rootRef.value) { inputRef.value?.focus() } } const handleSelectedChange = (chipValue: string, selected: boolean): void => { selectedValues.value = selected ? uniqueValues([...selectedValues.value, chipValue]) : selectedValues.value.filter((value) => value !== chipValue) emit('select', selectedValues.value) } expose({ rootRef, inputRef }) return () => { const inputSize = Math.max(props.placeholder.length, inputValue.value.length, 1) const children = [ props.label && h( 'label', { class: 'chip-input-label', for: props.id, }, props.label, ), ...values.value.map((chipValue) => h( CChip, { ariaRemoveLabel: `Remove ${chipValue}`, class: resolveChipClassName(props.chipClassName, chipValue), disabled: props.disabled, key: chipValue, onRemove: () => remove(chipValue), onSelectedChange: (selected: boolean) => handleSelectedChange(chipValue, selected), removable: Boolean(props.removable && !props.disabled && !props.readOnly), selectable: props.selectable, selected: selectedValues.value.includes(chipValue), }, { default: () => chipValue, }, ), ), h('input', { ref: inputRef, type: 'text', id: props.id, class: 'chip-input-field', disabled: props.disabled, readonly: Boolean(!props.disabled && props.readOnly), placeholder: props.placeholder, size: inputSize, value: inputValue.value, onBlur: handleInputBlur, onInput: (event: Event) => handleInputChange((event.target as HTMLInputElement).value), onKeydown: handleInputKeydown, onPaste: handlePaste, onFocus: () => { if (selectedValues.value.length > 0) { selectedValues.value = [] emit('select', []) } }, }), props.name && h('input', { type: 'hidden', name: props.name, value: values.value.join(','), }), ].filter(Boolean) return h( 'div', { ref: rootRef, class: [ 'chip-input', { [`chip-input-${props.size}`]: props.size, disabled: props.disabled, }, attrs.class, ], 'aria-disabled': props.disabled ? true : undefined, 'aria-readonly': props.readOnly ? true : undefined, onClick: handleContainerClick, onKeydown: handleContainerKeydown, }, children, ) } }, }) export { CChipInput }