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
*
@ -5,22 +7,8 @@
* 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 {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 {css, html, LitElement, nothing, PropertyValues, TemplateResult} from "lit";
import {repeat} from "lit/directives/repeat.js";
@ -14,7 +14,7 @@ import {EgwActionObject} from "../../egw_action/EgwActionObject";
import {EgwAction} from "../../egw_action/EgwAction";
import {EgwDragDropShoelaceTree} from "../../egw_action/EgwDragDropShoelaceTree";
export type TreeItemData = {
export type TreeItemData = SelectOption & {
focused?: boolean;
// Has children, but they may not be provided in item
child: Boolean | 1,
@ -652,7 +652,7 @@ export class Et2Tree extends Et2WidgetWithSelectMixin(LitElement)
/*
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)
{
//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>
<span class="tree-item__label">${selectOption.text}</span>
${selectOption.item ? repeat(selectOption.item, this._optionTemplate) : nothing}
<span class="tree-item__label">${selectOption.label ?? selectOption.text}</span>
${selectOption.children ? repeat(selectOption.children, this._optionTemplate) : (selectOption.item ? repeat(selectOption.item, this._optionTemplate) : nothing)}
</sl-tree-item>`
}

View File

@ -1,6 +1,6 @@
import {LitElement, nothing} from "lit";
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 {property} from "lit/decorators/property.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";
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.
*/
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()
@ -89,6 +89,11 @@ export class Et2TreeDropdown extends SearchMixin<Constructor<any> & Et2InputWidg
this.__value = [];
}
updated()
{
// @ts-ignore Popup sometimes loses the anchor which breaks the sizing
this._popup.handleAnchorChange();
}
/** Selected tree leaves */
@property()
set value(new_value : string | string[])
@ -202,6 +207,22 @@ export class Et2TreeDropdown extends SearchMixin<Constructor<any> & Et2InputWidg
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()
{
super.searchResultSelected();
@ -219,6 +240,9 @@ export class Et2TreeDropdown extends SearchMixin<Constructor<any> & Et2InputWidg
// Done with search, show the 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) =>
{
// 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;
})}`;
}

View File

@ -27,6 +27,11 @@ export type SearchResult = {
// If a search is in progress, does this option match.
// Automatically changed.
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
@state() hasFocus = false;
// For keyboard navigation of search results
@state() currentResult : HTMLElement & SearchResultElement = null;
@state() currentResult : LitElement & SearchResultElement = null;
// Search result nodes marked as "selected"
@state() selectedResults : (HTMLElement & SearchResultElement)[] = [];
@ -209,7 +214,7 @@ export const SearchMixin = <T extends Constructor<Et2InputWidgetInterface &
// Element where we render the search results
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[])
{
@ -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.
*
* @param {string} search
* @param {object} options
* @param {object} searchOptions
* @returns {Promise<any[]>}
* @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[]>[],
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[]>
@ -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).
* Only one result may be "current" at a time.
*/
private setCurrentResult(result : HTMLElement & SearchResultElement | null)
private setCurrentResult(result : LitElement & SearchResultElement | null)
{
// Clear selection
this._resultNodes.forEach((el) =>

View File

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