Add device search to quick bar (#23095)

* Add device search to quick bar

* Process code review
This commit is contained in:
Jan-Philipp Benecke 2024-12-03 07:50:07 +01:00 committed by GitHub
parent c3942d244d
commit e731f060f1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 164 additions and 61 deletions

View File

@ -3,13 +3,14 @@ import type { ListItem } from "@material/mwc-list/mwc-list-item";
import { import {
mdiClose, mdiClose,
mdiConsoleLine, mdiConsoleLine,
mdiDevices,
mdiEarth, mdiEarth,
mdiMagnify, mdiMagnify,
mdiReload, mdiReload,
mdiServerNetwork, mdiServerNetwork,
} from "@mdi/js"; } from "@mdi/js";
import type { TemplateResult } from "lit"; import type { TemplateResult } from "lit";
import { LitElement, css, html, nothing } from "lit"; import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators"; import { customElement, property, query, state } from "lit/decorators";
import { ifDefined } from "lit/directives/if-defined"; import { ifDefined } from "lit/directives/if-defined";
import { styleMap } from "lit/directives/style-map"; import { styleMap } from "lit/directives/style-map";
@ -38,7 +39,7 @@ import { haStyleDialog, haStyleScrollbar } from "../../resources/styles";
import { loadVirtualizer } from "../../resources/virtualizer"; import { loadVirtualizer } from "../../resources/virtualizer";
import type { HomeAssistant } from "../../types"; import type { HomeAssistant } from "../../types";
import { showConfirmationDialog } from "../generic/show-dialog-box"; import { showConfirmationDialog } from "../generic/show-dialog-box";
import type { QuickBarParams } from "./show-dialog-quick-bar"; import { QuickBarMode, type QuickBarParams } from "./show-dialog-quick-bar";
interface QuickBarItem extends ScorableTextItem { interface QuickBarItem extends ScorableTextItem {
primaryText: string; primaryText: string;
@ -56,9 +57,17 @@ interface EntityItem extends QuickBarItem {
icon?: TemplateResult; icon?: TemplateResult;
} }
interface DeviceItem extends QuickBarItem {
deviceId: string;
area?: string;
}
const isCommandItem = (item: QuickBarItem): item is CommandItem => const isCommandItem = (item: QuickBarItem): item is CommandItem =>
(item as CommandItem).categoryKey !== undefined; (item as CommandItem).categoryKey !== undefined;
const isDeviceItem = (item: QuickBarItem): item is DeviceItem =>
(item as DeviceItem).deviceId !== undefined;
interface QuickBarNavigationItem extends CommandItem { interface QuickBarNavigationItem extends CommandItem {
path: string; path: string;
} }
@ -77,20 +86,22 @@ export class QuickBar extends LitElement {
@state() private _entityItems?: EntityItem[]; @state() private _entityItems?: EntityItem[];
@state() private _deviceItems?: DeviceItem[];
@state() private _filter = ""; @state() private _filter = "";
@state() private _search = ""; @state() private _search = "";
@state() private _open = false; @state() private _open = false;
@state() private _commandMode = false;
@state() private _opened = false; @state() private _opened = false;
@state() private _narrow = false; @state() private _narrow = false;
@state() private _hint?: string; @state() private _hint?: string;
@state() private _mode = QuickBarMode.Entity;
@query("ha-textfield", false) private _filterInputField?: HTMLElement; @query("ha-textfield", false) private _filterInputField?: HTMLElement;
private _focusSet = false; private _focusSet = false;
@ -98,7 +109,7 @@ export class QuickBar extends LitElement {
private _focusListElement?: ListItem | null; private _focusListElement?: ListItem | null;
public async showDialog(params: QuickBarParams) { public async showDialog(params: QuickBarParams) {
this._commandMode = params.commandMode || this._toggleIfAlreadyOpened(); this._mode = params.mode || QuickBarMode.Entity;
this._hint = params.hint; this._hint = params.hint;
this._narrow = matchMedia( this._narrow = matchMedia(
"all and (max-width: 450px), all and (max-height: 500px)" "all and (max-width: 450px), all and (max-height: 500px)"
@ -125,8 +136,20 @@ export class QuickBar extends LitElement {
} }
private _getItems = memoizeOne( private _getItems = memoizeOne(
(commandMode: boolean, commandItems, entityItems, filter: string) => { (
const items = commandMode ? commandItems : entityItems; mode: QuickBarMode,
commandItems,
entityItems,
deviceItems,
filter: string
) => {
let items = entityItems;
if (mode === QuickBarMode.Command) {
items = commandItems;
} else if (mode === QuickBarMode.Device) {
items = deviceItems;
}
if (items && filter && filter !== " ") { if (items && filter && filter !== " ") {
return this._filterItems(items, filter); return this._filterItems(items, filter);
@ -141,12 +164,28 @@ export class QuickBar extends LitElement {
} }
const items: QuickBarItem[] | undefined = this._getItems( const items: QuickBarItem[] | undefined = this._getItems(
this._commandMode, this._mode,
this._commandItems, this._commandItems,
this._entityItems, this._entityItems,
this._deviceItems,
this._filter this._filter
); );
const translationKey =
this._mode === QuickBarMode.Device ? "devices" : "entities";
const placeholder = this.hass.localize(
`ui.dialogs.quick-bar.filter_placeholder.${translationKey}`
);
const commandMode = this._mode === QuickBarMode.Command;
const deviceMode = this._mode === QuickBarMode.Device;
const icon = commandMode
? mdiConsoleLine
: deviceMode
? mdiDevices
: mdiMagnify;
const searchPrefix = commandMode ? ">" : deviceMode ? "#" : "";
return html` return html`
<ha-dialog <ha-dialog
.heading=${this.hass.localize("ui.dialogs.quick-bar.title")} .heading=${this.hass.localize("ui.dialogs.quick-bar.title")}
@ -158,34 +197,20 @@ export class QuickBar extends LitElement {
<div slot="heading" class="heading"> <div slot="heading" class="heading">
<ha-textfield <ha-textfield
dialogInitialFocus dialogInitialFocus
.placeholder=${this.hass.localize( .placeholder=${placeholder}
"ui.dialogs.quick-bar.filter_placeholder" aria-label=${placeholder}
)} .value="${searchPrefix}${this._search}"
aria-label=${this.hass.localize(
"ui.dialogs.quick-bar.filter_placeholder"
)}
.value=${this._commandMode ? `>${this._search}` : this._search}
icon icon
.iconTrailing=${this._search !== undefined || this._narrow} .iconTrailing=${this._search !== undefined || this._narrow}
@input=${this._handleSearchChange} @input=${this._handleSearchChange}
@keydown=${this._handleInputKeyDown} @keydown=${this._handleInputKeyDown}
@focus=${this._setFocusFirstListItem} @focus=${this._setFocusFirstListItem}
> >
${this._commandMode <ha-svg-icon
? html` slot="leadingIcon"
<ha-svg-icon class="prefix"
slot="leadingIcon" .path=${icon}
class="prefix" ></ha-svg-icon>
.path=${mdiConsoleLine}
></ha-svg-icon>
`
: html`
<ha-svg-icon
slot="leadingIcon"
class="prefix"
.path=${mdiMagnify}
></ha-svg-icon>
`}
${this._search || this._narrow ${this._search || this._narrow
? html` ? html`
<div slot="trailingIcon"> <div slot="trailingIcon">
@ -232,8 +257,7 @@ export class QuickBar extends LitElement {
height: this._narrow height: this._narrow
? "calc(100vh - 56px)" ? "calc(100vh - 56px)"
: `${Math.min( : `${Math.min(
items.length * (this._commandMode ? 56 : 72) + items.length * (commandMode ? 56 : 72) + 26,
26,
500 500
)}px`, )}px`,
})} })}
@ -252,9 +276,11 @@ export class QuickBar extends LitElement {
} }
private async _initializeItemsIfNeeded() { private async _initializeItemsIfNeeded() {
if (this._commandMode) { if (this._mode === QuickBarMode.Command) {
this._commandItems = this._commandItems =
this._commandItems || (await this._generateCommandItems()); this._commandItems || (await this._generateCommandItems());
} else if (this._mode === QuickBarMode.Device) {
this._deviceItems = this._deviceItems || this._generateDeviceItems();
} else { } else {
this._entityItems = this._entityItems || this._generateEntityItems(); this._entityItems = this._entityItems || this._generateEntityItems();
} }
@ -279,11 +305,37 @@ export class QuickBar extends LitElement {
if (!item) { if (!item) {
return nothing; return nothing;
} }
return isCommandItem(item)
? this._renderCommandItem(item, index) if (isDeviceItem(item)) {
: this._renderEntityItem(item as EntityItem, index); return this._renderDeviceItem(item, index);
}
if (isCommandItem(item)) {
return this._renderCommandItem(item, index);
}
return this._renderEntityItem(item as EntityItem, index);
}; };
private _renderDeviceItem(item: DeviceItem, index?: number) {
return html`
<ha-list-item
.twoline=${Boolean(item.area)}
.item=${item}
index=${ifDefined(index)}
>
<span>${item.primaryText}</span>
${item.area
? html`
<span slot="secondary" class="item-text secondary"
>${item.area}</span
>
`
: nothing}
</ha-list-item>
`;
}
private _renderEntityItem(item: EntityItem, index?: number) { private _renderEntityItem(item: EntityItem, index?: number) {
return html` return html`
<ha-list-item <ha-list-item
@ -376,31 +428,34 @@ export class QuickBar extends LitElement {
private _handleSearchChange(ev: CustomEvent): void { private _handleSearchChange(ev: CustomEvent): void {
const newFilter = (ev.currentTarget as any).value; const newFilter = (ev.currentTarget as any).value;
const oldCommandMode = this._commandMode; const oldMode = this._mode;
const oldSearch = this._search; const oldSearch = this._search;
let newCommandMode: boolean; let newMode: QuickBarMode;
let newSearch: string; let newSearch: string;
if (newFilter.startsWith(">")) { if (newFilter.startsWith(">")) {
newCommandMode = true; newMode = QuickBarMode.Command;
newSearch = newFilter.substring(1);
} else if (newFilter.startsWith("#")) {
newMode = QuickBarMode.Device;
newSearch = newFilter.substring(1); newSearch = newFilter.substring(1);
} else { } else {
newCommandMode = false; newMode = QuickBarMode.Entity;
newSearch = newFilter; newSearch = newFilter;
} }
if (oldCommandMode === newCommandMode && oldSearch === newSearch) { if (oldMode === newMode && oldSearch === newSearch) {
return; return;
} }
this._commandMode = newCommandMode; this._mode = newMode;
this._search = newSearch; this._search = newSearch;
if (this._hint) { if (this._hint) {
this._hint = undefined; this._hint = undefined;
} }
if (oldCommandMode !== this._commandMode) { if (oldMode !== this._mode) {
this._focusSet = false; this._focusSet = false;
this._initializeItemsIfNeeded(); this._initializeItemsIfNeeded();
this._filter = this._search; this._filter = this._search;
@ -464,6 +519,32 @@ export class QuickBar extends LitElement {
); );
} }
private _generateDeviceItems(): DeviceItem[] {
return Object.keys(this.hass.devices)
.map((deviceId) => {
const device = this.hass.devices[deviceId];
const area = this.hass.areas[device.area_id!];
const deviceItem = {
primaryText: device.name!,
deviceId: device.id,
area: area?.name,
action: () => navigate(`/config/devices/device/${device.id}`),
};
return {
...deviceItem,
strings: [deviceItem.primaryText],
};
})
.sort((a, b) =>
caseInsensitiveStringCompare(
a.primaryText,
b.primaryText,
this.hass.locale.language
)
);
}
private _generateEntityItems(): EntityItem[] { private _generateEntityItems(): EntityItem[] {
return Object.keys(this.hass.states) return Object.keys(this.hass.states)
.map((entityId) => { .map((entityId) => {
@ -746,10 +827,6 @@ export class QuickBar extends LitElement {
}); });
} }
private _toggleIfAlreadyOpened() {
return this._opened ? !this._commandMode : false;
}
private _filterItems = memoizeOne( private _filterItems = memoizeOne(
(items: QuickBarItem[], filter: string): QuickBarItem[] => (items: QuickBarItem[], filter: string): QuickBarItem[] =>
fuzzyFilterSort<QuickBarItem>(filter.trimLeft(), items) fuzzyFilterSort<QuickBarItem>(filter.trimLeft(), items)

View File

@ -1,8 +1,14 @@
import { fireEvent } from "../../common/dom/fire_event"; import { fireEvent } from "../../common/dom/fire_event";
export const enum QuickBarMode {
Command = "command",
Device = "device",
Entity = "entity",
}
export interface QuickBarParams { export interface QuickBarParams {
entityFilter?: string; entityFilter?: string;
commandMode?: boolean; mode?: QuickBarMode;
hint?: string; hint?: string;
} }

View File

@ -8,7 +8,7 @@ import {
} from "@mdi/js"; } from "@mdi/js";
import type { UnsubscribeFunc } from "home-assistant-js-websocket"; import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit"; import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { LitElement, css, html } from "lit"; import { css, html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators"; import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one"; import memoizeOne from "memoize-one";
import { isComponentLoaded } from "../../../common/config/is_component_loaded"; import { isComponentLoaded } from "../../../common/config/is_component_loaded";
@ -33,7 +33,10 @@ import {
checkForEntityUpdates, checkForEntityUpdates,
filterUpdateEntitiesWithInstall, filterUpdateEntitiesWithInstall,
} from "../../../data/update"; } from "../../../data/update";
import { showQuickBar } from "../../../dialogs/quick-bar/show-dialog-quick-bar"; import {
QuickBarMode,
showQuickBar,
} from "../../../dialogs/quick-bar/show-dialog-quick-bar";
import { showRestartDialog } from "../../../dialogs/restart/show-dialog-restart"; import { showRestartDialog } from "../../../dialogs/restart/show-dialog-restart";
import type { PageNavigation } from "../../../layouts/hass-tabs-subpage"; import type { PageNavigation } from "../../../layouts/hass-tabs-subpage";
import { SubscribeMixin } from "../../../mixins/subscribe-mixin"; import { SubscribeMixin } from "../../../mixins/subscribe-mixin";
@ -325,7 +328,7 @@ class HaConfigDashboard extends SubscribeMixin(LitElement) {
private _showQuickBar(): void { private _showQuickBar(): void {
showQuickBar(this, { showQuickBar(this, {
commandMode: true, mode: QuickBarMode.Command,
hint: this.hass.enableShortcuts hint: this.hass.enableShortcuts
? this.hass.localize("ui.dialogs.quick-bar.key_c_hint") ? this.hass.localize("ui.dialogs.quick-bar.key_c_hint")
: undefined, : undefined,

View File

@ -18,7 +18,7 @@ import {
import "@polymer/paper-tabs/paper-tab"; import "@polymer/paper-tabs/paper-tab";
import "@polymer/paper-tabs/paper-tabs"; import "@polymer/paper-tabs/paper-tabs";
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit"; import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { LitElement, css, html } from "lit"; import { css, html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators"; import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map"; import { classMap } from "lit/directives/class-map";
import { ifDefined } from "lit/directives/if-defined"; import { ifDefined } from "lit/directives/if-defined";
@ -59,7 +59,10 @@ import {
showAlertDialog, showAlertDialog,
showConfirmationDialog, showConfirmationDialog,
} from "../../dialogs/generic/show-dialog-box"; } from "../../dialogs/generic/show-dialog-box";
import { showQuickBar } from "../../dialogs/quick-bar/show-dialog-quick-bar"; import {
QuickBarMode,
showQuickBar,
} from "../../dialogs/quick-bar/show-dialog-quick-bar";
import { showVoiceCommandDialog } from "../../dialogs/voice-command-dialog/show-ha-voice-command-dialog"; import { showVoiceCommandDialog } from "../../dialogs/voice-command-dialog/show-ha-voice-command-dialog";
import { haStyle } from "../../resources/styles"; import { haStyle } from "../../resources/styles";
import type { HomeAssistant, PanelInfo } from "../../types"; import type { HomeAssistant, PanelInfo } from "../../types";
@ -662,7 +665,7 @@ class HUIRoot extends LitElement {
private _showQuickBar(): void { private _showQuickBar(): void {
showQuickBar(this, { showQuickBar(this, {
commandMode: false, mode: QuickBarMode.Entity,
hint: this.hass.enableShortcuts hint: this.hass.enableShortcuts
? this.hass.localize("ui.tips.key_e_hint") ? this.hass.localize("ui.tips.key_e_hint")
: undefined, : undefined,

View File

@ -4,7 +4,10 @@ import memoizeOne from "memoize-one";
import { isComponentLoaded } from "../common/config/is_component_loaded"; import { isComponentLoaded } from "../common/config/is_component_loaded";
import { mainWindow } from "../common/dom/get_main_window"; import { mainWindow } from "../common/dom/get_main_window";
import type { QuickBarParams } from "../dialogs/quick-bar/show-dialog-quick-bar"; import type { QuickBarParams } from "../dialogs/quick-bar/show-dialog-quick-bar";
import { showQuickBar } from "../dialogs/quick-bar/show-dialog-quick-bar"; import {
QuickBarMode,
showQuickBar,
} from "../dialogs/quick-bar/show-dialog-quick-bar";
import type { Constructor, HomeAssistant } from "../types"; import type { Constructor, HomeAssistant } from "../types";
import { storeState } from "../util/ha-pref-storage"; import { storeState } from "../util/ha-pref-storage";
import { showToast } from "../util/toast"; import { showToast } from "../util/toast";
@ -36,7 +39,10 @@ export default <T extends Constructor<HassElement>>(superClass: T) =>
this._showQuickBar(ev.detail); this._showQuickBar(ev.detail);
break; break;
case "c": case "c":
this._showQuickBar(ev.detail, true); this._showQuickBar(ev.detail, QuickBarMode.Command);
break;
case "d":
this._showQuickBar(ev.detail, QuickBarMode.Device);
break; break;
case "m": case "m":
this._createMyLink(ev.detail); this._createMyLink(ev.detail);
@ -54,14 +60,16 @@ export default <T extends Constructor<HassElement>>(superClass: T) =>
tinykeys(window, { tinykeys(window, {
// Those are for latin keyboards that have e, c, m keys // Those are for latin keyboards that have e, c, m keys
e: (ev) => this._showQuickBar(ev), e: (ev) => this._showQuickBar(ev),
c: (ev) => this._showQuickBar(ev, true), c: (ev) => this._showQuickBar(ev, QuickBarMode.Command),
m: (ev) => this._createMyLink(ev), m: (ev) => this._createMyLink(ev),
a: (ev) => this._showVoiceCommandDialog(ev), a: (ev) => this._showVoiceCommandDialog(ev),
d: (ev) => this._showQuickBar(ev, QuickBarMode.Device),
// Those are fallbacks for non-latin keyboards that don't have e, c, m keys (qwerty-based shortcuts) // Those are fallbacks for non-latin keyboards that don't have e, c, m keys (qwerty-based shortcuts)
KeyE: (ev) => this._showQuickBar(ev), KeyE: (ev) => this._showQuickBar(ev),
KeyC: (ev) => this._showQuickBar(ev, true), KeyC: (ev) => this._showQuickBar(ev, QuickBarMode.Command),
KeyM: (ev) => this._createMyLink(ev), KeyM: (ev) => this._createMyLink(ev),
KeyA: (ev) => this._showVoiceCommandDialog(ev), KeyA: (ev) => this._showVoiceCommandDialog(ev),
KeyD: (ev) => this._showQuickBar(ev, QuickBarMode.Device),
}); });
} }
@ -86,7 +94,10 @@ export default <T extends Constructor<HassElement>>(superClass: T) =>
showVoiceCommandDialog(this, this.hass!, { pipeline_id: "last_used" }); showVoiceCommandDialog(this, this.hass!, { pipeline_id: "last_used" });
} }
private _showQuickBar(e: KeyboardEvent, commandMode = false) { private _showQuickBar(
e: KeyboardEvent,
mode: QuickBarMode = QuickBarMode.Entity
) {
if (!this._canShowQuickBar(e)) { if (!this._canShowQuickBar(e)) {
return; return;
} }
@ -96,7 +107,7 @@ export default <T extends Constructor<HassElement>>(superClass: T) =>
} }
e.preventDefault(); e.preventDefault();
showQuickBar(this, { commandMode }); showQuickBar(this, { mode });
} }
private async _createMyLink(e: KeyboardEvent) { private async _createMyLink(e: KeyboardEvent) {

View File

@ -1195,7 +1195,10 @@
"addon_info": "{addon} Info" "addon_info": "{addon} Info"
} }
}, },
"filter_placeholder": "Search entities", "filter_placeholder": {
"entities": "Search entities",
"devices": "Search devices"
},
"title": "Quick search", "title": "Quick search",
"key_c_hint": "Press 'c' on any page to open the command dialog", "key_c_hint": "Press 'c' on any page to open the command dialog",
"nothing_found": "Nothing found!" "nothing_found": "Nothing found!"