| /** |
| * -------------------------------------------------------------------------- |
| * Bootstrap chips.ts |
| * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) |
| * -------------------------------------------------------------------------- |
| */ |
| |
| import BaseComponent from './base-component.js' |
| import EventHandler, { type BootstrapEvent } from './dom/event-handler.js' |
| import SelectorEngine from './dom/selector-engine.js' |
| import { DefaultIconAllowlist, sanitizeHtml } from './util/sanitizer.js' |
| |
| /** |
| * Constants |
| */ |
| |
| const NAME = 'chips' |
| const DATA_KEY = 'bs.chips' |
| 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_CHIPS = '[data-bs-chips]' |
| 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>' |
| |
| type ChipsConfig = { |
| separator: string | null |
| allowDuplicates: boolean |
| maxChips: number | null |
| placeholder: string |
| dismissible: boolean |
| dismissIcon: string |
| createOnBlur: boolean |
| } |
| |
| const Default: ChipsConfig = { |
| 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 Chips extends BaseComponent { |
| protected declare _config: ChipsConfig |
| protected declare _input: HTMLInputElement |
| protected declare _chips: string[] |
| protected declare _selectedChips: Set<HTMLElement> |
| protected declare _anchorChip: HTMLElement | null |
| |
| constructor(element?: string | Element | null, config?: Partial<ChipsConfig> | null) { |
| super(element, config) |
| |
| const ghostInput = SelectorEngine.findOne<HTMLInputElement>(SELECTOR_GHOST_INPUT, this._element) |
| this._chips = [] |
| this._selectedChips = new Set() |
| this._anchorChip = null // For shift+click range selection |
| |
| if (ghostInput) { |
| this._input = ghostInput |
| } else { |
| this._createInput() |
| } |
| |
| this._initializeExistingChips() |
| this._addEventListeners() |
| } |
| |
| // Getters |
| static override get Default(): ChipsConfig { |
| return Default |
| } |
| |
| static override get DefaultType(): Record<string, string> { |
| return DefaultType |
| } |
| |
| static override get NAME(): string { |
| return NAME |
| } |
| |
| // Public |
| add(value: string): HTMLElement | null { |
| 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: string | HTMLElement): boolean { |
| let chip: HTMLElement | undefined |
| let value: string |
| |
| 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, then rebuild the value array from the remaining chips. |
| // This keeps `_chips` in the same order as the rendered chips even with |
| // duplicates, where splicing the first matching value would desync order. |
| chip.remove() |
| this._chips = this._getChipElements() |
| .map(chipElement => this._getChipValue(chipElement)) |
| .filter(Boolean) |
| |
| EventHandler.trigger(this._element, EVENT_CHANGE, { |
| values: this.getValues() |
| }) |
| |
| return true |
| } |
| |
| removeSelected(): void { |
| const chipsToRemove = [...this._selectedChips] |
| for (const chip of chipsToRemove) { |
| this.remove(chip) |
| } |
| |
| this._input?.focus() |
| } |
| |
| getValues(): string[] { |
| return [...this._chips] |
| } |
| |
| getSelectedValues(): string[] { |
| return [...this._selectedChips].map(chip => this._getChipValue(chip)) |
| } |
| |
| clear(): void { |
| 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(): void { |
| 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: HTMLElement, options: { addToSelection?: boolean, rangeSelect?: boolean } = {}): void { |
| 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(): void { |
| this._input?.focus() |
| } |
| |
| // Private |
| protected _getChipElements(): HTMLElement[] { |
| return SelectorEngine.find(SELECTOR_CHIP, this._element) |
| } |
| |
| protected _createInput(): void { |
| 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 |
| } |
| |
| protected _initializeExistingChips(): void { |
| 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) |
| } |
| } |
| } |
| |
| protected _setupChip(chip: HTMLElement): void { |
| // 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()) |
| } |
| } |
| |
| protected _createChip(value: string): HTMLElement { |
| 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 |
| } |
| |
| protected _createDismissButton(): HTMLButtonElement { |
| 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 |
| // dismissIcon accepts HTML (SVGs, icon fonts) and is also settable via the |
| // data API, so run it through the icon allowlist before insertion. |
| button.innerHTML = sanitizeHtml(this._config.dismissIcon, DefaultIconAllowlist) |
| return button |
| } |
| |
| protected _findChipByValue(value: string): HTMLElement | undefined { |
| const chips = this._getChipElements() |
| return chips.find(chip => this._getChipValue(chip) === value) |
| } |
| |
| protected _getChipValue(chip: HTMLElement): string { |
| if (chip.dataset.bsChipValue) { |
| return chip.dataset.bsChipValue |
| } |
| |
| const clone = chip.cloneNode(true) as HTMLElement |
| const dismiss = SelectorEngine.findOne(SELECTOR_CHIP_DISMISS, clone) |
| if (dismiss) { |
| dismiss.remove() |
| } |
| |
| return clone.textContent?.trim() || '' |
| } |
| |
| protected _addEventListeners(): void { |
| // 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 as Element).closest(SELECTOR_CHIP_DISMISS)) { |
| return |
| } |
| |
| const chip = (event.target as Element).closest<HTMLElement>(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 as Element).closest<HTMLElement>(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() |
| } |
| }) |
| } |
| |
| protected _handleInputKeydown(event: BootstrapEvent): void { |
| 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 |
| } |
| } |
| |
| protected _handleChipKeydown(event: BootstrapEvent): void { |
| const { key } = event |
| const chip = (event.target as Element).closest<HTMLElement>(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 |
| } |
| } |
| |
| protected _handleChipDelete(currentIndex: number, chips: HTMLElement[]): void { |
| 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() |
| } |
| } |
| |
| protected _navigateChip(chips: HTMLElement[], currentIndex: number, direction: number, shiftKey: boolean): void { |
| 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() |
| } |
| } |
| |
| protected _navigateToEdge(chips: HTMLElement[], targetIndex: number, shiftKey: boolean): void { |
| if (chips.length === 0) { |
| return |
| } |
| |
| const targetChip = chips[targetIndex] |
| this.selectChip(targetChip, shiftKey ? { rangeSelect: true } : {}) |
| targetChip.focus() |
| } |
| |
| protected _handleSelectAll(event: BootstrapEvent, chips: HTMLElement[]): void { |
| 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() |
| }) |
| } |
| |
| protected _handleInput(event: BootstrapEvent): void { |
| const { value } = event.target as HTMLInputElement |
| 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)! |
| } |
| } |
| |
| protected _handlePaste(event: BootstrapEvent): void { |
| const { separator } = this._config |
| if (!separator) { |
| return |
| } |
| |
| const pastedData = (event.clipboardData || (window as any).clipboardData).getData('text') |
| if (pastedData.includes(separator)) { |
| event.preventDefault() |
| |
| const parts = pastedData.split(separator) |
| for (const part of parts) { |
| this.add(part.trim()) |
| } |
| } |
| } |
| |
| protected _createChipFromInput(): void { |
| 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_CHIPS)) { |
| Chips.getOrCreateInstance(element) |
| } |
| }) |
| |
| export default Chips |
| export type { ChipsConfig } |