@coreui/vue
Version:
UI Components Library for Vue.js
425 lines (421 loc) • 15.5 kB
JavaScript
'use strict';
var vue = require('vue');
var buildChips = require('../chip-set/buildChips.js');
var useChipSet = require('../chip-set/useChipSet.js');
var isRTL = require('../../utils/isRTL.js');
// Initial chip values can be supplied declaratively as CChip slot content
// (parity with the vanilla ChipInput, which reads existing .chip elements).
const slotText = (slot) => {
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 = []) => {
const values = [];
for (const node of nodes) {
if (Array.isArray(node.children)) {
values.push(...valuesFromSlot(node.children));
continue;
}
const value = node.props?.value ??
slotText(node.children?.default);
if (value) {
values.push(value);
}
}
return values;
};
const uniqueValues = (values) => [
...new Set(values.map((value) => value.trim()).filter(Boolean)),
];
const resolveChipClassName = (chipClassName, value) => {
if (!chipClassName) {
return undefined;
}
if (typeof chipClassName === 'function') {
const resolvedClassName = chipClassName(value);
return typeof resolvedClassName === 'string' ? resolvedClassName : undefined;
}
return chipClassName;
};
const CChipInput = vue.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],
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,
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,
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,
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) => {
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 = vue.ref(uniqueValues(props.defaultValue.length > 0 ? props.defaultValue : valuesFromSlot(slots.default?.())));
const inputValue = vue.ref('');
const inputRef = vue.ref();
const generatedName = vue.useId();
const values = vue.computed(() => props.modelValue === undefined
? uniqueValues(internalValues.value)
: uniqueValues(props.modelValue));
// 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 = vue.computed(() => ({
disabled: props.disabled,
filter: props.filter,
removable: Boolean(props.removable && !props.disabled && !props.readOnly),
selectable: props.selectable,
}));
const { rootRef, clearSelection, getFocusableChips, handleKeydown } = useChipSet.useChipSet({
config,
selectionMode: () => props.selectionMode,
selected: () => undefined,
restoreFocusOnRemove: false,
onSelectionChange: (selected) => emit('select', selected),
onRemove: (value) => remove(value),
});
const emitValuesChange = (nextValues) => {
if (props.modelValue === undefined) {
internalValues.value = nextValues;
}
emit('update:modelValue', nextValues);
emit('change', nextValues);
};
const canAddMore = vue.computed(() => props.maxChips === null || values.value.length < props.maxChips);
const add = (rawValue) => {
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) => {
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 = () => {
if (add(inputValue.value)) {
inputValue.value = '';
}
};
const focusLastChip = () => {
const chips = getFocusableChips();
chips[chips.length - 1]?.focus();
};
const handleInputKeydown = (event) => {
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.default(rootRef.value) ? 'ArrowRight' : 'ArrowLeft';
const target = event.currentTarget;
if (event.key === towardChipsKey &&
target.selectionStart === 0 &&
target.selectionEnd === 0) {
event.preventDefault();
focusLastChip();
}
break;
}
case 'Escape': {
inputValue.value = '';
event.currentTarget.blur();
break;
}
// No default
}
};
const handleInputChange = (value) => {
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) => {
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) => {
if (!props.createOnBlur) {
return;
}
if (event.relatedTarget?.closest('.chip')) {
return;
}
createFromInput();
};
const handleContainerKeydown = (event) => {
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.default(rootRef.value) ? 'ArrowLeft' : 'ArrowRight')) {
const chips = getFocusableChips();
const lastChip = chips[chips.length - 1];
if (lastChip?.contains(event.target)) {
event.preventDefault();
inputRef.value?.focus();
return;
}
}
if (handleKeydown(event)) {
return;
}
if (event.key.length === 1) {
inputRef.value?.focus();
}
};
const handleContainerClick = (event) => {
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 &&
vue.h('label', {
class: 'chip-input-label',
for: props.id,
}, props.label),
...buildChips.chipsFromData(values.value.map((chipValue) => ({
value: chipValue,
label: chipValue,
ariaRemoveLabel: `Remove ${chipValue}`,
class: resolveChipClassName(props.chipClassName, chipValue),
}))),
vue.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) => handleInputChange(event.target.value),
onKeydown: handleInputKeydown,
onPaste: handlePaste,
onFocus: clearSelection,
}),
vue.h('input', {
type: 'hidden',
name: props.name ?? generatedName,
value: values.value.join(','),
}),
].filter(Boolean);
return vue.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);
};
},
});
exports.CChipInput = CChipInput;
//# sourceMappingURL=CChipInput.js.map