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;
}
.search__results {
display: none;
}
.tree-dropdown--searching .search__results {
display: initial;;
}
/* Tree */
sl-popup::part(popup) {
@ -170,4 +178,8 @@ export default css`
et2-tree::part(checkbox) {
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 styles from "./Et2TreeDropdown.styles";
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
@ -34,14 +43,14 @@ import {Et2Tag} from "../Et2Select/Tag/Et2Tag";
* @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()
{
return [
shoelace,
...super.styles,
super.styles,
styles
];
}
@ -60,23 +69,19 @@ export class Et2TreeDropdown extends Et2WidgetWithSelectMixin(LitElement)
*/
@property({type: Boolean, reflect: true}) open = false;
@state() searching = false;
@state() hasFocus = false;
@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 _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"));}
protected readonly hasSlotController = new HasSlotController(this, "help-text", "label");
protected readonly hasSlotController = new HasSlotController(<LitElement><unknown>this, "help-text", "label");
private __value : string[];
protected _searchTimeout : number;
protected _searchPromise : Promise<TreeItemData[]> = Promise.resolve([]);
constructor()
{
super();
@ -93,8 +98,8 @@ export class Et2TreeDropdown extends Et2WidgetWithSelectMixin(LitElement)
new_value = new_value.split(",")
}
const oldValue = this.__value;
// Filter to make sure there are no trailing commas
this.__value = <string[]>new_value.filter(v => v);
// Filter to make sure there are no trailing commas or duplicates
this.__value = Array.from(new Set(<string[]>new_value.filter(v => v)));
this.requestUpdate("value", oldValue);
}
@ -112,9 +117,9 @@ export class Et2TreeDropdown extends Et2WidgetWithSelectMixin(LitElement)
// Should not be needed, but not firing the update
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()
{
this.open = false;
this.treeOrSearch = "tree";
this.hasFocus = false;
this._popup.active = false;
// Should not be needed, but not firing the update
this.requestUpdate("open");
this.requestUpdate("hasFocus");
this._search.blur();
this._searchNode.blur();
clearTimeout(this._searchTimeout);
}
@ -158,6 +164,7 @@ export class Et2TreeDropdown extends Et2WidgetWithSelectMixin(LitElement)
this.open = false;
this._popup.active = false;
this._searchNode.value = "";
this.requestUpdate("open");
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
* (tags, otion navigation)
@ -219,7 +257,7 @@ export class Et2TreeDropdown extends Et2WidgetWithSelectMixin(LitElement)
else
{
// Arrow back to search, or got lost
this._search.focus();
this._searchNode.focus();
}
event.stopPropagation();
return false;
@ -237,7 +275,7 @@ export class Et2TreeDropdown extends Et2WidgetWithSelectMixin(LitElement)
}
else
{
this._search.focus();
this._searchNode.focus();
}
}
}
@ -247,20 +285,20 @@ export class Et2TreeDropdown extends Et2WidgetWithSelectMixin(LitElement)
this.hasFocus = true;
// Should not be needed, but not firing the update
this.requestUpdate("hasFocus");
this.show();
this.hide();
// Reset tags to not take focus
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)
{
clearTimeout(this._searchTimeout);
super.handleSearchKeyDown(event);
// 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._tags.forEach(t => t.tabIndex = 0);
@ -271,49 +309,12 @@ export class Et2TreeDropdown extends Et2WidgetWithSelectMixin(LitElement)
event.stopPropagation();
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()
{
this._search.focus();
this._searchNode.focus();
}
handleTagRemove(event : SlRemoveEvent, value : string)
@ -380,12 +381,14 @@ export class Et2TreeDropdown extends Et2WidgetWithSelectMixin(LitElement)
if(this.open)
{
this._popup.active = false;
this._searchNode.value = "";
}
else
{
this._popup.active = true;
}
this.open = this._popup.active;
this.treeOrSearch = "tree";
this.requestUpdate("open");
this.updateComplete.then(() =>
{
@ -407,16 +410,14 @@ export class Et2TreeDropdown extends Et2WidgetWithSelectMixin(LitElement)
}
return html`
<et2-image slot="prefix" part="icon" style="width: var(--icon-width)"
src="${option.icon ?? option.im0}"></et2-image>`
<et2-image slot="prefix" part="icon" src="${option.icon ?? option.im0}"></et2-image>`
}
inputTemplate()
{
return html`
<input id="search" type="text" part="input"
class="tree-dropdown__search"
exportparts="base:search__base"
class="tree-dropdown__search search__input"
autocomplete="off"
?disabled=${this.disabled}
?readonly=${this.readonly}
@ -473,7 +474,7 @@ export class Et2TreeDropdown extends Et2WidgetWithSelectMixin(LitElement)
tabindex="-1"
variant=${isValid ? nothing : "danger"}
size=${this.size || "medium"}
title=${option.path ?? option.title}
title=${option.title}
?removable=${!readonly}
?readonly=${readonly}
?editable=${isEditable}
@ -525,6 +526,7 @@ export class Et2TreeDropdown extends Et2WidgetWithSelectMixin(LitElement)
'tree-dropdown--readonly': this.readonly,
'tree-dropdown--focused': this.hasFocus,
'tree-dropdown--placeholder-visible': isPlaceholderVisible,
'tree-dropdown--searching': this.treeOrSearch == "search"
})}
flip
shift
@ -547,15 +549,13 @@ export class Et2TreeDropdown extends Et2WidgetWithSelectMixin(LitElement)
${this.tagsTemplate()}
${this.inputTemplate()}
</div>
${this.searching ? html`
<sl-spinner class="tree-dropdown"></sl-spinner>` : nothing
}
<slot part="suffix" name="suffix" class="tree-dropdown__suffix"></slot>
<slot name="expand-icon" part="expand-icon" class="tree-dropdown__expand-icon"
@click=${this.handleTriggerClick}>
<sl-icon library="system" name="chevron-down" aria-hidden="true"></sl-icon>
</slot>
</div>
${this.searchResultsTemplate()}
<et2-tree
class="tree-dropdown__tree"
?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);

View File

@ -4,17 +4,67 @@
* @returns {string}
*/
import {literal, StaticValue} from "lit/static-html.js";
import {property} from "lit/decorators/property.js";
import {PropertyValues} from "lit";
import {Et2TreeDropdown} from "./Et2TreeDropdown";
import {Et2CategoryTag} from "../Et2Select/Tag/Et2CategoryTag";
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
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
{
return literal`et2-category-tag`;
}
}
// @ts-ignore Type problems because of Et2WidgetWithSelectMixin in parent
customElements.define("et2-tree-cat", Et2TreeDropdownCategory);

View File

@ -128,6 +128,30 @@ class Taglist extends Etemplate\Widget
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
*

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)
{
$s = stripslashes($cat['name']);
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']
);
$category = static::formatCategory($cat, $categories);
$cat_id_list[] = $cat['id'];
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;
}
}