egroupware/api/js/etemplate/Et2Select/Et2Select.ts
nathan 851a054599 Select / Search bugs:
- search result would only be shown once even if next search included it
- selected values disappear when starting a new search
- loading spinner was not shown
2022-06-16 11:05:13 -06:00

787 lines
17 KiB
TypeScript

/**
* 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, html, PropertyValues, TemplateResult} from "@lion/core";
import {StaticOptions} from "./StaticOptions";
import {Et2widgetWithSelectMixin} from "./Et2WidgetWithSelectMixin";
import {SelectOption} from "./FindSelectOptions";
import {Et2InvokerMixin} from "../Et2Url/Et2InvokerMixin";
import {SlMenuItem, SlSelect} from "@shoelace-style/shoelace";
import {egw} from "../../jsapi/egw_global";
import shoelace from "../Styles/shoelace";
import {Et2WithSearchMixin} from "./SearchMixin";
import {Et2Tag} from "./Tag/Et2Tag";
// export Et2WidgetWithSelect which is used as type in other modules
export class Et2WidgetWithSelect extends Et2widgetWithSelectMixin(SlSelect)
{
};
// @ts-ignore SlSelect styles is a single CSSResult, not an array, so TS complains
export class Et2Select extends Et2WithSearchMixin(Et2InvokerMixin(Et2WidgetWithSelect))
{
static get styles()
{
return [
// Parent (SlSelect) returns a single cssResult, not an array
super.styles,
shoelace,
css`
:host {
display: block;
--icon-width: 20px;
}
/* This is the drop-down arrow on the right */
::slotted([slot="suffix"]) {
font-size: 120% !important;
font-weight: bold;
color: gray !important;
}
::slotted(img), img {
vertical-align: middle;
}
/* Get rid of padding before/after options */
sl-menu::part(base) {
padding: 0px;
}
/* Avoid double scrollbar if there are a lot of options */
.select__menu
{
max-height: initial;
}
/** 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 {
max-height: 5em;
overflow-y: auto;
gap: 0.1rem 0.5rem;
}
/* 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%);
}`
];
}
static get properties()
{
return {
...super.properties,
/**
* Toggle between single and multiple selection
*/
multiple: {
type: Boolean,
reflect: true,
},
/**
* Add a button to switch to multiple with given number of rows
*/
expand_multiple_rows: {
type: Number,
}
}
}
get slots()
{
return {
...super.slots,
input: () =>
{
return document.createElement("select");
}
}
}
constructor(...args : any[])
{
super();
this._triggerChange = this._triggerChange.bind(this);
this._doResize = this._doResize.bind(this);
}
connectedCallback()
{
super.connectedCallback();
this.getUpdateComplete().then(() =>
{
this.addEventListener("sl-clear", this._triggerChange)
this.addEventListener("sl-change", this._triggerChange);
this.addEventListener("sl-after-show", this._doResize)
});
}
disconnectedCallback()
{
super.disconnectedCallback();
this.removeEventListener("sl-clear", this._triggerChange)
this.removeEventListener("sl-change", this._triggerChange);
this.removeEventListener("sl-after-show", this._doResize);
}
firstUpdated(changedProperties?)
{
super.firstUpdated(changedProperties);
// If no value is set, choose the first option
// Only do this on firstUpdated() otherwise it is impossible to clear the field
const valueArray = Array.isArray(this.value) ? this.value : (!this.value ? [] : this.value.toString().split(','));
// value not in options --> use empty_label, if exists, or first option otherwise
if(!this.multiple && this.select_options.length > 0 && this.select_options.filter((option) => valueArray.find(val => val == option.value)).length === 0)
{
this.value = this.empty_label ? "" : "" + this.select_options[0]?.value; // ""+ to cast value of 0 to "0", to not replace with ""
}
}
_triggerChange(e)
{
this.dispatchEvent(new Event("change"));
}
/**
* Change the menu sizing to allow the menu to be wider than the field width, but no smaller
*
* @param e
* @private
*/
private _doResize(e)
{
this.menu.style.minWidth = this.menu.style.width;
this.menu.style.width = "";
}
/**
* Get the node where we're putting the selection options
*
* @returns {HTMLElement}
*/
get _optionTargetNode() : HTMLElement
{
return <HTMLElement><unknown>this;
}
/**
* @deprecated use this.multiple = multi
*
* @param multi
*/
set_multiple(multi)
{
this.multiple = multi;
}
set_value(val : string | string[] | number)
{
if (typeof val === 'number')
{
val = val.toString();
}
this.value = val || '';
}
/**
* Reimplemented to allow/keep string[] as value
*
* @param value string|string[]
*/
_callParser(value = this.formattedValue)
{
if(this.multiple && Array.isArray(value))
{
return value;
}
return super._callParser(value);
}
private _set_invoker(rows)
{
this._invokerAction = () => {
this.multiple = true;
this._inputNode.size = parseInt(rows) || 4;
this._invokerNode.style.display = 'none';
}
this._invokerTitle = egw.lang('Switch to multiple');
this._invokerLabel = '+';
}
transformAttributes(attrs)
{
if (attrs.expand_multiple_rows)
{
this._set_invoker(attrs.expand_multiple_rows);
}
super.transformAttributes(attrs);
}
set expand_multiple_rows(rows)
{
if (rows && !this.multiple)
{
this._set_invoker(rows);
}
else
{
this._invokerLabel = undefined;
}
this.__expand_multiple_rows = rows;
}
get expand_multiple_rows()
{
return this.__expand_multiple_rows;
}
/**
* Method to check if invoker can be activated: not disabled, empty or invalid
*
* Overwritten to NOT disable if empty.
*
* @protected
* */
_toggleInvokerDisabled()
{
if (this._invokerNode)
{
const invokerNode = /** @type {HTMLElement & {disabled: boolean}} */ (this._invokerNode);
invokerNode.disabled = this.disabled || this.hasFeedbackFor.length > 0;
}
}
/** @param {import('@lion/core').PropertyValues } changedProperties */
updated(changedProperties : PropertyValues)
{
super.updated(changedProperties);
if(changedProperties.has('select_options') || changedProperties.has("value") || changedProperties.has('empty_label'))
{
// Re-set value, the option for it may have just shown up
this.value = this.value || "";
}
// propagate multiple to selectbox
if(changedProperties.has('multiple'))
{
// switch the expand button off
if(this.multiple)
{
this.expand_multiple_rows = 0;
}
}
}
/**
* 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.maxTagsVisible > 0 && this.displayTags.length > this.maxTagsVisible)
{
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));
// Re-slice & add overflow tag
if(overflow)
{
this.displayTags = this.displayTags.slice(0, this.maxTagsVisible);
this.displayTags.push(overflow);
}
}
_emptyLabelTemplate() : TemplateResult
{
if(!this.empty_label || this.multiple)
{
return html``;
}
return html`
<sl-menu-item value="">${this.empty_label}</sl-menu-item>`;
}
/**
* Tag used for rendering options
* Used for finding & filtering options, they're created by the mixed-in class
* @returns {string}
*/
public get optionTag()
{
return "sl-menu-item";
}
_optionTemplate(option : SelectOption) : TemplateResult
{
let icon = option.icon ? html`
<et2-image slot="prefix" part="icon" style="width: var(--icon-width)"
src="${option.icon}"></et2-image>` : "";
// Tag used must match this.optionTag, but you can't use the variable directly.
// Pass option along so SearchMixin can grab it if needed
return html`
<sl-menu-item value="${option.value}" title="${option.title}" class="${option.class}" .option=${option}>
${icon}
${option.label}
</sl-menu-item>`;
}
/**
* Tag used for rendering tags when multiple=true
* Used for creating, finding & filtering options.
* @see createTagNode()
* @returns {string}
*/
public get tagTag()
{
return "et2-tag";
}
/**
* 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)
{
const tag = <Et2Tag>document.createElement(this.tagTag);
tag.value = item.value;
tag.textContent = this.getItemLabel(item);
tag.class = item.classList.value + " search_tag";
tag.addEventListener("dblclick", this._handleDoubleClick);
tag.addEventListener("click", this.handleTagInteraction);
tag.addEventListener("keydown", this.handleTagInteraction);
tag.addEventListener("sl-remove", (event) =>
{
event.stopPropagation();
if(!this.disabled)
{
item.checked = false;
this.syncValueFromItems();
}
});
let image = this._createImage(item);
if(image)
{
tag.prepend(image);
}
return tag;
}
protected _createImage(item)
{
let image = item.querySelector("et2-image");
if(image)
{
image = image.clone();
image.slot = "prefix";
image.class = "tag_image";
return image;
}
return "";
}
public get menuItems() : HTMLElement[]
{
return [...this.querySelectorAll<SlMenuItem>(this.optionTag)];
}
}
customElements.define("et2-select", Et2Select);
/**
* Use a single StaticOptions, since it should have no state
* @type {StaticOptions}
*/
const so = new StaticOptions();
export class Et2SelectApp extends Et2Select
{
constructor()
{
super();
this.select_options = so.app(this, {other: this.other || []});
}
}
// @ts-ignore TypeScript is not recognizing that this widget is a LitElement
customElements.define("et2-select-app", Et2SelectApp);
export class Et2SelectBitwise extends Et2Select
{
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;
this.requestUpdate("value", oldValue);
}
}
// @ts-ignore TypeScript is not recognizing that this widget is a LitElement
customElements.define("et2-select-bitwise", Et2SelectBitwise);
export class Et2SelectBool extends Et2Select
{
constructor()
{
super();
this.select_options = so.bool(this);
}
}
// @ts-ignore TypeScript is not recognizing that this widget is a LitElement
customElements.define("et2-select-bool", Et2SelectBool);
export class Et2SelectCategory extends Et2Select
{
static get styles()
{
return [
...super.styles,
css`
/* Category color on options */
::slotted(*) {
border-left: 3px solid var(--category-color, transparent);
}
.select--standard .select__control {
border-left: 4px solid transparent;
}
`
]
}
static get properties()
{
return {
...super.properties,
/**
* Include global categories
*/
global_categories: {type: Boolean},
/**
* Show categories from this application. If not set, will be the current application
*/
application: {type: String},
/**
* Show categories below this parent category
*/
parent_cat: {type: Number}
}
}
constructor()
{
super();
this.select_options = so.cat(this);
}
connectedCallback()
{
super.connectedCallback();
if(typeof this.application == 'undefined')
{
this.application =
// When the widget is first created, it doesn't have a parent and can't find it's instanceManager
(this.getInstanceManager() && this.getInstanceManager().app) ||
this.egw().app_name();
}
}
updated(changedProperties : PropertyValues)
{
super.updated(changedProperties);
if(changedProperties.has("value"))
{
this.doLabelChange()
}
}
doLabelChange()
{
// Update the display label when checked menu item's label changes
if(this.multiple)
{
return;
}
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") || "transparent";
}
}
get tagTag() : string
{
return "et2-category-tag";
}
/**
* Customise how tags are rendered. This overrides parent to set application
*
* @param item
* @protected
*/
protected _createTagNode(item)
{
let tag = super._createTagNode(item);
tag.application = this.application;
return tag;
}
}
// @ts-ignore TypeScript is not recognizing that this widget is a LitElement
customElements.define("et2-select-cat", Et2SelectCategory);
export class Et2SelectCountry extends Et2Select
{
constructor()
{
super();
this.select_options = so.country(this, {});
}
}
// @ts-ignore TypeScript is not recognizing that this widget is a LitElement
customElements.define("et2-select-country", Et2SelectCountry);
export class Et2SelectDay extends Et2Select
{
constructor()
{
super();
this.select_options = so.day(this, {other: this.other || []});
}
}
// @ts-ignore TypeScript is not recognizing that this widget is a LitElement
customElements.define("et2-select-day", Et2SelectDay);
export class Et2SelectDayOfWeek extends Et2Select
{
constructor()
{
super();
this.select_options = so.dow(this, {other: this.other || []});
}
}
// @ts-ignore TypeScript is not recognizing that this widget is a LitElement
customElements.define("et2-select-dow", Et2SelectDayOfWeek);
export class Et2SelectHour extends Et2Select
{
constructor()
{
super();
this.select_options = so.hour(this, {other: this.other || []});
}
}
// @ts-ignore TypeScript is not recognizing that this widget is a LitElement
customElements.define("et2-select-hour", Et2SelectHour);
export class Et2SelectMonth extends Et2Select
{
constructor()
{
super();
this.select_options = so.month(this);
}
}
// @ts-ignore TypeScript is not recognizing that this widget is a LitElement
customElements.define("et2-select-month", Et2SelectMonth);
export class Et2SelectNumber extends 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.select_options = so.number(this);
}
}
}
// @ts-ignore TypeScript is not recognizing that this widget is a LitElement
customElements.define("et2-select-number", Et2SelectNumber);
export class Et2SelectPercent extends Et2SelectNumber
{
constructor()
{
super();
this.min = 0;
this.max = 100;
this.interval = 10;
this.suffix = "%%";
}
}
// @ts-ignore TypeScript is not recognizing that this widget is a LitElement
customElements.define("et2-select-percent", Et2SelectPercent);
export class Et2SelectPriority extends Et2Select
{
constructor()
{
super();
this.select_options = so.priority(this);
}
}
// @ts-ignore TypeScript is not recognizing that this widget is a LitElement
customElements.define("et2-select-priority", Et2SelectPriority);
export class Et2SelectState extends Et2Select
{
constructor()
{
super();
this.select_options = so.state(this, {other: this.other || []});
}
}
// @ts-ignore TypeScript is not recognizing that this widget is a LitElement
customElements.define("et2-select-state", Et2SelectState);
export class Et2SelectTimezone extends Et2Select
{
constructor()
{
super();
this.select_options = so.timezone(this, {other: this.other || []});
}
}
// @ts-ignore TypeScript is not recognizing that this widget is a LitElement
customElements.define("et2-select-timezone", Et2SelectTimezone);
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.select_options = so.year(this);
}
}
}
// @ts-ignore TypeScript is not recognizing that this widget is a LitElement
customElements.define("et2-select-year", Et2SelectYear);