UNPKG

@coreui/vue

Version:

UI Components Library for Vue.js

504 lines (446 loc) 14 kB
import { computed, defineComponent, h, ref, useId, PropType, type VNode } from 'vue' import { chipsFromData } from '../chip-set/buildChips' import { useChipSet, type ChipSetConfig } from '../chip-set/useChipSet' import { isRTL } from '../../utils' type ChipClassName = string | ((value: string) => string) // Initial chip values can be supplied declaratively as CChip slot content // (parity with the vanilla ChipInput, which reads existing .chip elements). const slotText = (slot: unknown): string | undefined => { if (typeof slot !== 'function') { return undefined } const rendered = slot() const first = Array.isArray(rendered) ? rendered[0] : rendered if (typeof first === 'string') { return first } return typeof first?.children === 'string' ? first.children : undefined } const valuesFromSlot = (nodes: VNode[] = []): string[] => { const values: string[] = [] for (const node of nodes) { if (Array.isArray(node.children)) { values.push(...valuesFromSlot(node.children as VNode[])) continue } const value = (node.props?.value as string | undefined) ?? slotText((node.children as { default?: unknown } | null)?.default) if (value) { values.push(value) } } return values } 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, /** * Renders the chips as filter chips, each showing a leading check icon while selected. Implies `selectable`. */ filter: 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 how many chips can be selected at once. * * @values 'single', 'multiple' */ selectionMode: { type: String as PropType<'single' | 'multiple'>, default: 'multiple', }, /** * 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, slots }) { const internalValues = ref<string[]>( uniqueValues( props.defaultValue.length > 0 ? props.defaultValue : valuesFromSlot(slots.default?.()) ) ) const inputValue = ref('') const inputRef = ref<HTMLInputElement>() const generatedName = useId() const values = computed(() => props.modelValue === undefined ? uniqueValues(internalValues.value) : uniqueValues(props.modelValue as string[]) ) // CChipInput builds on the same engine as CChipSet: useChipSet owns selection // coordination, roving focus, and chip prop forwarding (provided to the chips // below). CChipInput owns the chip list and adds the text-input layer. const config = computed<ChipSetConfig>(() => ({ disabled: props.disabled, filter: props.filter, removable: Boolean(props.removable && !props.disabled && !props.readOnly), selectable: props.selectable, })) const { rootRef, clearSelection, getFocusableChips, handleKeydown } = useChipSet({ config, selectionMode: () => props.selectionMode, selected: () => undefined, restoreFocusOnRemove: false, onSelectionChange: (selected) => emit('select', selected), onRemove: (value) => remove(value), }) 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 } // Selection is cleaned up by useChipSet; here we just drop the value. emitValuesChange(values.value.filter((item) => item !== valueToRemove)) emit('remove', valueToRemove) inputRef.value?.focus() return true } const createFromInput = (): void => { if (add(inputValue.value)) { inputValue.value = '' } } const focusLastChip = (): void => { const chips = getFocusableChips() chips[chips.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': case 'ArrowRight': { // The arrow pointing toward the chips (left in LTR, right in RTL) jumps // to the last chip when the caret is at the start of the input. const towardChipsKey = isRTL(rootRef.value) ? 'ArrowRight' : 'ArrowLeft' const target = event.currentTarget as HTMLInputElement if ( event.key === towardChipsKey && 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 ? Infinity : props.maxChips - values.value.length 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 ? Infinity : props.maxChips - values.value.length 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 } // The arrow past the last chip moves focus into the text field (mirrored in RTL). if (event.key === (isRTL(rootRef.value) ? 'ArrowLeft' : 'ArrowRight')) { const chips = getFocusableChips() const lastChip = chips[chips.length - 1] if (lastChip?.contains(event.target as Node)) { event.preventDefault() inputRef.value?.focus() return } } if (handleKeydown(event)) { return } if (event.key.length === 1) { inputRef.value?.focus() } } const handleContainerClick = (event: MouseEvent): void => { if (event.target === rootRef.value) { inputRef.value?.focus() } } 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 ), ...chipsFromData( values.value.map((chipValue) => ({ value: chipValue, label: chipValue, ariaRemoveLabel: `Remove ${chipValue}`, class: resolveChipClassName(props.chipClassName, 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: clearSelection, }), h('input', { type: 'hidden', name: props.name ?? generatedName, 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 }