Implement searching in Et2TreeDropdown

This commit is contained in:
nathan 2024-02-22 14:33:22 -07:00
parent 3b823bd9ed
commit b0e8666ecb
5 changed files with 181 additions and 91 deletions

View File

@ -146,6 +146,14 @@ export default css`
order: 20; order: 20;
} }
.search__results {
display: none;
}
.tree-dropdown--searching .search__results {
display: initial;;
}
/* Tree */ /* Tree */
sl-popup::part(popup) { sl-popup::part(popup) {
@ -170,4 +178,8 @@ export default css`
et2-tree::part(checkbox) { et2-tree::part(checkbox) {
display: none; display: none;
} }
.tree-dropdown--searching et2-tree {
display: none;
}
`; `;

View File

@ -11,6 +11,15 @@ import {SlPopup, SlRemoveEvent} from "@shoelace-style/shoelace";
import shoelace from "../Styles/shoelace"; import shoelace from "../Styles/shoelace";
import styles from "./Et2TreeDropdown.styles"; import styles from "./Et2TreeDropdown.styles";
import {Et2Tag} from "../Et2Select/Tag/Et2Tag"; import {Et2Tag} from "../Et2Select/Tag/Et2Tag";
import {SearchMixin, SearchResult, SearchResultsInterface} from "../Et2Widget/SearchMixin";
import {Et2InputWidgetInterface} from "../Et2InputWidget/Et2InputWidget";
interface TreeSearchResult extends SearchResultsInterface<SearchResult>
{
}
type Constructor<T = {}> = new (...args : any[]) => T;
/** /**
* @summary A tree that is hidden in a dropdown * @summary A tree that is hidden in a dropdown
@ -34,14 +43,14 @@ import {Et2Tag} from "../Et2Select/Tag/Et2Tag";
* @csspart form-control - The form control that wraps the label, input, and help text. * @csspart form-control - The form control that wraps the label, input, and help text.
*/ */
export class Et2TreeDropdown extends Et2WidgetWithSelectMixin(LitElement) export class Et2TreeDropdown extends SearchMixin<Constructor<any> & Et2InputWidgetInterface & typeof LitElement, SearchResult, TreeSearchResult>(Et2WidgetWithSelectMixin(LitElement))
{ {
static get styles() static get styles()
{ {
return [ return [
shoelace, shoelace,
...super.styles, super.styles,
styles styles
]; ];
} }
@ -60,23 +69,19 @@ export class Et2TreeDropdown extends Et2WidgetWithSelectMixin(LitElement)
*/ */
@property({type: Boolean, reflect: true}) open = false; @property({type: Boolean, reflect: true}) open = false;
@state() searching = false;
@state() hasFocus = false;
@state() currentTag : Et2Tag; @state() currentTag : Et2Tag;
// We show search results in the same dropdown
@state() treeOrSearch : "tree" | "search" = "tree";
private get _popup() : SlPopup { return this.shadowRoot.querySelector("sl-popup")} private get _popup() : SlPopup { return this.shadowRoot.querySelector("sl-popup")}
private get _tree() : Et2Tree { return this.shadowRoot.querySelector("et2-tree")} private get _tree() : Et2Tree { return this.shadowRoot.querySelector("et2-tree")}
private get _search() : HTMLInputElement { return this.shadowRoot.querySelector("#search")}
private get _tags() : Et2Tag[] { return Array.from(this.shadowRoot.querySelectorAll("et2-tag"));} private get _tags() : Et2Tag[] { return Array.from(this.shadowRoot.querySelectorAll("et2-tag"));}
protected readonly hasSlotController = new HasSlotController(this, "help-text", "label"); protected readonly hasSlotController = new HasSlotController(<LitElement><unknown>this, "help-text", "label");
private __value : string[]; private __value : string[];
protected _searchTimeout : number;
protected _searchPromise : Promise<TreeItemData[]> = Promise.resolve([]);
constructor() constructor()
{ {
super(); super();
@ -93,8 +98,8 @@ export class Et2TreeDropdown extends Et2WidgetWithSelectMixin(LitElement)
new_value = new_value.split(",") new_value = new_value.split(",")
} }
const oldValue = this.__value; const oldValue = this.__value;
// Filter to make sure there are no trailing commas // Filter to make sure there are no trailing commas or duplicates
this.__value = <string[]>new_value.filter(v => v); this.__value = Array.from(new Set(<string[]>new_value.filter(v => v)));
this.requestUpdate("value", oldValue); this.requestUpdate("value", oldValue);
} }
@ -112,9 +117,9 @@ export class Et2TreeDropdown extends Et2WidgetWithSelectMixin(LitElement)
// Should not be needed, but not firing the update // Should not be needed, but not firing the update
this.requestUpdate("hasFocus"); this.requestUpdate("hasFocus");
if(this._search) if(this._searchNode)
{ {
this._search.focus(options); this._searchNode.focus(options);
} }
} }
@ -122,12 +127,13 @@ export class Et2TreeDropdown extends Et2WidgetWithSelectMixin(LitElement)
blur() blur()
{ {
this.open = false; this.open = false;
this.treeOrSearch = "tree";
this.hasFocus = false; this.hasFocus = false;
this._popup.active = false; this._popup.active = false;
// Should not be needed, but not firing the update // Should not be needed, but not firing the update
this.requestUpdate("open"); this.requestUpdate("open");
this.requestUpdate("hasFocus"); this.requestUpdate("hasFocus");
this._search.blur(); this._searchNode.blur();
clearTimeout(this._searchTimeout); clearTimeout(this._searchTimeout);
} }
@ -158,6 +164,7 @@ export class Et2TreeDropdown extends Et2WidgetWithSelectMixin(LitElement)
this.open = false; this.open = false;
this._popup.active = false; this._popup.active = false;
this._searchNode.value = "";
this.requestUpdate("open"); this.requestUpdate("open");
return this.updateComplete return this.updateComplete
} }
@ -183,6 +190,37 @@ export class Et2TreeDropdown extends Et2WidgetWithSelectMixin(LitElement)
} }
} }
startSearch()
{
super.startSearch();
// Show the dropdown, that's where the results will go
this.show();
// Hide the tree
this.treeOrSearch = "search";
}
protected searchResultSelected()
{
super.searchResultSelected();
if(this.multiple && typeof this.value !== "undefined")
{
// Add in the new result(s)
(<string[]>this.value).splice(this.value.length, 0, ...this.selectedResults.map(el => el.value));
}
else if(typeof this.value !== "undefined")
{
// Just replace our value with whatever they chose
this.value = this.selectedResults[0]?.value ?? "";
}
// Done with search, show the tree
this.treeOrSearch = "tree";
}
/** /**
* Keyboard events that the search input did not grab * Keyboard events that the search input did not grab
* (tags, otion navigation) * (tags, otion navigation)
@ -219,7 +257,7 @@ export class Et2TreeDropdown extends Et2WidgetWithSelectMixin(LitElement)
else else
{ {
// Arrow back to search, or got lost // Arrow back to search, or got lost
this._search.focus(); this._searchNode.focus();
} }
event.stopPropagation(); event.stopPropagation();
return false; return false;
@ -237,7 +275,7 @@ export class Et2TreeDropdown extends Et2WidgetWithSelectMixin(LitElement)
} }
else else
{ {
this._search.focus(); this._searchNode.focus();
} }
} }
} }
@ -247,20 +285,20 @@ export class Et2TreeDropdown extends Et2WidgetWithSelectMixin(LitElement)
this.hasFocus = true; this.hasFocus = true;
// Should not be needed, but not firing the update // Should not be needed, but not firing the update
this.requestUpdate("hasFocus"); this.requestUpdate("hasFocus");
this.show(); this.hide();
// Reset tags to not take focus // Reset tags to not take focus
this.setCurrentTag(null); this.setCurrentTag(null);
this._search.setSelectionRange(this._search.value.length, this._search.value.length); this._searchNode.setSelectionRange(this._searchNode.value.length, this._searchNode.value.length);
} }
handleSearchKeyDown(event) handleSearchKeyDown(event)
{ {
clearTimeout(this._searchTimeout); super.handleSearchKeyDown(event);
// Left at beginning goes to tags // Left at beginning goes to tags
if(this._search.selectionStart == 0 && event.key == "ArrowLeft") if(this._searchNode.selectionStart == 0 && event.key == "ArrowLeft")
{ {
this.hide(); this.hide();
this._tags.forEach(t => t.tabIndex = 0); this._tags.forEach(t => t.tabIndex = 0);
@ -271,49 +309,12 @@ export class Et2TreeDropdown extends Et2WidgetWithSelectMixin(LitElement)
event.stopPropagation(); event.stopPropagation();
return; return;
} }
// Tab on empty leaves
if(this._search.value == "" && event.key == "Tab")
{
// Propagate, browser will do its thing
return;
}
// Up / Down navigates options
if(['ArrowDown', 'ArrowUp'].includes(event.key) && this._tree)
{
if(!this.open)
{
this.show();
}
event.stopPropagation();
this._tree.focus();
return;
}
// Start search immediately
else if(event.key == "Enter")
{
event.preventDefault();
this.startSearch();
return;
}
else if(event.key == "Escape")
{
this.hide();
event.stopPropagation();
return;
}
// Start the search automatically if they have enough letters
// -1 because we're in keyDown handler, and value is from _before_ this key was pressed
if(this._search.value.length - 1 > 0)
{
this._searchTimeout = window.setTimeout(() => {this.startSearch()}, 500);
}
} }
protected handleLabelClick() protected handleLabelClick()
{ {
this._search.focus(); this._searchNode.focus();
} }
handleTagRemove(event : SlRemoveEvent, value : string) handleTagRemove(event : SlRemoveEvent, value : string)
@ -380,12 +381,14 @@ export class Et2TreeDropdown extends Et2WidgetWithSelectMixin(LitElement)
if(this.open) if(this.open)
{ {
this._popup.active = false; this._popup.active = false;
this._searchNode.value = "";
} }
else else
{ {
this._popup.active = true; this._popup.active = true;
} }
this.open = this._popup.active; this.open = this._popup.active;
this.treeOrSearch = "tree";
this.requestUpdate("open"); this.requestUpdate("open");
this.updateComplete.then(() => this.updateComplete.then(() =>
{ {
@ -407,16 +410,14 @@ export class Et2TreeDropdown extends Et2WidgetWithSelectMixin(LitElement)
} }
return html` return html`
<et2-image slot="prefix" part="icon" style="width: var(--icon-width)" <et2-image slot="prefix" part="icon" src="${option.icon ?? option.im0}"></et2-image>`
src="${option.icon ?? option.im0}"></et2-image>`
} }
inputTemplate() inputTemplate()
{ {
return html` return html`
<input id="search" type="text" part="input" <input id="search" type="text" part="input"
class="tree-dropdown__search" class="tree-dropdown__search search__input"
exportparts="base:search__base"
autocomplete="off" autocomplete="off"
?disabled=${this.disabled} ?disabled=${this.disabled}
?readonly=${this.readonly} ?readonly=${this.readonly}
@ -473,7 +474,7 @@ export class Et2TreeDropdown extends Et2WidgetWithSelectMixin(LitElement)
tabindex="-1" tabindex="-1"
variant=${isValid ? nothing : "danger"} variant=${isValid ? nothing : "danger"}
size=${this.size || "medium"} size=${this.size || "medium"}
title=${option.path ?? option.title} title=${option.title}
?removable=${!readonly} ?removable=${!readonly}
?readonly=${readonly} ?readonly=${readonly}
?editable=${isEditable} ?editable=${isEditable}
@ -525,6 +526,7 @@ export class Et2TreeDropdown extends Et2WidgetWithSelectMixin(LitElement)
'tree-dropdown--readonly': this.readonly, 'tree-dropdown--readonly': this.readonly,
'tree-dropdown--focused': this.hasFocus, 'tree-dropdown--focused': this.hasFocus,
'tree-dropdown--placeholder-visible': isPlaceholderVisible, 'tree-dropdown--placeholder-visible': isPlaceholderVisible,
'tree-dropdown--searching': this.treeOrSearch == "search"
})} })}
flip flip
shift shift
@ -547,15 +549,13 @@ export class Et2TreeDropdown extends Et2WidgetWithSelectMixin(LitElement)
${this.tagsTemplate()} ${this.tagsTemplate()}
${this.inputTemplate()} ${this.inputTemplate()}
</div> </div>
${this.searching ? html`
<sl-spinner class="tree-dropdown"></sl-spinner>` : nothing
}
<slot part="suffix" name="suffix" class="tree-dropdown__suffix"></slot> <slot part="suffix" name="suffix" class="tree-dropdown__suffix"></slot>
<slot name="expand-icon" part="expand-icon" class="tree-dropdown__expand-icon" <slot name="expand-icon" part="expand-icon" class="tree-dropdown__expand-icon"
@click=${this.handleTriggerClick}> @click=${this.handleTriggerClick}>
<sl-icon library="system" name="chevron-down" aria-hidden="true"></sl-icon> <sl-icon library="system" name="chevron-down" aria-hidden="true"></sl-icon>
</slot> </slot>
</div> </div>
${this.searchResultsTemplate()}
<et2-tree <et2-tree
class="tree-dropdown__tree" class="tree-dropdown__tree"
?readonly=${this.readonly} ?readonly=${this.readonly}
@ -572,4 +572,5 @@ export class Et2TreeDropdown extends Et2WidgetWithSelectMixin(LitElement)
} }
} }
// @ts-ignore Type problems because of Et2WidgetWithSelectMixin
customElements.define("et2-tree-dropdown", Et2TreeDropdown); customElements.define("et2-tree-dropdown", Et2TreeDropdown);

View File

@ -4,17 +4,67 @@
* @returns {string} * @returns {string}
*/ */
import {literal, StaticValue} from "lit/static-html.js"; import {literal, StaticValue} from "lit/static-html.js";
import {property} from "lit/decorators/property.js";
import {PropertyValues} from "lit";
import {Et2TreeDropdown} from "./Et2TreeDropdown"; import {Et2TreeDropdown} from "./Et2TreeDropdown";
import {Et2CategoryTag} from "../Et2Select/Tag/Et2CategoryTag"; import {Et2CategoryTag} from "../Et2Select/Tag/Et2CategoryTag";
export class Et2TreeDropdownCategory extends Et2TreeDropdown export class Et2TreeDropdownCategory extends Et2TreeDropdown
{ {
/**
* Application to get categories from
*/
@property({type: String}) application = '';
/**
* Include global categories
*/
@property({type: Boolean}) globalCategories = true;
private keep_import : Et2CategoryTag private keep_import : Et2CategoryTag
constructor()
{
super();
this.searchUrl = "EGroupware\\Api\\Etemplate\\Widget\\Taglist::ajax_category_search";
}
connectedCallback()
{
super.connectedCallback();
// Default the application if not set
if(!this.application && this.getInstanceManager())
{
this.application = this.getInstanceManager().app;
}
// Set the search options from our properties
this.searchOptions.application = this.application;
this.searchOptions.globalCategories = this.globalCategories;
}
willUpdate(changedProperties : PropertyValues)
{
super.willUpdate(changedProperties);
if(changedProperties.has('application'))
{
this.searchOptions.application = this.application;
}
if(changedProperties.has('globalCategories'))
{
this.searchOptions.globalCategories = this.globalCategories;
}
}
public get tagTag() : StaticValue public get tagTag() : StaticValue
{ {
return literal`et2-category-tag`; return literal`et2-category-tag`;
} }
} }
// @ts-ignore Type problems because of Et2WidgetWithSelectMixin in parent
customElements.define("et2-tree-cat", Et2TreeDropdownCategory); customElements.define("et2-tree-cat", Et2TreeDropdownCategory);

