egroupware_official/api/js/etemplate/Et2Select/Et2Select.ts
nathan db37c3b116 Improve some issues with Et2Select with multiple=false & allowFreeEntries=true
- Adding a free entry did not always update UI fully with new value
- it was impossible to edit a free entry, just replace it
2024-12-10 13:45:21 -07:00

1131 lines
32 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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 {Et2WithSearchMixin} from "./SearchMixin";
import {property} from "lit/decorators/property.js";
import {SlChangeEvent, SlOption, SlSelect} from "@shoelace-style/shoelace";
import {repeat} from "lit/directives/repeat.js";
import {classMap} from "lit/directives/class-map.js";
import {state} from "lit/decorators/state.js";
// export Et2WidgetWithSelect which is used as type in other modules
export class Et2WidgetWithSelect extends RowLimitedMixin(Et2WidgetWithSelectMixin(LitElement))
{
// Gets an array of all <sl-option> elements
protected getAllOptions()
{
// @ts-ignore
return [...this.querySelectorAll<Et2Option>('sl-option')];
}
};
/**
* @summary Select one or more options from a given list
* @since 23.1
*
* @dependency sl-select
* @dependency sl-option
*
* 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
*
* @slot - Reflected into listbox options. Must be <sl-option> elements. You can use <sl-divider> to group items visually. Normally you set the options by parameter.
* @slot prefix - Used to prepend a presentational icon or similar element to the combobox.
* @slot help-text - Text that describes how to use the input. Alternatively, you can use the `help-text` attribute.
*
* @event change - Emitted when the control's value changes.
* @event sl-clear - Emitted when the controls value is cleared.
* @event sl-input - Emitted when the control receives input.
* @event sl-focus - Emitted when the control gains focus.
* @event sl-blur - Emitted when the control loses focus.
* @event sl-show - Emitted when the suggestion menu opens.
* @event sl-after-show - Emitted after the suggestion menu opens and all animations are complete.
* @event sl-hide - Emitted when the suggestion menu closes.
* @event sl-after-hide - Emitted after the suggestion menu closes and all animations are complete.
*
* @csspart prefix - The container that wraps the prefix slot.
* @csspart tags - The container that houses option tags when multiselect is used.
* @csspart display-input - The element that displays the selected options label, an <input> element.
* @csspart expand-icon - The container that wraps the expand icon.
* @csspart combobox - The container the wraps the prefix, combobox, clear icon, and expand button.
* @csspart listbox - The listbox container where options are slotted.
* @csspart option - The options in the listbox container
* @csspart icon - Icon in the option
* @csspart emptyLabel - Wrapper around the label shown when there is no option selected
* @csspart tag__prefix - The container that wraps the option prefix
* @csspart tag__suffix - The container that wraps the option suffix
* @csspart tag__limit - Element that is shown when the number of selected options exceeds maxOptionsVisible
*/
// @ts-ignore SlSelect styles is a single CSSResult, not an array, so TS complains
export class Et2Select extends Et2WithSearchMixin(Et2WidgetWithSelect)
{
// Solves some issues with focus
static shadowRootOptions = {...LitElement.shadowRootOptions, delegatesFocus: true};
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;
}
.form-control--has-label::part(form-control-label) {
margin-right: var(--sl-spacing-medium);
}
::slotted(img), img {
vertical-align: middle;
}
/* No wrapping */
sl-option::part(base) {
white-space: nowrap;
}
/* No horizontal scrollbar, even if options are long */
.dropdown__panel {
overflow-x: clip;
}
/* Ellipsis when too small */
::part(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) */
::part(tags) {
overflow-y: auto;
margin-left: 0px;
max-height: initial;
min-height: auto;
gap: 0.1rem 0.5rem;
}
:host([rows]) ::part(tags) {
max-height: calc(var(--rows, 5) * (var(--sl-input-height-medium) * 0.8))
}
:host([readonly][rows='1']) ::part(tags) {
overflow: hidden;
}
/* No rows set, default height limit about 5 rows */
:host(:not([rows])) ::part(tags) {
max-height: 11em;
}
select:hover {
box-shadow: 1px 1px 1px rgb(0 0 0 / 60%);
}
/* Hide dropdown trigger when multiple & readonly */
:host([readonly][multiple]:not([rows='1']))::part(expand-icon) {
display: none;
}
/* Style for tag count if rows=1 */
.tag_limit {
position: absolute;
right: 0px;
top: 0px;
bottom: 0px;
box-shadow: rgb(0 0 0/50%) -1.5ex 0px 1ex -1ex, rgb(0 0 0 / 0%) 0px 0px 0px 0px;
}
.tag_limit::part(base) {
height: 100%;
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__popup {
width: -webkit-fill-available;
width: -moz-fill-available;
width: fill-available;
}
:host([readonly][multiple][rows]) .hover__popup::part(popup) {
z-index: var(--sl-z-index-dropdown);
background-color: var(--sl-color-neutral-0);
}
:host([ readonly ][ multiple ][ rows ]) .hover__popup .select__tags {
display: flex;
flex-wrap: wrap;
}
::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);
overflow-y: auto;
}
::part(display-label) {
margin: 0;
}
:host::part(display-label) {
max-height: 8em;
overflow-y: auto;
}
:host([readonly])::part(combobox) {
background: none;
opacity: 1;
border: none;
}
/* Position & style of group titles */
small {
padding: var(--sl-spacing-medium);
}
`
];
}
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 = '';
/** If the select is limited to 1 row, we show the number of tags not visible */
@state()
protected _tagsHidden = 0;
private __value : string | string[] = "";
protected tagOverflowObserver : IntersectionObserver = null;
protected get dropdown() { return this.select; }
constructor()
{
super();
this.hoist = true;
this._tagTemplate = this._tagTemplate.bind(this);
this._handleMouseEnter = this._handleMouseEnter.bind(this);
this._handleMouseLeave = this._handleMouseLeave.bind(this);
this._handleTagOverflow = this._handleTagOverflow.bind(this);
}
/**
* List of properties that get translated
*
* @returns object
*/
static get translate()
{
return {
...super.translate,
emptyLabel: true
}
}
connectedCallback()
{
super.connectedCallback();
this.addEventListener("focusin", this.handleFocus);
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();
// requestUpdate("value") above means we need to check tags again
this.select?.updateComplete.then(() => {this.checkTagOverflow(); });
});
}
disconnectedCallback()
{
super.disconnectedCallback();
this.removeEventListener("focusin", this.handleFocus);
this.removeEventListener("sl-change", this._triggerChange);
}
async getUpdateComplete()
{
const more = await super.getUpdateComplete();
await this.select.updateComplete;
return more;
}
_triggerChange(e)
{
if(super._triggerChange(e))
{
this.dispatchEvent(new Event("change", {bubbles: true}));
}
}
/**
* 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.value == []) && (this.emptyLabel || this.placeholder))
{
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;
// ""+ to cast value of 0 to "0", to not replace with ""
this.requestUpdate("value", oldValue);
}
}
@property()
get value()
{
// Handle a bunch of non-values, if it's multiple we want an array
if(this.multiple && (this.__value == "null" || this.__value == null || typeof this.__value == "undefined" ||
!this.emptyLabel && this.__value == "" && !this.select_options.find(o => o.value == "")))
{
return [];
}
if(!this.multiple && !this.emptyLabel && this.__value == "" && !this.select_options.find(o => o.value == ""))
{
return null;
}
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 = <string[]>[...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] : [];
}
else if(!this.multiple && Array.isArray(this.__value))
{
this.__value = this.__value.toString();
}
if(this.select)
{
this.select.value = this.shoelaceValue;
}
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)
{
console.warn("Invalid option '" + missing.join(", ") + "' removed from " + this.id, this);
value = value.filter(item => missing.indexOf(item) == -1);
}
}
return value;
}
/**
* Additional customisations from the XET node
*
* @param {Element} _node
*/
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 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();
});
}
}
/**
* After render, DOM nodes are there
*
* Check to see if tags overflow, set the counter flag
*
* @param {PropertyValues} changedProperties
*/
updated(changedProperties : PropertyValues)
{
super.updated(changedProperties);
this.checkTagOverflow();
}
protected checkTagOverflow()
{
// Create / destroy intersection observer
if(this.readonly && this.rows == "1" && this.multiple && this.tagOverflowObserver == null)
{
this.tagOverflowObserver = new IntersectionObserver(this._handleTagOverflow, {
root: this.select.shadowRoot.querySelector(".select__tags"),
threshold: 0.1
});
}
else if((!this.readonly || this.rows !== "1" || !this.multiple) && this.tagOverflowObserver !== null)
{
this.tagOverflowObserver.disconnect();
this.tagOverflowObserver = null;
}
if(this.tagOverflowObserver)
{
this.select.updateComplete.then(() =>
{
// @ts-ignore
for(const tag of this.select.shadowRoot.querySelectorAll(".select__tags *:not(div):not(sl-tag)"))
{
this.tagOverflowObserver.observe(tag);
}
});
}
}
/**
* 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`;
}
/** Sets focus on the control. */
focus(options? : FocusOptions)
{
this.handleFocus();
}
/** Removes focus from the control. */
blur()
{
if(typeof super.blur == "function")
{
super.blur();
}
this.hide();
}
private handleFocus()
{
if(this.disabled || this.readonly)
{
return;
}
this.select?.focus();
}
/**
* 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);
// Only interested in option clicks, but handler is bound higher
if(event.target.tagName !== "SL-OPTION")
{
return;
}
if(this._close_on_select)
{
this.hide();
}
}
private et2HandleBlur(event : Event)
{
if(typeof super.et2HandleBlur === "function")
{
super.et2HandleBlur(event);
}
}
protected handleValueChange(e : SlChangeEvent)
{
// Only interested when selected value changes, not any nested inputs
if(e.target !== this.select)
{
return;
}
const old_value = this.__value;
this.__value = Array.isArray(this.select.value) ?
this.select.value.map(e => e.replaceAll("___", " ")) :
this.select.value.replaceAll("___", " ");
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();
}
}
protected handleTagClick(event : MouseEvent)
{
if(typeof this.onTagClick == "function")
{
event.stopPropagation();
return this.onTagClick(event, event.target);
}
}
/**
* Callback for the intersection observer so we know when tags don't fit
*
* Here we set the flag to show how many more tags are hidden, but this only happens
* when there are more tags than space.
*
* @param entries
* @protected
*/
protected _handleTagOverflow(entries : IntersectionObserverEntry[])
{
const oldCount = this._tagsHidden;
let visibleTagCount = this.value.length - this._tagsHidden;
let update = false;
// If we have all tags, start from 0, otherwise it's just a change
if(entries.length == this.value.length)
{
visibleTagCount = 0;
}
else
{
update = true;
}
for(const tag of entries)
{
if(tag.isIntersecting)
{
visibleTagCount++;
}
else if(update && !tag.isIntersecting)
{
visibleTagCount--;
}
else
{
break;
}
}
if(visibleTagCount && visibleTagCount < this.value.length)
{
this._tagsHidden = this.value.length - visibleTagCount;
}
else
{
this._tagsHidden = 0;
}
this.requestUpdate("_tagsHidden", oldCount);
}
/**
* If rows=1 and multiple=true, when they put the mouse over the widget show all tags
* @param {MouseEvent} e
* @private
*/
protected _handleMouseEnter(e : MouseEvent)
{
if(this.readonly && this.rows == "1" && this.multiple == true && this.value.length > 1)
{
e.stopPropagation();
let distance = (-1 * parseInt(getComputedStyle(this).height)) + 2;
// Bind to turn this all off
this.addEventListener("mouseleave", this._handleMouseLeave);
// Popup - this might get wiped out next render(), might not
this.updateComplete.then(() =>
{
let tags = this.select.shadowRoot.querySelector(".select__tags");
let popup = document.createElement("sl-popup");
popup.anchor = this;
popup.distance = distance;
popup.placement = "bottom";
popup.strategy = "fixed";
popup.active = true;
popup.sync = "width";
popup.setAttribute("exportparts", "tags, popup");
popup.classList.add("hover__popup", "details", "hoist", "details__body");
this.shadowRoot.append(popup);
popup.appendChild(tags.cloneNode(true));
tags.style.width = getComputedStyle(this).width;
tags.style.margin = 0;
});
}
}
/**
* If we're showing all rows because of _handleMouseEnter, reset when mouse leaves
* @param {MouseEvent} e
* @private
*/
protected _handleMouseLeave(e : MouseEvent)
{
let popup = this.shadowRoot.querySelector("sl-popup");
if(popup)
{
// Popup still here. Remove it
popup.remove();
}
this.select.requestUpdate();
}
/** 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 get select() : SlSelect
{
return this.shadowRoot?.querySelector("sl-select");
}
/**
* Custom, dynamic styling
*
* Put as much as you can in static styles for performance reasons
* Override this for custom dynamic styles
*
* @returns {TemplateResult}
* @protected
*/
protected _styleTemplate() : TemplateResult
{
return null;
}
/**
* Used for the "no value" option for single select
* Placeholder is used for multi-select with no value
*
* @returns {TemplateResult}
*/
_emptyLabelTemplate() : TemplateResult
{
if(!this.emptyLabel || this.multiple)
{
return html``;
}
return html`
<sl-option
part="emptyLabel option"
value=""
.selected=${this.getValueAsArray().some(v => v == "")}
>
${this.emptyLabel}
</sl-option>`;
}
/**
* Iterate over all the options
* @returns {TemplateResult}
* @protected
*/
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), (o : SelectOption) => o.value, this._groupTemplate.bind(this))
}`;
}
/**
* Used to render each option into the select
* Override for custom select options. Note that spaces are not allowed in option values,
* and sl-select _requires_ options to be <sl-option>
*
* @param {SelectOption} option
* @returns {TemplateResult}
*/
protected _optionTemplate(option : SelectOption) : TemplateResult
{
// Exclude non-matches when searching
// unless they're already selected, in which case removing them removes them from value
if(typeof option.isMatch == "boolean" && !option.isMatch && !this.getValueAsArray().includes(option.value))
{
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 = (<string>option.value).replaceAll(" ", "___");
const classes = option.class ? Object.fromEntries((option.class).split(" ").map(k => [k, true])) : {};
return html`
<sl-option
part="option"
exportparts="prefix:tag__prefix, suffix:tag__suffix"
value="${value}"
title="${!option.title || this.noLang ? option.title : this.egw().lang(option.title)}"
class=${classMap({
"match": this.searchEnabled && (option.isMatch || false),
"no-match": this.searchEnabled && option.isMatch == false,
...classes
})}
.option=${option}
.selected=${this.getValueAsArray().some(v => v == value)}
?disabled=${option.disabled}
>
${this._iconTemplate(option)}
${this.noLang ? option.label : this.egw().lang(option.label)}
</sl-option>`;
}
/**
* Get the icon for the select option
*
* @param option
* @protected
*/
protected _iconTemplate(option : SelectOption)
{
if(!option.icon)
{
return html``;
}
return html`
<et2-image slot="prefix" part="icon" style="width: var(--icon-width)"
src="${option.icon}"></et2-image>`
}
/**
* Custom tag
*
* Override this to customise display when multiple=true.
* There is no restriction on the tag used, unlike _optionTemplate()
*
* @param {Et2Option} option
* @param {number} index
* @returns {TemplateResult}
* @protected
*/
protected _tagTemplate(option : SlOption, index : number) : TemplateResult
{
const readonly = (this.readonly || option && typeof (option.disabled) != "undefined" && option.disabled);
const isEditable = this.editModeEnabled && !readonly;
const image = this._iconTemplate(option.option ?? 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}
tabindex="-1"
?pill=${this.pill}
size=${this.size || "medium"}
title=${option.title}
?removable=${!readonly}
?readonly=${readonly}
.editable=${isEditable}
.value=${option.value.replaceAll("___", " ")}
@change=${this.handleTagEdit}
@dblclick=${this._handleDoubleClick}
@mousedown=${typeof this.onTagClick == "function" ? (e) => this.handleTagClick(e) : nothing}
>
${image ?? nothing}
${option.getTextLabel().trim()}
</${tagName}>
`;
}
protected _tagLimitTemplate() : TemplateResult | typeof nothing
{
if(this._tagsHidden == 0)
{
return nothing;
}
return html`
<sl-tag
part="tag__limit"
class="tag_limit"
slot="expand-icon"
>+${this._tagsHidden}
</sl-tag>`;
}
/**
* Additional customisation template
* Override if needed. Added after select options.
*
* @protected
*/
protected _extraTemplate() : TemplateResult | typeof nothing
{
return typeof super._extraTemplate == "function" ? super._extraTemplate() : nothing;
}
/**
* Shoelace select uses space as multiple separator, so our values cannot have a space in them.
* We replace spaces with "___" before passing the value to SlSelect
*
* @protected
*/
protected get shoelaceValue() : string | string[]
{
return Array.isArray(this.value) ?
this.value.map(v => { return v.replaceAll(" ", "___"); }) :
(typeof this.value == "string" ? this.value.replaceAll(" ", "___") : "");
}
public render()
{
const value = this.shoelaceValue;
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`
${this._styleTemplate()}
<sl-select
class=${classMap({
"form-control--has-label": this.label !== ""
})}
exportparts="form-control-label, prefix, tags, display-input, expand-icon, combobox, combobox:base, listbox, option"
label=${this.label}
placeholder=${this.placeholder || (this.multiple && this.emptyLabel ? this.emptyLabel : "")}
aria-label=${this.ariaLabel}
aria-description=${this.ariaDesciption}
?multiple=${this.multiple}
?disabled=${this.disabled || this.readonly}
?clearable=${this.clearable}
?required=${this.required}
helpText=${this.helpText}
hoist
placement=${this.placement}
tabindex="0"
.getTag=${this._tagTemplate}
maxOptionsVisible=${0}
value=${Array.isArray(value) ? value.join(" ") : value}
@sl-change=${this.handleValueChange}
@mouseenter=${this._handleMouseEnter}
@mouseup=${this.handleOptionClick}
@mousewheel=${
// Grab & stop mousewheel to prevent scrolling sidemenu when scrolling through options
e => e.stopImmediatePropagation()
}
size=${this.size || "medium"}
>
${icon}
${this._emptyLabelTemplate()}
${this._optionsTemplate()}
${this._tagLimitTemplate()}
${this._extraTemplate()}
<slot name="prefix" slot="prefix"></slot>
<slot></slot>
${this._helpTextTemplate()}
</sl-select>
`;
}
}
if(typeof customElements.get("et2-select") === "undefined")
{
customElements.define("et2-select", Et2Select);
}
declare global
{
interface HTMLElementTagNameMap
{
"et2-select" : Et2Select;
}
}