Implement local search in SearchMixin & for Et2TreeDropdown

Add ability for SearchResult to have children
This commit is contained in:
nathan 2024-02-23 10:49:16 -07:00
parent 5e32896ccd
commit a9a26ffe39
5 changed files with 71 additions and 34 deletions

View File

@ -1,3 +1,5 @@
import {SearchResult} from "../Et2Widget/SearchMixin";
/** /**
* Interface for select options * Interface for select options
* *
@ -5,22 +7,8 @@
* For option groups, value is the list of sub-options. * For option groups, value is the list of sub-options.
* *
*/ */
export interface SelectOption export interface SelectOption extends SearchResult
{ {
value : string | SelectOption[];
label : string;
// Hover help text
title? : string;
// Related image or icon
icon? : string;
// Class applied to node
class? : string;
// Show the option, but it is not selectable.
// If multiple=true and the option is in the value, it is not removable.
disabled? : boolean;
// If a search is in progress, does this option match.
// Automatically changed.
isMatch? : boolean;
} }
/** /**

View File

@ -1,6 +1,6 @@
import {SlTreeItem} from "@shoelace-style/shoelace"; import {SlTreeItem} from "@shoelace-style/shoelace";
import {egw} from "../../jsapi/egw_global"; import {egw} from "../../jsapi/egw_global";
import {find_select_options} from "../Et2Select/FindSelectOptions"; import {find_select_options, SelectOption} from "../Et2Select/FindSelectOptions";
import {Et2WidgetWithSelectMixin} from "../Et2Select/Et2WidgetWithSelectMixin"; import {Et2WidgetWithSelectMixin} from "../Et2Select/Et2WidgetWithSelectMixin";
import {css, html, LitElement, nothing, PropertyValues, TemplateResult} from "lit"; import {css, html, LitElement, nothing, PropertyValues, TemplateResult} from "lit";
import {repeat} from "lit/directives/repeat.js"; import {repeat} from "lit/directives/repeat.js";
@ -14,7 +14,7 @@ import {EgwActionObject} from "../../egw_action/EgwActionObject";
import {EgwAction} from "../../egw_action/EgwAction"; import {EgwAction} from "../../egw_action/EgwAction";
import {EgwDragDropShoelaceTree} from "../../egw_action/EgwDragDropShoelaceTree"; import {EgwDragDropShoelaceTree} from "../../egw_action/EgwDragDropShoelaceTree";
export type TreeItemData = { export type TreeItemData = SelectOption & {
focused?: boolean; focused?: boolean;
// Has children, but they may not be provided in item // Has children, but they may not be provided in item
child: Boolean | 1, child: Boolean | 1,
@ -652,7 +652,7 @@ export class Et2Tree extends Et2WidgetWithSelectMixin(LitElement)
/* /*
if collapsed .. opended? leaf? if collapsed .. opended? leaf?
*/ */
let img: String = selectOption.im0 ?? selectOption.im1 ?? selectOption.im2; let img : String = selectOption.icon ?? selectOption.im0 ?? selectOption.im1 ?? selectOption.im2;
if (img) if (img)
{ {
//sl-icon images need to be svgs if there is a png try to find the corresponding svg //sl-icon images need to be svgs if there is a png try to find the corresponding svg
@ -696,8 +696,8 @@ export class Et2Tree extends Et2WidgetWithSelectMixin(LitElement)
> >
<sl-icon src="${img ?? nothing}"></sl-icon> <sl-icon src="${img ?? nothing}"></sl-icon>
<span class="tree-item__label">${selectOption.text}</span> <span class="tree-item__label">${selectOption.label ?? selectOption.text}</span>
${selectOption.item ? repeat(selectOption.item, this._optionTemplate) : nothing} ${selectOption.children ? repeat(selectOption.children, this._optionTemplate) : (selectOption.item ? repeat(selectOption.item, this._optionTemplate) : nothing)}
</sl-tree-item>` </sl-tree-item>`
} }

View File

