mirror of
https://github.com/esiur/iui.git
synced 2026-02-01 21:50:39 +00:00
multi-select
This commit is contained in:
77
css/iui.css
77
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%;
|
||||
|
||||
76
demo/index.html
Normal file
76
demo/index.html
Normal file
@@ -0,0 +1,76 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta http-equiv="content-type" content="text/html; charset=UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta charset="UTF-8">
|
||||
|
||||
<link href="../css/iui.css" rel="stylesheet" />
|
||||
<script src="../src/iui.js" type="module"></script>
|
||||
|
||||
<style>
|
||||
body {
|
||||
font-family: Segoe UI;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.demo-card {
|
||||
max-width: 520px;
|
||||
padding: 16px;
|
||||
border: 1px solid #d6d6d6;
|
||||
border-radius: 12px;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.demo-title {
|
||||
font-size: 18px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.demo-note {
|
||||
color: #555;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<i-app>
|
||||
<div class="demo-card">
|
||||
<div class="demo-title">Multiselect Demo</div>
|
||||
<div class="demo-note">Type to filter, click items to add, use the x to remove.</div>
|
||||
|
||||
<i-multiselect id="demo-multi" placeholder="Search cities..." distinct key="id">
|
||||
<i-layout>
|
||||
<i-field name="label">${d.name}</i-field>
|
||||
<i-field name="menu">${d.name}</i-field>
|
||||
</i-layout>
|
||||
</i-multiselect>
|
||||
</div>
|
||||
</i-app>
|
||||
|
||||
<script>
|
||||
window.addEventListener("load", function () {
|
||||
const items = [
|
||||
{ id: 1, name: "Cairo" },
|
||||
{ id: 2, name: "Alexandria" },
|
||||
{ id: 3, name: "Giza" },
|
||||
{ id: 4, name: "Aswan" },
|
||||
{ id: 5, name: "Luxor" },
|
||||
{ id: 6, name: "Fayoum" },
|
||||
{ id: 7, name: "Sohag" },
|
||||
{ id: 8, name: "Hurghada" }
|
||||
];
|
||||
|
||||
const ms = document.getElementById("demo-multi");
|
||||
ms.query = function (offset, text) {
|
||||
const q = (text || "").toLowerCase();
|
||||
if (!q)
|
||||
return items;
|
||||
return items.filter(x => x.name.toLowerCase().indexOf(q) >= 0);
|
||||
};
|
||||
|
||||
ms.setData([items[0], items[3]]);
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@esiur/iui",
|
||||
"version": "1.2.78",
|
||||
"version": "1.2.8",
|
||||
"description": "Interactive User Interface",
|
||||
"main": "iui.js",
|
||||
"type": "module",
|
||||
|
||||
408
src/UI/Multiselect.js
Normal file
408
src/UI/Multiselect.js
Normal file
@@ -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;
|
||||
}
|
||||
});
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user