Compare commits

..

2 Commits

Author SHA1 Message Date
Paul Bottein
37b79c67c9 Fix search 2025-05-09 17:26:54 +02:00
Paul Bottein
0715835e0d Improve area picker UI and search 2025-05-09 17:08:38 +02:00
51 changed files with 1252 additions and 2607 deletions

View File

@@ -3,9 +3,6 @@ import { glob } from "glob";
import gulp from "gulp";
import yaml from "js-yaml";
import { marked } from "marked";
import ts from "typescript";
import { create } from "@custom-elements-manifest/analyzer";
import { litPlugin } from "@custom-elements-manifest/analyzer/src/features/framework-plugins/lit/lit.js";
import path from "path";
import paths from "../paths.cjs";
import "./clean.js";
@@ -16,28 +13,6 @@ import "./service-worker.js";
import "./translations.js";
import "./rspack.js";
gulp.task("generate-component-docs", async function generateComponentDocs() {
const filePaths = ["src/components/ha-alert.ts"];
const modules = await Promise.all(
filePaths.map(async (file) => {
const filePath = path.resolve(file);
console.log(`Reading ${file} -> ${filePath}`);
const source = fs.readFileSync(filePath).toString();
return ts.createSourceFile(file, source, ts.ScriptTarget.ES2015, true);
})
);
const manifest = create({
modules,
plugins: litPlugin(),
context: { dev: true },
});
console.log(manifest);
});
gulp.task("gather-gallery-pages", async function gatherPages() {
const pageDir = path.resolve(paths.gallery_dir, "src/pages");
const files = await glob(path.resolve(pageDir, "**/*"));

View File

@@ -1,186 +0,0 @@
{
"schemaVersion": "1.0.0",
"readme": "",
"modules": [
{
"kind": "javascript-module",
"path": "src/components/ha-alert.ts",
"declarations": [
{
"kind": "class",
"description": "A custom alert component for displaying messages with various alert types.",
"name": "HaAlert",
"cssProperties": [
{
"description": "The color used for \"info\" alerts.",
"name": "--info-color"
},
{
"description": "The color used for \"warning\" alerts.",
"name": "--warning-color"
},
{
"description": "The color used for \"error\" alerts.",
"name": "--error-color"
},
{
"description": "The color used for \"success\" alerts.",
"name": "--success-color"
},
{
"description": "The primary text color used in the alert.",
"name": "--primary-text-color"
}
],
"cssParts": [
{
"description": "The container for the alert.",
"name": "issue-type"
},
{
"description": "The container for the alert icon.",
"name": "icon"
},
{
"description": "The container for the alert content.",
"name": "content"
},
{
"description": "The container for the alert actions.",
"name": "action"
},
{
"description": "The container for the alert title.",
"name": "title"
}
],
"slots": [
{
"description": "The main content of the alert.",
"name": ""
},
{
"description": "Slot for providing a custom icon for the alert.",
"name": "icon"
},
{
"description": "Slot for providing custom actions or buttons for the alert.",
"name": "action"
}
],
"members": [
{
"kind": "field",
"name": "title",
"type": {
"text": "string"
},
"privacy": "public",
"default": "\"\"",
"description": "The title of the alert. Defaults to an empty string.",
"attribute": "title"
},
{
"kind": "field",
"name": "alertType",
"type": {
"text": "\"info\" | \"warning\" | \"error\" | \"success\""
},
"privacy": "public",
"default": "\"info\"",
"description": "The type of alert to display. Defaults to \"info\". Determines the styling and icon used.",
"attribute": "alert-type"
},
{
"kind": "field",
"name": "dismissable",
"type": {
"text": "boolean"
},
"privacy": "public",
"default": "false",
"description": "Whether the alert can be dismissed. Defaults to `false`. If `true`, a dismiss button is displayed.",
"attribute": "dismissable"
},
{
"kind": "field",
"name": "narrow",
"type": {
"text": "boolean"
},
"privacy": "public",
"default": "false",
"description": "Whether the alert should use a narrow layout. Defaults to `false`.",
"attribute": "narrow"
},
{
"kind": "method",
"name": "_dismissClicked",
"privacy": "private"
}
],
"events": [
{
"description": "Fired when the dismiss button is clicked.",
"name": "alert-dismissed-clicked"
}
],
"attributes": [
{
"name": "title",
"type": {
"text": "string"
},
"default": "\"\"",
"description": "The title of the alert. Defaults to an empty string.",
"fieldName": "title"
},
{
"name": "alert-type",
"type": {
"text": "\"info\" | \"warning\" | \"error\" | \"success\""
},
"default": "\"info\"",
"description": "The type of alert to display. Defaults to \"info\". Determines the styling and icon used.",
"fieldName": "alertType"
},
{
"name": "dismissable",
"type": {
"text": "boolean"
},
"default": "false",
"description": "Whether the alert can be dismissed. Defaults to `false`. If `true`, a dismiss button is displayed.",
"fieldName": "dismissable"
},
{
"name": "narrow",
"type": {
"text": "boolean"
},
"default": "false",
"description": "Whether the alert should use a narrow layout. Defaults to `false`.",
"fieldName": "narrow"
}
],
"superclass": {
"name": "LitElement",
"package": "lit"
},
"tagName": "ha-alert",
"customElement": true
}
],
"exports": [
{
"kind": "custom-element-definition",
"name": "ha-alert",
"declaration": {
"name": "HaAlert",
"module": "src/components/ha-alert.ts"
}
}
]
}
]
}

View File

@@ -4,7 +4,7 @@ export default {
"prettier --cache --write",
"lit-analyzer --quiet",
],
"*.{json,css,md,markdown,html,ya?ml}": "prettier --cache --write",
"*.{json,css,md,markdown,html,y?aml}": "prettier --cache --write",
"translations/*/*.json": (files) =>
'printf "%s\n" "Translation files should not be added or modified here. Instead, make the necessary modifications in src/translations/en.json. Other languages are managed externally. Please see https://developers.home-assistant.io/docs/translations/ for details." ' +
files.join(" ") +

View File

@@ -20,9 +20,7 @@
"prepack": "pinst --disable",
"postpack": "pinst --enable",
"test": "vitest run --config test/vitest.config.ts",
"test:coverage": "vitest run --config test/vitest.config.ts --coverage",
"analyze": "cem analyze --litelement --globs \"src/components/ha-alert.ts\" --dev",
"doc": "gulp generate-component-docs"
"test:coverage": "vitest run --config test/vitest.config.ts --coverage"
},
"author": "Paulus Schoutsen <Paulus@PaulusSchoutsen.nl> (http://paulusschoutsen.nl)",
"license": "Apache-2.0",
@@ -98,7 +96,7 @@
"@webcomponents/scoped-custom-element-registry": "0.0.10",
"@webcomponents/webcomponentsjs": "2.8.0",
"app-datepicker": "5.1.1",
"barcode-detector": "3.0.4",
"barcode-detector": "3.0.1",
"color-name": "2.0.0",
"comlink": "4.4.2",
"core-js": "3.42.0",
@@ -115,7 +113,7 @@
"gulp-zopfli-green": "6.0.2",
"hls.js": "1.6.2",
"home-assistant-js-websocket": "9.5.0",
"idb-keyval": "6.2.2",
"idb-keyval": "6.2.1",
"intl-messageformat": "10.7.16",
"js-yaml": "4.1.0",
"leaflet": "1.9.4",
@@ -155,17 +153,15 @@
"@babel/core": "7.27.1",
"@babel/helper-define-polyfill-provider": "0.6.4",
"@babel/plugin-transform-runtime": "7.27.1",
"@babel/preset-env": "7.27.2",
"@bundle-stats/plugin-webpack-filter": "4.20.1",
"@custom-elements-manifest/analyzer": "0.10.4",
"@custom-elements-manifest/to-markdown": "0.1.0",
"@lokalise/node-api": "14.7.0",
"@babel/preset-env": "7.27.1",
"@bundle-stats/plugin-webpack-filter": "4.20.0",
"@lokalise/node-api": "14.5.2",
"@octokit/auth-oauth-device": "7.1.5",
"@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",
"@rsdoctor/rspack-plugin": "1.1.0",
"@rspack/cli": "1.3.8",
"@rspack/core": "1.3.8",
"@types/babel__plugin-transform-runtime": "7.9.5",
"@types/chromecast-caf-receiver": "6.0.21",
"@types/chromecast-caf-sender": "1.0.11",
@@ -191,7 +187,7 @@
"del": "8.0.0",
"eslint": "9.26.0",
"eslint-config-airbnb-base": "15.0.0",
"eslint-config-prettier": "10.1.5",
"eslint-config-prettier": "10.1.2",
"eslint-import-resolver-webpack": "0.13.10",
"eslint-plugin-import": "2.31.0",
"eslint-plugin-lit": "2.1.1",
@@ -237,7 +233,7 @@
"clean-css": "5.3.3",
"@lit/reactive-element": "2.1.0",
"@fullcalendar/daygrid": "6.1.17",
"globals": "16.1.0",
"globals": "16.0.0",
"tslib": "2.8.1",
"@material/mwc-list@^0.27.0": "patch:@material/mwc-list@npm%3A0.27.0#~/.yarn/patches/@material-mwc-list-npm-0.27.0-5344fc9de4.patch"
},

View File

