321 lines
14 KiB
JavaScript
321 lines
14 KiB
JavaScript
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
|
|
} |