Et2Email: Readonly & limited rows styling

This commit is contained in:
nathan 2024-01-11 16:25:38 -07:00
parent 57c76e9840
commit a673a6ac5f
4 changed files with 258 additions and 18 deletions

View File

@ -27,7 +27,7 @@ export default css`
border-radius: var(--sl-input-border-radius-medium); border-radius: var(--sl-input-border-radius-medium);
font-size: var(--sl-input-font-size-medium); font-size: var(--sl-input-font-size-medium);
min-height: var(--sl-input-height-medium); min-height: var(--sl-input-height-medium);
max-height: calc(var(--height, 2.5) * var(--sl-input-height-medium)); max-height: calc(var(--height, 5) * var(--sl-input-height-medium));
overflow-y: auto; overflow-y: auto;
padding-block: 0; padding-block: 0;
padding-inline: var(--sl-input-spacing-medium); padding-inline: var(--sl-input-spacing-medium);
@ -58,7 +58,8 @@ export default css`
order: 1; order: 1;
} }
/* Tags */ /* Tags */
.email et2-email-tag {
et2-email-tag {
order: 2; order: 2;
flex-grow: 0; flex-grow: 0;
margin: auto 0px; margin: auto 0px;
@ -137,4 +138,70 @@ export default css`
padding-inline: var(--sl-spacing-x-large); padding-inline: var(--sl-spacing-x-large);
} }
/**
* Readonly
*/
:host([readonly]) .email .email__combobox {
border: none;
box-shadow: none;
max-height: calc(var(--height, 5) * (var(--sl-input-height-medium) * 0.8))
}
:host([readonly])::part(expand-icon) {
display: none;
}
:host([readonly]) .email__search {
display: none;
}
/**
* Style for tag count if readonly and rows=1
*/
:host([readonly][rows="1"]) .email__combobox {
overflow: hidden;
min-height: auto;
max-height: calc(var(--sl-input-height-medium) * 0.8);
}
.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;
z-index: 1;
}
.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 readonly rows=1 */
:host([ readonly ][ rows ]) .hover__popup {
width: -webkit-fill-available;
width: -moz-fill-available;
width: fill-available;
}
:host([readonly][rows]) .hover__popup::part(popup) {
z-index: var(--sl-z-index-dropdown);
background-color: white;
display: flex;
flex-wrap: wrap;
/* Same as .email__combobox */
gap: 0.1rem 0.5rem;
}
/* End styles for [readonly][rows=1] */
`; `;

View File

@ -11,6 +11,7 @@ import {html, LitElement, nothing, PropertyValues, TemplateResult} from "lit";
import {property} from "lit/decorators/property.js"; import {property} from "lit/decorators/property.js";
import {state} from "lit/decorators/state.js"; import {state} from "lit/decorators/state.js";
import {classMap} from "lit/directives/class-map.js"; import {classMap} from "lit/directives/class-map.js";
import {styleMap} from "lit/directives/style-map.js";
import {keyed} from "lit/directives/keyed.js"; import {keyed} from "lit/directives/keyed.js";
import {live} from "lit/directives/live.js"; import {live} from "lit/directives/live.js";
import {map} from "lit/directives/map.js"; import {map} from "lit/directives/map.js";
@ -63,7 +64,7 @@ import Sortable from "sortablejs/modular/sortable.complete.esm.js";
* @csspart option - Each matching email address suggestion * @csspart option - Each matching email address suggestion
* @csspart tag - The individual tags that represent each email address. * @csspart tag - The individual tags that represent each email address.
* *
* @cssproperty [--height=2.5] - The maximum height of the widget, to limit size when you have a lot of addresses. * @cssproperty [--height=5] - The maximum height of the widget, to limit size when you have a lot of addresses. Set by rows property, when set.
*/ */
export class Et2Email extends Et2InputWidget(LitElement) implements SearchMixinInterface export class Et2Email extends Et2InputWidget(LitElement) implements SearchMixinInterface
{ {
@ -144,11 +145,21 @@ export class Et2Email extends Et2InputWidget(LitElement) implements SearchMixinI
*/ */
@property({type: String}) searchUrl = "EGroupware\\Api\\Etemplate\\Widget\\Taglist::ajax_email"; @property({type: String}) searchUrl = "EGroupware\\Api\\Etemplate\\Widget\\Taglist::ajax_email";
/**
* Limit the maximum height of the widget, for when you have a lot of addresses.
* Set it to 1 for special single-line styling, 0 to disable
* @type {number}
*/
@property({type: Number, reflect: true}) rows;
@state() searching = false; @state() searching = false;
@state() hasFocus = false; @state() hasFocus = false;
@state() currentOption : SlOption; @state() currentOption : SlOption;
@state() currentTag : Et2EmailTag; @state() currentTag : Et2EmailTag;
/** If the select is limited to 1 row, we show the number of tags not visible */
@state() _tagsHidden = 0;
get _popup() : SlPopup { return this.shadowRoot.querySelector("sl-popup");} get _popup() : SlPopup { return this.shadowRoot.querySelector("sl-popup");}
@ -187,6 +198,9 @@ export class Et2Email extends Et2InputWidget(LitElement) implements SearchMixinI
protected _searchPromise : Promise<SelectOption[]> = Promise.resolve([]); protected _searchPromise : Promise<SelectOption[]> = Promise.resolve([]);
protected _selectOptions : SelectOption[] = []; protected _selectOptions : SelectOption[] = [];
// Overflow Observer for +# display
protected tagOverflowObserver : IntersectionObserver = null;
// Drag / drop / sort // Drag / drop / sort
protected _sortable : Sortable; protected _sortable : Sortable;
@ -208,8 +222,10 @@ export class Et2Email extends Et2InputWidget(LitElement) implements SearchMixinI
this.handleOpenChange = this.handleOpenChange.bind(this); this.handleOpenChange = this.handleOpenChange.bind(this);
this.handleLostFocus = this.handleLostFocus.bind(this); this.handleLostFocus = this.handleLostFocus.bind(this);
this.handleSortEnd = this.handleSortEnd.bind(this); this.handleSortEnd = this.handleSortEnd.bind(this);
this.handleTagOverflow = this.handleTagOverflow.bind(this);
this.handleMouseEnter = this.handleMouseEnter.bind(this);
this.handleMouseLeave = this.handleMouseLeave.bind(this);
} }
connectedCallback() connectedCallback()
@ -271,6 +287,7 @@ export class Et2Email extends Et2InputWidget(LitElement) implements SearchMixinI
{ {
this.makeSortable(); this.makeSortable();
} }
this.checkTagOverflow();
} }
private _getEmailDisplayPreference() private _getEmailDisplayPreference()
@ -374,6 +391,35 @@ export class Et2Email extends Et2InputWidget(LitElement) implements SearchMixinI
} }
} }
protected checkTagOverflow()
{
// Create / destroy intersection observer
if(this.readonly && this.rows == "1" && this.tagOverflowObserver == null)
{
this.tagOverflowObserver = new IntersectionObserver(this.handleTagOverflow, {
root: this.shadowRoot.querySelector(".email__combobox"),
threshold: 0.1
});
}
else if((!this.readonly || this.rows !== 1) && this.tagOverflowObserver !== null)
{
this.tagOverflowObserver.disconnect();
this.tagOverflowObserver = null;
}
if(this.tagOverflowObserver)
{
this.updateComplete.then(() =>
{
for(const tag of Array.from(this.shadowRoot.querySelectorAll(".email__combobox et2-email-tag")))
{
this.tagOverflowObserver.observe(tag);
}
});
}
}
/** /**
* Create an entry that is not in the suggestions and add it to the value * Create an entry that is not in the suggestions and add it to the value
* *
@ -667,6 +713,56 @@ export class Et2Email extends Et2InputWidget(LitElement) implements SearchMixinI
} }
/**
* 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);
}
/** /**
* Sometimes users paste multiple comma separated values at once. Split them then handle normally. * Sometimes users paste multiple comma separated values at once. Split them then handle normally.
* Overridden here to handle email addresses that may have commas using the regex from the validator. * Overridden here to handle email addresses that may have commas using the regex from the validator.
@ -895,6 +991,36 @@ export class Et2Email extends Et2InputWidget(LitElement) implements SearchMixinI
} }
} }
/**
* 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.rows == "1" && this.value.length > 1)
{
e.stopPropagation();
// Bind to turn this all off
this.addEventListener("mouseleave", this.handleMouseLeave);
this.classList.add("hover");
this.requestUpdate();
}
}
/**
* If we're showing all rows because of _handleMouseEnter, reset when mouse leaves
* @param {MouseEvent} e
* @private
*/
protected handleMouseLeave(e : MouseEvent)
{
this.classList.remove("hover");
this.requestUpdate();
}
/** /**
* Keyboard events from the suggestion list * Keyboard events from the suggestion list
* *
@ -1013,6 +1139,31 @@ export class Et2Email extends Et2InputWidget(LitElement) implements SearchMixinI
this.dispatchEvent(new Event("change", {bubbles: true})); this.dispatchEvent(new Event("change", {bubbles: true}));
} }
/* Sub-template when [readonly][rows=1] to show all tags in current value in popup */
readonlyHoverTemplate()
{
if(!this.classList.contains("hover"))
{
return nothing;
}
// Offset distance to open _over_ the rest
let distance = (-1 * parseInt(getComputedStyle(this).height));
return html`
<sl-popup
active
anchor=${this}
auto-size="both"
class="hover__popup details hoist details__body"
distance=${distance}
placement="bottom"
sync="width"
>
${this.tagsTemplate()}
</sl-popup>
`;
}
tagsTemplate() tagsTemplate()
{ {
return html`${keyed(this._valueUID, map(this.value, (value, index) => this.tagTemplate(value)))}`; return html`${keyed(this._valueUID, map(this.value, (value, index) => this.tagTemplate(value)))}`;
@ -1045,6 +1196,21 @@ export class Et2Email extends Et2InputWidget(LitElement) implements SearchMixinI
</et2-email-tag>`; </et2-email-tag>`;
} }
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>`;
}
inputTemplate() inputTemplate()
{ {
return html` return html`
@ -1110,6 +1276,13 @@ export class Et2Email extends Et2InputWidget(LitElement) implements SearchMixinI
const hasHelpText = this.helpText ? true : !!hasHelpTextSlot; const hasHelpText = this.helpText ? true : !!hasHelpTextSlot;
const isPlaceholderVisible = this.placeholder && this.value.length === 0 && !this.disabled && !this.readonly; const isPlaceholderVisible = this.placeholder && this.value.length === 0 && !this.disabled && !this.readonly;
let styles = {};
if(this.rows !== 0)
{
styles["--height"] = this.rows;
}
// TODO Don't forget required & disabled // TODO Don't forget required & disabled
return html` return html`
@ -1121,7 +1294,9 @@ export class Et2Email extends Et2InputWidget(LitElement) implements SearchMixinI
'form-control--has-label': hasLabel, 'form-control--has-label': hasLabel,
'form-control--has-help-text': hasHelpText 'form-control--has-help-text': hasHelpText
})} })}
style=${styleMap(styles)}
@click=${this.handleLabelClick} @click=${this.handleLabelClick}
@mouseenter=${this.handleMouseEnter}
@mousedown=${() => @mousedown=${() =>
{ {
if(!this.hasFocus) if(!this.hasFocus)
@ -1141,6 +1316,7 @@ export class Et2Email extends Et2InputWidget(LitElement) implements SearchMixinI
<slot name="label">${this.label}</slot> <slot name="label">${this.label}</slot>
</label> </label>
<div part="form-control-input" class="form-control-input"> <div part="form-control-input" class="form-control-input">
${this.readonlyHoverTemplate()}
<sl-popup <sl-popup
class=${classMap({ class=${classMap({
email: true, email: true,
@ -1171,6 +1347,7 @@ export class Et2Email extends Et2InputWidget(LitElement) implements SearchMixinI
<slot part="prefix" name="prefix" class="email__prefix"></slot> <slot part="prefix" name="prefix" class="email__prefix"></slot>
${this.tagsTemplate()} ${this.tagsTemplate()}
${this.inputTemplate()} ${this.inputTemplate()}
${this.tagLimitTemplate()}
${this.searching ? html` ${this.searching ? html`
<sl-spinner class="email__loading"></sl-spinner>` : nothing} <sl-spinner class="email__loading"></sl-spinner>` : nothing}
<slot part="suffix" name="suffix" class="email__suffix"></slot> <slot part="suffix" name="suffix" class="email__suffix"></slot>

View File

@ -90,7 +90,7 @@ export function inputBasicTests(before : Function, test_value : string, value_se
// Shows as empty / no value // Shows as empty / no value
let value = (<Element><unknown>element).querySelector(value_selector) || (<Element><unknown>element).shadowRoot.querySelector(value_selector); let value = (<Element><unknown>element).querySelector(value_selector) || (<Element><unknown>element).shadowRoot.querySelector(value_selector);
assert.isDefined(value, "Bad value selector '" + value_selector + "'"); assert.isDefined(value, "Bad value selector '" + value_selector + "'");
debugger;
assert.equal(value.textContent.trim(), "", "Displaying something when there is no value"); assert.equal(value.textContent.trim(), "", "Displaying something when there is no value");
if(element.multiple) if(element.multiple)
{ {
@ -104,7 +104,7 @@ export function inputBasicTests(before : Function, test_value : string, value_se
it("value out matches value in", async() => it("value out matches value in", async() =>
{ {
element.set_value(test_value); element.set_value(test_value);
debugger;
// wait for asychronous changes to the DOM // wait for asychronous changes to the DOM
await elementUpdated(<Element><unknown>element); await elementUpdated(<Element><unknown>element);

View File

@ -30,28 +30,24 @@
<et2-vbox class="addresses"> <et2-vbox class="addresses">
<et2-hbox> <et2-hbox>
<et2-description value="From" class="firstColumnTitle"></et2-description> <et2-description value="From" class="firstColumnTitle"></et2-description>
<et2-select-email id="additionalfromaddress" readonly="true" <et2-email id="additionalfromaddress" readonly="true"
fullEmail="@emailTag=fullemail" onlyEmail="@emailTag=onlyemail" multiple="true" onclick="app.mail.onclickCompose"></et2-email>
onclick="app.mail.onclickCompose"></et2-select-email>
<et2-date-time align="right" id="date" readonly="true"></et2-date-time> <et2-date-time align="right" id="date" readonly="true"></et2-date-time>
</et2-hbox> </et2-hbox>
<et2-hbox disabled="!@toaddress" width="100%"> <et2-hbox disabled="!@toaddress" width="100%">
<et2-description value="To" class="firstColumnTitle"></et2-description> <et2-description value="To" class="firstColumnTitle"></et2-description>
<et2-select-email id="additionaltoaddress" readonly="true" multiple="true" <et2-email id="additionaltoaddress" readonly="true" rows="1"
rows="1" fullEmail="@emailTag=fullemail" onlyEmail="@emailTag=onlyemail" onTagClick="app.mail.onclickCompose"></et2-email>
onTagClick="app.mail.onclickCompose"></et2-select-email>
</et2-hbox> </et2-hbox>
<et2-hbox disabled="!@ccaddress" width="100%"> <et2-hbox disabled="!@ccaddress" width="100%">
<et2-description value="Cc" class="firstColumnTitle"></et2-description> <et2-description value="Cc" class="firstColumnTitle"></et2-description>
<et2-select-email id="ccaddress" readonly="true" multiple="true" <et2-email id="ccaddress" readonly="true" rows="1"
rows="1" fullEmail="@emailTag=fullemail" onlyEmail="@emailTag=onlyemail" onTagClick="app.mail.onclickCompose"></et2-email>
onTagClick="app.mail.onclickCompose"></et2-select-email>
</et2-hbox> </et2-hbox>
<et2-hbox disabled="!@bccaddress" width="100%"> <et2-hbox disabled="!@bccaddress" width="100%">
<et2-description value="Bcc" class="firstColumnTitle"></et2-description> <et2-description value="Bcc" class="firstColumnTitle"></et2-description>
<et2-select-email id="bccaddress" readonly="true" multiple="true" <et2-email id="bccaddress" readonly="true" rows="1"
rows="1" fullEmail="@emailTag=fullemail" onlyEmail="@emailTag=onlyemail" onTagClick="app.mail.onclickCompose"></et2-email>
onTagClick="app.mail.onclickCompose"></et2-select-email>
</et2-hbox> </et2-hbox>
<et2-hbox width="100%" disabled="!@attachmentsBlock"> <et2-hbox width="100%" disabled="!@attachmentsBlock">
<et2-description value="Attachments" class="firstColumnTitle"></et2-description> <et2-description value="Attachments" class="firstColumnTitle"></et2-description>