blob: 790ecd08ca758f6bda7baec9f2a8428f598cf70c [file] [edit]
/*!
* Bootstrap chips.js v6.0.0-alpha1 (https://getbootstrap.com/)
* Copyright 2011-2026 The Bootstrap Authors (https://github.com/twbs/bootstrap/graphs/contributors)
* 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";
import { DefaultIconAllowlist, sanitizeHtml } from "./util/sanitizer.js";
//#region js/src/chips.ts
/**
* --------------------------------------------------------------------------
* Bootstrap chips.ts
* Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)
* --------------------------------------------------------------------------
*/
/**
* Constants
*/
const NAME = "chips";
const EVENT_KEY = `.bs.chips`;
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 = {
separator: ",",
allowDuplicates: false,
maxChips: null,
placeholder: "",
dismissible: true,
dismissIcon: "<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>",
createOnBlur: true
};
const DefaultType = {
separator: "(string|null)",
allowDuplicates: "boolean",
maxChips: "(number|null)",
placeholder: "string",
dismissible: "boolean",
dismissIcon: "string",
createOnBlur: "boolean"
};
/**
* Class definition
*/
var Chips = class extends BaseComponent {
constructor(element, config) {
super(element, config);
const ghostInput = SelectorEngine.findOne(SELECTOR_GHOST_INPUT, this._element);
this._chips = [];
this._selectedChips = /* @__PURE__ */ new Set();
this._anchorChip = null;
if (ghostInput) this._input = ghostInput;
else this._createInput();
this._initializeExistingChips();
this._addEventListeners();
}
static get Default() {
return Default;
}
static get DefaultType() {
return DefaultType;
}
static get NAME() {
return NAME;
}
add(value) {
const trimmedValue = String(value).trim();
if (!trimmedValue) return null;
if (!this._config.allowDuplicates && this._chips.includes(trimmedValue)) return null;
if (this._config.maxChips !== null && this._chips.length >= this._config.maxChips) return null;
if (EventHandler.trigger(this._element, EVENT_ADD, {
value: trimmedValue,
relatedTarget: this._input
}).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;
if (EventHandler.trigger(this._element, EVENT_REMOVE, {
value,
chip,
relatedTarget: this._input
}).defaultPrevented) return false;
this._selectedChips.delete(chip);
if (this._anchorChip === chip) this._anchorChip = null;
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() {
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) {
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) 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 {
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();
}
_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) {
chip.setAttribute("tabindex", "0");
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;
chip.append(document.createTextNode(value));
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");
button.innerHTML = sanitizeHtml(this._config.dismissIcon, DefaultIconAllowlist);
return button;
}
_findChipByValue(value) {
return this._getChipElements().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() {
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) => {
if (!event.relatedTarget?.closest(SELECTOR_CHIP)) this._createChipFromInput();
});
EventHandler.on(this._element, "click", SELECTOR_CHIP, (event) => {
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();
}
});
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();
}
});
EventHandler.on(this._element, "keydown", SELECTOR_CHIP, (event) => {
this._handleChipKeydown(event);
});
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) {
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;
}
}
_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;
}
}
_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_CHIPS)) Chips.getOrCreateInstance(element);
});
//#endregion
export { Chips as default };
//# sourceMappingURL=chips.js.map