import type { ListItem } from "@material/mwc-list/mwc-list-item"; import { mdiClose, mdiConsoleLine, mdiDevices, mdiEarth, mdiKeyboard, mdiMagnify, mdiReload, mdiServerNetwork, } from "@mdi/js"; import Fuse from "fuse.js"; import type { TemplateResult } 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"; import memoizeOne from "memoize-one"; import { canShowPage } from "../../common/config/can_show_page"; import { componentsWithService } from "../../common/config/components_with_service"; import { isComponentLoaded } from "../../common/config/is_component_loaded"; import { fireEvent } from "../../common/dom/fire_event"; import { computeAreaName } from "../../common/entity/compute_area_name"; import { computeDeviceNameDisplay } from "../../common/entity/compute_device_name"; import { computeDomain } from "../../common/entity/compute_domain"; import { entityUseDeviceName } from "../../common/entity/compute_entity_name"; import { computeStateName } from "../../common/entity/compute_state_name"; import { getDeviceContext } from "../../common/entity/context/get_device_context"; import { navigate } from "../../common/navigate"; import { caseInsensitiveStringCompare } from "../../common/string/compare"; import type { ScorableTextItem } from "../../common/string/filter/sequence-matching"; import { computeRTL } from "../../common/util/compute_rtl"; import { debounce } from "../../common/util/debounce"; import "../../components/ha-button"; import "../../components/ha-icon-button"; import "../../components/ha-label"; import "../../components/ha-list"; import "../../components/ha-md-list-item"; import "../../components/ha-spinner"; import "../../components/ha-textfield"; import "../../components/ha-tip"; import { getConfigEntries } from "../../data/config_entries"; import { fetchHassioAddonsInfo } from "../../data/hassio/addon"; import { domainToName } from "../../data/integration"; import { getPanelNameTranslationKey } from "../../data/panel"; import type { PageNavigation } from "../../layouts/hass-tabs-subpage"; import { configSections } from "../../panels/config/ha-panel-config"; import { HaFuse } from "../../resources/fuse"; import { haStyleDialog, haStyleScrollbar } from "../../resources/styles"; import { loadVirtualizer } from "../../resources/virtualizer"; import type { HomeAssistant } from "../../types"; import { brandsUrl } from "../../util/brands-url"; import { showConfirmationDialog } from "../generic/show-dialog-box"; import { showShortcutsDialog } from "../shortcuts/show-shortcuts-dialog"; import { QuickBarMode, type QuickBarParams } from "./show-dialog-quick-bar"; interface QuickBarItem extends ScorableTextItem { primaryText: string; iconPath?: string; action(data?: any): void; } interface CommandItem extends QuickBarItem { categoryKey: "reload" | "navigation" | "server_control"; categoryText: string; } interface EntityItem extends QuickBarItem { altText: string; icon?: TemplateResult; translatedDomain: string; entityId: string; friendlyName: string; } interface DeviceItem extends QuickBarItem { deviceId: string; domain?: string; translatedDomain?: 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; } type NavigationInfo = PageNavigation & Pick; type BaseNavigationCommand = Pick< QuickBarNavigationItem, "primaryText" | "path" >; @customElement("ha-quick-bar") export class QuickBar extends LitElement { @property({ attribute: false }) public hass!: HomeAssistant; @state() private _commandItems?: CommandItem[]; @state() private _entityItems?: EntityItem[]; @state() private _deviceItems?: DeviceItem[]; @state() private _filter = ""; @state() private _search = ""; @state() private _open = 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; private _focusListElement?: ListItem | null; public async showDialog(params: QuickBarParams) { this._mode = params.mode || QuickBarMode.Entity; this._hint = params.hint; this._narrow = matchMedia( "all and (max-width: 450px), all and (max-height: 500px)" ).matches; this._initializeItemsIfNeeded(); this._open = true; } public closeDialog() { this._open = false; this._opened = false; this._focusSet = false; this._filter = ""; this._search = ""; this._entityItems = undefined; this._commandItems = undefined; fireEvent(this, "dialog-closed", { dialog: this.localName }); } protected willUpdate() { if (!this.hasUpdated) { loadVirtualizer(); } } private _getItems = memoizeOne( ( 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); } return items; } ); protected render() { if (!this._open) { return nothing; } const items: QuickBarItem[] | undefined = this._getItems( this._mode, this._commandItems, this._entityItems, this._deviceItems, this._filter ); const translationKey = this._mode === QuickBarMode.Device ? "filter_placeholder_devices" : "filter_placeholder"; const placeholder = this.hass.localize( `ui.dialogs.quick-bar.${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`
${this._search || this._narrow ? html`
${this._search && html``} ${this._narrow ? html` ${this.hass!.localize("ui.common.close")} ` : ""}
` : ""}
${!items ? html`` : items.length === 0 ? html`
${this.hass.localize("ui.dialogs.quick-bar.nothing_found")}
` : html` ${this._opened ? html` ` : ""} `} ${this._hint ? html`${this._hint}` : ""}
`; } private async _initializeItemsIfNeeded() { if (this._mode === QuickBarMode.Command) { this._commandItems = this._commandItems || (await this._generateCommandItems()); } else if (this._mode === QuickBarMode.Device) { this._deviceItems = this._deviceItems || (await this._generateDeviceItems()); } else { this._entityItems = this._entityItems || (await this._generateEntityItems()); } } private _handleOpened() { this._opened = 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) => { if (!item) { return nothing; } 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` ${item.domain ? html`` : nothing} ${item.primaryText} ${item.area ? html` ${item.area} ` : nothing} ${item.translatedDomain ? html`
${item.translatedDomain}
` : nothing}
`; } private _renderEntityItem(item: EntityItem, index?: number) { const showEntityId = this.hass.userData?.showEntityIdPicker; return html` ${item.iconPath ? html` ` : html`${item.icon}`} ${item.primaryText} ${item.altText ? html` ${item.altText} ` : nothing} ${item.entityId && showEntityId ? html` ${item.entityId} ` : nothing} ${item.translatedDomain && !showEntityId ? html`
${item.translatedDomain}
` : nothing}
`; } private _renderCommandItem(item: CommandItem, index?: number) { return html` ${item.iconPath ? html` ` : nothing} ${item.categoryText} ${item.primaryText} `; } private async _processItemAndCloseDialog(item: QuickBarItem, index: number) { this._addSpinnerToCommandItem(index); await item.action(); this.closeDialog(); } private _handleInputKeyDown(ev: KeyboardEvent) { if (ev.code === "Enter") { const firstItem = this._getItemAtIndex(0); if (!firstItem || firstItem.style.display === "none") { return; } this._processItemAndCloseDialog((firstItem as any).item, 0); } else if (ev.code === "ArrowDown") { ev.preventDefault(); this._getItemAtIndex(0)?.focus(); this._focusSet = true; this._focusListElement = this._getItemAtIndex(0); } } private _getItemAtIndex(index: number): ListItem | null { return this.renderRoot.querySelector(`ha-md-list-item[index="${index}"]`); } private _addSpinnerToCommandItem(index: number): void { const div = document.createElement("div"); div.slot = "meta"; const spinner = document.createElement("ha-spinner"); spinner.size = "small"; div.appendChild(spinner); this._getItemAtIndex(index)?.appendChild(div); } private _handleSearchChange(ev: CustomEvent): void { const newFilter = (ev.currentTarget as any).value; const oldMode = this._mode; const oldSearch = this._search; let newMode: QuickBarMode; let newSearch: string; if (newFilter.startsWith(">")) { newMode = QuickBarMode.Command; newSearch = newFilter.substring(1); } else if (newFilter.startsWith("#")) { newMode = QuickBarMode.Device; newSearch = newFilter.substring(1); } else { newMode = QuickBarMode.Entity; newSearch = newFilter; } if (oldMode === newMode && oldSearch === newSearch) { return; } this._mode = newMode; this._search = newSearch; if (this._hint) { this._hint = undefined; } if (oldMode !== this._mode) { this._focusSet = false; this._initializeItemsIfNeeded(); this._filter = this._search; } else { if (this._focusSet && this._focusListElement) { this._focusSet = false; // @ts-ignore this._focusListElement.rippleHandlers.endFocus(); } this._debouncedSetFilter(this._search); } } private _clearSearch() { this._search = ""; this._filter = ""; } private _debouncedSetFilter = debounce((filter: string) => { this._filter = filter; }, 100); private _setFocusFirstListItem() { // @ts-ignore this._getItemAtIndex(0)?.rippleHandlers.startFocus(); this._focusListElement = this._getItemAtIndex(0); } private _handleListItemKeyDown(ev: KeyboardEvent) { const isSingleCharacter = ev.key.length === 1; const index = (ev.target as HTMLElement).getAttribute("index"); const isFirstListItem = index === "0"; this._focusListElement = ev.target as ListItem; if (ev.key === "ArrowDown") { this._getItemAtIndex(Number(index) + 1)?.focus(); } if (ev.key === "ArrowUp") { if (isFirstListItem) { this._filterInputField?.focus(); } else { this._getItemAtIndex(Number(index) - 1)?.focus(); } } if (ev.key === "Enter" || ev.key === " ") { this._processItemAndCloseDialog( (ev.target as any).item, Number((ev.target as HTMLElement).getAttribute("index")) ); } if (ev.key === "Backspace" || isSingleCharacter) { (ev.currentTarget as HTMLElement).scrollTop = 0; this._filterInputField?.focus(); } } private _handleItemClick(ev) { const listItem = ev.target.closest("ha-md-list-item"); this._processItemAndCloseDialog( listItem.item, Number(listItem.getAttribute("index")) ); } private async _generateDeviceItems(): Promise { const configEntries = await getConfigEntries(this.hass); const configEntryLookup = Object.fromEntries( configEntries.map((entry) => [entry.entry_id, entry]) ); return Object.values(this.hass.devices) .filter((device) => !device.disabled_by) .map((device) => { const deviceName = computeDeviceNameDisplay(device, this.hass); const { area } = getDeviceContext(device, this.hass); const areaName = area ? computeAreaName(area) : undefined; const deviceItem = { primaryText: deviceName, deviceId: device.id, area: areaName, action: () => navigate(`/config/devices/device/${device.id}`), }; const configEntry = device.primary_config_entry ? configEntryLookup[device.primary_config_entry] : undefined; const domain = configEntry?.domain; const translatedDomain = domain ? domainToName(this.hass.localize, domain) : undefined; return { ...deviceItem, domain, translatedDomain, strings: [deviceName, areaName, domain, domainToName].filter( Boolean ) as string[], }; }) .sort((a, b) => caseInsensitiveStringCompare( a.primaryText, b.primaryText, this.hass.locale.language ) ); } private async _generateEntityItems(): Promise { const isRTL = computeRTL(this.hass); await this.hass.loadBackendTranslation("title"); return Object.keys(this.hass.states) .map((entityId) => { const stateObj = this.hass.states[entityId]; const friendlyName = computeStateName(stateObj); // Keep this for search const useDeviceName = entityUseDeviceName( stateObj, this.hass.entities, this.hass.devices ); const name = this.hass.formatEntityName( stateObj, useDeviceName ? { type: "device" } : { type: "entity" } ); const primary = name || entityId; const secondary = this.hass.formatEntityName( stateObj, useDeviceName ? [{ type: "area" }] : [{ type: "area" }, { type: "device" }], { separator: isRTL ? " ◂ " : " ▸ ", } ); const translatedDomain = domainToName( this.hass.localize, computeDomain(entityId) ); const entityItem = { primaryText: primary, altText: secondary, icon: html` `, translatedDomain: translatedDomain, entityId: entityId, friendlyName: friendlyName, action: () => fireEvent(this, "hass-more-info", { entityId }), }; return { ...entityItem, strings: [entityItem.primaryText, entityItem.altText], }; }) .sort((a, b) => caseInsensitiveStringCompare( a.primaryText, b.primaryText, this.hass.locale.language ) ); } private async _generateCommandItems(): Promise { return [ ...(await this._generateReloadCommands()), ...this._generateServerControlCommands(), ...(await this._generateNavigationCommands()), ].sort((a, b) => caseInsensitiveStringCompare( a.strings.join(" "), b.strings.join(" "), this.hass.locale.language ) ); } private async _generateReloadCommands(): Promise { // Get all domains that have a direct "reload" service const reloadableDomains = componentsWithService(this.hass, "reload"); const localize = await this.hass.loadBackendTranslation( "title", reloadableDomains ); const commands = reloadableDomains.map((domain) => ({ primaryText: this.hass.localize(`ui.dialogs.quick-bar.commands.reload.${domain}`) || this.hass.localize("ui.dialogs.quick-bar.commands.reload.reload", { domain: domainToName(localize, domain), }), action: () => this.hass.callService(domain, "reload"), iconPath: mdiReload, categoryText: this.hass.localize( `ui.dialogs.quick-bar.commands.types.reload` ), })); // Add "frontend.reload_themes" commands.push({ primaryText: this.hass.localize( "ui.dialogs.quick-bar.commands.reload.themes" ), action: () => this.hass.callService("frontend", "reload_themes"), iconPath: mdiReload, categoryText: this.hass.localize( "ui.dialogs.quick-bar.commands.types.reload" ), }); // Add "homeassistant.reload_core_config" commands.push({ primaryText: this.hass.localize( "ui.dialogs.quick-bar.commands.reload.core" ), action: () => this.hass.callService("homeassistant", "reload_core_config"), iconPath: mdiReload, categoryText: this.hass.localize( "ui.dialogs.quick-bar.commands.types.reload" ), }); // Add "homeassistant.reload_all" commands.push({ primaryText: this.hass.localize( "ui.dialogs.quick-bar.commands.reload.all" ), action: () => this.hass.callService("homeassistant", "reload_all"), iconPath: mdiReload, categoryText: this.hass.localize( "ui.dialogs.quick-bar.commands.types.reload" ), }); return commands.map((command) => ({ ...command, categoryKey: "reload", strings: [`${command.categoryText} ${command.primaryText}`], })); } private _generateServerControlCommands(): CommandItem[] { const serverActions = ["restart", "stop"] as const; return serverActions.map((action) => { const categoryKey: CommandItem["categoryKey"] = "server_control"; const item = { primaryText: this.hass.localize( "ui.dialogs.quick-bar.commands.server_control.perform_action", { action: this.hass.localize( `ui.dialogs.quick-bar.commands.server_control.${action}` ), } ), iconPath: mdiServerNetwork, categoryText: this.hass.localize( `ui.dialogs.quick-bar.commands.types.${categoryKey}` ), categoryKey, action: async () => { const confirmed = await showConfirmationDialog(this, { title: this.hass.localize( `ui.dialogs.restart.${action}.confirm_title` ), text: this.hass.localize( `ui.dialogs.restart.${action}.confirm_description` ), confirmText: this.hass.localize( `ui.dialogs.restart.${action}.confirm_action` ), destructive: true, }); if (!confirmed) { return; } this.hass.callService("homeassistant", action); }, }; return { ...item, strings: [`${item.categoryText} ${item.primaryText}`], }; }); } private async _generateNavigationCommands(): Promise { const panelItems = this._generateNavigationPanelCommands(); const sectionItems = this._generateNavigationConfigSectionCommands(); const supervisorItems: BaseNavigationCommand[] = []; if (isComponentLoaded(this.hass, "hassio")) { const addonsInfo = await fetchHassioAddonsInfo(this.hass); supervisorItems.push({ path: "/hassio/store", primaryText: this.hass.localize( "ui.dialogs.quick-bar.commands.navigation.addon_store" ), }); supervisorItems.push({ path: "/hassio/dashboard", primaryText: this.hass.localize( "ui.dialogs.quick-bar.commands.navigation.addon_dashboard" ), }); for (const addon of addonsInfo.addons.filter((a) => a.version)) { supervisorItems.push({ path: `/hassio/addon/${addon.slug}`, primaryText: this.hass.localize( "ui.dialogs.quick-bar.commands.navigation.addon_info", { addon: addon.name } ), }); } } const additionalItems = [ { path: "", primaryText: this.hass.localize( "ui.dialogs.quick-bar.commands.navigation.shortcuts" ), action: () => showShortcutsDialog(this), iconPath: mdiKeyboard, }, ]; return this._finalizeNavigationCommands([ ...panelItems, ...sectionItems, ...supervisorItems, ...additionalItems, ]); } private _generateNavigationPanelCommands(): BaseNavigationCommand[] { return Object.keys(this.hass.panels) .filter( (panelKey) => panelKey !== "_my_redirect" && panelKey !== "hassio" ) .map((panelKey) => { const panel = this.hass.panels[panelKey]; const translationKey = getPanelNameTranslationKey(panel); const primaryText = this.hass.localize(translationKey) || panel.title || panel.url_path; return { primaryText, path: `/${panel.url_path}`, }; }); } private _generateNavigationConfigSectionCommands(): BaseNavigationCommand[] { const items: NavigationInfo[] = []; for (const sectionKey of Object.keys(configSections)) { for (const page of configSections[sectionKey]) { if (!canShowPage(this.hass, page)) { continue; } const info = this._getNavigationInfoFromConfig(page); if (!info) { continue; } // Add to list, but only if we do not already have an entry for the same path and component if (items.some((e) => e.path === info.path)) { continue; } items.push(info); } } return items; } private _getNavigationInfoFromConfig( page: PageNavigation ): NavigationInfo | undefined { const path = page.path.substring(1); let name = path.substring(path.indexOf("/") + 1); name = name.indexOf("/") > -1 ? name.substring(0, name.indexOf("/")) : name; const caption = (name && this.hass.localize( `ui.dialogs.quick-bar.commands.navigation.${name}` )) || // @ts-expect-error (page.translationKey && this.hass.localize(page.translationKey)); if (caption) { return { ...page, primaryText: caption }; } return undefined; } private _finalizeNavigationCommands( items: BaseNavigationCommand[] ): CommandItem[] { return items.map((item) => { const categoryKey: CommandItem["categoryKey"] = "navigation"; const navItem = { iconPath: mdiEarth, categoryText: this.hass.localize( `ui.dialogs.quick-bar.commands.types.${categoryKey}` ), action: () => navigate(item.path), ...item, }; return { ...navItem, strings: [`${navItem.categoryText} ${navItem.primaryText}`], categoryKey, }; }); } private _fuseIndex = memoizeOne((items: QuickBarItem[]) => Fuse.createIndex( [ "primaryText", "altText", "friendlyName", "translatedDomain", "entityId", // for technical search ], items ) ); private _filterItems = memoizeOne( (items: QuickBarItem[], filter: string): QuickBarItem[] => { const index = this._fuseIndex(items); const fuse = new HaFuse(items, {}, index); const results = fuse.multiTermsSearch(filter.trim()); if (!results || !results.length) { return items; } return results.map((result) => result.item); } ); static get styles() { return [ haStyleScrollbar, haStyleDialog, css` ha-list { position: relative; --mdc-list-vertical-padding: 0; } .heading { display: flex; align-items: center; --mdc-theme-primary: var(--primary-text-color); } .heading ha-textfield { flex-grow: 1; } ha-dialog { --dialog-z-index: 9; --dialog-content-padding: 0; } @media (min-width: 800px) { ha-dialog { --mdc-dialog-max-width: 800px; --mdc-dialog-min-width: 500px; --dialog-surface-position: fixed; --dialog-surface-top: var(--ha-space-10); --mdc-dialog-max-height: calc(100% - var(--ha-space-18)); } } @media all and (max-width: 450px), all and (max-height: 500px) { ha-textfield { --mdc-shape-small: 0; } } @media all and (max-width: 450px), all and (max-height: 690px) { .hint { display: none; } } ha-svg-icon.prefix { color: var(--primary-text-color); } ha-textfield ha-icon-button { --mdc-icon-button-size: 24px; color: var(--primary-text-color); } .command-category { --ha-label-icon-color: #585858; --ha-label-text-color: #212121; } .command-category.reload { --ha-label-background-color: #cddc39; } .command-category.navigation { --ha-label-background-color: var(--light-primary-color); } .command-category.server_control { --ha-label-background-color: var(--warning-color); } span.command-text { margin-left: var(--ha-space-2); margin-inline-start: var(--ha-space-2); margin-inline-end: initial; direction: var(--direction); } ha-md-list-item { width: 100%; } /* Fixed height for items because we are use virtualizer */ ha-md-list-item.two-line { --md-list-item-one-line-container-height: 64px; --md-list-item-two-line-container-height: 64px; --md-list-item-top-space: var(--ha-space-2); --md-list-item-bottom-space: var(--ha-space-2); } ha-md-list-item.three-line { width: 100%; --md-list-item-one-line-container-height: 72px; --md-list-item-two-line-container-height: 72px; --md-list-item-three-line-container-height: 72px; --md-list-item-top-space: var(--ha-space-2); --md-list-item-bottom-space: var(--ha-space-2); } ha-md-list-item .code { font-family: var(--ha-font-family-code); font-size: var(--ha-font-size-xs); } ha-md-list-item .domain { font-size: var(--ha-font-size-s); font-weight: var(--ha-font-weight-normal); line-height: var(--ha-line-height-normal); align-self: flex-end; max-width: 30%; text-overflow: ellipsis; overflow: hidden; white-space: nowrap; } ha-md-list-item img { width: 32px; height: 32px; } ha-tip { padding: var(--ha-space-5); } .nothing-found { padding: var(--ha-space-4) var(--ha-space-0); text-align: center; } div[slot="trailingIcon"] { display: flex; align-items: center; } lit-virtualizer { contain: size layout !important; } `, ]; } } declare global { interface HTMLElementTagNameMap { "ha-quick-bar": QuickBar; } }