@@ -164,8 +164,6 @@ export class HaDataTable extends LitElement {
@state() private _collapsedGroups: string[] = [];
@state() private _lastSelectedRowId: string | null = null;
private _checkableRowsCount?: number;
private _checkedRows: string[] = [];
@@ -189,7 +187,6 @@ export class HaDataTable extends LitElement {
public clearSelection(): void {
this._checkedRows = [];
this._lastSelectedRowId = null;
this._checkedRowsChanged();
}
@@ -197,7 +194,6 @@ export class HaDataTable extends LitElement {
this._checkedRows = this._filteredData
.filter((data) => data.selectable !== false)
.map((data) => data[this.id]);
this._lastSelectedRowId = null;
this._checkedRowsChanged();
}
@@ -211,7 +207,6 @@ export class HaDataTable extends LitElement {
this._checkedRows.push(id);
}
});
this._lastSelectedRowId = null;
this._checkedRowsChanged();
}
@@ -222,7 +217,6 @@ export class HaDataTable extends LitElement {
this._checkedRows.splice(index, 1);
}
});
this._lastSelectedRowId = null;
this._checkedRowsChanged();
}
@@ -267,7 +261,6 @@ export class HaDataTable extends LitElement {
if (this.columns[columnId].direction) {
this.sortDirection = this.columns[columnId].direction!;
this.sortColumn = columnId;
this._lastSelectedRowId = null;
fireEvent(this, "sorting-changed", {
column: columnId,
@@ -293,7 +286,6 @@ export class HaDataTable extends LitElement {
if (properties.has("filter")) {
this._debounceSearch(this.filter);
this._lastSelectedRowId = null;
}
if (properties.has("data")) {
@@ -304,11 +296,9 @@ export class HaDataTable extends LitElement {
if (!this.hasUpdated && this.initialCollapsedGroups) {
this._collapsedGroups = this.initialCollapsedGroups;
this._lastSelectedRowId = null;
fireEvent(this, "collapsed-changed", { value: this._collapsedGroups });
} else if (properties.has("groupColumn")) {
this._collapsedGroups = [];
this._lastSelectedRowId = null;
fireEvent(this, "collapsed-changed", { value: this._collapsedGroups });
}
@@ -322,14 +312,6 @@ export class HaDataTable extends LitElement {
this._sortFilterData();
}
if (
properties.has("_filter") ||
properties.has("sortColumn") ||
properties.has("sortDirection")
) {
this._lastSelectedRowId = null;
}
if (properties.has("selectable") || properties.has("hiddenColumns")) {
this._filteredData = [...this._filteredData];
}
@@ -560,7 +542,7 @@ export class HaDataTable extends LitElement {
>
<ha-checkbox
class="mdc-data-table__row-checkbox"
@click=${this._handleRowCheckboxClicked}
@change=${this._handleRowCheckboxClick}
.rowId=${row[this.id]}
.disabled=${row.selectable === false}
.checked=${this._checkedRows.includes(String(row[this.id]))}
@@ -742,7 +724,6 @@ export class HaDataTable extends LitElement {
Object.entries(sorted).forEach(([groupName, rows]) => {
groupedItems.push({
append: true,
selectable: false,
content: html`<div
class="mdc-data-table__cell group-header"
role="cell"
@@ -769,7 +750,7 @@ export class HaDataTable extends LitElement {
}
if (appendRow) {
items.push({ append: true, selectable: false, content: appendRow });
items.push({ append: true, content: appendRow });
}
if (hasFab) {
@@ -819,84 +800,23 @@ export class HaDataTable extends LitElement {
this._checkedRows = [];
this._checkedRowsChanged();
}
this._lastSelectedRowId = null;
}
private _handleRowCheckboxClicked = (ev: Event) => {
private _handleRowCheckboxClick = (ev: Event) => {
const checkbox = ev.currentTarget as HaCheckbox;
const rowId = (checkbox as any).rowId;
const groupedData = this._groupData(
this._filteredData,
this.localizeFunc || this.hass.localize,
this.appendRow,
this.hasFab,
this.groupColumn,
this.groupOrder,
this._collapsedGroups
);
if (
groupedData.find((data) => data[this.id] === rowId)?.selectable === false
) {
if (checkbox.checked) {
if (this._checkedRows.includes(rowId)) {
return;
}
const rowIndex = groupedData.findIndex((data) => data[this.id] === rowId);
if (
ev instanceof MouseEvent &&
ev.shiftKey &&
this._lastSelectedRowId !== null
) {
const lastSelectedRowIndex = groupedData.findIndex(
(data) => data[this.id] === this._lastSelectedRowId
);
if (lastSelectedRowIndex > -1 && rowIndex > -1) {
this._checkedRows = [
...this._checkedRows,
...this._selectRange(groupedData, lastSelectedRowIndex, rowIndex),
];
}
} else if (!checkbox.checked) {
if (!this._checkedRows.includes(rowId)) {
this._checkedRows = [...this._checkedRows, rowId];
}
} else {
this._checkedRows = this._checkedRows.filter((row) => row !== rowId);
}
if (rowIndex > -1) {
this._lastSelectedRowId = rowId;
}
this._checkedRowsChanged();
};
private _selectRange(
groupedData: DataTableRowData[],
startIndex: number,
endIndex: number
) {
const start = Math.min(startIndex, endIndex);
const end = Math.max(startIndex, endIndex);
const checkedRows: string[] = [];
for (let i = start; i <= end; i++) {
const row = groupedData[i];
if (
row &&
row.selectable !== false &&
!this._checkedRows.includes(row[this.id])
) {
checkedRows.push(row[this.id]);
}
}
return checkedRows;
}
private _handleRowClick = (ev: Event) => {
if (
ev
@@ -938,7 +858,6 @@ export class HaDataTable extends LitElement {
if (this.filter) {
return;
}
this._lastSelectedRowId = null;
this._debounceSearch(ev.detail.value);
}
@@ -975,13 +894,11 @@ export class HaDataTable extends LitElement {
} else {
this._collapsedGroups = [...this._collapsedGroups, groupName];
}
this._lastSelectedRowId = null;
fireEvent(this, "collapsed-changed", { value: this._collapsedGroups });
};
public expandAllGroups() {
this._collapsedGroups = [];
this._lastSelectedRowId = null;
fireEvent(this, "collapsed-changed", { value: this._collapsedGroups });
}
@@ -999,7 +916,6 @@ export class HaDataTable extends LitElement {
delete grouped.undefined;
}
this._collapsedGroups = Object.keys(grouped);
this._lastSelectedRowId = null;
fireEvent(this, "collapsed-changed", { value: this._collapsedGroups });
}

View File

@@ -178,9 +178,7 @@ export class HaEntityComboBox extends LitElement {
: nothing}
${item.domain_name && !showEntityId
? html`
<div slot="trailing-supporting-text" class="domain">
${item.domain_name}
</div>
<div slot="trailing-supporting-text">${item.domain_name}</div>
`
: nothing}
</ha-combo-box-item>
@@ -410,9 +408,7 @@ export class HaEntityComboBox extends LitElement {
protected render(): TemplateResult {
return html`
<ha-combo-box
item-id-path="id"
item-value-path="id"
item-label-path="label"
.hass=${this.hass}
.value=${this._value}
.label=${this.label === undefined

View File

@@ -1,13 +1,6 @@
import { mdiClose, mdiMenuDown, mdiShape } from "@mdi/js";
import type { ComboBoxLightOpenedChangedEvent } from "@vaadin/combo-box/vaadin-combo-box-light";
import {
css,
html,
LitElement,
nothing,
type CSSResultGroup,
type PropertyValues,
} from "lit";
import { css, html, LitElement, nothing, type CSSResultGroup } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { fireEvent } from "../../common/dom/fire_event";
import { stopPropagation } from "../../common/dom/stop_propagation";
@@ -113,12 +106,6 @@ export class HaEntityPicker extends LitElement {
@state() private _opened = false;
protected firstUpdated(changedProperties: PropertyValues): void {
super.firstUpdated(changedProperties);
// Load title translations so it is available when the combo-box opens
this.hass.loadBackendTranslation("title");
}
private _renderContent() {
const entityId = this.value || "";

View File

@@ -396,9 +396,6 @@ export class HaStatisticComboBox extends LitElement {
return html`
<ha-combo-box
item-id-path="id"
item-value-path="id"
item-label-path="label"
.hass=${this.hass}
.label=${this.label === undefined && this.hass
? this.hass.localize("ui.components.statistic-picker.statistic")
@@ -408,6 +405,9 @@ export class HaStatisticComboBox extends LitElement {
.disabled=${this.disabled}
.allowCustomValue=${this.allowCustomEntity}
.filteredItems=${this._items}
item-value-path="id"
item-id-path="id"
item-label-path="label"
@opened-changed=${this._openedChanged}
@value-changed=${this._statisticChanged}
@filter-changed=${this._filterChanged}

View File

@@ -25,36 +25,6 @@ declare global {
}
}
/**
* A custom alert component for displaying messages with various alert types.
*
* @element ha-alert
*
* @property {string} title - The title of the alert. Defaults to an empty string.
* @property {"info" | "warning" | "error" | "success"} alertType - The type of alert to display.
* Defaults to "info". Determines the styling and icon used.
* @property {boolean} dismissable - Whether the alert can be dismissed. Defaults to `false`.
* If `true`, a dismiss button is displayed.
* @property {boolean} narrow - Whether the alert should use a narrow layout. Defaults to `false`.
*
* @slot - The main content of the alert.
* @slot icon - Slot for providing a custom icon for the alert.
* @slot action - Slot for providing custom actions or buttons for the alert.
*
* @fires alert-dismissed-clicked - Fired when the dismiss button is clicked.
*
* @csspart issue-type - The container for the alert.
* @csspart icon - The container for the alert icon.
* @csspart content - The container for the alert content.
* @csspart action - The container for the alert actions.
* @csspart title - The container for the alert title.
*
* @cssprop --info-color - The color used for "info" alerts.
* @cssprop --warning-color - The color used for "warning" alerts.
* @cssprop --error-color - The color used for "error" alerts.
* @cssprop --success-color - The color used for "success" alerts.
* @cssprop --primary-text-color - The primary text color used in the alert.
*/
@customElement("ha-alert")
class HaAlert extends LitElement {
// eslint-disable-next-line lit/no-native-attributes

View File

@@ -0,0 +1,508 @@
import { mdiTextureBox } 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 { LitElement, html, nothing } from "lit";
import { customElement, property, query, state } 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 { computeFloorName } from "../common/entity/compute_floor_name";
import { getAreaContext } from "../common/entity/context/get_area_context";
import { caseInsensitiveStringCompare } from "../common/string/compare";
import type { AreaRegistryEntry } from "../data/area_registry";
import { createAreaRegistryEntry } from "../data/area_registry";
import type {
DeviceEntityDisplayLookup,
DeviceRegistryEntry,
} from "../data/device_registry";
import { getDeviceEntityDisplayLookup } from "../data/device_registry";
import type { EntityRegistryDisplayEntry } from "../data/entity_registry";
import { showAlertDialog } from "../dialogs/generic/show-dialog-box";
import { showAreaRegistryDetailDialog } from "../panels/config/areas/show-dialog-area-registry-detail";
import { HaFuse } from "../resources/fuse";
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-icon-button";
import "./ha-svg-icon";
interface AreaComboBoxItem {
// Force empty label to always display empty value by default in the search field
id: string;
label: "";
primary: string;
secondary?: string;
icon?: string;
search_labels?: string[];
sorting_label?: string;
}
const rowRenderer: ComboBoxLitRenderer<AreaComboBoxItem> = (item) => html`
<ha-combo-box-item type="button">
${item.icon
? html`<ha-icon slot="start" .icon=${item.icon}></ha-icon>`
: html`<ha-svg-icon slot="start" .path=${mdiTextureBox}></ha-svg-icon>`}
<span slot="headline">${item.primary}</span>
${item.secondary
? html`<span slot="supporting-text">${item.secondary}</span>`
: nothing}
</ha-combo-box-item>
`;
const ADD_NEW_ID = "___ADD_NEW___";
const NO_ITEMS_ID = "___NO_ITEMS___";
const ADD_NEW_SUGGESTION_ID = "___ADD_NEW_SUGGESTION___";
@customElement("ha-area-combo-box")
export class HaAreaComboBox extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property() public label?: string;
@property() public value?: string;
@property() public helper?: string;
@property() public placeholder?: string;
@property({ type: Boolean, attribute: "no-add" })
public noAdd = false;
/**
* Show only areas with entities from specific domains.
* @type {Array}
* @attr include-domains
*/
@property({ type: Array, attribute: "include-domains" })
public includeDomains?: string[];
/**
* Show no areas with entities of these domains.
* @type {Array}
* @attr exclude-domains
*/
@property({ type: Array, attribute: "exclude-domains" })
public excludeDomains?: string[];
/**
* Show only areas with entities of these device classes.
* @type {Array}
* @attr include-device-classes
*/
@property({ type: Array, attribute: "include-device-classes" })
public includeDeviceClasses?: string[];
/**
* List of areas to be excluded.
* @type {Array}
* @attr exclude-areas
*/
@property({ type: Array, attribute: "exclude-areas" })
public excludeAreas?: string[];
@property({ attribute: false })
public deviceFilter?: HaDevicePickerDeviceFilterFunc;
@property({ attribute: false })
public entityFilter?: (entity: HassEntity) => boolean;
@property({ type: Boolean }) public disabled = false;
@property({ type: Boolean }) public required = false;
@state() private _opened?: boolean;
@query("ha-combo-box", true) public comboBox!: HaComboBox;
private _suggestion?: string;
private _init = false;
public async open() {
await this.updateComplete;
await this.comboBox?.open();
}
public async focus() {
await this.updateComplete;
await this.comboBox?.focus();
}
private _getItems = memoizeOne(
(
areas: AreaRegistryEntry[],
devices: DeviceRegistryEntry[],
entities: EntityRegistryDisplayEntry[],
includeDomains: this["includeDomains"],
excludeDomains: this["excludeDomains"],
includeDeviceClasses: this["includeDeviceClasses"],
deviceFilter: this["deviceFilter"],
entityFilter: this["entityFilter"],
noAdd: this["noAdd"],
excludeAreas: this["excludeAreas"]
): AreaComboBoxItem[] => {
let deviceEntityLookup: DeviceEntityDisplayLookup = {};
let inputDevices: DeviceRegistryEntry[] | undefined;
let inputEntities: EntityRegistryDisplayEntry[] | undefined;
if (
includeDomains ||
excludeDomains ||
includeDeviceClasses ||
deviceFilter ||
entityFilter
) {
deviceEntityLookup = getDeviceEntityDisplayLookup(entities);
inputDevices = devices;
inputEntities = entities.filter((entity) => entity.area_id);
if (includeDomains) {
inputDevices = inputDevices!.filter((device) => {
const devEntities = deviceEntityLookup[device.id];
if (!devEntities || !devEntities.length) {
return false;
}
return deviceEntityLookup[device.id].some((entity) =>
includeDomains.includes(computeDomain(entity.entity_id))
);
});
inputEntities = inputEntities!.filter((entity) =>
includeDomains.includes(computeDomain(entity.entity_id))
);
}
if (excludeDomains) {
inputDevices = inputDevices!.filter((device) => {
const devEntities = deviceEntityLookup[device.id];
if (!devEntities || !devEntities.length) {
return true;
}
return entities.every(
(entity) =>
!excludeDomains.includes(computeDomain(entity.entity_id))
);
});
inputEntities = inputEntities!.filter(
(entity) =>
!excludeDomains.includes(computeDomain(entity.entity_id))
);
}
if (includeDeviceClasses) {
inputDevices = inputDevices!.filter((device) => {
const devEntities = deviceEntityLookup[device.id];
if (!devEntities || !devEntities.length) {
return false;
}
return deviceEntityLookup[device.id].some((entity) => {
const stateObj = this.hass.states[entity.entity_id];
if (!stateObj) {
return false;
}
return (
stateObj.attributes.device_class &&
includeDeviceClasses.includes(stateObj.attributes.device_class)
);
});
});
inputEntities = inputEntities!.filter((entity) => {
const stateObj = this.hass.states[entity.entity_id];
return (
stateObj.attributes.device_class &&
includeDeviceClasses.includes(stateObj.attributes.device_class)
);
});
}
if (deviceFilter) {
inputDevices = inputDevices!.filter((device) =>
deviceFilter!(device)
);
}
if (entityFilter) {
inputDevices = inputDevices!.filter((device) => {
const devEntities = deviceEntityLookup[device.id];
if (!devEntities || !devEntities.length) {
return false;
}
return deviceEntityLookup[device.id].some((entity) => {
const stateObj = this.hass.states[entity.entity_id];
if (!stateObj) {
return false;
}
return entityFilter(stateObj);
});
});
inputEntities = inputEntities!.filter((entity) => {
const stateObj = this.hass.states[entity.entity_id];
if (!stateObj) {
return false;
}
return entityFilter!(stateObj);
});
}
}
let outputAreas = areas;
let areaIds: string[] | undefined;
if (inputDevices) {
areaIds = inputDevices
.filter((device) => device.area_id)
.map((device) => device.area_id!);
}
if (inputEntities) {
areaIds = (areaIds ?? []).concat(
inputEntities
.filter((entity) => entity.area_id)
.map((entity) => entity.area_id!)
);
}
if (areaIds) {
outputAreas = outputAreas.filter((area) =>
areaIds!.includes(area.area_id)
);
}
if (excludeAreas) {
outputAreas = outputAreas.filter(
(area) => !excludeAreas!.includes(area.area_id)
);
}
let items = outputAreas
.map<AreaComboBoxItem>((area) => {
const { floor } = getAreaContext(area, this.hass);
const floorName = floor ? computeFloorName(floor) : undefined;
const areaName = computeAreaName(area);
return {
label: "",
id: area.area_id,
primary: areaName || area.area_id,
secondary: floorName,
icon: area.icon || undefined,
sorting_label: areaName,
search_labels: [
areaName,
floorName,
area.area_id,
...area.aliases,
].filter((v): v is string => Boolean(v)),
};
})
.sort((entityA, entityB) =>
caseInsensitiveStringCompare(
entityA.sorting_label!,
entityB.sorting_label!,
this.hass.locale.language
)
);
if (!items.length) {
items = [
{
label: "",
id: NO_ITEMS_ID,
primary: this.hass.localize("ui.components.area-picker.no_areas"),
icon: "mdi:magnify",
},
];
}
return noAdd
? items
: [
...items,
{
label: "",
id: ADD_NEW_ID,
primary: this.hass.localize("ui.components.area-picker.add_new"),
icon: "mdi:plus",
},
];
}
);
protected updated(changedProps: PropertyValues) {
if (
(!this._init && this.hass) ||
(this._init && changedProps.has("_opened") && this._opened)
) {
this._init = true;
const items = this._getItems(
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
);
this.comboBox.items = items;
this.comboBox.filteredItems = items;
}
}
protected render(): TemplateResult {
return html`
<ha-combo-box
item-id-path="id"
item-value-path="id"
item-label-path="label"
.hass=${this.hass}
.helper=${this.helper}
.value=${this._value}
.disabled=${this.disabled}
.required=${this.required}
.label=${this.label === undefined && this.hass
? this.hass.localize("ui.components.area-picker.area")
: this.label}
.placeholder=${this.placeholder
? this.hass.areas[this.placeholder]?.name
: undefined}
.renderer=${rowRenderer}
@filter-changed=${this._filterChanged}
@opened-changed=${this._openedChanged}
@value-changed=${this._areaChanged}
>
</ha-combo-box>
`;
}
private _fuseIndex = memoizeOne((items: AreaComboBoxItem[]) =>
Fuse.createIndex(["search_labels"], items)
);
private _filterChanged(ev: CustomEvent): void {
if (!this._opened) return;
const target = ev.target as HaComboBox;
const items = target.items as AreaComboBoxItem[];
const filterString = ev.detail.value.trim().toLowerCase() as string;
const index = this._fuseIndex(items);
const fuse = new HaFuse(items, {}, index);
const results = fuse.multiTermsSearch(filterString);
if (results) {
if (results.length === 0) {
if (this.noAdd) {
this.comboBox.filteredItems = [
{
id: NO_ITEMS_ID,
primary: this.hass.localize("ui.components.area-picker.no_match"),
icon: "mdi:magnify",
},
] as AreaComboBoxItem[];
} else {
this._suggestion = filterString;
this.comboBox.filteredItems = [
{
id: ADD_NEW_SUGGESTION_ID,
primary: this.hass.localize(
"ui.components.area-picker.add_new_sugestion",
{ name: this._suggestion }
),
icon: "mdi:plus",
},
] as AreaComboBoxItem[];
}
} else {
target.filteredItems = results.map((result) => result.item);
}
} else {
this.comboBox.filteredItems = target.items as AreaComboBoxItem[];
}
}
private get _value() {
return this.value || "";
}
private _openedChanged(ev: ValueChangedEvent<boolean>) {
this._opened = ev.detail.value;
}
private _areaChanged(ev: ValueChangedEvent<string>) {
ev.stopPropagation();
let newValue = ev.detail.value;
if (newValue === NO_ITEMS_ID) {
newValue = "";
this.comboBox.setInputValue("");
return;
}
if (![ADD_NEW_SUGGESTION_ID, ADD_NEW_ID].includes(newValue)) {
if (newValue !== this._value) {
this._setValue(newValue);
}
return;
}
(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._getItems(
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("");
}
private _setValue(value?: string) {
this.value = value;
setTimeout(() => {
fireEvent(this, "value-changed", { value });
}, 0);
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-area-combo-box": HaAreaComboBox;
}
}

View File

@@ -1,47 +1,24 @@
import { mdiTextureBox } from "@mdi/js";
import type { ComboBoxLitRenderer } from "@vaadin/combo-box/lit";
import { mdiClose, mdiMenuDown, mdiShape, mdiTextureBox } from "@mdi/js";
import type { ComboBoxLightOpenedChangedEvent } from "@vaadin/combo-box/vaadin-combo-box-light";
import type { HassEntity } from "home-assistant-js-websocket";
import type { PropertyValues, TemplateResult } from "lit";
import { LitElement, html } from "lit";
import { LitElement, css, html, nothing, type CSSResultGroup } from "lit";
import { customElement, property, query, state } 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 { createAreaRegistryEntry } from "../data/area_registry";
import type {
DeviceEntityDisplayLookup,
DeviceRegistryEntry,
} from "../data/device_registry";
import { getDeviceEntityDisplayLookup } from "../data/device_registry";
import type { EntityRegistryDisplayEntry } from "../data/entity_registry";
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 { stopPropagation } from "../common/dom/stop_propagation";
import { computeAreaName } from "../common/entity/compute_area_name";
import { computeFloorName } from "../common/entity/compute_floor_name";
import { getAreaContext } from "../common/entity/context/get_area_context";
import { debounce } from "../common/util/debounce";
import type { HomeAssistant } from "../types";
import type { HaDevicePickerDeviceFilterFunc } from "./device/ha-device-picker";
import "./ha-area-combo-box";
import type { HaAreaComboBox } from "./ha-area-combo-box";
import "./ha-combo-box";
import type { HaComboBox } from "./ha-combo-box";
import "./ha-combo-box-item";
import type { HaComboBoxItem } from "./ha-combo-box-item";
import "./ha-icon-button";
import "./ha-svg-icon";
type ScorableAreaRegistryEntry = ScorableTextItem & AreaRegistryEntry;
const rowRenderer: ComboBoxLitRenderer<AreaRegistryEntry> = (item) => html`
<ha-combo-box-item type="button">
${item.icon
? html`<ha-icon slot="start" .icon=${item.icon}></ha-icon>`
: html`<ha-svg-icon slot="start" .path=${mdiTextureBox}></ha-svg-icon>`}
${item.name}
</ha-combo-box-item>
`;
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 {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -99,389 +76,233 @@ export class HaAreaPicker extends LitElement {
@property({ type: Boolean }) public required = false;
@state() private _opened?: boolean;
@property({ attribute: "hide-clear-icon", type: Boolean })
public hideClearIcon = false;
@query("ha-combo-box", true) public comboBox!: HaComboBox;
@query("#anchor") private _anchor?: HaComboBoxItem;
private _suggestion?: string;
@query("#input") private _input?: HaAreaComboBox;
private _init = false;
@state() private _opened = false;
public async open() {
await this.updateComplete;
await this.comboBox?.open();
}
private _renderContent() {
const areaId = this.value || "";
public async focus() {
await this.updateComplete;
await this.comboBox?.focus();
}
private _getAreas = memoizeOne(
(
areas: AreaRegistryEntry[],
devices: DeviceRegistryEntry[],
entities: EntityRegistryDisplayEntry[],
includeDomains: this["includeDomains"],
excludeDomains: this["excludeDomains"],
includeDeviceClasses: this["includeDeviceClasses"],
deviceFilter: this["deviceFilter"],
entityFilter: this["entityFilter"],
noAdd: this["noAdd"],
excludeAreas: this["excludeAreas"]
): AreaRegistryEntry[] => {
let deviceEntityLookup: DeviceEntityDisplayLookup = {};
let inputDevices: DeviceRegistryEntry[] | undefined;
let inputEntities: EntityRegistryDisplayEntry[] | undefined;
if (
includeDomains ||
excludeDomains ||
includeDeviceClasses ||
deviceFilter ||
entityFilter
) {
deviceEntityLookup = getDeviceEntityDisplayLookup(entities);
inputDevices = devices;
inputEntities = entities.filter((entity) => entity.area_id);
if (includeDomains) {
inputDevices = inputDevices!.filter((device) => {
const devEntities = deviceEntityLookup[device.id];
if (!devEntities || !devEntities.length) {
return false;
}
return deviceEntityLookup[device.id].some((entity) =>
includeDomains.includes(computeDomain(entity.entity_id))
);
});
inputEntities = inputEntities!.filter((entity) =>
includeDomains.includes(computeDomain(entity.entity_id))
);
}
if (excludeDomains) {
inputDevices = inputDevices!.filter((device) => {
const devEntities = deviceEntityLookup[device.id];
if (!devEntities || !devEntities.length) {
return true;
}
return entities.every(
(entity) =>
!excludeDomains.includes(computeDomain(entity.entity_id))
);
});
inputEntities = inputEntities!.filter(
(entity) =>
!excludeDomains.includes(computeDomain(entity.entity_id))
);
}
if (includeDeviceClasses) {
inputDevices = inputDevices!.filter((device) => {
const devEntities = deviceEntityLookup[device.id];
if (!devEntities || !devEntities.length) {
return false;
}
return deviceEntityLookup[device.id].some((entity) => {
const stateObj = this.hass.states[entity.entity_id];
if (!stateObj) {
return false;
}
return (
stateObj.attributes.device_class &&
includeDeviceClasses.includes(stateObj.attributes.device_class)
);
});
});
inputEntities = inputEntities!.filter((entity) => {
const stateObj = this.hass.states[entity.entity_id];
return (
stateObj.attributes.device_class &&
includeDeviceClasses.includes(stateObj.attributes.device_class)
);
});
}
if (deviceFilter) {
inputDevices = inputDevices!.filter((device) =>
deviceFilter!(device)
);
}
if (entityFilter) {
inputDevices = inputDevices!.filter((device) => {
const devEntities = deviceEntityLookup[device.id];
if (!devEntities || !devEntities.length) {
return false;
}
return deviceEntityLookup[device.id].some((entity) => {
const stateObj = this.hass.states[entity.entity_id];
if (!stateObj) {
return false;
}
return entityFilter(stateObj);
});
});
inputEntities = inputEntities!.filter((entity) => {
const stateObj = this.hass.states[entity.entity_id];
if (!stateObj) {
return false;
}
return entityFilter!(stateObj);
});
}
}
let outputAreas = areas;
let areaIds: string[] | undefined;
if (inputDevices) {
areaIds = inputDevices
.filter((device) => device.area_id)
.map((device) => device.area_id!);
}
if (inputEntities) {
areaIds = (areaIds ?? []).concat(
inputEntities
.filter((entity) => entity.area_id)
.map((entity) => entity.area_id!)
);
}
if (areaIds) {
outputAreas = outputAreas.filter((area) =>
areaIds!.includes(area.area_id)
);
}
if (excludeAreas) {
outputAreas = outputAreas.filter(
(area) => !excludeAreas!.includes(area.area_id)
);
}
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,
},
];
}
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,
},
];
}
);
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;
}
}
protected render(): TemplateResult {
if (!areaId) {
return html`
<ha-combo-box
.hass=${this.hass}
.helper=${this.helper}
item-value-path="area_id"
item-id-path="area_id"
item-label-path="name"
.value=${this._value}
.disabled=${this.disabled}
.required=${this.required}
.label=${this.label === undefined && this.hass
? this.hass.localize("ui.components.area-picker.area")
: this.label}
.placeholder=${this.placeholder
? this.hass.areas[this.placeholder]?.name
: undefined}
.renderer=${rowRenderer}
@filter-changed=${this._filterChanged}
@opened-changed=${this._openedChanged}
@value-changed=${this._areaChanged}
<span slot="headline" class="placeholder"
>${this.placeholder ??
this.hass.localize("ui.components.area-picker.placeholder")}</span
>
</ha-combo-box>
<ha-svg-icon class="edit" slot="end" .path=${mdiMenuDown}></ha-svg-icon>
`;
}
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 area = this.hass.areas[areaId];
const showClearIcon =
!this.required && !this.disabled && !this.hideClearIcon;
if (!area) {
return html`
<ha-svg-icon slot="start" .path=${mdiShape}></ha-svg-icon>
<span slot="headline">${area}</span>
${showClearIcon
? html`<ha-icon-button
class="clear"
slot="end"
@click=${this._clear}
.path=${mdiClose}
></ha-icon-button>`
: nothing}
<ha-svg-icon class="edit" slot="end" .path=${mdiMenuDown}></ha-svg-icon>
`;
}
const filteredItems = fuzzyFilterSort<ScorableAreaRegistryEntry>(
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;
}
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`<ha-icon slot="start" .icon=${icon}></ha-icon>`
: html`<ha-svg-icon slot="start" .path=${mdiTextureBox}></ha-svg-icon>`}
<span slot="headline">${areaName}</span>
${floorName
? html`<span slot="supporting-text">${floorName}</span>`
: nothing}
${showClearIcon
? html`<ha-icon-button
class="clear"
slot="end"
@click=${this._clear}
.path=${mdiClose}
></ha-icon-button>`
: nothing}
<ha-svg-icon class="edit" slot="end" .path=${mdiMenuDown}></ha-svg-icon>
`;
}
private get _value() {
return this.value || "";
protected render() {
return html`
${this.label ? html`<label>${this.label}</label>` : nothing}
<div class="container">
${!this._opened
? html`<ha-combo-box-item
.disabled=${this.disabled}
id="anchor"
type="button"
compact
@click=${this._showPicker}
>
${this._renderContent()}
</ha-combo-box-item>`
: html`<ha-area-combo-box
id="input"
.hass=${this.hass}
.autofocus=${this.autofocus}
.label=${this.hass.localize("ui.common.search")}
.value=${this.value}
.noAdd=${this.noAdd}
.includeDomains=${this.includeDomains}
.excludeDomains=${this.excludeDomains}
.includeDeviceClasses=${this.includeDeviceClasses}
.entityFilter=${this.entityFilter}
.excludeAreas=${this.excludeAreas}
hide-clear-icon
@opened-changed=${this._debounceOpenedChanged}
@value-changed=${this._valueChanged}
@input=${stopPropagation}
></ha-area-combo-box>`}
${this._renderHelper()}
</div>
`;
}
private _openedChanged(ev: ValueChangedEvent<boolean>) {
this._opened = ev.detail.value;
private _renderHelper() {
return this.helper
? html`<ha-input-helper-text>${this.helper}</ha-input-helper-text>`
: nothing;
}
private _areaChanged(ev: ValueChangedEvent<string>) {
ev.stopPropagation();
let newValue = ev.detail.value;
if (newValue === NO_ITEMS_ID) {
newValue = "";
this.comboBox.setInputValue("");
return;
private _clear(e) {
e.stopPropagation();
this.value = undefined;
fireEvent(this, "value-changed", { value: undefined });
fireEvent(this, "change");
}
if (![ADD_NEW_SUGGESTION_ID, ADD_NEW_ID].includes(newValue)) {
if (newValue !== this._value) {
this._setValue(newValue);
}
return;
}
(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("");
}
private _setValue(value?: string) {
private _valueChanged(e) {
e.stopPropagation();
const value = e.detail.value;
this.value = value;
setTimeout(() => {
fireEvent(this, "value-changed", { value });
fireEvent(this, "change");
}, 0);
}
private async _showPicker() {
if (this.disabled) {
return;
}
this._opened = true;
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`
mwc-menu-surface {
--mdc-menu-min-width: 100%;
}
.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;
}
`,
];
}
}

View File

@@ -39,7 +39,7 @@ export class HaComboBoxItem extends HaMdListItem {
font-family: var(--ha-font-family-code);
font-size: var(--ha-font-size-xs);
}
::slotted(.domain) {
[slot="trailing-supporting-text"] {
font-size: var(--ha-font-size-s);
font-weight: var(--ha-font-weight-normal);
line-height: var(--ha-line-height-normal);

View File

@@ -201,6 +201,25 @@ class HaWebRtcPlayer extends LitElement {
let candidates = "";
if (this._clientConfig?.getCandidatesUpfront) {
await new Promise<void>((resolve) => {
this._peerConnection!.onicegatheringstatechange = (ev: Event) => {
const iceGatheringState = (ev.target as RTCPeerConnection)
.iceGatheringState;
if (iceGatheringState === "complete") {
this._peerConnection!.onicegatheringstatechange = null;
resolve();
}
this._logEvent("Ice gathering state changed", iceGatheringState);
};
});
if (!this._peerConnection || !this.entityid) {
return;
}
}
while (this._candidatesList.length) {
const candidate = this._candidatesList.pop();
if (candidate) {

View File

@@ -190,6 +190,7 @@ export const fetchCameraCapabilities = async (
export interface WebRTCClientConfiguration {
configuration: RTCConfiguration;
dataChannel?: string;
getCandidatesUpfront: boolean;
}
export const fetchWebRtcClientConfiguration = async (

View File

@@ -1,4 +1,5 @@
import type { Connection } from "home-assistant-js-websocket";
import { getOptimisticCollection } from "./collection";
export interface CoreFrontendUserData {
showAdvanced?: boolean;
@@ -41,15 +42,30 @@ export const saveFrontendUserData = async <
value,
});
export const getOptimisticFrontendUserDataCollection = <
UserDataKey extends ValidUserDataKey,
>(
conn: Connection,
userDataKey: UserDataKey
) =>
getOptimisticCollection(
(_conn, data) =>
saveFrontendUserData(
conn,
userDataKey,
// @ts-ignore
data
),
conn,
`_frontendUserData-${userDataKey}`,
() => fetchFrontendUserData(conn, userDataKey)
);
export const subscribeFrontendUserData = <UserDataKey extends ValidUserDataKey>(
conn: Connection,
userDataKey: UserDataKey,
onChange: (data: { value: FrontendUserData[UserDataKey] | null }) => void
onChange: (state: FrontendUserData[UserDataKey] | null) => void
) =>
conn.subscribeMessage<{ value: FrontendUserData[UserDataKey] | null }>(
onChange,
{
type: "frontend/subscribe_user_data",
key: userDataKey,
}
getOptimisticFrontendUserDataCollection(conn, userDataKey).subscribe(
onChange
);

View File

@@ -1,9 +1,5 @@
import type { HomeAssistant } from "../types";
import {
fetchFrontendUserData,
saveFrontendUserData,
subscribeFrontendUserData,
} from "./frontend";
import { fetchFrontendUserData, saveFrontendUserData } from "./frontend";
export enum NumberFormat {
language = "language",
@@ -81,11 +77,6 @@ export type TranslationCategory =
export const fetchTranslationPreferences = (hass: HomeAssistant) =>
fetchFrontendUserData(hass.connection, "language");
export const subscribeTranslationPreferences = (
hass: HomeAssistant,
callback: (data: { value: FrontendLocaleData | null }) => void
) => subscribeFrontendUserData(hass.connection, "language", callback);
export const saveTranslationPreferences = (
hass: HomeAssistant,
data: FrontendLocaleData

View File

@@ -1,20 +1,17 @@
import { mdiClose, mdiHelpCircle } from "@mdi/js";
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import type { CSSResultGroup, PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import type { HASSDomEvent } from "../../common/dom/fire_event";
import { fireEvent } from "../../common/dom/fire_event";
import "../../components/ha-dialog";
import "../../components/ha-dialog-header";
import "../../components/ha-icon-button";
import type { DataEntryFlowStep } from "../../data/data_entry_flow";
import {
subscribeDataEntryFlowProgress,
subscribeDataEntryFlowProgressed,
} from "../../data/data_entry_flow";
import type { DeviceRegistryEntry } from "../../data/device_registry";
import { haStyleDialog } from "../../resources/styles";
import type { HomeAssistant } from "../../types";
import { documentationUrl } from "../../util/documentation-url";
@@ -174,92 +171,6 @@ class DataEntryFlowDialog extends LitElement {
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
private _devices = memoizeOne(
(
showDevices: boolean,
devices: DeviceRegistryEntry[],
entry_id?: string
) =>
showDevices && entry_id
? devices.filter((device) => device.config_entries.includes(entry_id))
: []
);
private _getDialogTitle(): string {
if (this._loading || !this._step || !this._params) {
return "";
}
switch (this._step.type) {
case "form":
return this._params.flowConfig.renderShowFormStepHeader(
this.hass,
this._step
);
case "abort":
return this._params.flowConfig.renderAbortHeader
? this._params.flowConfig.renderAbortHeader(this.hass, this._step)
: this.hass.localize(
`component.${this._params.domain ?? this._step.handler}.title`
);
case "progress":
return this._params.flowConfig.renderShowFormProgressHeader(
this.hass,
this._step
);
case "menu":
return this._params.flowConfig.renderMenuHeader(this.hass, this._step);
case "create_entry": {
const devicesLength = this._devices(
this._params.flowConfig.showDevices,
Object.values(this.hass.devices),
this._step.result?.entry_id
).length;
return this.hass.localize(
`ui.panel.config.integrations.config_flow.${
devicesLength ? "device_created" : "success"
}`,
{
number: devicesLength,
}
);
}
default:
return "";
}
}
private _getDialogSubtitle(): string | TemplateResult | undefined {
if (this._loading || !this._step || !this._params) {
return "";
}
switch (this._step.type) {
case "form":
return this._params.flowConfig.renderShowFormStepSubheader?.(
this.hass,
this._step
);
case "abort":
return this._params.flowConfig.renderAbortSubheader?.(
this.hass,
this._step
);
case "progress":
return this._params.flowConfig.renderShowFormProgressSubheader?.(
this.hass,
this._step
);
case "menu":
return this._params.flowConfig.renderMenuSubheader?.(
this.hass,
this._step
);
default:
return "";
}
}
protected render() {
if (!this._params) {
return nothing;
@@ -276,9 +187,6 @@ class DataEntryFlowDialog extends LitElement {
this._params.manifest?.is_built_in) ||
!!this._params.manifest?.documentation;
const dialogTitle = this._getDialogTitle();
const dialogSubtitle = this._getDialogSubtitle();
return html`
<ha-dialog
open
@@ -286,32 +194,27 @@ class DataEntryFlowDialog extends LitElement {
scrimClickAction
escapeKeyAction
hideActions
.heading=${dialogTitle}
>
<ha-dialog-header slot="heading">
<ha-icon-button
.label=${this.hass.localize("ui.common.close")}
.path=${mdiClose}
dialogAction="close"
slot="navigationIcon"
></ha-icon-button>
<div
slot="title"
class="dialog-title${this._step?.type === "form" ? " form" : ""}"
title=${dialogTitle}
>
${dialogTitle}
</div>
${dialogSubtitle
? html` <div slot="subtitle">${dialogSubtitle}</div>`
: nothing}
${showDocumentationLink && !this._loading && this._step
<div>
${this._loading || this._step === null
? html`
<step-flow-loading
.flowConfig=${this._params.flowConfig}
.hass=${this.hass}
.loadingReason=${this._loading!}
.handler=${this._handler}
.step=${this._step}
></step-flow-loading>
`
: this._step === undefined
? // When we are going to next step, we render 1 round of empty
// to reset the element.
nothing
: html`
<div class="dialog-actions">
${showDocumentationLink
? html`
<a
slot="actionItems"
class="help"
href=${this._params.manifest!.is_built_in
? documentationUrl(
this.hass,
@@ -329,29 +232,19 @@ class DataEntryFlowDialog extends LitElement {
></a>
`
: nothing}
</ha-dialog-header>
<div>
${this._loading || this._step === null
? html`
<step-flow-loading
.flowConfig=${this._params.flowConfig}
.hass=${this.hass}
.loadingReason=${this._loading!}
.handler=${this._handler}
.step=${this._step}
></step-flow-loading>
`
: this._step === undefined
? // When we are going to next step, we render 1 round of empty
// to reset the element.
nothing
: html`
<ha-icon-button
.label=${this.hass.localize("ui.common.close")}
.path=${mdiClose}
dialogAction="close"
></ha-icon-button>
</div>
${this._step.type === "form"
? html`
<step-flow-form
.flowConfig=${this._params.flowConfig}
.step=${this._step}
.hass=${this.hass}
.increasePaddingEnd=${showDocumentationLink}
></step-flow-form>
`
: this._step.type === "external"
@@ -360,6 +253,7 @@ class DataEntryFlowDialog extends LitElement {
.flowConfig=${this._params.flowConfig}
.step=${this._step}
.hass=${this.hass}
.increasePaddingEnd=${showDocumentationLink}
></step-flow-external>
`
: this._step.type === "abort"
@@ -371,6 +265,7 @@ class DataEntryFlowDialog extends LitElement {
.handler=${this._step.handler}
.domain=${this._params.domain ??
this._step.handler}
.increasePaddingEnd=${showDocumentationLink}
></step-flow-abort>
`
: this._step.type === "progress"
@@ -380,6 +275,7 @@ class DataEntryFlowDialog extends LitElement {
.step=${this._step}
.hass=${this.hass}
.progress=${this._progress}
.increasePaddingEnd=${showDocumentationLink}
></step-flow-progress>
`
: this._step.type === "menu"
@@ -388,6 +284,7 @@ class DataEntryFlowDialog extends LitElement {
.flowConfig=${this._params.flowConfig}
.step=${this._step}
.hass=${this.hass}
.increasePaddingEnd=${showDocumentationLink}
></step-flow-menu>
`
: html`
@@ -397,11 +294,7 @@ class DataEntryFlowDialog extends LitElement {
.hass=${this.hass}
.navigateToResult=${this._params
.navigateToResult ?? false}
.devices=${this._devices(
this._params.flowConfig.showDevices,
Object.values(this.hass.devices),
this._step.result?.entry_id
)}
.increasePaddingEnd=${showDocumentationLink}
></step-flow-create-entry>
`}
`}
@@ -491,14 +384,16 @@ class DataEntryFlowDialog extends LitElement {
ha-dialog {
--dialog-content-padding: 0;
}
.dialog-title {
overflow: hidden;
text-overflow: ellipsis;
.dialog-actions {
padding: 16px;
position: absolute;
top: 0;
right: 0;
inset-inline-start: initial;
inset-inline-end: 0px;
direction: var(--direction);
}
.dialog-title.form {
white-space: normal;
}
.help {
.dialog-actions > * {
color: var(--secondary-text-color);
}
`,

View File

@@ -31,12 +31,10 @@ export interface FlowConfig {
deleteFlow(hass: HomeAssistant, flowId: string): Promise<unknown>;
renderAbortHeader?(hass: HomeAssistant, step: DataEntryFlowStepAbort): string;
renderAbortSubheader?(
renderAbortHeader?(
hass: HomeAssistant,
step: DataEntryFlowStepAbort
): string | TemplateResult;
): TemplateResult | string;
renderAbortDescription(
hass: HomeAssistant,
@@ -46,11 +44,6 @@ export interface FlowConfig {
renderShowFormStepHeader(
hass: HomeAssistant,
step: DataEntryFlowStepForm
): string;
renderShowFormStepSubheader?(
hass: HomeAssistant,
step: DataEntryFlowStepForm
): string | TemplateResult;
renderShowFormStepDescription(
@@ -107,11 +100,6 @@ export interface FlowConfig {
renderShowFormProgressHeader(
hass: HomeAssistant,
step: DataEntryFlowStepProgress
): string;
renderShowFormProgressSubheader?(
hass: HomeAssistant,
step: DataEntryFlowStepProgress
): string | TemplateResult;
renderShowFormProgressDescription(
@@ -119,9 +107,7 @@ export interface FlowConfig {
step: DataEntryFlowStepProgress
): TemplateResult | "";
renderMenuHeader(hass: HomeAssistant, step: DataEntryFlowStepMenu): string;
renderMenuSubheader?(
renderMenuHeader(
hass: HomeAssistant,
step: DataEntryFlowStepMenu
): string | TemplateResult;

View File

@@ -22,6 +22,9 @@ class StepFlowAbort extends LitElement {
@property({ attribute: false }) public handler!: string;
@property({ type: Boolean, attribute: "increase-padding-end" })
public increasePaddingEnd = false;
protected firstUpdated(changed: PropertyValues) {
super.firstUpdated(changed);
if (this.step.reason === "missing_credentials") {
@@ -34,6 +37,11 @@ class StepFlowAbort extends LitElement {
return nothing;
}
return html`
<h2 class=${this.increasePaddingEnd ? "end-space" : ""}>
${this.params.flowConfig.renderAbortHeader
? this.params.flowConfig.renderAbortHeader(this.hass, this.step)
: this.hass.localize(`component.${this.domain}.title`)}
</h2>
<div class="content">
${this.params.flowConfig.renderAbortDescription(this.hass, this.step)}
</div>

View File

@@ -36,7 +36,8 @@ class StepFlowCreateEntry extends LitElement {
@property({ attribute: false }) public step!: DataEntryFlowStepCreateEntry;
@property({ attribute: false }) public devices!: DeviceRegistryEntry[];
@property({ type: Boolean, attribute: "increase-padding-end" })
public increasePaddingEnd = false;
public navigateToResult = false;
@@ -45,6 +46,17 @@ class StepFlowCreateEntry extends LitElement {
{ name?: string; area?: string }
> = {};
private _devices = memoizeOne(
(
showDevices: boolean,
devices: DeviceRegistryEntry[],
entry_id?: string
) =>
showDevices && entry_id
? devices.filter((device) => device.config_entries.includes(entry_id))
: []
);
private _deviceEntities = memoizeOne(
(
deviceId: string,
@@ -63,16 +75,22 @@ class StepFlowCreateEntry extends LitElement {
return;
}
const devices = this._devices(
this.flowConfig.showDevices,
Object.values(this.hass.devices),
this.step.result?.entry_id
);
if (
this.devices.length !== 1 ||
this.devices[0].primary_config_entry !== this.step.result?.entry_id ||
devices.length !== 1 ||
devices[0].primary_config_entry !== this.step.result?.entry_id ||
this.step.result.domain === "voip"
) {
return;
}
const assistSatellites = this._deviceEntities(
this.devices[0].id,
devices[0].id,
Object.values(this.hass.entities),
"assist_satellite"
);
@@ -85,14 +103,26 @@ class StepFlowCreateEntry extends LitElement {
this.navigateToResult = false;
this._flowDone();
showVoiceAssistantSetupDialog(this, {
deviceId: this.devices[0].id,
deviceId: devices[0].id,
});
}
}
protected render(): TemplateResult {
const localize = this.hass.localize;
const devices = this._devices(
this.flowConfig.showDevices,
Object.values(this.hass.devices),
this.step.result?.entry_id
);
return html`
<h2 class=${this.increasePaddingEnd ? "end-space" : ""}>
${devices.length
? localize("ui.panel.config.integrations.config_flow.assign_area", {
number: devices.length,
})
: `${localize("ui.panel.config.integrations.config_flow.success")}!`}
</h2>
<div class="content">
${this.flowConfig.renderCreateEntryDescription(this.hass, this.step)}
${this.step.result?.state === "not_loaded"
@@ -102,10 +132,10 @@ class StepFlowCreateEntry extends LitElement {
)}</span
>`
: nothing}
${this.devices.length === 0 &&
${devices.length === 0 &&
["options_flow", "repair_flow"].includes(this.flowConfig.flowType)
? nothing
: this.devices.length === 0
: devices.length === 0
? html`<p>
${localize(
"ui.panel.config.integrations.config_flow.created_config",
@@ -114,7 +144,7 @@ class StepFlowCreateEntry extends LitElement {
</p>`
: html`
<div class="devices">
${this.devices.map(
${devices.map(
(device) => html`
<div class="device">
<div class="device-info">
@@ -173,7 +203,7 @@ class StepFlowCreateEntry extends LitElement {
<div class="buttons">
<mwc-button @click=${this._flowDone}
>${localize(
`ui.panel.config.integrations.config_flow.${!this.devices.length || Object.keys(this._deviceUpdate).length ? "finish" : "finish_skip"}`
`ui.panel.config.integrations.config_flow.${!devices.length || Object.keys(this._deviceUpdate).length ? "finish" : "finish_skip"}`
)}</mwc-button
>
</div>

View File

@@ -15,10 +15,16 @@ class StepFlowExternal extends LitElement {
@property({ attribute: false }) public step!: DataEntryFlowStepExternal;
@property({ type: Boolean, attribute: "increase-padding-end" })
public increasePaddingEnd = false;
protected render(): TemplateResult {
const localize = this.hass.localize;
return html`
<h2 class=${this.increasePaddingEnd ? "end-space" : ""}>
${this.flowConfig.renderExternalStepHeader(this.hass, this.step)}
</h2>
<div class="content">
${this.flowConfig.renderExternalStepDescription(this.hass, this.step)}
<div class="open-button">
@@ -50,6 +56,9 @@ class StepFlowExternal extends LitElement {
.open-button a {
text-decoration: none;
}
h2.end-space {
padding-inline-end: 72px;
}
`,
];
}

View File

@@ -6,18 +6,18 @@ import { dynamicElement } from "../../common/dom/dynamic-element-directive";
import { fireEvent } from "../../common/dom/fire_event";
import { isNavigationClick } from "../../common/dom/is-navigation-click";
import "../../components/ha-alert";
import "../../components/ha-spinner";
import { computeInitialHaFormData } from "../../components/ha-form/compute-initial-ha-form-data";
import "../../components/ha-form/ha-form";
import type { HaFormSchema } from "../../components/ha-form/types";
import "../../components/ha-markdown";
import "../../components/ha-spinner";
import { autocompleteLoginFields } from "../../data/auth";
import type { DataEntryFlowStepForm } from "../../data/data_entry_flow";
import { previewModule } from "../../data/preview";
import { haStyle } from "../../resources/styles";
import type { HomeAssistant } from "../../types";
import type { FlowConfig } from "./show-dialog-data-entry-flow";
import { configFlowContentStyles } from "./styles";
import { haStyle } from "../../resources/styles";
import { previewModule } from "../../data/preview";
@customElement("step-flow-form")
class StepFlowForm extends LitElement {
@@ -27,6 +27,9 @@ class StepFlowForm extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean, attribute: "increase-padding-end" })
public increasePaddingEnd = false;
@state() private _loading = false;
@state() private _stepData?: Record<string, any>;
@@ -43,6 +46,9 @@ class StepFlowForm extends LitElement {
const stepData = this._stepDataProcessed;
return html`
<h2 class=${this.increasePaddingEnd ? "end-space" : ""}>
${this.flowConfig.renderShowFormStepHeader(this.hass, this.step)}
</h2>
<div class="content" @click=${this._clickHandler}>
${this.flowConfig.renderShowFormStepDescription(this.hass, this.step)}
${this._errorMsg
@@ -275,6 +281,9 @@ class StepFlowForm extends LitElement {
margin-top: 24px;
display: block;
}
h2 {
word-break: break-word;
}
`,
];
}

View File

@@ -17,6 +17,9 @@ class StepFlowMenu extends LitElement {
@property({ attribute: false }) public step!: DataEntryFlowStepMenu;
@property({ type: Boolean, attribute: "increase-padding-end" })
public increasePaddingEnd = false;
protected render(): TemplateResult {
let options: string[];
let translations: Record<string, string>;
@@ -42,6 +45,9 @@ class StepFlowMenu extends LitElement {
);
return html`
<h2 class=${this.increasePaddingEnd ? "end-space" : ""}>
${this.flowConfig.renderMenuHeader(this.hass, this.step)}
</h2>
${description ? html`<div class="content">${description}</div>` : ""}
<div class="options">
${options.map(

View File

@@ -2,13 +2,13 @@ import "@material/mwc-button";
import type { CSSResultGroup, TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import { blankBeforePercent } from "../../common/translations/blank_before_percent";
import "../../components/ha-progress-ring";
import "../../components/ha-spinner";
import type { DataEntryFlowStepProgress } from "../../data/data_entry_flow";
import type { HomeAssistant } from "../../types";
import type { FlowConfig } from "./show-dialog-data-entry-flow";
import { configFlowContentStyles } from "./styles";
import { blankBeforePercent } from "../../common/translations/blank_before_percent";
@customElement("step-flow-progress")
class StepFlowProgress extends LitElement {
@@ -24,8 +24,14 @@ class StepFlowProgress extends LitElement {
@property({ type: Number })
public progress?: number;
@property({ type: Boolean, attribute: "increase-padding-end" })
public increasePaddingEnd = false;
protected render(): TemplateResult {
return html`
<h2 class=${this.increasePaddingEnd ? "end-space" : ""}>
${this.flowConfig.renderShowFormProgressHeader(this.hass, this.step)}
</h2>
<div class="content">
${this.progress
? html`

View File

@@ -25,6 +25,9 @@ export const configFlowContentStyles = css`
text-transform: var(--mdc-typography-headline6-text-transform, inherit);
box-sizing: border-box;
}
h2.end-space {
padding-inline-end: 72px;
}
.content,
.preview {

View File

@@ -9,50 +9,50 @@ import {
mdiReload,
mdiServerNetwork,
} from "@mdi/js";
import Fuse from "fuse.js";
import type { TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { ifDefined } from "lit/directives/if-defined";
import { styleMap } from "lit/directives/style-map";
import memoizeOne from "memoize-one";
import Fuse from "fuse.js";
import { canShowPage } from "../../common/config/can_show_page";
import { componentsWithService } from "../../common/config/components_with_service";
import { isComponentLoaded } from "../../common/config/is_component_loaded";
import { fireEvent } from "../../common/dom/fire_event";
import { computeAreaName } from "../../common/entity/compute_area_name";
import {
computeDeviceName,
computeDeviceNameDisplay,
} from "../../common/entity/compute_device_name";
import { computeDomain } from "../../common/entity/compute_domain";
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 { navigate } from "../../common/navigate";
import { caseInsensitiveStringCompare } from "../../common/string/compare";
import type { ScorableTextItem } from "../../common/string/filter/sequence-matching";
import { computeRTL } from "../../common/util/compute_rtl";
import { debounce } from "../../common/util/debounce";
import "../../components/ha-icon-button";
import "../../components/ha-label";
import "../../components/ha-list";
import "../../components/ha-md-list-item";
import "../../components/ha-spinner";
import "../../components/ha-textfield";
import "../../components/ha-tip";
import "../../components/ha-md-list-item";
import { fetchHassioAddonsInfo } from "../../data/hassio/addon";
import { domainToName } from "../../data/integration";
import { getPanelNameTranslationKey } from "../../data/panel";
import type { PageNavigation } from "../../layouts/hass-tabs-subpage";
import { configSections } from "../../panels/config/ha-panel-config";
import { HaFuse } from "../../resources/fuse";
import { haStyleDialog, haStyleScrollbar } from "../../resources/styles";
import { loadVirtualizer } from "../../resources/virtualizer";
import type { HomeAssistant } from "../../types";
import { showConfirmationDialog } from "../generic/show-dialog-box";
import { showShortcutsDialog } from "../shortcuts/show-shortcuts-dialog";
import { QuickBarMode, type QuickBarParams } from "./show-dialog-quick-bar";
import { getEntityContext } from "../../common/entity/context/get_entity_context";
import { computeEntityName } from "../../common/entity/compute_entity_name";
import { computeAreaName } from "../../common/entity/compute_area_name";
import { computeRTL } from "../../common/util/compute_rtl";
import { computeDomain } from "../../common/entity/compute_domain";
import { computeStateName } from "../../common/entity/compute_state_name";
import { HaFuse } from "../../resources/fuse";
interface QuickBarItem extends ScorableTextItem {
primaryText: string;
@@ -152,6 +152,11 @@ export class QuickBar extends LitElement {
}
}
protected firstUpdated(changedProps) {
super.firstUpdated(changedProps);
this.hass.loadBackendTranslation("title");
}
private _getItems = memoizeOne(
(
mode: QuickBarMode,
@@ -299,8 +304,7 @@ export class QuickBar extends LitElement {
} else if (this._mode === QuickBarMode.Device) {
this._deviceItems = this._deviceItems || this._generateDeviceItems();
} else {
this._entityItems =
this._entityItems || (await this._generateEntityItems());
this._entityItems = this._entityItems || this._generateEntityItems();
}
}
@@ -382,7 +386,7 @@ export class QuickBar extends LitElement {
`
: nothing}
${item.translatedDomain && !showEntityId
? html`<div slot="trailing-supporting-text" class="domain">
? html`<div slot="trailing-supporting-text">
${item.translatedDomain}
</div>`
: nothing}
@@ -577,11 +581,9 @@ export class QuickBar extends LitElement {
);
}
private async _generateEntityItems(): Promise<EntityItem[]> {
private _generateEntityItems(): EntityItem[] {
const isRTL = computeRTL(this.hass);
await this.hass.loadBackendTranslation("title");
return Object.keys(this.hass.states)
.map((entityId) => {
const stateObj = this.hass.states[entityId];
@@ -1025,7 +1027,7 @@ export class QuickBar extends LitElement {
font-size: var(--ha-font-size-xs);
}
ha-md-list-item .domain {
ha-md-list-item [slot="trailing-supporting-text"] {
font-size: var(--ha-font-size-s);
font-weight: var(--ha-font-weight-normal);
line-height: var(--ha-line-height-normal);

View File

@@ -1,21 +0,0 @@
import type { ReactiveElement } from "lit";
import type { Constructor } from "../types";
export const WakeLockMixin = <T extends Constructor<ReactiveElement>>(
superClass: T
) =>
class WakeLockClass extends superClass {
private _wakeLock?: Promise<WakeLockSentinel>;
public connectedCallback() {
super.connectedCallback();
if ("wakeLock" in navigator) {
this._wakeLock = navigator.wakeLock.request();
}
}
public disconnectedCallback() {
super.disconnectedCallback();
this._wakeLock?.then((wakeLock) => wakeLock.release());
}
};

View File

@@ -18,8 +18,6 @@ export class HaChooseAction extends LitElement implements ActionElement {
@property({ attribute: false }) public action!: ChooseAction;
@property({ type: Boolean }) public narrow = false;
@state() private _showDefault = false;
public static get defaultConfig(): ChooseAction {
@@ -37,7 +35,6 @@ export class HaChooseAction extends LitElement implements ActionElement {
.disabled=${this.disabled}
@value-changed=${this._optionsChanged}
.hass=${this.hass}
.narrow=${this.narrow}
></ha-automation-option>
${this._showDefault || action.default
@@ -52,7 +49,6 @@ export class HaChooseAction extends LitElement implements ActionElement {
.disabled=${this.disabled}
@value-changed=${this._defaultChanged}
.hass=${this.hass}
.narrow=${this.narrow}
></ha-automation-action>
`
: html`

View File

@@ -18,8 +18,6 @@ export class HaIfAction extends LitElement implements ActionElement {
@property({ attribute: false }) public action!: IfAction;
@property({ type: Boolean }) public narrow = false;
@state() private _showElse = false;
public static get defaultConfig(): IfAction {
@@ -43,7 +41,6 @@ export class HaIfAction extends LitElement implements ActionElement {
.disabled=${this.disabled}
@value-changed=${this._ifChanged}
.hass=${this.hass}
.narrow=${this.narrow}
></ha-automation-condition>
<h3>
@@ -56,7 +53,6 @@ export class HaIfAction extends LitElement implements ActionElement {
.disabled=${this.disabled}
@value-changed=${this._thenChanged}
.hass=${this.hass}
.narrow=${this.narrow}
></ha-automation-action>
${this._showElse || action.else
? html`
@@ -70,7 +66,6 @@ export class HaIfAction extends LitElement implements ActionElement {
.disabled=${this.disabled}
@value-changed=${this._elseChanged}
.hass=${this.hass}
.narrow=${this.narrow}
></ha-automation-action>
`
: html` <div class="link-button-row">

View File

@@ -183,7 +183,6 @@ export default class HaAutomationOptionRow extends LitElement {
)}
.disabled=${this.disabled}
.hass=${this.hass}
.narrow=${this.narrow}
@value-changed=${this._conditionChanged}
></ha-automation-condition>
<h4>
@@ -195,7 +194,6 @@ export default class HaAutomationOptionRow extends LitElement {
.actions=${ensureArray(this.option.sequence) || []}
.disabled=${this.disabled}
.hass=${this.hass}
.narrow=${this.narrow}
@value-changed=${this._actionChanged}
></ha-automation-action>
</div>

View File

@@ -107,8 +107,6 @@ export interface DeviceAlert {
text: string;
}
const DEVICE_ALERTS_INTERVAL = 30000;
@customElement("ha-config-device-page")
export class HaConfigDevicePage extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -136,8 +134,6 @@ export class HaConfigDevicePage extends LitElement {
@state() private _deviceAlerts?: DeviceAlert[];
private _deviceAlertsTimeout?: number;
@state()
@consume({ context: fullEntitiesContext, subscribe: true })
_entityReg!: EntityRegistryEntry[];
@@ -284,7 +280,6 @@ export class HaConfigDevicePage extends LitElement {
this._getDiagnosticButtons(this._diagnosticDownloadLinks);
this._getDeleteActions();
this._getDeviceActions();
clearTimeout(this._deviceAlertsTimeout);
this._getDeviceAlerts();
}
@@ -300,11 +295,6 @@ export class HaConfigDevicePage extends LitElement {
}
}
public disconnectedCallback() {
super.disconnectedCallback();
clearTimeout(this._deviceAlertsTimeout);
}
protected render() {
if (!this.hass || !this.deviceId) {
return nothing;
@@ -1163,10 +1153,6 @@ export class HaConfigDevicePage extends LitElement {
if (deviceAlerts.length) {
this._deviceAlerts = deviceAlerts;
this._deviceAlertsTimeout = window.setTimeout(
() => this._getDeviceAlerts(),
DEVICE_ALERTS_INTERVAL
);
}
}

View File

@@ -5,11 +5,9 @@ import {
mdiNetwork,
mdiPlus,
mdiPencil,
mdiCheckCircle,
mdiAlertCircle,
} from "@mdi/js";
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import type { ConfigEntry } from "../../../../../data/config_entries";
import { getConfigEntries } from "../../../../../data/config_entries";
@@ -27,8 +25,6 @@ import "../../../ha-config-section";
import "../../../../../components/ha-form/ha-form";
import "../../../../../components/buttons/ha-progress-button";
import "../../../../../components/ha-settings-row";
import "../../../../../components/ha-svg-icon";
import "../../../../../components/ha-alert";
import { showZHAChangeChannelDialog } from "./show-dialog-zha-change-channel";
import type {
ZHAConfiguration,
@@ -40,7 +36,6 @@ import {
updateZHAConfiguration,
fetchZHANetworkSettings,
createZHANetworkBackup,
fetchDevices,
} from "../../../../../data/zha";
import { showAlertDialog } from "../../../../../dialogs/generic/show-dialog-box";
@@ -80,27 +75,18 @@ class ZHAConfigDashboard extends LitElement {
@state() private _networkSettings?: ZHANetworkSettings;
@state() private _totalDevices = 0;
@state() private _offlineDevices = 0;
@state() private _error?: string;
@state() private _generatingBackup = false;
protected firstUpdated(changedProperties: PropertyValues) {
protected firstUpdated(changedProperties: PropertyValues): void {
super.firstUpdated(changedProperties);
if (this.hass) {
this.hass.loadBackendTranslation("config_panel", "zha", false);
this._fetchConfiguration();
this._fetchSettings();
this._fetchDevicesAndUpdateStatus();
}
}
protected render(): TemplateResult {
const deviceOnline =
this._offlineDevices < this._totalDevices || this._totalDevices === 0;
return html`
<hass-tabs-subpage
.hass=${this.hass}
@@ -109,43 +95,11 @@ class ZHAConfigDashboard extends LitElement {
.tabs=${zhaTabs}
back-path="/config/integrations"
>
<ha-card class="content network-status">
${this._error
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
: nothing}
<div class="card-content">
<div class="heading">
<div class="icon">
<ha-svg-icon
.path=${deviceOnline ? mdiCheckCircle : mdiAlertCircle}
class=${deviceOnline ? "online" : "offline"}
></ha-svg-icon>
</div>
<div class="details">
ZHA
${this.hass.localize(
"ui.panel.config.zha.configuration_page.status_title"
)}:
${this.hass.localize(
`ui.panel.config.zha.configuration_page.status_${deviceOnline ? "online" : "offline"}`
)}<br />
<small>
${this.hass.localize(
"ui.panel.config.zha.configuration_page.devices",
{ count: this._totalDevices }
<ha-card
header=${this.hass.localize(
"ui.panel.config.zha.configuration_page.shortcuts_title"
)}
</small>
<small class="offline">
${this._offlineDevices > 0
? html`(${this.hass.localize(
"ui.panel.config.zha.configuration_page.devices_offline",
{ count: this._offlineDevices }
)})`
: nothing}
</small>
</div>
</div>
</div>
>
${this.configEntryId
? html`<div class="card-actions">
<a
@@ -314,17 +268,6 @@ class ZHAConfigDashboard extends LitElement {
this._networkSettings = await fetchZHANetworkSettings(this.hass!);
}
private async _fetchDevicesAndUpdateStatus(): Promise<void> {
try {
const devices = await fetchDevices(this.hass);
this._totalDevices = devices.length;
this._offlineDevices =
this._totalDevices - devices.filter((d) => d.available).length;
} catch (err: any) {
this._error = err.message || err;
}
}
private async _showChannelMigrationDialog(): Promise<void> {
if (this._networkSettings!.device.path === MULTIPROTOCOL_ADDON_URL) {
showAlertDialog(this, {
@@ -445,43 +388,6 @@ class ZHAConfigDashboard extends LitElement {
margin-top: -16px;
margin-bottom: -16px;
}
.content {
margin-top: 24px;
}
.network-status div.heading {
display: flex;
align-items: center;
}
.network-status div.heading .icon {
margin-inline-end: 16px;
}
.network-status div.heading ha-svg-icon {
--mdc-icon-size: 48px;
}
.network-status div.heading .details {
font-size: var(--ha-font-size-xl);
}
.network-status small {
font-size: var(--ha-font-size-m);
}
.network-status small.offline {
color: var(--secondary-text-color);
}
.network-status .online {
color: var(--state-on-color, var(--success-color));
}
.network-status .offline {
color: var(--error-color, var(--error-color));
}
`,
];
}

View File

@@ -48,6 +48,7 @@ import "../../../../../../components/ha-dialog-header";
import "../../../../../../components/ha-fade-in";
import "../../../../../../components/ha-icon-button";
import "../../../../../../components/ha-qr-scanner";
import "../../../../../../components/ha-spinner";
import { computeStateName } from "../../../../../../common/entity/compute_state_name";
import { navigate } from "../../../../../../common/navigate";

View File

@@ -3,10 +3,9 @@ import { css, html, LitElement, nothing } from "lit";
import "../../../../../../components/ha-fade-in";
import "../../../../../../components/ha-spinner";
import { WakeLockMixin } from "../../../../../../mixins/wakelock-mixin";
@customElement("zwave-js-add-node-loading")
export class ZWaveJsAddNodeLoading extends WakeLockMixin(LitElement) {
export class ZWaveJsAddNodeLoading extends LitElement {
@property() public description?: string;
@property({ type: Number }) public delay = 0;

View File

@@ -10,10 +10,9 @@ import { InclusionStrategy } from "../../../../../../data/zwave_js";
import "../../../../../../components/ha-spinner";
import "../../../../../../components/ha-button";
import "../../../../../../components/ha-alert";
import { WakeLockMixin } from "../../../../../../mixins/wakelock-mixin";
@customElement("zwave-js-add-node-searching-devices")
export class ZWaveJsAddNodeSearchingDevices extends WakeLockMixin(LitElement) {
export class ZWaveJsAddNodeSearchingDevices extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean, attribute: "smart-start" })

View File

@@ -468,7 +468,7 @@ class ZWaveJSConfigDashboard extends SubscribeMixin(LitElement) {
.value=${this._backupProgress}
></ha-progress-ring>
${this.hass.localize(
"ui.panel.config.zwave_js.dashboard.nvm_backup.creating"
"ui.panel.config.zwave_js.dashboard.nvm_backup.downloading"
)}
${this._backupProgress}%`
: this._restoreProgress !== undefined

View File

@@ -1,6 +1,7 @@
import { html, nothing } from "lit";
import type { DataEntryFlowStep } from "../../../data/data_entry_flow";
import { domainToName } from "../../../data/integration";
import "./dialog-repairs-issue-subtitle";
import type { RepairsIssue } from "../../../data/repairs";
import {
createRepairsFlow,
@@ -13,7 +14,6 @@ import {
showFlowDialog,
} from "../../../dialogs/config-flow/show-dialog-data-entry-flow";
import type { HomeAssistant } from "../../../types";
import "./dialog-repairs-issue-subtitle";
const mergePlaceholders = (issue: RepairsIssue, step: DataEntryFlowStep) =>
step.description_placeholders && issue.translation_placeholders
@@ -68,11 +68,8 @@ export const showRepairsFlowDialog = (
deleteFlow: deleteRepairsFlow,
renderAbortHeader(hass) {
return hass.localize("ui.dialogs.repair_flow.form.header");
},
renderAbortSubheader(hass) {
return html`
${hass.localize("ui.dialogs.repair_flow.form.header")}
<dialog-repairs-issue-subtitle
.hass=${hass}
.issue=${issue}
@@ -101,18 +98,13 @@ export const showRepairsFlowDialog = (
},
renderShowFormStepHeader(hass, step) {
return (
hass.localize(
return html`
${hass.localize(
`component.${issue.domain}.issues.${
issue.translation_key || issue.issue_id
}.fix_flow.step.${step.step_id}.title`,
mergePlaceholders(issue, step)
) || hass.localize("ui.dialogs.repair_flow.form.header")
);
},
renderShowFormStepSubheader(hass) {
return html`
) || hass.localize("ui.dialogs.repair_flow.form.header")}
<dialog-repairs-issue-subtitle
.hass=${hass}
.issue=${issue}
@@ -204,18 +196,13 @@ export const showRepairsFlowDialog = (
},
renderShowFormProgressHeader(hass, step) {
return (
hass.localize(
return html`
${hass.localize(
`component.${issue.domain}.issues.step.${
issue.translation_key || issue.issue_id
}.fix_flow.${step.step_id}.title`,
mergePlaceholders(issue, step)
) || hass.localize(`component.${issue.domain}.title`)
);
},
renderShowFormProgressSubheader(hass) {
return html`
) || hass.localize(`component.${issue.domain}.title`)}
<dialog-repairs-issue-subtitle
.hass=${hass}
.issue=${issue}
@@ -242,18 +229,13 @@ export const showRepairsFlowDialog = (
},
renderMenuHeader(hass, step) {
return (
hass.localize(
return html`
${hass.localize(
`component.${issue.domain}.issues.${
issue.translation_key || issue.issue_id
}.fix_flow.step.${step.step_id}.title`,
mergePlaceholders(issue, step)
) || hass.localize(`component.${issue.domain}.title`)
);
},
renderMenuSubheader(hass) {
return html`
) || hass.localize(`component.${issue.domain}.title`)}
<dialog-repairs-issue-subtitle
.hass=${hass}
.issue=${issue}

View File

@@ -244,10 +244,7 @@ export class HuiEnergyDevicesGraphCard
chartData.sort((a: any, b: any) => b.value[0] - a.value[0]);
chartData.length = Math.min(
this._config?.max_devices || Infinity,
chartData.length
);
chartData.length = this._config?.max_devices || chartData.length;
this._chartData = datasets;
await this.updateComplete;

View File

@@ -139,7 +139,6 @@ class HuiMapCard extends LitElement implements LovelaceCard {
? processConfigEntities<MapEntityConfig>(this._config.entities)
: [];
this._mapEntities = this._getMapEntities();
this._clusterMarkers = this._config.cluster ?? true;
}
public getCardSize(): number {
@@ -216,8 +215,6 @@ class HuiMapCard extends LitElement implements LovelaceCard {
render-passive
></ha-map>
<div id="buttons">
${this._mapEntities.length > 1
? html`
<ha-icon-button
.label=${this.hass!.localize(
"ui.panel.lovelace.cards.map.toggle_grouping"
@@ -229,8 +226,6 @@ class HuiMapCard extends LitElement implements LovelaceCard {
@click=${this._toggleClusterMarkers}
tabindex="0"
></ha-icon-button>
`
: nothing}
<ha-icon-button
.label=${this.hass!.localize(
"ui.panel.lovelace.cards.map.reset_focus"

View File

@@ -337,7 +337,6 @@ export interface MapCardConfig extends LovelaceCardConfig {
geo_location_sources?: (GeoLocationSourceConfig | string)[];
dark_mode?: boolean;
theme_mode?: ThemeMode;
cluster?: boolean;
}
export interface MarkdownCardConfig extends LovelaceCardConfig {

View File

@@ -110,7 +110,11 @@ export class HuiDialogEditCard
}
protected updated(changedProps: PropertyValues): void {
if (!this._cardConfig || !changedProps.has("_cardConfig")) {
if (
!this._cardConfig ||
this._documentationURL !== undefined ||
!changedProps.has("_cardConfig")
) {
return;
}

View File

@@ -58,6 +58,7 @@ const cardConfigStruct = assign(
icon_double_tap_action: optional(actionConfigStruct),
features: optional(array(any())),
features_position: optional(enums(["bottom", "inline"])),
areas: optional(any()),
})
);
@@ -82,6 +83,14 @@ export class HuiTileCardEditor
hideState: boolean
) =>
[
{
name: "areas",
selector: {
area: {
multiple: true,
},
},
},
{ name: "entity", selector: { entity: {} } },
{
name: "content",

View File

@@ -7,23 +7,6 @@ import {
import type { HomeAssistant } from "../../../types";
import { documentationUrl } from "../../../util/documentation-url";
const NON_STANDARD_URLS = {
"energy-date-selection": "energy/#energy-date-picker",
"energy-usage-graph": "energy/#energy-usage-graph",
"energy-solar-graph": "energy/#solar-production-graph",
"energy-gas-graph": "energy/#gas-consumption-graph",
"energy-water-graph": "energy/#water-consumption-graph",
"energy-distribution": "energy/#energy-distribution",
"energy-sources-table": "energy/#energy-sources-table",
"energy-grid-neutrality-gauge": "energy/#grid-neutrality-gauge",
"energy-solar-consumed-gauge": "energy/#solar-consumed-gauge",
"energy-carbon-consumed-gauge": "energy/#carbon-consumed-gauge",
"energy-self-sufficiency-gauge": "energy/#self-sufficiency-gauge",
"energy-devices-graph": "energy/#devices-energy-graph",
"energy-devices-detail-graph": "energy/#detail-devices-energy-graph",
"energy-sankey": "energy/#sankey-energy-graph",
};
export const getCardDocumentationURL = (
hass: HomeAssistant,
type: string
@@ -32,7 +15,7 @@ export const getCardDocumentationURL = (
return getCustomCardEntry(stripCustomPrefix(type))?.documentationURL;
}
return `${documentationUrl(hass, "/dashboards/")}${NON_STANDARD_URLS[type] || type}`;
return `${documentationUrl(hass, "/dashboards/")}${type}`;
};
export const getBadgeDocumentationURL = (

View File

@@ -1,12 +1,11 @@
import type { TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import "../../components/ha-alert";
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import "../../components/ha-card";
import "../../components/ha-settings-row";
import "../../components/ha-switch";
import type { CoreFrontendUserData } from "../../data/frontend";
import { saveFrontendUserData } from "../../data/frontend";
import { getOptimisticFrontendUserDataCollection } from "../../data/frontend";
import type { HomeAssistant } from "../../types";
@customElement("ha-advanced-mode-row")
@@ -17,13 +16,8 @@ class AdvancedModeRow extends LitElement {
@property({ attribute: false }) public coreUserData?: CoreFrontendUserData;
@state() private _error?: string;
protected render(): TemplateResult {
return html`
${this._error
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
: nothing}
<ha-settings-row .narrow=${this.narrow}>
<span slot="heading">
${this.hass.localize("ui.panel.profile.advanced_mode.title")}
@@ -47,24 +41,16 @@ class AdvancedModeRow extends LitElement {
}
private async _advancedToggled(ev) {
try {
saveFrontendUserData(this.hass.connection, "core", {
getOptimisticFrontendUserDataCollection(this.hass.connection, "core").save({
...this.coreUserData,
showAdvanced: ev.currentTarget.checked,
});
} catch (err: any) {
this._error = err.message || err;
}
}
static styles = css`
a {
color: var(--primary-color);
}
ha-alert {
margin: 0 16px;
display: block;
}
`;
}

View File

@@ -1,12 +1,11 @@
import type { TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import "../../components/ha-alert";
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import "../../components/ha-card";
import "../../components/ha-settings-row";
import "../../components/ha-switch";
import type { CoreFrontendUserData } from "../../data/frontend";
import { saveFrontendUserData } from "../../data/frontend";
import { getOptimisticFrontendUserDataCollection } from "../../data/frontend";
import type { HomeAssistant } from "../../types";
@customElement("ha-entity-id-picker-row")
@@ -17,13 +16,8 @@ class EntityIdPickerRow extends LitElement {
@property({ attribute: false }) public coreUserData?: CoreFrontendUserData;
@state() private _error?: string;
protected render(): TemplateResult {
return html`
${this._error
? html`<ha-alert alert-type="error">${this._error}</ha-alert>`
: nothing}
<ha-settings-row .narrow=${this.narrow}>
<span slot="heading">
${this.hass.localize("ui.panel.profile.entity_id_picker.title")}</span
@@ -41,24 +35,16 @@ class EntityIdPickerRow extends LitElement {
}
private async _toggled(ev) {
try {
saveFrontendUserData(this.hass.connection, "core", {
getOptimisticFrontendUserDataCollection(this.hass.connection, "core").save({
...this.coreUserData,
showEntityIdPicker: ev.currentTarget.checked,
});
} catch (err: any) {
this._error = err.message || err;
}
}
static styles = css`
a {
color: var(--primary-color);
}
ha-alert {
margin: 0 16px;
display: block;
}
`;
}

View File

@@ -4,32 +4,32 @@ import type { CSSResultGroup, TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../common/dom/fire_event";
import { nextRender } from "../../common/util/render-status";
import "../../components/ha-card";
import "../../layouts/hass-tabs-subpage";
import { profileSections } from "./ha-panel-profile";
import { isExternal } from "../../data/external";
import type { CoreFrontendUserData } from "../../data/frontend";
import { subscribeFrontendUserData } from "../../data/frontend";
import { getOptimisticFrontendUserDataCollection } from "../../data/frontend";
import { showConfirmationDialog } from "../../dialogs/generic/show-dialog-box";
import "../../layouts/hass-tabs-subpage";
import { isMobileClient } from "../../util/is_mobile";
import { haStyle } from "../../resources/styles";
import type { HomeAssistant, Route } from "../../types";
import { isMobileClient } from "../../util/is_mobile";
import "./ha-advanced-mode-row";
import "./ha-enable-shortcuts-row";
import "./ha-entity-id-picker-row";
import "./ha-force-narrow-row";
import { profileSections } from "./ha-panel-profile";
import "./ha-pick-dashboard-row";
import "./ha-pick-date-format-row";
import "./ha-pick-first-weekday-row";
import "./ha-pick-language-row";
import "./ha-pick-number-format-row";
import "./ha-pick-theme-row";
import "./ha-pick-time-format-row";
import "./ha-pick-date-format-row";
import "./ha-pick-time-zone-row";
import "./ha-push-notifications-row";
import "./ha-set-suspend-row";
import "./ha-set-vibrate-row";
import { nextRender } from "../../common/util/render-status";
@customElement("ha-profile-section-general")
class HaProfileSectionGeneral extends LitElement {
@@ -41,16 +41,15 @@ class HaProfileSectionGeneral extends LitElement {
@property({ attribute: false }) public route!: Route;
private _unsubCoreData?: Promise<UnsubscribeFunc>;
private _unsubCoreData?: UnsubscribeFunc;
private _getCoreData() {
this._unsubCoreData = subscribeFrontendUserData(
this._unsubCoreData = getOptimisticFrontendUserDataCollection(
this.hass.connection,
"core",
({ value }) => {
this._coreUserData = value;
}
);
"core"
).subscribe((coreUserData) => {
this._coreUserData = coreUserData;
});
}
public connectedCallback() {
@@ -71,7 +70,7 @@ class HaProfileSectionGeneral extends LitElement {
public disconnectedCallback() {
super.disconnectedCallback();
if (this._unsubCoreData) {
this._unsubCoreData.then((unsub) => unsub());
this._unsubCoreData();
this._unsubCoreData = undefined;
}
}

View File

@@ -8,7 +8,6 @@ import {
subscribeServices,
} from "home-assistant-js-websocket";
import { fireEvent } from "../common/dom/fire_event";
import { promiseTimeout } from "../common/util/promise-timeout";
import { subscribeAreaRegistry } from "../data/area_registry";
import { broadcastConnectionStatus } from "../data/connection-status";
import { subscribeDeviceRegistry } from "../data/device_registry";
@@ -23,8 +22,6 @@ import {
TimeFormat,
TimeZone,
} from "../data/translation";
import { subscribeEntityRegistryDisplay } from "../data/ws-entity_registry_display";
import { subscribeFloorRegistry } from "../data/ws-floor_registry";
import { subscribePanels } from "../data/ws-panels";
import { translationMetadata } from "../resources/translations-metadata";
import type { Constructor, HomeAssistant, ServiceCallResponse } from "../types";
@@ -33,6 +30,9 @@ import { fetchWithAuth } from "../util/fetch-with-auth";
import { getState } from "../util/ha-pref-storage";
import hassCallApi, { hassCallApiRaw } from "../util/hass-call-api";
import type { HassBaseEl } from "./hass-base-mixin";
import { promiseTimeout } from "../common/util/promise-timeout";
import { subscribeFloorRegistry } from "../data/ws-floor_registry";
import { subscribeEntityRegistryDisplay } from "../data/ws-entity_registry_display";
export const connectionMixin = <T extends Constructor<HassBaseEl>>(
superClass: T
@@ -280,9 +280,9 @@ export const connectionMixin = <T extends Constructor<HassBaseEl>>(
subscribeConfig(conn, (config) => this._updateHass({ config }));
subscribeServices(conn, (services) => this._updateHass({ services }));
subscribePanels(conn, (panels) => this._updateHass({ panels }));
subscribeFrontendUserData(conn, "core", ({ value: userData }) => {
this._updateHass({ userData });
});
subscribeFrontendUserData(conn, "core", (userData) =>
this._updateHass({ userData })
);
clearInterval(this.__backendPingInterval);
this.__backendPingInterval = setInterval(() => {

View File

@@ -8,18 +8,17 @@ import {
} from "../common/util/compute_rtl";
import { debounce } from "../common/util/debounce";
import type {
DateFormat,
FirstWeekday,
NumberFormat,
TimeFormat,
TimeZone,
DateFormat,
TranslationCategory,
TimeZone,
} from "../data/translation";
import {
getHassTranslations,
getHassTranslationsPre109,
saveTranslationPreferences,
subscribeTranslationPreferences,
} from "../data/translation";
import { translationMetadata } from "../resources/translations-metadata";
import type { Constructor, HomeAssistant } from "../types";
@@ -120,10 +119,7 @@ export default <T extends Constructor<HassBaseEl>>(superClass: T) =>
protected hassConnected() {
super.hassConnected();
subscribeTranslationPreferences(this.hass!, async ({ value }) => {
const locale = await getUserLocale(value);
getUserLocale(this.hass!).then((locale) => {
if (locale?.language && this.hass!.language !== locale.language) {
// We just got language from backend, no need to save back
this._selectLanguage(locale.language, false);

View File

@@ -702,7 +702,8 @@
"no_areas": "You don't have any areas",
"no_match": "No matching areas found",
"unassigned_areas": "Unassigned areas",
"failed_create_area": "Failed to create area."
"failed_create_area": "Failed to create area.",
"placeholder": "Select an area"
},
"floor-picker": {
"clear": "Clear",
@@ -4725,7 +4726,7 @@
"check_your_email": "Check your email for instructions on how to reset your password."
},
"register": {
"title": "Register account",
"title": "Register Account",
"headline": "Start your free trial",
"information": "Create an account to start your free one month trial with Home Assistant Cloud. No payment information necessary.",
"information2": "The trial will give you access to all the benefits of Home Assistant Cloud, including:",
@@ -5340,7 +5341,7 @@
},
"config_flow": {
"success": "Success",
"device_created": "{number, plural,\n one {Device}\n other {Devices}\n} created",
"assign_area": "Assign {number, plural,\n one {device}\n other {devices}\n} to area",
"device_name": "Device name",
"aborted": "Aborted",
"close": "Close",
@@ -5610,11 +5611,7 @@
"value": "Value"
},
"configuration_page": {
"status_title": "status",
"status_online": "online",
"status_offline": "offline",
"devices": "{count} {count, plural,\n one {device}\n other {devices}\n}",
"devices_offline": "{count} offline",
"shortcuts_title": "Shortcuts",
"update_button": "Update configuration",
"download_backup": "Download backup",
"migrate_radio": "Migrate radio",
@@ -5765,7 +5762,7 @@
"backup_failed": "Failed to download backup",
"restore_complete": "Backup restored",
"restore_failed": "Failed to restore backup",
"creating": "Creating backup",
"downloading": "Downloading backup",
"restoring": "Restoring backup",
"migrate": "Migrate controller"
},
@@ -6423,7 +6420,7 @@
"zeroconf_info": "Show services discovered using mDNS. Does not include services unknown to Home Assistant."
},
"network_adapter": "Network adapter",
"network_adapter_info": "Configure which network adapters integrations will use. A restart is required for these settings to apply.",
"network_adapter_info": "Configure which network adapters integrations will use. Currently this setting only affects multicast traffic. A restart is required for these settings to apply.",
"ip_information": "IP Information",
"adapter": {
"auto_configure": "Auto configure",

View File

@@ -1,5 +1,7 @@
import type { FrontendLocaleData } from "../data/translation";
import { fetchTranslationPreferences } from "../data/translation";
import { translationMetadata } from "../resources/translations-metadata";
import type { HomeAssistant } from "../types";
const BASE_URL = `${__STATIC_PATH__}translations`;
const STORAGE = window.localStorage || {};
@@ -66,14 +68,15 @@ export function findAvailableLanguage(language: string) {
* Get user selected locale data from backend
*/
export async function getUserLocale(
data: FrontendLocaleData | null
hass: HomeAssistant
): Promise<Partial<FrontendLocaleData>> {
const language = data?.language;
const number_format = data?.number_format;
const time_format = data?.time_format;
const date_format = data?.date_format;
const time_zone = data?.time_zone;
const first_weekday = data?.first_weekday;
const result = await fetchTranslationPreferences(hass);
const language = result?.language;
const number_format = result?.number_format;
const time_format = result?.time_format;
const date_format = result?.date_format;
const time_zone = result?.time_zone;
const first_weekday = result?.first_weekday;
if (language) {
const availableLanguage = findAvailableLanguage(language);
if (availableLanguage) {

1524
yarn.lock

File diff suppressed because it is too large Load Diff