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:
nathan 2022-06-13 17:21:35 -06:00
parent a26b775505
commit a7874ecb63

View File

@ -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) =>