Get category icons & colors working for select category

Also some refactoring of things to where they should be
This commit is contained in:
nathan 2022-06-15 16:43:39 -06:00
parent 2a79264674
commit 260d8f523a
8 changed files with 293 additions and 133 deletions

View File

@ -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']))
{

View File

@ -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
<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
return html`
<sl-menu-item value="${option.value}" title="${option.title}" class="${option.class}">
${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);
@ -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

View File

@ -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`
<et2-image slot="prefix" part="icon"
src="/egroupware/api/avatar.php?account_id=${item.value}&etag=1"></et2-image>`;
const image = super._createImage(item);
image.src = "/egroupware/api/avatar.php?account_id=" + item.value + "&etag=1";
return image;
}
}

View File

@ -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;
}
`
];
}

View File

@ -142,10 +142,6 @@ export const Et2WithSearchMixin = <T extends Constructor<LitElement>>(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 = <T extends Constructor<LitElement>>(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 = <T extends Constructor<LitElement>>(superclass
protected _searchInputTemplate()
{
let edit = '';
let edit = null;
if(this.editModeEnabled)
{
edit = html`<input id="edit" type="text" part="input"
@ -385,20 +361,6 @@ export const Et2WithSearchMixin = <T extends Constructor<LitElement>>(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 = <T extends Constructor<LitElement>>(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 = <T extends Constructor<LitElement>>(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 = <T extends Constructor<LitElement>>(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`
<et2-tag class="${item.classList.value} search_tag"
removable
value="${item.value}"
@dblclick=${this._handleDoubleClick}
@click=${this.handleTagInteraction}
@keydown=${this.handleTagInteraction}
@sl-remove=${(event) =>
{
event.stopPropagation();
if(!this.disabled)
{
item.checked = false;
this.syncValueFromItems();
}
}}
>
${this._tagImageTemplate(item)}
${this.getItemLabel(item)}
</et2-tag>
`;
}
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");

View File

@ -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`<style>.tag { --category-color: ${cat_var}}</style>`;
//@formatter:on
}
}
customElements.define("et2-category-tag", Et2CategoryTag);

View File

@ -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()}
<span
part="base"
class=${classMap({

View File

@ -54,6 +54,7 @@ import './Et2Select/Et2SelectAccount';
import './Et2Select/Et2SelectEmail';
import './Et2Select/Et2SelectReadonly';
import './Et2Select/Tag/Et2Tag';
import './Et2Select/Tag/Et2CategoryTag'
import './Et2Textarea/Et2Textarea';
import './Et2Textbox/Et2Textbox';
import './Et2Textbox/Et2TextboxReadonly';