/** * EGroupware eTemplate2 - Select WebComponent * * @license http://opensource.org/licenses/gpl-license.php GPL - GNU General Public License * @package api * @link https://www.egroupware.org * @author Nathan Gray */ import {css, LitElement, nothing, PropertyValues, TemplateResult} from "lit"; import {html, literal, StaticValue} from "lit/static-html.js"; import {Et2WidgetWithSelectMixin} from "./Et2WidgetWithSelectMixin"; import {SelectOption} from "./FindSelectOptions"; import shoelace from "../Styles/shoelace"; import {RowLimitedMixin} from "../Layout/RowLimitedMixin"; import {Et2Tag} from "./Tag/Et2Tag"; import {Et2WithSearchMixin} from "./SearchMixin"; import {property} from "lit/decorators/property.js"; import {SlChangeEvent, SlSelect} from "@shoelace-style/shoelace"; import {repeat} from "lit/directives/repeat.js"; // export Et2WidgetWithSelect which is used as type in other modules export class Et2WidgetWithSelect extends RowLimitedMixin(Et2WidgetWithSelectMixin(LitElement)) { // Gets an array of all elements protected getAllOptions() { // @ts-ignore return [...this.querySelectorAll('sl-option')]; } }; /** * Select widget * * At its most basic, you can select one option from a list provided. The list can be passed from the server in * the sel_options array or options can be added as children in the template. Some extending classes provide specific * options, such as Et2SelectPercent or Et2SelectCountry. All provided options will be mixed together and used. * * To allow selecting more than one option, use the attribute multiple="true". This will take & return an array * as value instead of just a string. * * SearchMixin adds additional abilities to ALL select boxes * @see Et2WithSearchMixin * * Override for extending widgets: * # Custom display of selected value * When selecting a single value (!multiple) you can override doLabelChange() to customise the displayed label * @see Et2SelectCategory, which adds in the category icon * * # Custom option rows * Options can have 'class' and 'icon' properties that will be used for the option * The easiest way for further customisation to use CSS in an external file (like etemplate2.css) and ::part(). * @see Et2SelectCountry which displays flags via CSS instead of using SelectOption.icon * * # Custom tags * When multiple is set, instead of a single value each selected value is shown in a tag. While it's possible to * use CSS to some degree, we can also use a custom tag class that extends Et2Tag. * 1. Create the extending class * 2. Make sure it's loaded (add to etemplate2.ts) * 3. In your extending Et2Select, override get tagTag() to return the custom tag name * */ // @ts-ignore SlSelect styles is a single CSSResult, not an array, so TS complains export class Et2Select extends Et2WithSearchMixin(Et2WidgetWithSelect) { private _block_change_event : boolean = false; static get styles() { return [ // Parent (SlSelect) returns a single cssResult, not an array shoelace, super.styles, css` :host { display: block; flex: 1 0 auto; --icon-width: 20px; } ::slotted(img), img { vertical-align: middle; } /* Get rid of padding before/after options */ sl-menu::part(base) { padding: 0px; } /* No horizontal scrollbar, even if options are long */ .dropdown__panel { overflow-x: clip; } /* Ellipsis when too small */ .select__tags { max-width: 100%; } .select__label { display: block; text-overflow: ellipsis; /* This is usually not used due to flex, but is the basis for ellipsis calculation */ width: 10ex; } /** multiple=true uses tags for each value **/ /* styling for icon inside tag (not option) */ .tag_image { margin-right: var(--sl-spacing-x-small); } /* Maximum height + scrollbar on tags (+ other styling) */ .select__tags { margin-left: 0px; max-height: initial; overflow-y: auto; gap: 0.1rem 0.5rem; } .select--medium .select__tags { padding-top: 2px; padding-bottom: 2px; } :host([rows]) .select__control > .select__label > .select__tags { max-height: calc(var(--rows, 5) * 29px); } :host([rows='1']) .select__tags { overflow: hidden; } /* Keep overflow tag right-aligned. It's the only sl-tag. */ .select__tags sl-tag { margin-left: auto; } select:hover { box-shadow: 1px 1px 1px rgb(0 0 0 / 60%); } /* Hide dropdown trigger when multiple & readonly */ :host([readonly][multiple])::part(expand-icon) { display: none; } /* Style for tag count if rows=1 */ :host([readonly][multiple][rows])::part(tags) { position: absolute; right: 0px; top: 1px; box-shadow: rgb(0 0 0/50%) -1.5ex 0px 1ex -1ex, rgb(0 0 0 / 0%) 0px 0px 0px 0px; } :host([readonly][multiple][rows]) .select__tags sl-tag::part(base) { background-color: var(--sl-input-background-color); border-top-left-radius: 0; border-bottom-left-radius: 0; font-weight: bold; min-width: 3em; justify-content: center; } /* Show all rows on hover if rows=1 */ :host([readonly][multiple][rows]):hover .select__tags { width: -webkit-fill-available; width: -moz-fill-available; width: fill-available; } ::part(listbox) { z-index: 1; background: var(--sl-input-background-color); padding: var(--sl-input-spacing-small); padding-left: 2px; box-shadow: var(--sl-shadow-large); min-width: fit-content; border-radius: var(--sl-border-radius-small); border: 1px solid var(--sl-color-neutral-200); max-height: 15em; overflow-y: auto; } ::part(display-label) { margin: 0; } :host::part(display-label) { max-height: 8em; overflow-y: auto; } ` ]; } static get properties() { return { ...super.properties, /** * Toggle between single and multiple selection */ multiple: { type: Boolean, reflect: true, }, /** * Click handler for individual tags instead of the select as a whole. * Only used if multiple=true so we have tags */ onTagClick: { type: Function, } } } /** Placeholder text to show as a hint when the select is empty. */ @property() placeholder = ''; /** Allows more than one option to be selected. */ @property({type: Boolean, reflect: true}) multiple = false; /** Disables the select control. */ @property({type: Boolean, reflect: true}) disabled = false; /** Adds a clear button when the select is not empty. */ @property({type: Boolean}) clearable = false; /** The select's label. If you need to display HTML, use the `label` slot instead. */ @property() label = ''; /** * The preferred placement of the select's menu. Note that the actual placement may vary as needed to keep the listbox * inside of the viewport. */ @property({reflect: true}) placement : 'top' | 'bottom' = 'bottom'; /** The select's help text. If you need to display HTML, use the `help-text` slot instead. */ @property({attribute: 'help-text'}) helpText = ''; /** The select's required attribute. */ @property({type: Boolean, reflect: true}) required = false; private __value : string | string[] = ""; constructor() { super(); this.hoist = true; this._tagTemplate = this._tagTemplate.bind(this); } /** * List of properties that get translated * * @returns object */ static get translate() { return { ...super.translate, emptyLabel: true } } connectedCallback() { super.connectedCallback(); this.updateComplete.then(() => { this.addEventListener("sl-change", this._triggerChange); // Fixes missing empty label this.select?.requestUpdate("value"); // Fixes incorrect opening position this.select?.popup.handleAnchorChange(); }); } disconnectedCallback() { super.disconnectedCallback(); this.removeEventListener("sl-change", this._triggerChange); } _triggerChange(e) { if(super._triggerChange(e) && !this._block_change_event) { this.dispatchEvent(new Event("change", {bubbles: true})); } if(this._block_change_event) { this.updateComplete.then(() => this._block_change_event = false); } } /** * Handle the case where there is no value set, or the value provided is not an option. * If this happens, we choose the first option or empty label. * * Careful when this is called. We change the value here, so an infinite loop is possible if the widget has * onchange. * */ protected fix_bad_value() { // Stop if there are no options if(!Array.isArray(this.select_options) || this.select_options.length == 0) { // Nothing to do here return; } // emptyLabel is fine if(this.value === "" && this.emptyLabel) { return; } let valueArray = this.getValueAsArray(); // Check for value using missing options (deleted or otherwise not allowed) let filtered = this.filterOutMissingOptions(valueArray); if(filtered.length != valueArray.length) { this.value = filtered; return; } // Multiple is allowed to be empty, and if we don't have an emptyLabel or options nothing to do if(this.multiple || (!this.emptyLabel && this.select_options.length === 0)) { return; } // See if parent (search / free entry) is OK with it if(super.fix_bad_value()) { return; } // If somebody gave '' as a select_option, let it be if(this.value === '' && this.select_options.filter((option) => this.value === option.value).length == 1) { return; } // If no value is set, choose the first option // Only do this on once during initial setup, or it can be impossible to clear the value // value not in options --> use emptyLabel, if exists, or first option otherwise if(this.select_options.filter((option) => valueArray.find(val => val == option.value) || Array.isArray(option.value) && option.value.filter(o => valueArray.find(val => val == o.value))).length === 0) { let oldValue = this.value; this.value = this.emptyLabel ? "" : "" + this.select_options[0]?.value; this._block_change_event = (oldValue != this.value); // ""+ to cast value of 0 to "0", to not replace with "" this.requestUpdate("value", oldValue); } } @property() get value() { return this.multiple ? this.__value ?? [] : this.__value ?? ""; } // @ts-ignore set value(val : string | string[] | number | number[]) { if(typeof val === "undefined" || val == null) { val = ""; } if(typeof val === 'string' && val.indexOf(',') !== -1 && this.multiple) { val = val.split(','); } if(typeof val === 'number') { val = val.toString(); } const oldValue = this.value; if(Array.isArray(val)) { // Make sure value has no duplicates, and values are strings this.__value = [...new Set(val.map(v => (typeof v === 'number' ? v.toString() : v || '')))]; } else { this.__value = val; } if(this.multiple && typeof this.__value == "string") { this.__value = this.__value != "" ? [this.__value] : []; } if(this.select) { this.select.value = this.__value; } this.requestUpdate("value", oldValue); } /** * Check a value for missing options and remove them. * * We'll warn about it in the helpText, and if they save the change will be made. * This is to avoid the server-side validation error, which the user can't do much about. * * @param {string[]} value * @returns {string[]} */ filterOutMissingOptions(value : string[]) : string[] { if(!this.readonly && value && value.length > 0 && !this.allowFreeEntries && this.select_options.length > 0) { function filterBySelectOptions(arrayToFilter, options : SelectOption[]) { const filteredArray = arrayToFilter.filter(item => { // Check if item is found in options return !options.some(option => { if(typeof option.value === 'string') { // Regular option return option.value === item; } else if(Array.isArray(option.value)) { // Recursively check if item is found in nested array (option groups) return filterBySelectOptions([item], option.value).length > 0; } return false; }); }); return filteredArray; } // Empty is allowed, if there's an emptyLabel if(value.toString() == "" && this.emptyLabel) { return value; } const missing = filterBySelectOptions(value, this.select_options); if(missing.length > 0) { debugger; console.warn("Invalid option '" + missing.join(", ") + "' removed"); value = value.filter(item => missing.indexOf(item) == -1); } } return value; } /** * Add an option for the "empty label" option, used if there's no value * * @returns {TemplateResult} */ loadFromXML(_node : Element) { super.loadFromXML(_node); // Wait for update to be complete before we check for bad value so extending selects can have a chance this.updateComplete.then(() => this.fix_bad_value()); } /** @param {import('@lion/core').PropertyValues } changedProperties */ willUpdate(changedProperties : PropertyValues) { super.willUpdate(changedProperties); if(changedProperties.has("multiple")) { this.value = this.__value; } if(changedProperties.has("select_options") || changedProperties.has("value") || changedProperties.has("emptyLabel")) { this.updateComplete.then(() => this.fix_bad_value()); } if(changedProperties.has("select_options") && changedProperties.has("value")) { } } /** * Override this method from SlSelect to stick our own tags in there * syncItemsFromValue() { if(typeof super.syncItemsFromValue === "function") { super.syncItemsFromValue(); } // Only applies to multiple if(typeof this.displayTags !== "object" || !this.multiple) { return; } let overflow = null; if(this.maxOptionsVisible > 0 && this.displayTags.length > this.maxOptionsVisible) { overflow = this.displayTags.pop(); } const checkedItems = Object.values(this._menuItems).filter(item => this.value.includes(item.value)); this.displayTags = checkedItems.map(item => this._createTagNode(item)); if(checkedItems.length !== this.value.length && this.multiple) { // There's a value that does not have a menu item, probably invalid. // Add it as a marked tag so it can be corrected or removed. const filteredValues = this.value.filter(str => !checkedItems.some(obj => obj.value === str)); for(let i = 0; i < filteredValues.length; i++) { const badTag = this._createTagNode({ value: filteredValues[i], getTextLabel: () => filteredValues[i], classList: {value: ""} }); badTag.variant = "danger"; badTag.contactPlus = false; // Put it in front so it shows this.displayTags.unshift(badTag); } } // Re-slice & add overflow tag if(overflow) { this.displayTags = this.displayTags.slice(0, this.maxOptionsVisible); this.displayTags.push(overflow); } else if(this.multiple && this.rows == 1 && this.readonly && this.value.length > 1) { // Maybe more tags than we can show, show the count this.displayTags.push(html` ${this.value.length} `); } } */ _emptyLabelTemplate() : TemplateResult { if(!this.emptyLabel || this.multiple) { return html``; } return html` v == "")} > ${this.emptyLabel} `; } protected _optionsTemplate() : TemplateResult { return html`${repeat(this.select_options // Filter out empty values if we have empty label to avoid duplicates .filter(o => this.emptyLabel ? o.value !== '' : o), this._groupTemplate.bind(this)) }`; } /** * Used to render each option into the select * * @param {SelectOption} option * @returns {TemplateResult} */ protected _optionTemplate(option : SelectOption) : TemplateResult { // Exclude non-matches when searching if(typeof option.isMatch == "boolean" && !option.isMatch) { return html``; } // Tag used must match this.optionTag, but you can't use the variable directly. // Pass option along so SearchMixin can grab it if needed const value = (option.value).replaceAll(" ", "___"); return html` v == value)} ?disabled=${option.disabled} > ${this._iconTemplate(option)} ${this.noLang ? option.label : this.egw().lang(option.label)} `; } /** * Tag used for rendering tags when multiple=true * Used for creating, finding & filtering options. * @see createTagNode() * @returns {string} */ public get tagTag() : StaticValue { return literal`et2-tag`; } /** * Custom tag * @param {Et2Option} option * @param {number} index * @returns {TemplateResult} * @protected */ protected _tagTemplate(option : Et2Option, index : number) : TemplateResult { const readonly = (this.readonly || option && typeof (option.disabled) != "undefined" && option.disabled); const isEditable = this.editModeEnabled && !readonly; const image = this._createImage(option); const tagName = this.tagTag; return html` <${tagName} part="tag" exportparts=" base:tag__base, content:tag__content, remove-button:tag__remove-button, remove-button__base:tag__remove-button__base, icon:icon " class=${"search_tag " + option.classList.value} ?pill=${this.pill} size=${this.size} ?removable=${!readonly} ?readonly=${readonly} ?editable=${isEditable} .value=${option.value.replaceAll("___", " ")} @dblclick=${this._handleDoubleClick} @click=${typeof this.onTagClick == "function" ? (e) => this.onTagClick(e, e.target) : nothing} > ${image ?? nothing} ${option.getTextLabel().trim()} `; } /** * Additional customisation template * @returns {*} * @protected */ protected _extraTemplate() { return typeof super._extraTemplate == "function" ? super._extraTemplate() : nothing; } /** * Customise how tags are rendered. This overrides what SlSelect * does in syncItemsFromValue(). * This is a copy+paste from SlSelect.syncItemsFromValue(). * * @param item * @protected */ protected _createTagNode(item) { console.warn("Deprecated"); debugger; let tag; if(typeof super._createTagNode == "function") { tag = super._createTagNode(item); } else { tag = document.createElement(this.tagTag); } tag.value = item.value; tag.textContent = item?.getTextLabel()?.trim(); tag.class = item.classList.value + " search_tag"; tag.setAttribute("exportparts", "icon"); if(this.size) { tag.size = this.size; } if(this.readonly || item.option && typeof (item.option.disabled) != "undefined" && item.option.disabled) { tag.removable = false; tag.readonly = true; } else { tag.addEventListener("dblclick", this._handleDoubleClick); tag.addEventListener("click", this.handleTagInteraction); tag.addEventListener("keydown", this.handleTagInteraction); tag.addEventListener("sl-remove", (event : CustomEvent) => this.handleTagRemove(event, item)); } // Allow click handler even if read only if(typeof this.onTagClick == "function") { tag.addEventListener("click", (e) => this.onTagClick(e, e.target)); } let image = this._createImage(item); if(image) { tag.prepend(image); } return tag; } blur() { if(typeof super.blur == "function") { super.blur(); } this.hide(); } /* Parent should be fine now? private handleTagRemove(event : CustomEvent, option) { event.stopPropagation(); if(!this.disabled) { option.selected = false; let index = this.value.indexOf(option.value); if(index > -1) { this.value.splice(index, 1); } this.dispatchEvent(new CustomEvent('sl-input')); this.dispatchEvent(new CustomEvent('sl-change')); this.validate(); } } */ /** * Apply the user preference to close the dropdown if an option is clicked, even if multiple=true. * The default (from SlSelect) leaves the dropdown open for multiple=true * * @param {MouseEvent} event * @private */ private handleOptionClick(event : MouseEvent) { super.handleOptionClick(event); if(this._close_on_select) { this.hide(); } } private et2HandleBlur(event : Event) { if(typeof super.et2HandleBlur === "function") { super.et2HandleBlur(event); } this.dropdown?.hide(); } protected handleValueChange(e : SlChangeEvent) { const old_value = this.__value; this.__value = this.select.value; this.requestUpdate("value", old_value); } /** * Always close the dropdown if an option is clicked, even if multiple=true. This differs from SlSelect, * which leaves the dropdown open for multiple=true * * @param {KeyboardEvent} event * @private */ private handleKeyDown(event : KeyboardEvent) { if(event.key === 'Enter' || (event.key === ' ' && this.typeToSelectString === '')) { this.dropdown.hide().then(() => { if(typeof this.handleMenuHide == "function") { // Make sure search gets hidden this.handleMenuHide(); } }); event.stopPropagation(); } } /** * Get the icon for the select option * * @param option * @protected */ protected _iconTemplate(option) { if(!option.icon) { return html``; } return html` ` } protected _createImage(item) { let image = item?.querySelector ? item.querySelector("et2-image") || item.querySelector("[slot='prefix']") : null; if(image) { image = image.clone(); image.slot = "prefix"; image.class = "tag_image"; return image; } return ""; } /** Shows the listbox. */ async show() { return this.select.show(); } /** Hides the listbox. */ async hide() { this.select.hide(); } get open() { return this.select?.open ?? false; } protected _renderOptions() {return Promise.resolve();} protected get select() : SlSelect { return this.shadowRoot?.querySelector("sl-select"); } public render() { const value = Array.isArray(this.value) ? this.value.map(v => { return v.replaceAll(" ", "___"); }) : (typeof this.value == "string" ? this.value.replaceAll(" ", "___") : ""); let icon : TemplateResult | typeof nothing = nothing; if(!this.multiple) { const icon_option = this.select_options.find(o => (o.value == value || Array.isArray(value) && value.includes(o.value)) && o.icon); if(icon_option) { icon = this._iconTemplate(icon_option); } } return html` ${icon} ${this._emptyLabelTemplate()} ${this._optionsTemplate()} ${this._extraTemplate()} `; } } if(typeof customElements.get("et2-select") === "undefined") { customElements.define("et2-select", Et2Select); } declare global { interface HTMLElementTagNameMap { "et2-select" : Et2Select; } }