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

View File

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

View File

@ -8,7 +8,7 @@ import {
} from "@mdi/js";
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
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 memoizeOne from "memoize-one";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
@ -33,7 +33,10 @@ import {
checkForEntityUpdates,
filterUpdateEntitiesWithInstall,
} 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 type { PageNavigation } from "../../../layouts/hass-tabs-subpage";
import { SubscribeMixin } from "../../../mixins/subscribe-mixin";
@ -325,7 +328,7 @@ class HaConfigDashboard extends SubscribeMixin(LitElement) {
private _showQuickBar(): void {
showQuickBar(this, {
commandMode: true,
mode: QuickBarMode.Command,
hint: this.hass.enableShortcuts
? this.hass.localize("ui.dialogs.quick-bar.key_c_hint")
: undefined,

View File

@ -18,7 +18,7 @@ import {
import "@polymer/paper-tabs/paper-tab";
import "@polymer/paper-tabs/paper-tabs";
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 { classMap } from "lit/directives/class-map";
import { ifDefined } from "lit/directives/if-defined";
@ -59,7 +59,10 @@ import {
showAlertDialog,
showConfirmationDialog,
} 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 { haStyle } from "../../resources/styles";
import type { HomeAssistant, PanelInfo } from "../../types";
@ -662,7 +665,7 @@ class HUIRoot extends LitElement {
private _showQuickBar(): void {
showQuickBar(this, {
commandMode: false,
mode: QuickBarMode.Entity,
hint: this.hass.enableShortcuts
? this.hass.localize("ui.tips.key_e_hint")
: undefined,

View File

@ -4,7 +4,10 @@ import memoizeOne from "memoize-one";
import { isComponentLoaded } from "../common/config/is_component_loaded";
import { mainWindow } from "../common/dom/get_main_window";
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 { storeState } from "../util/ha-pref-storage";
import { showToast } from "../util/toast";
@ -36,7 +39,10 @@ export default <T extends Constructor<HassElement>>(superClass: T) =>
this._showQuickBar(ev.detail);
break;
case "c":
this._showQuickBar(ev.detail, true);
this._showQuickBar(ev.detail, QuickBarMode.Command);
break;
case "d":
this._showQuickBar(ev.detail, QuickBarMode.Device);
break;
case "m":
this._createMyLink(ev.detail);
@ -54,14 +60,16 @@ export default <T extends Constructor<HassElement>>(superClass: T) =>
tinykeys(window, {
// Those are for latin keyboards that have e, c, m keys
e: (ev) => this._showQuickBar(ev),
c: (ev) => this._showQuickBar(ev, true),
c: (ev) => this._showQuickBar(ev, QuickBarMode.Command),
m: (ev) => this._createMyLink(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)
KeyE: (ev) => this._showQuickBar(ev),
KeyC: (ev) => this._showQuickBar(ev, true),
KeyC: (ev) => this._showQuickBar(ev, QuickBarMode.Command),
KeyM: (ev) => this._createMyLink(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" });
}
private _showQuickBar(e: KeyboardEvent, commandMode = false) {
private _showQuickBar(
e: KeyboardEvent,
mode: QuickBarMode = QuickBarMode.Entity
) {
if (!this._canShowQuickBar(e)) {
return;
}
@ -96,7 +107,7 @@ export default <T extends Constructor<HassElement>>(superClass: T) =>
}
e.preventDefault();
showQuickBar(this, { commandMode });
showQuickBar(this, { mode });
}
private async _createMyLink(e: KeyboardEvent) {

View File

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