From e4e656a14b3232fafbf92e2bba4903dd6f7d594e Mon Sep 17 00:00:00 2001 From: ahmed Date: Sat, 24 Jan 2026 15:25:08 +0300 Subject: [PATCH] multi-select --- css/iui.css | 77 +++++++- demo/index.html | 76 ++++++++ package.json | 2 +- src/UI/Multiselect.js | 408 ++++++++++++++++++++++++++++++++++++++++++ src/iui.js | 1 + 5 files changed, 560 insertions(+), 4 deletions(-) create mode 100644 demo/index.html create mode 100644 src/UI/Multiselect.js 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";