View File

@ -128,6 +128,30 @@ class Taglist extends Etemplate\Widget
return mail_compose::ajax_searchAddress(); return mail_compose::ajax_searchAddress();
} }
/**
* @param $search
* @param array{application: string, globalCategories: boolean, num_rows: string} $options
* @return void
*/
public static function ajax_category_search($search, $options)
{
$results = ['results' => []];
$start = 0;
$categories = new Api\Categories('', $options['application'] ?: '');
foreach($categories->return_sorted_array($start, (int)$options['num_rows'], $search, 'ASC', 'cat_name', (boolean)$options['globalCategories'], false) as $cat)
{
$results['results'][] = Tree::formatCategory($cat, $categories);
}
$results['total'] = $categories->total_records;
// switch regular JSON response handling off
Api\Json\Request::isJSONRequest(false);
header('Content-Type: application/json; charset=utf-8');
echo json_encode($results);
exit;
}
/** /**
* Validate input * Validate input
* *

View File

@ -519,28 +519,7 @@ class Tree extends Etemplate\Widget
{ {
foreach((array)$categories->return_array($cat_id ? 'subs' : 'mains', 0, false, '', 'ASC', 'name', $globals, $cat_id) as $cat) foreach((array)$categories->return_array($cat_id ? 'subs' : 'mains', 0, false, '', 'ASC', 'name', $globals, $cat_id) as $cat)
{ {
$s = stripslashes($cat['name']); $category = static::formatCategory($cat, $categories);
if($cat['app_name'] == 'phpgw' || $cat['owner'] == '-1')
{
$s .= ' ♦';
}
// 1D array
$category = $cat + array(
'text' => $s,
'path' => $categories->id2name($cat['id'], 'path'),
/*
These ones to play nice when a user puts a tree & a selectbox with the same
ID on the form (addressbook edit):
if tree overwrites selectbox options, selectbox will still work
*/
'value' => $cat['id'],
'label' => $s,
'icon' => $cat['data']['icon'] ?? '',
'title' => $cat['description']
);
$cat_id_list[] = $cat['id']; $cat_id_list[] = $cat['id'];
if(!empty($cat['children'])) if(!empty($cat['children']))
{ {
@ -552,8 +531,32 @@ class Tree extends Etemplate\Widget
} }
} }
public static function ajaxSearch($search, $options)
{
public static function formatCategory($cat, &$categories_object)
{
$s = stripslashes($cat['name']);
if($cat['app_name'] == 'phpgw' || $cat['owner'] == '-1')
{
$s .= ' ♦';
}
// 1D array
$category = $cat + array(
// Legacy
'text' => $s,
'path' => $categories_object->id2name($cat['id'], 'path'),
//Client side search interface
'value' => $cat['id'],
'label' => $s,
'icon' => $cat['data']['icon'] ?? '',
'title' => $cat['description']
);
if(!empty($cat['children']))
{
$category['hasChildren'] = true;
}
return $category;
} }
} }