import { IgniteHtml } from '../ignite-html/ignite-html.js'; import { IgniteElement } from "../ignite-html/ignite-element.js"; import { IgniteTemplate, list, div, input, button, h4, span, i, converter, text } from "../ignite-html/ignite-template.js"; import { Chip } from "./chip.js"; import { Popper } from "./popper.js"; import { LinearProgress } from "./linear-progress.js"; class ChipList extends IgniteElement { constructor() { super(); } get styles() { return /*css*/` mt-chip-list:hover { cursor: pointer; } mt-chip-list.editing { outline: 0; box-shadow: var(--bs-focus-ring-x, 0) var(--bs-focus-ring-y, 0) var(--bs-focus-ring-blur, 0) var(--bs-focus-ring-width) var(--bs-focus-ring-color); border-color: var(--bs-primary-border-subtle); } mt-chip-list .input-container { display: flex; flex-direction: column; justify-content: center; flex: 1; flex-basis: auto; flex-shrink: 1; } mt-chip-list .input-container > .input { outline: none; } mt-chip-list .search-result:hover { background-color: #e0e0e0; border-radius: 0.3em; } `; } get properties() { return { items: [], itemConverter: (item) => new text(item), itemsMax: Number.MAX_SAFE_INTEGER, placeholder: null, editing: false, input: null, search: true, searching: false, searchLoading: false, searchResults: null, searchResultConverter: (result) => new text(result), searchDelay: 300, searchCallback: null, searchFunction: null, documentListener: null, freeForm: true, chipBackground: null, chipColor: null, changed: false, readOnly: false, showClearButton: true }; } render() { return this.template .class("form-control form-control-lg position-relative d-flex flex-row gap-2 align-items-center justify-content-between") .class(this.editing, value => value ? "editing" : null) .attribute("tabindex", "0") .onFocus(e => this.onFocus()) .child( new div().class("d-flex flex-row flex-wrap gap-2 align-items-center").child( //Placeholder new span().class("text-muted").child(this.placeholder).hide([this.editing, this.items], (editing, items) => editing || (items != null && items.length > 0)), //Chips new list(this.items, (item) => { return new Chip() .property("readOnly", this.readOnly) .property("onDelete", () => { this.items = this.items.filter(needle => needle != item); this.changed = true; //Make sure changed flag was set. }) .child(new converter(item, this.itemConverter)); }), //Text input new div() .class("input-container") .style("min-width", "1em") .show(this.editing) .child( new div() .class("input") .attribute("contenteditable", "true") .ref(this.input) .on("keydown", (e) => { //If we are read only don't do anything. if (this.readOnly) { return; } //If the escape key is pressed stop searching until something else happens. if (e.key == "Escape") { this.stopSearching(); e.preventDefault(); e.stopPropagation(); return; } //If the enter key is pressed add a new item if free form allows. else if (e.key == "Enter") { e.preventDefault(); e.stopPropagation(); if (this.freeForm && this.input.textContent.trim().length >= 1) { this.stopSearching(); //Add a new item to the chip list. if (this.items) { this.items.push(this.input.textContent.trim()); } else { this.items = [this.input.textContent.trim()]; } this.input.innerHTML = ""; this.input.focus(); //Refocus to ensure we still have it. this.changed = true; //Make sure changed flag was set. return; } else if (!this.searching) { this.searching = true; this.searchLoading = false; } } //Reset the searching and input if we get a tab, since the browser //will focus the next avaiable element. else if (e.key == "Tab") { this.editing = false; //Fire a change event if there was a change. if (this.changed) { this.changed = false; this.dispatchEvent(new Event("change")); } this.stopSearching(); this.input.innerHTML = ""; return; } //If we are not searching and a key was pressed, open the search box. else if (!this.searching && this.search && (e.key !== "Backspace" || (e.key == "Backspace" && e.target.textContent.length > 1)) && (this.items == null || this.items.length < this.itemsMax)) { this.searching = true; this.searchLoading = false; } //Don't allow input if we reached the max number of items. else if (this.items != null && this.items.length >= this.itemsMax && e.key !== "Backspace") { e.preventDefault(); } //Remove the last item and stop searching if the backspace was pressed and there is no search text else if (e.key == "Backspace" && (this.input.textContent.length == 0 || (this.input.textContent.length == 1 && this.input.textContent[0] == " "))) { e.preventDefault(); if (this.items) { this.items.pop(); this.changed = true; //Make sure changed flag was set. } this.stopSearching(); } //If we are searching and we have a search function invoke it. if (this.searching && this.searchFunction) { //Clear a pending search if there is one if (this.searchCallback) { clearTimeout(this.searchCallback); this.searchCallback = null; } //Schedule a new search this.searchCallback = setTimeout(async () => { this.searchLoading = true; this.searchResults = await this.searchFunction(this.input.textContent.trim()); this.searchLoading = false; }, this.searchDelay); } }) ) ), //Clear button new button() .class("btn btn-link text-muted fs-4") .attribute("tabindex", "-1") .title("Clear") .show([this.showClearButton, this.items], (show, items) => show && items && items.length > 0) .child(new i().class('fa fa-times')) .onClick(() => { this.items = null; this.editing = false; this.stopSearching(); this.input.blur(); this.input.innerHTML = ""; this.changed = false; this.dispatchEvent(new Event("change")); }), //Search results popper new Popper() .property("show", this.searching) .child( new div() .class("d-flex flex-column justify-content-center p-2 shadow bg-white border rounded-3") .child( //Progress bar new LinearProgress() .class("my-2") .property("loading", this.searchLoading) .property("color", "var(--bs-primary)"), //Showing results new span().class("text-muted").hide(this.searchLoading).innerText(this.searchResults, results => results && results.length > 0 ? `Showing ${results.length} ${results.length == 1 ? "result" : "results"}` : "No results"), //Results new div().class("d-flex flex-column gap-2").hide(this.searchLoading).child( new list( this.searchResults, result => new div() .class("search-result") .onClick(() => this.searchResultClick(result)) .child(new converter(result, this.searchResultConverter)) ) ) ) ), ) } ready() { //Add a listener to the document click to blur our element. this.documentListener = (e) => this.onBlur(e); window.document.addEventListener("click", this.documentListener); } cleanup() { window.document.removeEventListener("click", this.documentListener); } onFocus() { if (!this.readOnly) { if (!this.editing) { this.editing = true; this.input.focus(); this.input.textContent = " "; } else { this.input.focus(); } } } onBlur(e) { //Only blur if we are editing and the target is not ourself or any of our children. if (this.editing) { if (e.target != this && !this.contains(e.target)) { this.editing = false; this.searching = false; //Fire a change event if there was a change. if (this.changed) { this.changed = false; this.dispatchEvent(new Event("change")); } this.input.blur(); this.input.innerHTML = ""; } } } searchResultClick(item) { if (this.items == null) { this.items = []; } this.changed = true; //Make sure changed flag is set. this.items.push(item); this.searching = false; this.input.innerHTML = ""; this.input.focus(); } stopSearching() { this.searching = false; this.searchLoading = false; if (this.searchCallback) { clearTimeout(this.searchCallback); this.searchCallback = null; } } } class ChipListTemplate extends IgniteTemplate { constructor(...children) { super("mt-chip-list", children); } } IgniteHtml.register("mt-chip-list", ChipList); export { ChipListTemplate as ChipList }