diff --git a/demo/src/html/index.html.template b/demo/src/html/index.html.template index adcae133fb..09a6e7b514 100644 --- a/demo/src/html/index.html.template +++ b/demo/src/html/index.html.template @@ -68,7 +68,7 @@ } #ha-launch-screen .ha-launch-screen-spacer-top { flex: 1; - margin-top: calc( 2 * max(env(safe-area-inset-bottom), 48px) + 46px ); + margin-top: calc( 2 * max(var(--safe-area-inset-bottom), 48px) + 46px ); padding-top: 48px; } #ha-launch-screen .ha-launch-screen-spacer-bottom { @@ -76,7 +76,7 @@ padding-top: 48px; } .ohf-logo { - margin: max(env(safe-area-inset-bottom), 48px) 0; + margin: max(var(--safe-area-inset-bottom), 48px) 0; display: flex; flex-direction: column; align-items: center; diff --git a/hassio/src/dashboard/hassio-dashboard.ts b/hassio/src/dashboard/hassio-dashboard.ts index 531f171541..4cbf3c97b4 100644 --- a/hassio/src/dashboard/hassio-dashboard.ts +++ b/hassio/src/dashboard/hassio-dashboard.ts @@ -132,9 +132,9 @@ class HassioDashboard extends LitElement { } ha-fab.non-tabs { position: fixed; - right: calc(16px + env(safe-area-inset-right)); - bottom: calc(16px + env(safe-area-inset-bottom)); - inset-inline-end: calc(16px + env(safe-area-inset-right)); + right: calc(16px + var(--safe-area-inset-right)); + bottom: calc(16px + var(--safe-area-inset-bottom)); + inset-inline-end: calc(16px + var(--safe-area-inset-right)); inset-inline-start: initial; z-index: 1; } diff --git a/hassio/src/dialogs/network/dialog-hassio-network.ts b/hassio/src/dialogs/network/dialog-hassio-network.ts index f9b3a61613..862b81769a 100644 --- a/hassio/src/dialogs/network/dialog-hassio-network.ts +++ b/hassio/src/dialogs/network/dialog-hassio-network.ts @@ -610,7 +610,7 @@ export class DialogHassioNetwork display: flex; justify-content: space-between; padding: 8px; - padding-bottom: max(env(safe-area-inset-bottom), 8px); + padding-bottom: max(var(--safe-area-inset-bottom), 8px); background-color: var(--mdc-theme-surface, #fff); } .warning { diff --git a/package.json b/package.json index c52d8a6628..248a54eb3c 100644 --- a/package.json +++ b/package.json @@ -32,9 +32,9 @@ "@codemirror/commands": "6.8.1", "@codemirror/language": "6.11.0", "@codemirror/legacy-modes": "6.5.1", - "@codemirror/search": "6.5.10", + "@codemirror/search": "6.5.11", "@codemirror/state": "6.5.2", - "@codemirror/view": "6.36.7", + "@codemirror/view": "6.36.8", "@egjs/hammerjs": "2.0.17", "@formatjs/intl-datetimeformat": "6.18.0", "@formatjs/intl-displaynames": "6.8.11", @@ -137,7 +137,6 @@ "tinykeys": "3.0.0", "ua-parser-js": "2.0.3", "vis-data": "7.1.9", - "vis-network": "9.1.9", "vue": "2.7.16", "vue2-daterange-picker": "0.6.8", "weekstart": "2.0.0", @@ -160,8 +159,8 @@ "@octokit/plugin-retry": "7.2.1", "@octokit/rest": "21.1.1", "@rsdoctor/rspack-plugin": "1.1.2", - "@rspack/cli": "1.3.9", - "@rspack/core": "1.3.9", + "@rspack/cli": "1.3.10", + "@rspack/core": "1.3.10", "@types/babel__plugin-transform-runtime": "7.9.5", "@types/chromecast-caf-receiver": "6.0.21", "@types/chromecast-caf-sender": "1.0.11", @@ -185,7 +184,7 @@ "babel-plugin-template-html-minifier": "4.1.0", "browserslist-useragent-regexp": "4.1.3", "del": "8.0.0", - "eslint": "9.26.0", + "eslint": "9.27.0", "eslint-config-airbnb-base": "15.0.0", "eslint-config-prettier": "10.1.5", "eslint-import-resolver-webpack": "0.13.10", @@ -219,7 +218,7 @@ "terser-webpack-plugin": "5.3.14", "ts-lit-plugin": "2.0.2", "typescript": "5.8.3", - "typescript-eslint": "8.32.0", + "typescript-eslint": "8.32.1", "vite-tsconfig-paths": "5.1.4", "vitest": "3.1.3", "webpack-stats-plugin": "1.1.3", diff --git a/src/common/translations/markdown_support.ts b/src/common/translations/markdown_support.ts new file mode 100644 index 0000000000..2cf7271a47 --- /dev/null +++ b/src/common/translations/markdown_support.ts @@ -0,0 +1,14 @@ +import { html } from "lit"; +import type { LocalizeFunc } from "./localize"; + +const MARKDOWN_SUPPORT_URL = "https://commonmark.org/help/"; + +export const supportsMarkdownHelper = (localize: LocalizeFunc) => + localize("ui.common.supports_markdown", { + markdown_help_link: html`${localize("ui.common.markdown")}`, + }); diff --git a/src/components/chart/ha-chart-base.ts b/src/components/chart/ha-chart-base.ts index b03f88847c..0703d66eb9 100644 --- a/src/components/chart/ha-chart-base.ts +++ b/src/components/chart/ha-chart-base.ts @@ -48,7 +48,8 @@ export class HaChartBase extends LitElement { @property({ attribute: "expand-legend", type: Boolean }) public expandLegend?: boolean; - @property({ attribute: false }) public extraComponents?: any[]; + // extraComponents is not reactive and should not trigger updates + public extraComponents?: any[]; @state() @consume({ context: themesContext, subscribe: true }) @@ -106,48 +107,49 @@ export class HaChartBase extends LitElement { }) ); - // Add keyboard event listeners - const handleKeyDown = (ev: KeyboardEvent) => { - if ( - !this._modifierPressed && - ((isMac && ev.key === "Meta") || (!isMac && ev.key === "Control")) - ) { - this._modifierPressed = true; - if (!this.options?.dataZoom) { - this._setChartOptions({ dataZoom: this._getDataZoomConfig() }); + if (!this.options?.dataZoom) { + // Add keyboard event listeners + const handleKeyDown = (ev: KeyboardEvent) => { + if ( + !this._modifierPressed && + ((isMac && ev.key === "Meta") || (!isMac && ev.key === "Control")) + ) { + this._modifierPressed = true; + if (!this.options?.dataZoom) { + this._setChartOptions({ dataZoom: this._getDataZoomConfig() }); + } + // drag to zoom + this.chart?.dispatchAction({ + type: "takeGlobalCursor", + key: "dataZoomSelect", + dataZoomSelectActive: true, + }); } - // drag to zoom - this.chart?.dispatchAction({ - type: "takeGlobalCursor", - key: "dataZoomSelect", - dataZoomSelectActive: true, - }); - } - }; + }; - const handleKeyUp = (ev: KeyboardEvent) => { - if ( - this._modifierPressed && - ((isMac && ev.key === "Meta") || (!isMac && ev.key === "Control")) - ) { - this._modifierPressed = false; - if (!this.options?.dataZoom) { - this._setChartOptions({ dataZoom: this._getDataZoomConfig() }); + const handleKeyUp = (ev: KeyboardEvent) => { + if ( + this._modifierPressed && + ((isMac && ev.key === "Meta") || (!isMac && ev.key === "Control")) + ) { + this._modifierPressed = false; + if (!this.options?.dataZoom) { + this._setChartOptions({ dataZoom: this._getDataZoomConfig() }); + } + this.chart?.dispatchAction({ + type: "takeGlobalCursor", + key: "dataZoomSelect", + dataZoomSelectActive: false, + }); } - this.chart?.dispatchAction({ - type: "takeGlobalCursor", - key: "dataZoomSelect", - dataZoomSelectActive: false, - }); - } - }; - - window.addEventListener("keydown", handleKeyDown); - window.addEventListener("keyup", handleKeyUp); - this._listeners.push( - () => window.removeEventListener("keydown", handleKeyDown), - () => window.removeEventListener("keyup", handleKeyUp) - ); + }; + window.addEventListener("keydown", handleKeyDown); + window.addEventListener("keyup", handleKeyUp); + this._listeners.push( + () => window.removeEventListener("keydown", handleKeyDown), + () => window.removeEventListener("keyup", handleKeyUp) + ); + } } protected firstUpdated() { @@ -191,16 +193,19 @@ export class HaChartBase extends LitElement {
${this._renderLegend()} - ${this._isZoomed - ? html`` - : nothing} +
+ ${this._isZoomed + ? html`` + : nothing} + +
`; } @@ -210,7 +215,7 @@ export class HaChartBase extends LitElement { return nothing; } const legend = ensureArray(this.options.legend)[0] as LegendComponentOption; - if (!legend.show) { + if (!legend.show || legend.type !== "custom") { return nothing; } const datasets = ensureArray(this.data); @@ -315,7 +320,9 @@ export class HaChartBase extends LitElement { this.chart.on("click", (e: ECElementEvent) => { fireEvent(this, "chart-click", e); }); - this.chart.getZr().on("dblclick", this._handleClickZoom); + if (!this.options?.dataZoom) { + this.chart.getZr().on("dblclick", this._handleClickZoom); + } if (this._isTouchDevice) { this.chart.getZr().on("click", (e: ECElementEvent) => { if (!e.zrByTouch) { @@ -410,6 +417,12 @@ export class HaChartBase extends LitElement { } as XAXisOption; }); } + let legend = this.options?.legend; + if (legend) { + legend = ensureArray(legend).map((l) => + l.type === "custom" ? { show: false } : l + ); + } const options = { animation: !this._reducedMotion, darkMode: this._themes.darkMode ?? false, @@ -424,7 +437,7 @@ export class HaChartBase extends LitElement { iconStyle: { opacity: 0 }, }, ...this.options, - legend: { show: false }, + legend, xAxis, }; @@ -725,16 +738,26 @@ export class HaChartBase extends LitElement { height: 100%; width: 100%; } - .zoom-reset { + .chart-controls { position: absolute; top: 16px; right: 4px; + display: flex; + flex-direction: column; + gap: 4px; + } + .chart-controls ha-icon-button, + .chart-controls ::slotted(ha-icon-button) { background: var(--card-background-color); border-radius: 4px; --mdc-icon-button-size: 32px; color: var(--primary-color); border: 1px solid var(--divider-color); } + .chart-controls ha-icon-button.inactive, + .chart-controls ::slotted(ha-icon-button.inactive) { + color: var(--state-inactive-color); + } .chart-legend { max-height: 60%; overflow-y: auto; diff --git a/src/components/chart/ha-network-graph.ts b/src/components/chart/ha-network-graph.ts new file mode 100644 index 0000000000..c9693419dc --- /dev/null +++ b/src/components/chart/ha-network-graph.ts @@ -0,0 +1,299 @@ +import type { EChartsType } from "echarts/core"; +import type { GraphSeriesOption } from "echarts/charts"; +import { css, html, LitElement, nothing } from "lit"; +import { customElement, property, state, query } from "lit/decorators"; +import type { TopLevelFormatterParams } from "echarts/types/dist/shared"; +import { mdiFormatTextVariant, mdiGoogleCirclesGroup } from "@mdi/js"; +import memoizeOne from "memoize-one"; +import { listenMediaQuery } from "../../common/dom/media_query"; +import type { ECOption } from "../../resources/echarts"; +import "./ha-chart-base"; +import type { HaChartBase } from "./ha-chart-base"; +import type { HomeAssistant } from "../../types"; + +export interface NetworkNode { + id: string; + name?: string; + category?: number; + label?: string; + value?: number; + symbolSize?: number; + symbol?: string; + itemStyle?: { + color?: string; + borderColor?: string; + borderWidth?: number; + }; + fixed?: boolean; + /** + * Distance from the center, where 0 is the center and 1 is the edge + */ + polarDistance?: number; +} + +export interface NetworkLink { + source: string; + target: string; + value?: number; + reverseValue?: number; + lineStyle?: { + width?: number; + color?: string; + type?: "solid" | "dashed" | "dotted"; + }; + symbolSize?: number | number[]; + symbol?: string; + label?: { + show?: boolean; + formatter?: string; + }; + ignoreForceLayout?: boolean; +} + +export interface NetworkData { + nodes: NetworkNode[]; + links: NetworkLink[]; + categories?: { name: string; symbol: string }[]; +} + +// eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/consistent-type-imports +let GraphChart: typeof import("echarts/lib/chart/graph/install"); + +@customElement("ha-network-graph") +export class HaNetworkGraph extends LitElement { + public chart?: EChartsType; + + @property({ attribute: false }) public data!: NetworkData; + + @property({ attribute: false }) public tooltipFormatter?: ( + params: TopLevelFormatterParams + ) => string; + + public hass!: HomeAssistant; + + @state() private _reducedMotion = false; + + @state() private _physicsEnabled = true; + + @state() private _showLabels = true; + + private _listeners: (() => void)[] = []; + + private _nodePositions: Record = {}; + + @query("ha-chart-base") private _baseChart?: HaChartBase; + + constructor() { + super(); + if (!GraphChart) { + import("echarts/lib/chart/graph/install").then((module) => { + GraphChart = module; + this.requestUpdate(); + }); + } + } + + public async connectedCallback() { + super.connectedCallback(); + this._listeners.push( + listenMediaQuery("(prefers-reduced-motion)", (matches) => { + if (this._reducedMotion !== matches) { + this._reducedMotion = matches; + } + }) + ); + } + + public disconnectedCallback() { + super.disconnectedCallback(); + while (this._listeners.length) { + this._listeners.pop()!(); + } + } + + protected render() { + if (!GraphChart) { + return nothing; + } + return html` + + + + `; + } + + private _createOptions = memoizeOne( + (categories?: NetworkData["categories"]): ECOption => ({ + tooltip: { + trigger: "item", + confine: true, + formatter: this.tooltipFormatter, + }, + legend: { + show: !!categories?.length, + data: categories?.map((category) => ({ + ...category, + icon: category.symbol, + })), + top: 8, + }, + dataZoom: { + type: "inside", + filterMode: "none", + }, + }) + ); + + private _getSeries = memoizeOne( + ( + data: NetworkData, + physicsEnabled: boolean, + reducedMotion: boolean, + showLabels: boolean + ) => { + const containerWidth = this.clientWidth; + const containerHeight = this.clientHeight; + return [ + { + id: "network", + type: "graph", + layout: physicsEnabled ? "force" : "none", + draggable: true, + roam: true, + selectedMode: "single", + label: { + show: showLabels, + position: "right", + }, + emphasis: { + focus: "adjacency", + }, + force: { + repulsion: [400, 600], + edgeLength: [200, 300], + gravity: 0.1, + layoutAnimation: !reducedMotion && data.nodes.length < 100, + }, + edgeSymbol: ["none", "arrow"], + edgeSymbolSize: 10, + data: data.nodes.map((node) => { + const echartsNode: NonNullable[number] = + { + id: node.id, + name: node.name, + category: node.category, + value: node.value, + symbolSize: node.symbolSize || 30, + symbol: node.symbol || "circle", + itemStyle: node.itemStyle || {}, + fixed: node.fixed, + }; + if (this._nodePositions[node.id]) { + echartsNode.x = this._nodePositions[node.id].x; + echartsNode.y = this._nodePositions[node.id].y; + } else if (typeof node.polarDistance === "number") { + // set the position of the node at polarDistance from the center in a random direction + const angle = Math.random() * 2 * Math.PI; + echartsNode.x = + containerWidth / 2 + + ((Math.cos(angle) * containerWidth) / 2) * node.polarDistance; + echartsNode.y = + containerHeight / 2 + + ((Math.sin(angle) * containerHeight) / 2) * node.polarDistance; + this._nodePositions[node.id] = { + x: echartsNode.x, + y: echartsNode.y, + }; + } + return echartsNode; + }), + links: data.links.map((link) => ({ + ...link, + value: link.reverseValue + ? Math.max(link.value ?? 0, link.reverseValue) + : link.value, + // remove arrow for bidirectional links + symbolSize: link.reverseValue ? 1 : link.symbolSize, // 0 doesn't work + })), + categories: data.categories || [], + }, + ] as any; + } + ); + + private _togglePhysics() { + if (this._baseChart?.chart) { + this._baseChart.chart + // @ts-ignore private method but no other way to get the graph positions + .getModel() + .getSeriesByIndex(0) + .getGraph() + .eachNode((node: any) => { + const layout = node.getLayout(); + if (layout) { + this._nodePositions[node.id] = { + x: layout[0], + y: layout[1], + }; + } + }); + } + this._physicsEnabled = !this._physicsEnabled; + } + + private _toggleLabels() { + this._showLabels = !this._showLabels; + } + + static styles = css` + :host { + display: block; + position: relative; + } + ha-chart-base { + height: 100%; + --chart-max-height: 100%; + } + + ha-icon-button, + ::slotted(ha-icon-button) { + margin-right: 12px; + } + `; +} + +declare global { + interface HTMLElementTagNameMap { + "ha-network-graph": HaNetworkGraph; + } + interface HASSDomEvents { + "node-selected": { id: string }; + } +} diff --git a/src/components/chart/state-history-chart-line.ts b/src/components/chart/state-history-chart-line.ts index 2ad664e627..5e11621b60 100644 --- a/src/components/chart/state-history-chart-line.ts +++ b/src/components/chart/state-history-chart-line.ts @@ -287,6 +287,7 @@ export class StateHistoryChartLine extends LitElement { }, } as YAXisOption, legend: { + type: "custom", show: this.showNames, }, grid: { diff --git a/src/components/chart/statistics-chart.ts b/src/components/chart/statistics-chart.ts index 57b66dac25..ea8469653a 100644 --- a/src/components/chart/statistics-chart.ts +++ b/src/components/chart/statistics-chart.ts @@ -308,6 +308,7 @@ export class StatisticsChart extends LitElement { }, }, legend: { + type: "custom", show: !this.hideLegend, data: this._legendData, }, diff --git a/src/components/device/ha-device-picker.ts b/src/components/device/ha-device-picker.ts index d5b304fe2a..38431a19e3 100644 --- a/src/components/device/ha-device-picker.ts +++ b/src/components/device/ha-device-picker.ts @@ -1,33 +1,28 @@ import type { ComboBoxLitRenderer } from "@vaadin/combo-box/lit"; import type { HassEntity } from "home-assistant-js-websocket"; -import type { PropertyValues, TemplateResult } from "lit"; -import { LitElement, html, nothing } from "lit"; +import { html, LitElement, nothing, type PropertyValues } from "lit"; import { customElement, property, query, state } from "lit/decorators"; import memoizeOne from "memoize-one"; import { fireEvent } from "../../common/dom/fire_event"; -import { computeDeviceNameDisplay } from "../../common/entity/compute_device_name"; +import { computeAreaName } from "../../common/entity/compute_area_name"; +import { + computeDeviceName, + computeDeviceNameDisplay, +} from "../../common/entity/compute_device_name"; import { computeDomain } from "../../common/entity/compute_domain"; -import { stringCompare } from "../../common/string/compare"; -import type { ScorableTextItem } from "../../common/string/filter/sequence-matching"; -import { fuzzyFilterSort } from "../../common/string/filter/sequence-matching"; -import type { - DeviceEntityDisplayLookup, - DeviceRegistryEntry, +import { getDeviceContext } from "../../common/entity/context/get_device_context"; +import { getConfigEntries, type ConfigEntry } from "../../data/config_entries"; +import { + getDeviceEntityDisplayLookup, + type DeviceEntityDisplayLookup, + type DeviceRegistryEntry, } from "../../data/device_registry"; -import { getDeviceEntityDisplayLookup } from "../../data/device_registry"; -import type { EntityRegistryDisplayEntry } from "../../data/entity_registry"; -import type { HomeAssistant, ValueChangedEvent } from "../../types"; -import "../ha-combo-box"; -import type { HaComboBox } from "../ha-combo-box"; -import "../ha-combo-box-item"; - -interface Device { - name: string; - area: string; - id: string; -} - -type ScorableDevice = ScorableTextItem & Device; +import { domainToName } from "../../data/integration"; +import type { HomeAssistant } from "../../types"; +import { brandsUrl } from "../../util/brands-url"; +import "../ha-generic-picker"; +import type { HaGenericPicker } from "../ha-generic-picker"; +import type { PickerComboBoxItem } from "../ha-picker-combo-box"; export type HaDevicePickerDeviceFilterFunc = ( device: DeviceRegistryEntry @@ -35,25 +30,35 @@ export type HaDevicePickerDeviceFilterFunc = ( export type HaDevicePickerEntityFilterFunc = (entity: HassEntity) => boolean; -const rowRenderer: ComboBoxLitRenderer = (item) => html` - - ${item.name} - ${item.area - ? html`${item.area}` - : nothing} - -`; +interface DevicePickerItem extends PickerComboBoxItem { + domain?: string; + domain_name?: string; +} @customElement("ha-device-picker") export class HaDevicePicker extends LitElement { @property({ attribute: false }) public hass!: HomeAssistant; + // eslint-disable-next-line lit/no-native-attributes + @property({ type: Boolean }) public autofocus = false; + + @property({ type: Boolean }) public disabled = false; + + @property({ type: Boolean }) public required = false; + @property() public label?: string; @property() public value?: string; @property() public helper?: string; + @property() public placeholder?: string; + + @property({ type: String, attribute: "search-label" }) + public searchLabel?: string; + + @property({ attribute: false, type: Array }) public createDomains?: string[]; + /** * Show only devices with entities from specific domains. * @type {Array} @@ -92,38 +97,52 @@ export class HaDevicePicker extends LitElement { @property({ attribute: false }) public entityFilter?: HaDevicePickerEntityFilterFunc; - @property({ type: Boolean }) public disabled = false; + @property({ attribute: "hide-clear-icon", type: Boolean }) + public hideClearIcon = false; - @property({ type: Boolean }) public required = false; + @query("ha-generic-picker") private _picker?: HaGenericPicker; - @state() private _opened?: boolean; + @state() private _configEntryLookup: Record = {}; - @query("ha-combo-box", true) public comboBox!: HaComboBox; + protected firstUpdated(_changedProperties: PropertyValues): void { + super.firstUpdated(_changedProperties); + this._loadConfigEntries(); + } - private _init = false; + private async _loadConfigEntries() { + const configEntries = await getConfigEntries(this.hass); + this._configEntryLookup = Object.fromEntries( + configEntries.map((entry) => [entry.entry_id, entry]) + ); + } + + private _getItems = () => + this._getDevices( + this.hass.devices, + this.hass.entities, + this._configEntryLookup, + this.includeDomains, + this.excludeDomains, + this.includeDeviceClasses, + this.deviceFilter, + this.entityFilter, + this.excludeDevices + ); private _getDevices = memoizeOne( ( - devices: DeviceRegistryEntry[], - areas: HomeAssistant["areas"], - entities: EntityRegistryDisplayEntry[], + haDevices: HomeAssistant["devices"], + haEntities: HomeAssistant["entities"], + configEntryLookup: Record, includeDomains: this["includeDomains"], excludeDomains: this["excludeDomains"], includeDeviceClasses: this["includeDeviceClasses"], deviceFilter: this["deviceFilter"], entityFilter: this["entityFilter"], excludeDevices: this["excludeDevices"] - ): ScorableDevice[] => { - if (!devices.length) { - return [ - { - id: "no_devices", - area: "", - name: this.hass.localize("ui.components.device-picker.no_devices"), - strings: [], - }, - ]; - } + ): DevicePickerItem[] => { + const devices = Object.values(haDevices); + const entities = Object.values(haEntities); let deviceEntityLookup: DeviceEntityDisplayLookup = {}; @@ -214,133 +233,158 @@ export class HaDevicePicker extends LitElement { ); } - const outputDevices = inputDevices.map((device) => { - const name = computeDeviceNameDisplay( + const outputDevices = inputDevices.map((device) => { + const deviceName = computeDeviceNameDisplay( device, this.hass, deviceEntityLookup[device.id] ); + const { area } = getDeviceContext(device, this.hass); + + const areaName = area ? computeAreaName(area) : undefined; + + const configEntry = device.primary_config_entry + ? configEntryLookup?.[device.primary_config_entry] + : undefined; + + const domain = configEntry?.domain; + const domainName = domain + ? domainToName(this.hass.localize, domain) + : undefined; + return { id: device.id, - name: - name || + label: "", + primary: + deviceName || this.hass.localize("ui.components.device-picker.unnamed_device"), - area: - device.area_id && areas[device.area_id] - ? areas[device.area_id].name - : this.hass.localize("ui.components.device-picker.no_area"), - strings: [name || ""], + secondary: areaName, + domain: configEntry?.domain, + domain_name: domainName, + search_labels: [deviceName, areaName, domain, domainName].filter( + Boolean + ) as string[], + sorting_label: deviceName || "zzz", }; }); - if (!outputDevices.length) { - return [ - { - id: "no_devices", - area: "", - name: this.hass.localize("ui.components.device-picker.no_match"), - strings: [], - }, - ]; - } - if (outputDevices.length === 1) { - return outputDevices; - } - return outputDevices.sort((a, b) => - stringCompare(a.name || "", b.name || "", this.hass.locale.language) - ); + + return outputDevices; } ); - public async open() { - await this.updateComplete; - await this.comboBox?.open(); - } + private _valueRenderer = memoizeOne( + (configEntriesLookup: Record) => (value: string) => { + const deviceId = value; + const device = this.hass.devices[deviceId]; - public async focus() { - await this.updateComplete; - await this.comboBox?.focus(); - } + if (!device) { + return html`${deviceId}`; + } - protected updated(changedProps: PropertyValues) { - if ( - (!this._init && this.hass) || - (this._init && changedProps.has("_opened") && this._opened) - ) { - this._init = true; - const devices = this._getDevices( - Object.values(this.hass.devices), - this.hass.areas, - Object.values(this.hass.entities), - this.includeDomains, - this.excludeDomains, - this.includeDeviceClasses, - this.deviceFilter, - this.entityFilter, - this.excludeDevices - ); - this.comboBox.items = devices; - this.comboBox.filteredItems = devices; + const { area } = getDeviceContext(device, this.hass); + + const deviceName = device ? computeDeviceName(device) : undefined; + const areaName = area ? computeAreaName(area) : undefined; + + const primary = deviceName; + const secondary = areaName; + + const configEntry = device.primary_config_entry + ? configEntriesLookup[device.primary_config_entry] + : undefined; + + return html` + ${configEntry + ? html`` + : nothing} + ${primary} + ${secondary} + `; } - } + ); + + private _rowRenderer: ComboBoxLitRenderer = (item) => html` + + ${item.domain + ? html` + + ` + : nothing} + + ${item.primary} + ${item.secondary + ? html`${item.secondary}` + : nothing} + ${item.domain_name + ? html` +
+ ${item.domain_name} +
+ ` + : nothing} +
+ `; + + protected render() { + const placeholder = + this.placeholder ?? + this.hass.localize("ui.components.device-picker.placeholder"); + const notFoundLabel = this.hass.localize( + "ui.components.device-picker.no_match" + ); + + const valueRenderer = this._valueRenderer(this._configEntryLookup); - protected render(): TemplateResult { return html` - + .autofocus=${this.autofocus} + .label=${this.label} + .searchLabel=${this.searchLabel} + .notFoundLabel=${notFoundLabel} + .placeholder=${placeholder} + .value=${this.value} + .rowRenderer=${this._rowRenderer} + .getItems=${this._getItems} + .hideClearIcon=${this.hideClearIcon} + .valueRenderer=${valueRenderer} + @value-changed=${this._valueChanged} + > + `; } - private get _value() { - return this.value || ""; + public async open() { + await this.updateComplete; + await this._picker?.open(); } - private _filterChanged(ev: CustomEvent): void { - const target = ev.target as HaComboBox; - const filterString = ev.detail.value.toLowerCase(); - target.filteredItems = filterString.length - ? fuzzyFilterSort(filterString, target.items || []) - : target.items; - } - - private _deviceChanged(ev: ValueChangedEvent) { + private _valueChanged(ev) { ev.stopPropagation(); - let newValue = ev.detail.value; - - if (newValue === "no_devices") { - newValue = ""; - } - - if (newValue !== this._value) { - this._setValue(newValue); - } - } - - private _openedChanged(ev: ValueChangedEvent) { - this._opened = ev.detail.value; - } - - private _setValue(value: string) { + const value = ev.detail.value; this.value = value; - setTimeout(() => { - fireEvent(this, "value-changed", { value }); - fireEvent(this, "change"); - }, 0); + fireEvent(this, "value-changed", { value }); } } diff --git a/src/components/device/ha-devices-picker.ts b/src/components/device/ha-devices-picker.ts index 8c6a2fb570..3223ce4a37 100644 --- a/src/components/device/ha-devices-picker.ts +++ b/src/components/device/ha-devices-picker.ts @@ -1,7 +1,7 @@ import { css, html, LitElement, nothing } from "lit"; import { customElement, property } from "lit/decorators"; import { fireEvent } from "../../common/dom/fire_event"; -import type { ValueChangedEvent, HomeAssistant } from "../../types"; +import type { HomeAssistant, ValueChangedEvent } from "../../types"; import "./ha-device-picker"; import type { HaDevicePickerDeviceFilterFunc, diff --git a/src/components/entity/ha-entity-picker.ts b/src/components/entity/ha-entity-picker.ts index e8055a985b..397685a32b 100644 --- a/src/components/entity/ha-entity-picker.ts +++ b/src/components/entity/ha-entity-picker.ts @@ -403,7 +403,8 @@ export class HaEntityPicker extends LitElement { } public async open() { - this._picker?.open(); + await this.updateComplete; + await this._picker?.open(); } private _valueChanged(ev) { diff --git a/src/components/entity/ha-statistic-combo-box.ts b/src/components/entity/ha-statistic-combo-box.ts deleted file mode 100644 index eaddfe7ddd..0000000000 --- a/src/components/entity/ha-statistic-combo-box.ts +++ /dev/null @@ -1,481 +0,0 @@ -import { mdiChartLine, mdiHelpCircle, mdiShape } from "@mdi/js"; -import type { ComboBoxLitRenderer } from "@vaadin/combo-box/lit"; -import Fuse from "fuse.js"; -import type { HassEntity } from "home-assistant-js-websocket"; -import type { PropertyValues, TemplateResult } from "lit"; -import { html, LitElement, nothing } from "lit"; -import { customElement, property, query, state } from "lit/decorators"; -import memoizeOne from "memoize-one"; -import { ensureArray } from "../../common/array/ensure-array"; -import { fireEvent } from "../../common/dom/fire_event"; -import { computeAreaName } from "../../common/entity/compute_area_name"; -import { computeDeviceName } from "../../common/entity/compute_device_name"; -import { computeEntityName } from "../../common/entity/compute_entity_name"; -import { computeStateName } from "../../common/entity/compute_state_name"; -import { getEntityContext } from "../../common/entity/context/get_entity_context"; -import { caseInsensitiveStringCompare } from "../../common/string/compare"; -import { computeRTL } from "../../common/util/compute_rtl"; -import { domainToName } from "../../data/integration"; -import type { StatisticsMetaData } from "../../data/recorder"; -import { getStatisticIds, getStatisticLabel } from "../../data/recorder"; -import { HaFuse } from "../../resources/fuse"; -import type { HomeAssistant, ValueChangedEvent } from "../../types"; -import "../ha-combo-box"; -import type { HaComboBox } from "../ha-combo-box"; -import "../ha-combo-box-item"; -import "../ha-svg-icon"; -import "./state-badge"; -import { documentationUrl } from "../../util/documentation-url"; - -type StatisticItemType = "entity" | "external" | "no_state"; - -interface StatisticItem { - // Force empty label to always display empty value by default in the search field - id: string; - statistic_id?: string; - label: ""; - primary: string; - secondary?: string; - search_labels?: string[]; - sorting_label?: string; - icon_path?: string; - type?: StatisticItemType; - stateObj?: HassEntity; -} - -const MISSING_ID = "___missing-entity___"; - -const TYPE_ORDER = ["entity", "external", "no_state"] as StatisticItemType[]; - -@customElement("ha-statistic-combo-box") -export class HaStatisticComboBox extends LitElement { - @property({ attribute: false }) public hass!: HomeAssistant; - - @property() public label?: string; - - @property() public value?: string; - - @property({ attribute: "statistic-types" }) - public statisticTypes?: "mean" | "sum"; - - @property({ type: Boolean, attribute: "allow-custom-entity" }) - public allowCustomEntity; - - @property({ attribute: false, type: Array }) - public statisticIds?: StatisticsMetaData[]; - - @property({ type: Boolean }) public disabled = false; - - /** - * Show only statistics natively stored with these units of measurements. - * @type {Array} - * @attr include-statistics-unit-of-measurement - */ - @property({ - type: Array, - attribute: "include-statistics-unit-of-measurement", - }) - public includeStatisticsUnitOfMeasurement?: string | string[]; - - /** - * Show only statistics with these unit classes. - * @attr include-unit-class - */ - @property({ attribute: "include-unit-class" }) - public includeUnitClass?: string | string[]; - - /** - * Show only statistics with these device classes. - * @attr include-device-class - */ - @property({ attribute: "include-device-class" }) - public includeDeviceClass?: string | string[]; - - /** - * Show only statistics on entities. - * @type {Boolean} - * @attr entities-only - */ - @property({ type: Boolean, attribute: "entities-only" }) - public entitiesOnly = false; - - /** - * List of statistics to be excluded. - * @type {Array} - * @attr exclude-statistics - */ - @property({ type: Array, attribute: "exclude-statistics" }) - public excludeStatistics?: string[]; - - @property({ attribute: false }) public helpMissingEntityUrl = - "/more-info/statistics/"; - - @state() private _opened = false; - - @query("ha-combo-box", true) public comboBox!: HaComboBox; - - private _initialItems = false; - - private _items: StatisticItem[] = []; - - protected firstUpdated(changedProperties: PropertyValues): void { - super.firstUpdated(changedProperties); - this.hass.loadBackendTranslation("title"); - } - - private _rowRenderer: ComboBoxLitRenderer = ( - item, - { index } - ) => { - const showEntityId = this.hass.userData?.showEntityIdPicker; - return html` - - ${item.icon_path - ? html` - - ` - : item.stateObj - ? html` - - ` - : nothing} - ${item.primary} - ${item.secondary - ? html`${item.secondary}` - : nothing} - ${item.id && showEntityId - ? html` - ${item.statistic_id} - ` - : nothing} - - `; - }; - - private _getItems = memoizeOne( - ( - _opened: boolean, - hass: this["hass"], - statisticIds: StatisticsMetaData[], - includeStatisticsUnitOfMeasurement?: string | string[], - includeUnitClass?: string | string[], - includeDeviceClass?: string | string[], - entitiesOnly?: boolean, - excludeStatistics?: string[], - value?: string - ): StatisticItem[] => { - if (!statisticIds.length) { - return [ - { - id: "", - label: "", - primary: this.hass.localize( - "ui.components.statistic-picker.no_statistics" - ), - }, - ]; - } - - if (includeStatisticsUnitOfMeasurement) { - const includeUnits: (string | null)[] = ensureArray( - includeStatisticsUnitOfMeasurement - ); - statisticIds = statisticIds.filter((meta) => - includeUnits.includes(meta.statistics_unit_of_measurement) - ); - } - if (includeUnitClass) { - const includeUnitClasses: (string | null)[] = - ensureArray(includeUnitClass); - statisticIds = statisticIds.filter((meta) => - includeUnitClasses.includes(meta.unit_class) - ); - } - if (includeDeviceClass) { - const includeDeviceClasses: (string | null)[] = - ensureArray(includeDeviceClass); - statisticIds = statisticIds.filter((meta) => { - const stateObj = this.hass.states[meta.statistic_id]; - if (!stateObj) { - return true; - } - return includeDeviceClasses.includes( - stateObj.attributes.device_class || "" - ); - }); - } - - const isRTL = computeRTL(this.hass); - - const output: StatisticItem[] = []; - statisticIds.forEach((meta) => { - if ( - excludeStatistics && - meta.statistic_id !== value && - excludeStatistics.includes(meta.statistic_id) - ) { - return; - } - const stateObj = this.hass.states[meta.statistic_id]; - - if (!stateObj) { - if (!entitiesOnly) { - const id = meta.statistic_id; - const label = getStatisticLabel(this.hass, meta.statistic_id, meta); - const type = - meta.statistic_id.includes(":") && - !meta.statistic_id.includes(".") - ? "external" - : "no_state"; - - if (type === "no_state") { - output.push({ - id, - primary: label, - secondary: this.hass.localize( - "ui.components.statistic-picker.no_state" - ), - label: "", - type, - sorting_label: label, - search_labels: [label, id], - icon_path: mdiShape, - }); - } else if (type === "external") { - const domain = id.split(":")[0]; - const domainName = domainToName(this.hass.localize, domain); - output.push({ - id, - statistic_id: id, - primary: label, - secondary: domainName, - label: "", - type, - sorting_label: label, - search_labels: [label, domainName, id], - icon_path: mdiChartLine, - }); - } - } - return; - } - const id = meta.statistic_id; - - const { area, device } = getEntityContext(stateObj, hass); - - const friendlyName = computeStateName(stateObj); // Keep this for search - const entityName = computeEntityName(stateObj, hass); - const deviceName = device ? computeDeviceName(device) : undefined; - const areaName = area ? computeAreaName(area) : undefined; - - const primary = entityName || deviceName || id; - const secondary = [areaName, entityName ? deviceName : undefined] - .filter(Boolean) - .join(isRTL ? " ◂ " : " ▸ "); - - output.push({ - id, - statistic_id: id, - label: "", - primary, - secondary, - stateObj: stateObj, - type: "entity", - sorting_label: [deviceName, entityName].join("_"), - search_labels: [ - entityName, - deviceName, - areaName, - friendlyName, - id, - ].filter(Boolean) as string[], - }); - }); - - if (!output.length) { - return [ - { - id: "", - primary: this.hass.localize( - "ui.components.statistic-picker.no_match" - ), - label: "", - }, - ]; - } - - if (output.length > 1) { - output.sort((a, b) => { - const aPrefix = TYPE_ORDER.indexOf(a.type || "no_state"); - const bPrefix = TYPE_ORDER.indexOf(b.type || "no_state"); - - return caseInsensitiveStringCompare( - `${aPrefix}_${a.sorting_label || ""}`, - `${bPrefix}_${b.sorting_label || ""}`, - this.hass.locale.language - ); - }); - } - - output.push({ - id: MISSING_ID, - primary: this.hass.localize( - "ui.components.statistic-picker.missing_entity" - ), - label: "", - icon_path: mdiHelpCircle, - }); - - return output; - } - ); - - public async open() { - await this.updateComplete; - await this.comboBox?.open(); - } - - public async focus() { - await this.updateComplete; - await this.comboBox?.focus(); - } - - protected shouldUpdate(changedProps: PropertyValues) { - if ( - changedProps.has("value") || - changedProps.has("label") || - changedProps.has("disabled") - ) { - return true; - } - return !(!changedProps.has("_opened") && this._opened); - } - - public willUpdate(changedProps: PropertyValues) { - if ( - (!this.hasUpdated && !this.statisticIds) || - changedProps.has("statisticTypes") - ) { - this._getStatisticIds(); - } - - if ( - this.statisticIds && - (!this._initialItems || (changedProps.has("_opened") && this._opened)) - ) { - this._items = this._getItems( - this._opened, - this.hass, - this.statisticIds!, - this.includeStatisticsUnitOfMeasurement, - this.includeUnitClass, - this.includeDeviceClass, - this.entitiesOnly, - this.excludeStatistics, - this.value - ); - if (this._initialItems) { - this.comboBox.filteredItems = this._items; - } - this._initialItems = true; - } - } - - protected render(): TemplateResult | typeof nothing { - if (this._items.length === 0) { - return nothing; - } - - return html` - - `; - } - - private async _getStatisticIds() { - this.statisticIds = await getStatisticIds(this.hass, this.statisticTypes); - } - - private get _value() { - return this.value || ""; - } - - private _statisticChanged(ev: ValueChangedEvent) { - ev.stopPropagation(); - let newValue = ev.detail.value; - if (newValue === MISSING_ID) { - newValue = ""; - window.open( - documentationUrl(this.hass, this.helpMissingEntityUrl), - "_blank" - ); - } - - if (newValue !== this._value) { - this._setValue(newValue); - } - } - - private _openedChanged(ev: ValueChangedEvent) { - this._opened = ev.detail.value; - } - - private _fuseIndex = memoizeOne((states: StatisticItem[]) => - Fuse.createIndex(["search_labels"], states) - ); - - private _filterChanged(ev: CustomEvent): void { - if (!this._opened) return; - - const target = ev.target as HaComboBox; - const filterString = ev.detail.value.trim().toLowerCase() as string; - - const index = this._fuseIndex(this._items); - const fuse = new HaFuse(this._items, {}, index); - - const results = fuse.multiTermsSearch(filterString); - - if (results) { - target.filteredItems = results.map((result) => result.item); - } else { - target.filteredItems = this._items; - } - } - - private _setValue(value: string) { - this.value = value; - setTimeout(() => { - fireEvent(this, "value-changed", { value }); - fireEvent(this, "change"); - }, 0); - } -} - -declare global { - interface HTMLElementTagNameMap { - "ha-statistic-combo-box": HaStatisticComboBox; - } -} diff --git a/src/components/entity/ha-statistic-picker.ts b/src/components/entity/ha-statistic-picker.ts index b3c9145055..3b6e262fc3 100644 --- a/src/components/entity/ha-statistic-picker.ts +++ b/src/components/entity/ha-statistic-picker.ts @@ -1,45 +1,45 @@ -import { mdiChartLine, mdiClose, mdiMenuDown, mdiShape } from "@mdi/js"; -import type { ComboBoxLightOpenedChangedEvent } from "@vaadin/combo-box/vaadin-combo-box-light"; +import { mdiChartLine, mdiHelpCircle, mdiShape } from "@mdi/js"; +import type { ComboBoxLitRenderer } from "@vaadin/combo-box/lit"; import type { HassEntity } from "home-assistant-js-websocket"; -import { - css, - html, - LitElement, - nothing, - type CSSResultGroup, - type PropertyValues, -} from "lit"; -import { customElement, property, query, state } from "lit/decorators"; +import { html, LitElement, nothing, type PropertyValues } from "lit"; +import { customElement, property, query } from "lit/decorators"; import memoizeOne from "memoize-one"; +import { ensureArray } from "../../common/array/ensure-array"; import { fireEvent } from "../../common/dom/fire_event"; -import { stopPropagation } from "../../common/dom/stop_propagation"; import { computeAreaName } from "../../common/entity/compute_area_name"; import { computeDeviceName } from "../../common/entity/compute_device_name"; import { computeEntityName } from "../../common/entity/compute_entity_name"; +import { computeStateName } from "../../common/entity/compute_state_name"; import { getEntityContext } from "../../common/entity/context/get_entity_context"; import { computeRTL } from "../../common/util/compute_rtl"; -import { debounce } from "../../common/util/debounce"; import { domainToName } from "../../data/integration"; import { getStatisticIds, getStatisticLabel, type StatisticsMetaData, } from "../../data/recorder"; -import type { HomeAssistant } from "../../types"; +import type { HomeAssistant, ValueChangedEvent } from "../../types"; +import { documentationUrl } from "../../util/documentation-url"; import "../ha-combo-box-item"; +import "../ha-generic-picker"; +import type { HaGenericPicker } from "../ha-generic-picker"; import "../ha-icon-button"; import "../ha-input-helper-text"; -import type { HaMdListItem } from "../ha-md-list-item"; +import type { PickerComboBoxItem } from "../ha-picker-combo-box"; +import type { PickerValueRenderer } from "../ha-picker-field"; import "../ha-svg-icon"; -import "./ha-statistic-combo-box"; -import type { HaStatisticComboBox } from "./ha-statistic-combo-box"; import "./state-badge"; -interface StatisticItem { - primary: string; - secondary?: string; - iconPath?: string; +const TYPE_ORDER = ["entity", "external", "no_state"] as StatisticItemType[]; + +const MISSING_ID = "___missing-entity___"; + +type StatisticItemType = "entity" | "external" | "no_state"; + +interface StatisticComboBoxItem extends PickerComboBoxItem { + statistic_id?: string; stateObj?: HassEntity; + type?: StatisticItemType; } @customElement("ha-statistic-picker") @@ -70,6 +70,9 @@ export class HaStatisticPicker extends LitElement { @property({ attribute: false, type: Array }) public statisticIds?: StatisticsMetaData[]; + @property({ attribute: false }) public helpMissingEntityUrl = + "/more-info/statistics/"; + /** * Show only statistics natively stored with these units of measurements. * @type {Array} @@ -114,11 +117,7 @@ export class HaStatisticPicker extends LitElement { @property({ attribute: "hide-clear-icon", type: Boolean }) public hideClearIcon = false; - @query("#anchor") private _anchor?: HaMdListItem; - - @query("#input") private _input?: HaStatisticComboBox; - - @state() private _opened = false; + @query("ha-generic-picker") private _picker?: HaGenericPicker; public willUpdate(changedProps: PropertyValues) { if ( @@ -133,6 +132,165 @@ export class HaStatisticPicker extends LitElement { this.statisticIds = await getStatisticIds(this.hass, this.statisticTypes); } + private _getItems = () => + this._getStatisticsItems( + this.hass, + this.statisticIds, + this.includeStatisticsUnitOfMeasurement, + this.includeUnitClass, + this.includeDeviceClass, + this.entitiesOnly, + this.excludeStatistics, + this.value + ); + + private _getAdditionalItems(): StatisticComboBoxItem[] { + return [ + { + id: MISSING_ID, + primary: this.hass.localize( + "ui.components.statistic-picker.missing_entity" + ), + icon_path: mdiHelpCircle, + }, + ]; + } + + private _getStatisticsItems = memoizeOne( + ( + hass: HomeAssistant, + statisticIds?: StatisticsMetaData[], + includeStatisticsUnitOfMeasurement?: string | string[], + includeUnitClass?: string | string[], + includeDeviceClass?: string | string[], + entitiesOnly?: boolean, + excludeStatistics?: string[], + value?: string + ): StatisticComboBoxItem[] => { + if (!statisticIds) { + return []; + } + + if (includeStatisticsUnitOfMeasurement) { + const includeUnits: (string | null)[] = ensureArray( + includeStatisticsUnitOfMeasurement + ); + statisticIds = statisticIds.filter((meta) => + includeUnits.includes(meta.statistics_unit_of_measurement) + ); + } + if (includeUnitClass) { + const includeUnitClasses: (string | null)[] = + ensureArray(includeUnitClass); + statisticIds = statisticIds.filter((meta) => + includeUnitClasses.includes(meta.unit_class) + ); + } + if (includeDeviceClass) { + const includeDeviceClasses: (string | null)[] = + ensureArray(includeDeviceClass); + statisticIds = statisticIds.filter((meta) => { + const stateObj = this.hass.states[meta.statistic_id]; + if (!stateObj) { + return true; + } + return includeDeviceClasses.includes( + stateObj.attributes.device_class || "" + ); + }); + } + + const isRTL = computeRTL(this.hass); + + const output: StatisticComboBoxItem[] = []; + + statisticIds.forEach((meta) => { + if ( + excludeStatistics && + meta.statistic_id !== value && + excludeStatistics.includes(meta.statistic_id) + ) { + return; + } + const stateObj = this.hass.states[meta.statistic_id]; + + if (!stateObj) { + if (!entitiesOnly) { + const id = meta.statistic_id; + const label = getStatisticLabel(this.hass, meta.statistic_id, meta); + const type = + meta.statistic_id.includes(":") && + !meta.statistic_id.includes(".") + ? "external" + : "no_state"; + + const sortingPrefix = `${TYPE_ORDER.indexOf(type)}`; + if (type === "no_state") { + output.push({ + id, + primary: label, + secondary: this.hass.localize( + "ui.components.statistic-picker.no_state" + ), + type, + sorting_label: [sortingPrefix, label].join("_"), + search_labels: [label, id], + icon_path: mdiShape, + }); + } else if (type === "external") { + const domain = id.split(":")[0]; + const domainName = domainToName(this.hass.localize, domain); + output.push({ + id, + statistic_id: id, + primary: label, + secondary: domainName, + type, + sorting_label: [sortingPrefix, label].join("_"), + search_labels: [label, domainName, id], + icon_path: mdiChartLine, + }); + } + } + return; + } + const id = meta.statistic_id; + + const { area, device } = getEntityContext(stateObj, hass); + + const friendlyName = computeStateName(stateObj); // Keep this for search + const entityName = computeEntityName(stateObj, hass); + const deviceName = device ? computeDeviceName(device) : undefined; + const areaName = area ? computeAreaName(area) : undefined; + + const primary = entityName || deviceName || id; + const secondary = [areaName, entityName ? deviceName : undefined] + .filter(Boolean) + .join(isRTL ? " ◂ " : " ▸ "); + + const sortingPrefix = `${TYPE_ORDER.indexOf("entity")}`; + output.push({ + id, + statistic_id: id, + primary, + secondary, + stateObj: stateObj, + type: "entity", + sorting_label: [sortingPrefix, deviceName, entityName].join("_"), + search_labels: [ + entityName, + deviceName, + areaName, + friendlyName, + id, + ].filter(Boolean) as string[], + }); + }); + + return output; + } + ); + private _statisticMetaData = memoizeOne( (statisticId: string, statisticIds: StatisticsMetaData[]) => { if (!statisticIds) { @@ -144,26 +302,11 @@ export class HaStatisticPicker extends LitElement { } ); - private _renderContent() { - const statisticId = this.value || ""; - - if (!this.value) { - return html` - ${this.placeholder ?? - this.hass.localize( - "ui.components.statistic-picker.placeholder" - )} - - `; - } + private _valueRenderer: PickerValueRenderer = (value) => { + const statisticId = value; const item = this._computeItem(statisticId); - const showClearIcon = - !this.required && !this.disabled && !this.hideClearIcon; - return html` ${item.stateObj ? html` @@ -173,29 +316,19 @@ export class HaStatisticPicker extends LitElement { slot="start" > ` - : item.iconPath - ? html`` + : item.icon_path + ? html` + + ` : nothing} ${item.primary} ${item.secondary ? html`${item.secondary}` : nothing} - ${showClearIcon - ? html`` - : nothing} - `; - } + }; - private _computeItem(statisticId: string): StatisticItem { + private _computeItem(statisticId: string): StatisticComboBoxItem { const stateObj = this.hass.states[statisticId]; if (stateObj) { @@ -211,11 +344,24 @@ export class HaStatisticPicker extends LitElement { const secondary = [areaName, entityName ? deviceName : undefined] .filter(Boolean) .join(isRTL ? " ◂ " : " ▸ "); + const friendlyName = computeStateName(stateObj); // Keep this for search + const sortingPrefix = `${TYPE_ORDER.indexOf("entity")}`; return { + id: statisticId, + statistic_id: statisticId, primary, secondary, - stateObj, + stateObj: stateObj, + type: "entity", + sorting_label: [sortingPrefix, deviceName, entityName].join("_"), + search_labels: [ + entityName, + deviceName, + areaName, + friendlyName, + statisticId, + ].filter(Boolean) as string[], }; } @@ -230,175 +376,124 @@ export class HaStatisticPicker extends LitElement { : "no_state"; if (type === "external") { + const sortingPrefix = `${TYPE_ORDER.indexOf("external")}`; const label = getStatisticLabel(this.hass, statisticId, statistic); const domain = statisticId.split(":")[0]; const domainName = domainToName(this.hass.localize, domain); return { + id: statisticId, + statistic_id: statisticId, primary: label, secondary: domainName, - iconPath: mdiChartLine, + type: "external", + sorting_label: [sortingPrefix, label].join("_"), + search_labels: [label, domainName, statisticId], + icon_path: mdiChartLine, }; } } + const sortingPrefix = `${TYPE_ORDER.indexOf("external")}`; + const label = getStatisticLabel(this.hass, statisticId, statistic); + return { - primary: statisticId, - iconPath: mdiShape, + id: statisticId, + primary: label, + secondary: this.hass.localize("ui.components.statistic-picker.no_state"), + type: "no_state", + sorting_label: [sortingPrefix, label].join("_"), + search_labels: [label, statisticId], + icon_path: mdiShape, }; } - protected render() { + private _rowRenderer: ComboBoxLitRenderer = ( + item, + { index } + ) => { + const showEntityId = this.hass.userData?.showEntityIdPicker; return html` - ${this.label ? html`` : nothing} -
- ${!this._opened + + ${item.icon_path ? html` - - ${this._renderContent()} - + ` - : html` - - `} - ${this._renderHelper()} -
+ : item.stateObj + ? html` + + ` + : nothing} + ${item.primary} + ${item.secondary || item.type + ? html`${item.secondary} - ${item.type}` + : nothing} + ${item.statistic_id && showEntityId + ? html` + ${item.statistic_id} + ` + : nothing} + + `; + }; + + protected render() { + const placeholder = + this.placeholder ?? + this.hass.localize("ui.components.statistic-picker.placeholder"); + const notFoundLabel = this.hass.localize( + "ui.components.statistic-picker.no_match" + ); + + return html` + + `; } - private _renderHelper() { - return this.helper - ? html`${this.helper}` - : nothing; - } + private _valueChanged(ev: ValueChangedEvent) { + ev.stopPropagation(); + const value = ev.detail.value; - private _clear(e) { - e.stopPropagation(); - this.value = undefined; - fireEvent(this, "value-changed", { value: undefined }); - fireEvent(this, "change"); - } - - private async _showPicker() { - if (this.disabled) { + if (value === MISSING_ID) { + window.open( + documentationUrl(this.hass, this.helpMissingEntityUrl), + "_blank" + ); return; } - this._opened = true; + + this.value = value; + fireEvent(this, "value-changed", { value }); + } + + public async open() { await this.updateComplete; - this._input?.focus(); - this._input?.open(); - } - - // Multiple calls to _openedChanged can be triggered in quick succession - // when the menu is opened - private _debounceOpenedChanged = debounce( - (ev) => this._openedChanged(ev), - 10 - ); - - private async _openedChanged(ev: ComboBoxLightOpenedChangedEvent) { - const opened = ev.detail.value; - if (this._opened && !opened) { - this._opened = false; - await this.updateComplete; - this._anchor?.focus(); - } - } - - static get styles(): CSSResultGroup { - return [ - css` - .container { - position: relative; - display: block; - } - ha-combo-box-item { - background-color: var(--mdc-text-field-fill-color, whitesmoke); - border-radius: 4px; - border-end-end-radius: 0; - border-end-start-radius: 0; - --md-list-item-one-line-container-height: 56px; - --md-list-item-two-line-container-height: 56px; - --md-list-item-top-space: 8px; - --md-list-item-bottom-space: 8px; - --md-list-item-leading-space: 8px; - --md-list-item-trailing-space: 8px; - --ha-md-list-item-gap: 8px; - /* Remove the default focus ring */ - --md-focus-ring-width: 0px; - --md-focus-ring-duration: 0s; - } - - /* Add Similar focus style as the text field */ - ha-combo-box-item:after { - display: block; - content: ""; - position: absolute; - pointer-events: none; - bottom: 0; - left: 0; - right: 0; - height: 1px; - width: 100%; - background-color: var( - --mdc-text-field-idle-line-color, - rgba(0, 0, 0, 0.42) - ); - transform: - height 180ms ease-in-out, - background-color 180ms ease-in-out; - } - - ha-combo-box-item:focus:after { - height: 2px; - background-color: var(--mdc-theme-primary); - } - - ha-combo-box-item ha-svg-icon[slot="start"] { - margin: 0 4px; - } - .clear { - margin: 0 -8px; - --mdc-icon-button-size: 32px; - --mdc-icon-size: 20px; - } - .edit { - --mdc-icon-size: 20px; - width: 32px; - } - label { - display: block; - margin: 0 0 8px; - } - .placeholder { - color: var(--secondary-text-color); - padding: 0 8px; - } - `, - ]; + await this._picker?.open(); } } diff --git a/src/components/ha-area-picker.ts b/src/components/ha-area-picker.ts index 5cbd57a26e..1f14568261 100644 --- a/src/components/ha-area-picker.ts +++ b/src/components/ha-area-picker.ts @@ -1,15 +1,14 @@ -import { mdiTextureBox } from "@mdi/js"; -import type { ComboBoxLitRenderer } from "@vaadin/combo-box/lit"; +import { mdiPlus, mdiTextureBox } from "@mdi/js"; import type { HassEntity } from "home-assistant-js-websocket"; -import type { PropertyValues, TemplateResult } from "lit"; -import { LitElement, html } from "lit"; -import { customElement, property, query, state } from "lit/decorators"; +import type { TemplateResult } from "lit"; +import { LitElement, html, nothing } from "lit"; +import { customElement, property, query } from "lit/decorators"; import memoizeOne from "memoize-one"; import { fireEvent } from "../common/dom/fire_event"; +import { computeAreaName } from "../common/entity/compute_area_name"; import { computeDomain } from "../common/entity/compute_domain"; -import type { ScorableTextItem } from "../common/string/filter/sequence-matching"; -import { fuzzyFilterSort } from "../common/string/filter/sequence-matching"; -import type { AreaRegistryEntry } from "../data/area_registry"; +import { computeFloorName } from "../common/entity/compute_floor_name"; +import { getAreaContext } from "../common/entity/context/get_area_context"; import { createAreaRegistryEntry } from "../data/area_registry"; import type { DeviceEntityDisplayLookup, @@ -21,26 +20,15 @@ import { showAlertDialog } from "../dialogs/generic/show-dialog-box"; import { showAreaRegistryDetailDialog } from "../panels/config/areas/show-dialog-area-registry-detail"; import type { HomeAssistant, ValueChangedEvent } from "../types"; import type { HaDevicePickerDeviceFilterFunc } from "./device/ha-device-picker"; -import "./ha-combo-box"; -import type { HaComboBox } from "./ha-combo-box"; import "./ha-combo-box-item"; +import "./ha-generic-picker"; +import type { HaGenericPicker } from "./ha-generic-picker"; import "./ha-icon-button"; +import type { PickerComboBoxItem } from "./ha-picker-combo-box"; +import type { PickerValueRenderer } from "./ha-picker-field"; import "./ha-svg-icon"; -type ScorableAreaRegistryEntry = ScorableTextItem & AreaRegistryEntry; - -const rowRenderer: ComboBoxLitRenderer = (item) => html` - - ${item.icon - ? html`` - : html``} - ${item.name} - -`; - const ADD_NEW_ID = "___ADD_NEW___"; -const NO_ITEMS_ID = "___NO_ITEMS___"; -const ADD_NEW_SUGGESTION_ID = "___ADD_NEW_SUGGESTION___"; @customElement("ha-area-picker") export class HaAreaPicker extends LitElement { @@ -99,41 +87,68 @@ export class HaAreaPicker extends LitElement { @property({ type: Boolean }) public required = false; - @state() private _opened?: boolean; - - @query("ha-combo-box", true) public comboBox!: HaComboBox; - - private _suggestion?: string; - - private _init = false; + @query("ha-generic-picker") private _picker?: HaGenericPicker; public async open() { await this.updateComplete; - await this.comboBox?.open(); + await this._picker?.open(); } - public async focus() { - await this.updateComplete; - await this.comboBox?.focus(); - } + // Recompute value renderer when the areas change + private _computeValueRenderer = memoizeOne( + (_haAreas: HomeAssistant["areas"]): PickerValueRenderer => + (value) => { + const area = this.hass.areas[value]; + + if (!area) { + return html` + + ${area} + `; + } + + const { floor } = getAreaContext(area, this.hass); + + const areaName = area ? computeAreaName(area) : undefined; + const floorName = floor ? computeFloorName(floor) : undefined; + + const icon = area.icon; + + return html` + ${icon + ? html`` + : html``} + ${areaName} + ${floorName + ? html`${floorName}` + : nothing} + `; + } + ); private _getAreas = memoizeOne( ( - areas: AreaRegistryEntry[], - devices: DeviceRegistryEntry[], - entities: EntityRegistryDisplayEntry[], + haAreas: HomeAssistant["areas"], + haDevices: HomeAssistant["devices"], + haEntities: HomeAssistant["entities"], includeDomains: this["includeDomains"], excludeDomains: this["excludeDomains"], includeDeviceClasses: this["includeDeviceClasses"], deviceFilter: this["deviceFilter"], entityFilter: this["entityFilter"], - noAdd: this["noAdd"], excludeAreas: this["excludeAreas"] - ): AreaRegistryEntry[] => { + ): PickerComboBoxItem[] => { let deviceEntityLookup: DeviceEntityDisplayLookup = {}; let inputDevices: DeviceRegistryEntry[] | undefined; let inputEntities: EntityRegistryDisplayEntry[] | undefined; + const areas = Object.values(haAreas); + const devices = Object.values(haDevices); + const entities = Object.values(haEntities); + if ( includeDomains || excludeDomains || @@ -263,225 +278,147 @@ export class HaAreaPicker extends LitElement { ); } - if (!outputAreas.length) { - outputAreas = [ - { - area_id: NO_ITEMS_ID, - floor_id: null, - name: this.hass.localize("ui.components.area-picker.no_areas"), - picture: null, - icon: null, - aliases: [], - labels: [], - temperature_entity_id: null, - humidity_entity_id: null, - created_at: 0, - modified_at: 0, - }, - ]; - } + const items = outputAreas.map((area) => { + const { floor } = getAreaContext(area, this.hass); + const floorName = floor ? computeFloorName(floor) : undefined; + const areaName = computeAreaName(area); + return { + id: area.area_id, + primary: areaName || area.area_id, + secondary: floorName, + icon: area.icon || undefined, + icon_path: area.icon ? undefined : mdiTextureBox, + sorting_label: areaName, + search_labels: [ + areaName, + floorName, + area.area_id, + ...area.aliases, + ].filter((v): v is string => Boolean(v)), + }; + }); - return noAdd - ? outputAreas - : [ - ...outputAreas, - { - area_id: ADD_NEW_ID, - floor_id: null, - name: this.hass.localize("ui.components.area-picker.add_new"), - picture: null, - icon: "mdi:plus", - aliases: [], - labels: [], - temperature_entity_id: null, - humidity_entity_id: null, - created_at: 0, - modified_at: 0, - }, - ]; + return items; } ); - protected updated(changedProps: PropertyValues) { - if ( - (!this._init && this.hass) || - (this._init && changedProps.has("_opened") && this._opened) - ) { - this._init = true; - const areas = this._getAreas( - Object.values(this.hass.areas), - Object.values(this.hass.devices), - Object.values(this.hass.entities), - this.includeDomains, - this.excludeDomains, - this.includeDeviceClasses, - this.deviceFilter, - this.entityFilter, - this.noAdd, - this.excludeAreas - ).map((area) => ({ - ...area, - strings: [area.area_id, ...area.aliases, area.name], - })); - this.comboBox.items = areas; - this.comboBox.filteredItems = areas; + private _getItems = () => + this._getAreas( + this.hass.areas, + this.hass.devices, + this.hass.entities, + this.includeDomains, + this.excludeDomains, + this.includeDeviceClasses, + this.deviceFilter, + this.entityFilter, + this.excludeAreas + ); + + private _allAreaNames = memoizeOne( + (areas: HomeAssistant["areas"]) => + Object.values(areas) + .map((area) => computeAreaName(area)?.toLowerCase()) + .filter(Boolean) as string[] + ); + + private _getAdditionalItems = ( + searchString?: string + ): PickerComboBoxItem[] => { + if (this.noAdd) { + return []; } - } + + const allAreas = this._allAreaNames(this.hass.areas); + + if (searchString && !allAreas.includes(searchString.toLowerCase())) { + return [ + { + id: ADD_NEW_ID + searchString, + primary: this.hass.localize( + "ui.components.area-picker.add_new_sugestion", + { + name: searchString, + } + ), + icon_path: mdiPlus, + }, + ]; + } + + return [ + { + id: ADD_NEW_ID, + primary: this.hass.localize("ui.components.area-picker.add_new"), + icon_path: mdiPlus, + }, + ]; + }; protected render(): TemplateResult { + const placeholder = + this.placeholder ?? this.hass.localize("ui.components.area-picker.area"); + + const valueRenderer = this._computeValueRenderer(this.hass.areas); + return html` - - + `; } - private _filterChanged(ev: CustomEvent): void { - const target = ev.target as HaComboBox; - const filterString = ev.detail.value; - if (!filterString) { - this.comboBox.filteredItems = this.comboBox.items; - return; - } - - const filteredItems = fuzzyFilterSort( - filterString, - target.items?.filter( - (item) => ![NO_ITEMS_ID, ADD_NEW_ID].includes(item.label_id) - ) || [] - ); - if (filteredItems.length === 0) { - if (this.noAdd) { - this.comboBox.filteredItems = [ - { - area_id: NO_ITEMS_ID, - floor_id: null, - name: this.hass.localize("ui.components.area-picker.no_match"), - icon: null, - picture: null, - labels: [], - aliases: [], - temperature_entity_id: null, - humidity_entity_id: null, - created_at: 0, - modified_at: 0, - }, - ] as AreaRegistryEntry[]; - } else { - this._suggestion = filterString; - this.comboBox.filteredItems = [ - { - area_id: ADD_NEW_SUGGESTION_ID, - floor_id: null, - name: this.hass.localize( - "ui.components.area-picker.add_new_sugestion", - { name: this._suggestion } - ), - icon: "mdi:plus", - picture: null, - labels: [], - aliases: [], - temperature_entity_id: null, - humidity_entity_id: null, - created_at: 0, - modified_at: 0, - }, - ] as AreaRegistryEntry[]; - } - } else { - this.comboBox.filteredItems = filteredItems; - } - } - - private get _value() { - return this.value || ""; - } - - private _openedChanged(ev: ValueChangedEvent) { - this._opened = ev.detail.value; - } - - private _areaChanged(ev: ValueChangedEvent) { + private _valueChanged(ev: ValueChangedEvent) { ev.stopPropagation(); - let newValue = ev.detail.value; + const value = ev.detail.value; - if (newValue === NO_ITEMS_ID) { - newValue = ""; - this.comboBox.setInputValue(""); + if (!value) { + this._setValue(undefined); return; } - if (![ADD_NEW_SUGGESTION_ID, ADD_NEW_ID].includes(newValue)) { - if (newValue !== this._value) { - this._setValue(newValue); - } - return; + if (value.startsWith(ADD_NEW_ID)) { + this.hass.loadFragmentTranslation("config"); + + const suggestedName = value.substring(ADD_NEW_ID.length); + + showAreaRegistryDetailDialog(this, { + suggestedName: suggestedName, + createEntry: async (values) => { + try { + const area = await createAreaRegistryEntry(this.hass, values); + this._setValue(area.area_id); + } catch (err: any) { + showAlertDialog(this, { + title: this.hass.localize( + "ui.components.area-picker.failed_create_area" + ), + text: err.message, + }); + } + }, + }); } - (ev.target as any).value = this._value; - - this.hass.loadFragmentTranslation("config"); - - showAreaRegistryDetailDialog(this, { - suggestedName: newValue === ADD_NEW_SUGGESTION_ID ? this._suggestion : "", - createEntry: async (values) => { - try { - const area = await createAreaRegistryEntry(this.hass, values); - const areas = [...Object.values(this.hass.areas), area]; - this.comboBox.filteredItems = this._getAreas( - areas, - Object.values(this.hass.devices)!, - Object.values(this.hass.entities)!, - this.includeDomains, - this.excludeDomains, - this.includeDeviceClasses, - this.deviceFilter, - this.entityFilter, - this.noAdd, - this.excludeAreas - ); - await this.updateComplete; - await this.comboBox.updateComplete; - this._setValue(area.area_id); - } catch (err: any) { - showAlertDialog(this, { - title: this.hass.localize( - "ui.components.area-picker.failed_create_area" - ), - text: err.message, - }); - } - }, - }); - - this._suggestion = undefined; - this.comboBox.setInputValue(""); + this._setValue(value); } private _setValue(value?: string) { this.value = value; - setTimeout(() => { - fireEvent(this, "value-changed", { value }); - fireEvent(this, "change"); - }, 0); + fireEvent(this, "value-changed", { value }); + fireEvent(this, "change"); } } diff --git a/src/components/ha-assist-chat.ts b/src/components/ha-assist-chat.ts index be1629326c..ce069a94ee 100644 --- a/src/components/ha-assist-chat.ts +++ b/src/components/ha-assist-chat.ts @@ -5,8 +5,11 @@ import { customElement, property, query, state } from "lit/decorators"; import { classMap } from "lit/directives/class-map"; import type { HomeAssistant } from "../types"; import { + type PipelineRunEvent, runAssistPipeline, type AssistPipeline, + type ConversationChatLogAssistantDelta, + type ConversationChatLogToolResultDelta, } from "../data/assist_pipeline"; import { supportsFeature } from "../common/entity/supports-feature"; import { ConversationEntityFeature } from "../data/conversation"; @@ -90,7 +93,7 @@ export class HaAssistChat extends LitElement { super.disconnectedCallback(); this._audioRecorder?.close(); this._audioRecorder = undefined; - this._audio?.pause(); + this._unloadAudio(); this._conversation = []; this._conversationId = null; } @@ -109,25 +112,24 @@ export class HaAssistChat extends LitElement { const supportsSTT = this.pipeline?.stt_engine && !this.disableSpeech; return html` - ${controlHA - ? nothing - : html` - - ${this.hass.localize( - "ui.dialogs.voice_command.conversation_no_control" - )} - - `} -
-
- ${this._conversation!.map( - // New lines matter for messages - // prettier-ignore - (message) => html` +
+ ${controlHA + ? nothing + : html` + + ${this.hass.localize( + "ui.dialogs.voice_command.conversation_no_control" + )} + + `} +
+ ${this._conversation!.map( + // New lines matter for messages + // prettier-ignore + (message) => html`
${message.text}
` - )} -
+ )}
{ if (this._audioBuffer) { @@ -293,27 +295,36 @@ export class HaAssistChat extends LitElement { await this._audioRecorder.start(); this._addMessage(userMessage); - this.requestUpdate("_audioRecorder"); - let continueConversation = false; - let hassMessage = { - who: "hass", - text: "…", - error: false, - }; - let currentDeltaRole = ""; - // To make sure the answer is placed at the right user text, we add it before we process it + const hassMessageProcesser = this._createAddHassMessageProcessor(); + try { const unsub = await runAssistPipeline( this.hass, - (event) => { + (event: PipelineRunEvent) => { if (event.type === "run-start") { this._stt_binary_handler_id = event.data.runner_data.stt_binary_handler_id; + this._audio = new Audio(event.data.tts_output!.url); + this._audio.play(); + this._audio.addEventListener("ended", () => { + this._unloadAudio(); + if (hassMessageProcesser.continueConversation) { + this._startListening(); + } + }); + this._audio.addEventListener("pause", this._unloadAudio); + this._audio.addEventListener("canplaythrough", () => + this._audio?.play() + ); + this._audio.addEventListener("error", () => { + this._unloadAudio(); + showAlertDialog(this, { title: "Error playing audio." }); + }); } // When we start STT stage, the WS has a binary handler - if (event.type === "stt-start" && this._audioBuffer) { + else if (event.type === "stt-start" && this._audioBuffer) { // Send the buffer over the WS to the STT engine. for (const buffer of this._audioBuffer) { this._sendAudioChunk(buffer); @@ -322,91 +333,26 @@ export class HaAssistChat extends LitElement { } // Stop recording if the server is done with STT stage - if (event.type === "stt-end") { + else if (event.type === "stt-end") { this._stt_binary_handler_id = undefined; this._stopListening(); userMessage.text = event.data.stt_output.text; this.requestUpdate("_conversation"); - // To make sure the answer is placed at the right user text, we add it before we process it - this._addMessage(hassMessage); - } - - if (event.type === "intent-progress") { - const delta = event.data.chat_log_delta; - - // new message - if (delta.role) { - // If currentDeltaRole exists, it means we're receiving our - // second or later message. Let's add it to the chat. - if (currentDeltaRole && delta.role && hassMessage.text !== "…") { - // Remove progress indicator of previous message - hassMessage.text = hassMessage.text.substring( - 0, - hassMessage.text.length - 1 - ); - - hassMessage = { - who: "hass", - text: "…", - error: false, - }; - this._addMessage(hassMessage); - } - currentDeltaRole = delta.role; - } - - if ( - currentDeltaRole === "assistant" && - "content" in delta && - delta.content - ) { - hassMessage.text = - hassMessage.text.substring(0, hassMessage.text.length - 1) + - delta.content + - "…"; - this.requestUpdate("_conversation"); - } - } - - if (event.type === "intent-end") { - this._conversationId = event.data.intent_output.conversation_id; - continueConversation = - event.data.intent_output.continue_conversation; - const plain = event.data.intent_output.response.speech?.plain; - if (plain) { - hassMessage.text = plain.speech; - } - this.requestUpdate("_conversation"); - } - - if (event.type === "tts-end") { - const url = event.data.tts_output.url; - this._audio = new Audio(url); - this._audio.play(); - this._audio.addEventListener("ended", () => { - this._unloadAudio(); - if (continueConversation) { - this._startListening(); - } - }); - this._audio.addEventListener("pause", this._unloadAudio); - this._audio.addEventListener("canplaythrough", this._playAudio); - this._audio.addEventListener("error", this._audioError); - } - - if (event.type === "run-end") { + // Add the response message placeholder to the chat when we know the STT is done + hassMessageProcesser.addMessage(); + } else if (event.type.startsWith("intent-")) { + hassMessageProcesser.processEvent(event); + } else if (event.type === "run-end") { this._stt_binary_handler_id = undefined; unsub(); - } - - if (event.type === "error") { + } else if (event.type === "error") { + this._unloadAudio(); this._stt_binary_handler_id = undefined; if (userMessage.text === "…") { userMessage.text = event.data.message; userMessage.error = true; } else { - hassMessage.text = event.data.message; - hassMessage.error = true; + hassMessageProcesser.setError(event.data.message); } this._stopListening(); this.requestUpdate("_conversation"); @@ -464,90 +410,33 @@ export class HaAssistChat extends LitElement { this.hass.connection.socket!.send(data); } - private _playAudio = () => { - this._audio?.play(); - }; - - private _audioError = () => { - showAlertDialog(this, { title: "Error playing audio." }); - this._audio?.removeAttribute("src"); - }; - private _unloadAudio = () => { - this._audio?.removeAttribute("src"); + if (!this._audio) { + return; + } + this._audio.pause(); + this._audio.removeAttribute("src"); this._audio = undefined; }; private async _processText(text: string) { + this._unloadAudio(); this._processing = true; - this._audio?.pause(); this._addMessage({ who: "user", text }); - let hassMessage = { - who: "hass", - text: "…", - error: false, - }; - let currentDeltaRole = ""; - // To make sure the answer is placed at the right user text, we add it before we process it - this._addMessage(hassMessage); + const hassMessageProcesser = this._createAddHassMessageProcessor(); + hassMessageProcesser.addMessage(); try { const unsub = await runAssistPipeline( this.hass, (event) => { - if (event.type === "intent-progress") { - const delta = event.data.chat_log_delta; - - // new message and previous message has content - if (delta.role) { - // If currentDeltaRole exists, it means we're receiving our - // second or later message. Let's add it to the chat. - if ( - currentDeltaRole && - delta.role === "assistant" && - hassMessage.text !== "…" - ) { - // Remove progress indicator of previous message - hassMessage.text = hassMessage.text.substring( - 0, - hassMessage.text.length - 1 - ); - - hassMessage = { - who: "hass", - text: "…", - error: false, - }; - this._addMessage(hassMessage); - } - currentDeltaRole = delta.role; - } - - if ( - currentDeltaRole === "assistant" && - "content" in delta && - delta.content - ) { - hassMessage.text = - hassMessage.text.substring(0, hassMessage.text.length - 1) + - delta.content + - "…"; - this.requestUpdate("_conversation"); - } + if (event.type.startsWith("intent-")) { + hassMessageProcesser.processEvent(event); } - if (event.type === "intent-end") { - this._conversationId = event.data.intent_output.conversation_id; - const plain = event.data.intent_output.response.speech?.plain; - if (plain) { - hassMessage.text = plain.speech; - } - this.requestUpdate("_conversation"); unsub(); } if (event.type === "error") { - hassMessage.text = event.data.message; - hassMessage.error = true; - this.requestUpdate("_conversation"); + hassMessageProcesser.setError(event.data.message); unsub(); } }, @@ -560,20 +449,126 @@ export class HaAssistChat extends LitElement { } ); } catch { - hassMessage.text = this.hass.localize("ui.dialogs.voice_command.error"); - hassMessage.error = true; - this.requestUpdate("_conversation"); + hassMessageProcesser.setError( + this.hass.localize("ui.dialogs.voice_command.error") + ); } finally { this._processing = false; } } + private _createAddHassMessageProcessor() { + let currentDeltaRole = ""; + + const progressToNextMessage = () => { + if (progress.hassMessage.text === "…") { + return; + } + progress.hassMessage.text = progress.hassMessage.text.substring( + 0, + progress.hassMessage.text.length - 1 + ); + + progress.hassMessage = { + who: "hass", + text: "…", + error: false, + }; + this._addMessage(progress.hassMessage); + }; + + const isAssistantDelta = ( + _delta: any + ): _delta is Partial => + currentDeltaRole === "assistant"; + + const isToolResult = ( + _delta: any + ): _delta is ConversationChatLogToolResultDelta => + currentDeltaRole === "tool_result"; + + const tools: Record< + string, + ConversationChatLogAssistantDelta["tool_calls"][0] + > = {}; + + const progress = { + continueConversation: false, + hassMessage: { + who: "hass", + text: "…", + error: false, + }, + addMessage: () => { + this._addMessage(progress.hassMessage); + }, + setError: (error: string) => { + progressToNextMessage(); + progress.hassMessage.text = error; + progress.hassMessage.error = true; + this.requestUpdate("_conversation"); + }, + processEvent: (event: PipelineRunEvent) => { + if (event.type === "intent-progress") { + const delta = event.data.chat_log_delta; + + // new message + if (delta.role) { + progressToNextMessage(); + currentDeltaRole = delta.role; + } + + if (isAssistantDelta(delta)) { + if (delta.content) { + progress.hassMessage.text = + progress.hassMessage.text.substring( + 0, + progress.hassMessage.text.length - 1 + ) + + delta.content + + "…"; + this.requestUpdate("_conversation"); + } + if (delta.tool_calls) { + for (const toolCall of delta.tool_calls) { + tools[toolCall.id] = toolCall; + } + } + } else if (isToolResult(delta)) { + if (tools[delta.tool_call_id]) { + delete tools[delta.tool_call_id]; + } + } + } else if (event.type === "intent-end") { + this._conversationId = event.data.intent_output.conversation_id; + progress.continueConversation = + event.data.intent_output.continue_conversation; + const response = + event.data.intent_output.response.speech?.plain.speech; + if (!response) { + return; + } + if (event.data.intent_output.response.response_type === "error") { + progress.setError(response); + } else { + progress.hassMessage.text = response; + this.requestUpdate("_conversation"); + } + } + }, + }; + return progress; + } + static styles = css` :host { flex: 1; display: flex; flex-direction: column; } + ha-alert { + margin-bottom: 8px; + } ha-textfield { display: block; } @@ -581,17 +576,14 @@ export class HaAssistChat extends LitElement { flex: 1; display: block; box-sizing: border-box; - position: relative; - } - .messages-container { - position: absolute; - bottom: 0px; - right: 0px; - left: 0px; - padding: 0px 10px 16px; - box-sizing: border-box; overflow-y: auto; max-height: 100%; + display: flex; + flex-direction: column; + padding: 0 12px 16px; + } + .spacer { + flex: 1; } .message { white-space: pre-line; @@ -601,6 +593,9 @@ export class HaAssistChat extends LitElement { padding: 8px; border-radius: 15px; } + .message:last-child { + margin-bottom: 0; + } @media all and (max-width: 450px), all and (max-height: 500px) { .message { @@ -619,7 +614,7 @@ export class HaAssistChat extends LitElement { margin-left: 24px; margin-inline-start: 24px; margin-inline-end: initial; - float: var(--float-end); + align-self: flex-end; text-align: right; border-bottom-right-radius: 0px; background-color: var(--chat-background-color-user, var(--primary-color)); @@ -631,7 +626,7 @@ export class HaAssistChat extends LitElement { margin-right: 24px; margin-inline-end: 24px; margin-inline-start: initial; - float: var(--float-start); + align-self: flex-start; border-bottom-left-radius: 0px; background-color: var( --chat-background-color-hass, diff --git a/src/components/ha-base-time-input.ts b/src/components/ha-base-time-input.ts index 51aab0e0f1..eae4ccc306 100644 --- a/src/components/ha-base-time-input.ts +++ b/src/components/ha-base-time-input.ts @@ -81,27 +81,27 @@ export class HaBaseTimeInput extends LitElement { /** * Label for the day input */ - @property({ attribute: false }) dayLabel = ""; + @property({ type: String, attribute: "day-label" }) dayLabel = ""; /** * Label for the hour input */ - @property({ attribute: false }) hourLabel = ""; + @property({ type: String, attribute: "hour-label" }) hourLabel = ""; /** * Label for the min input */ - @property({ attribute: false }) minLabel = ""; + @property({ type: String, attribute: "min-label" }) minLabel = ""; /** * Label for the sec input */ - @property({ attribute: false }) secLabel = ""; + @property({ type: String, attribute: "sec-label" }) secLabel = ""; /** * Label for the milli sec input */ - @property({ attribute: false }) millisecLabel = ""; + @property({ type: String, attribute: "ms-label" }) millisecLabel = ""; /** * show the sec field @@ -342,7 +342,7 @@ export class HaBaseTimeInput extends LitElement { padding-right: 3px; } ha-textfield { - width: 55px; + width: 60px; flex-grow: 1; text-align: center; --mdc-shape-small: 0; diff --git a/src/components/ha-dialog.ts b/src/components/ha-dialog.ts index 64d5565cc8..933a70efef 100644 --- a/src/components/ha-dialog.ts +++ b/src/components/ha-dialog.ts @@ -90,7 +90,7 @@ export class HaDialog extends DialogBase { } .mdc-dialog__actions { justify-content: var(--justify-action-buttons, flex-end); - padding: 12px 24px max(env(safe-area-inset-bottom), 12px) 24px; + padding: 12px 24px max(var(--safe-area-inset-bottom), 12px) 24px; } .mdc-dialog__actions span:nth-child(1) { flex: var(--secondary-action-button-flex, unset); @@ -117,7 +117,7 @@ export class HaDialog extends DialogBase { :host([hideactions]) .mdc-dialog .mdc-dialog__content { padding-bottom: max( var(--dialog-content-padding, 24px), - env(safe-area-inset-bottom) + var(--safe-area-inset-bottom) ); } .mdc-dialog .mdc-dialog__surface { diff --git a/src/components/ha-duration-input.ts b/src/components/ha-duration-input.ts index 9620db7b50..7a4416c422 100644 --- a/src/components/ha-duration-input.ts +++ b/src/components/ha-duration-input.ts @@ -52,11 +52,11 @@ class HaDurationInput extends LitElement { .milliseconds=${this._milliseconds} @value-changed=${this._durationChanged} no-hours-limit - dayLabel="dd" - hourLabel="hh" - minLabel="mm" - secLabel="ss" - millisecLabel="ms" + day-label="dd" + hour-label="hh" + min-label="mm" + sec-label="ss" + ms-label="ms" > `; } diff --git a/src/components/ha-expansion-panel.ts b/src/components/ha-expansion-panel.ts index 931831d9d7..179ae51386 100644 --- a/src/components/ha-expansion-panel.ts +++ b/src/components/ha-expansion-panel.ts @@ -202,6 +202,7 @@ export class HaExpansionPanel extends LitElement { .header, ::slotted([slot="header"]) { flex: 1; + overflow-wrap: anywhere; } .container { diff --git a/src/components/ha-floor-picker.ts b/src/components/ha-floor-picker.ts index 4107aadc2c..84617c46e4 100644 --- a/src/components/ha-floor-picker.ts +++ b/src/components/ha-floor-picker.ts @@ -1,14 +1,13 @@ +import { mdiPlus, mdiTextureBox } from "@mdi/js"; import type { ComboBoxLitRenderer } from "@vaadin/combo-box/lit"; import type { HassEntity } from "home-assistant-js-websocket"; -import type { PropertyValues, TemplateResult } from "lit"; +import type { TemplateResult } from "lit"; import { LitElement, html } from "lit"; -import { customElement, property, query, state } from "lit/decorators"; +import { customElement, property, query } from "lit/decorators"; import memoizeOne from "memoize-one"; import { fireEvent } from "../common/dom/fire_event"; import { computeDomain } from "../common/entity/compute_domain"; -import type { ScorableTextItem } from "../common/string/filter/sequence-matching"; -import { fuzzyFilterSort } from "../common/string/filter/sequence-matching"; -import type { AreaRegistryEntry } from "../data/area_registry"; +import { computeFloorName } from "../common/entity/compute_floor_name"; import { updateAreaRegistryEntry } from "../data/area_registry"; import type { DeviceEntityDisplayLookup, @@ -16,33 +15,29 @@ import type { } from "../data/device_registry"; import { getDeviceEntityDisplayLookup } from "../data/device_registry"; import type { EntityRegistryDisplayEntry } from "../data/entity_registry"; -import type { FloorRegistryEntry } from "../data/floor_registry"; import { createFloorRegistryEntry, getFloorAreaLookup, + type FloorRegistryEntry, } from "../data/floor_registry"; import { showAlertDialog } from "../dialogs/generic/show-dialog-box"; import { showFloorRegistryDetailDialog } from "../panels/config/areas/show-dialog-floor-registry-detail"; import type { HomeAssistant, ValueChangedEvent } from "../types"; import type { HaDevicePickerDeviceFilterFunc } from "./device/ha-device-picker"; -import "./ha-combo-box"; -import type { HaComboBox } from "./ha-combo-box"; import "./ha-combo-box-item"; import "./ha-floor-icon"; +import "./ha-generic-picker"; +import type { HaGenericPicker } from "./ha-generic-picker"; import "./ha-icon-button"; - -type ScorableFloorRegistryEntry = ScorableTextItem & FloorRegistryEntry; +import type { PickerComboBoxItem } from "./ha-picker-combo-box"; +import type { PickerValueRenderer } from "./ha-picker-field"; +import "./ha-svg-icon"; const ADD_NEW_ID = "___ADD_NEW___"; -const NO_FLOORS_ID = "___NO_FLOORS___"; -const ADD_NEW_SUGGESTION_ID = "___ADD_NEW_SUGGESTION___"; -const rowRenderer: ComboBoxLitRenderer = (item) => html` - - - ${item.name} - -`; +interface FloorComboBoxItem extends PickerComboBoxItem { + floor?: FloorRegistryEntry; +} @customElement("ha-floor-picker") export class HaFloorPicker extends LitElement { @@ -88,7 +83,7 @@ export class HaFloorPicker extends LitElement { * @type {Array} * @attr exclude-floors */ - @property({ type: Array, attribute: "exclude-floor" }) + @property({ type: Array, attribute: "exclude-floors" }) public excludeFloors?: string[]; @property({ attribute: false }) @@ -101,38 +96,53 @@ export class HaFloorPicker extends LitElement { @property({ type: Boolean }) public required = false; - @state() private _opened?: boolean; - - @query("ha-combo-box", true) public comboBox!: HaComboBox; - - private _suggestion?: string; - - private _init = false; + @query("ha-generic-picker") private _picker?: HaGenericPicker; public async open() { await this.updateComplete; - await this.comboBox?.open(); + await this._picker?.open(); } - public async focus() { - await this.updateComplete; - await this.comboBox?.focus(); - } + // Recompute value renderer when the areas change + private _computeValueRenderer = memoizeOne( + (_haAreas: HomeAssistant["floors"]): PickerValueRenderer => + (value) => { + const floor = this.hass.floors[value]; + + if (!floor) { + return html` + + ${floor} + `; + } + + const floorName = floor ? computeFloorName(floor) : undefined; + + return html` + + ${floorName} + `; + } + ); private _getFloors = memoizeOne( ( - floors: FloorRegistryEntry[], - areas: AreaRegistryEntry[], - devices: DeviceRegistryEntry[], - entities: EntityRegistryDisplayEntry[], + haFloors: HomeAssistant["floors"], + haAreas: HomeAssistant["areas"], + haDevices: HomeAssistant["devices"], + haEntities: HomeAssistant["entities"], includeDomains: this["includeDomains"], excludeDomains: this["excludeDomains"], includeDeviceClasses: this["includeDeviceClasses"], deviceFilter: this["deviceFilter"], entityFilter: this["entityFilter"], - noAdd: this["noAdd"], excludeFloors: this["excludeFloors"] - ): FloorRegistryEntry[] => { + ): FloorComboBoxItem[] => { + const floors = Object.values(haFloors); + const areas = Object.values(haAreas); + const devices = Object.values(haDevices); + const entities = Object.values(haEntities); + let deviceEntityLookup: DeviceEntityDisplayLookup = {}; let inputDevices: DeviceRegistryEntry[] | undefined; let inputEntities: EntityRegistryDisplayEntry[] | undefined; @@ -269,216 +279,169 @@ export class HaFloorPicker extends LitElement { ); } - if (!outputFloors.length) { - outputFloors = [ - { - floor_id: NO_FLOORS_ID, - name: this.hass.localize("ui.components.floor-picker.no_floors"), - icon: null, - level: null, - aliases: [], - created_at: 0, - modified_at: 0, - }, - ]; - } + const items = outputFloors.map((floor) => { + const floorName = computeFloorName(floor); + return { + id: floor.floor_id, + primary: floorName, + floor: floor, + sorting_label: floor.level?.toString() || "zzzzz", + search_labels: [floorName, floor.floor_id, ...floor.aliases].filter( + (v): v is string => Boolean(v) + ), + }; + }); - return noAdd - ? outputFloors - : [ - ...outputFloors, - { - floor_id: ADD_NEW_ID, - name: this.hass.localize("ui.components.floor-picker.add_new"), - icon: "mdi:plus", - level: null, - aliases: [], - created_at: 0, - modified_at: 0, - }, - ]; + return items; } ); - protected updated(changedProps: PropertyValues) { - if ( - (!this._init && this.hass) || - (this._init && changedProps.has("_opened") && this._opened) - ) { - this._init = true; - const floors = this._getFloors( - Object.values(this.hass.floors), - Object.values(this.hass.areas), - Object.values(this.hass.devices), - Object.values(this.hass.entities), - this.includeDomains, - this.excludeDomains, - this.includeDeviceClasses, - this.deviceFilter, - this.entityFilter, - this.noAdd, - this.excludeFloors - ).map((floor) => ({ - ...floor, - strings: [floor.floor_id, floor.name, ...floor.aliases], - })); - this.comboBox.items = floors; - this.comboBox.filteredItems = floors; + private _rowRenderer: ComboBoxLitRenderer = (item) => html` + + ${item.icon_path + ? html` + + ` + : html` + + `} + ${item.primary} + + `; + + private _getItems = () => + this._getFloors( + this.hass.floors, + this.hass.areas, + this.hass.devices, + this.hass.entities, + this.includeDomains, + this.excludeDomains, + this.includeDeviceClasses, + this.deviceFilter, + this.entityFilter, + this.excludeFloors + ); + + private _allFloorNames = memoizeOne( + (floors: HomeAssistant["floors"]) => + Object.values(floors) + .map((floor) => computeFloorName(floor)?.toLowerCase()) + .filter(Boolean) as string[] + ); + + private _getAdditionalItems = ( + searchString?: string + ): PickerComboBoxItem[] => { + if (this.noAdd) { + return []; } - } + + const allFloors = this._allFloorNames(this.hass.floors); + + if (searchString && !allFloors.includes(searchString.toLowerCase())) { + return [ + { + id: ADD_NEW_ID + searchString, + primary: this.hass.localize( + "ui.components.floor-picker.add_new_sugestion", + { + name: searchString, + } + ), + icon_path: mdiPlus, + }, + ]; + } + + return [ + { + id: ADD_NEW_ID, + primary: this.hass.localize("ui.components.floor-picker.add_new"), + icon_path: mdiPlus, + }, + ]; + }; protected render(): TemplateResult { + const placeholder = + this.placeholder ?? + this.hass.localize("ui.components.floor-picker.floor"); + + const valueRenderer = this._computeValueRenderer(this.hass.floors); + return html` - - + `; } - private _filterChanged(ev: CustomEvent): void { - const target = ev.target as HaComboBox; - const filterString = ev.detail.value; - if (!filterString) { - this.comboBox.filteredItems = this.comboBox.items; - return; - } - - const filteredItems = fuzzyFilterSort( - filterString, - target.items?.filter( - (item) => ![NO_FLOORS_ID, ADD_NEW_ID].includes(item.label_id) - ) || [] - ); - if (filteredItems.length === 0) { - if (this.noAdd) { - this.comboBox.filteredItems = [ - { - floor_id: NO_FLOORS_ID, - name: this.hass.localize("ui.components.floor-picker.no_match"), - icon: null, - level: null, - aliases: [], - created_at: 0, - modified_at: 0, - }, - ] as FloorRegistryEntry[]; - } else { - this._suggestion = filterString; - this.comboBox.filteredItems = [ - { - floor_id: ADD_NEW_SUGGESTION_ID, - name: this.hass.localize( - "ui.components.floor-picker.add_new_sugestion", - { name: this._suggestion } - ), - icon: "mdi:plus", - level: null, - aliases: [], - created_at: 0, - modified_at: 0, - }, - ] as FloorRegistryEntry[]; - } - } else { - this.comboBox.filteredItems = filteredItems; - } - } - - private get _value() { - return this.value || ""; - } - - private _openedChanged(ev: ValueChangedEvent) { - this._opened = ev.detail.value; - } - - private _floorChanged(ev: ValueChangedEvent) { + private _valueChanged(ev: ValueChangedEvent) { ev.stopPropagation(); - let newValue = ev.detail.value; + const value = ev.detail.value; - if (newValue === NO_FLOORS_ID) { - newValue = ""; - this.comboBox.setInputValue(""); + if (!value) { + this._setValue(undefined); return; } - if (![ADD_NEW_SUGGESTION_ID, ADD_NEW_ID].includes(newValue)) { - if (newValue !== this._value) { - this._setValue(newValue); - } - return; - } + if (value.startsWith(ADD_NEW_ID)) { + this.hass.loadFragmentTranslation("config"); - (ev.target as any).value = this._value; + const suggestedName = value.substring(ADD_NEW_ID.length); - this.hass.loadFragmentTranslation("config"); - - showFloorRegistryDetailDialog(this, { - suggestedName: newValue === ADD_NEW_SUGGESTION_ID ? this._suggestion : "", - createEntry: async (values, addedAreas) => { - try { - const floor = await createFloorRegistryEntry(this.hass, values); - addedAreas.forEach((areaId) => { - updateAreaRegistryEntry(this.hass, areaId, { - floor_id: floor.floor_id, + showFloorRegistryDetailDialog(this, { + suggestedName: suggestedName, + createEntry: async (values, addedAreas) => { + try { + const floor = await createFloorRegistryEntry(this.hass, values); + addedAreas.forEach((areaId) => { + updateAreaRegistryEntry(this.hass, areaId, { + floor_id: floor.floor_id, + }); }); - }); - const floors = [...Object.values(this.hass.floors), floor]; - this.comboBox.filteredItems = this._getFloors( - floors, - Object.values(this.hass.areas)!, - Object.values(this.hass.devices)!, - Object.values(this.hass.entities)!, - this.includeDomains, - this.excludeDomains, - this.includeDeviceClasses, - this.deviceFilter, - this.entityFilter, - this.noAdd, - this.excludeFloors - ); - await this.updateComplete; - await this.comboBox.updateComplete; - this._setValue(floor.floor_id); - } catch (err: any) { - showAlertDialog(this, { - title: this.hass.localize( - "ui.components.floor-picker.failed_create_floor" - ), - text: err.message, - }); - } - }, - }); + this._setValue(floor.floor_id); + } catch (err: any) { + showAlertDialog(this, { + title: this.hass.localize( + "ui.components.floor-picker.failed_create_floor" + ), + text: err.message, + }); + } + }, + }); + } - this._suggestion = undefined; - this.comboBox.setInputValue(""); + this._setValue(value); } private _setValue(value?: string) { this.value = value; - setTimeout(() => { - fireEvent(this, "value-changed", { value }); - fireEvent(this, "change"); - }, 0); + fireEvent(this, "value-changed", { value }); + fireEvent(this, "change"); } } diff --git a/src/components/ha-items-display-editor.ts b/src/components/ha-items-display-editor.ts index af7fb2ba1f..d0e1319be0 100644 --- a/src/components/ha-items-display-editor.ts +++ b/src/components/ha-items-display-editor.ts @@ -25,6 +25,7 @@ export interface DisplayItem { value: string; label: string; description?: string; + disableSorting?: boolean; } export interface DisplayValue { @@ -50,6 +51,9 @@ export class HaItemDisplayEditor extends LitElement { @property({ type: Boolean, attribute: "show-navigation-button" }) public showNavigationButton = false; + @property({ type: Boolean, attribute: "dont-sort-visible" }) + public dontSortVisible = false; + @property({ attribute: false }) public value: DisplayValue = { order: [], @@ -122,9 +126,15 @@ export class HaItemDisplayEditor extends LitElement { private _visibleItems = memoizeOne( (items: DisplayItem[], hidden: string[], order: string[]) => { const compare = orderCompare(order); - return items - .filter((item) => !hidden.includes(item.value)) - .sort((a, b) => compare(a.value, b.value)); + + const visibleItems = items.filter((item) => !hidden.includes(item.value)); + if (this.dontSortVisible) { + return visibleItems; + } + + return items.sort((a, b) => + a.disableSorting && !b.disableSorting ? -1 : compare(a.value, b.value) + ); } ); @@ -160,7 +170,14 @@ export class HaItemDisplayEditor extends LitElement { (item) => item.value, (item: DisplayItem, _idx) => { const isVisible = !this.value.hidden.includes(item.value); - const { label, value, description, icon, iconPath } = item; + const { + label, + value, + description, + icon, + iconPath, + disableSorting, + } = item; return html` ${label} ${description ? html`${description}` : nothing} - ${isVisible + ${isVisible && !disableSorting ? html` = (item) => html` - - ${item.icon - ? html`` - : nothing} - ${item.name} - -`; +const NO_LABELS = "___NO_LABELS___"; @customElement("ha-label-picker") export class HaLabelPicker extends SubscribeMixin(LitElement) { @@ -101,24 +88,13 @@ export class HaLabelPicker extends SubscribeMixin(LitElement) { @property({ type: Boolean }) public required = false; - @state() private _opened?: boolean; - @state() private _labels?: LabelRegistryEntry[]; - @query("ha-combo-box", true) public comboBox!: HaComboBox; - - private _suggestion?: string; - - private _init = false; + @query("ha-generic-picker") private _picker?: HaGenericPicker; public async open() { await this.updateComplete; - await this.comboBox?.open(); - } - - public async focus() { - await this.updateComplete; - await this.comboBox?.focus(); + await this._picker?.open(); } protected hassSubscribe(): (UnsubscribeFunc | Promise)[] { @@ -129,20 +105,61 @@ export class HaLabelPicker extends SubscribeMixin(LitElement) { ]; } + private _labelMap = memoizeOne( + ( + labels: LabelRegistryEntry[] | undefined + ): Map => { + if (!labels) { + return new Map(); + } + return new Map(labels.map((label) => [label.label_id, label])); + } + ); + + private _valueRenderer: PickerValueRenderer = (value) => { + const label = this._labelMap(this._labels).get(value); + + if (!label) { + return html` + + ${value} + `; + } + + return html` + ${label.icon + ? html`` + : html``} + ${label.name} + `; + }; + private _getLabels = memoizeOne( ( - labels: LabelRegistryEntry[], - areas: HomeAssistant["areas"], - devices: DeviceRegistryEntry[], - entities: EntityRegistryDisplayEntry[], + labels: LabelRegistryEntry[] | undefined, + haAreas: HomeAssistant["areas"], + haDevices: HomeAssistant["devices"], + haEntities: HomeAssistant["entities"], includeDomains: this["includeDomains"], excludeDomains: this["excludeDomains"], includeDeviceClasses: this["includeDeviceClasses"], deviceFilter: this["deviceFilter"], entityFilter: this["entityFilter"], - noAdd: this["noAdd"], excludeLabels: this["excludeLabels"] - ): LabelRegistryEntry[] => { + ): PickerComboBoxItem[] => { + if (!labels || labels.length === 0) { + return [ + { + id: NO_LABELS, + primary: this.hass.localize("ui.components.label-picker.no_labels"), + icon_path: mdiLabel, + }, + ]; + } + + const devices = Object.values(haDevices); + const entities = Object.values(haEntities); + let deviceEntityLookup: DeviceEntityDisplayLookup = {}; let inputDevices: DeviceRegistryEntry[] | undefined; let inputEntities: EntityRegistryDisplayEntry[] | undefined; @@ -274,7 +291,7 @@ export class HaLabelPicker extends SubscribeMixin(LitElement) { if (areaIds) { areaIds.forEach((areaId) => { - const area = areas[areaId]; + const area = haAreas[areaId]; area.labels.forEach((label) => usedLabels.add(label)); }); } @@ -291,192 +308,144 @@ export class HaLabelPicker extends SubscribeMixin(LitElement) { ); } - if (!outputLabels.length) { - outputLabels = [ - { - label_id: NO_LABELS_ID, - name: this.hass.localize("ui.components.label-picker.no_match"), - icon: null, - color: null, - description: null, - created_at: 0, - modified_at: 0, - }, - ]; - } + const items = outputLabels.map((label) => ({ + id: label.label_id, + primary: label.name, + icon: label.icon || undefined, + icon_path: label.icon ? undefined : mdiLabel, + sorting_label: label.name, + search_labels: [label.name, label.label_id, label.description].filter( + (v): v is string => Boolean(v) + ), + })); - return noAdd - ? outputLabels - : [ - ...outputLabels, - { - label_id: ADD_NEW_ID, - name: this.hass.localize("ui.components.label-picker.add_new"), - icon: "mdi:plus", - color: null, - description: null, - created_at: 0, - modified_at: 0, - }, - ]; + return items; } ); - protected updated(changedProps: PropertyValues) { - if ( - (!this._init && this.hass && this._labels) || - (this._init && changedProps.has("_opened") && this._opened) - ) { - this._init = true; - const items = this._getLabels( - this._labels!, - this.hass.areas, - Object.values(this.hass.devices), - Object.values(this.hass.entities), - this.includeDomains, - this.excludeDomains, - this.includeDeviceClasses, - this.deviceFilter, - this.entityFilter, - this.noAdd, - this.excludeLabels - ).map((label) => ({ - ...label, - strings: [label.label_id, label.name], - })); + private _getItems = () => + this._getLabels( + this._labels, + this.hass.areas, + this.hass.devices, + this.hass.entities, + this.includeDomains, + this.excludeDomains, + this.includeDeviceClasses, + this.deviceFilter, + this.entityFilter, + this.excludeLabels + ); - this.comboBox.items = items; - this.comboBox.filteredItems = items; + private _allLabelNames = memoizeOne((labels?: LabelRegistryEntry[]) => { + if (!labels) { + return []; } - } + return [ + ...new Set( + labels + .map((label) => label.name.toLowerCase()) + .filter(Boolean) as string[] + ), + ]; + }); + + private _getAdditionalItems = ( + searchString?: string + ): PickerComboBoxItem[] => { + if (this.noAdd) { + return []; + } + + const allLabelNames = this._allLabelNames(this._labels); + + if (searchString && !allLabelNames.includes(searchString.toLowerCase())) { + return [ + { + id: ADD_NEW_ID + searchString, + primary: this.hass.localize( + "ui.components.label-picker.add_new_sugestion", + { + name: searchString, + } + ), + icon_path: mdiPlus, + }, + ]; + } + + return [ + { + id: ADD_NEW_ID, + primary: this.hass.localize("ui.components.label-picker.add_new"), + icon_path: mdiPlus, + }, + ]; + }; protected render(): TemplateResult { + const placeholder = + this.placeholder ?? + this.hass.localize("ui.components.label-picker.label"); + return html` - label.label_id === this.placeholder) - ?.name - : undefined} - .renderer=${rowRenderer} - @filter-changed=${this._filterChanged} - @opened-changed=${this._openedChanged} - @value-changed=${this._labelChanged} + .autofocus=${this.autofocus} + .label=${this.label} + .notFoundLabel=${this.hass.localize( + "ui.components.label-picker.no_match" + )} + .placeholder=${placeholder} + .value=${this.value} + .getItems=${this._getItems} + .getAdditionalItems=${this._getAdditionalItems} + .valueRenderer=${this._valueRenderer} + @value-changed=${this._valueChanged} > - + `; } - private _filterChanged(ev: CustomEvent): void { - const target = ev.target as HaComboBox; - const filterString = ev.detail.value; - if (!filterString) { - this.comboBox.filteredItems = this.comboBox.items; - return; - } - - const filteredItems = fuzzyFilterSort( - filterString, - target.items?.filter( - (item) => ![NO_LABELS_ID, ADD_NEW_ID].includes(item.label_id) - ) || [] - ); - if (filteredItems.length === 0) { - if (this.noAdd) { - this.comboBox.filteredItems = [ - { - label_id: NO_LABELS_ID, - name: this.hass.localize("ui.components.label-picker.no_match"), - icon: null, - color: null, - }, - ] as ScorableLabelItem[]; - } else { - this._suggestion = filterString; - this.comboBox.filteredItems = [ - { - label_id: ADD_NEW_SUGGESTION_ID, - name: this.hass.localize( - "ui.components.label-picker.add_new_sugestion", - { name: this._suggestion } - ), - icon: "mdi:plus", - color: null, - }, - ] as ScorableLabelItem[]; - } - } else { - this.comboBox.filteredItems = filteredItems; - } - } - - private get _value() { - return this.value || ""; - } - - private _openedChanged(ev: ValueChangedEvent) { - this._opened = ev.detail.value; - } - - private _labelChanged(ev: ValueChangedEvent) { + private _valueChanged(ev: ValueChangedEvent) { ev.stopPropagation(); - let newValue = ev.detail.value; - if (newValue === NO_LABELS_ID) { - newValue = ""; - this.comboBox.setInputValue(""); + const value = ev.detail.value; + + if (value === NO_LABELS) { return; } - if (![ADD_NEW_SUGGESTION_ID, ADD_NEW_ID].includes(newValue)) { - if (newValue !== this._value) { - this._setValue(newValue); - } + if (!value) { + this._setValue(undefined); return; } - (ev.target as any).value = this._value; + if (value.startsWith(ADD_NEW_ID)) { + this.hass.loadFragmentTranslation("config"); - this.hass.loadFragmentTranslation("config"); + const suggestedName = value.substring(ADD_NEW_ID.length); - showLabelDetailDialog(this, { - entry: undefined, - suggestedName: newValue === ADD_NEW_SUGGESTION_ID ? this._suggestion : "", - createEntry: async (values) => { - const label = await createLabelRegistryEntry(this.hass, values); - const labels = [...this._labels!, label]; - this.comboBox.filteredItems = this._getLabels( - labels, - this.hass.areas!, - Object.values(this.hass.devices)!, - Object.values(this.hass.entities)!, - this.includeDomains, - this.excludeDomains, - this.includeDeviceClasses, - this.deviceFilter, - this.entityFilter, - this.noAdd, - this.excludeLabels - ); - await this.updateComplete; - await this.comboBox.updateComplete; - this._setValue(label.label_id); - return label; - }, - }); + showLabelDetailDialog(this, { + suggestedName: suggestedName, + createEntry: async (values) => { + try { + const label = await createLabelRegistryEntry(this.hass, values); + this._setValue(label.label_id); + } catch (err: any) { + showAlertDialog(this, { + title: this.hass.localize( + "ui.components.label-picker.failed_create_label" + ), + text: err.message, + }); + } + }, + }); + return; + } - this._suggestion = undefined; - this.comboBox.setInputValue(""); + this._setValue(value); } private _setValue(value?: string) { diff --git a/src/components/ha-labels-picker.ts b/src/components/ha-labels-picker.ts index 81590f3247..3d2db17271 100644 --- a/src/components/ha-labels-picker.ts +++ b/src/components/ha-labels-picker.ts @@ -122,6 +122,7 @@ export class HaLabelsPicker extends SubscribeMixin(LitElement) { this.hass.locale.language ); return html` + ${this.label ? html`` : nothing} ${labels?.length ? html` ${repeat( @@ -157,9 +158,6 @@ export class HaLabelsPicker extends SubscribeMixin(LitElement) { .helper=${this.helper} .disabled=${this.disabled} .required=${this.required} - .label=${this.label === undefined && this.hass - ? this.hass.localize("ui.components.label-picker.add_label") - : this.label} .placeholder=${this.placeholder} .excludeLabels=${this.value} @value-changed=${this._labelChanged} @@ -182,12 +180,7 @@ export class HaLabelsPicker extends SubscribeMixin(LitElement) { showLabelDetailDialog(this, { entry: label, updateEntry: async (values) => { - const updated = await updateLabelRegistryEntry( - this.hass, - label.label_id, - values - ); - return updated; + await updateLabelRegistryEntry(this.hass, label.label_id, values); }, }); } @@ -219,6 +212,10 @@ export class HaLabelsPicker extends SubscribeMixin(LitElement) { --ha-input-chip-selected-container-opacity: 0.5; --md-input-chip-selected-outline-width: 1px; } + label { + display: block; + margin: 0 0 8px; + } `; } diff --git a/src/components/ha-md-dialog.ts b/src/components/ha-md-dialog.ts index 61e6ae6a94..3aaf8e1a20 100644 --- a/src/components/ha-md-dialog.ts +++ b/src/components/ha-md-dialog.ts @@ -168,10 +168,10 @@ export class HaMdDialog extends Dialog { @media all and (max-width: 450px), all and (max-height: 500px) { :host(:not([type="alert"])) { min-width: calc( - 100vw - env(safe-area-inset-right) - env(safe-area-inset-left) + 100vw - var(--safe-area-inset-right) - var(--safe-area-inset-left) ); max-width: calc( - 100vw - env(safe-area-inset-right) - env(safe-area-inset-left) + 100vw - var(--safe-area-inset-right) - var(--safe-area-inset-left) ); min-height: 100%; max-height: 100%; diff --git a/src/components/ha-picker-combo-box.ts b/src/components/ha-picker-combo-box.ts index 95ab8fcbc6..029dfd7f31 100644 --- a/src/components/ha-picker-combo-box.ts +++ b/src/components/ha-picker-combo-box.ts @@ -212,6 +212,10 @@ export class HaPickerComboBox extends LitElement { this.comboBox.setTextFieldValue(""); const newValue = ev.detail.value?.trim(); + if (newValue === NO_MATCHING_ITEMS_FOUND_ID) { + return; + } + if (newValue !== this._value) { this._setValue(newValue); } diff --git a/src/components/ha-sidebar.ts b/src/components/ha-sidebar.ts index 15205ad2fa..64476ae81a 100644 --- a/src/components/ha-sidebar.ts +++ b/src/components/ha-sidebar.ts @@ -1,11 +1,9 @@ -import "@material/mwc-button/mwc-button"; import { mdiBell, mdiCalendar, mdiCellphoneCog, mdiChartBox, mdiClipboardList, - mdiClose, mdiCog, mdiFormatListBulletedType, mdiHammer, @@ -13,12 +11,11 @@ import { mdiMenu, mdiMenuOpen, mdiPlayBoxMultiple, - mdiPlus, mdiTooltipAccount, mdiViewDashboard, } from "@mdi/js"; import type { UnsubscribeFunc } from "home-assistant-js-websocket"; -import type { CSSResult, CSSResultGroup, PropertyValues } from "lit"; +import type { CSSResultGroup, PropertyValues } from "lit"; import { LitElement, css, html, nothing } from "lit"; import { customElement, @@ -29,7 +26,6 @@ import { } from "lit/decorators"; import { classMap } from "lit/directives/class-map"; import memoizeOne from "memoize-one"; -import { storage } from "../common/decorators/storage"; import { fireEvent } from "../common/dom/fire_event"; import { toggleAttribute } from "../common/dom/toggle_attribute"; import { stringCompare } from "../common/string/compare"; @@ -40,6 +36,7 @@ import { subscribeNotifications } from "../data/persistent_notification"; import { subscribeRepairsIssueRegistry } from "../data/repairs"; import type { UpdateEntity } from "../data/update"; import { updateCanInstall } from "../data/update"; +import { showEditSidebarDialog } from "../dialogs/sidebar/show-dialog-edit-sidebar"; import { SubscribeMixin } from "../mixins/subscribe-mixin"; import { actionHandler } from "../panels/lovelace/common/directives/action-handler-directive"; import { haStyleScrollbar } from "../resources/styles"; @@ -49,8 +46,6 @@ import "./ha-icon-button"; import "./ha-md-list"; import "./ha-md-list-item"; import type { HaMdListItem } from "./ha-md-list-item"; -import "./ha-menu-button"; -import "./ha-sortable"; import "./ha-svg-icon"; import "./user/ha-user-badge"; @@ -67,7 +62,7 @@ const SORT_VALUE_URL_PATHS = { config: 11, }; -const PANEL_ICONS = { +export const PANEL_ICONS = { calendar: mdiCalendar, "developer-tools": mdiHammer, energy: mdiLightningBolt, @@ -140,7 +135,7 @@ const defaultPanelSorter = ( return stringCompare(a.title!, b.title!, language); }; -const computePanels = memoizeOne( +export const computePanels = memoizeOne( ( panels: HomeAssistant["panels"], defaultPanel: HomeAssistant["defaultPanel"], @@ -192,8 +187,11 @@ class HaSidebar extends SubscribeMixin(LitElement) { @property({ attribute: "always-expand", type: Boolean }) public alwaysExpand = false; - @property({ attribute: "edit-mode", type: Boolean }) - public editMode = false; + @property({ attribute: false }) + public panelOrder!: string[]; + + @property({ attribute: false }) + public hiddenPanels!: string[]; @state() private _notifications?: PersistentNotification[]; @@ -207,26 +205,8 @@ class HaSidebar extends SubscribeMixin(LitElement) { private _recentKeydownActiveUntil = 0; - private _editStyleLoaded = false; - private _unsubPersistentNotifications: UnsubscribeFunc | undefined; - @state() - @storage({ - key: "sidebarPanelOrder", - state: true, - subscribe: true, - }) - private _panelOrder: string[] = []; - - @state() - @storage({ - key: "sidebarHiddenPanels", - state: true, - subscribe: true, - }) - private _hiddenPanels: string[] = []; - @query(".tooltip") private _tooltip!: HTMLDivElement; public hassSubscribe(): UnsubscribeFunc[] { @@ -270,13 +250,12 @@ class HaSidebar extends SubscribeMixin(LitElement) { changedProps.has("expanded") || changedProps.has("narrow") || changedProps.has("alwaysExpand") || - changedProps.has("editMode") || changedProps.has("_externalConfig") || changedProps.has("_updatesCount") || changedProps.has("_issuesCount") || changedProps.has("_notifications") || - changedProps.has("_hiddenPanels") || - changedProps.has("_panelOrder") + changedProps.has("hiddenPanels") || + changedProps.has("panelOrder") ) { return true; } @@ -322,9 +301,6 @@ class HaSidebar extends SubscribeMixin(LitElement) { if (changedProps.has("alwaysExpand")) { toggleAttribute(this, "expanded", this.alwaysExpand); } - if (changedProps.has("editMode") && this.editMode) { - this._editModeActivated(); - } if (!changedProps.has("hass")) { return; } @@ -374,8 +350,7 @@ class HaSidebar extends SubscribeMixin(LitElement) { class="menu" @action=${this._handleAction} .actionHandler=${actionHandler({ - hasHold: !this.editMode, - disabled: this.editMode, + hasHold: true, })} > ${!this.narrow @@ -389,11 +364,7 @@ class HaSidebar extends SubscribeMixin(LitElement) { > ` : ""} - ${this.editMode - ? html` - ${this.hass.localize("ui.sidebar.done")} - ` - : html`
Home Assistant
`} +
Home Assistant
`; } @@ -401,14 +372,13 @@ class HaSidebar extends SubscribeMixin(LitElement) { const [beforeSpacer, afterSpacer] = computePanels( this.hass.panels, this.hass.defaultPanel, - this._panelOrder, - this._hiddenPanels, + this.panelOrder, + this.hiddenPanels, this.hass.locale ); // prettier-ignore return html` - - ${this.editMode - ? this._renderPanelsEdit(beforeSpacer, selectedPanel) - : this._renderPanels(beforeSpacer, selectedPanel)} + ${this._renderPanels(beforeSpacer, selectedPanel)} ${this._renderSpacer()} ${this._renderPanels(afterSpacer, selectedPanel)} ${this._renderExternalConfiguration()} - `; } - private _renderPanels( - panels: PanelInfo[], - selectedPanel: string, - sortable = false - ) { + private _renderPanels(panels: PanelInfo[], selectedPanel: string) { return panels.map((panel) => this._renderPanel( panel.url_path, @@ -444,36 +407,26 @@ class HaSidebar extends SubscribeMixin(LitElement) { : panel.url_path in PANEL_ICONS ? PANEL_ICONS[panel.url_path] : undefined, - selectedPanel, - sortable + selectedPanel ) ); } - private _renderPanelsEdit(beforeSpacer: PanelInfo[], selectedPanel: string) { - return html` - ${this._renderPanels(beforeSpacer, selectedPanel, true)} - ${this._renderSpacer()}${this._renderHiddenPanels()} - `; - } - private _renderPanel( urlPath: string, title: string | null, icon: string | null | undefined, iconPath: string | null | undefined, - selectedPanel: string, - sortable = false + selectedPanel: string ) { return urlPath === "config" ? this._renderConfiguration(title, selectedPanel) : html` ` : html``} ${title} - ${this.editMode - ? html`` - : nothing} `; } - private _panelMoved(ev: CustomEvent) { - ev.stopPropagation(); - const { oldIndex, newIndex } = ev.detail; - - const [beforeSpacer] = computePanels( - this.hass.panels, - this.hass.defaultPanel, - this._panelOrder, - this._hiddenPanels, - this.hass.locale - ); - - const panelOrder = beforeSpacer.map((panel) => panel.url_path); - const panel = panelOrder.splice(oldIndex, 1)[0]; - panelOrder.splice(newIndex, 0, panel); - - this._panelOrder = panelOrder; - } - - private _renderHiddenPanels() { - return html`${this._hiddenPanels.length - ? html`${this._hiddenPanels.map((url) => { - const panel = this.hass.panels[url]; - if (!panel) { - return ""; - } - return html` - ${panel.url_path === this.hass.defaultPanel && !panel.icon - ? html`` - : panel.url_path in PANEL_ICONS - ? html`` - : html``} - ${panel.url_path === this.hass.defaultPanel - ? this.hass.localize("panel.states") - : this.hass.localize(`panel.${panel.title}`) || - panel.title} - - `; - })} - ${this._renderSpacer()}` - : ""}`; - } - private _renderDivider() { return html`
`; } @@ -677,48 +559,17 @@ class HaSidebar extends SubscribeMixin(LitElement) { return; } - fireEvent(this, "hass-edit-sidebar", { editMode: true }); + showEditSidebarDialog(this, { + saveCallback: this._saveSidebar, + }); } - private async _editModeActivated() { - await this._loadEditStyle(); - } - - private async _loadEditStyle() { - if (this._editStyleLoaded) return; - - const editStylesImport = await import("../resources/ha-sidebar-edit-style"); - - const style = document.createElement("style"); - style.innerHTML = (editStylesImport.sidebarEditStyle as CSSResult).cssText; - this.shadowRoot!.appendChild(style); - - await this.updateComplete; - } - - private _closeEditMode() { - fireEvent(this, "hass-edit-sidebar", { editMode: false }); - } - - private async _hidePanel(ev: Event) { - ev.preventDefault(); - const panel = (ev.currentTarget as any).panel; - if (this._hiddenPanels.includes(panel)) { - return; - } - // Make a copy for Memoize - this._hiddenPanels = [...this._hiddenPanels, panel]; - // Remove it from the panel order - this._panelOrder = this._panelOrder.filter((order) => order !== panel); - } - - private async _unhidePanel(ev: Event) { - ev.preventDefault(); - const panel = (ev.currentTarget as any).panel; - this._hiddenPanels = this._hiddenPanels.filter( - (hidden) => hidden !== panel - ); - } + private _saveSidebar = (order: string[], hidden: string[]) => { + fireEvent(this, "hass-edit-sidebar", { + order, + hidden, + }); + }; private _itemMouseEnter(ev: MouseEvent) { // On keypresses on the listbox, we're going to ignore mouse enter events @@ -851,12 +702,12 @@ class HaSidebar extends SubscribeMixin(LitElement) { ); font-size: var(--ha-font-size-xl); align-items: center; - padding-left: calc(4px + env(safe-area-inset-left)); - padding-inline-start: calc(4px + env(safe-area-inset-left)); + padding-left: calc(4px + var(--safe-area-inset-left)); + padding-inline-start: calc(4px + var(--safe-area-inset-left)); padding-inline-end: initial; } :host([expanded]) .menu { - width: calc(256px + env(safe-area-inset-left)); + width: calc(256px + var(--safe-area-inset-left)); } .menu ha-icon-button { color: var(--sidebar-icon-color); @@ -875,12 +726,6 @@ class HaSidebar extends SubscribeMixin(LitElement) { :host([expanded]) .title { display: initial; } - :host([expanded]) .menu mwc-button { - margin: 0 8px; - } - .menu mwc-button { - width: 100%; - } .hidden-panel { display: none; } @@ -890,11 +735,11 @@ class HaSidebar extends SubscribeMixin(LitElement) { box-sizing: border-box; height: calc(100% - var(--header-height) - 132px); height: calc( - 100% - var(--header-height) - 132px - env(safe-area-inset-bottom) + 100% - var(--header-height) - 132px - var(--safe-area-inset-bottom) ); overflow-x: hidden; background: none; - margin-left: env(safe-area-inset-left); + margin-left: var(--safe-area-inset-left); } ha-md-list-item { @@ -914,7 +759,7 @@ class HaSidebar extends SubscribeMixin(LitElement) { } :host([expanded]) ha-md-list-item { width: 248px; - width: calc(248px - env(safe-area-inset-left)); + width: calc(248px - var(--safe-area-inset-left)); } ha-md-list-item.selected { @@ -949,7 +794,6 @@ class HaSidebar extends SubscribeMixin(LitElement) { } ha-md-list-item .item-text { - font-family: var(--ha-font-family-body); display: none; font-size: var(--ha-font-size-m); font-weight: var(--ha-font-weight-medium); diff --git a/src/components/ha-target-picker.ts b/src/components/ha-target-picker.ts index 20fd210484..e7a98128b7 100644 --- a/src/components/ha-target-picker.ts +++ b/src/components/ha-target-picker.ts @@ -419,7 +419,10 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) { .hass=${this.hass} id="input" .type=${"device_id"} - .label=${this.hass.localize( + .placeholder=${this.hass.localize( + "ui.components.target-picker.add_device_id" + )} + .searchLabel=${this.hass.localize( "ui.components.target-picker.add_device_id" )} .deviceFilter=${this.deviceFilter} @@ -438,7 +441,10 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) { .hass=${this.hass} id="input" .type=${"label_id"} - .label=${this.hass.localize( + .placeholder=${this.hass.localize( + "ui.components.target-picker.add_label_id" + )} + .searchLabel=${this.hass.localize( "ui.components.target-picker.add_label_id" )} no-add diff --git a/src/components/ha-time-input.ts b/src/components/ha-time-input.ts index 7d55082960..a6e380accd 100644 --- a/src/components/ha-time-input.ts +++ b/src/components/ha-time-input.ts @@ -28,22 +28,30 @@ export class HaTimeInput extends LitElement { protected render() { const useAMPM = useAmPm(this.locale); - const parts = this.value?.split(":") || []; - let hours = parts[0]; - const numberHours = Number(parts[0]); - if (numberHours && useAMPM && numberHours > 12 && numberHours < 24) { - hours = String(numberHours - 12).padStart(2, "0"); - } - if (useAMPM && numberHours === 0) { - hours = "12"; + let hours = NaN; + let minutes = NaN; + let seconds = NaN; + let numberHours = 0; + if (this.value) { + const parts = this.value?.split(":") || []; + minutes = parts[1] ? Number(parts[1]) : 0; + seconds = parts[2] ? Number(parts[2]) : 0; + hours = parts[0] ? Number(parts[0]) : 0; + numberHours = hours; + if (numberHours && useAMPM && numberHours > 12 && numberHours < 24) { + hours = numberHours - 12; + } + if (useAMPM && numberHours === 0) { + hours = 12; + } } return html` = 12 ? "PM" : "AM"} .disabled=${this.disabled} @@ -52,6 +60,11 @@ export class HaTimeInput extends LitElement { .required=${this.required} .clearable=${this.clearable && this.value !== undefined} .helper=${this.helper} + day-label="dd" + hour-label="hh" + min-label="mm" + sec-label="ss" + ms-label="ms" > `; } diff --git a/src/components/ha-toast.ts b/src/components/ha-toast.ts index 8946aee00c..4f6be82b24 100644 --- a/src/components/ha-toast.ts +++ b/src/components/ha-toast.ts @@ -14,9 +14,9 @@ export class HaToast extends Snackbar { .mdc-snackbar { margin: 8px; - right: calc(8px + env(safe-area-inset-right)); - bottom: calc(8px + env(safe-area-inset-bottom)); - left: calc(8px + env(safe-area-inset-left)); + right: calc(8px + var(--safe-area-inset-right)); + bottom: calc(8px + var(--safe-area-inset-bottom)); + left: calc(8px + var(--safe-area-inset-left)); } .mdc-snackbar__surface { @@ -37,9 +37,9 @@ export class HaToast extends Snackbar { @media all and (max-width: 450px), all and (max-height: 500px) { .mdc-snackbar { - right: env(safe-area-inset-right); - bottom: env(safe-area-inset-bottom); - left: env(safe-area-inset-left); + right: var(--safe-area-inset-right); + bottom: var(--safe-area-inset-bottom); + left: var(--safe-area-inset-left); } .mdc-snackbar__surface { min-width: 100%; diff --git a/src/components/media-player/ha-browse-media-tts.ts b/src/components/media-player/ha-browse-media-tts.ts index 1924af09a4..c6b327975c 100644 --- a/src/components/media-player/ha-browse-media-tts.ts +++ b/src/components/media-player/ha-browse-media-tts.ts @@ -214,6 +214,7 @@ class BrowseMediaTTS extends LitElement { item.media_content_id = `${ item.media_content_id.split("?")[0] }?${query.toString()}`; + item.media_content_type = "audio/mp3"; item.can_play = true; item.title = message; fireEvent(this, "tts-picked", { item }); diff --git a/src/components/user/ha-user-picker.ts b/src/components/user/ha-user-picker.ts index d16e68cf1d..2959a37d89 100644 --- a/src/components/user/ha-user-picker.ts +++ b/src/components/user/ha-user-picker.ts @@ -1,21 +1,30 @@ +import type { ComboBoxLitRenderer } from "@vaadin/combo-box/lit"; import type { TemplateResult } from "lit"; -import { css, html, LitElement } from "lit"; -import { property } from "lit/decorators"; +import { html, LitElement, nothing } from "lit"; +import { customElement, property } from "lit/decorators"; import memoizeOne from "memoize-one"; import { fireEvent } from "../../common/dom/fire_event"; -import { stringCompare } from "../../common/string/compare"; import type { User } from "../../data/user"; import { fetchUsers } from "../../data/user"; import type { HomeAssistant } from "../../types"; -import "../ha-select"; +import "../ha-combo-box-item"; +import "../ha-generic-picker"; +import type { PickerComboBoxItem } from "../ha-picker-combo-box"; +import type { PickerValueRenderer } from "../ha-picker-field"; import "./ha-user-badge"; -import "../ha-list-item"; +interface UserComboBoxItem extends PickerComboBoxItem { + user?: User; +} + +@customElement("ha-user-picker") class HaUserPicker extends LitElement { - public hass?: HomeAssistant; + @property({ attribute: false }) public hass!: HomeAssistant; @property() public label?: string; + @property() public placeholder?: string; + @property({ attribute: false }) public noUserLabel?: string; @property() public value = ""; @@ -24,78 +33,124 @@ class HaUserPicker extends LitElement { @property({ type: Boolean }) public disabled = false; - private _sortedUsers = memoizeOne((users?: User[]) => { + protected firstUpdated(changedProps) { + super.firstUpdated(changedProps); + if (!this.users) { + this._fetchUsers(); + } + } + + private async _fetchUsers() { + this.users = await fetchUsers(this.hass); + } + + private usersMap = memoizeOne((users?: User[]): Map => { + if (!users) { + return new Map(); + } + return new Map(users.map((user) => [user.id, user])); + }); + + private _valueRenderer: PickerValueRenderer = (value) => { + const user = this.usersMap(this.users).get(value); + if (!user) { + return html` ${value} `; + } + + return html` + + ${user.name} + `; + }; + + private _rowRenderer: ComboBoxLitRenderer = (item) => { + const user = item.user; + if (!user) { + return html` + ${item.icon + ? html`` + : item.icon_path + ? html`` + : nothing} + ${item.primary} + ${item.secondary + ? html`${item.secondary}` + : nothing} + `; + } + + return html` + + + ${item.primary} + + `; + }; + + private _getUsers = memoizeOne((users?: User[]) => { if (!users) { return []; } return users .filter((user) => !user.system_generated) - .sort((a, b) => - stringCompare(a.name, b.name, this.hass!.locale.language) - ); + .map((user) => ({ + id: user.id, + primary: user.name, + domain_name: user.name, + search_labels: [user.name, user.id, user.username].filter( + Boolean + ) as string[], + sorting_label: user.name, + user, + })); }); + private _getItems = () => this._getUsers(this.users); + protected render(): TemplateResult { + const placeholder = + this.placeholder ?? this.hass.localize("ui.components.user-picker.user"); + return html` - - ${this.users?.length === 0 - ? html` - ${this.noUserLabel || - this.hass?.localize("ui.components.user-picker.no_user")} - ` - : ""} - ${this._sortedUsers(this.users).map( - (user) => html` - - - ${user.name} - - ` + .notFoundLabel=${this.hass.localize( + "ui.components.user-picker.no_match" )} - + .placeholder=${placeholder} + .value=${this.value} + .getItems=${this._getItems} + .valueRenderer=${this._valueRenderer} + .rowRenderer=${this._rowRenderer} + @value-changed=${this._valueChanged} + > + `; } - protected firstUpdated(changedProps) { - super.firstUpdated(changedProps); - if (this.users === undefined) { - fetchUsers(this.hass!).then((users) => { - this.users = users; - }); - } + private _valueChanged(ev) { + const value = ev.detail.value; + + this.value = value; + fireEvent(this, "value-changed", { value }); + fireEvent(this, "change"); } - - private _userChanged(ev) { - const newValue = ev.target.value; - - if (newValue !== this.value) { - this.value = newValue; - setTimeout(() => { - fireEvent(this, "value-changed", { value: newValue }); - fireEvent(this, "change"); - }, 0); - } - } - - static styles = css` - :host { - display: inline-block; - } - `; } -customElements.define("ha-user-picker", HaUserPicker); - declare global { interface HTMLElementTagNameMap { "ha-user-picker": HaUserPicker; diff --git a/src/components/user/ha-users-picker.ts b/src/components/user/ha-users-picker.ts index d3160be467..a6c01df6fe 100644 --- a/src/components/user/ha-users-picker.ts +++ b/src/components/user/ha-users-picker.ts @@ -1,4 +1,3 @@ -import { mdiClose } from "@mdi/js"; import { css, html, LitElement, nothing } from "lit"; import { customElement, property } from "lit/decorators"; import { guard } from "lit/directives/guard"; @@ -6,13 +5,15 @@ import memoizeOne from "memoize-one"; import { fireEvent } from "../../common/dom/fire_event"; import type { User } from "../../data/user"; import { fetchUsers } from "../../data/user"; -import type { ValueChangedEvent, HomeAssistant } from "../../types"; +import type { HomeAssistant, ValueChangedEvent } from "../../types"; import "../ha-icon-button"; import "./ha-user-picker"; @customElement("ha-users-picker") -class HaUsersPickerLight extends LitElement { - @property({ attribute: false }) public hass?: HomeAssistant; +class HaUsersPicker extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @property() public label?: string; @property({ attribute: false }) public value?: string[]; @@ -29,13 +30,15 @@ class HaUsersPickerLight extends LitElement { protected firstUpdated(changedProps) { super.firstUpdated(changedProps); - if (this.users === undefined) { - fetchUsers(this.hass!).then((users) => { - this.users = users; - }); + if (!this.users) { + this._fetchUsers(); } } + private async _fetchUsers() { + this.users = await fetchUsers(this.hass); + } + protected render() { if (!this.hass || !this.users) { return nothing; @@ -43,15 +46,13 @@ class HaUsersPickerLight extends LitElement { const notSelectedUsers = this._notSelectedUsers(this.users, this.value); return html` + ${this.label ? html`` : nothing} ${guard([notSelectedUsers], () => this.value?.map( (user_id, idx) => html`
- - >
` ) )} - +
+ +
`; } @@ -120,12 +113,12 @@ class HaUsersPickerLight extends LitElement { }); } - private _userChanged(event: ValueChangedEvent) { - event.stopPropagation(); - const index = (event.currentTarget as any).index; - const newValue = event.detail.value; + private _userChanged(ev: ValueChangedEvent) { + ev.stopPropagation(); + const index = (ev.currentTarget as any).index; + const newValue = ev.detail.value; const newUsers = [...this._currentUsers]; - if (newValue === "") { + if (!newValue) { newUsers.splice(index, 1); } else { newUsers.splice(index, 1, newValue); @@ -148,24 +141,15 @@ class HaUsersPickerLight extends LitElement { this._updateUsers([...currentUsers, toAdd]); } - private _removeUser(event) { - const userId = (event.currentTarget as any).userId; - this._updateUsers(this._currentUsers.filter((user) => user !== userId)); - } - - static styles = css` - :host { - display: block; - } + static override styles = css` div { - display: flex; - align-items: center; + margin-top: 8px; } `; } declare global { interface HTMLElementTagNameMap { - "ha-users-picker": HaUsersPickerLight; + "ha-users-picker": HaUsersPicker; } } diff --git a/src/data/assist_pipeline.ts b/src/data/assist_pipeline.ts index 72da3c5e9a..8c6d10949a 100644 --- a/src/data/assist_pipeline.ts +++ b/src/data/assist_pipeline.ts @@ -1,6 +1,5 @@ import type { HomeAssistant } from "../types"; import type { ConversationResult } from "./conversation"; -import type { ResolvedMediaSource } from "./media_source"; import type { SpeechMetadata } from "./stt"; export interface AssistPipeline { @@ -53,10 +52,16 @@ interface PipelineRunStartEvent extends PipelineEventBase { data: { pipeline: string; language: string; + conversation_id: string; runner_data: { stt_binary_handler_id: number | null; timeout: number; }; + tts_output?: { + token: string; + url: string; + mime_type: string; + }; }; } interface PipelineRunEndEvent extends PipelineEventBase { @@ -109,7 +114,7 @@ interface PipelineIntentStartEvent extends PipelineEventBase { }; } -interface ConversationChatLogAssistantDelta { +export interface ConversationChatLogAssistantDelta { role: "assistant"; content: string; tool_calls: { @@ -119,7 +124,7 @@ interface ConversationChatLogAssistantDelta { }[]; } -interface ConversationChatLogToolResultDelta { +export interface ConversationChatLogToolResultDelta { role: "tool_result"; agent_id: string; tool_call_id: string; @@ -156,7 +161,12 @@ interface PipelineTTSStartEvent extends PipelineEventBase { interface PipelineTTSEndEvent extends PipelineEventBase { type: "tts-end"; data: { - tts_output: ResolvedMediaSource; + tts_output: { + media_id: string; + token: string; + url: string; + mime_type: string; + }; }; } diff --git a/src/dialogs/more-info/controls/more-info-update.ts b/src/dialogs/more-info/controls/more-info-update.ts index f303233e47..4bb768c1c7 100644 --- a/src/dialogs/more-info/controls/more-info-update.ts +++ b/src/dialogs/more-info/controls/more-info-update.ts @@ -471,8 +471,8 @@ class MoreInfoUpdate extends LitElement { position: sticky; bottom: 0; margin: 0 -24px 0 -24px; - margin-bottom: calc(-1 * max(env(safe-area-inset-bottom), 24px)); - padding-bottom: env(safe-area-inset-bottom); + margin-bottom: calc(-1 * max(var(--safe-area-inset-bottom), 24px)); + padding-bottom: var(--safe-area-inset-bottom); box-sizing: border-box; display: flex; flex-direction: column; diff --git a/src/dialogs/more-info/ha-more-info-info.ts b/src/dialogs/more-info/ha-more-info-info.ts index 0517dd206c..5662726d2d 100644 --- a/src/dialogs/more-info/ha-more-info-info.ts +++ b/src/dialogs/more-info/ha-more-info-info.ts @@ -128,7 +128,7 @@ export class MoreInfoInfo extends LitElement { flex-direction: column; flex: 1; padding: 24px; - padding-bottom: max(env(safe-area-inset-bottom), 24px); + padding-bottom: max(var(--safe-area-inset-bottom), 24px); } [data-domain="camera"] .content { diff --git a/src/dialogs/notifications/notification-drawer.ts b/src/dialogs/notifications/notification-drawer.ts index a3c83bcd69..bbb41039eb 100644 --- a/src/dialogs/notifications/notification-drawer.ts +++ b/src/dialogs/notifications/notification-drawer.ts @@ -159,11 +159,11 @@ export class HuiNotificationDrawer extends LitElement { .notifications { overflow-y: auto; padding-top: 16px; - padding-left: env(safe-area-inset-left); - padding-right: env(safe-area-inset-right); - padding-inline-start: env(safe-area-inset-left); - padding-inline-end: env(safe-area-inset-right); - padding-bottom: env(safe-area-inset-bottom); + padding-left: var(--safe-area-inset-left); + padding-right: var(--safe-area-inset-right); + padding-inline-start: var(--safe-area-inset-left); + padding-inline-end: var(--safe-area-inset-right); + padding-bottom: var(--safe-area-inset-bottom); height: calc(100% - 1px - var(--header-height)); box-sizing: border-box; background-color: var(--primary-background-color); diff --git a/src/dialogs/quick-bar/ha-quick-bar.ts b/src/dialogs/quick-bar/ha-quick-bar.ts index f27ff5b428..68b2fa2a5f 100644 --- a/src/dialogs/quick-bar/ha-quick-bar.ts +++ b/src/dialogs/quick-bar/ha-quick-bar.ts @@ -28,6 +28,7 @@ import { import { computeDomain } from "../../common/entity/compute_domain"; import { computeEntityName } from "../../common/entity/compute_entity_name"; import { computeStateName } from "../../common/entity/compute_state_name"; +import { getDeviceContext } from "../../common/entity/context/get_device_context"; import { getEntityContext } from "../../common/entity/context/get_entity_context"; import { navigate } from "../../common/navigate"; import { caseInsensitiveStringCompare } from "../../common/string/compare"; @@ -41,6 +42,7 @@ 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"; @@ -50,6 +52,7 @@ 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"; @@ -75,6 +78,8 @@ interface EntityItem extends QuickBarItem { interface DeviceItem extends QuickBarItem { deviceId: string; + domain?: string; + translatedDomain?: string; area?: string; } @@ -297,7 +302,8 @@ export class QuickBar extends LitElement { this._commandItems = this._commandItems || (await this._generateCommandItems()); } else if (this._mode === QuickBarMode.Device) { - this._deviceItems = this._deviceItems || this._generateDeviceItems(); + this._deviceItems = + this._deviceItems || (await this._generateDeviceItems()); } else { this._entityItems = this._entityItems || (await this._generateEntityItems()); @@ -344,10 +350,28 @@ export class QuickBar extends LitElement { tabindex="0" type="button" > + ${item.domain + ? html`` + : nothing} ${item.primaryText} ${item.area ? html` ${item.area} ` : nothing} + ${item.translatedDomain + ? html`
+ ${item.translatedDomain} +
` + : nothing} `; } @@ -549,23 +573,44 @@ export class QuickBar extends LitElement { ); } - private _generateDeviceItems(): DeviceItem[] { + 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 area = device.area_id - ? this.hass.areas[device.area_id] - : undefined; + const deviceName = computeDeviceNameDisplay(device, this.hass); + + const { area } = getDeviceContext(device, this.hass); + + const areaName = area ? computeAreaName(area) : undefined; + const deviceItem = { - primaryText: computeDeviceNameDisplay(device, this.hass), + primaryText: deviceName, deviceId: device.id, - area: area?.name, + 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, - strings: [deviceItem.primaryText], + domain, + translatedDomain, + strings: [deviceName, areaName, domain, domainToName].filter( + Boolean + ) as string[], }; }) .sort((a, b) => @@ -1036,6 +1081,11 @@ export class QuickBar extends LitElement { white-space: nowrap; } + ha-md-list-item img { + width: 32px; + height: 32px; + } + ha-tip { padding: 20px; } diff --git a/src/dialogs/sidebar/dialog-edit-sidebar.ts b/src/dialogs/sidebar/dialog-edit-sidebar.ts new file mode 100644 index 0000000000..efc42284cc --- /dev/null +++ b/src/dialogs/sidebar/dialog-edit-sidebar.ts @@ -0,0 +1,159 @@ +import "@material/mwc-linear-progress/mwc-linear-progress"; +import { mdiClose } from "@mdi/js"; +import { css, html, LitElement, nothing } from "lit"; +import { customElement, property, query, state } from "lit/decorators"; +import memoizeOne from "memoize-one"; +import { fireEvent } from "../../common/dom/fire_event"; +import "../../components/ha-dialog-header"; +import "../../components/ha-icon-button"; +import "../../components/ha-items-display-editor"; +import type { DisplayValue } from "../../components/ha-items-display-editor"; +import "../../components/ha-md-dialog"; +import type { HaMdDialog } from "../../components/ha-md-dialog"; +import { computePanels, PANEL_ICONS } from "../../components/ha-sidebar"; +import type { HomeAssistant } from "../../types"; +import type { EditSidebarDialogParams } from "./show-dialog-edit-sidebar"; + +@customElement("dialog-edit-sidebar") +class DialogEditSidebar extends LitElement { + @property({ attribute: false }) public hass!: HomeAssistant; + + @state() private _open = false; + + @query("ha-md-dialog") private _dialog?: HaMdDialog; + + @state() private _order: string[] = []; + + @state() private _hidden: string[] = []; + + private _saveCallback?: (order: string[], hidden: string[]) => void; + + public async showDialog(params: EditSidebarDialogParams): Promise { + this._open = true; + + const storedOrder = localStorage.getItem("sidebarPanelOrder"); + const storedHidden = localStorage.getItem("sidebarHiddenPanels"); + + this._order = storedOrder ? JSON.parse(storedOrder) : this._order; + this._hidden = storedHidden ? JSON.parse(storedHidden) : this._hidden; + this._saveCallback = params.saveCallback; + } + + private _dialogClosed(): void { + this._open = false; + fireEvent(this, "dialog-closed", { dialog: this.localName }); + } + + public closeDialog(): void { + this._dialog?.close(); + } + + private _panels = memoizeOne((panels: HomeAssistant["panels"]) => + panels ? Object.values(panels) : [] + ); + + protected render() { + if (!this._open) { + return nothing; + } + + const dialogTitle = this.hass.localize("ui.sidebar.edit_sidebar"); + + const panels = this._panels(this.hass.panels); + + const [beforeSpacer, afterSpacer] = computePanels( + this.hass.panels, + this.hass.defaultPanel, + this._order, + this._hidden, + this.hass.locale + ); + + const items = [ + ...beforeSpacer, + ...panels.filter((panel) => this._hidden.includes(panel.url_path)), + ...afterSpacer.filter((panel) => panel.url_path !== "config"), + ].map((panel) => ({ + value: panel.url_path, + label: + panel.url_path === this.hass.defaultPanel + ? panel.title || this.hass.localize("panel.states") + : this.hass.localize(`panel.${panel.title}`) || panel.title || "?", + icon: panel.icon || undefined, + iconPath: + panel.url_path === this.hass.defaultPanel && !panel.icon + ? PANEL_ICONS.lovelace + : panel.url_path in PANEL_ICONS + ? PANEL_ICONS[panel.url_path] + : undefined, + disableSorting: panel.url_path === "developer-tools", + })); + + return html` + + + + ${dialogTitle} + +
+ + +
+
+ + ${this.hass.localize("ui.common.cancel")} + + + ${this.hass.localize("ui.common.save")} + +
+
+ `; + } + + private _changed(ev: CustomEvent<{ value: DisplayValue }>): void { + const { order = [], hidden = [] } = ev.detail.value; + this._order = [...order]; + this._hidden = [...hidden]; + } + + private _save(): void { + this._saveCallback?.(this._order ?? [], this._hidden ?? []); + this.closeDialog(); + } + + static styles = css` + ha-md-dialog { + min-width: 600px; + max-height: 90%; + } + + @media all and (max-width: 600px), all and (max-height: 500px) { + ha-md-dialog { + --md-dialog-container-shape: 0; + min-width: 100%; + min-height: 100%; + } + } + `; +} + +declare global { + interface HTMLElementTagNameMap { + "dialog-edit-sidebar": DialogEditSidebar; + } +} diff --git a/src/dialogs/sidebar/show-dialog-edit-sidebar.ts b/src/dialogs/sidebar/show-dialog-edit-sidebar.ts new file mode 100644 index 0000000000..4a88bafd6b --- /dev/null +++ b/src/dialogs/sidebar/show-dialog-edit-sidebar.ts @@ -0,0 +1,18 @@ +import { fireEvent } from "../../common/dom/fire_event"; + +export interface EditSidebarDialogParams { + saveCallback: (order: string[], hidden: string[]) => void; +} + +export const loadEditSidebarDialog = () => import("./dialog-edit-sidebar"); + +export const showEditSidebarDialog = ( + element: HTMLElement, + dialogParams: EditSidebarDialogParams +): void => { + fireEvent(element, "show-dialog", { + dialogTag: "dialog-edit-sidebar", + dialogImport: loadEditSidebarDialog, + dialogParams, + }); +}; diff --git a/src/external_app/external_app_entrypoint.ts b/src/external_app/external_app_entrypoint.ts index e5aac787a8..66edfdb504 100644 --- a/src/external_app/external_app_entrypoint.ts +++ b/src/external_app/external_app_entrypoint.ts @@ -7,6 +7,7 @@ This is the entry point for providing external app stuff from app entrypoint. import { fireEvent } from "../common/dom/fire_event"; import { mainWindow } from "../common/dom/get_main_window"; +import { navigate } from "../common/navigate"; import { showAutomationEditor } from "../data/automation"; import type { HomeAssistantMain } from "../layouts/home-assistant-main"; import type { @@ -50,7 +51,7 @@ export const addExternalBarCodeListener = ( }; }; -const handleExternalMessage = ( +export const handleExternalMessage = ( hassMainEl: HomeAssistantMain, msg: EMIncomingMessageCommands ): boolean => { @@ -64,6 +65,14 @@ const handleExternalMessage = ( success: true, result: null, }); + } else if (msg.command === "navigate") { + navigate(msg.payload.path, msg.payload.options); + bus.fireMessage({ + id: msg.id, + type: "result", + success: true, + result: null, + }); } else if (msg.command === "notifications/show") { fireEvent(hassMainEl, "hass-show-notifications"); bus.fireMessage({ diff --git a/src/external_app/external_messaging.ts b/src/external_app/external_messaging.ts index 773182c248..be25a5f223 100644 --- a/src/external_app/external_messaging.ts +++ b/src/external_app/external_messaging.ts @@ -1,3 +1,4 @@ +import type { NavigateOptions } from "../common/navigate"; import type { AutomationConfig } from "../data/automation"; const CALLBACK_EXTERNAL_BUS = "externalBus"; @@ -178,31 +179,40 @@ type EMOutgoingMessageWithoutAnswer = | EMOutgoingMessageImprovScan | EMOutgoingMessageImprovConfigureDevice; -interface EMIncomingMessageRestart { +export interface EMIncomingMessageRestart { id: number; type: "command"; command: "restart"; } +export interface EMIncomingMessageNavigate { + id: number; + type: "command"; + command: "navigate"; + payload: { + path: string; + options?: NavigateOptions; + }; +} -interface EMIncomingMessageShowNotifications { +export interface EMIncomingMessageShowNotifications { id: number; type: "command"; command: "notifications/show"; } -interface EMIncomingMessageToggleSidebar { +export interface EMIncomingMessageToggleSidebar { id: number; type: "command"; command: "sidebar/toggle"; } -interface EMIncomingMessageShowSidebar { +export interface EMIncomingMessageShowSidebar { id: number; type: "command"; command: "sidebar/show"; } -interface EMIncomingMessageShowAutomationEditor { +export interface EMIncomingMessageShowAutomationEditor { id: number; type: "command"; command: "automation/editor/show"; @@ -250,14 +260,14 @@ export interface ImprovDiscoveredDevice { name: string; } -interface EMIncomingMessageImprovDeviceDiscovered extends EMMessage { +export interface EMIncomingMessageImprovDeviceDiscovered extends EMMessage { id: number; type: "command"; command: "improv/discovered_device"; payload: ImprovDiscoveredDevice; } -interface EMIncomingMessageImprovDeviceSetupDone extends EMMessage { +export interface EMIncomingMessageImprovDeviceSetupDone extends EMMessage { id: number; type: "command"; command: "improv/device_setup_done"; @@ -265,6 +275,7 @@ interface EMIncomingMessageImprovDeviceSetupDone extends EMMessage { export type EMIncomingMessageCommands = | EMIncomingMessageRestart + | EMIncomingMessageNavigate | EMIncomingMessageShowNotifications | EMIncomingMessageToggleSidebar | EMIncomingMessageShowSidebar diff --git a/src/html/_preload_roboto.html.template b/src/html/_preload_roboto.html.template index 382beadfc0..1ab7c56001 100644 --- a/src/html/_preload_roboto.html.template +++ b/src/html/_preload_roboto.html.template @@ -1,6 +1,5 @@