diff --git a/css/iui.css b/css/iui.css
index 9a8b921..51258e8 100644
--- a/css/iui.css
+++ b/css/iui.css
@@ -1686,7 +1686,7 @@ html[dir='rtl'] .select-label, html[dir='rtl'] .select-autocomplete-textbox, htm
font-size: small;
}
-.select-menu-repeat {
+.select-menu-repeat, .multiselect-menu-repeat, .multiselect-autocomplete-menu-repeat {
background: var(--menu-background);
overflow-y: auto;
overflow-x: hidden;
@@ -1916,12 +1916,12 @@ _:-moz-tree-row(hover), html[dir='rtl'] .autocomplete-menu, html[dir='rtl'] .sel
opacity: 1;
}
-.select-menu-repeat > div {
+.select-menu-repeat > div, .multiselect-menu-repeat > div, .multiselect-autocomplete-menu-repeat > div {
padding: 0 3px;
cursor: pointer;
}
- .select-menu-repeat > div:hover {
+ .select-menu-repeat > div:hover, .multiselect-menu-repeat > div:hover, .multiselect-autocomplete-menu-repeat > div:hover {
background: var(--menu-item-hover-background);
box-shadow: var(--menu-item-hover-box-shadow);
color: var(--menu-item-hover-color);
@@ -2276,6 +2276,72 @@ html[dir='rtl'] .bar, html[dir='rtl'] .datetimepicker-month, html[dir='rtl'] .da
width: 100%;
}
+.multiselect-input {
+ display: flex;
+ flex: 1;
+ flex-wrap: wrap;
+ gap: 4px;
+ align-items: center;
+ min-height: 1.2em;
+ padding: 2px 5px;
+ border-radius: 10px;
+ background: var(--selectlist-label-background);
+ border: var(--textbox-border);
+ transition: var(--selectlist-transition);
+ cursor: text;
+}
+
+.multiselect-input:focus-within {
+ outline: none;
+ border-color: var(--textbox-border-color-focus);
+ box-shadow: var(--textbox-box-shadow-focus);
+}
+
+.multiselect-input .multiselect-autocomplete-textbox {
+ background: transparent;
+ border: 0;
+ min-width: 120px;
+ flex: 1;
+ padding: 2px 4px;
+ user-select: auto;
+}
+
+.multiselect-chips {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 4px;
+ align-items: center;
+}
+
+.multiselect-chip {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ background: #e8f3ff;
+ color: #2a4b65;
+ border: 1px solid #c7dff5;
+ border-radius: 999px;
+ padding: 1px 6px;
+}
+
+.multiselect-chip-text {
+ padding-right: 2px;
+}
+
+.multiselect-chip-remove {
+ width: 14px;
+ height: 14px;
+ background-image: var(--multiselect-list-remove-background-image);
+ background-size: contain;
+ background-repeat: no-repeat;
+ background-position: center;
+ cursor: pointer;
+}
+
+.multiselect-chip-remove:hover {
+ background-image: var(--multiselect-list-remove-background-image-hover);
+}
+
.multiselect-add, .select-autocomplete-add, .select-add {
background: var(--selectlist-label-background);
width: 27px;
@@ -2350,6 +2416,11 @@ html[dir='rtl'] .multiselect-list-remove {
box-shadow: var(--menu-item-hover-box-shadow);
}
+.multiselect-invalid .multiselect-input {
+ border-color: var(--textbox-border-color-invalid) !important;
+ box-shadow: var(--textbox-box-shadow-invalid) !important;
+}
+
.grid {
/*
width: 100%;
diff --git a/demo/index.html b/demo/index.html
new file mode 100644
index 0000000..6adaa10
--- /dev/null
+++ b/demo/index.html
@@ -0,0 +1,76 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Multiselect Demo
+
Type to filter, click items to add, use the x to remove.
+
+
+
+ ${d.name}
+ ${d.name}
+
+
+
+
+
+
+
+
diff --git a/package.json b/package.json
index 13a14d8..784d405 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@esiur/iui",
- "version": "1.2.78",
+ "version": "1.2.8",
"description": "Interactive User Interface",
"main": "iui.js",
"type": "module",
diff --git a/src/UI/Multiselect.js b/src/UI/Multiselect.js
new file mode 100644
index 0000000..f0f14fd
--- /dev/null
+++ b/src/UI/Multiselect.js
@@ -0,0 +1,408 @@
+import { IUI } from '../Core/IUI.js';
+import IUIElement from '../Core/IUIElement.js';
+import Menu from '../UI/Menu.js';
+import Layout from '../Data/Layout.js';
+import Repeat from '../Data/Repeat.js';
+
+export default IUI.module(class Multiselect extends IUIElement {
+ constructor() {
+ super({
+ visible: false,
+ searchlist: false,
+ hasArrow: true,
+ distinct: false,
+ query: (x) => null
+ });
+
+ this._register("select");
+ this._register("input");
+ this._register("add");
+ this._register("remove");
+ }
+
+ disconnectedCallback() {
+ if (!this.searchlist && this.menu)
+ app.removeChild(this.menu);
+ }
+
+ connectedCallback() {
+ super.connectedCallback();
+ if (!this.searchlist && this.menu)
+ app.appendChild(this.menu);
+ }
+
+ _parseBoolAttr(name, fallback) {
+ let value = this.getAttribute(name);
+ if (value == null)
+ return fallback;
+ return !(value === "false" || value === "0" || value === "no");
+ }
+
+ _checkValidity() {
+
+ if (this.validate != null) {
+ try {
+ let valid = this.validate.apply(this);
+ if (!valid) {
+ this.setAttribute("invalid", "");
+ this.classList.add(this.cssClass + "-invalid");
+ return false;
+ }
+ else {
+ this.removeAttribute("invalid");
+ this.classList.remove(this.cssClass + "-invalid");
+ return true;
+ }
+ }
+ catch (ex) {
+ console.log("Validation Error", ex);
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ _getItemKey(item) {
+ if (!this._keyField)
+ return item;
+ return item == null ? item : item[this._keyField];
+ }
+
+ _hasItem(item, list) {
+ let key = this._getItemKey(item);
+ for (let i = 0; i < list.length; i++)
+ if (this._getItemKey(list[i]) === key)
+ return true;
+ return false;
+ }
+
+ _getItemFromTarget(target) {
+ let node = target;
+ while (node && node !== this.menu && node !== this.repeat) {
+ if (node.data !== undefined)
+ return node.data;
+ node = node.parentElement;
+ }
+ return undefined;
+ }
+
+ async create() {
+
+ this.isAuto = this.hasAttribute("auto");
+ this.field = this.getAttribute("field");
+
+ if (this.field != null) {
+ this.setAttribute(":data", `d['${this.field}']`);
+ this.setAttribute(":revert", `d['${this.field}'] = this.data`);
+ }
+
+ this.distinct = this._parseBoolAttr("distinct", this.distinct);
+ this._keyField = this.getAttribute("key") || this.getAttribute("key-field");
+
+ let self = this;
+
+ this.repeat = new Repeat();
+ this.repeat.cssClass = this.cssClass + "-menu-repeat";
+
+ if (this.hasAttribute("menu")) {
+ let menuData = this.getAttribute("menu");
+ this.repeat.setAttribute(":data", menuData);
+ }
+
+ if (this.hasAttribute("footer")) {
+ let footer = this.getAttribute("footer");
+ this.footer = document.createElement("div");
+ this.footer.className = this.cssClass + "-footer";
+ this.footer.innerHTML = footer;
+ }
+
+ this.menu = new Menu({ cssClass: this.cssClass + "-autocomplete-menu", "target-class": "" });
+
+ this.menu.on("click", async (e) => {
+ if (e.target != self.textbox && e.target != self.footer && e.target !== self.menu && e.target != self.repeat) {
+ let item = self._getItemFromTarget(e.target);
+ if (item !== undefined) {
+ await self._addItem(item);
+ }
+ }
+ }).on("visible", x => { if (!x.visible) self.hide(); });
+
+ this.textbox = document.createElement("input");
+ this.textbox.type = "search";
+ this.textbox.className = this.cssClass + "-autocomplete-textbox";
+
+ if (this.placeholder)
+ this.textbox.placeholder = this.placeholder;
+
+ this.textbox.addEventListener("keyup", function (e) {
+ if (e.keyCode != 13) {
+ self._query(0, self.textbox.value);
+ }
+ });
+
+ this.textbox.addEventListener("focus", function () {
+ self.show();
+ });
+
+ // get collection
+ let layout = Layout.get(this, "div", true, true);
+ let menuTemplate = null;
+ let chipTemplate = null;
+
+ if (layout != null && layout.label != undefined && layout.menu != undefined) {
+ chipTemplate = layout.label.node;
+ menuTemplate = layout.menu.node;
+ }
+ else if (layout != null && layout.null != null) {
+ chipTemplate = layout.null.node;
+ menuTemplate = layout.null.node.cloneNode(true);
+ }
+ else {
+ chipTemplate = document.createElement("div");
+ chipTemplate.innerHTML = this.innerHTML;
+ menuTemplate = document.createElement("div");
+ menuTemplate.innerHTML = this.innerHTML;
+ }
+
+ this.repeat.appendChild(menuTemplate);
+
+ this.chips = new Repeat();
+ this.chips.cssClass = this.cssClass + "-chips";
+
+ let chip = document.createElement("div");
+ chip.className = this.cssClass + "-chip";
+ chip.setAttribute("repeat", "");
+
+ let chipText = document.createElement("div");
+ chipText.className = this.cssClass + "-chip-text";
+ chipText.appendChild(chipTemplate);
+
+ let chipRemove = document.createElement("div");
+ chipRemove.className = this.cssClass + "-chip-remove";
+
+ chip.appendChild(chipText);
+ chip.appendChild(chipRemove);
+ this.chips.appendChild(chip);
+
+ this.wrap = document.createElement("div");
+ this.wrap.className = this.cssClass + "-input";
+ this.wrap.appendChild(this.chips);
+ this.wrap.appendChild(this.textbox);
+
+ this.header = document.createElement("div");
+ this.header.className = this.cssClass + "-autocomplete-header";
+ this.header.appendChild(this.wrap);
+
+ this.appendChild(this.header);
+
+ this.wrap.addEventListener("click", function (e) {
+ if (e.target && e.target.classList.contains(self.cssClass + "-chip-remove"))
+ return;
+ self.textbox.focus();
+ self.show();
+ });
+
+ this.chips.addEventListener("click", function (e) {
+ let remove = e.target?.closest("." + self.cssClass + "-chip-remove");
+ if (!remove)
+ return;
+ let chip = remove.closest("." + self.cssClass + "-chip");
+ if (!chip)
+ return;
+ let index = parseInt(chip.dataset.index, 10);
+ if (!Number.isNaN(index))
+ self._removeItemAt(index);
+ });
+
+ this.menu.appendChild(this.repeat);
+ if (this.footer != null)
+ this.menu.appendChild(this.footer);
+
+ if (this.hasArrow) {
+ this.arrow = document.createElement("div");
+ this.arrow.className = this.cssClass + "-autocomplete-arrow";
+ this.header.appendChild(this.arrow);
+
+ this.arrow.addEventListener("click", function () {
+ if (self.visible)
+ self.hide();
+ else
+ self.show();
+ });
+ }
+
+ if (this.searchlist)
+ this.appendChild(this.menu);
+ else {
+ app.appendChild(this.menu);
+ if (app.loaded) {
+ await IUI.create(this.menu);
+ IUI.bind(this.menu, false, "menu", this.__i_bindings?.scope, false);
+ this.__i_bindings?.scope?.refs?._build();
+ await IUI.created(this.menu);
+ }
+ }
+
+ this.addEventListener("click", function (e) {
+ if (e.target == self.textbox)
+ self.show();
+ });
+ }
+
+ get disabled() {
+ return this.hasAttribute("disabled");
+ }
+
+ set disabled(value) {
+ if (this.textbox)
+ this.textbox.disabled = value;
+
+ if (value)
+ this.setAttribute("disabled", value);
+ else
+ this.removeAttribute("disabled");
+ }
+
+ show() {
+ this.setVisible(true);
+ }
+
+ hide() {
+ this.setVisible(false);
+ }
+
+ clear() {
+ if (this.textbox)
+ this.textbox.value = "";
+ return this.setData([]);
+ }
+
+ async _query() {
+
+ if (this.disabled)
+ return;
+
+ let text = this.textbox ? this.textbox.value : null;
+ let res;
+
+ if (this.query instanceof Array) {
+ res = this.query;
+ }
+ else if (this.query instanceof Function) {
+ res = this.query(0, text);
+ if (res instanceof Promise)
+ res = await res;
+ }
+
+ await this.menu.setData(res);
+ }
+
+ async _addItem(item) {
+ if (item == null)
+ return;
+
+ let next = Array.isArray(this._data) ? this._data.slice() : [];
+
+ if (this.distinct && this._hasItem(item, next))
+ return;
+
+ next.push(item);
+ await this.setData(next);
+
+ this._emit("input", { value: next });
+ this._emit("add", { value: item });
+
+ if (this.textbox) {
+ this.textbox.value = "";
+ this._query(0);
+ this.textbox.focus();
+ }
+ }
+
+ async _removeItemAt(index) {
+ let list = Array.isArray(this._data) ? this._data.slice() : [];
+ if (index < 0 || index >= list.length)
+ return;
+
+ let removed = list.splice(index, 1)[0];
+ await this.setData(list);
+
+ this._emit("input", { value: list });
+ this._emit("remove", { value: removed });
+ }
+
+ _syncChipIndices() {
+ if (!this.chips || !this.chips.list)
+ return;
+ for (let i = 0; i < this.chips.list.length; i++)
+ this.chips.list[i].dataset.index = i;
+ }
+
+ async setData(value, radix) {
+
+ if (value?.toArray instanceof Function)
+ value = value.toArray();
+ else if (!(value instanceof Array || value instanceof Int32Array))
+ value = value == null ? [] : [value];
+
+ if (this.distinct) {
+ let seen = new Set();
+ let deduped = [];
+ for (let i = 0; i < value.length; i++) {
+ let key = this._getItemKey(value[i]);
+ if (!seen.has(key)) {
+ seen.add(key);
+ deduped.push(value[i]);
+ }
+ }
+ value = deduped;
+ }
+
+ await super.setData(value, radix);
+
+ this._syncChipIndices();
+
+ try {
+ this._emit("select", { value });
+ }
+ catch (ex) {
+ this._emit("select", { value });
+ }
+
+ if (this._checkValidity() && this.isAuto)
+ this.revert();
+ }
+
+ setVisible(visible) {
+ if (visible == this.visible)
+ return;
+
+ if (visible) {
+ this._query(0);
+
+ var rect = this.getBoundingClientRect();
+ this.menu.style.width = (this.clientWidth - this._computeMenuOuterWidth()) + "px";
+ this.menu.style.paddingTop = rect.height + "px";
+ this.menu.setVisible(true, rect.left, rect.top);
+ this.visible = true;
+
+ this.classList.add(this.cssClass + "-visible");
+
+ if (this.textbox)
+ setTimeout(() => {
+ this.textbox.focus();
+ }, 100);
+
+ }
+ else {
+ this.visible = false;
+ this.classList.remove(this.cssClass + "-visible");
+ this.menu.hide();
+ }
+ }
+
+ _computeMenuOuterWidth() {
+ return this.menu.offsetWidth - this.menu.clientWidth;
+ }
+});
diff --git a/src/iui.js b/src/iui.js
index 711d344..e7b6ab7 100644
--- a/src/iui.js
+++ b/src/iui.js
@@ -34,6 +34,7 @@ import "./UI/Menu.js";
import "./Data/TableRow.js";
import "./UI/Select.js";
+import "./UI/Multiselect.js";
import "./UI/DropDown.js";
import "./UI/Grid.js";