>(supe
}
return html`
- ${this.noLang ? option.label : this.egw().lang(option.label)}
+ ${this.noLang ? option.label : this.egw().lang(option.label)}
${option.value.map(this._optionTemplate.bind(this))}
`;
diff --git a/api/js/etemplate/Et2Select/FindSelectOptions.ts b/api/js/etemplate/Et2Select/FindSelectOptions.ts
index 0d1d69e8a1..cd9603ea7e 100644
--- a/api/js/etemplate/Et2Select/FindSelectOptions.ts
+++ b/api/js/etemplate/Et2Select/FindSelectOptions.ts
@@ -18,6 +18,9 @@ export interface SelectOption
// Show the option, but it is not selectable.
// If multiple=true and the option is in the value, it is not removable.
disabled? : boolean;
+ // If a search is in progress, does this option match.
+ // Automatically changed.
+ isMatch? : boolean;
}
/**
diff --git a/api/js/etemplate/Et2Select/SearchMixin.js b/api/js/etemplate/Et2Select/SearchMixin.js
new file mode 100644
index 0000000000..e805332ef6
--- /dev/null
+++ b/api/js/etemplate/Et2Select/SearchMixin.js
@@ -0,0 +1,1181 @@
+/**
+ * EGroupware eTemplate2 - SearchMixin
+ *
+ * @license http://opensource.org/licenses/gpl-license.php GPL - GNU General Public License
+ * @package api
+ * @link https://www.egroupware.org
+ * @author Nathan Gray
+ */
+var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
+ function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
+ return new (P || (P = Promise))(function (resolve, reject) {
+ function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
+ function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
+ function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
+ step((generator = generator.apply(thisArg, _arguments || [])).next());
+ });
+};
+import { css, html, render, SlotMixin } from "@lion/core";
+import { cleanSelectOptions } from "./FindSelectOptions";
+import { Et2Tag } from "./Tag/Et2Tag";
+import { SlMenuItem } from "@shoelace-style/shoelace";
+import { waitForEvent } from "@shoelace-style/shoelace/dist/internal/event";
+import { StaticOptions } from "./StaticOptions";
+// Otherwise import gets stripped
+let keep_import;
+/**
+ * Base class for things that do search type behaviour
+ * Separated to keep things a little simpler.
+ *
+ * Currently I assume we're extending an Et2Select, so changes may need to be made for better abstraction
+ */
+export const Et2WithSearchMixin = (superclass) => {
+ class Et2WidgetWithSearch extends SlotMixin(superclass) {
+ constructor(...args) {
+ super(...args);
+ // Hold the original option data from earlier search results, since we discard on subsequent search
+ this._selected_remote = [];
+ this.search = false;
+ this.searchUrl = "";
+ this.searchOptions = { app: "addressbook" };
+ this.allowFreeEntries = false;
+ this.editModeEnabled = false;
+ // Hiding the selected options from the dropdown means we can't un-select the tags
+ // hidden by the max limit. Prefer no limit.
+ this.maxTagsVisible = -1;
+ this.validators = [];
+ /**
+ * Used by Subclassers to add default Validators.
+ * A email input for instance, always needs the isEmail validator.
+ * @example
+ * ```js
+ * this.defaultValidators.push(new IsDate());
+ * ```
+ * @type {Validator[]}
+ */
+ this.defaultValidators = [];
+ this.handleMenuSelect = this.handleMenuSelect.bind(this);
+ this._handleChange = this._handleChange.bind(this);
+ this.handleTagEdit = this.handleTagEdit.bind(this);
+ this._handleAfterShow = this._handleAfterShow.bind(this);
+ this._handleSearchBlur = this._handleSearchBlur.bind(this);
+ this._handleClear = this._handleClear.bind(this);
+ this._handleDoubleClick = this._handleDoubleClick.bind(this);
+ this._handleSearchAbort = this._handleSearchAbort.bind(this);
+ this._handleSearchChange = this._handleSearchChange.bind(this);
+ this._handleSearchKeyDown = this._handleSearchKeyDown.bind(this);
+ this._handleEditKeyDown = this._handleEditKeyDown.bind(this);
+ this._handlePaste = this._handlePaste.bind(this);
+ }
+ static get properties() {
+ return Object.assign(Object.assign({}, super.properties), { search: { type: Boolean, reflect: true }, searchUrl: { type: String },
+ /**
+ * Allow custom entries that are not in the options
+ */
+ allowFreeEntries: { type: Boolean, reflect: true },
+ /**
+ * Additional search parameters that are passed to the server
+ * when we query searchUrl
+ */
+ searchOptions: { type: Object },
+ /**
+ * Allow editing tags by clicking on them.
+ * allowFreeEntries must be true
+ */
+ editModeEnabled: { type: Boolean } });
+ }
+ static get styles() {
+ return [
+ // @ts-ignore
+ ...(super.styles ? (Symbol.iterator in Object(super.styles) ? super.styles : [super.styles]) : []),
+ css `
+ /* Move the widget border
+ .form-control-input {
+ border: solid var(--sl-input-border-width) var(--sl-input-border-color);
+ border-radius: var(--sl-input-border-radius-medium);
+ }
+ .form-control-input:hover {
+ background-color: var(--sl-input-background-color-hover);
+ border-color: var(--sl-input-border-color-hover);
+ color: var(--sl-input-color-hover);
+ }
+ .select--standard .select__control {
+ border-style: none;
+ }
+ /* Move focus highlight */
+ .form-control-input:focus-within {
+ box-shadow: var(--sl-focus-ring);
+ }
+ .select--standard.select--focused:not(.select--disabled) .select__control {
+ box-shadow: initial;
+ }
+ /* Show / hide SlSelect icons - dropdown arrow, etc but not loading spinner */
+ :host([allowFreeEntries]) ::slotted(sl-icon[slot="suffix"]) {
+ display: none;
+ }
+ /* Make search textbox take full width */
+ ::slotted(.search_input), ::slotted(.search_input) input, .search_input, .search_input input {
+ width: 100%;
+ }
+ .search_input input {
+ flex: 1 1 auto;
+ width: 100%;
+ }
+ /* Full width search textbox covers loading spinner, lift it up */
+ ::slotted(sl-spinner) {
+ z-index: 2;
+ }
+ /* Don't show the current value while searching for single, we want the space
+ This lets the current value shrink to nothing so the input can expand
+ */
+ .select__label {
+ flex: 1 15 auto;
+ }
+ /* Show edit textbox only when editing */
+ .search_input #edit {
+ display: none;
+ }
+ .search_input.editing #search {
+ display: none;
+ }
+ .search_input.editing #edit {
+ display: initial;
+ }
+ :host([search]:not([multiple])) .select--open .select__prefix {
+ flex: 2 1 auto;
+ width: 100%;
+ }
+ :host([search]:not([multiple])) .select--open .select__label {
+ margin: 0px;
+ }
+ :host([allowfreeentries]:not([multiple])) .select--standard.select--open:not(.select--disabled) .select__control .select__prefix {
+ flex: 1 1 auto;
+ }
+ :host([allowfreeentries]:not([multiple])) .select--standard.select--open:not(.select--disabled) .select__control .select__label {
+ display: none;
+ }
+
+ /* Search textbox general styling, starts hidden */
+
+ .select__prefix ::slotted(.search_input), .search_input {
+ display: none;
+ flex: 1 1 auto;
+ margin-left: 0px;
+ width: 100%;
+ height: var(--sl-input-height-medium);
+ position: absolute;
+ background-color: white;
+ z-index: var(--sl-z-index-dropdown);
+ }
+
+ /* Search UI active - show textbox & stuff */
+
+ ::slotted(.search_input.active), .search_input.active,
+ .search_input.editing {
+ display: flex;
+ }
+
+ /* If multiple and no value, overlap search onto widget instead of below */
+
+ :host([multiple]) .search_input.active.novalue {
+ top: 0px;
+ }
+
+ /* Hide options that do not match current search text */
+ ::slotted(.no-match) {
+ display: none;
+ }
+ /* Different cursor for editable tags */
+ :host([allowfreeentries]):not([readonly]) .search_tag::part(base) {
+ cursor: text;
+ }
+
+ /** Readonly **/
+ /* No border */
+ :host([readonly]) .form-control-input {
+ border: none;
+ }
+ /* disable focus border */
+ :host([readonly]) .form-control-input:focus-within {
+ box-shadow: none;
+ }
+ /* no menu */
+ :host([readonly]) sl-menu {
+ display: none;
+ }
+ /* normal cursor */
+ :host([readonly]) .select__control {
+ cursor: initial;
+ }
+ `
+ ];
+ }
+ connectedCallback() {
+ super.connectedCallback();
+ this.classList.toggle("search", this.searchEnabled);
+ // Missing any of the required attributes? Don't change anything.
+ // If readonly, skip it
+ if (!this.searchEnabled && !this.editModeEnabled && !this.allowFreeEntries || this.readonly) {
+ return;
+ }
+ this._addNodes();
+ this._bindListeners();
+ }
+ disconnectedCallback() {
+ super.disconnectedCallback();
+ this._unbindListeners();
+ }
+ willUpdate(changedProperties) {
+ super.willUpdate(changedProperties);
+ // Turn on search if there's more than 20 options
+ if (changedProperties.has("select_options") && this.select_options.length > 20) {
+ this.search = true;
+ }
+ // If searchURL is set, turn on search
+ if (changedProperties.has("searchUrl") && this.searchUrl) {
+ this.search = true;
+ // Decode URL, possibly again. If set in template, it can wind up double-encoded.
+ this.searchUrl = this.egw().decodePath(this.searchUrl);
+ }
+ // Add missing options if search or free entries enabled
+ if (changedProperties.has("value") && this.value) {
+ // Overridden to add options if allowFreeEntries=true
+ if (this.allowFreeEntries && typeof this.value == "string" && !this.select_options.find(o => o.value == this.value &&
+ (!o.class || o.class && !o.class.includes('remote')))) {
+ this.createFreeEntry(this.value);
+ }
+ else if (this.allowFreeEntries && this.multiple) {
+ this.value.forEach((e) => {
+ if (!this.select_options.find(o => o.value == e)) {
+ this.createFreeEntry(e);
+ }
+ });
+ }
+ if (this.searchEnabled) {
+ // Check to see if value is for an option we do not have
+ for (const newValueElement of this.getValueAsArray()) {
+ if (this.select_options.some(o => o.value == newValueElement)) {
+ continue;
+ }
+ this._missingOption(newValueElement);
+ }
+ }
+ }
+ }
+ update(changedProperties) {
+ var _a;
+ super.update(changedProperties);
+ // One of the key properties has changed, need to add the needed nodes
+ if (changedProperties.has("search") || changedProperties.has("editModeEnabled") || changedProperties.has("allowFreeEntries")) {
+ // Missing any of the required attributes? Now we need to take it out.
+ if (!this.searchEnabled && !this.editModeEnabled && !this.allowFreeEntries || this.readonly) {
+ (_a = this.querySelector(".search_input")) === null || _a === void 0 ? void 0 : _a.remove();
+ return;
+ }
+ // Normally this should be handled in render(), but we have to add our nodes in
+ this._addNodes();
+ }
+ // Update any tags if edit mode changes
+ if (changedProperties.has("editModeEnabled") || changedProperties.has("readonly")) {
+ // Required because we explicitly create tags instead of doing it in render()
+ this.shadowRoot.querySelectorAll(".select__tags > *").forEach((tag) => {
+ tag.editable = this.editModeEnabled && !this.readonly;
+ tag.removable = !this.readonly;
+ });
+ }
+ }
+ /**
+ * Add the nodes we need to search - adjust parent shadowDOM
+ *
+ * @protected
+ */
+ _addNodes() {
+ if (this._activeControls) {
+ // Already there
+ return;
+ }
+ const div = document.createElement("div");
+ div.classList.add("search_input");
+ render(this._searchInputTemplate(), div);
+ if (!super.multiple) {
+ div.slot = "prefix";
+ this.appendChild(div);
+ return;
+ }
+ super.updateComplete.then(() => {
+ let control = this.shadowRoot.querySelector(".form-control-input");
+ control.append(div);
+ });
+ }
+ /**
+ * Customise how tags are rendered.
+ * Override to add edit
+ *
+ * @param item
+ * @protected
+ */
+ _createTagNode(item) {
+ let tag = document.createElement(this.tagTag);
+ tag.editable = this.editModeEnabled && !this.readonly;
+ return tag;
+ }
+ _searchInputTemplate() {
+ let edit = null;
+ if (this.editModeEnabled) {
+ edit = html ` e.stopPropagation()}
+ @blur=${this.stopEdit.bind(this)}
+ />`;
+ }
+ // I can't figure out how to get this full width via CSS
+ return html `
+
+ ${edit}
+ `;
+ }
+ _noResultsTemplate() {
+ return html `
+ ${this.egw().lang("no suggestions")}
`;
+ }
+ /**
+ * Do we have the needed properties set, so we can actually do searching
+ *
+ * @returns {boolean}
+ */
+ get searchEnabled() {
+ return !this.readonly && (this.search || this.searchUrl.length > 0);
+ }
+ get _searchInputNode() {
+ var _a;
+ return (_a = this._activeControls) === null || _a === void 0 ? void 0 : _a.querySelector("#search");
+ }
+ get _editInputNode() {
+ var _a;
+ return (_a = this._activeControls) === null || _a === void 0 ? void 0 : _a.querySelector("input#edit");
+ }
+ get _activeControls() {
+ var _a;
+ return ((_a = this.shadowRoot) === null || _a === void 0 ? void 0 : _a.querySelector(".search_input")) ||
+ this.querySelector(".search_input");
+ }
+ /**
+ * Only local options, excludes server options
+ *
+ * @protected
+ */
+ get localItems() {
+ return this.querySelectorAll(this.optionTag + ":not(.remote)");
+ }
+ /**
+ * Only remote options from search results
+ * @returns {NodeList}
+ * @protected
+ */
+ get remoteItems() {
+ return this.querySelectorAll(this.optionTag + ".remote");
+ }
+ /**
+ * Only free entries
+ * @returns {NodeList}
+ * @protected
+ */
+ get freeEntries() {
+ return this.querySelectorAll(this.optionTag + ".freeEntry");
+ }
+ get select_options() {
+ let options = [];
+ // Any provided options
+ options = options.concat(this.__select_options);
+ // Any kept remote options
+ options = options.concat(this._selected_remote);
+ if (this.allowFreeEntries) {
+ this.freeEntries.forEach((item) => {
+ if (!options.some(i => i.value == item.value)) {
+ options.push({ value: item.value, label: item.textContent, class: item.classList.toString() });
+ }
+ });
+ }
+ return options;
+ }
+ set select_options(options) {
+ var _a;
+ super.select_options = options;
+ // Remove any selected remote, they're real options now
+ for (let remote_index = this._selected_remote.length - 1; remote_index >= 0; remote_index--) {
+ let remote = this._selected_remote[remote_index];
+ if (options.findIndex(o => o.value == remote.value) != -1) {
+ this._selected_remote.splice(remote_index, 1);
+ (_a = this.querySelector('[value="' + remote.value + '"]')) === null || _a === void 0 ? void 0 : _a.classList.remove("remote");
+ }
+ }
+ }
+ get value() {
+ return super.value;
+ }
+ set value(new_value) {
+ super.value = new_value;
+ if (!new_value || !this.allowFreeEntries && !this.searchUrl) {
+ return;
+ }
+ }
+ /**
+ * Some [part of a] value is missing from the available options, but should be there, so find and add it.
+ *
+ * This is used when not all options are sent to the client (search, link list). Ideally we want to send
+ * the options for the current value, but sometimes this is not the best option so here we search or create
+ * the option as needed. These are not free entries, but need to match some list somewhere.
+ *
+ * @param {string} newValueElement
+ * @protected
+ */
+ _missingOption(newValueElement) {
+ // Given a value we need to search for - this will add in all matches, including the one needed
+ this.remoteSearch(newValueElement, this.searchOptions).then((result) => {
+ const option = result.find(o => o.value == newValueElement);
+ if (option) {
+ this._selected_remote.push(option);
+ }
+ });
+ }
+ fix_bad_value() {
+ if (!this.allowFreeEntries && !this.searchEnabled) {
+ // Let regular select deal with it
+ return false;
+ }
+ const valueArray = Array.isArray(this.value) ? this.value : (!this.value ? [] : this.value.toString().split(','));
+ // Check any already found options
+ if (Object.values(this.menuItems).filter((option) => valueArray.find(val => val == option.value)).length === 0) {
+ return false;
+ }
+ return true;
+ // TODO? Should we check the server, or just be OK with it? Passing the "current" value in sel_options makes sure the value is there
+ }
+ _bindListeners() {
+ this.addEventListener("sl-clear", this._handleClear);
+ this.addEventListener("sl-after-show", this._handleAfterShow);
+ // Need our own change to catch the change event from search input
+ this.addEventListener("change", this._handleChange);
+ if (this.allowFreeEntries) {
+ this.addEventListener("paste", this._handlePaste);
+ }
+ this.updateComplete.then(() => {
+ var _a, _b;
+ // Search messes up event order. Since it throws its own bubbling change event,
+ // selecting an option fires 2 change events - 1 before the widget is finished adjusting, losing the value
+ // We catch all change events, then call this._oldChange only when value changes
+ this.removeEventListener("change", this._oldChange);
+ (_a = this._searchInputNode) === null || _a === void 0 ? void 0 : _a.removeEventListener("change", this._searchInputNode.handleChange);
+ (_b = this._searchInputNode) === null || _b === void 0 ? void 0 : _b.addEventListener("change", this._handleSearchChange);
+ this.dropdown.querySelector('.select__label').addEventListener("change", this.handleTagEdit);
+ });
+ }
+ _unbindListeners() {
+ var _a;
+ this.removeEventListener("sl-select", this._handleSelect);
+ this.removeEventListener("sl-after-show", this._handleAfterShow);
+ this.removeEventListener("sl-clear", this._handleClear);
+ this.removeEventListener("change", this._handleChange);
+ this.removeEventListener("paste", this._handlePaste);
+ (_a = this._searchInputNode) === null || _a === void 0 ? void 0 : _a.removeEventListener("change", this._handleSearchChange);
+ }
+ handleMenuShow() {
+ var _a, _b;
+ if (this.readonly) {
+ return;
+ }
+ // Move search (& menu) if there's no value
+ (_a = this._activeControls) === null || _a === void 0 ? void 0 : _a.classList.toggle("novalue", this.multiple && this.value == '' || !this.multiple);
+ // Reset for parent calculations, will be adjusted after if needed
+ this.dropdown.setAttribute("distance", 0);
+ super.handleMenuShow();
+ if (this.searchEnabled || this.allowFreeEntries) {
+ (_b = this._activeControls) === null || _b === void 0 ? void 0 : _b.classList.add("active");
+ this._searchInputNode.focus();
+ this._searchInputNode.select();
+ // Hide edit explicitly since it's so hard via CSS
+ if (this._editInputNode) {
+ this._editInputNode.style.display = "none";
+ }
+ }
+ if (this.editModeEnabled && this.allowFreeEntries && !this.multiple && this.value) {
+ this.startEdit();
+ this._editInputNode.select();
+ // Hide search explicitly since it's so hard via CSS
+ this._searchInputNode.style.display = "none";
+ }
+ }
+ /**
+ * Reposition the dropdown to allow space for current value and search. If the dropdown was positioned above
+ * instead of below, we don't need the extra space - remove it.
+ */
+ _handleAfterShow() {
+ // Need to give positioner a chance to position.
+ // If we call it right away, it has not updated.
+ // I haven't found an event or Promise to hook on to
+ window.setTimeout(() => {
+ var _a, _b, _c, _d, _e;
+ if (((_a = this.dropdown) === null || _a === void 0 ? void 0 : _a.getAttribute("distance")) && ((_c = (_b = this.dropdown) === null || _b === void 0 ? void 0 : _b.popup) === null || _c === void 0 ? void 0 : _c.dataset.currentPlacement) == "top") {
+ this.dropdown.setAttribute("distance", 0);
+ this.dropdown.reposition();
+ }
+ else {
+ (_d = this.dropdown) === null || _d === void 0 ? void 0 : _d.setAttribute("distance", !this._activeControls || ((_e = this._activeControls) === null || _e === void 0 ? void 0 : _e.classList.contains("novalue")) ?
+ parseInt(getComputedStyle(this.control).getPropertyValue("border-width")) :
+ // Make room for search below
+ parseInt(getComputedStyle(this._activeControls).getPropertyValue("--sl-input-height-medium")));
+ }
+ }, 100);
+ }
+ focus() {
+ var _a;
+ (_a = this.dropdown) === null || _a === void 0 ? void 0 : _a.show().then(() => {
+ this._searchInputNode.focus();
+ });
+ }
+ handleMenuHide() {
+ var _a;
+ if (this.readonly) {
+ return;
+ }
+ clearTimeout(this._searchTimeout);
+ super.handleMenuHide();
+ // Reset display
+ if (this._searchInputNode) {
+ this._searchInputNode.style.display = "";
+ }
+ if (this._editInputNode) {
+ this._editInputNode.style.display = "";
+ }
+ if (this.searchEnabled || this.allowFreeEntries) {
+ (_a = this._activeControls) === null || _a === void 0 ? void 0 : _a.classList.remove("active");
+ this.shadowRoot.querySelector('.select__label').style.display = "";
+ }
+ }
+ _triggerChange(event) {
+ // Don't want searchbox events to trigger change event
+ if (event.target == this._searchInputNode) {
+ event.stopImmediatePropagation();
+ event.preventDefault();
+ return false;
+ }
+ return true;
+ }
+ _handleChange(event) {
+ if (event.target == this._searchInputNode) {
+ event.stopImmediatePropagation();
+ event.preventDefault();
+ return false;
+ }
+ return this._oldChange(event);
+ }
+ _handleDoubleClick(event) {
+ // No edit (shouldn't happen...)
+ if (!this.editModeEnabled) {
+ return;
+ }
+ // Find the tag
+ const path = event.composedPath();
+ const tag = path.find((el) => el instanceof Et2Tag);
+ this.dropdown.hide();
+ this.updateComplete.then(() => {
+ tag.startEdit(event);
+ });
+ }
+ /**
+ * An option was selected
+ */
+ handleMenuSelect(event) {
+ // Need to keep the remote option - only if selected
+ if (event.detail.item.classList.contains("remote") && !this.select_options.find(o => o.value == event.detail.item.value)) {
+ this._selected_remote.push(Object.assign({}, event.detail.item.option));
+ }
+ super.handleMenuSelect(event);
+ this.updateComplete.then(() => {
+ // If they just chose one from the list, re-focus the search
+ if (this.multiple && this.searchEnabled) {
+ this._searchInputNode.focus();
+ this._searchInputNode.select();
+ // If we were overlapping, reset
+ if (this._activeControls.classList.contains("novalue")) {
+ this.handleMenuShow();
+ this._handleAfterShow();
+ }
+ // Scroll the new tag into view
+ if (event.detail && event.detail.item) {
+ // Causes sidemenu (calendar) to scroll to top & get stuck
+ /*
+ this.updateComplete.then(() =>
+ {
+ this.shadowRoot.querySelector("et2-tag[value='" + event.detail.item.value.replace(/'/g, "\\\'") + "']")?.scrollIntoView({block: "nearest"});
+ });
+ */
+ }
+ }
+ else if (!this.multiple && this.searchEnabled) {
+ // Stop all the search stuff when they select an option
+ // this shows all non-matching options again
+ this._handleSearchAbort(event);
+ }
+ });
+ }
+ /**
+ * Value was cleared
+ */
+ _handleClear(e) {
+ // Only keep remote options that are still used
+ this._selected_remote = this._selected_remote.filter((option) => this.getValueAsArray().indexOf(option.value) !== -1);
+ if (!this.multiple && this.searchEnabled) {
+ this._handleSearchAbort(e);
+ // Restore label styling
+ this.shadowRoot.querySelector("[part='display-label']").style.display = "";
+ // Start searching again
+ this.updateComplete.then(() => this.handleMenuShow());
+ }
+ }
+ /**
+ * Handle blur from search field
+ *
+ * Either the user changed fields, or selected an option. For selecting don't interfere, but for
+ * changing fields we need to make sure the menu is hidden.
+ *
+ * @param event
+ */
+ _handleSearchBlur(event) {
+ return __awaiter(this, void 0, void 0, function* () {
+ clearTimeout(this._searchTimeout);
+ if (event.relatedTarget && event.relatedTarget instanceof SlMenuItem) {
+ return;
+ }
+ // Try any value they had in progress
+ if (this._searchInputNode.value && this.allowFreeEntries) {
+ this.createFreeEntry(this._searchInputNode.value);
+ }
+ this.clearSearch();
+ });
+ }
+ /**
+ * Handle keypresses inside the search input
+ * @param {KeyboardEvent} event
+ * @protected
+ */
+ _handleSearchKeyDown(event) {
+ var _a;
+ clearTimeout(this._searchTimeout);
+ (_a = this._activeControls) === null || _a === void 0 ? void 0 : _a.classList.add("active");
+ this.dropdown.show();
+ // Pass off some keys to select
+ if (['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) {
+ // Strip out hidden non-matching selected & disabled items so key navigation works
+ this.menuItems = this.menuItems.filter(i => !i.disabled);
+ return super.handleKeyDown(event);
+ }
+ event.stopPropagation();
+ // Don't allow event to bubble or it will interact with select
+ event.stopImmediatePropagation();
+ if (Et2WidgetWithSearch.TAG_BREAK.indexOf(event.key) !== -1 && this.allowFreeEntries && this.createFreeEntry(this._searchInputNode.value)) {
+ event.preventDefault();
+ this._searchInputNode.value = "";
+ this.dropdown.hide().then(() => __awaiter(this, void 0, void 0, function* () {
+ // update sizing / position before getting ready for another one
+ if (this.multiple) {
+ yield this.dropdown.show();
+ this._searchInputNode.focus();
+ }
+ }));
+ }
+ else if (event.key == "Enter") {
+ event.preventDefault();
+ this.startSearch();
+ return;
+ }
+ else if (event.key == "Escape") {
+ this._handleSearchAbort(event);
+ this.dropdown.hide();
+ return;
+ }
+ // Start the search automatically if they have enough letters
+ // -1 because we're in keyDown handler, and value is from _before_ this key was pressed
+ if (this._searchInputNode.value.length >= Et2WidgetWithSearch.MIN_CHARS - 1) {
+ this._searchTimeout = window.setTimeout(() => { this.startSearch(); }, Et2WidgetWithSearch.SEARCH_TIMEOUT);
+ }
+ }
+ _handleEditKeyDown(event) {
+ // Stop propagation, or parent key handler will add again
+ event.stopImmediatePropagation();
+ if (Et2WidgetWithSearch.TAG_BREAK.indexOf(event.key) !== -1 && this.allowFreeEntries) {
+ // Prevent default, since that would try to submit
+ event.preventDefault();
+ this.stopEdit();
+ }
+ // Abort edit, put original value back
+ else if (event.key == "Escape") {
+ this.stopEdit(true);
+ }
+ }
+ /**
+ * Sometimes users paste multiple comma separated values at once. Split them then handle normally.
+ *
+ * @param {ClipboardEvent} event
+ * @protected
+ */
+ _handlePaste(event) {
+ event.preventDefault();
+ let paste = event.clipboardData.getData('text');
+ if (!paste) {
+ return;
+ }
+ const selection = window.getSelection();
+ if (selection.rangeCount) {
+ selection.deleteFromDocument();
+ }
+ let values = paste.split(/,\t/);
+ values.forEach(v => {
+ this.createFreeEntry(v.trim());
+ });
+ this.dropdown.hide();
+ }
+ /**
+ * Start searching
+ *
+ * If we have local options, we'll search & display any matches.
+ * If serverUrl is set, we'll ask the server for results as well.
+ */
+ startSearch() {
+ return __awaiter(this, void 0, void 0, function* () {
+ // Stop timeout timer
+ clearTimeout(this._searchTimeout);
+ // Show a spinner
+ let spinner = document.createElement("sl-spinner");
+ spinner.slot = "suffix";
+ this.appendChild(spinner);
+ // Hide clear button
+ let clear_button = this._searchInputNode.shadowRoot.querySelector(".input__clear");
+ if (clear_button) {
+ clear_button.style.display = "none";
+ }
+ // Clear previous results
+ this._clearResults();
+ yield this.updateComplete;
+ // Start the searches
+ return Promise.all([
+ this.localSearch(this._searchInputNode.value, this.searchOptions),
+ this.remoteSearch(this._searchInputNode.value, this.searchOptions)
+ ]).then(() => {
+ // Show no results indicator
+ if (this.menuItems.filter(e => !e.classList.contains("no-match")).length == 0) {
+ let target = this._optionTargetNode || this;
+ let temp = document.createElement("div");
+ render(this._noResultsTemplate(), temp);
+ target.append(temp.children[0]);
+ }
+ // Remove spinner
+ spinner.remove();
+ // Restore clear button
+ if (clear_button) {
+ clear_button.style.display = "";
+ }
+ }).then(() => {
+ // Not sure why this stays hidden if there's no results, but it sticks and hides all results afterward
+ this.dropdown.shadowRoot.querySelector(".dropdown__panel").removeAttribute("hidden");
+ // Call our resize stuff explicitly
+ this._handleAfterShow();
+ });
+ });
+ }
+ /**
+ * Clear search term and any search results
+ *
+ * Local options are not removed, but remote options are
+ */
+ clearSearch() {
+ // Stop timeout timer
+ clearTimeout(this._searchTimeout);
+ this._clearResults();
+ // Clear search term
+ if (this._searchInputNode) {
+ this._searchInputNode.value = "";
+ }
+ }
+ _clearResults() {
+ var _a;
+ let target = this._optionTargetNode || this;
+ // Remove "no suggestions"
+ (_a = target.querySelector(".no-results")) === null || _a === void 0 ? void 0 : _a.remove();
+ // Remove any previously selected remote options that aren't used anymore
+ this._selected_remote = this._selected_remote.filter((option) => {
+ return this.multiple ? this.value.indexOf(option.value) != -1 : this.value == option.value;
+ });
+ // Remove remote options that aren't used
+ let keepers = this._selected_remote.reduce((prev, current) => {
+ return prev + ":not([value='" + ('' + current.value).replace(/'/g, "\\\'") + "'])";
+ }, "");
+ target.querySelectorAll(".remote" + keepers).forEach(o => o.remove());
+ target.childNodes.forEach((n) => {
+ if (n.nodeType == Node.COMMENT_NODE) {
+ n.remove();
+ }
+ });
+ // Reset remaining options. It might be faster to re-create instead.
+ this._menuItems.forEach((item) => {
+ var _a;
+ item.disabled = ((_a = item.option) === null || _a === void 0 ? void 0 : _a.disabled) || false;
+ item.classList.remove("match");
+ item.classList.remove("no-match");
+ });
+ }
+ /**
+ * Filter the local options
+ *
+ * @param {string} search
+ * @protected
+ */
+ localSearch(search, options) {
+ return new Promise((resolve) => {
+ this.localItems.forEach((item) => {
+ let match = this.searchMatch(search, item);
+ item.classList.toggle("match", match);
+ // set disabled so arrow keys step over. Might be a better way to handle that
+ item.disabled = !match;
+ item.classList.toggle("no-match", !match);
+ });
+ resolve();
+ });
+ }
+ /**
+ * Ask for remote options and add them in unconditionally
+ * @param {string} search
+ * @protected
+ */
+ remoteSearch(search, options) {
+ if (!this.searchUrl) {
+ return Promise.resolve([]);
+ }
+ // Check our URL: JSON file or URL?
+ if (this.searchUrl.includes(".json")) {
+ // Get the file, search it
+ return this.jsonQuery(search, options);
+ }
+ else {
+ // Fire off the query to the server
+ return this.remoteQuery(search, options);
+ }
+ }
+ /**
+ * Search through a JSON file in the browser
+ *
+ * @param {string} search
+ * @param {object} options
+ * @protected
+ */
+ jsonQuery(search, options) {
+ // Get the file
+ const controller = new AbortController();
+ const signal = controller.signal;
+ let response_ok = false;
+ return StaticOptions.cached_from_file(this, this.searchUrl)
+ .then(options => {
+ // Filter the options
+ const lower_search = search.toLowerCase();
+ const filtered = options.filter(option => {
+ return option.label.toLowerCase().includes(lower_search) || option.value.includes(search);
+ });
+ // Limit results
+ const totalCount = filtered.length;
+ if (filtered.length > Et2WidgetWithSearch.RESULT_LIMIT) {
+ filtered.splice(Et2WidgetWithSearch.RESULT_LIMIT);
+ }
+ // Add the matches
+ this.processRemoteResults(filtered, totalCount);
+ return filtered;
+ })
+ .catch((_err) => {
+ this.egw().message(_err.statusText || this.searchUrl, "error");
+ return [];
+ });
+ }
+ /**
+ * Actually query the server.
+ *
+ * This can be overridden to change request parameters or eg. send them as POST parameters.
+ *
+ * Default implementation here sends search string and options:
+ * - as two parameters to the AJAX function
+ * - and (additional) as GET parameters plus search string as "query"
+ *
+ * This is done to support as well the old taglist callbacks, as the regular select ones!
+ *
+ * @param {string} search
+ * @param {object} options
+ * @returns {any}
+ * @protected
+ */
+ remoteQuery(search, options) {
+ // Include a limit, even if options don't, to avoid massive lists breaking the UI
+ let sendOptions = Object.assign({ num_rows: Et2WidgetWithSearch.RESULT_LIMIT }, options);
+ return this.egw().request(this.egw().link(this.egw().ajaxUrl(this.egw().decodePath(this.searchUrl)), Object.assign({ query: search }, sendOptions)), [search, sendOptions]).then((results) => {
+ // If results have a total included, pull it out.
+ // It will cause errors if left in the results
+ let total = null;
+ if (typeof results.total !== "undefined") {
+ total = results.total;
+ delete results.total;
+ }
+ let entries = cleanSelectOptions(results);
+ this.processRemoteResults(entries, total);
+ return entries;
+ });
+ }
+ /**
+ * Add in remote results
+ * @param results
+ * @param totalResults If there are more results than were returned, total number of matches
+ * @protected
+ */
+ processRemoteResults(entries, totalResults = 0) {
+ if (!(entries === null || entries === void 0 ? void 0 : entries.length)) {
+ return Promise.resolve();
+ }
+ // Add a "remote" class so we can tell these apart from any local results
+ entries.forEach((entry) => entry.class = (entry.class || "") + " remote");
+ let target = this._optionTargetNode || this;
+ if (target) {
+ // Add in remote options, avoiding duplicates
+ this.select_options.filter(function (item) {
+ let i = entries.findIndex(x => (x.value == item.value));
+ if (i <= -1) {
+ entries.push(item);
+ }
+ return null;
+ });
+ let options = html `${entries.map(this._optionTemplate.bind(this))}`;
+ /**
+ * Add in new options.
+ * Rendering directly into target will remove existing options, which we don't need to do
+ */
+ let temp_target = document.createElement("div");
+ let resultCount = entries.length;
+ render(options, temp_target);
+ return Promise.all(([...temp_target.querySelectorAll(":scope > *")].map(item => item.render)))
+ .then(() => {
+ temp_target.querySelectorAll(":scope > *").forEach((item) => {
+ // Avoid duplicate error
+ if (!target.querySelector("[value='" + ('' + item.value).replace(/'/g, "\\\'") + "']")) {
+ target.appendChild(item);
+ }
+ });
+ this.handleMenuSlotChange();
+ })
+ .then(() => {
+ if (totalResults && totalResults > resultCount) {
+ // More results available that were not sent
+ let count = document.createElement("span");
+ count.classList.add("remote");
+ count.textContent = this.egw().lang("%1 more...", totalResults - resultCount);
+ target.appendChild(count);
+ }
+ });
+ }
+ }
+ /**
+ * Check if one of our [local] items matches the search
+ *
+ * @param search
+ * @param item
+ * @returns {boolean}
+ * @protected
+ */
+ searchMatch(search, item) {
+ var _a;
+ if (!item || !item.value) {
+ return false;
+ }
+ if ((_a = item.textContent) === null || _a === void 0 ? void 0 : _a.toLowerCase().includes(search.toLowerCase())) {
+ return true;
+ }
+ if (typeof item.value == "string") {
+ return item.value.includes(search.toLowerCase());
+ }
+ return item.value == search;
+ }
+ /**
+ * Create an entry that is not in the options and add it to the value
+ *
+ * @param {string} text Used as both value and label
+ */
+ createFreeEntry(text) {
+ var _a;
+ if (!text || !this.validateFreeEntry(text)) {
+ return false;
+ }
+ // Make sure not to double-add
+ if (!this.querySelector("[value='" + text.replace(/'/g, "\\\'") + "']") && !this.__select_options.find(o => o.value == text)) {
+ this.__select_options.push({
+ value: text.trim(),
+ label: text.trim(),
+ class: "freeEntry"
+ });
+ this.requestUpdate('select_options');
+ }
+ // Make sure not to double-add
+ if (this.multiple && this.value.indexOf(text) == -1) {
+ this.value.push(text);
+ }
+ else if (!this.multiple && this.value !== text) {
+ this.value = text;
+ }
+ this.requestUpdate("value");
+ // If we were overlapping edit inputbox with the value display, reset
+ if (!this.readonly && ((_a = this._activeControls) === null || _a === void 0 ? void 0 : _a.classList.contains("novalue"))) {
+ this._searchInputNode.style.display = "";
+ }
+ return true;
+ }
+ /**
+ * Check if a free entry value is acceptable.
+ * We use validators directly using the proposed value
+ *
+ * @param text
+ * @returns {boolean}
+ */
+ validateFreeEntry(text) {
+ let validators = [...this.validators, ...this.defaultValidators];
+ let result = validators.filter(v => v.execute(text, v.param, { node: this }));
+ return validators.length > 0 && result.length == 0 || validators.length == 0;
+ }
+ handleTagEdit(event) {
+ var _a;
+ let value = event.target.value;
+ let original = event.target.dataset.original_value;
+ if (!value || !this.allowFreeEntries || !this.validateFreeEntry(value)) {
+ // Not a good value, reset it.
+ event.target.variant = "danger";
+ return false;
+ }
+ event.target.variant = "success";
+ // Add to internal list
+ this.createFreeEntry(value);
+ // Remove original from value & DOM
+ if (value != original) {
+ if (this.multiple) {
+ this.value = this.value.filter(v => v !== original);
+ }
+ else {
+ this.value = value;
+ }
+ (_a = this.querySelector("[value='" + original.replace(/'/g, "\\\'") + "']")) === null || _a === void 0 ? void 0 : _a.remove();
+ this.__select_options = this.__select_options.filter(v => v.value !== original);
+ }
+ }
+ /**
+ * Start editing the current value if multiple=false
+ *
+ * @param {Et2Tag} tag
+ */
+ startEdit(tag) {
+ const tag_value = tag ? tag.value : this.value;
+ // hide the menu
+ this.dropdown.hide();
+ waitForEvent(this, "sl-after-hide").then(() => {
+ // Turn on edit UI
+ this._activeControls.classList.add("editing", "active");
+ // Pre-set value to tag value
+ this._editInputNode.style.display = "";
+ this._editInputNode.value = tag_value;
+ this._editInputNode.focus();
+ // If they abort the edit, they'll want the original back.
+ this._editInputNode.dataset.initial = tag_value;
+ });
+ }
+ stopEdit(abort = false) {
+ var _a, _b;
+ // type to select will focus matching entries, but we don't want to stop the edit yet
+ if (typeof abort == "object" && abort.type == "blur") {
+ if (((_a = abort.relatedTarget) === null || _a === void 0 ? void 0 : _a.localName) == "sl-menu-item") {
+ return;
+ }
+ // Edit lost focus, accept changes
+ abort = false;
+ }
+ const original = this._editInputNode.dataset.initial;
+ delete this._editInputNode.dataset.initial;
+ let value = abort ? original : this._editInputNode.value;
+ this._editInputNode.value = "";
+ if (value && value != original) {
+ this.createFreeEntry(value);
+ this.updateComplete.then(() => {
+ const item = this.querySelector("[value='" + value.replace(/'/g, "\\\'") + "']");
+ item.dispatchEvent(new CustomEvent("sl-select", { detail: { item } }));
+ });
+ }
+ // Remove original from value & DOM
+ if (value != original) {
+ if (this.multiple) {
+ this.value = this.value.filter(v => v !== original);
+ (_b = this.querySelector("[value='" + original.replace(/'/g, "\\\'") + "']")) === null || _b === void 0 ? void 0 : _b.remove();
+ }
+ else {
+ this.value = value;
+ }
+ this.select_options = this.select_options.filter(v => v.value !== original);
+ }
+ this._activeControls.classList.remove("editing", "active");
+ if (!this.multiple) {
+ this.updateComplete.then(() => __awaiter(this, void 0, void 0, function* () {
+ // Don't know why, but this doesn't always work leaving the value hidden by prefix
+ yield this.dropdown.hide();
+ this.dropdown.classList.remove("select--open");
+ this.dropdown.panel.setAttribute("hidden", "");
+ }));
+ }
+ this.syncItemsFromValue();
+ }
+ _handleSearchAbort(e) {
+ this._activeControls.classList.remove("active");
+ this.clearSearch();
+ this.syncItemsFromValue();
+ }
+ /**
+ * et2-searchbox (SlInput) sends out an event on change.
+ * We don't care, and if we let it bubble it'll get in the way.
+ * @param e
+ * @protected
+ */
+ _handleSearchChange(e) {
+ e.stopImmediatePropagation();
+ e.preventDefault();
+ return false;
+ }
+ }
+ /**
+ * When user is typing, we wait this long for them to be finished before we start the search
+ * @type {number}
+ * @protected
+ */
+ Et2WidgetWithSearch.SEARCH_TIMEOUT = 500;
+ /**
+ * We need at least this many characters before we start the search
+ *
+ * @type {number}
+ * @protected
+ */
+ Et2WidgetWithSearch.MIN_CHARS = 2;
+ /**
+ * Limit server searches to 100 results, matches Link::DEFAULT_NUM_ROWS
+ * @type {number}
+ */
+ Et2WidgetWithSearch.RESULT_LIMIT = 100;
+ /**
+ * These characters will end a free tag
+ * @type {string[]}
+ */
+ Et2WidgetWithSearch.TAG_BREAK = ["Tab", "Enter", ","];
+ return Et2WidgetWithSearch;
+};
+//# sourceMappingURL=SearchMixin.js.map
\ No newline at end of file
diff --git a/api/js/etemplate/Et2Select/SearchMixin.ts b/api/js/etemplate/Et2Select/SearchMixin.ts
index 42e80cd834..f5db4adebc 100644
--- a/api/js/etemplate/Et2Select/SearchMixin.ts
+++ b/api/js/etemplate/Et2Select/SearchMixin.ts
@@ -7,14 +7,13 @@
* @author Nathan Gray
*/
-
-import {css, html, LitElement, render, SlotMixin} from "@lion/core";
+import {css, CSSResultGroup, html, LitElement, nothing, render, TemplateResult} from "lit";
import {cleanSelectOptions, SelectOption} from "./FindSelectOptions";
import {Validator} from "@lion/form-core";
import {Et2Tag} from "./Tag/Et2Tag";
import {SlMenuItem} from "@shoelace-style/shoelace";
-import {waitForEvent} from "@shoelace-style/shoelace/dist/internal/event";
import {StaticOptions} from "./StaticOptions";
+import {dedupeMixin} from "@open-wc/dedupe-mixin";
// Otherwise import gets stripped
let keep_import : Et2Tag;
@@ -66,6 +65,13 @@ export declare class SearchMixinInterface
* Check a [local] item to see if it matches
*/
searchMatch(search : string, options : object, item : LitElement) : boolean
+
+ /**
+ * Additional customisation location, where we stick the search elements
+ *
+ * @type {TemplateResult}
+ */
+ _extraTemplate : TemplateResult
}
/**
@@ -74,9 +80,9 @@ export declare class SearchMixinInterface
*
* Currently I assume we're extending an Et2Select, so changes may need to be made for better abstraction
*/
-export const Et2WithSearchMixin = >(superclass : T) =>
+export const Et2WithSearchMixin = dedupeMixin(>(superclass : T) =>
{
- class Et2WidgetWithSearch extends SlotMixin(superclass)
+ class Et2WidgetWithSearch extends superclass
{
static get properties()
{
@@ -105,54 +111,18 @@ export const Et2WithSearchMixin = >(superclass
}
}
- static get styles()
+ static get styles() : CSSResultGroup
{
return [
// @ts-ignore
...(super.styles ? (Symbol.iterator in Object(super.styles) ? super.styles : [super.styles]) : []),
css`
- /* Move the widget border
- .form-control-input {
- border: solid var(--sl-input-border-width) var(--sl-input-border-color);
- border-radius: var(--sl-input-border-radius-medium);
- }
- .form-control-input:hover {
- background-color: var(--sl-input-background-color-hover);
- border-color: var(--sl-input-border-color-hover);
- color: var(--sl-input-color-hover);
- }
- .select--standard .select__control {
- border-style: none;
- }
- /* Move focus highlight */
- .form-control-input:focus-within {
- box-shadow: var(--sl-focus-ring);
- }
- .select--standard.select--focused:not(.select--disabled) .select__control {
- box-shadow: initial;
- }
- /* Show / hide SlSelect icons - dropdown arrow, etc but not loading spinner */
- :host([allowFreeEntries]) ::slotted(sl-icon[slot="suffix"]) {
- display: none;
- }
- /* Make search textbox take full width */
- ::slotted(.search_input), ::slotted(.search_input) input, .search_input, .search_input input {
- width: 100%;
- }
- .search_input input {
- flex: 1 1 auto;
- width: 100%;
- }
+
/* Full width search textbox covers loading spinner, lift it up */
::slotted(sl-spinner) {
z-index: 2;
}
- /* Don't show the current value while searching for single, we want the space
- This lets the current value shrink to nothing so the input can expand
- */
- .select__label {
- flex: 1 15 auto;
- }
+
/* Show edit textbox only when editing */
.search_input #edit {
display: none;
@@ -163,36 +133,55 @@ export const Et2WithSearchMixin = >(superclass
.search_input.editing #edit {
display: initial;
}
- :host([search]:not([multiple])) .select--open .select__prefix {
+
+
+ :host([search]) sl-select[open]::part(prefix), :host([allowfreeentries]) sl-select[open]::part(prefix) {
+ order: 9;
flex: 2 1 auto;
+ flex-wrap: wrap;
width: 100%;
}
- :host([search]:not([multiple])) .select--open .select__label {
- margin: 0px;
- }
- :host([allowfreeentries]:not([multiple])) .select--standard.select--open:not(.select--disabled) .select__control .select__prefix {
- flex: 1 1 auto;
- }
- :host([allowfreeentries]:not([multiple])) .select--standard.select--open:not(.select--disabled) .select__control .select__label {
+
+ :host([search]) sl-select[open]::part(display-input), :host([allowfreeentries]) sl-select[open]::part(display-input) {
display: none;
}
+ :host([search][multiple]) sl-select[open]::part(expand-icon) {
+ display: none;
+ }
+
+ :host([multiple]) sl-select[open]::part(tags) {
+ flex-basis: 100%;
+ }
+
+ :host([multiple]) sl-select[open]::part(combobox) {
+ flex-flow: wrap;
+ }
+
+
/* Search textbox general styling, starts hidden */
- .select__prefix ::slotted(.search_input), .search_input {
+ .search_input {
display: none;
+ /* See also etemplate2.css, searchbox border turned off in there */
+ border: none;
flex: 1 1 auto;
+ order: 2;
margin-left: 0px;
- width: 100%;
height: var(--sl-input-height-medium);
- position: absolute;
+ width: 100%;
background-color: white;
z-index: var(--sl-z-index-dropdown);
}
+ :host([search]) et2-textbox::part(base) {
+ border: none;
+ box-shadow: none;
+ }
+
/* Search UI active - show textbox & stuff */
- ::slotted(.search_input.active), .search_input.active,
+ .search_input.active,
.search_input.editing {
display: flex;
}
@@ -204,7 +193,8 @@ export const Et2WithSearchMixin = >(superclass
}
/* Hide options that do not match current search text */
- ::slotted(.no-match) {
+
+ .no-match {
display: none;
}
/* Different cursor for editable tags */
@@ -221,10 +211,6 @@ export const Et2WithSearchMixin = >(superclass
:host([readonly]) .form-control-input:focus-within {
box-shadow: none;
}
- /* no menu */
- :host([readonly]) sl-menu {
- display: none;
- }
/* normal cursor */
:host([readonly]) .select__control {
cursor: initial;
@@ -264,6 +250,11 @@ export const Et2WithSearchMixin = >(superclass
// Hold the original option data from earlier search results, since we discard on subsequent search
private _selected_remote = [];
+ // Hold current search results, selected or otherwise
+ private _remote_options = [];
+
+ private _total_result_count = 0;
+
/**
* These characters will end a free tag
* @type {string[]}
@@ -283,7 +274,7 @@ export const Et2WithSearchMixin = >(superclass
// Hiding the selected options from the dropdown means we can't un-select the tags
// hidden by the max limit. Prefer no limit.
- this.maxTagsVisible = -1;
+ this.maxOptionsVisible = -1;
this.validators = [];
/**
@@ -297,16 +288,19 @@ export const Et2WithSearchMixin = >(superclass
*/
this.defaultValidators = [];
- this.handleMenuSelect = this.handleMenuSelect.bind(this);
+ this.handleOptionClick = this.handleOptionClick.bind(this);
this._handleChange = this._handleChange.bind(this);
this.handleTagEdit = this.handleTagEdit.bind(this);
this._handleAfterShow = this._handleAfterShow.bind(this);
+ this._handleMenuHide = this._handleMenuHide.bind(this);
this._handleSearchBlur = this._handleSearchBlur.bind(this);
this._handleClear = this._handleClear.bind(this);
this._handleDoubleClick = this._handleDoubleClick.bind(this);
this._handleSearchAbort = this._handleSearchAbort.bind(this);
+ this._handleSearchClear = this._handleSearchClear.bind(this);
this._handleSearchChange = this._handleSearchChange.bind(this);
this._handleSearchKeyDown = this._handleSearchKeyDown.bind(this);
+ this._handleSearchMouseDown = this._handleSearchMouseDown.bind(this);
this._handleEditKeyDown = this._handleEditKeyDown.bind(this);
this._handlePaste = this._handlePaste.bind(this);
}
@@ -324,7 +318,6 @@ export const Et2WithSearchMixin = >(superclass
return;
}
- this._addNodes();
this._bindListeners();
}
@@ -363,7 +356,7 @@ export const Et2WithSearchMixin = >(superclass
}
else if(this.allowFreeEntries && this.multiple)
{
- this.value.forEach((e) =>
+ this.getValueAsArray().forEach((e) =>
{
if(!this.select_options.find(o => o.value == e))
{
@@ -402,7 +395,7 @@ export const Et2WithSearchMixin = >(superclass
}
// Normally this should be handled in render(), but we have to add our nodes in
- this._addNodes();
+ //this._addNodes();
}
// Update any tags if edit mode changes
if(changedProperties.has("editModeEnabled") || changedProperties.has("readonly"))
@@ -416,54 +409,33 @@ export const Et2WithSearchMixin = >(superclass
}
}
- /**
- * Add the nodes we need to search - adjust parent shadowDOM
- *
- * @protected
- */
- protected _addNodes()
+ protected _extraTemplate()
{
- if(this._activeControls)
+ if(!this.searchEnabled && !this.editModeEnabled && !this.allowFreeEntries || this.readonly)
{
- // Already there
- return;
+ return nothing;
}
- const div = document.createElement("div");
- div.classList.add("search_input");
- render(this._searchInputTemplate(), div);
- if(!super.multiple)
- {
- div.slot = "prefix";
- this.appendChild(div);
- return;
- }
-
- super.updateComplete.then(() =>
- {
- let control = this.shadowRoot.querySelector(".form-control-input");
- control.append(div);
- });
+ return html`
+ ${this._searchInputTemplate()}
+ ${this._moreResultsTemplate()}
+ `;
}
- /**
- * Customise how tags are rendered.
- * Override to add edit
- *
- * @param item
- * @protected
- */
- protected _createTagNode(item)
+ protected _moreResultsTemplate()
{
- let tag = document.createElement(this.tagTag);
- tag.editable = this.editModeEnabled && !this.readonly;
+ if(this._total_result_count == 0 || this._total_result_count - this._remote_options.length == 0)
+ {
+ return nothing;
+ }
+ const more = this.egw().lang("%1 more...", this._total_result_count - this._remote_options.length);
- return tag;
+ return html`${more}`;
}
protected _searchInputTemplate()
{
- let edit = null;
+ let edit = nothing;
if(this.editModeEnabled)
{
edit = html`>(superclass
@blur=${this.stopEdit.bind(this)}
/>`;
}
- // I can't figure out how to get this full width via CSS
return html`
-
+
${edit}
+
`;
}
@@ -517,6 +493,10 @@ export const Et2WithSearchMixin = >(superclass
this.querySelector(".search_input");
}
+ protected get optionTag()
+ {
+ return 'sl-option';
+ }
/**
* Only local options, excludes server options
@@ -525,7 +505,7 @@ export const Et2WithSearchMixin = >(superclass
*/
protected get localItems() : NodeList
{
- return this.querySelectorAll(this.optionTag + ":not(.remote)");
+ return this.select.querySelectorAll(this.optionTag + ":not(.remote)");
}
/**
@@ -535,7 +515,7 @@ export const Et2WithSearchMixin = >(superclass
*/
protected get remoteItems() : NodeList
{
- return this.querySelectorAll(this.optionTag + ".remote");
+ return this.select?.querySelectorAll(this.optionTag + ".remote") ?? [];
}
/**
@@ -545,7 +525,7 @@ export const Et2WithSearchMixin = >(superclass
*/
protected get freeEntries() : NodeList
{
- return this.querySelectorAll(this.optionTag + ".freeEntry");
+ return this.select?.querySelectorAll(this.optionTag + ".freeEntry") ?? [];
}
get select_options() : SelectOption[]
@@ -558,6 +538,9 @@ export const Et2WithSearchMixin = >(superclass
// Any kept remote options
options = options.concat(this._selected_remote ?? []);
+ // Current search results
+ options = options.concat(this._remote_options ?? []);
+
if(this.allowFreeEntries)
{
this.freeEntries.forEach((item : SlMenuItem) =>
@@ -600,11 +583,11 @@ export const Et2WithSearchMixin = >(superclass
{
return;
}
-
+
// If widget is currently open, we may need to re-calculate search / dropdown positioning
if(this.isOpen)
{
- this.handleMenuShow();
+ this._handleMenuShow();
}
}
@@ -641,7 +624,7 @@ export const Et2WithSearchMixin = >(superclass
const valueArray = Array.isArray(this.value) ? this.value : (!this.value ? [] : this.value.toString().split(','));
// Check any already found options
- if(Object.values(this.menuItems).filter((option) => valueArray.find(val => val == option.value)).length === 0)
+ if(Object.values(this.getAllOptions()).filter((option) => valueArray.find(val => val == option.value)).length === 0)
{
return false;
}
@@ -653,7 +636,9 @@ export const Et2WithSearchMixin = >(superclass
protected _bindListeners()
{
this.addEventListener("sl-clear", this._handleClear);
+ this.addEventListener("sl-show", this._handleMenuShow);
this.addEventListener("sl-after-show", this._handleAfterShow);
+ this.addEventListener("sl-hide", this._handleMenuHide);
// Need our own change to catch the change event from search input
this.addEventListener("change", this._handleChange);
@@ -673,14 +658,16 @@ export const Et2WithSearchMixin = >(superclass
this._searchInputNode?.removeEventListener("change", this._searchInputNode.handleChange);
this._searchInputNode?.addEventListener("change", this._handleSearchChange);
- this.dropdown.querySelector('.select__label').addEventListener("change", this.handleTagEdit);
+ // this.dropdown.querySelector('.select__label').addEventListener("change", this.handleTagEdit);
});
}
protected _unbindListeners()
{
this.removeEventListener("sl-select", this._handleSelect);
+ this.removeEventListener("sl-show", this._handleMenuShow);
this.removeEventListener("sl-after-show", this._handleAfterShow);
+ this.removeEventListener("sl-hide", this._handleMenuHide);
this.removeEventListener("sl-clear", this._handleClear)
this.removeEventListener("change", this._handleChange);
this.removeEventListener("paste", this._handlePaste);
@@ -688,7 +675,7 @@ export const Et2WithSearchMixin = >(superclass
this._searchInputNode?.removeEventListener("change", this._handleSearchChange);
}
- handleMenuShow()
+ _handleMenuShow()
{
if(this.readonly)
{
@@ -698,15 +685,11 @@ export const Et2WithSearchMixin = >(superclass
this._activeControls?.classList.toggle("novalue", this.multiple && this.value == '' || !this.multiple);
// Reset for parent calculations, will be adjusted after if needed
- this.dropdown.setAttribute("distance", 0);
-
- super.handleMenuShow();
+ //this.dropdown.setAttribute("distance", 0);
if(this.searchEnabled || this.allowFreeEntries)
{
this._activeControls?.classList.add("active");
- this._searchInputNode.focus();
- this._searchInputNode.select();
// Hide edit explicitly since it's so hard via CSS
if(this._editInputNode)
{
@@ -729,6 +712,12 @@ export const Et2WithSearchMixin = >(superclass
*/
_handleAfterShow()
{
+ if(this.searchEnabled || this.allowFreeEntries)
+ {
+ this._searchInputNode.focus();
+ this._searchInputNode.select();
+ }
+ return;
// Need to give positioner a chance to position.
// If we call it right away, it has not updated.
// I haven't found an event or Promise to hook on to
@@ -749,24 +738,22 @@ export const Et2WithSearchMixin = >(superclass
);
}
}, 100);
+
}
focus()
{
- this.dropdown?.show().then(() =>
- {
- this._searchInputNode.focus();
- });
+ this.show();
+ this._searchInputNode.focus();
}
- handleMenuHide()
+ _handleMenuHide()
{
if(this.readonly)
{
return;
}
- clearTimeout(this._searchTimeout);
- super.handleMenuHide();
+ this.clearSearch();
// Reset display
if(this._searchInputNode)
@@ -778,11 +765,7 @@ export const Et2WithSearchMixin = >(superclass
this._editInputNode.style.display = "";
}
- if(this.searchEnabled || this.allowFreeEntries)
- {
- this._activeControls?.classList.remove("active");
- this.shadowRoot.querySelector('.select__label').style.display = "";
- }
+ this._activeControls?.classList.remove("active");
}
_triggerChange(event)
@@ -830,14 +813,14 @@ export const Et2WithSearchMixin = >(superclass
/**
* An option was selected
*/
- handleMenuSelect(event)
+ handleOptionClick(event)
{
// Need to keep the remote option - only if selected
- if(event.detail.item.classList.contains("remote") && !this.select_options.find(o => o.value == event.detail.item.value))
+ if(event.target.classList.contains("remote") && !this.select_options.find(o => o.value == event.target.value))
{
- this._selected_remote.push({...event.detail.item.option});
+ this._selected_remote.push({...event.target.option});
}
- super.handleMenuSelect(event);
+ super.handleOptionClick(event);
this.updateComplete.then(() =>
{
@@ -850,12 +833,12 @@ export const Et2WithSearchMixin = >(superclass
// If we were overlapping, reset
if(this._activeControls.classList.contains("novalue"))
{
- this.handleMenuShow();
+ this._handleMenuShow();
this._handleAfterShow();
}
// Scroll the new tag into view
- if(event.detail && event.detail.item)
+ if(event.detail)
{
// Causes sidemenu (calendar) to scroll to top & get stuck
/*
@@ -881,17 +864,14 @@ export const Et2WithSearchMixin = >(superclass
_handleClear(e)
{
// Only keep remote options that are still used
- this._selected_remote = this._selected_remote.filter((option) => this.getValueAsArray().indexOf(option.value) !== -1);
+ this._selected_remote = this._selected_remote.filter((option) => this.value.indexOf(option.value) !== -1);
if(!this.multiple && this.searchEnabled)
{
this._handleSearchAbort(e);
- // Restore label styling
- this.shadowRoot.querySelector("[part='display-label']").style.display = "";
-
// Start searching again
- this.updateComplete.then(() => this.handleMenuShow())
+ this._handleMenuShow();
}
}
@@ -906,17 +886,6 @@ export const Et2WithSearchMixin = >(superclass
async _handleSearchBlur(event : FocusEvent)
{
clearTimeout(this._searchTimeout);
- if(event.relatedTarget && event.relatedTarget instanceof SlMenuItem)
- {
- return;
- }
-
- // Try any value they had in progress
- if(this._searchInputNode.value && this.allowFreeEntries)
- {
- this.createFreeEntry(this._searchInputNode.value);
- }
- this.clearSearch();
}
/**
@@ -928,15 +897,14 @@ export const Et2WithSearchMixin = >(superclass
{
clearTimeout(this._searchTimeout);
this._activeControls?.classList.add("active");
- this.dropdown.show();
// Pass off some keys to select
if(['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key))
{
// Strip out hidden non-matching selected & disabled items so key navigation works
- this.menuItems = this.menuItems.filter(i => !i.disabled);
- return super.handleKeyDown(event);
+ // TODO
+ return;
}
event.stopPropagation();
@@ -946,12 +914,12 @@ export const Et2WithSearchMixin = >(superclass
{
event.preventDefault();
this._searchInputNode.value = "";
- this.dropdown.hide().then(async() =>
+ this.updateComplete.then(async() =>
{
// update sizing / position before getting ready for another one
if(this.multiple)
{
- await this.dropdown.show();
+ // await this.show();
this._searchInputNode.focus();
}
});
@@ -977,6 +945,17 @@ export const Et2WithSearchMixin = >(superclass
}
}
+ /**
+ * Combobox listens for mousedown, which interferes with search clear button.
+ * Here we block it from bubbling
+ * @param {MouseEvent} event
+ * @protected
+ */
+ protected _handleSearchMouseDown(event : MouseEvent)
+ {
+ event.stopPropagation();
+ }
+
protected _handleEditKeyDown(event : KeyboardEvent)
{
// Stop propagation, or parent key handler will add again
@@ -1037,7 +1016,7 @@ export const Et2WithSearchMixin = >(superclass
// Show a spinner
let spinner = document.createElement("sl-spinner");
- spinner.slot = "suffix";
+ spinner.slot = "expand-icon";
this.appendChild(spinner);
// Hide clear button
@@ -1048,6 +1027,7 @@ export const Et2WithSearchMixin = >(superclass
}
// Clear previous results
+ this._total_result_count = 0;
this._clearResults();
await this.updateComplete;
@@ -1058,7 +1038,7 @@ export const Et2WithSearchMixin = >(superclass
]).then(() =>
{
// Show no results indicator
- if(this.menuItems.filter(e => !e.classList.contains("no-match")).length == 0)
+ if(this.getAllOptions().filter(e => !e.classList.contains("no-match")).length == 0)
{
let target = this._optionTargetNode || this;
let temp = document.createElement("div");
@@ -1074,13 +1054,6 @@ export const Et2WithSearchMixin = >(superclass
{
clear_button.style.display = "";
}
- }).then(() =>
- {
- // Not sure why this stays hidden if there's no results, but it sticks and hides all results afterward
- this.dropdown.shadowRoot.querySelector(".dropdown__panel").removeAttribute("hidden");
-
- // Call our resize stuff explicitly
- this._handleAfterShow();
});
}
@@ -1128,14 +1101,9 @@ export const Et2WithSearchMixin = >(superclass
n.remove();
}
})
-
- // Reset remaining options. It might be faster to re-create instead.
- this._menuItems.forEach((item) =>
- {
- item.disabled = item.option?.disabled || false;
- item.classList.remove("match");
- item.classList.remove("no-match");
- });
+ // Not searching anymore, clear flag
+ this.select_options.map((o) => o.isMatch = null);
+ this.requestUpdate("select_options");
}
/**
@@ -1148,14 +1116,11 @@ export const Et2WithSearchMixin = >(superclass
{
return new Promise((resolve) =>
{
- this.localItems.forEach((item) =>
+ this.select_options.forEach((option) =>
{
- let match = this.searchMatch(search, item);
- item.classList.toggle("match", match);
- // set disabled so arrow keys step over. Might be a better way to handle that
- item.disabled = !match;
- item.classList.toggle("no-match", !match);
+ option.isMatch = this.searchMatch(search, option);
})
+ this.requestUpdate("select_options");
resolve();
});
}
@@ -1208,13 +1173,13 @@ export const Et2WithSearchMixin = >(superclass
return option.label.toLowerCase().includes(lower_search) || option.value.includes(search)
});
// Limit results
- const totalCount = filtered.length;
+ this._total_result_count = filtered.length;
if(filtered.length > Et2WidgetWithSearch.RESULT_LIMIT)
{
filtered.splice(Et2WidgetWithSearch.RESULT_LIMIT);
}
// Add the matches
- this.processRemoteResults(filtered, totalCount);
+ this.processRemoteResults(filtered);
return filtered;
})
.catch((_err) =>
@@ -1252,14 +1217,14 @@ export const Et2WithSearchMixin = >(superclass
{
// If results have a total included, pull it out.
// It will cause errors if left in the results
- let total = null;
+ this._total_result_count = results.length;
if(typeof results.total !== "undefined")
{
- total = results.total;
+ this._total_result_count = results.total;
delete results.total;
}
let entries = cleanSelectOptions(results);
- this.processRemoteResults(entries, total);
+ this.processRemoteResults(entries);
return entries;
});
}
@@ -1267,68 +1232,36 @@ export const Et2WithSearchMixin = >(superclass
/**
* Add in remote results
* @param results
- * @param totalResults If there are more results than were returned, total number of matches
* @protected
*/
- protected processRemoteResults(entries, totalResults = 0)
+ protected processRemoteResults(entries)
{
if(!entries?.length)
{
return Promise.resolve();
}
// Add a "remote" class so we can tell these apart from any local results
- entries.forEach((entry) => entry.class = (entry.class || "") + " remote");
-
- let target = this._optionTargetNode || this;
- if(target)
+ entries.forEach((entry) =>
{
- // Add in remote options, avoiding duplicates
- this.select_options.filter(function(item)
+ entry.class = (entry.class || "") + " remote";
+ // Server says it's a match
+ entry.isMatch = true;
+ });
+
+
+ // Add in remote options, avoiding duplicates
+ this.select_options.filter(function(item)
+ {
+ let i = entries.findIndex(x => (x.value == item.value));
+ if(i <= -1)
{
- let i = entries.findIndex(x => (x.value == item.value));
- if(i <= -1)
- {
- entries.push(item);
- }
- return null;
- });
+ entries.push(item);
+ }
+ return null;
+ });
- let options = html`${entries.map(this._optionTemplate.bind(this))}`;
-
- /**
- * Add in new options.
- * Rendering directly into target will remove existing options, which we don't need to do
- */
-
- let temp_target = document.createElement("div");
- let resultCount = entries.length;
-
- render(options, temp_target);
- return Promise.all(([...temp_target.querySelectorAll(":scope > *")].map(item => item.render)))
- .then(() =>
- {
- temp_target.querySelectorAll(":scope > *").forEach((item) =>
- {
- // Avoid duplicate error
- if(!target.querySelector("[value='" + ('' + item.value).replace(/'/g, "\\\'") + "']"))
- {
- target.appendChild(item);
- }
- })
- this.handleMenuSlotChange();
- })
- .then(() =>
- {
- if(totalResults && totalResults > resultCount)
- {
- // More results available that were not sent
- let count = document.createElement("span")
- count.classList.add("remote");
- count.textContent = this.egw().lang("%1 more...", totalResults - resultCount);
- target.appendChild(count);
- }
- });
- }
+ this._remote_options = entries;
+ this.requestUpdate("select_options");
}
/**
@@ -1339,21 +1272,21 @@ export const Et2WithSearchMixin = >(superclass
* @returns {boolean}
* @protected
*/
- protected searchMatch(search, item) : boolean
+ protected searchMatch(search, option : SelectOption) : boolean
{
- if(!item || !item.value)
+ if(!option || !option.value)
{
return false;
}
- if(item.textContent?.toLowerCase().includes(search.toLowerCase()))
+ if(option.label?.toLowerCase().includes(search.toLowerCase()))
{
return true;
}
- if(typeof item.value == "string")
+ if(typeof option.value == "string")
{
- return item.value.includes(search.toLowerCase());
+ return option.value.includes(search.toLowerCase());
}
- return item.value == search;
+ return option.value == search;
}
/**
@@ -1378,16 +1311,21 @@ export const Et2WithSearchMixin = >(superclass
this.requestUpdate('select_options');
}
- // Make sure not to double-add
- if(this.multiple && this.value.indexOf(text) == -1)
+ // Make sure not to double-add, but wait until the option is there
+ this.updateComplete.then(() =>
{
- this.value.push(text);
- }
- else if(!this.multiple && this.value !== text)
- {
- this.value = text;
- }
- this.requestUpdate("value");
+ if(this.multiple && this.getValueAsArray().indexOf(text) == -1)
+ {
+ let value = this.getValueAsArray();
+ value.push(text);
+ this.value = value;
+ }
+ else if(!this.multiple && this.value !== text)
+ {
+ this.value = text;
+ }
+ this.requestUpdate("value");
+ });
// If we were overlapping edit inputbox with the value display, reset
if(!this.readonly && this._activeControls?.classList.contains("novalue"))
@@ -1478,7 +1416,7 @@ export const Et2WithSearchMixin = >(superclass
// type to select will focus matching entries, but we don't want to stop the edit yet
if(typeof abort == "object" && abort.type == "blur")
{
- if(abort.relatedTarget?.localName == "sl-menu-item")
+ if(abort.relatedTarget?.localName == this.optionTag)
{
return;
}
@@ -1530,14 +1468,12 @@ export const Et2WithSearchMixin = >(superclass
this.dropdown.panel.setAttribute("hidden", "");
});
}
- this.syncItemsFromValue();
}
protected _handleSearchAbort(e)
{
this._activeControls.classList.remove("active");
this.clearSearch();
- this.syncItemsFromValue();
}
/**
@@ -1552,7 +1488,14 @@ export const Et2WithSearchMixin = >(superclass
e.preventDefault();
return false;
}
+
+ protected _handleSearchClear(e)
+ {
+ e.stopImmediatePropagation();
+ e.preventDefault();
+ this.clearSearch();
+ }
}
return Et2WidgetWithSearch as unknown as Constructor & T;
-}
\ No newline at end of file
+});
\ No newline at end of file
diff --git a/api/js/etemplate/Et2Select/Et2SelectAccount.ts b/api/js/etemplate/Et2Select/Select/Et2SelectAccount.ts
similarity index 77%
rename from api/js/etemplate/Et2Select/Et2SelectAccount.ts
rename to api/js/etemplate/Et2Select/Select/Et2SelectAccount.ts
index fc45fab108..537d0cb213 100644
--- a/api/js/etemplate/Et2Select/Et2SelectAccount.ts
+++ b/api/js/etemplate/Et2Select/Select/Et2SelectAccount.ts
@@ -7,13 +7,13 @@
* @author Ralf Becker
*/
-import {Et2Select} from "./Et2Select";
-import {cleanSelectOptions, SelectOption} from "./FindSelectOptions";
-import {SelectAccountMixin} from "./SelectAccountMixin";
-import {Et2StaticSelectMixin} from "./StaticOptions";
-import {html, nothing} from "@lion/core";
+import {Et2Select} from "../Et2Select";
+import {cleanSelectOptions, SelectOption} from "../FindSelectOptions";
+import {SelectAccountMixin} from "../SelectAccountMixin";
+import {Et2StaticSelectMixin} from "../StaticOptions";
+import {html, nothing} from "lit";
-export type AccountType = 'accounts'|'groups'|'both'|'owngroups';
+export type AccountType = 'accounts' | 'groups' | 'both' | 'owngroups';
/**
* @customElement et2-select-account
@@ -58,17 +58,16 @@ export class Et2SelectAccount extends SelectAccountMixin(Et2StaticSelectMixin(Et
{
if(this.accountType === 'both')
{
- fetch.push(this.egw().accounts('accounts').then(options => {this.static_options = this.static_options.concat(cleanSelectOptions(options))}));
+ fetch.push(this.egw().accounts('accounts').then(options => {this._static_options = this._static_options.concat(cleanSelectOptions(options))}));
}
- fetch.push(this.egw().accounts('owngroups').then(options => {this.static_options = this.static_options.concat(cleanSelectOptions(options))}));
+ fetch.push(this.egw().accounts('owngroups').then(options => {this._static_options = this._static_options.concat(cleanSelectOptions(options))}));
}
else
{
- fetch.push(this.egw().accounts(this.accountType).then(options => {this.static_options = this.static_options.concat(cleanSelectOptions(options))}));
+ fetch.push(this.egw().accounts(this.accountType).then(options => {this._static_options = this._static_options.concat(cleanSelectOptions(options))}));
}
- this.fetchComplete = Promise.all(fetch)
- .then(() => this._renderOptions());
+ this.fetchComplete = Promise.all(fetch);
}
@@ -102,12 +101,7 @@ export class Et2SelectAccount extends SelectAccountMixin(Et2StaticSelectMixin(Et
{
return [];
}
- let select_options : Array = [...(this.static_options || []), ...super.select_options];
-
- return select_options.filter((value, index, self) =>
- {
- return self.findIndex(v => v.value === value.value) === index;
- });
+ return super.select_options;
}
set select_options(new_options : SelectOption[])
diff --git a/api/js/etemplate/Et2Select/Select/Et2SelectApp.ts b/api/js/etemplate/Et2Select/Select/Et2SelectApp.ts
new file mode 100644
index 0000000000..c748c6edad
--- /dev/null
+++ b/api/js/etemplate/Et2Select/Select/Et2SelectApp.ts
@@ -0,0 +1,17 @@
+import {Et2Select} from "../Et2Select";
+import {Et2StaticSelectMixin, StaticOptions as so} from "../StaticOptions";
+import {cleanSelectOptions} from "../FindSelectOptions";
+
+export class Et2SelectApp extends Et2StaticSelectMixin(Et2Select)
+{
+ public connectedCallback()
+ {
+ super.connectedCallback()
+ this.fetchComplete = so.app(this, {}).then((options) =>
+ {
+ this.set_static_options(cleanSelectOptions(options));
+ })
+ }
+}
+
+customElements.define("et2-select-app", Et2SelectApp);
\ No newline at end of file
diff --git a/api/js/etemplate/Et2Select/Select/Et2SelectBitwise.ts b/api/js/etemplate/Et2Select/Select/Et2SelectBitwise.ts
new file mode 100644
index 0000000000..84d8abf661
--- /dev/null
+++ b/api/js/etemplate/Et2Select/Select/Et2SelectBitwise.ts
@@ -0,0 +1,26 @@
+import {Et2Select} from "../Et2Select";
+import {Et2StaticSelectMixin} from "../StaticOptions";
+
+export class Et2SelectBitwise extends Et2StaticSelectMixin(Et2Select)
+{
+ /* currently handled server-side */
+ /*
+ set value(new_value)
+ {
+ let oldValue = this._value;
+ let expanded_value = [];
+ let options = this.select_options;
+ for(let index in options)
+ {
+ let right = parseInt(options[index].value);
+ if(!!(new_value & right))
+ {
+ expanded_value.push(right);
+ }
+ }
+ super.value = expanded_value;
+ }
+ */
+}
+
+customElements.define("et2-select-bitwise", Et2SelectBitwise);
\ No newline at end of file
diff --git a/api/js/etemplate/Et2Select/Select/Et2SelectBool.ts b/api/js/etemplate/Et2Select/Select/Et2SelectBool.ts
new file mode 100644
index 0000000000..f91675acfa
--- /dev/null
+++ b/api/js/etemplate/Et2Select/Select/Et2SelectBool.ts
@@ -0,0 +1,28 @@
+import {Et2Select} from "../Et2Select";
+import {Et2StaticSelectMixin, StaticOptions} from "../StaticOptions";
+
+export class Et2SelectBool extends Et2StaticSelectMixin(Et2Select)
+{
+ constructor()
+ {
+ super();
+
+ this._static_options = StaticOptions.bool(this);
+ }
+
+ get value()
+ {
+ return super.value;
+ }
+
+ /**
+ * Boolean option values are "0" and "1", so change boolean to those
+ * @param {string | string[]} new_value
+ */
+ set value(new_value)
+ {
+ super.value = new_value ? "1" : "0";
+ }
+}
+
+customElements.define("et2-select-bool", Et2SelectBool);
diff --git a/api/js/etemplate/Et2Select/Et2SelectCategory.ts b/api/js/etemplate/Et2Select/Select/Et2SelectCategory.ts
similarity index 52%
rename from api/js/etemplate/Et2Select/Et2SelectCategory.ts
rename to api/js/etemplate/Et2Select/Select/Et2SelectCategory.ts
index d614d383ce..c462612b1e 100644
--- a/api/js/etemplate/Et2Select/Et2SelectCategory.ts
+++ b/api/js/etemplate/Et2Select/Select/Et2SelectCategory.ts
@@ -8,10 +8,13 @@
*/
-import {css, PropertyValues} from "@lion/core";
-import {Et2Select} from "./Et2Select";
-import {Et2StaticSelectMixin, StaticOptions as so} from "./StaticOptions";
-import {cleanSelectOptions} from "./FindSelectOptions";
+import {css, html, nothing, PropertyValues, TemplateResult, unsafeCSS} from "lit";
+import {Et2Select} from "../Et2Select";
+import {Et2StaticSelectMixin, StaticOptions as so} from "../StaticOptions";
+import {cleanSelectOptions} from "../FindSelectOptions";
+import {StaticValue} from "lit/development/static-html";
+import {literal} from "lit/static-html.js";
+import {repeat} from "lit/directives/repeat.js";
/**
* Customised Select widget for categories
@@ -25,12 +28,14 @@ export class Et2SelectCategory extends Et2StaticSelectMixin(Et2Select)
...super.styles,
css`
/* Category color on options */
- ::slotted(*) {
+
+ sl-option {
border-left: 6px solid var(--category-color, transparent);
}
/* Border on the (single) selected value */
- :host(.hasValue:not([multiple])) .select--standard .select__control {
- border-left: 6px solid var(--sl-input-border-color);
+
+ :host(:not([multiple]))::part(combobox) {
+ border-left: 6px solid var(--category-color, var(--sl-input-border-color));
}
`
]
@@ -73,13 +78,6 @@ export class Et2SelectCategory extends Et2StaticSelectMixin(Et2Select)
(this.getInstanceManager() && this.getInstanceManager().app) ||
this.egw().app_name();
}
- // If app passes options (addressbook index) we'll use those instead.
- // They will be found automatically by update() after ID is set.
- await this.updateComplete;
- if(this.select_options.length == 0)
- {
-
- }
}
@@ -91,72 +89,57 @@ export class Et2SelectCategory extends Et2StaticSelectMixin(Et2Select)
{
this.fetchComplete = so.cat(this).then(options =>
{
- this.static_options = cleanSelectOptions(options);
+ this._static_options = cleanSelectOptions(options);
this.requestUpdate("select_options");
});
}
-
- if(changedProperties.has("value") || changedProperties.has('select_options'))
- {
- this.doLabelChange()
- }
}
- /**
- * Override from parent (SlSelect) to customise display of the current value.
- * Here's where we add the icon & color border
- */
- doLabelChange()
+
+ protected handleValueChange(e)
{
- // Update the display label when checked menu item's label changes
- if(this.multiple)
- {
- return;
- }
+ super.handleValueChange(e);
- const checkedItem = this.menuItems.find(item => item.value === this.value);
- this.displayLabel = checkedItem ? checkedItem.textContent : '';
- this.querySelector("[slot=prefix].tag_image")?.remove();
- if(checkedItem)
- {
- let image = this._createImage(checkedItem)
- if(image)
- {
- this.append(image);
- }
- this.dropdown.querySelector(".select__control").style.borderColor =
- getComputedStyle(checkedItem).getPropertyValue("--category-color") || "";
- }
+ // Just re-draw to get the colors & icon
+ this.requestUpdate();
}
/**
- * Render select_options as child DOM Nodes
+ * Used to render each option into the select
+ * Overridden for colors
*
- * Overridden here so we can re-do the displayed label after first load of select options.
- * Initial load order / lifecycle does not have all the options at the right time
- * @protected
+ * @param {SelectOption} option
+ * @returns {TemplateResult}
*/
- protected _renderOptions()
+ public render() : TemplateResult
{
- // @ts-ignore Doesn't know about Et2WidgetWithSelectMixin._renderOptions()
- return super._renderOptions().then(() =>
- {
- // @ts-ignore Doesn't know about SlSelect.menuItems
- if(this.menuItems.length > 0)
- {
- this.doLabelChange();
- }
- });
+ /** CSS variables are not making it through to options, re-declaring them here works */
+ return html`
+
+ ${super.render()}
+ `;
}
-
+
/**
* Use a custom tag for when multiple=true
*
* @returns {string}
*/
- get tagTag() : string
+ public get tagTag() : StaticValue
{
- return "et2-category-tag";
+ return literal`et2-category-tag`;
}
/**
diff --git a/api/js/etemplate/Et2Select/Et2SelectCountry.ts b/api/js/etemplate/Et2Select/Select/Et2SelectCountry.ts
similarity index 82%
rename from api/js/etemplate/Et2Select/Et2SelectCountry.ts
rename to api/js/etemplate/Et2Select/Select/Et2SelectCountry.ts
index 1cd7d3aaa9..d031d0be9d 100644
--- a/api/js/etemplate/Et2Select/Et2SelectCountry.ts
+++ b/api/js/etemplate/Et2Select/Select/Et2SelectCountry.ts
@@ -8,10 +8,10 @@
*/
-import {Et2Select} from "./Et2Select";
-import {Et2StaticSelectMixin, StaticOptions as so} from "./StaticOptions";
-import {egw} from "../../jsapi/egw_global";
-import {SelectOption} from "./FindSelectOptions";
+import {Et2Select} from "../Et2Select";
+import {Et2StaticSelectMixin, StaticOptions as so} from "../StaticOptions";
+import {egw} from "../../../jsapi/egw_global";
+import {SelectOption} from "../FindSelectOptions";
/**
* Customised Select widget for countries
@@ -38,7 +38,7 @@ export class Et2SelectCountry extends Et2StaticSelectMixin(Et2Select)
(>so.country(this, {}, true)).then(options =>
{
- this.static_options = options
+ this._static_options = options
this.requestUpdate("select_options");
});
}
diff --git a/api/js/etemplate/Et2Select/Select/Et2SelectDay.ts b/api/js/etemplate/Et2Select/Select/Et2SelectDay.ts
new file mode 100644
index 0000000000..e2979c5c7b
--- /dev/null
+++ b/api/js/etemplate/Et2Select/Select/Et2SelectDay.ts
@@ -0,0 +1,14 @@
+import {Et2Select} from "../Et2Select";
+import {Et2StaticSelectMixin, StaticOptions as so} from "../StaticOptions";
+
+export class Et2SelectDay extends Et2StaticSelectMixin(Et2Select)
+{
+ constructor()
+ {
+ super();
+
+ this._static_options = so.day(this, {});
+ }
+}
+
+customElements.define("et2-select-day", Et2SelectDay);
\ No newline at end of file
diff --git a/api/js/etemplate/Et2Select/Select/Et2SelectDayOfWeek.ts b/api/js/etemplate/Et2Select/Select/Et2SelectDayOfWeek.ts
new file mode 100644
index 0000000000..0ebee30b58
--- /dev/null
+++ b/api/js/etemplate/Et2Select/Select/Et2SelectDayOfWeek.ts
@@ -0,0 +1,53 @@
+import {Et2Select} from "../Et2Select";
+import {Et2StaticSelectMixin, StaticOptions} from "../StaticOptions";
+import {cleanSelectOptions} from "../FindSelectOptions";
+
+export class Et2SelectDayOfWeek extends Et2StaticSelectMixin(Et2Select)
+{
+ connectedCallback()
+ {
+ super.connectedCallback();
+
+ // Wait for connected instead of constructor because attributes make a difference in
+ // which options are offered
+ this.fetchComplete = StaticOptions.dow(this, {other: this.other || []}).then(options =>
+ {
+ this.set_static_options(cleanSelectOptions(options));
+ });
+ }
+
+ set value(new_value)
+ {
+ let expanded_value = typeof new_value == "object" ? new_value : [];
+ if(new_value && (typeof new_value == "string" || typeof new_value == "number"))
+ {
+ let int_value = parseInt(new_value);
+ this.updateComplete.then(() =>
+ {
+ this.fetchComplete.then(() =>
+ {
+ let options = this.select_options;
+ for(let index in options)
+ {
+ let right = parseInt(options[index].value);
+
+ if((int_value & right) == right)
+ {
+ expanded_value.push("" + right);
+ }
+ }
+ super.value = expanded_value;
+ })
+ });
+ return;
+ }
+ super.value = expanded_value;
+ }
+
+ get value()
+ {
+ return super.value;
+ }
+}
+
+customElements.define("et2-select-dow", Et2SelectDayOfWeek);
\ No newline at end of file
diff --git a/api/js/etemplate/Et2Select/Et2SelectEmail.ts b/api/js/etemplate/Et2Select/Select/Et2SelectEmail.ts
similarity index 84%
rename from api/js/etemplate/Et2Select/Et2SelectEmail.ts
rename to api/js/etemplate/Et2Select/Select/Et2SelectEmail.ts
index cffdd01183..c74f59fb2b 100644
--- a/api/js/etemplate/Et2Select/Et2SelectEmail.ts
+++ b/api/js/etemplate/Et2Select/Select/Et2SelectEmail.ts
@@ -7,11 +7,12 @@
* @author Nathan Gray
*/
-import {Et2Select} from "./Et2Select";
-import {css, html, nothing, PropertyValues} from "@lion/core";
-import {IsEmail} from "../Validators/IsEmail";
+import {Et2Select} from "../Et2Select";
+import {css, html, nothing, PropertyValues} from "lit";
+import {IsEmail} from "../../Validators/IsEmail";
import interact from "@interactjs/interact";
import {Validator} from "@lion/form-core";
+import {classMap} from "lit/directives/class-map.js";
/**
* Select email address(es)
@@ -100,6 +101,7 @@ export class Et2SelectEmail extends Et2Select
this.defaultValidators.push(new IsEmail(this.allowPlaceholder));
}
+
/** @param {import('@lion/core').PropertyValues } changedProperties */
willUpdate(changedProperties : PropertyValues)
{
@@ -112,6 +114,40 @@ export class Et2SelectEmail extends Et2Select
}
}
+ updated(changedProperties : Map)
+ {
+ // Make tags draggable
+ if(!this.readonly && this.allowFreeEntries && this.allowDragAndDrop)
+ {
+ let dragTranslate = {x: 0, y: 0};
+ const tags = this.shadowRoot.querySelectorAll(".select__tags [part='tag']");
+ let draggable = interact(tags).draggable({
+ startAxis: 'xy',
+ listeners: {
+ start: function(e)
+ {
+ let dragPosition = {x: e.page.x, y: e.page.y};
+ dragTranslate = {x: 0, y: 0};
+ e.target.setAttribute('style', `width:${e.target.clientWidth}px !important`);
+ e.target.style.position = 'fixed';
+ e.target.style.zIndex = 10;
+ e.target.style.transform =
+ `translate(${dragPosition.x}px, ${dragPosition.y}px)`;
+ },
+ move: function(e)
+ {
+ dragTranslate.x += e.delta.x;
+ dragTranslate.y += e.delta.y;
+ e.target.style.transform =
+ `translate(${dragTranslate.x}px, ${dragTranslate.y}px)`;
+ }
+ }
+ });
+ // set parent_node with widget context in order to make it accessible after drop
+ draggable.parent_node = this;
+ }
+ }
+
connectedCallback()
{
super.connectedCallback();
@@ -145,25 +181,7 @@ export class Et2SelectEmail extends Et2Select
}
});
}
-
- /**
- * Handle keypresses inside the search input
- * Overridden from parent to also skip the hidden selected options, which other selects do not do
- *
- * @param {KeyboardEvent} event
- * @protected
- */
- protected _handleSearchKeyDown(event : KeyboardEvent)
- {
- // Pass off some keys to select
- if(['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key))
- {
- // Strip out hidden non-matching selected so key navigation works
- this.menuItems = this.menuItems.filter(i => !i.checked);
- }
- return super._handleSearchKeyDown(event);
- }
-
+
/**
* Actually query the server.
*
@@ -187,55 +205,21 @@ export class Et2SelectEmail extends Et2Select
*
* @returns {string}
*/
- get tagTag() : string
+ _tagTemplate(option, index)
{
- return "et2-email-tag";
- }
-
- /**
- * override tag creation in order to add DND functionality
- * @param item
- * @protected
- */
- protected _createTagNode(item)
- {
- let tag = super._createTagNode(item);
-
- tag.fullEmail = this.fullEmail;
- tag.onlyEmail = this.onlyEmail;
-
- // Re-set after setting fullEmail as that can change what we show
- tag.textContent = item.getTextLabel().trim();
-
- if(!this.readonly && this.allowFreeEntries && this.allowDragAndDrop)
- {
- let dragTranslate = {x: 0, y: 0};
- tag.class = item.classList.value + " et2-select-draggable";
- let draggable = interact(tag).draggable({
- startAxis: 'xy',
- listeners: {
- start: function(e)
- {
- let dragPosition = {x:e.page.x, y:e.page.y};
- e.target.setAttribute('style', `width:${e.target.clientWidth}px !important`);
- e.target.style.position = 'fixed';
- e.target.style.zIndex = 10;
- e.target.style.transform =
- `translate(${dragPosition.x}px, ${dragPosition.y}px)`;
- },
- move : function(e)
- {
- dragTranslate.x += e.delta.x;
- dragTranslate.y += e.delta.y;
- e.target.style.transform =
- `translate(${dragTranslate.x}px, ${dragTranslate.y}px)`;
- }
- }
- });
- // set parent_node with widget context in order to make it accessible after drop
- draggable.parent_node = this;
- }
- return tag;
+ return html`
+
+ ${option.getTextLabel().trim()}
+
+ `;
}
/**
diff --git a/api/js/etemplate/Et2Select/Select/Et2SelectHour.ts b/api/js/etemplate/Et2Select/Select/Et2SelectHour.ts
new file mode 100644
index 0000000000..4423a53d88
--- /dev/null
+++ b/api/js/etemplate/Et2Select/Select/Et2SelectHour.ts
@@ -0,0 +1,14 @@
+import {Et2Select} from "../Et2Select";
+import {Et2StaticSelectMixin, StaticOptions} from "../StaticOptions";
+
+export class Et2SelectHour extends Et2StaticSelectMixin(Et2Select)
+{
+ constructor()
+ {
+ super();
+
+ this._static_options = StaticOptions.hour(this, {other: this.other || []});
+ }
+}
+
+customElements.define("et2-select-hour", Et2SelectHour);
\ No newline at end of file
diff --git a/api/js/etemplate/Et2Select/Select/Et2SelectLang.ts b/api/js/etemplate/Et2Select/Select/Et2SelectLang.ts
new file mode 100644
index 0000000000..6ea9f7eb3e
--- /dev/null
+++ b/api/js/etemplate/Et2Select/Select/Et2SelectLang.ts
@@ -0,0 +1,14 @@
+import {Et2Select} from "../Et2Select";
+import {Et2StaticSelectMixin, StaticOptions} from "../StaticOptions";
+
+export class Et2SelectLang extends Et2StaticSelectMixin(Et2Select)
+{
+ constructor()
+ {
+ super();
+
+ this._static_options = StaticOptions.lang(this, {other: this.other || []});
+ }
+}
+
+customElements.define("et2-select-lang", Et2SelectLang);
\ No newline at end of file
diff --git a/api/js/etemplate/Et2Select/Select/Et2SelectMonth.ts b/api/js/etemplate/Et2Select/Select/Et2SelectMonth.ts
new file mode 100644
index 0000000000..72d9a8577a
--- /dev/null
+++ b/api/js/etemplate/Et2Select/Select/Et2SelectMonth.ts
@@ -0,0 +1,14 @@
+import {Et2Select} from "../Et2Select";
+import {Et2StaticSelectMixin, StaticOptions} from "../StaticOptions";
+
+export class Et2SelectMonth extends Et2StaticSelectMixin(Et2Select)
+{
+ constructor()
+ {
+ super();
+
+ this._static_options = StaticOptions.month(this);
+ }
+}
+
+customElements.define("et2-select-month", Et2SelectMonth);
\ No newline at end of file
diff --git a/api/js/etemplate/Et2Select/Select/Et2SelectNumber.ts b/api/js/etemplate/Et2Select/Select/Et2SelectNumber.ts
new file mode 100644
index 0000000000..960dff879b
--- /dev/null
+++ b/api/js/etemplate/Et2Select/Select/Et2SelectNumber.ts
@@ -0,0 +1,52 @@
+import {Et2Select} from "../Et2Select";
+import {Et2StaticSelectMixin, StaticOptions} from "../StaticOptions";
+import {PropertyValues} from 'lit';
+
+export class Et2SelectNumber extends Et2StaticSelectMixin(Et2Select)
+{
+ static get properties()
+ {
+ return {
+ ...super.properties,
+ /**
+ * Step between numbers
+ */
+ interval: {type: Number},
+ min: {type: Number},
+ max: {type: Number},
+
+ /**
+ * Add one or more leading zeros
+ * Set to how many zeros you want (000)
+ */
+ leading_zero: {type: String},
+ /**
+ * Appended after every number
+ */
+ suffix: {type: String}
+ }
+ }
+
+ constructor()
+ {
+ super();
+ this.min = 1;
+ this.max = 10;
+ this.interval = 1;
+ this.leading_zero = "";
+ this.suffix = "";
+ }
+
+ updated(changedProperties : PropertyValues)
+ {
+ super.updated(changedProperties);
+
+ if(changedProperties.has('min') || changedProperties.has('max') || changedProperties.has('interval') || changedProperties.has('suffix'))
+ {
+ this._static_options = StaticOptions.number(this);
+ this.requestUpdate("select_options");
+ }
+ }
+}
+
+customElements.define("et2-select-number", Et2SelectNumber);
\ No newline at end of file
diff --git a/api/js/etemplate/Et2Select/Select/Et2SelectPercent.ts b/api/js/etemplate/Et2Select/Select/Et2SelectPercent.ts
new file mode 100644
index 0000000000..491fcb58bc
--- /dev/null
+++ b/api/js/etemplate/Et2Select/Select/Et2SelectPercent.ts
@@ -0,0 +1,15 @@
+import {Et2SelectNumber} from "./Et2SelectNumber";
+
+export class Et2SelectPercent extends Et2SelectNumber
+{
+ constructor()
+ {
+ super();
+ this.min = 0;
+ this.max = 100;
+ this.interval = 10;
+ this.suffix = "%%";
+ }
+}
+
+customElements.define("et2-select-percent", Et2SelectPercent);
\ No newline at end of file
diff --git a/api/js/etemplate/Et2Select/Select/Et2SelectPriority.ts b/api/js/etemplate/Et2Select/Select/Et2SelectPriority.ts
new file mode 100644
index 0000000000..265477369f
--- /dev/null
+++ b/api/js/etemplate/Et2Select/Select/Et2SelectPriority.ts
@@ -0,0 +1,14 @@
+import {Et2Select} from "../Et2Select";
+import {Et2StaticSelectMixin, StaticOptions} from "../StaticOptions";
+
+export class Et2SelectPriority extends Et2StaticSelectMixin(Et2Select)
+{
+ constructor()
+ {
+ super();
+
+ this._static_options = StaticOptions.priority(this);
+ }
+}
+
+customElements.define("et2-select-priority", Et2SelectPriority);
\ No newline at end of file
diff --git a/api/js/etemplate/Et2Select/Et2SelectReadonly.ts b/api/js/etemplate/Et2Select/Select/Et2SelectReadonly.ts
similarity index 92%
rename from api/js/etemplate/Et2Select/Et2SelectReadonly.ts
rename to api/js/etemplate/Et2Select/Select/Et2SelectReadonly.ts
index fa5053e954..3c49b1b3b4 100644
--- a/api/js/etemplate/Et2Select/Et2SelectReadonly.ts
+++ b/api/js/etemplate/Et2Select/Select/Et2SelectReadonly.ts
@@ -8,12 +8,13 @@
*/
-import {css, html, LitElement, repeat, TemplateResult} from "@lion/core";
-import {et2_IDetachedDOM} from "../et2_core_interfaces";
-import {Et2Widget} from "../Et2Widget/Et2Widget";
-import {Et2StaticSelectMixin, StaticOptions, StaticOptions as so} from "./StaticOptions";
-import {cleanSelectOptions, find_select_options, SelectOption} from "./FindSelectOptions";
-import {SelectAccountMixin} from "./SelectAccountMixin";
+import {css, html, LitElement, TemplateResult} from "lit";
+import {repeat} from "lit/directives/repeat.js";
+import {et2_IDetachedDOM} from "../../et2_core_interfaces";
+import {Et2Widget} from "../../Et2Widget/Et2Widget";
+import {Et2StaticSelectMixin, StaticOptions, StaticOptions as so} from "../StaticOptions";
+import {cleanSelectOptions, find_select_options, SelectOption} from "../FindSelectOptions";
+import {SelectAccountMixin} from "../SelectAccountMixin";
/**
* This is a stripped-down read-only widget used in nextmatch
@@ -143,6 +144,11 @@ li {
return this.value;
}
+ getValueAsArray()
+ {
+ return (Array.isArray(this.value) ? this.value : [this.value]);
+ }
+
set value(new_value : string | string[])
{
// Split anything that is still a CSV
@@ -206,10 +212,11 @@ li {
render()
{
+ const value = this.getValueAsArray();
return html`
${repeat(
- (Array.isArray(this.value) ? this.value : [this.value]),
+ this.getValueAsArray(),
(val : string) => val, (val) =>
{
let option = (this.select_options).find(option => option.value == val);
@@ -282,14 +289,16 @@ customElements.define("et2-select-app_ro", Et2SelectAppReadonly);
export class Et2SelectBitwiseReadonly extends Et2SelectReadonly
{
+ /* Currently handled server side, we get an array
render()
{
let new_value = [];
+ let int_value = parseInt(this.value);
for(let index in this.select_options)
{
let option = this.select_options[index];
let right = parseInt(option && option.value ? option.value : index);
- if(!!(this.value & right))
+ if(!!(int_value & right))
{
new_value.push(right);
}
@@ -307,6 +316,8 @@ export class Et2SelectBitwiseReadonly extends Et2SelectReadonly
})}
`;
}
+
+ */
}
// @ts-ignore TypeScript is not recognizing that this widget is a LitElement
@@ -349,7 +360,6 @@ export class Et2SelectPercentReadonly extends Et2SelectReadonly
constructor()
{
super(...arguments);
- this.suffix = "%%";
this.select_options = so.percent(this);
}
}
@@ -391,6 +401,23 @@ export class Et2SelectDayOfWeekReadonly extends Et2StaticSelectMixin(Et2SelectRe
this.set_static_options(cleanSelectOptions(options));
});
}
+
+ getValueAsArray()
+ {
+ let expanded_value = [];
+ let int_value = parseInt(this.value);
+ let options = this.select_options;
+ for(let index in options)
+ {
+ let right = parseInt(options[index].value);
+
+ if((int_value & right) == right)
+ {
+ expanded_value.push("" + right);
+ }
+ }
+ return expanded_value;
+ }
}
// @ts-ignore TypeScript is not recognizing that this widget is a LitElement
@@ -422,7 +449,7 @@ export class Et2SelectNumberReadonly extends Et2StaticSelectMixin(Et2SelectReado
{
protected find_select_options(_attrs)
{
- this.static_options = so.number(this, _attrs);
+ this._static_options = so.number(this, _attrs);
}
}
diff --git a/api/js/etemplate/Et2Select/Select/Et2SelectState.ts b/api/js/etemplate/Et2Select/Select/Et2SelectState.ts
new file mode 100644
index 0000000000..9c1ac5336b
--- /dev/null
+++ b/api/js/etemplate/Et2Select/Select/Et2SelectState.ts
@@ -0,0 +1,45 @@
+import {Et2Select} from "../Et2Select";
+import {Et2StaticSelectMixin, StaticOptions} from "../StaticOptions";
+import {SelectOption} from "../FindSelectOptions";
+
+export class Et2SelectState extends Et2StaticSelectMixin(Et2Select)
+{
+ /**
+ * Two-letter ISO country code
+ */
+ protected __countryCode;
+
+ static get properties()
+ {
+ return {
+ ...super.properties,
+ countryCode: String,
+ }
+ }
+
+ constructor()
+ {
+ super();
+
+ this.countryCode = 'DE';
+ }
+
+ get countryCode()
+ {
+ return this.__countryCode;
+ }
+
+ set countryCode(code : string)
+ {
+ this.__countryCode = code;
+ this._static_options = StaticOptions.state(this, {country_code: code});
+ this.requestUpdate("select_options");
+ }
+
+ set_country_code(code)
+ {
+ this.countryCode = code;
+ }
+}
+
+customElements.define("et2-select-state", Et2SelectState);
\ No newline at end of file
diff --git a/api/js/etemplate/Et2Select/Select/Et2SelectTab.ts b/api/js/etemplate/Et2Select/Select/Et2SelectTab.ts
new file mode 100644
index 0000000000..ec39624827
--- /dev/null
+++ b/api/js/etemplate/Et2Select/Select/Et2SelectTab.ts
@@ -0,0 +1,61 @@
+import {Et2SelectApp} from "./Et2SelectApp";
+import {SelectOption} from "../FindSelectOptions";
+
+export class Et2SelectTab extends Et2SelectApp
+{
+ constructor()
+ {
+ super();
+
+ this.allowFreeEntries = true;
+ }
+
+ set value(new_value)
+ {
+ if(!new_value)
+ {
+ super.value = new_value;
+ return;
+ }
+ const values = Array.isArray(new_value) ? new_value : [new_value];
+ const options = this.select_options;
+ values.forEach(value =>
+ {
+ if(!options.filter(option => option.value == value).length)
+ {
+ const matches = value.match(/^([a-z0-9]+)\-/i);
+ let option : SelectOption = {value: value, label: value};
+ if(matches)
+ {
+ option = options.filter(option => option.value == matches[1])[0] || {
+ value: value,
+ label: this.egw().lang(matches[1])
+ };
+ option.value = value;
+ option.label += ' ' + this.egw().lang('Tab');
+ }
+ try
+ {
+ const app = opener?.framework.getApplicationByName(value);
+ if(app && app.displayName)
+ {
+ option.label = app.displayName;
+ }
+ }
+ catch(e)
+ {
+ // ignore security exception, if opener is not accessible
+ }
+ this.select_options.concat(option);
+ }
+ })
+ super.value = new_value;
+ }
+
+ get value()
+ {
+ return super.value;
+ }
+}
+
+customElements.define("et2-select-tab", Et2SelectTab);
\ No newline at end of file
diff --git a/api/js/etemplate/Et2Select/Et2SelectThumbnail.ts b/api/js/etemplate/Et2Select/Select/Et2SelectThumbnail.ts
similarity index 95%
rename from api/js/etemplate/Et2Select/Et2SelectThumbnail.ts
rename to api/js/etemplate/Et2Select/Select/Et2SelectThumbnail.ts
index a651703903..1313af2891 100644
--- a/api/js/etemplate/Et2Select/Et2SelectThumbnail.ts
+++ b/api/js/etemplate/Et2Select/Select/Et2SelectThumbnail.ts
@@ -7,9 +7,9 @@
* @author Nathan Gray
*/
-import {Et2Select} from "./Et2Select";
-import {css} from "@lion/core";
-import {SelectOption} from "./FindSelectOptions";
+import {Et2Select} from "../Et2Select";
+import {css} from "lit";
+import {SelectOption} from "../FindSelectOptions";
export class Et2SelectThumbnail extends Et2Select
{
diff --git a/api/js/etemplate/Et2Select/Select/Et2SelectTimezone.ts b/api/js/etemplate/Et2Select/Select/Et2SelectTimezone.ts
new file mode 100644
index 0000000000..ff8dd669a1
--- /dev/null
+++ b/api/js/etemplate/Et2Select/Select/Et2SelectTimezone.ts
@@ -0,0 +1,14 @@
+import {Et2Select} from "../Et2Select";
+import {Et2StaticSelectMixin, StaticOptions} from "../StaticOptions";
+
+export class Et2SelectTimezone extends Et2StaticSelectMixin(Et2Select)
+{
+ constructor()
+ {
+ super();
+
+ this._static_options = StaticOptions.timezone(this, {other: this.other || []});
+ }
+}
+
+customElements.define("et2-select-timezone", Et2SelectTimezone);
\ No newline at end of file
diff --git a/api/js/etemplate/Et2Select/Select/Et2SelectYear.ts b/api/js/etemplate/Et2Select/Select/Et2SelectYear.ts
new file mode 100644
index 0000000000..db231aa2fc
--- /dev/null
+++ b/api/js/etemplate/Et2Select/Select/Et2SelectYear.ts
@@ -0,0 +1,25 @@
+import {Et2SelectNumber} from "./Et2SelectNumber";
+import {PropertyValues} from "lit";
+import {StaticOptions} from "../StaticOptions";
+
+export class Et2SelectYear extends Et2SelectNumber
+{
+ constructor()
+ {
+ super();
+ this.min = -3;
+ this.max = 2;
+ }
+
+ updated(changedProperties : PropertyValues)
+ {
+ super.updated(changedProperties);
+
+ if(changedProperties.has('min') || changedProperties.has('max') || changedProperties.has('interval') || changedProperties.has('suffix'))
+ {
+ this._static_options = StaticOptions.year(this);
+ }
+ }
+}
+
+customElements.define("et2-select-year", Et2SelectYear);
\ No newline at end of file
diff --git a/api/js/etemplate/Et2Select/SelectAccountMixin.ts b/api/js/etemplate/Et2Select/SelectAccountMixin.ts
index 429ad91672..1c35111b59 100644
--- a/api/js/etemplate/Et2Select/SelectAccountMixin.ts
+++ b/api/js/etemplate/Et2Select/SelectAccountMixin.ts
@@ -1,5 +1,5 @@
import {SelectOption} from "./FindSelectOptions";
-import {LitElement} from "@lion/core";
+import {LitElement} from "lit";
/**
* EGroupware eTemplate2 - SelectAccountMixin
diff --git a/api/js/etemplate/Et2Select/SelectTypes.ts b/api/js/etemplate/Et2Select/SelectTypes.ts
new file mode 100644
index 0000000000..ab23cdb7f8
--- /dev/null
+++ b/api/js/etemplate/Et2Select/SelectTypes.ts
@@ -0,0 +1,25 @@
+/**
+ * Import all our sub-types
+ */
+
+import './Select/Et2SelectAccount';
+import './Select/Et2SelectApp';
+import './Select/Et2SelectBitwise';
+import './Select/Et2SelectBool';
+import './Select/Et2SelectCategory';
+import './Select/Et2SelectCountry';
+import './Select/Et2SelectDay';
+import './Select/Et2SelectDayOfWeek';
+import './Select/Et2SelectEmail';
+import './Select/Et2SelectHour';
+import './Select/Et2SelectLang';
+import './Select/Et2SelectMonth';
+import './Select/Et2SelectNumber';
+import './Select/Et2SelectPercent';
+import './Select/Et2SelectPriority';
+import './Select/Et2SelectReadonly';
+import './Select/Et2SelectState';
+import './Select/Et2SelectTab';
+import './Select/Et2SelectThumbnail';
+import './Select/Et2SelectTimezone';
+import './Select/Et2SelectYear';
\ No newline at end of file
diff --git a/api/js/etemplate/Et2Select/StaticOptions.js b/api/js/etemplate/Et2Select/StaticOptions.js
new file mode 100644
index 0000000000..fa371e0a0e
--- /dev/null
+++ b/api/js/etemplate/Et2Select/StaticOptions.js
@@ -0,0 +1,285 @@
+/**
+ * Some static options, no need to transfer them over and over.
+ * We still need the same thing on the server side to validate, so they
+ * have to match. See Etemplate\Widget\Select::typeOptions()
+ * The type specific legacy options wind up in attrs.other, but should be explicitly
+ * defined and set.
+ *
+ * @param {type} widget
+ */
+import { sprintf } from "../../egw_action/egw_action_common";
+import { cleanSelectOptions, find_select_options } from "./FindSelectOptions";
+/**
+ * Base class for things that have static options
+ *
+ * We keep static options separate and concatenate them in to allow for extra options without
+ * overwriting them when we get static options from the server
+ */
+export const Et2StaticSelectMixin = (superclass) => {
+ class Et2StaticSelectOptions extends (superclass) {
+ constructor(...args) {
+ super(...args);
+ this.static_options = [];
+ this.fetchComplete = Promise.resolve();
+ // Trigger the options to get rendered into the DOM
+ this.requestUpdate("select_options");
+ }
+ get select_options() {
+ // @ts-ignore
+ const options = super.select_options || [];
+ // make sure result is unique
+ return [...new Map([...(this.static_options || []), ...options].map(item => [item.value, item])).values()];
+ }
+ set select_options(new_options) {
+ // @ts-ignore IDE doesn't recognise property
+ super.select_options = new_options;
+ }
+ set_static_options(new_static_options) {
+ this.static_options = new_static_options;
+ this.requestUpdate("select_options");
+ }
+ /**
+ * Override the parent fix_bad_value() to wait for server-side options
+ * to come back before we check to see if the value is not there.
+ */
+ fix_bad_value() {
+ this.fetchComplete.then(() => {
+ // @ts-ignore Doesn't know it's an Et2Select
+ if (typeof super.fix_bad_value == "function") {
+ // @ts-ignore Doesn't know it's an Et2Select
+ super.fix_bad_value();
+ }
+ });
+ }
+ }
+ return Et2StaticSelectOptions;
+};
+/**
+ * Some options change, or are too complicated to have twice, so we get the
+ * options from the server once, then keep them to use if they're needed again.
+ * We use the options string to keep the different possibilities (eg. categories
+ * for different apps) separate.
+ *
+ * @param {et2_selectbox} widget Selectbox we're looking at
+ * @param {string} options_string
+ * @param {Object} attrs Widget attributes (not yet fully set)
+ * @param {boolean} return_promise true: always return a promise
+ * @returns {Object[]|Promise