From 54ec37994c2b3aa62faacb5a0d5434e8f0e8677e Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Wed, 21 Oct 2020 13:07:44 +0200 Subject: [PATCH] Improve performance of quick bar (#7359) Co-authored-by: Donnie --- src/common/search/search-input.ts | 10 +- src/common/string/filter/sequence-matching.ts | 1 + src/dialogs/quick-bar/ha-quick-bar.ts | 310 ++++++++++++------ src/resources/styles.ts | 1 + 4 files changed, 217 insertions(+), 105 deletions(-) diff --git a/src/common/search/search-input.ts b/src/common/search/search-input.ts index 0b0038b9af..97a52b1864 100644 --- a/src/common/search/search-input.ts +++ b/src/common/search/search-input.ts @@ -51,18 +51,14 @@ class SearchInput extends LitElement { @value-changed=${this._filterInputChanged} .noLabelFloat=${this.noLabelFloat} > - + + + ${this.filter && html` diff --git a/src/common/string/filter/sequence-matching.ts b/src/common/string/filter/sequence-matching.ts index e870ffe976..f8ea1f40cf 100644 --- a/src/common/string/filter/sequence-matching.ts +++ b/src/common/string/filter/sequence-matching.ts @@ -59,6 +59,7 @@ export const fuzzyFilterSort: FuzzyFilterSort = (filter, items) => { : fuzzySequentialMatch(filter, item.text); return item; }) + .filter((item) => item.score === undefined || item.score > 0) .sort(({ score: scoreA = 0 }, { score: scoreB = 0 }) => scoreA > scoreB ? -1 : scoreA < scoreB ? 1 : 0 ); diff --git a/src/dialogs/quick-bar/ha-quick-bar.ts b/src/dialogs/quick-bar/ha-quick-bar.ts index b7c099855c..2f204be372 100644 --- a/src/dialogs/quick-bar/ha-quick-bar.ts +++ b/src/dialogs/quick-bar/ha-quick-bar.ts @@ -1,8 +1,9 @@ -import "../../components/ha-circular-progress"; -import "../../components/ha-header-bar"; -import "@polymer/paper-input/paper-input"; -import "@material/mwc-list/mwc-list-item"; import "@material/mwc-list/mwc-list"; +import type { List } from "@material/mwc-list/mwc-list"; +import { SingleSelectedEvent } from "@material/mwc-list/mwc-list-foundation"; +import "@material/mwc-list/mwc-list-item"; +import type { ListItem } from "@material/mwc-list/mwc-list-item"; +import { mdiConsoleLine } from "@mdi/js"; import { css, customElement, @@ -10,25 +11,32 @@ import { internalProperty, LitElement, property, + PropertyValues, query, } from "lit-element"; +import { ifDefined } from "lit-html/directives/if-defined"; +import { styleMap } from "lit-html/directives/style-map"; +import { scroll } from "lit-virtualizer"; +import memoizeOne from "memoize-one"; +import { componentsWithService } from "../../common/config/components_with_service"; import { fireEvent } from "../../common/dom/fire_event"; -import "../../components/ha-dialog"; -import { haStyleDialog } from "../../resources/styles"; -import { HomeAssistant } from "../../types"; -import { PolymerChangedEvent } from "../../polymer-types"; +import { computeDomain } from "../../common/entity/compute_domain"; +import { computeStateName } from "../../common/entity/compute_state_name"; +import { domainIcon } from "../../common/entity/domain_icon"; +import "../../common/search/search-input"; +import { compare } from "../../common/string/compare"; import { fuzzyFilterSort, ScorableTextItem, } from "../../common/string/filter/sequence-matching"; -import { componentsWithService } from "../../common/config/components_with_service"; -import { domainIcon } from "../../common/entity/domain_icon"; -import { computeDomain } from "../../common/entity/compute_domain"; +import { debounce } from "../../common/util/debounce"; +import "../../components/ha-circular-progress"; +import "../../components/ha-dialog"; +import "../../components/ha-header-bar"; import { domainToName } from "../../data/integration"; +import { haStyleDialog } from "../../resources/styles"; +import { HomeAssistant } from "../../types"; import { QuickBarParams } from "./show-dialog-quick-bar"; -import { compare } from "../../common/string/compare"; -import { SingleSelectedEvent } from "@material/mwc-list/mwc-list-foundation"; -import { computeStateName } from "../../common/entity/compute_state_name"; interface QuickBarItem extends ScorableTextItem { icon: string; @@ -43,9 +51,9 @@ export class QuickBar extends LitElement { @internalProperty() private _entityItems: QuickBarItem[] = []; - @internalProperty() private _items: QuickBarItem[] = []; + @internalProperty() private _items?: QuickBarItem[] = []; - @internalProperty() private _itemFilter = ""; + @internalProperty() private _filter = ""; @internalProperty() private _opened = false; @@ -53,144 +61,214 @@ export class QuickBar extends LitElement { @internalProperty() private _commandTriggered = -1; - @internalProperty() private _activatedIndex = 0; + @internalProperty() private _done = false; - @query("paper-input", false) private _filterInputField?: HTMLElement; + @query("search-input", false) private _filterInputField?: HTMLElement; - @query("mwc-list-item", false) private _firstListItem?: HTMLElement; + private _focusSet = false; public async showDialog(params: QuickBarParams) { this._commandMode = params.commandMode || false; - this._opened = true; this._commandItems = this._generateCommandItems(); this._entityItems = this._generateEntityItems(); + this._opened = true; } public closeDialog() { this._opened = false; - this._itemFilter = ""; + this._done = false; + this._focusSet = false; + this._filter = ""; this._commandTriggered = -1; - this._resetActivatedIndex(); + this._items = []; fireEvent(this, "dialog-closed", { dialog: this.localName }); } + protected updated(changedProperties: PropertyValues) { + if ( + this._opened && + (changedProperties.has("_filter") || + changedProperties.has("_commandMode")) + ) { + this._setFilteredItems(); + } + } + protected render() { if (!this._opened) { return html``; } return html` - - + ${this._itemFilter}` : this._itemFilter} + .filter=${this._commandMode ? `>${this._filter}` : this._filter} @keydown=${this._handleInputKeyDown} - @focus=${this._resetActivatedIndex} - > - - ${this._items.map( - (item, index) => html` - - - ${item.text} - ${item.altText - ? html` - ${item.altText} - ` - : null} - ${this._commandTriggered === index - ? html`` - : null} - - ` - )} - + @focus=${this._setFocusFirstListItem} + > + ${this._commandMode + ? html`` + : ""} + + ${!this._items + ? html`` + : html` + ${scroll({ + items: this._items, + renderItem: (item: QuickBarItem, index?: number) => + this._renderItem(item, index), + })} + `} `; } - private async processItemAndCloseDialog(ev: SingleSelectedEvent) { - const index = ev.detail.index; - const item = (ev.target as any).items[index].item; + private _handleOpened() { + this._setFilteredItems(); + this.updateComplete.then(() => { + this._done = true; + }); + } + private async _handleRangeChanged(e) { + if (this._focusSet) { + return; + } + if (e.firstVisible > -1) { + this._focusSet = true; + await this.updateComplete; + this._setFocusFirstListItem(); + } + } + + private _renderItem(item: QuickBarItem, index?: number) { + return html` + + + ${item.text} + ${item.altText + ? html` + ${item.altText} + ` + : null} + ${this._commandTriggered === index + ? html`` + : null} + + `; + } + + private async processItemAndCloseDialog(item: QuickBarItem, index: number) { this._commandTriggered = index; await item.action(); this.closeDialog(); } - private _resetActivatedIndex() { - this._activatedIndex = 0; + private _handleSelected(ev: SingleSelectedEvent) { + const index = ev.detail.index; + const item = ((ev.target as List).items[index] as any).item; + this.processItemAndCloseDialog(item, index); } private _handleInputKeyDown(ev: KeyboardEvent) { if (ev.code === "Enter") { - this._firstListItem?.click(); + if (!this._items?.length) { + return; + } + + this.processItemAndCloseDialog(this._items[0], 0); } else if (ev.code === "ArrowDown") { ev.preventDefault(); - this._firstListItem?.focus(); + this._getItemAtIndex(0)?.focus(); + this._getItemAtIndex(1)?.focus(); } } + private _getItemAtIndex(index: number): ListItem | null { + return this.renderRoot.querySelector(`mwc-list-item[index="${index}"]`); + } + + private _handleSearchChange(ev: CustomEvent): void { + const newFilter = ev.detail.value; + const oldCommandMode = this._commandMode; + + if (newFilter.startsWith(">")) { + this._commandMode = true; + this._debouncedSetFilter(newFilter.substring(1)); + } else { + this._commandMode = false; + this._debouncedSetFilter(newFilter); + } + + if (oldCommandMode !== this._commandMode) { + this._items = undefined; + } + } + + private _debouncedSetFilter = debounce((filter: string) => { + this._filter = filter; + }, 100); + + private _setFocusFirstListItem() { + // @ts-ignore + this._getItemAtIndex(0)?.rippleHandlers.startFocus(); + } + private _handleListItemKeyDown(ev: KeyboardEvent) { const isSingleCharacter = ev.key.length === 1; - const isFirstListItem = (ev.target as any).index === 0; + const isFirstListItem = + (ev.target as HTMLElement).getAttribute("index") === "0"; if (ev.key === "ArrowUp") { if (isFirstListItem) { this._filterInputField?.focus(); - } else { - this._activatedIndex--; } - } else if (ev.key === "ArrowDown") { - this._activatedIndex++; } - if (ev.key === "Backspace" || isSingleCharacter) { + (ev.currentTarget as List).scrollTop = 0; this._filterInputField?.focus(); - this._resetActivatedIndex(); - } - } - - private _entityFilterChanged(ev: PolymerChangedEvent) { - const newFilter = ev.detail.value; - - if (newFilter.startsWith(">")) { - this._commandMode = true; - this._itemFilter = newFilter.substring(1); - } else { - this._commandMode = false; - this._itemFilter = newFilter; - } - - this._items = this._commandMode ? this._commandItems : this._entityItems; - - if (this._itemFilter !== "") { - this._items = fuzzyFilterSort( - this._itemFilter.trimLeft(), - this._items - ); } } @@ -227,12 +305,24 @@ export class QuickBar extends LitElement { .sort((a, b) => compare(a.text.toLowerCase(), b.text.toLowerCase())); } + private _setFilteredItems() { + const items = this._commandMode ? this._commandItems : this._entityItems; + this._items = this._filter + ? this._filterItems(items || [], this._filter) + : items; + } + + private _filterItems = memoizeOne( + (items: QuickBarItem[], filter: string): QuickBarItem[] => + fuzzyFilterSort(filter.trimLeft(), items) + ); + static get styles() { return [ haStyleDialog, css` .heading { - padding: 20px 20px 0px; + padding: 8px 20px 0px; } mwc-list-item span[slot="secondary"], @@ -242,7 +332,7 @@ export class QuickBar extends LitElement { ha-dialog { --dialog-z-index: 8; - --dialog-content-padding: 0px 24px 20px; + --dialog-content-padding: 0; } @media (min-width: 800px) { @@ -254,6 +344,30 @@ export class QuickBar extends LitElement { --mdc-dialog-max-height: calc(100% - 72px); } } + + ha-icon { + color: var(--secondary-text-color); + } + + ha-svg-icon.prefix { + margin: 8px; + } + + .uni-virtualizer-host { + display: block; + position: relative; + contain: strict; + overflow: auto; + height: 100%; + } + + .uni-virtualizer-host > * { + box-sizing: border-box; + } + + mwc-list-item { + width: 100%; + } `, ]; } diff --git a/src/resources/styles.ts b/src/resources/styles.ts index db838e3d8e..161c953a67 100644 --- a/src/resources/styles.ts +++ b/src/resources/styles.ts @@ -75,6 +75,7 @@ export const derivedStyles = { "mdc-theme-on-secondary": "var(--text-primary-color)", "mdc-theme-on-surface": "var(--primary-text-color)", "mdc-theme-text-primary-on-background": "var(--primary-text-color)", + "mdc-theme-text-secondary-on-background": "var(--secondary-text-color)", "app-header-text-color": "var(--text-primary-color)", "app-header-background-color": "var(--primary-color)", "material-body-text-color": "var(--primary-text-color)",