From 260d8f523a140670ac385be85a6825f3b55e707b Mon Sep 17 00:00:00 2001 From: nathan Date: Wed, 15 Jun 2022 16:43:39 -0600 Subject: [PATCH] Get category icons & colors working for select category Also some refactoring of things to where they should be --- api/categories.php | 4 +- api/js/etemplate/Et2Select/Et2Select.ts | 210 +++++++++++++++++- .../etemplate/Et2Select/Et2SelectAccount.ts | 10 +- api/js/etemplate/Et2Select/Et2SelectEmail.ts | 6 + api/js/etemplate/Et2Select/SearchMixin.ts | 123 +--------- .../etemplate/Et2Select/Tag/Et2CategoryTag.ts | 54 +++++ api/js/etemplate/Et2Select/Tag/Et2Tag.ts | 18 +- api/js/etemplate/etemplate2.ts | 1 + 8 files changed, 293 insertions(+), 133 deletions(-) create mode 100644 api/js/etemplate/Et2Select/Tag/Et2CategoryTag.ts diff --git a/api/categories.php b/api/categories.php index 4086c45b8d..8dd1a09212 100644 --- a/api/categories.php +++ b/api/categories.php @@ -44,7 +44,9 @@ foreach($categories as $cat) { // Use slightly more specific selector that just class, to allow defaults // if the category has no color - $content .= ".egwGridView_scrollarea tr.row_category.cat_{$cat['id']} > td:first-child, .select-cat li.cat_{$cat['id']}, .et2_selectbox ul.chzn-results li.cat_{$cat['id']}, .et2_selectbox ul.chzn-choices li.cat_{$cat['id']}, .nextmatch_header_row .et2_selectbox.select-cat.cat_{$cat['id']} a.chzn-single , et2-select-cat > .cat_{$cat['id']} {border-left-color: {$cat['data']['color']};} .cat_{$cat['id']}.fullline_cat_bg, div.cat_{$cat['id']}, span.cat_{$cat['id']} { background-color: {$cat['data']['color']};} /*{$cat['name']}*/\n"; + $content .= "/** {$cat['name']} **/\n/*webComponent*/\n"; + $content .= ":root,:host {--cat-{$cat['id']}-color: {$cat['data']['color']};}, :host.cat_{$cat['id']}, .cat_{$cat['id']} {--category-color: {$cat['data']['color']};} et2-select-cat > .cat_{$cat['id']} {--category-color: {$cat['data']['color']};} \n"; + $content .= "/*legacy*/\n.egwGridView_scrollarea tr.row_category.cat_{$cat['id']} > td:first-child, .select-cat li.cat_{$cat['id']}, .et2_selectbox ul.chzn-results li.cat_{$cat['id']}, .et2_selectbox ul.chzn-choices li.cat_{$cat['id']}, .nextmatch_header_row .et2_selectbox.select-cat.cat_{$cat['id']} a.chzn-single , .cat_{$cat['id']}.fullline_cat_bg, div.cat_{$cat['id']}, span.cat_{$cat['id']} { background-color: {$cat['data']['color']};} \n"; } if (!empty($cat['data']['icon'])) { diff --git a/api/js/etemplate/Et2Select/Et2Select.ts b/api/js/etemplate/Et2Select/Et2Select.ts index 77b948dd4f..c96c6d4745 100644 --- a/api/js/etemplate/Et2Select/Et2Select.ts +++ b/api/js/etemplate/Et2Select/Et2Select.ts @@ -13,10 +13,11 @@ import {StaticOptions} from "./StaticOptions"; import {Et2widgetWithSelectMixin} from "./Et2WidgetWithSelectMixin"; import {SelectOption} from "./FindSelectOptions"; import {Et2InvokerMixin} from "../Et2Url/Et2InvokerMixin"; -import {SlSelect} from "@shoelace-style/shoelace"; +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) @@ -49,12 +50,33 @@ export class Et2Select extends Et2WithSearchMixin(Et2InvokerMixin(Et2WidgetWithS 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%); }` @@ -260,16 +282,48 @@ export class Et2Select extends Et2WithSearchMixin(Et2InvokerMixin(Et2WidgetWithS } // propagate multiple to selectbox - if (changedProperties.has('multiple')) + if(changedProperties.has('multiple')) { // switch the expand button off - if (this.multiple) + 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) @@ -280,18 +334,93 @@ export class Et2Select extends Et2WithSearchMixin(Et2InvokerMixin(Et2WidgetWithS ${this.empty_label}`; } + /** + * 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` ` : ""; + // Tag used must match this.optionTag, but you can't use the variable directly return html` ${icon} ${option.label} `; } + + /** + * 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 = 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(this.optionTag)]; + } } customElements.define("et2-select", Et2Select); @@ -359,9 +488,13 @@ export class Et2SelectCategory extends Et2Select return [ ...super.styles, css` - ::slotted(*) { - border-left: 3px solid transparent; - } + /* Category color on options */ + ::slotted(*) { + border-left: 3px solid var(--category-color, transparent); + } + .select--standard .select__control { + border-left: 4px solid transparent; + } ` ] } @@ -391,6 +524,71 @@ export class Et2SelectCategory extends Et2Select 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 diff --git a/api/js/etemplate/Et2Select/Et2SelectAccount.ts b/api/js/etemplate/Et2Select/Et2SelectAccount.ts index 28c68e1bac..fd602051d1 100644 --- a/api/js/etemplate/Et2Select/Et2SelectAccount.ts +++ b/api/js/etemplate/Et2Select/Et2SelectAccount.ts @@ -9,7 +9,7 @@ import {Et2Select} from "./Et2Select"; import {SelectOption} from "./FindSelectOptions"; -import {html} from "@lion/core"; +import {Et2Image} from "../Et2Image/Et2Image"; export type AccountType = 'accounts'|'groups'|'both'|'owngroups'; @@ -90,11 +90,11 @@ export class Et2SelectAccount extends Et2Select * @protected * */ - protected _tagImageTemplate(item) + protected _createImage(item) : Et2Image { - return html` - `; + const image = super._createImage(item); + image.src = "/egroupware/api/avatar.php?account_id=" + item.value + "&etag=1"; + return image; } } diff --git a/api/js/etemplate/Et2Select/Et2SelectEmail.ts b/api/js/etemplate/Et2Select/Et2SelectEmail.ts index e91f28e9b3..8f70819a6c 100644 --- a/api/js/etemplate/Et2Select/Et2SelectEmail.ts +++ b/api/js/etemplate/Et2Select/Et2SelectEmail.ts @@ -29,6 +29,12 @@ export class Et2SelectEmail extends Et2Select ::slotted(sl-icon[slot="suffix"]) { display: none; } + + /* Hide selected options from the dropdown */ + ::slotted([checked]) + { + display: none; + } ` ]; } diff --git a/api/js/etemplate/Et2Select/SearchMixin.ts b/api/js/etemplate/Et2Select/SearchMixin.ts index e6a49a6bbf..5885beca41 100644 --- a/api/js/etemplate/Et2Select/SearchMixin.ts +++ b/api/js/etemplate/Et2Select/SearchMixin.ts @@ -142,10 +142,6 @@ export const Et2WithSearchMixin = >(superclass :host([allowFreeEntries]) ::slotted([slot="suffix"]) { display: none; } - /* Get rid of padding before/after options */ - sl-menu::part(base) { - padding: 0px; - } /* Make search textbox take full width */ ::slotted(.search_input), ::slotted(.search_input) input, .search_input, .search_input input { width: 100%; @@ -195,30 +191,10 @@ export const Et2WithSearchMixin = >(superclass ::slotted(.no-match) { display: none; } - /* Hide selected options from the dropdown */ - ::slotted([checked]) - { - display: none; - } /* Different cursor for editable tags */ :host([allowfreeentries]) .search_tag::part(base) { cursor: text; } - /* 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; - } ` ] } @@ -337,7 +313,7 @@ export const Et2WithSearchMixin = >(superclass protected _searchInputTemplate() { - let edit = ''; + let edit = null; if(this.editModeEnabled) { edit = html`>(superclass this.querySelector(".search_input"); } - /** - * 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"; - } - - protected get menuItems() - { - return this.querySelectorAll(this.optionTag); - } /** * Only local options, excludes server options @@ -432,7 +394,7 @@ export const Et2WithSearchMixin = >(superclass // Overridden to add options if allowFreeEntries=true if(this.allowFreeEntries) { - if(typeof this.value == "string" && !Object.values(this.menuItems).find(o => o.value == this.value)) + if(typeof this.value == "string" && !this.menuItems.find(o => o.value == this.value)) { this.createFreeEntry(this.value); } @@ -440,7 +402,7 @@ export const Et2WithSearchMixin = >(superclass { this.value.forEach((e) => { - if(!Object.values(this.menuItems).find(o => o.value == e)) + if(!this.menuItems.find(o => o.value == e)) { this.createFreeEntry(e); } @@ -856,85 +818,6 @@ export const Et2WithSearchMixin = >(superclass this.handleMenuShow(); } - /** - * 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._tagTemplate(item)); - - // Re-slice & add overflow tag - if(overflow) - { - this.displayTags = this.displayTags.slice(0, this.maxTagsVisible); - this.displayTags.push(overflow); - } - } - - - /** - * Customise how tags are rendered. This overrides what SlSelect - * does in syncItemsFromValue(). - * This is a copy+paste from SlSelect.syncItemsFromValue(). - * - * @param item - * @protected - */ - protected _tagTemplate(item) - { - return html` - - { - event.stopPropagation(); - if(!this.disabled) - { - item.checked = false; - this.syncValueFromItems(); - } - }} - > - ${this._tagImageTemplate(item)} - ${this.getItemLabel(item)} - - `; - } - - protected _tagImageTemplate(item) - { - let image = item.querySelector("et2-image"); - if(image) - { - image = image.clone(); - image.slot = "prefix"; - image.class = "tag_image"; - return image; - } - return ""; - } - protected _handleSearchAbort(e) { this._activeControls.classList.remove("active"); diff --git a/api/js/etemplate/Et2Select/Tag/Et2CategoryTag.ts b/api/js/etemplate/Et2Select/Tag/Et2CategoryTag.ts new file mode 100644 index 0000000000..0a7cacff47 --- /dev/null +++ b/api/js/etemplate/Et2Select/Tag/Et2CategoryTag.ts @@ -0,0 +1,54 @@ +/** + * EGroupware eTemplate2 - Category Tag 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, TemplateResult} from "@lion/core"; +import shoelace from "../../Styles/shoelace"; +import {Et2Tag} from "./Et2Tag"; + +/** + * Tag is usually used in a Et2CategorySelect with multiple=true, but there's no reason it can't go anywhere + */ +export class Et2CategoryTag extends Et2Tag +{ + private value : string; + + static get styles() + { + return [ + super.styles, + shoelace, css` + .tag { + gap: var(--sl-spacing-2x-small); + /* --category-color is passed through in _styleTemplate() */ + border-left: 5px solid var(--category-color, transparent); + } + `]; + } + + constructor(...args : []) + { + super(...args); + } + + /** + * Due to how the scoping / encapulation works, we need to re-assign the category color + * variable here so it can be passed through. .cat_# {--category-color} is not visible. + * + * @returns {TemplateResult} + * @protected + */ + protected _styleTemplate() : TemplateResult + { + let cat_var = "var(--cat-" + this.value + "-color)" + // @formatter:off + return html``; + //@formatter:on + } +} + +customElements.define("et2-category-tag", Et2CategoryTag); \ No newline at end of file diff --git a/api/js/etemplate/Et2Select/Tag/Et2Tag.ts b/api/js/etemplate/Et2Select/Tag/Et2Tag.ts index 2d75d88eba..9d6bc06e2a 100644 --- a/api/js/etemplate/Et2Select/Tag/Et2Tag.ts +++ b/api/js/etemplate/Et2Select/Tag/Et2Tag.ts @@ -8,7 +8,7 @@ */ import {Et2Widget} from "../../Et2Widget/Et2Widget"; import {SlTag} from "@shoelace-style/shoelace"; -import {classMap, css, html} from "@lion/core"; +import {classMap, css, html, TemplateResult} from "@lion/core"; import shoelace from "../../Styles/shoelace"; /** @@ -36,15 +36,31 @@ export class Et2Tag extends Et2Widget(SlTag) `]; } + static get properties() + { + return { + ...super.properties, + value: {type: String, reflect: true} + } + } + constructor(...args : []) { super(...args); + this.value = ""; this.pill = true; + this.removable = true; + } + + protected _styleTemplate() : TemplateResult + { + return null; } render() { return html` + ${this._styleTemplate()}