blob: 925dd9068dfeaca04dea73d9cabafc0537e0cd7f [file] [log] [blame] [edit]
/**
* --------------------------------------------------------------------------
* Bootstrap chip-input.js
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
* --------------------------------------------------------------------------
*/
import BaseComponent from './base-component.js'
import EventHandler from './dom/event-handler.js'
import SelectorEngine from './dom/selector-engine.js'
/**
* Constants
*/
const NAME = 'chipInput'
const DATA_KEY = 'bs.chip-input'
const EVENT_KEY = `.${DATA_KEY}`
const DATA_API_KEY = '.data-api'
const EVENT_ADD = `add${EVENT_KEY}`
const EVENT_REMOVE = `remove${EVENT_KEY}`
const EVENT_CHANGE = `change${EVENT_KEY}`
const EVENT_SELECT = `select${EVENT_KEY}`
const SELECTOR_DATA_CHIP_INPUT = '[data-bs-chip-input]'
const SELECTOR_GHOST_INPUT = '.form-ghost'
const SELECTOR_CHIP = '.chip'
const SELECTOR_CHIP_DISMISS = '.chip-dismiss'
const CLASS_NAME_CHIP = 'chip'
const CLASS_NAME_CHIP_DISMISS = 'chip-dismiss'
const CLASS_NAME_ACTIVE = 'active'
const DEFAULT_DISMISS_ICON = '<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="4" y1="4" x2="12" y2="12"/><line x1="12" y1="4" x2="4" y2="12"/></svg>'
const Default = {
separator: ',',
allowDuplicates: false,
maxChips: null,
placeholder: '',
dismissible: true,
dismissIcon: DEFAULT_DISMISS_ICON,
createOnBlur: true
}
const DefaultType = {
separator: '(string|null)',
allowDuplicates: 'boolean',
maxChips: '(number|null)',
placeholder: 'string',
dismissible: 'boolean',
dismissIcon: 'string',
createOnBlur: 'boolean'
}
/**
* Class definition
*/
class ChipInput extends BaseComponent {
constructor(element, config) {
super(element, config)
this._input = SelectorEngine.findOne(SELECTOR_GHOST_INPUT, this._element)
this._chips = []
this._selectedChips = new Set()
this._anchorChip = null // For shift+click range selection
if (!this._input) {
this._createInput()
}
this._initializeExistingChips()
this._addEventListeners()
}
// Getters
static get Default() {
return Default
}
static get DefaultType() {
return DefaultType
}
static get NAME() {
return NAME
}
// Public
add(value) {
const trimmedValue = String(value).trim()
if (!trimmedValue) {
return null
}
// Check for duplicates
if (!this._config.allowDuplicates && this._chips.includes(trimmedValue)) {
return null
}
// Check max chips limit
if (this._config.maxChips !== null && this._chips.length >= this._config.maxChips) {
return null
}
const addEvent = EventHandler.trigger(this._element, EVENT_ADD, {
value: trimmedValue,
relatedTarget: this._input
})
if (addEvent.defaultPrevented) {
return null
}
const chip = this._createChip(trimmedValue)
this._element.insertBefore(chip, this._input)
this._chips.push(trimmedValue)
EventHandler.trigger(this._element, EVENT_CHANGE, {
values: this.getValues()
})
return chip
}
remove(chipOrValue) {
let chip
let value
if (typeof chipOrValue === 'string') {
value = chipOrValue
chip = this._findChipByValue(value)
} else {
chip = chipOrValue
value = this._getChipValue(chip)
}
if (!chip || !value) {
return false
}
const removeEvent = EventHandler.trigger(this._element, EVENT_REMOVE, {
value,
chip,
relatedTarget: this._input
})
if (removeEvent.defaultPrevented) {
return false
}
// Remove from selection
this._selectedChips.delete(chip)
if (this._anchorChip === chip) {
this._anchorChip = null
}
// Remove from DOM and array
chip.remove()
this._chips = this._chips.filter(v => v !== value)
EventHandler.trigger(this._element, EVENT_CHANGE, {
values: this.getValues()
})
return true
}
removeSelected() {
const chipsToRemove = [...this._selectedChips]
for (const chip of chipsToRemove) {
this.remove(chip)
}
this._input?.focus()
}
getValues() {
return [...this._chips]
}
getSelectedValues() {
return [...this._selectedChips].map(chip => this._getChipValue(chip))
}
clear() {
const chips = SelectorEngine.find(SELECTOR_CHIP, this._element)
for (const chip of chips) {
chip.remove()
}
this._chips = []
this._selectedChips.clear()
this._anchorChip = null
EventHandler.trigger(this._element, EVENT_CHANGE, {
values: []
})
}
clearSelection() {
for (const chip of this._selectedChips) {
chip.classList.remove(CLASS_NAME_ACTIVE)
}
this._selectedChips.clear()
this._anchorChip = null
EventHandler.trigger(this._element, EVENT_SELECT, {
selected: []
})
}
selectChip(chip, options = {}) {
const { addToSelection = false, rangeSelect = false } = options
const chipElements = this._getChipElements()
if (!chipElements.includes(chip)) {
return
}
if (rangeSelect && this._anchorChip) {
// Range selection from anchor to chip
const anchorIndex = chipElements.indexOf(this._anchorChip)
const chipIndex = chipElements.indexOf(chip)
const start = Math.min(anchorIndex, chipIndex)
const end = Math.max(anchorIndex, chipIndex)
if (!addToSelection) {
this.clearSelection()
}
for (let i = start; i <= end; i++) {
this._selectedChips.add(chipElements[i])
chipElements[i].classList.add(CLASS_NAME_ACTIVE)
}
} else if (addToSelection) {
// Toggle selection
if (this._selectedChips.has(chip)) {
this._selectedChips.delete(chip)
chip.classList.remove(CLASS_NAME_ACTIVE)
} else {
this._selectedChips.add(chip)
chip.classList.add(CLASS_NAME_ACTIVE)
this._anchorChip = chip
}
} else {
// Single selection
this.clearSelection()
this._selectedChips.add(chip)
chip.classList.add(CLASS_NAME_ACTIVE)
this._anchorChip = chip
}
EventHandler.trigger(this._element, EVENT_SELECT, {
selected: this.getSelectedValues()
})
}
focus() {
this._input?.focus()
}
// Private
_getChipElements() {
return SelectorEngine.find(SELECTOR_CHIP, this._element)
}
_createInput() {
const input = document.createElement('input')
input.type = 'text'
input.className = 'form-ghost'
if (this._config.placeholder) {
input.placeholder = this._config.placeholder
}
this._element.append(input)
this._input = input
}
_initializeExistingChips() {
const existingChips = SelectorEngine.find(SELECTOR_CHIP, this._element)
for (const chip of existingChips) {
const value = this._getChipValue(chip)
if (value) {
this._chips.push(value)
this._setupChip(chip)
}
}
}
_setupChip(chip) {
// Make chip focusable
chip.setAttribute('tabindex', '0')
// Add dismiss button if needed
if (this._config.dismissible && !SelectorEngine.findOne(SELECTOR_CHIP_DISMISS, chip)) {
chip.append(this._createDismissButton())
}
}
_createChip(value) {
const chip = document.createElement('span')
chip.className = CLASS_NAME_CHIP
chip.dataset.bsChipValue = value
// Add text node
chip.append(document.createTextNode(value))
// Setup chip (tabindex, dismiss button)
this._setupChip(chip)
return chip
}
_createDismissButton() {
const button = document.createElement('button')
button.type = 'button'
button.className = CLASS_NAME_CHIP_DISMISS
button.setAttribute('aria-label', 'Remove')
button.setAttribute('tabindex', '-1') // Not in tab order, chips handle keyboard
button.innerHTML = this._config.dismissIcon
return button
}
_findChipByValue(value) {
const chips = this._getChipElements()
return chips.find(chip => this._getChipValue(chip) === value)
}
_getChipValue(chip) {
if (chip.dataset.bsChipValue) {
return chip.dataset.bsChipValue
}
const clone = chip.cloneNode(true)
const dismiss = SelectorEngine.findOne(SELECTOR_CHIP_DISMISS, clone)
if (dismiss) {
dismiss.remove()
}
return clone.textContent?.trim() || ''
}
_addEventListeners() {
// Input events
EventHandler.on(this._input, 'keydown', event => this._handleInputKeydown(event))
EventHandler.on(this._input, 'input', event => this._handleInput(event))
EventHandler.on(this._input, 'paste', event => this._handlePaste(event))
EventHandler.on(this._input, 'focus', () => this.clearSelection())
if (this._config.createOnBlur) {
EventHandler.on(this._input, 'blur', event => {
// Don't create chip if clicking on a chip
if (!event.relatedTarget?.closest(SELECTOR_CHIP)) {
this._createChipFromInput()
}
})
}
// Chip click events (delegated)
EventHandler.on(this._element, 'click', SELECTOR_CHIP, event => {
// Ignore clicks on dismiss button
if (event.target.closest(SELECTOR_CHIP_DISMISS)) {
return
}
const chip = event.target.closest(SELECTOR_CHIP)
if (chip) {
event.preventDefault()
this.selectChip(chip, {
addToSelection: event.metaKey || event.ctrlKey,
rangeSelect: event.shiftKey
})
chip.focus()
}
})
// Dismiss button clicks (delegated)
EventHandler.on(this._element, 'click', SELECTOR_CHIP_DISMISS, event => {
event.stopPropagation()
const chip = event.target.closest(SELECTOR_CHIP)
if (chip) {
this.remove(chip)
this._input?.focus()
}
})
// Chip keyboard events (delegated)
EventHandler.on(this._element, 'keydown', SELECTOR_CHIP, event => {
this._handleChipKeydown(event)
})
// Focus input when clicking container background
EventHandler.on(this._element, 'click', event => {
if (event.target === this._element) {
this.clearSelection()
this._input?.focus()
}
})
}
_handleInputKeydown(event) {
const { key } = event
switch (key) {
case 'Enter': {
event.preventDefault()
this._createChipFromInput()
break
}
case 'Backspace':
case 'Delete': {
if (this._input.value === '') {
event.preventDefault()
const chips = this._getChipElements()
if (chips.length > 0) {
// Select last chip and focus it
const lastChip = chips.at(-1)
this.selectChip(lastChip)
lastChip.focus()
}
}
break
}
case 'ArrowLeft': {
if (this._input.selectionStart === 0 && this._input.selectionEnd === 0) {
event.preventDefault()
const chips = this._getChipElements()
if (chips.length > 0) {
const lastChip = chips.at(-1)
if (event.shiftKey) {
this.selectChip(lastChip, { addToSelection: true })
} else {
this.selectChip(lastChip)
}
lastChip.focus()
}
}
break
}
case 'Escape': {
this._input.value = ''
this.clearSelection()
this._input.blur()
break
}
// No default
}
}
_handleChipKeydown(event) {
const { key } = event
const chip = event.target.closest(SELECTOR_CHIP)
if (!chip) {
return
}
const chips = this._getChipElements()
const currentIndex = chips.indexOf(chip)
switch (key) {
case 'Backspace':
case 'Delete': {
event.preventDefault()
this._handleChipDelete(currentIndex, chips)
break
}
case 'ArrowLeft': {
event.preventDefault()
this._navigateChip(chips, currentIndex, -1, event.shiftKey)
break
}
case 'ArrowRight': {
event.preventDefault()
this._navigateChip(chips, currentIndex, 1, event.shiftKey)
break
}
case 'Home': {
event.preventDefault()
this._navigateToEdge(chips, 0, event.shiftKey)
break
}
case 'End': {
event.preventDefault()
this.clearSelection()
this._input?.focus()
break
}
case 'a': {
this._handleSelectAll(event, chips)
break
}
case 'Escape': {
event.preventDefault()
this.clearSelection()
this._input?.focus()
break
}
// No default
}
}
_handleChipDelete(currentIndex, chips) {
if (this._selectedChips.size === 0) {
return
}
const nextIndex = Math.min(currentIndex, chips.length - this._selectedChips.size - 1)
this.removeSelected()
const remainingChips = this._getChipElements()
if (remainingChips.length > 0) {
const focusIndex = Math.max(0, Math.min(nextIndex, remainingChips.length - 1))
remainingChips[focusIndex].focus()
this.selectChip(remainingChips[focusIndex])
} else {
this._input?.focus()
}
}
_navigateChip(chips, currentIndex, direction, shiftKey) {
const targetIndex = currentIndex + direction
if (direction < 0 && targetIndex >= 0) {
const targetChip = chips[targetIndex]
this.selectChip(targetChip, shiftKey ? { addToSelection: true, rangeSelect: true } : {})
targetChip.focus()
} else if (direction > 0 && targetIndex < chips.length) {
const targetChip = chips[targetIndex]
this.selectChip(targetChip, shiftKey ? { addToSelection: true, rangeSelect: true } : {})
targetChip.focus()
} else if (direction > 0) {
this.clearSelection()
this._input?.focus()
}
}
_navigateToEdge(chips, targetIndex, shiftKey) {
if (chips.length === 0) {
return
}
const targetChip = chips[targetIndex]
this.selectChip(targetChip, shiftKey ? { rangeSelect: true } : {})
targetChip.focus()
}
_handleSelectAll(event, chips) {
if (!(event.metaKey || event.ctrlKey)) {
return
}
event.preventDefault()
for (const c of chips) {
this._selectedChips.add(c)
c.classList.add(CLASS_NAME_ACTIVE)
}
EventHandler.trigger(this._element, EVENT_SELECT, {
selected: this.getSelectedValues()
})
}
_handleInput(event) {
const { value } = event.target
const { separator } = this._config
if (separator && value.includes(separator)) {
const parts = value.split(separator)
for (const part of parts.slice(0, -1)) {
this.add(part.trim())
}
this._input.value = parts.at(-1)
}
}
_handlePaste(event) {
const { separator } = this._config
if (!separator) {
return
}
const pastedData = (event.clipboardData || window.clipboardData).getData('text')
if (pastedData.includes(separator)) {
event.preventDefault()
const parts = pastedData.split(separator)
for (const part of parts) {
this.add(part.trim())
}
}
}
_createChipFromInput() {
const value = this._input.value.trim()
if (value) {
this.add(value)
this._input.value = ''
}
}
}
/**
* Data API implementation
*/
EventHandler.on(document, `DOMContentLoaded${EVENT_KEY}${DATA_API_KEY}`, () => {
for (const element of SelectorEngine.find(SELECTOR_DATA_CHIP_INPUT)) {
ChipInput.getOrCreateInstance(element)
}
})
export default ChipInput