forked from extern/egroupware
Select / Search CSS:
- Keep tags visible while searching, adding or editing a free entry - hide selected options from dropdown - double-click to edit free entries
This commit is contained in:
parent
a26b775505
commit
a7874ecb63
@ -124,27 +124,61 @@ export const Et2WithSearchMixin = <T extends Constructor<LitElement>>(superclass
|
|||||||
:host([search]) ::slotted([slot="suffix"]) {
|
:host([search]) ::slotted([slot="suffix"]) {
|
||||||
display: initial;
|
display: initial;
|
||||||
}
|
}
|
||||||
|
/* Move the widget border */
|
||||||
|
.form-control-input {
|
||||||
|
border: solid var(--sl-input-border-width) var(--sl-input-border-color);
|
||||||
|
border-radius: var(--sl-input-border-radius-medium);
|
||||||
|
}
|
||||||
|
.select--standard .select__control {
|
||||||
|
border-style: none;
|
||||||
|
}
|
||||||
|
/* Move focus highlight */
|
||||||
|
.form-control-input:focus-within {
|
||||||
|
box-shadow: var(--sl-focus-ring);
|
||||||
|
}
|
||||||
|
.select--standard.select--focused:not(.select--disabled) .select__control {
|
||||||
|
box-shadow: initial;
|
||||||
|
}
|
||||||
:host([allowFreeEntries]) ::slotted([slot="suffix"]) {
|
:host([allowFreeEntries]) ::slotted([slot="suffix"]) {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Make search textbox take full width */
|
/* Make search textbox take full width */
|
||||||
::slotted([name="search_input"]:focus ){
|
::slotted(.search_input), ::slotted(.search_input) input, .search_input, .search_input input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
:host([search]) .select--open .select__prefix {
|
.search_input input {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
/* Show edit textbox only when editing */
|
||||||
|
.search_input #edit {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.search_input.editing #search {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.search_input.editing #edit {
|
||||||
|
display: initial;
|
||||||
|
}
|
||||||
|
:host([search]:not([multiple])) .select--open .select__prefix {
|
||||||
flex: 2 1 auto;
|
flex: 2 1 auto;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Search textbox general styling, starts hidden */
|
/* Search textbox general styling, starts hidden */
|
||||||
.select__prefix ::slotted(.search_input) {
|
.select__prefix ::slotted(.search_input),.search_input {
|
||||||
display: none;
|
display: none;
|
||||||
|
flex: 1 1 auto;
|
||||||
margin-left: 0px;
|
margin-left: 0px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
height: var(--sl-input-height-medium);
|
||||||
|
position: relative;
|
||||||
|
background-color: white;
|
||||||
}
|
}
|
||||||
/* Search UI active - show textbox & stuff */
|
/* Search UI active - show textbox & stuff */
|
||||||
::slotted(.search_input.active) {
|
::slotted(.search_input.active),.search_input.active,
|
||||||
|
.search_input.editing{
|
||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -180,6 +214,11 @@ export const Et2WithSearchMixin = <T extends Constructor<LitElement>>(superclass
|
|||||||
private _searchTimeout : number;
|
private _searchTimeout : number;
|
||||||
protected static SEARCH_TIMEOUT = 500;
|
protected static SEARCH_TIMEOUT = 500;
|
||||||
protected static MIN_CHARS = 2;
|
protected static MIN_CHARS = 2;
|
||||||
|
/**
|
||||||
|
* These characters will end a free tag
|
||||||
|
* @type {string[]}
|
||||||
|
*/
|
||||||
|
static TAG_BREAK : string[] = ["Tab", "Enter", ","];
|
||||||
|
|
||||||
constructor(...args : any[])
|
constructor(...args : any[])
|
||||||
{
|
{
|
||||||
@ -192,6 +231,10 @@ export const Et2WithSearchMixin = <T extends Constructor<LitElement>>(superclass
|
|||||||
this.allowFreeEntries = false;
|
this.allowFreeEntries = false;
|
||||||
this.editModeEnabled = false;
|
this.editModeEnabled = false;
|
||||||
|
|
||||||
|
// Hiding the selected options from the dropdown means we can't un-select the tags
|
||||||
|
// hidden by the max limit. Prefer no limit.
|
||||||
|
this.maxTagsVisible = -1;
|
||||||
|
|
||||||
this.validators = [];
|
this.validators = [];
|
||||||
/**
|
/**
|
||||||
* Used by Subclassers to add default Validators.
|
* Used by Subclassers to add default Validators.
|
||||||
@ -204,10 +247,12 @@ export const Et2WithSearchMixin = <T extends Constructor<LitElement>>(superclass
|
|||||||
*/
|
*/
|
||||||
this.defaultValidators = [];
|
this.defaultValidators = [];
|
||||||
|
|
||||||
|
this._handleSelect = this._handleSelect.bind(this);
|
||||||
this._handleSearchButtonClick = this._handleSearchButtonClick.bind(this);
|
this._handleSearchButtonClick = this._handleSearchButtonClick.bind(this);
|
||||||
|
this._handleDoubleClick = this._handleDoubleClick.bind(this);
|
||||||
this._handleSearchAbort = this._handleSearchAbort.bind(this);
|
this._handleSearchAbort = this._handleSearchAbort.bind(this);
|
||||||
this._handleSearchKeyDown = this._handleSearchKeyDown.bind(this);
|
this._handleSearchKeyDown = this._handleSearchKeyDown.bind(this);
|
||||||
this.handleTagInteraction = this.handleTagInteraction.bind(this);
|
this._handleEditKeyDown = this._handleEditKeyDown.bind(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
connectedCallback()
|
connectedCallback()
|
||||||
@ -244,26 +289,46 @@ export const Et2WithSearchMixin = <T extends Constructor<LitElement>>(superclass
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Add the nodes we need to search
|
* Add the nodes we need to search - adjust parent shadowDOM
|
||||||
*
|
*
|
||||||
* @protected
|
* @protected
|
||||||
*
|
|
||||||
* NB: Not sure which is the best way yet, SlotMixin or using render()
|
|
||||||
*/
|
*/
|
||||||
protected _addNodes()
|
protected _addNodes()
|
||||||
{
|
{
|
||||||
const div = document.createElement("div");
|
const div = document.createElement("div");
|
||||||
div.classList.add("search_input");
|
div.classList.add("search_input");
|
||||||
div.slot = "prefix";
|
|
||||||
render(this._searchInputTemplate(), div);
|
render(this._searchInputTemplate(), div);
|
||||||
this.appendChild(div);
|
if(!super.multiple)
|
||||||
|
{
|
||||||
|
div.slot = "prefix";
|
||||||
|
this.appendChild(div);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
super.updateComplete.then(() =>
|
||||||
|
{
|
||||||
|
let control = this.shadowRoot.querySelector(".form-control-input");
|
||||||
|
control.append(div);
|
||||||
|
|
||||||
|
// Move menu down to make space for search
|
||||||
|
this.dropdown.setAttribute("distance", parseInt(getComputedStyle(div).getPropertyValue("--sl-input-height-medium")));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
protected _searchInputTemplate()
|
protected _searchInputTemplate()
|
||||||
{
|
{
|
||||||
|
let edit = '';
|
||||||
|
if(this.editModeEnabled)
|
||||||
|
{
|
||||||
|
edit = html`<input id="edit" type="text" part="input"
|
||||||
|
@keydown=${this._handleEditKeyDown}
|
||||||
|
@blur=${this.stopEdit.bind(this)}
|
||||||
|
/>`;
|
||||||
|
}
|
||||||
// I can't figure out how to get this full width via CSS
|
// I can't figure out how to get this full width via CSS
|
||||||
return html`
|
return html`
|
||||||
<input type="text" part="input" style="width:100%" @keydown=${this._handleSearchKeyDown}/>
|
<input id="search" type="text" part="input" @keydown=${this._handleSearchKeyDown}/>
|
||||||
|
${edit}
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -285,12 +350,19 @@ export const Et2WithSearchMixin = <T extends Constructor<LitElement>>(superclass
|
|||||||
|
|
||||||
protected get _searchInputNode()
|
protected get _searchInputNode()
|
||||||
{
|
{
|
||||||
return this.querySelector(".search_input input");
|
return this._activeControls.querySelector("input#search");
|
||||||
|
}
|
||||||
|
|
||||||
|
protected get _editInputNode() : HTMLInputElement
|
||||||
|
{
|
||||||
|
return this._activeControls.querySelector("input#edit");
|
||||||
}
|
}
|
||||||
|
|
||||||
protected get _activeControls()
|
protected get _activeControls()
|
||||||
{
|
{
|
||||||
return this.querySelector(".search_input");
|
return this.multiple ?
|
||||||
|
this.shadowRoot.querySelector(".search_input") :
|
||||||
|
this.querySelector(".search_input");
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -355,6 +427,7 @@ export const Et2WithSearchMixin = <T extends Constructor<LitElement>>(superclass
|
|||||||
protected _bindListeners()
|
protected _bindListeners()
|
||||||
{
|
{
|
||||||
this.addEventListener("sl-blur", this._handleSearchAbort);
|
this.addEventListener("sl-blur", this._handleSearchAbort);
|
||||||
|
this.addEventListener("sl-select", this._handleSelect);
|
||||||
if(this._oldChange)
|
if(this._oldChange)
|
||||||
{
|
{
|
||||||
// Search messes up event order somehow, selecting an option fires the change event before
|
// Search messes up event order somehow, selecting an option fires the change event before
|
||||||
@ -382,10 +455,6 @@ export const Et2WithSearchMixin = <T extends Constructor<LitElement>>(superclass
|
|||||||
this._activeControls?.classList.add("active");
|
this._activeControls?.classList.add("active");
|
||||||
this._searchInputNode.focus();
|
this._searchInputNode.focus();
|
||||||
this._searchInputNode.select();
|
this._searchInputNode.select();
|
||||||
|
|
||||||
// Hide the label for the currently selected value - it shows as checked in list
|
|
||||||
// and we want the space
|
|
||||||
this.shadowRoot.querySelector("[part='display-label']").style.display = "none";
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -395,39 +464,35 @@ export const Et2WithSearchMixin = <T extends Constructor<LitElement>>(superclass
|
|||||||
if(this.searchEnabled)
|
if(this.searchEnabled)
|
||||||
{
|
{
|
||||||
this._activeControls?.classList.remove("active");
|
this._activeControls?.classList.remove("active");
|
||||||
|
|
||||||
// Restore selected value visibility
|
|
||||||
this.shadowRoot.querySelector("[part='display-label']").style.display = "";
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
handleTagInteraction(event : KeyboardEvent | MouseEvent)
|
_handleDoubleClick(event : MouseEvent)
|
||||||
{
|
{
|
||||||
let result = super.handleTagInteraction(event);
|
// No edit (shouldn't happen...)
|
||||||
|
if(!this.editModeEnabled)
|
||||||
// Check if remove button was clicked
|
|
||||||
const path = event.composedPath();
|
|
||||||
const clearButton = path.find((el) =>
|
|
||||||
{
|
|
||||||
if(el instanceof HTMLElement)
|
|
||||||
{
|
|
||||||
const element = el as HTMLElement;
|
|
||||||
return element.classList.contains('tag__remove');
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
});
|
|
||||||
|
|
||||||
// No edit, or removed tag
|
|
||||||
if(!this.editModeEnabled || clearButton)
|
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find the tag
|
// Find the tag
|
||||||
|
const path = event.composedPath();
|
||||||
const tag = <Et2Tag>path.find((el) => el instanceof Et2Tag);
|
const tag = <Et2Tag>path.find((el) => el instanceof Et2Tag);
|
||||||
this.startEdit(tag);
|
this.startEdit(tag);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An option was selected
|
||||||
|
*/
|
||||||
|
_handleSelect(event)
|
||||||
|
{
|
||||||
|
// If they just chose one from the list, re-focus the search
|
||||||
|
if(this.multiple && this.searchEnabled)
|
||||||
|
{
|
||||||
|
this._searchInputNode.focus();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Value was cleared
|
* Value was cleared
|
||||||
*/
|
*/
|
||||||
@ -455,7 +520,7 @@ export const Et2WithSearchMixin = <T extends Constructor<LitElement>>(superclass
|
|||||||
|
|
||||||
// Don't allow event to bubble or it will interact with select
|
// Don't allow event to bubble or it will interact with select
|
||||||
event.stopImmediatePropagation();
|
event.stopImmediatePropagation();
|
||||||
if(["Tab", "Enter", ","].indexOf(event.key) && this.allowFreeEntries && this.createFreeEntry(this._searchInputNode.value))
|
if(Et2WidgetWithSearch.TAG_BREAK.indexOf(event.key) !== -1 && this.allowFreeEntries && this.createFreeEntry(this._searchInputNode.value))
|
||||||
{
|
{
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
this._searchInputNode.value = "";
|
this._searchInputNode.value = "";
|
||||||
@ -470,6 +535,10 @@ export const Et2WithSearchMixin = <T extends Constructor<LitElement>>(superclass
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
this.startSearch();
|
this.startSearch();
|
||||||
}
|
}
|
||||||
|
else if(event.key == "Escape")
|
||||||
|
{
|
||||||
|
this._handleSearchAbort(event);
|
||||||
|
}
|
||||||
|
|
||||||
// Start the search automatically if they have enough letters
|
// Start the search automatically if they have enough letters
|
||||||
clearTimeout(this._searchTimeout);
|
clearTimeout(this._searchTimeout);
|
||||||
@ -479,6 +548,22 @@ export const Et2WithSearchMixin = <T extends Constructor<LitElement>>(superclass
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected _handleEditKeyDown(event : KeyboardEvent)
|
||||||
|
{
|
||||||
|
if(event.key == "Enter" && this.allowFreeEntries)
|
||||||
|
{
|
||||||
|
event.preventDefault();
|
||||||
|
// Stop propagation, or parent key handler will add again
|
||||||
|
event.stopImmediatePropagation();
|
||||||
|
this.stopEdit();
|
||||||
|
}
|
||||||
|
// Abort edit, put original value back
|
||||||
|
else if(event.key == "Escape")
|
||||||
|
{
|
||||||
|
this.stopEdit(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Start searching
|
* Start searching
|
||||||
*
|
*
|
||||||
@ -631,7 +716,9 @@ export const Et2WithSearchMixin = <T extends Constructor<LitElement>>(superclass
|
|||||||
value: text,
|
value: text,
|
||||||
label: text
|
label: text
|
||||||
});
|
});
|
||||||
|
this.requestUpdate('select_options');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Make sure not to double-add
|
// Make sure not to double-add
|
||||||
if(this.multiple && this.value.indexOf(text) == -1)
|
if(this.multiple && this.value.indexOf(text) == -1)
|
||||||
{
|
{
|
||||||
@ -640,8 +727,21 @@ export const Et2WithSearchMixin = <T extends Constructor<LitElement>>(superclass
|
|||||||
else if(!this.multiple)
|
else if(!this.multiple)
|
||||||
{
|
{
|
||||||
this.value = text;
|
this.value = text;
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
this.requestUpdate('select_options');
|
|
||||||
|
// Once added to options, add to value / tags
|
||||||
|
this.updateComplete.then(() =>
|
||||||
|
{
|
||||||
|
this.menuItems.forEach(o =>
|
||||||
|
{
|
||||||
|
if(o.value == text)
|
||||||
|
{
|
||||||
|
o.dispatchEvent(new Event("click"));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this.syncItemsFromValue();
|
||||||
|
});
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -661,20 +761,43 @@ export const Et2WithSearchMixin = <T extends Constructor<LitElement>>(superclass
|
|||||||
return result.length == 0;
|
return result.length == 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start editing an existing (free) tag
|
||||||
|
*
|
||||||
|
* @param {Et2Tag} tag
|
||||||
|
*/
|
||||||
public startEdit(tag : Et2Tag)
|
public startEdit(tag : Et2Tag)
|
||||||
{
|
{
|
||||||
// Turn on edit UI
|
const tag_value = tag.textContent.trim();
|
||||||
this.handleMenuShow();
|
|
||||||
|
|
||||||
// but hide the menu
|
// hide the menu
|
||||||
this.updateComplete.then(() => this.dropdown.hide());
|
//this.dropdown.hide()
|
||||||
|
|
||||||
|
// Turn on edit UI
|
||||||
|
this._activeControls.classList.add("editing", "active");
|
||||||
|
|
||||||
// Pre-set value to tag value
|
// Pre-set value to tag value
|
||||||
this._searchInputNode.value = tag.textContent.trim();
|
this._editInputNode.value = tag_value
|
||||||
|
this._editInputNode.focus();
|
||||||
|
|
||||||
// Remove from value & DOM. If they finish the edit, the new one will be added.
|
// Remove from value & DOM. If they finish the edit, the new one will be added.
|
||||||
this.value = this.value.filter(v => v !== this._searchInputNode.value);
|
this.value = this.value.filter(v => v !== tag_value);
|
||||||
|
this.select_options = this.select_options.filter(v => v.value !== tag_value);
|
||||||
|
this.querySelector("[value='" + tag_value + "']").remove();
|
||||||
tag.remove();
|
tag.remove();
|
||||||
|
|
||||||
|
// If they abort the edit, they'll want the original back.
|
||||||
|
this._editInputNode.dataset.initial = tag_value;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected stopEdit(abort = false)
|
||||||
|
{
|
||||||
|
let value = abort ? this._editInputNode.dataset.initial : this._editInputNode.value;
|
||||||
|
|
||||||
|
this.createFreeEntry(value);
|
||||||
|
delete this._editInputNode.dataset.initial;
|
||||||
|
|
||||||
|
this._activeControls.classList.remove("editing", "active");
|
||||||
}
|
}
|
||||||
|
|
||||||
protected _handleSearchButtonClick(e)
|
protected _handleSearchButtonClick(e)
|
||||||
@ -735,6 +858,7 @@ export const Et2WithSearchMixin = <T extends Constructor<LitElement>>(superclass
|
|||||||
return html`
|
return html`
|
||||||
<et2-tag class="search_tag"
|
<et2-tag class="search_tag"
|
||||||
removable
|
removable
|
||||||
|
@dblclick=${this._handleDoubleClick}
|
||||||
@click=${this.handleTagInteraction}
|
@click=${this.handleTagInteraction}
|
||||||
@keydown=${this.handleTagInteraction}
|
@keydown=${this.handleTagInteraction}
|
||||||
@sl-remove=${(event) =>
|
@sl-remove=${(event) =>
|
||||||
|
Loading…
Reference in New Issue
Block a user