@ -1,6 +1,6 @@
import {LitElement, nothing} from "lit"; import {LitElement, nothing} from "lit";
import {html, literal, StaticValue} from "lit/static-html.js"; import {html, literal, StaticValue} from "lit/static-html.js";
import {Et2Tree, TreeItemData} from "./Et2Tree"; import {Et2Tree, TreeItemData, TreeSearchResult} from "./Et2Tree";
import {Et2WidgetWithSelectMixin} from "../Et2Select/Et2WidgetWithSelectMixin"; import {Et2WidgetWithSelectMixin} from "../Et2Select/Et2WidgetWithSelectMixin";
import {property} from "lit/decorators/property.js"; import {property} from "lit/decorators/property.js";
import {classMap} from "lit/directives/class-map.js"; import {classMap} from "lit/directives/class-map.js";
@ -15,7 +15,7 @@ import {SearchMixin, SearchResult, SearchResultsInterface} from "../Et2Widget/Se
import {Et2InputWidgetInterface} from "../Et2InputWidget/Et2InputWidget"; import {Et2InputWidgetInterface} from "../Et2InputWidget/Et2InputWidget";
interface TreeSearchResult extends SearchResultsInterface<SearchResult> interface TreeSearchResults extends SearchResultsInterface<TreeSearchResult>
{ {
} }
@ -43,7 +43,7 @@ type Constructor<T = {}> = new (...args : any[]) => T;
* @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 SearchMixin<Constructor<any> & Et2InputWidgetInterface & typeof LitElement, SearchResult, TreeSearchResult>(Et2WidgetWithSelectMixin(LitElement)) export class Et2TreeDropdown extends SearchMixin<Constructor<any> & Et2InputWidgetInterface & typeof LitElement, TreeSearchResult, TreeSearchResults>(Et2WidgetWithSelectMixin(LitElement))
{ {
static get styles() static get styles()
@ -89,6 +89,11 @@ export class Et2TreeDropdown extends SearchMixin<Constructor<any> & Et2InputWidg
this.__value = []; this.__value = [];
} }
updated()
{
// @ts-ignore Popup sometimes loses the anchor which breaks the sizing
this._popup.handleAnchorChange();
}
/** Selected tree leaves */ /** Selected tree leaves */
@property() @property()
set value(new_value : string | string[]) set value(new_value : string | string[])
@ -202,6 +207,22 @@ export class Et2TreeDropdown extends SearchMixin<Constructor<any> & Et2InputWidg
this.treeOrSearch = "search"; this.treeOrSearch = "search";
} }
/**
* If you have a local list of options, you can search through them on the client and include them in the results.
*
* This is done independently from the server-side search, and the results are merged.
*
* @param {string} search
* @param {object} options
* @returns {Promise<any[]>}
* @protected
*/
protected localSearch<DataType extends SearchResult>(search : string, searchOptions : object, localOptions : DataType[] = []) : Promise<DataType[]>
{
return super.localSearch(search, searchOptions, this.select_options);
}
protected searchResultSelected() protected searchResultSelected()
{ {
super.searchResultSelected(); super.searchResultSelected();
@ -219,6 +240,9 @@ export class Et2TreeDropdown extends SearchMixin<Constructor<any> & Et2InputWidg
// Done with search, show the tree // Done with search, show the tree
this.treeOrSearch = "tree"; this.treeOrSearch = "tree";
// Close the dropdown
this.hide();
this.requestUpdate("value");
} }
/** /**
@ -448,7 +472,7 @@ export class Et2TreeDropdown extends SearchMixin<Constructor<any> & Et2InputWidg
return html`${map(value, (value, index) => return html`${map(value, (value, index) =>
{ {
// Deal with value that is not in options // Deal with value that is not in options
const option = this.optionSearch(value, this.select_options, 'value', 'item'); const option = this.optionSearch(value, this.select_options, 'value', 'children');
return option ? this.tagTemplate(option) : nothing; return option ? this.tagTemplate(option) : nothing;
})}`; })}`;
} }

View File

@ -27,6 +27,11 @@ export type SearchResult = {
// If a search is in progress, does this option match. // If a search is in progress, does this option match.
// Automatically changed. // Automatically changed.
isMatch? : boolean; isMatch? : boolean;
// The item has children (option group)
hasChildren? : boolean,
// The item's children
children? : SearchResult[]
} }
/** /**
@ -191,7 +196,7 @@ export const SearchMixin = <T extends Constructor<Et2InputWidgetInterface &
// The component has the focus // The component has the focus
@state() hasFocus = false; @state() hasFocus = false;
// For keyboard navigation of search results // For keyboard navigation of search results
@state() currentResult : HTMLElement & SearchResultElement = null; @state() currentResult : LitElement & SearchResultElement = null;
// Search result nodes marked as "selected" // Search result nodes marked as "selected"
@state() selectedResults : (HTMLElement & SearchResultElement)[] = []; @state() selectedResults : (HTMLElement & SearchResultElement)[] = [];
@ -209,7 +214,7 @@ export const SearchMixin = <T extends Constructor<Et2InputWidgetInterface &
// Element where we render the search results // Element where we render the search results
protected get _listNode() : HTMLElement { return this.shadowRoot.querySelector("#listbox");} protected get _listNode() : HTMLElement { return this.shadowRoot.querySelector("#listbox");}
protected get _resultNodes() : (HTMLElement & SearchResultElement)[] { return this._listNode ? Array.from(this._listNode.querySelectorAll("*:not(div)")) : [];} protected get _resultNodes() : (LitElement & SearchResultElement)[] { return this._listNode ? Array.from(this._listNode.querySelectorAll("*:not(div)")) : [];}
constructor(...args : any[]) constructor(...args : any[])
{ {
@ -278,17 +283,38 @@ export const SearchMixin = <T extends Constructor<Et2InputWidgetInterface &
* This is done independently from the server-side search, and the results are merged. * This is done independently from the server-side search, and the results are merged.
* *
* @param {string} search * @param {string} search
* @param {object} options * @param {object} searchOptions
* @returns {Promise<any[]>} * @returns {Promise<any[]>}
* @protected * @protected
*/ */
protected localSearch<DataType>(search : string, options : object) : Promise<DataType[]> protected localSearch<DataType extends SearchResult>(search : string, searchOptions : object, localOptions : DataType[] = []) : Promise<DataType[]>
{ {
const nullResults : Results = <Results><unknown>{ const local : Results = <Results><unknown>{
results: <DataType[]>[], results: <DataType[]>[],
total: 0 total: 0
} }
return Promise.resolve(this.processLocalResults(nullResults)); let doSearch = function <DataType extends SearchResult>(options : DataType[], value : string)
{
options.forEach((option) =>
{
if(typeof option !== "object")
{
return;
}
if(option.label?.includes(value) || option.value?.includes(value))
{
local.results.push(option);
local.total++;
}
if(typeof option.children != "undefined" && Array.isArray(option.children))
{
return doSearch(option.children, value);
}
});
}
doSearch(localOptions, search);
return Promise.resolve(this.processLocalResults(local));
} }
protected remoteSearch<DataType>(search : string, options : object) : Promise<DataType[]> protected remoteSearch<DataType>(search : string, options : object) : Promise<DataType[]>
@ -375,7 +401,7 @@ export const SearchMixin = <T extends Constructor<Et2InputWidgetInterface &
* Sets the current search result, which is the one the user is currently interacting with (e.g. via keyboard). * Sets the current search result, which is the one the user is currently interacting with (e.g. via keyboard).
* Only one result may be "current" at a time. * Only one result may be "current" at a time.
*/ */
private setCurrentResult(result : HTMLElement & SearchResultElement | null) private setCurrentResult(result : LitElement & SearchResultElement | null)
{ {
// Clear selection // Clear selection
this._resultNodes.forEach((el) => this._resultNodes.forEach((el) =>

View File

@ -523,9 +523,8 @@ class Tree extends Etemplate\Widget
$cat_id_list[] = $cat['id']; $cat_id_list[] = $cat['id'];
if(!empty($cat['children'])) if(!empty($cat['children']))
{ {
$category['item'] = []; unset($category['children']);
unset($cat['children']); static::processCategory($cat['id'], $category['children'], $categories, $globals, $cat_id_list);
static::processCategory($cat['id'], $category['item'], $categories, $globals, $cat_id_list);
} }
$options[] = $category; $options[] = $category;
} }