Compare commits

..

3 Commits

Author SHA1 Message Date
Paul Bottein 8580c99f0a WIP: autofocus, search reset, keyboard nav, cleanup 2026-05-22 10:06:10 +02:00
Paul Bottein 3cf2d9d6dd WIP: migrate entity picker to composable components 2026-05-21 15:23:25 +02:00
Paul Bottein 081212eab1 WIP: split target picker into multiple components 2026-05-21 15:23:25 +02:00
240 changed files with 4760 additions and 7045 deletions
+3 -3
View File
@@ -41,14 +41,14 @@ jobs:
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
uses: github/codeql-action/init@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4
with:
languages: ${{ matrix.language }}
# Autobuild attempts to build any compiled languages (C/C++, C#, or Java).
# If this step fails, then you should remove it and run the build manually (see below)
- name: Autobuild
uses: github/codeql-action/autobuild@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
uses: github/codeql-action/autobuild@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4
# ️ Command-line programs to run using the OS shell.
# 📚 https://git.io/JvXDl
@@ -62,4 +62,4 @@ jobs:
# make release
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4.35.5
uses: github/codeql-action/analyze@68bde559dea0fdcac2102bfdf6230c5f70eb485e # v4.35.4
+1 -1
View File
@@ -1 +1 @@
24.16.0
24.15.0
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -13,4 +13,4 @@ nodeLinker: node-modules
npmMinimalAgeGate: 3d
yarnPath: .yarn/releases/yarn-4.15.0.cjs
yarnPath: .yarn/releases/yarn-4.14.1.cjs
+1
View File
@@ -1,3 +1,4 @@
import "@material/mwc-top-app-bar-fixed";
import { html, css, LitElement } from "lit";
import { customElement } from "lit/decorators";
import "../../src/components/ha-icon-button";
+5 -10
View File
@@ -1,3 +1,4 @@
import "@material/mwc-top-app-bar-fixed";
import { mdiMenu, mdiSwapHorizontal } from "@mdi/js";
import type { PropertyValues } from "lit";
import { LitElement, css, html } from "lit";
@@ -10,7 +11,6 @@ import type { HaDrawer } from "../../src/components/ha-drawer";
import { HaExpansionPanel } from "../../src/components/ha-expansion-panel";
import "../../src/components/ha-icon-button";
import "../../src/components/ha-svg-icon";
import "../../src/components/ha-top-app-bar-fixed";
import "../../src/managers/notification-manager";
import { haStyle } from "../../src/resources/styles";
import { PAGES, SIDEBAR } from "../build/import-pages";
@@ -84,7 +84,7 @@ class HaGallery extends LitElement {
<div class="drawer-title">Home Assistant Design</div>
<div class="sidebar">${sidebar}</div>
<div slot="appContent" class="app-content">
<ha-top-app-bar-fixed>
<mwc-top-app-bar-fixed>
<ha-icon-button
slot="navigationIcon"
@click=${this._menuTapped}
@@ -94,7 +94,7 @@ class HaGallery extends LitElement {
<div slot="title">
${PAGES[this._page].metadata.title || this._page.split("/")[1]}
</div>
</ha-top-app-bar-fixed>
</mwc-top-app-bar-fixed>
<div class="content">
${PAGES[this._page].description
? html`
@@ -227,12 +227,11 @@ class HaGallery extends LitElement {
-webkit-user-select: initial;
-moz-user-select: initial;
--ha-sidebar-width: 256px;
--header-height: 64px;
}
.sidebar {
box-sizing: border-box;
max-height: calc(100vh - var(--header-height));
max-height: calc(100vh - 64px);
overflow-y: auto;
padding: 4px;
}
@@ -244,7 +243,7 @@ class HaGallery extends LitElement {
display: flex;
font-size: var(--ha-font-size-l);
font-weight: var(--ha-font-weight-medium);
min-height: var(--header-height);
min-height: 64px;
padding: 0 16px;
}
@@ -278,10 +277,6 @@ class HaGallery extends LitElement {
background: var(--primary-background-color);
}
ha-drawer[type="dismissible"][open] ha-top-app-bar-fixed {
--ha-top-app-bar-width: calc(100% - var(--ha-sidebar-width));
}
.content {
flex: 1;
}
+10 -21
View File
@@ -1,12 +1,10 @@
import { provide } from "@lit/context";
import type { PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, state } from "lit/decorators";
import { applyThemesOnElement } from "../../../../src/common/dom/apply_themes_on_element";
import "../../../../src/components/ha-card";
import type { TemplateResult, PropertyValues } from "lit";
import { html, css, LitElement } from "lit";
import { customElement } from "lit/decorators";
import "../../../../src/components/ha-tip";
import { internationalizationContext } from "../../../../src/data/context";
import type { HomeAssistantInternationalization } from "../../../../src/types";
import "../../../../src/components/ha-card";
import { applyThemesOnElement } from "../../../../src/common/dom/apply_themes_on_element";
import { provideHass } from "../../../../src/fake_data/provide_hass";
const tips: (string | TemplateResult)[] = [
"Test tip",
@@ -16,25 +14,16 @@ const tips: (string | TemplateResult)[] = [
@customElement("demo-components-ha-tip")
export class DemoHaTip extends LitElement {
@provide({ context: internationalizationContext })
@state()
protected _i18n: HomeAssistantInternationalization = {
localize: ((key: string) => key) as any,
language: "en",
selectedLanguage: null,
locale: {} as any,
translationMetadata: {} as any,
loadBackendTranslation: (async () => (key: string) => key) as any,
loadFragmentTranslation: (async () => (key: string) => key) as any,
};
protected render(): TemplateResult {
return html` ${["light", "dark"].map(
(mode) => html`
<div class=${mode}>
<ha-card header="ha-tip ${mode} demo">
<div class="card-content">
${tips.map((tip) => html`<ha-tip>${tip}</ha-tip>`)}
${tips.map(
(tip) =>
html`<ha-tip .hass=${provideHass(this)}>${tip}</ha-tip>`
)}
</div>
</ha-card>
</div>
+30 -27
View File
@@ -38,24 +38,24 @@
"@codemirror/search": "6.7.0",
"@codemirror/state": "6.6.0",
"@codemirror/view": "6.43.0",
"@date-fns/tz": "1.5.0",
"@date-fns/tz": "1.4.1",
"@egjs/hammerjs": "2.0.17",
"@formatjs/intl-datetimeformat": "7.4.6",
"@formatjs/intl-displaynames": "7.3.8",
"@formatjs/intl-durationformat": "0.10.12",
"@formatjs/intl-getcanonicallocales": "3.2.9",
"@formatjs/intl-listformat": "8.3.8",
"@formatjs/intl-locale": "5.3.8",
"@formatjs/intl-numberformat": "9.3.9",
"@formatjs/intl-pluralrules": "6.3.8",
"@formatjs/intl-relativetimeformat": "12.3.8",
"@formatjs/intl-datetimeformat": "7.4.5",
"@formatjs/intl-displaynames": "7.3.7",
"@formatjs/intl-durationformat": "0.10.11",
"@formatjs/intl-getcanonicallocales": "3.2.8",
"@formatjs/intl-listformat": "8.3.7",
"@formatjs/intl-locale": "5.3.7",
"@formatjs/intl-numberformat": "9.3.8",
"@formatjs/intl-pluralrules": "6.3.7",
"@formatjs/intl-relativetimeformat": "12.3.7",
"@fullcalendar/core": "6.1.20",
"@fullcalendar/daygrid": "6.1.20",
"@fullcalendar/interaction": "6.1.20",
"@fullcalendar/list": "6.1.20",
"@fullcalendar/luxon3": "6.1.20",
"@fullcalendar/timegrid": "6.1.20",
"@home-assistant/webawesome": "3.7.0-ha.0",
"@home-assistant/webawesome": "3.3.1-ha.3",
"@lezer/highlight": "1.2.3",
"@lit-labs/motion": "1.1.0",
"@lit-labs/observers": "2.1.0",
@@ -65,14 +65,17 @@
"@material/mwc-base": "0.27.0",
"@material/mwc-formfield": "patch:@material/mwc-formfield@npm%3A0.27.0#~/.yarn/patches/@material-mwc-formfield-npm-0.27.0-9528cb60f6.patch",
"@material/mwc-list": "patch:@material/mwc-list@npm%3A0.27.0#~/.yarn/patches/@material-mwc-list-npm-0.27.0-5344fc9de4.patch",
"@material/mwc-top-app-bar": "0.27.0",
"@material/mwc-top-app-bar-fixed": "0.27.0",
"@material/top-app-bar": "=14.0.0-canary.53b3cad2f.0",
"@material/web": "2.4.1",
"@mdi/js": "7.4.47",
"@mdi/svg": "7.4.47",
"@replit/codemirror-indentation-markers": "6.5.3",
"@swc/helpers": "0.5.21",
"@thomasloven/round-slider": "0.6.0",
"@tsparticles/engine": "4.0.5",
"@tsparticles/preset-links": "4.0.5",
"@tsparticles/engine": "4.0.2",
"@tsparticles/preset-links": "4.0.2",
"@vibrant/color": "4.0.4",
"@webcomponents/scoped-custom-element-registry": "0.0.10",
"@webcomponents/webcomponentsjs": "2.8.0",
@@ -83,19 +86,19 @@
"core-js": "3.49.0",
"cropperjs": "1.6.2",
"culori": "4.0.2",
"date-fns": "4.3.0",
"date-fns": "4.2.0",
"deep-clone-simple": "1.1.1",
"deep-freeze": "0.0.1",
"dialog-polyfill": "0.5.6",
"echarts": "6.1.0",
"echarts": "6.0.0",
"element-internals-polyfill": "3.0.2",
"fuse.js": "7.3.0",
"google-timezones-json": "1.2.0",
"gulp-zopfli-green": "7.0.0",
"hls.js": "1.6.16",
"home-assistant-js-websocket": "9.6.0",
"idb-keyval": "6.2.4",
"intl-messageformat": "11.2.7",
"idb-keyval": "6.2.2",
"intl-messageformat": "11.2.6",
"js-yaml": "4.1.1",
"leaflet": "1.9.4",
"leaflet-draw": "patch:leaflet-draw@npm%3A1.0.4#./.yarn/patches/leaflet-draw-npm-1.0.4-0ca0ebcf65.patch",
@@ -103,7 +106,7 @@
"lit": "3.3.3",
"lit-html": "3.3.3",
"luxon": "3.7.2",
"marked": "18.0.4",
"marked": "18.0.3",
"memoize-one": "6.0.0",
"node-vibrant": "4.0.4",
"object-hash": "3.0.0",
@@ -115,7 +118,7 @@
"sortablejs": "patch:sortablejs@npm%3A1.15.6#~/.yarn/patches/sortablejs-npm-1.15.6-3235a8f83b.patch",
"stacktrace-js": "2.0.2",
"superstruct": "2.0.2",
"tinykeys": "4.0.0",
"tinykeys": "3.0.0",
"weekstart": "2.0.0",
"workbox-cacheable-response": "7.4.1",
"workbox-core": "7.4.1",
@@ -132,13 +135,13 @@
"@babel/preset-env": "7.29.5",
"@bundle-stats/plugin-webpack-filter": "4.22.1",
"@eslint/js": "10.0.1",
"@html-eslint/eslint-plugin": "0.61.0",
"@html-eslint/eslint-plugin": "0.60.0",
"@lokalise/node-api": "16.0.0",
"@octokit/auth-oauth-device": "8.0.3",
"@octokit/plugin-retry": "8.1.0",
"@octokit/rest": "22.0.1",
"@rsdoctor/rspack-plugin": "1.5.11",
"@rspack/core": "2.0.4",
"@rspack/core": "2.0.3",
"@rspack/dev-server": "2.0.1",
"@types/babel__plugin-transform-runtime": "7.9.5",
"@types/chromecast-caf-receiver": "6.0.26",
@@ -157,7 +160,7 @@
"@types/sortablejs": "1.15.9",
"@types/tar": "7.0.87",
"@types/webspeechapi": "0.0.29",
"@vitest/coverage-v8": "4.1.7",
"@vitest/coverage-v8": "4.1.6",
"babel-loader": "10.1.1",
"babel-plugin-template-html-minifier": "4.1.0",
"browserslist-useragent-regexp": "4.1.4",
@@ -172,7 +175,7 @@
"eslint-plugin-wc": "3.1.0",
"fancy-log": "2.0.0",
"fs-extra": "11.3.5",
"generate-license-file": "4.2.1",
"generate-license-file": "4.1.1",
"glob": "13.0.6",
"globals": "17.6.0",
"gulp": "5.0.1",
@@ -198,9 +201,9 @@
"terser-webpack-plugin": "5.6.0",
"ts-lit-plugin": "2.0.2",
"typescript": "6.0.3",
"typescript-eslint": "8.59.4",
"typescript-eslint": "8.59.3",
"vite-tsconfig-paths": "6.1.1",
"vitest": "4.1.7",
"vitest": "4.1.6",
"webpack-stats-plugin": "1.1.3",
"webpackbar": "7.0.0",
"workbox-build": "patch:workbox-build@npm%3A7.4.1#~/.yarn/patches/workbox-build-npm-7.4.1-c84561662c.patch"
@@ -216,8 +219,8 @@
"@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",
"glob@^10.2.2": "^10.5.0"
},
"packageManager": "yarn@4.15.0",
"packageManager": "yarn@4.14.1",
"volta": {
"node": "24.16.0"
"node": "24.15.0"
}
}
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "home-assistant-frontend"
version = "20260527.0"
version = "20260429.0"
license = "Apache-2.0"
license-files = ["LICENSE*"]
description = "The Home Assistant frontend"
+1 -15
View File
@@ -1,20 +1,6 @@
import timezones from "google-timezones-json";
import { TimeZone } from "../../data/translation";
const RESOLVED_RAW = Intl.DateTimeFormat?.().resolvedOptions?.().timeZone;
// Some environments (e.g. Android emulator) return a UTC offset like "+00:00"
// instead of an IANA zone name. Only accept values that are known IANA zones,
// matching the list used by ha-timezone-picker.
const RESOLVED_TIME_ZONE =
RESOLVED_RAW &&
(RESOLVED_RAW === "UTC" ||
RESOLVED_RAW === "Etc/UTC" ||
RESOLVED_RAW in timezones)
? RESOLVED_RAW
: undefined;
export const HAS_RESOLVED_IANA_TIME_ZONE = RESOLVED_TIME_ZONE !== undefined;
const RESOLVED_TIME_ZONE = Intl.DateTimeFormat?.().resolvedOptions?.().timeZone;
// Browser time zone can be determined from Intl, with fallback to UTC for polyfill or no support.
export const LOCAL_TIME_ZONE = RESOLVED_TIME_ZONE ?? "UTC";
+2 -22
View File
@@ -1,16 +1,8 @@
import { consume } from "@lit/context";
import type { HassEntities, HassEntity } from "home-assistant-js-websocket";
import type {
HomeAssistant,
HomeAssistantInternationalization,
} from "../../types";
import {
entitiesContext,
internationalizationContext,
statesContext,
} from "../../data/context";
import type { HomeAssistant } from "../../types";
import { entitiesContext, statesContext } from "../../data/context";
import type { EntityRegistryDisplayEntry } from "../../data/entity/entity_registry";
import type { LocalizeFunc } from "../translations/localize";
import { transform } from "./transform";
interface ConsumeEntryConfig {
@@ -99,15 +91,3 @@ export const consumeEntityRegistryEntry = (config: ConsumeEntryConfig) =>
return typeof id === "string" ? entities?.[id] : undefined;
}
);
/**
* Consumes `internationalizationContext` and narrows it to the `localize`
* function. No host watching is needed — the decorated property updates
* whenever the i18n context changes.
*/
export const consumeLocalize = () =>
composeDecorator<HomeAssistantInternationalization, LocalizeFunc>(
internationalizationContext,
undefined,
({ localize }) => localize
);
@@ -1,6 +1,5 @@
import { LitElement, css, html, nothing } from "lit";
import { LitElement, css, html } from "lit";
import { customElement, property } from "lit/decorators";
import "../ha-tooltip";
export type LiveTestState = "pass" | "fail" | "invalid" | "unknown";
@@ -13,7 +12,6 @@ export type LiveTestState = "pass" | "fail" | "invalid" | "unknown";
*
* @attr {"pass"|"fail"|"invalid"|"unknown"} state - The current live-test state. Defaults to `unknown`.
* @attr {string} label - Accessible label announced by assistive technology.
* @attr {string} message - Optional tooltip body shown on hover/focus.
*/
@customElement("ha-automation-row-live-test")
export class HaAutomationRowLiveTest extends LitElement {
@@ -21,8 +19,6 @@ export class HaAutomationRowLiveTest extends LitElement {
@property() public label = "";
@property() public message?: string;
protected render() {
return html`
<div
@@ -31,9 +27,6 @@ export class HaAutomationRowLiveTest extends LitElement {
tabindex="0"
aria-label=${this.label}
></div>
${this.message
? html`<ha-tooltip for="indicator">${this.message}</ha-tooltip>`
: nothing}
`;
}
@@ -128,9 +128,7 @@ export class HaAutomationRow extends LitElement {
}
.row {
display: flex;
padding-left: var(--ha-space-3);
padding-inline-start: var(--ha-space-3);
padding-inline-end: initial;
padding: 0 0 0 var(--ha-space-3);
min-height: 48px;
align-items: flex-start;
cursor: pointer;
@@ -146,8 +144,6 @@ export class HaAutomationRow extends LitElement {
transition: transform 150ms cubic-bezier(0.4, 0, 0.2, 1);
color: var(--ha-color-on-neutral-quiet);
margin-left: calc(var(--ha-space-2) * -1);
margin-inline-start: calc(var(--ha-space-2) * -1);
margin-inline-end: initial;
}
:host([building-block]) .leading-icon-wrapper {
background-color: var(--ha-color-fill-neutral-loud-resting);
+18
View File
@@ -956,11 +956,29 @@ export class HaChartBase extends LitElement {
const xAxis = (this.options?.xAxis?.[0] ?? this.options?.xAxis) as
| XAXisOption
| undefined;
const yAxis = (this.options?.yAxis?.[0] ?? this.options?.yAxis) as
| YAXisOption
| undefined;
const series = ensureArray(this.data).map((s) => {
const data = this._hiddenDatasets.has(String(s.id ?? s.name))
? undefined
: s.data;
if (data && s.type === "line") {
if (yAxis?.type === "log") {
// set <=0 values to null so they render as gaps on a log graph
return {
...s,
data: (data as LineSeriesOption["data"])!.map((v) =>
Array.isArray(v)
? [
v[0],
typeof v[1] !== "number" || v[1] > 0 ? v[1] : null,
...v.slice(2),
]
: v
),
};
}
if (s.sampling === "minmax") {
const minX = xAxis?.min
? xAxis.min instanceof Date
@@ -1,14 +1,13 @@
import { mdiDragHorizontalVariant, mdiEye, mdiEyeOff } from "@mdi/js";
import type { CSSResultGroup } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, state } from "lit/decorators";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { repeat } from "lit/directives/repeat";
import memoizeOne from "memoize-one";
import { consumeLocalize } from "../../common/decorators/consume-context-entry";
import { fireEvent } from "../../common/dom/fire_event";
import type { LocalizeFunc } from "../../common/translations/localize";
import { haStyleDialog } from "../../resources/styles";
import type { HomeAssistant } from "../../types";
import "../ha-button";
import "../ha-dialog-footer";
import "../ha-icon-button";
@@ -25,9 +24,7 @@ import type { DataTableSettingsDialogParams } from "./show-dialog-data-table-set
@customElement("dialog-data-table-settings")
export class DialogDataTableSettings extends LitElement {
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
@property({ attribute: false }) public hass!: HomeAssistant;
@state() private _params?: DataTableSettingsDialogParams;
@@ -120,7 +117,7 @@ export class DialogDataTableSettings extends LitElement {
return nothing;
}
const localize = this._params.localizeFunc || this._localize;
const localize = this._params.localizeFunc || this.hass.localize;
const columns = this._sortedColumns(
this._params.columns,
@@ -175,7 +172,7 @@ export class DialogDataTableSettings extends LitElement {
.hidden=${!isVisible}
.path=${isVisible ? mdiEye : mdiEyeOff}
slot="meta"
.label=${localize(
.label=${this.hass!.localize(
`ui.components.data-table.settings.${isVisible ? "hide" : "show"}`,
{ title: typeof col.title === "string" ? col.title : "" }
)}
+134 -98
View File
@@ -1,9 +1,9 @@
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
import { mdiPlus, mdiShape } from "@mdi/js";
import { html, LitElement, nothing, type PropertyValues } from "lit";
import { css, html, LitElement, nothing, type PropertyValues } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../common/dom/fire_event";
import { fireEvent, type HASSDomEvent } from "../../common/dom/fire_event";
import { computeEntityPickerDisplay } from "../../common/entity/compute_entity_name_display";
import { isValidEntityId } from "../../common/entity/valid_entity_id";
import type { HaEntityPickerEntityFilterFunc } from "../../data/entity/entity";
@@ -20,17 +20,20 @@ import {
} from "../../panels/config/helpers/const";
import { showHelperDetailDialog } from "../../panels/config/helpers/show-dialog-helper-detail";
import type { HomeAssistant } from "../../types";
import "../ha-button";
import "../ha-combo-box-item";
import "../ha-generic-picker";
import "../ha-icon";
import type { HaGenericPicker } from "../ha-generic-picker";
import type { PickerComboBoxSearchFn } from "../ha-picker-combo-box";
import "../ha-picker-field";
import type { PickerValueRenderer } from "../ha-picker-field";
import "../ha-picker-popover";
import "../ha-picker-search-list";
import type {
HaPickerSearchList,
PickerSearchFn,
} from "../ha-picker-search-list";
import "../ha-svg-icon";
import "./state-badge";
const CREATE_ID = "___create-new-entity___";
@customElement("ha-entity-picker")
export class HaEntityPicker extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -122,15 +125,17 @@ export class HaEntityPicker extends LitElement {
@property({ attribute: "hide-clear-icon", type: Boolean })
public hideClearIcon = false;
@property({ attribute: "add-button", type: Boolean })
public addButton = false;
@query(".trigger") private _trigger?: HTMLElement;
@property({ attribute: "add-button-label" }) public addButtonLabel?: string;
@query("ha-picker-search-list") private _searchList?: HaPickerSearchList;
@query("ha-generic-picker") private _picker?: HaGenericPicker;
@state() private _pickerOpen = false;
@state() private _pendingEntityId?: string;
// Commit fires on @closed (after the hide animation) to avoid flicker.
private _pendingValue?: string;
protected willUpdate(changedProperties: PropertyValues<this>) {
if (
this._pendingEntityId &&
@@ -145,7 +150,7 @@ export class HaEntityPicker extends LitElement {
protected firstUpdated(changedProperties: PropertyValues<this>): void {
super.firstUpdated(changedProperties);
// Load title translations so it is available when the combo-box opens
// Preload title translations so they're ready when the dropdown opens.
this.hass.loadBackendTranslation("title");
}
@@ -275,40 +280,48 @@ export class HaEntityPicker extends LitElement {
`;
};
private _getAdditionalItems = () =>
this._getCreateItems(this.hass.localize, this.createDomains);
private _getCreateItems = memoizeOne(
private _getCreateActions = memoizeOne(
(
localize: this["hass"]["localize"],
createDomains: this["createDomains"]
) => {
): EntityComboBoxItem[] => {
if (!createDomains?.length) {
return [];
}
this.hass.loadFragmentTranslation("config");
return createDomains.map((domain) => {
const primary = localize(
"ui.components.entity.entity-picker.create_helper",
{
domain: isHelperDomain(domain)
? localize(
`ui.panel.config.helpers.types.${domain as HelperDomain}`
) || domain
: domainToName(localize, domain),
}
);
return {
id: CREATE_ID + domain,
primary: primary,
secondary: localize("ui.components.entity.entity-picker.new_entity"),
icon_path: mdiPlus,
} satisfies EntityComboBoxItem;
});
return createDomains.map((domain) => ({
id: `__create-helper__${domain}`,
primary: localize("ui.components.entity.entity-picker.create_helper", {
domain: isHelperDomain(domain)
? localize(
`ui.panel.config.helpers.types.${domain as HelperDomain}`
) || domain
: domainToName(localize, domain),
}),
secondary: localize("ui.components.entity.entity-picker.new_entity"),
icon_path: mdiPlus,
onSelect: ({ close }) => {
close();
this._openCreateHelper(domain);
},
}));
}
);
private _openCreateHelper(domain: string) {
showHelperDetailDialog(this, {
domain,
dialogClosedCallback: (item) => {
if (!item.entityId) return;
if (this.hass.states[item.entityId]) {
this._setValue(item.entityId);
} else {
this._pendingEntityId = item.entityId;
}
},
});
}
private _getEntitiesMemoized = memoizeOne(getEntities);
private _getItems = () => {
@@ -341,53 +354,67 @@ export class HaEntityPicker extends LitElement {
const placeholder =
this.placeholder ??
this.hass.localize("ui.components.entity.entity-picker.placeholder");
const items = this._getItems();
const actions = this._getCreateActions(
this.hass.localize,
this.createDomains
);
const hideClearIcon = this.hideClearIcon || this._shouldHideClearIcon();
return html`
<ha-generic-picker
.hass=${this.hass}
.disabled=${this.disabled}
.autofocus=${this.autofocus}
.allowCustomValue=${this.allowCustomEntity}
.required=${this.required}
.label=${this.label}
.placeholder=${placeholder}
.helper=${this.helper}
.value=${this.addButton ? undefined : this.value}
.searchLabel=${this.searchLabel}
.notFoundLabel=${this._notFoundLabel}
.rowRenderer=${this._rowRenderer}
.getItems=${this._getItems}
.getAdditionalItems=${this._getAdditionalItems}
.hideClearIcon=${this.hideClearIcon || this._shouldHideClearIcon()}
.searchFn=${this._searchFn}
.valueRenderer=${this._valueRenderer}
.searchKeys=${entityComboBoxKeys}
use-top-label
.addButtonLabel=${this.addButton
? (this.addButtonLabel ??
this.hass.localize("ui.components.entity.entity-picker.add"))
: undefined}
.unknownItemText=${this.hass.localize(
"ui.components.entity.entity-picker.unknown"
)}
@value-changed=${this._valueChanged}
>
</ha-generic-picker>
<div class="picker">
<div class="trigger" @click=${this._openPicker}>
<slot name="trigger">
<ha-picker-field
type="button"
compact
.label=${this.label}
.placeholder=${placeholder}
.value=${this.value}
.valueRenderer=${this._valueRenderer}
.required=${this.required}
.disabled=${this.disabled}
.helper=${this.helper}
.hideClearIcon=${hideClearIcon}
?autofocus=${this.autofocus}
@clear=${this._clear}
></ha-picker-field>
</slot>
</div>
<ha-picker-popover
.open=${this._pickerOpen}
.anchor=${this._trigger ?? null}
.label=${this.label ?? ""}
@closed=${this._handlePickerClosed}
>
<ha-picker-search-list
autofocus
.items=${items}
.value=${this.value}
.searchKeys=${entityComboBoxKeys}
.searchFn=${this._searchFn}
.rowRenderer=${this._rowRenderer}
.actions=${actions}
.searchPlaceholder=${this.searchLabel ??
this.hass.localize("ui.common.search")}
.notFoundLabel=${this._notFoundLabel}
@item-selected=${this._handleItemSelected}
></ha-picker-search-list>
</ha-picker-popover>
</div>
`;
}
private _searchFn: PickerComboBoxSearchFn<EntityComboBoxItem> = (
private _searchFn: PickerSearchFn<EntityComboBoxItem> = (
search,
filteredItems
) => {
// If there is exact match for entity id, put it first
const index = filteredItems.findIndex(
(item) => item.stateObj?.entity_id === search
);
if (index === -1) {
return filteredItems;
}
const [exactMatch] = filteredItems.splice(index, 1);
filteredItems.unshift(exactMatch);
return filteredItems;
@@ -395,46 +422,43 @@ export class HaEntityPicker extends LitElement {
public async open() {
await this.updateComplete;
await this._picker?.open();
this._openPicker();
}
private _valueChanged(ev) {
private _openPicker = () => {
if (this.disabled) return;
this._pickerOpen = true;
};
private _handlePickerClosed = () => {
if (this._pendingValue !== undefined) {
const pending = this._pendingValue;
this._pendingValue = undefined;
this._setValue(pending);
}
this._pickerOpen = false;
this._searchList?.reset();
};
private _handleItemSelected = (
ev: HASSDomEvent<{ id: string; index: number; newTab?: boolean }>
) => {
ev.stopPropagation();
const value = ev.detail.value;
if (!value) {
this._setValue(undefined);
return;
}
if (value.startsWith(CREATE_ID)) {
const domain = value.substring(CREATE_ID.length);
showHelperDetailDialog(this, {
domain,
dialogClosedCallback: (item) => {
if (item.entityId) {
if (this.hass.states[item.entityId]) {
this._setValue(item.entityId);
} else {
this._pendingEntityId = item.entityId;
}
}
},
});
return;
}
const value = ev.detail.id;
if (!isValidEntityId(value) && !this._findExtraOption(value)) {
this._pickerOpen = false;
return;
}
this._pendingValue = value;
this._pickerOpen = false;
};
this._setValue(value);
private _clear() {
this._setValue(undefined);
}
private _setValue(value: string | undefined) {
this.value = value;
fireEvent(this, "value-changed", { value });
fireEvent(this, "change");
}
@@ -443,6 +467,18 @@ export class HaEntityPicker extends LitElement {
this.hass.localize("ui.components.entity.entity-picker.no_match", {
term: html`<b>${search}</b>`,
});
static styles = css`
:host {
display: block;
}
.picker {
position: relative;
}
ha-picker-field {
width: 100%;
}
`;
}
declare global {
+3 -6
View File
@@ -6,9 +6,8 @@ import {
mdiInformationOutline,
} from "@mdi/js";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { customElement, property } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import type { LocalizeFunc } from "../common/translations/localize";
import { fireEvent } from "../common/dom/fire_event";
import "./ha-icon-button";
@@ -40,9 +39,7 @@ class HaAlert extends LitElement {
@property({ type: Boolean }) public dismissable = false;
@state()
@consumeLocalize()
private _localize?: LocalizeFunc;
@property({ attribute: false }) public localize?: LocalizeFunc;
@property({ type: Boolean }) public narrow = false;
@@ -71,7 +68,7 @@ class HaAlert extends LitElement {
${this.dismissable
? html`<ha-icon-button
@click=${this._dismissClicked}
.label=${this._localize?.("ui.common.dismiss_alert")}
.label=${this.localize!("ui.common.dismiss_alert")}
.path=${mdiClose}
></ha-icon-button>`
: nothing}
@@ -61,6 +61,7 @@ export class HaAreasDisplayEditor extends LitElement {
>
<ha-svg-icon slot="leading-icon" .path=${mdiTextureBox}></ha-svg-icon>
<ha-items-display-editor
.hass=${this.hass}
.items=${items}
.value=${value}
@value-changed=${this._areaDisplayChanged}
@@ -107,6 +107,7 @@ export class HaAreasFloorsDisplayEditor extends LitElement {
></ha-svg-icon>
`}
<ha-items-display-editor
.hass=${this.hass}
.items=${groupedAreasItems[floor.floor_id]}
.value=${value}
.floorId=${floor.floor_id}
-1
View File
@@ -294,7 +294,6 @@ export class HaDrawer extends LitElement {
border-inline-end: 1px solid var(--divider-color, rgba(0, 0, 0, 0.12));
box-sizing: border-box;
transition: width var(--ha-animation-duration-normal) ease;
z-index: 6;
}
.app-content {
@@ -54,6 +54,7 @@ export class HaEntitiesDisplayEditor extends LitElement {
return html`
<ha-items-display-editor
.hass=${this.hass}
.items=${items}
.value=${value}
@value-changed=${this._itemDisplayChanged}
-3
View File
@@ -109,8 +109,6 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
@property({ attribute: "custom-value-label" })
public customValueLabel?: string;
@property({ type: Boolean, attribute: "no-sort" }) public noSort = false;
@query(".container") private _containerElement?: HTMLDivElement;
@query("ha-picker-combo-box") private _comboBox?: HaPickerComboBox;
@@ -273,7 +271,6 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
.selectedSection=${this.selectedSection}
.searchKeys=${this.searchKeys}
.customValueLabel=${this.customValueLabel}
.noSort=${this.noSort}
></ha-picker-combo-box>
`;
}
+41 -59
View File
@@ -1,77 +1,59 @@
import { css, html, LitElement } from "lit";
// @ts-ignore
import topAppBarStyles from "@material/top-app-bar/dist/mdc.top-app-bar.min.css";
import { css, html, LitElement, unsafeCSS } from "lit";
import { customElement } from "lit/decorators";
@customElement("ha-header-bar")
export class HaHeaderBar extends LitElement {
protected render() {
return html`<header class="header-bar">
<div class="row">
<section class="section" id="navigation">
return html`<header class="mdc-top-app-bar">
<div class="mdc-top-app-bar__row">
<section
class="mdc-top-app-bar__section mdc-top-app-bar__section--align-start"
id="navigation"
>
<slot name="navigationIcon"></slot>
<span class="title">
<span class="mdc-top-app-bar__title">
<slot name="title"></slot>
</span>
</section>
<section class="section end" id="actions" role="toolbar">
<section
class="mdc-top-app-bar__section mdc-top-app-bar__section--align-end"
id="actions"
role="toolbar"
>
<slot name="actionItems"></slot>
</section>
</div>
</header>`;
}
static override styles = css`
:host {
display: block;
}
.header-bar {
box-sizing: border-box;
color: var(--app-header-text-color, var(--primary-text-color));
background-color: var(
--app-header-background-color,
var(--primary-background-color)
);
padding: var(--header-bar-padding);
}
.row {
display: flex;
align-items: center;
box-sizing: border-box;
width: 100%;
height: var(--header-height);
}
.section {
display: flex;
align-items: center;
box-sizing: border-box;
min-width: 0;
height: 100%;
padding: 0 var(--ha-space-3);
}
#navigation {
flex: 1 1 auto;
}
.section.end {
flex: none;
justify-content: flex-end;
}
.title {
display: block;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: var(--ha-font-size-xl);
font-weight: var(--ha-font-weight-normal);
line-height: var(--header-height);
padding-inline-start: var(--ha-space-6);
}
`;
static get styles() {
return [
unsafeCSS(topAppBarStyles),
css`
.mdc-top-app-bar__row {
height: var(--header-height);
}
.mdc-top-app-bar {
position: static;
color: var(--mdc-theme-on-primary, #fff);
padding: var(--header-bar-padding);
}
.mdc-top-app-bar__section.mdc-top-app-bar__section--align-start {
flex: 1;
}
.mdc-top-app-bar__section.mdc-top-app-bar__section--align-end {
flex: none;
}
.mdc-top-app-bar__title {
font-size: var(--ha-font-size-xl);
padding-inline-start: 24px;
padding-inline-end: initial;
}
`,
];
}
}
declare global {
+4 -7
View File
@@ -2,11 +2,10 @@ import "@home-assistant/webawesome/dist/components/divider/divider";
import { mdiDotsVertical } from "@mdi/js";
import type { TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import { customElement, property } from "lit/decorators";
import { stopPropagation } from "../common/dom/stop_propagation";
import type { LocalizeFunc } from "../common/translations/localize";
import { haStyle } from "../resources/styles";
import type { HomeAssistant } from "../types";
import "./ha-dropdown";
import "./ha-dropdown-item";
import "./ha-icon-button";
@@ -27,9 +26,7 @@ export interface IconOverflowMenuItem {
@customElement("ha-icon-overflow-menu")
export class HaIconOverflowMenu extends LitElement {
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Array }) public items: IconOverflowMenuItem[] = [];
@@ -47,7 +44,7 @@ export class HaIconOverflowMenu extends LitElement {
@click=${stopPropagation}
>
<ha-icon-button
.label=${this._localize("ui.common.overflow_menu")}
.label=${this.hass.localize("ui.common.overflow_menu")}
.path=${mdiDotsVertical}
slot="trigger"
></ha-icon-button>
+1 -1
View File
@@ -14,7 +14,7 @@ class InputHelperText extends LitElement {
:host {
display: block;
color: var(--mdc-text-field-label-ink-color, rgba(0, 0, 0, 0.6));
font-size: var(--ha-font-size-s);
font-size: 0.75rem;
padding-left: 16px;
padding-right: 16px;
padding-inline-start: 16px;
+3 -6
View File
@@ -8,11 +8,10 @@ import { ifDefined } from "lit/directives/if-defined";
import { repeat } from "lit/directives/repeat";
import { until } from "lit/directives/until";
import memoizeOne from "memoize-one";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import { fireEvent } from "../common/dom/fire_event";
import { stopPropagation } from "../common/dom/stop_propagation";
import { orderCompare } from "../common/string/compare";
import type { LocalizeFunc } from "../common/translations/localize";
import type { HomeAssistant } from "../types";
import "./ha-icon";
import "./ha-icon-button";
import "./ha-icon-next";
@@ -47,9 +46,7 @@ declare global {
@customElement("ha-items-display-editor")
export class HaItemDisplayEditor extends LitElement {
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public items: DisplayItem[] = [];
@@ -164,7 +161,7 @@ export class HaItemDisplayEditor extends LitElement {
? html`<ha-icon-button
.path=${isVisible ? mdiEye : mdiEyeOff}
slot="end"
.label=${this._localize(
.label=${this.hass.localize(
`ui.components.items-display-editor.${isVisible ? "hide" : "show"}`,
{
label: label,
+1 -3
View File
@@ -167,8 +167,6 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
@property({ type: Boolean, reflect: true }) public clearable = false;
@property({ type: Boolean, attribute: "no-sort" }) public noSort = false;
@query("lit-virtualizer") public virtualizerElement?: LitVirtualizer;
@query("ha-input-search") private _searchFieldElement?: HaInputSearch;
@@ -344,7 +342,7 @@ export class HaPickerComboBox extends ScrollableFadeMixin(LitElement) {
private _getItems = () => {
let items = [...(this.getItems(this._search, this._selectedSection) || [])];
if (!this.sections?.length && !this.noSort) {
if (!this.sections?.length) {
items = items.sort((entityA, entityB) => {
const sortLabelA =
typeof entityA === "string" ? entityA : entityA.sorting_label;
+394
View File
@@ -0,0 +1,394 @@
import type { LitVirtualizer } from "@lit-labs/virtualizer";
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
import { mdiMagnify, mdiMinusBoxOutline } from "@mdi/js";
import {
css,
html,
LitElement,
type CSSResultGroup,
type TemplateResult,
} from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { tinykeys } from "tinykeys";
import {
fireEvent,
type HASSDomCurrentTargetEvent,
} from "../common/dom/fire_event";
import { loadVirtualizer } from "../resources/virtualizer";
import "./ha-combo-box-item";
import {
DEFAULT_ROW_RENDERER_CONTENT,
type PickerComboBoxItem,
} from "./ha-picker-combo-box";
import "./ha-svg-icon";
const EMPTY_ROW_ID = "___empty___";
export interface PickerActionContext {
host: HTMLElement;
close: () => void;
}
/** Items with `onSelect` are action rows: the callback fires instead of `item-selected`. */
export interface PickerListItem extends PickerComboBoxItem {
onSelect?: (ctx: PickerActionContext) => void | Promise<void>;
}
export type PickerListEntry = PickerListItem | string;
interface PickerListRowElement extends HTMLDivElement {
index: number;
item: PickerListItem;
}
const DEFAULT_ROW: RenderItemFunction<PickerListItem> = (item) =>
html`<ha-combo-box-item type="button" compact>
${DEFAULT_ROW_RENDERER_CONTENT(item)}
</ha-combo-box-item>`;
/**
* Headless virtualized list for picker UIs. Receives pre-filtered `items`,
* renders rows via `rowRenderer`. String entries are section titles.
*/
@customElement("ha-picker-list")
export class HaPickerList extends LitElement {
@property({ attribute: false }) public items: PickerListEntry[] = [];
@property() public value?: string;
@property({ attribute: false })
public rowRenderer?: RenderItemFunction<PickerListItem>;
@property({ attribute: "empty-label" }) public emptyLabel?: string;
@property({ attribute: false })
public notFoundLabel?:
| string
| TemplateResult
| ((search: string) => string | TemplateResult);
/** Current search string. Picks between empty/notFound placeholders; filtering is the consumer's job. */
@property({ attribute: "current-search" }) public currentSearch = "";
@state() private _highlightedIndex = -1;
@state() private _valuePinned = true;
@query("lit-virtualizer") private _virtualizer?: LitVirtualizer;
private _unsubscribeKeys?: () => void;
public willUpdate() {
if (!this.hasUpdated) {
loadVirtualizer();
}
}
protected firstUpdated() {
this._registerKeys();
}
public disconnectedCallback() {
super.disconnectedCallback();
this._unsubscribeKeys?.();
}
public selectNext = (ev?: KeyboardEvent) => this._next(ev);
public selectPrev = (ev?: KeyboardEvent) => this._prev(ev);
public selectFirst = (ev?: KeyboardEvent) => this._first(ev);
public selectLast = (ev?: KeyboardEvent) => this._last(ev);
public commitHighlighted = (newTab = false) =>
this._commitAt(this._highlightedIndex, newTab);
protected render() {
const items = this.items.length
? this.items
: [{ id: EMPTY_ROW_ID, primary: "" } as PickerListItem];
return html`
<lit-virtualizer
.keyFunction=${this._keyFunction}
tabindex="0"
scroller
.items=${items}
.renderItem=${this._renderEntry}
.layout=${this.value && this._valuePinned
? {
pin: {
index: this._initialPinIndex(),
block: "center",
},
}
: undefined}
@unpinned=${this._handleUnpinned}
@focus=${this._focusList}
@blur=${this._resetHighlight}
></lit-virtualizer>
`;
}
private _keyFunction = (item: PickerListEntry) =>
typeof item === "string" ? item : item.id;
private _renderEntry: RenderItemFunction<PickerListEntry> = (item, index) => {
if (typeof item === "string") {
return html`<div class="title">${item}</div>`;
}
if (item.id === EMPTY_ROW_ID) {
return this._renderEmptyRow();
}
const renderer = this.rowRenderer ?? DEFAULT_ROW;
return html`<div
id=${`list-item-${index}`}
class="row ${this.value === item.id ? "current-value" : ""}"
.item=${item}
.index=${index}
@click=${this._handleClick}
>
${renderer(item as PickerListItem, index)}
</div>`;
};
private _renderEmptyRow() {
const search = this.currentSearch;
const message = search
? typeof this.notFoundLabel === "function"
? this.notFoundLabel(search)
: (this.notFoundLabel ?? "No matching items found")
: (this.emptyLabel ?? "No items available");
return html`
<div class="row empty">
<ha-combo-box-item type="text" compact>
<ha-svg-icon
slot="start"
.path=${search ? mdiMagnify : mdiMinusBoxOutline}
></ha-svg-icon>
<span slot="headline">${message}</span>
</ha-combo-box-item>
</div>
`;
}
private _handleClick = (
ev: MouseEvent & HASSDomCurrentTargetEvent<PickerListRowElement>
) => {
ev.stopPropagation();
const row = ev.currentTarget;
if (row.item.disabled) return;
this._dispatchSelection(row.item, row.index, ev.ctrlKey || ev.metaKey);
};
private _dispatchSelection(
item: PickerListItem,
index: number,
newTab: boolean
) {
if (item.onSelect) {
void item.onSelect({
host: this,
close: () => fireEvent(this, "picker-close-request"),
});
return;
}
fireEvent(this, "item-selected", { id: item.id, index, newTab });
}
private _handleUnpinned = () => {
this._valuePinned = false;
};
private _registerKeys() {
this._unsubscribeKeys = tinykeys(this, {
ArrowDown: this._next,
ArrowUp: this._prev,
Home: this._first,
End: this._last,
Enter: this._commitHighlight,
"$mod+Enter": this._commitHighlightNewTab,
});
}
private _focusList = () => {
if (this._highlightedIndex === -1) this._initializeHighlight();
};
private _resetHighlight = () => {
this._virtualizer?.querySelector(".selected")?.classList.remove("selected");
this._highlightedIndex = -1;
};
private _initializeHighlight() {
if (!this._virtualizer) return;
const items = this._virtualizer.items as PickerListEntry[];
if (this.value) {
const i = items.findIndex(
(item) => typeof item !== "string" && item.id === this.value
);
if (i !== -1) {
this._highlightedIndex = i;
this._scrollToHighlight();
return;
}
}
this._first();
}
private _initialPinIndex(): number {
if (!this.value) return 0;
const i = this.items.findIndex(
(item) => typeof item !== "string" && item.id === this.value
);
return i === -1 ? 0 : i;
}
private _isPickable(item: PickerListEntry | undefined): boolean {
return !!item && typeof item !== "string" && item.id !== EMPTY_ROW_ID;
}
private _step(direction: 1 | -1) {
if (!this._virtualizer) return;
const items = this._virtualizer.items as PickerListEntry[];
if (!items.length) return;
let i = this._highlightedIndex + direction;
const guard = items.length;
let n = 0;
while (n++ < guard && i >= 0 && i < items.length) {
if (this._isPickable(items[i])) {
this._highlightedIndex = i;
this._scrollToHighlight();
return;
}
i += direction;
}
}
private _next = (ev?: KeyboardEvent) => {
ev?.preventDefault();
if (this._highlightedIndex === -1) {
this._initializeHighlight();
return;
}
this._step(1);
};
private _prev = (ev?: KeyboardEvent) => {
ev?.preventDefault();
if (this._highlightedIndex === -1) {
this._initializeHighlight();
return;
}
this._step(-1);
};
private _first = (ev?: KeyboardEvent) => {
ev?.preventDefault();
this._jumpTo(0, 1);
};
private _last = (ev?: KeyboardEvent) => {
ev?.preventDefault();
if (!this._virtualizer) return;
this._jumpTo(this._virtualizer.items.length - 1, -1);
};
private _jumpTo(start: number, direction: 1 | -1) {
if (!this._virtualizer) return;
const items = this._virtualizer.items as PickerListEntry[];
for (let i = start; i >= 0 && i < items.length; i += direction) {
if (this._isPickable(items[i])) {
this._highlightedIndex = i;
this._scrollToHighlight();
return;
}
}
}
private _commitHighlight = (ev: KeyboardEvent) => {
this._commitAt(this._highlightedIndex, ev.ctrlKey || ev.metaKey);
};
private _commitHighlightNewTab = () => {
this._commitAt(this._highlightedIndex, true);
};
private _commitAt(index: number, newTab: boolean) {
if (index === -1 || !this._virtualizer) return;
const item = this._virtualizer.items[index] as PickerListEntry;
if (typeof item === "string" || item.disabled) return;
this._dispatchSelection(item, index, newTab);
}
private _scrollToHighlight() {
this._virtualizer?.querySelector(".selected")?.classList.remove("selected");
this._virtualizer
?.element(this._highlightedIndex)
?.scrollIntoView({ block: "nearest" });
requestAnimationFrame(() => {
this._virtualizer
?.querySelector(`#list-item-${this._highlightedIndex}`)
?.classList.add("selected");
});
}
static styles: CSSResultGroup = css`
:host {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
}
lit-virtualizer {
flex: 1;
outline: none;
}
.row {
display: flex;
width: 100%;
align-items: center;
box-sizing: border-box;
min-height: 36px;
cursor: pointer;
}
.row.empty {
cursor: default;
}
.row ha-combo-box-item {
width: 100%;
}
.row.current-value {
background-color: var(--ha-color-fill-primary-quiet-resting);
}
.row.selected {
background-color: var(--ha-color-fill-neutral-quiet-hover);
}
@media (prefers-color-scheme: dark) {
.row.selected {
background-color: var(--ha-color-fill-neutral-normal-hover);
}
}
.title {
box-sizing: border-box;
width: 100%;
background-color: var(--ha-color-fill-neutral-quiet-resting);
padding: var(--ha-space-1) var(--ha-space-4);
font-weight: var(--ha-font-weight-bold);
color: var(--secondary-text-color);
min-height: var(--ha-space-6);
display: flex;
align-items: center;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-picker-list": HaPickerList;
}
interface HASSDomEvents {
"item-selected": { id: string; index: number; newTab?: boolean };
"picker-close-request": undefined;
}
}
+257
View File
@@ -0,0 +1,257 @@
import "@home-assistant/webawesome/dist/components/popover/popover";
import {
css,
html,
LitElement,
nothing,
type CSSResultGroup,
type PropertyValues,
} from "lit";
import { customElement, property, state } from "lit/decorators";
import { styleMap } from "lit/directives/style-map";
import { fireEvent } from "../common/dom/fire_event";
import "./ha-bottom-sheet";
/**
* Responsive popover for picker UIs: anchored `wa-popover` on desktop,
* `ha-bottom-sheet` on narrow viewports. Anchor drives the width.
*/
@customElement("ha-picker-popover")
export class HaPickerPopover extends LitElement {
@property({ type: Boolean, reflect: true }) public open = false;
@property({ attribute: false }) public anchor?: HTMLElement | null;
@property() public label?: string;
@property() public placement:
| "bottom"
| "top"
| "left"
| "right"
| "top-start"
| "top-end"
| "right-start"
| "right-end"
| "bottom-start"
| "bottom-end"
| "left-start"
| "left-end" = "bottom-start";
@state() private _bodyWidth = 0;
@state() private _narrow = false;
@state() private _openedNarrow = false;
// Kept true across the hide animation so wa-popover can finish its
// transition; cleared on wa-after-hide.
@state() private _mounted = false;
// Flipped one rAF after mount so wa-popover sees a false→true edge
// and runs the show flow.
@state() private _showing = false;
// Defers slot projection until after the show animation so a
// virtualized list inside isn't measured at the scaled size.
@state() private _contentReady = false;
private _openFrame?: number;
protected willUpdate(changedProperties: PropertyValues<this>) {
if (changedProperties.has("open")) {
if (this.open) {
this._measureAnchor();
this._openedNarrow = this._narrow;
this._mounted = true;
} else {
this._showing = false;
}
}
if (changedProperties.has("anchor") && this.open) {
this._measureAnchor();
}
}
private _measureAnchor() {
if (this.anchor) {
this._bodyWidth = this.anchor.offsetWidth;
}
}
protected updated() {
if (this.open && this._mounted && !this._showing) {
this._scheduleShow();
}
}
private _scheduleShow() {
if (this._openFrame !== undefined) return;
this._openFrame = requestAnimationFrame(() => {
this._openFrame = undefined;
if (this.open && this._mounted) {
this._showing = true;
}
});
}
private _cancelShow() {
if (this._openFrame === undefined) return;
cancelAnimationFrame(this._openFrame);
this._openFrame = undefined;
}
connectedCallback() {
super.connectedCallback();
this._handleResize();
window.addEventListener("resize", this._handleResize);
this.addEventListener("picker-close-request", this._handleCloseRequest);
}
disconnectedCallback() {
super.disconnectedCallback();
window.removeEventListener("resize", this._handleResize);
this.removeEventListener("picker-close-request", this._handleCloseRequest);
this._cancelShow();
}
private _handleCloseRequest = (ev: Event) => {
ev.stopPropagation();
this._showing = false;
};
private _handleResize = () => {
this._narrow =
window.matchMedia("(max-width: 870px)").matches ||
window.matchMedia("(max-height: 500px)").matches;
if (!this._openedNarrow && this.open) {
this._measureAnchor();
}
};
private _handleShown = () => {
this._contentReady = true;
fireEvent(this, "opened");
// Native [autofocus] fires before the popover is visible; refocus
// a slotted search component after projection.
requestAnimationFrame(() => {
const focusable = this.querySelector(
"ha-picker-search-list, ha-picker-search"
) as (HTMLElement & { focus?: () => void }) | null;
focusable?.focus?.();
});
};
private _handleHidden = (ev: Event) => {
ev.stopPropagation();
this._mounted = false;
this._showing = false;
this._contentReady = false;
fireEvent(this, "closed");
};
protected render() {
if (!this._mounted) return nothing;
if (this._openedNarrow) {
return html`
<ha-bottom-sheet
flexcontent
.open=${this._showing}
@wa-after-show=${this._handleShown}
@closed=${this._handleHidden}
role="dialog"
aria-modal="true"
aria-label=${this.label ?? ""}
>
<div class="content">
${this._contentReady ? html`<slot></slot>` : nothing}
</div>
</ha-bottom-sheet>
`;
}
return html`
<wa-popover
.open=${this._showing}
style=${styleMap({ "--body-width": `${this._bodyWidth}px` })}
without-arrow
distance="-4"
.placement=${this.placement}
.anchor=${this.anchor ?? null}
auto-size="vertical"
auto-size-padding="16"
@wa-after-show=${this._handleShown}
@wa-after-hide=${this._handleHidden}
trap-focus
role="dialog"
aria-modal="true"
aria-label=${this.label ?? ""}
>
<div class="content">
${this._contentReady ? html`<slot></slot>` : nothing}
</div>
</wa-popover>
`;
}
static styles: CSSResultGroup = css`
:host {
display: contents;
}
wa-popover {
--wa-space-l: 0;
/* Disable wa-popover's built-in 25rem cap. */
--max-width: none;
}
wa-popover::part(dialog)::backdrop {
background: none;
}
wa-popover::part(body) {
width: var(--ha-picker-popover-width, max(var(--body-width), 250px));
max-width: var(
--ha-picker-popover-max-width,
var(--ha-picker-popover-width, max(var(--body-width), 250px))
);
max-height: 500px;
height: 70vh;
overflow: hidden;
padding: 0;
}
@media (max-height: 1000px) {
wa-popover::part(body) {
max-height: 400px;
}
}
.content {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
ha-bottom-sheet {
--ha-bottom-sheet-height: 90vh;
--ha-bottom-sheet-height: calc(100dvh - var(--ha-space-12));
--ha-bottom-sheet-max-height: var(--ha-bottom-sheet-height);
--ha-bottom-sheet-max-width: 600px;
--ha-bottom-sheet-padding: 0;
--ha-bottom-sheet-surface-background: var(--card-background-color);
--ha-bottom-sheet-border-radius: var(--ha-border-radius-2xl);
--ha-bottom-sheet-content-padding: 0 var(--safe-area-inset-right)
var(--safe-area-inset-bottom) var(--safe-area-inset-left);
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-picker-popover": HaPickerPopover;
}
}
+188
View File
@@ -0,0 +1,188 @@
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
import Fuse from "fuse.js";
import {
css,
html,
LitElement,
type CSSResultGroup,
type TemplateResult,
} from "lit";
import { customElement, property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import type { HASSDomEvent } from "../common/dom/fire_event";
import {
multiTermSortedSearch,
type FuseWeightedKey,
} from "../resources/fuseMultiTerm";
import { DEFAULT_SEARCH_KEYS } from "./ha-picker-combo-box";
import type { HaPickerList, PickerListItem } from "./ha-picker-list";
import "./ha-picker-list";
import "./ha-picker-search";
import type { HaPickerSearch } from "./ha-picker-search";
export type PickerSearchFn<T extends PickerListItem = PickerListItem> = (
search: string,
filtered: T[],
all: T[]
) => T[];
/**
* Search input + virtualized list with built-in Fuse.js filtering.
* For custom filtering pipelines, compose `ha-picker-search` and
* `ha-picker-list` directly instead.
*/
@customElement("ha-picker-search-list")
export class HaPickerSearchList<
T extends PickerListItem = PickerListItem,
> extends LitElement {
@property({ attribute: false }) public items: T[] = [];
@property() public value?: string;
@property({ attribute: false }) public searchKeys?: FuseWeightedKey[];
@property({ attribute: false }) public searchFn?: PickerSearchFn<T>;
@property({ attribute: false })
public rowRenderer?: RenderItemFunction<T>;
@property({ attribute: false }) public actions?: PickerListItem[];
@property({ attribute: "search-placeholder" })
public searchPlaceholder?: string;
@property({ attribute: "empty-label" }) public emptyLabel?: string;
@property({ attribute: false })
public notFoundLabel?:
| string
| TemplateResult
| ((search: string) => string | TemplateResult);
// eslint-disable-next-line lit/no-native-attributes
@property({ type: Boolean }) public autofocus = false;
@state() private _search = "";
@query("ha-picker-search") private _searchEl?: HaPickerSearch;
@query("ha-picker-list") private _listEl?: HaPickerList;
public focus() {
this._searchEl?.focus();
}
public reset() {
this._search = "";
}
protected render() {
const displayItems = this._computeDisplayItems(
this.items,
this._search,
this.searchKeys,
this.searchFn,
this.actions
);
return html`
<ha-picker-search
?autofocus=${this.autofocus}
.value=${this._search}
.placeholder=${this.searchPlaceholder ?? ""}
@search-changed=${this._handleSearchChanged}
@keydown=${this._handleSearchKeydown}
></ha-picker-search>
<ha-picker-list
.items=${displayItems}
.value=${this.value}
.rowRenderer=${this.rowRenderer as RenderItemFunction<PickerListItem>}
.currentSearch=${this._search}
.notFoundLabel=${this.notFoundLabel}
.emptyLabel=${this.emptyLabel}
></ha-picker-list>
`;
}
// Forward nav keys from search input to list (focus stays in search).
private _handleSearchKeydown = (ev: KeyboardEvent) => {
const list = this._listEl;
if (!list) return;
switch (ev.key) {
case "ArrowDown":
ev.preventDefault();
list.selectNext(ev);
break;
case "ArrowUp":
ev.preventDefault();
list.selectPrev(ev);
break;
case "Home":
ev.preventDefault();
list.selectFirst(ev);
break;
case "End":
ev.preventDefault();
list.selectLast(ev);
break;
case "Enter":
ev.preventDefault();
list.commitHighlighted(ev.ctrlKey || ev.metaKey);
break;
default:
}
};
private _fuseIndex = memoizeOne(
(items: T[], searchKeys?: FuseWeightedKey[]) =>
Fuse.createIndex(searchKeys ?? DEFAULT_SEARCH_KEYS, items)
);
private _computeDisplayItems = memoizeOne(
(
items: T[],
search: string,
searchKeys: FuseWeightedKey[] | undefined,
searchFn: PickerSearchFn<T> | undefined,
actions: PickerListItem[] | undefined
): PickerListItem[] => {
let filtered = items;
if (search) {
const keys = searchKeys ?? DEFAULT_SEARCH_KEYS;
const index = this._fuseIndex(items, keys);
filtered = multiTermSortedSearch<T>(
items,
search,
keys,
(item) => item.id,
index
);
if (searchFn) {
filtered = searchFn(search, filtered, items);
}
}
if (actions?.length) {
return [...filtered, ...actions];
}
return filtered;
}
);
private _handleSearchChanged = (ev: HASSDomEvent<{ value: string }>) => {
this._search = ev.detail.value;
};
static styles: CSSResultGroup = css`
:host {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-picker-search-list": HaPickerSearchList;
}
}
+60
View File
@@ -0,0 +1,60 @@
import { css, html, LitElement, type CSSResultGroup } from "lit";
import { customElement, property, query } from "lit/decorators";
import { fireEvent } from "../common/dom/fire_event";
import "./input/ha-input-search";
import type { HaInputSearch } from "./input/ha-input-search";
/** Search input for picker UIs; emits `search-changed`. */
@customElement("ha-picker-search")
export class HaPickerSearch extends LitElement {
@property() public value = "";
@property() public placeholder?: string;
// eslint-disable-next-line lit/no-native-attributes
@property({ type: Boolean }) public autofocus = false;
@query("ha-input-search") private _input?: HaInputSearch;
public focus() {
// ha-input doesn't expose focus(); reach the wa-input it wraps.
this._input?.shadowRoot?.querySelector<HTMLElement>("wa-input")?.focus();
}
protected render() {
return html`
<ha-input-search
appearance="outlined"
.value=${this.value}
.placeholder=${this.placeholder ?? ""}
?autofocus=${this.autofocus}
@input=${this._handleInput}
></ha-input-search>
`;
}
private _handleInput = (ev: Event) => {
const value = (ev.target as HaInputSearch).value ?? "";
this.value = value;
fireEvent(this, "search-changed", { value });
};
static styles: CSSResultGroup = css`
:host {
display: block;
padding: 0 var(--ha-space-3) var(--ha-space-3);
}
ha-input-search {
width: 100%;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-picker-search": HaPickerSearch;
}
interface HASSDomEvents {
"search-changed": { value: string };
}
}
+94
View File
@@ -0,0 +1,94 @@
import { css, html, LitElement, nothing, type CSSResultGroup } from "lit";
import { customElement, property } from "lit/decorators";
import { fireEvent } from "../common/dom/fire_event";
import { isTouch } from "../util/is_touch";
import "./chips/ha-chip-set";
import "./chips/ha-filter-chip";
export interface PickerSection {
id: string;
label: string;
}
export type PickerSectionDef = PickerSection | "separator";
/** Section filter chip bar; emits `section-changed`. Toggling the active chip clears the filter. */
@customElement("ha-picker-section-chips")
export class HaPickerSectionChips extends LitElement {
@property({ attribute: false }) public sections?: PickerSectionDef[];
@property() public selected?: string;
protected render() {
if (!this.sections?.length) return nothing;
return html`
<ha-chip-set>
${this.sections.map((section) =>
section === "separator"
? html`<div class="separator"></div>`
: html`<ha-filter-chip
@mousedown=${isTouch ? undefined : this._preventBlur}
@click=${this._handleClick}
data-section-id=${section.id}
.selected=${this.selected === section.id}
.label=${section.label}
></ha-filter-chip>`
)}
</ha-chip-set>
`;
}
private _preventBlur = (ev: Event) => {
ev.preventDefault();
};
private _handleClick = (ev: Event) => {
const id = (ev.currentTarget as HTMLElement).dataset.sectionId;
if (!id) return;
const next = this.selected === id ? undefined : id;
this.selected = next;
fireEvent(this, "section-changed", { section: next });
};
static styles: CSSResultGroup = css`
:host {
display: block;
padding: 0 var(--ha-space-3) var(--ha-space-3);
}
ha-chip-set {
display: flex;
flex-wrap: nowrap;
gap: var(--ha-space-2);
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: none;
/* Room for the chip's focus ring (clipped by overflow-y: hidden). */
padding: var(--ha-space-1) 0;
margin: calc(-1 * var(--ha-space-1)) 0;
}
ha-chip-set::-webkit-scrollbar {
display: none;
}
ha-chip-set ha-filter-chip {
flex-shrink: 0;
--md-filter-chip-selected-container-color: var(
--ha-color-fill-primary-normal-hover
);
color: var(--primary-color);
}
.separator {
height: var(--ha-space-8);
width: 0;
border: 1px solid var(--ha-color-border-neutral-quiet);
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-picker-section-chips": HaPickerSectionChips;
}
interface HASSDomEvents {
"section-changed": { section: string | undefined };
}
}
+8 -15
View File
@@ -1,12 +1,11 @@
import { consume, type ContextType } from "@lit/context";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { customElement, property } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { ifDefined } from "lit/directives/if-defined";
import { styleMap } from "lit/directives/style-map";
import { fireEvent } from "../common/dom/fire_event";
import { computeRTL } from "../common/util/compute_rtl";
import { internationalizationContext, uiContext } from "../data/context";
import type { HomeAssistant } from "../types";
import "./radio/ha-radio-group";
import type { HaRadioGroup } from "./radio/ha-radio-group";
import "./radio/ha-radio-option";
@@ -27,6 +26,8 @@ export interface SelectBoxOption {
@customElement("ha-select-box")
export class HaSelectBox extends LitElement {
@property({ attribute: false }) public hass?: HomeAssistant;
@property({ attribute: false }) public options: SelectBoxOption[] = [];
@property({ attribute: false }) public value?: string;
@@ -39,14 +40,6 @@ export class HaSelectBox extends LitElement {
@property({ type: Boolean, attribute: "stacked_image" })
public stackedImage = false;
@state()
@consume({ context: internationalizationContext, subscribe: true })
protected _i18n?: ContextType<typeof internationalizationContext>;
@state()
@consume({ context: uiContext, subscribe: true })
protected _ui?: ContextType<typeof uiContext>;
render() {
const maxColumns = this.maxColumns ?? 3;
const columns = Math.min(maxColumns, this.options.length);
@@ -69,11 +62,11 @@ export class HaSelectBox extends LitElement {
const disabled = option.disabled || this.disabled || false;
const selected = option.value === this.value;
const isDark = this._ui?.themes.darkMode || false;
const isRTL = this._i18n
const isDark = this.hass?.themes.darkMode || false;
const isRTL = this.hass
? computeRTL(
this._i18n.language,
this._i18n.translationMetadata.translations
this.hass.language,
this.hass.translationMetadata.translations
)
: false;
@@ -1,31 +1,30 @@
import { LitElement, css, html, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { consumeLocalize } from "../../common/decorators/consume-context-entry";
import { fireEvent } from "../../common/dom/fire_event";
import type {
LocalizeFunc,
LocalizeKeys,
} from "../../common/translations/localize";
import type { LocalizeKeys } from "../../common/translations/localize";
import type {
AutomationBehavior,
AutomationBehaviorConditionMode,
AutomationBehaviorSelector,
AutomationBehaviorTriggerMode,
} from "../../data/selector";
import type { HomeAssistant } from "../../types";
import "../ha-input-helper-text";
import "../ha-select-box";
import type { SelectBoxOption } from "../ha-select-box";
import "../ha-select-box";
const TRIGGER_BEHAVIORS: AutomationBehaviorTriggerMode[] = [
"each",
"any",
"first",
"all",
"last",
];
const CONDITION_BEHAVIORS: AutomationBehaviorConditionMode[] = ["any", "all"];
@customElement("ha-selector-automation_behavior")
export class HaSelectorAutomationBehavior extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false })
public selector!: AutomationBehaviorSelector;
@@ -40,9 +39,6 @@ export class HaSelectorAutomationBehavior extends LitElement {
@property({ type: Boolean }) public required = true;
@consumeLocalize()
protected _localize?: LocalizeFunc;
protected render() {
const { mode } = this.selector.automation_behavior ?? {};
const modeKey = mode ?? "trigger";
@@ -64,6 +60,7 @@ export class HaSelectorAutomationBehavior extends LitElement {
return html`
<ha-select-box
.hass=${this.hass}
.options=${options}
.value=${this.value ?? ""}
max_columns="1"
@@ -98,10 +95,8 @@ export class HaSelectorAutomationBehavior extends LitElement {
return translated;
}
}
return (
this._localize?.(
`ui.components.selectors.automation_behavior.${mode ?? "trigger"}.options.${behavior}.${field}` as LocalizeKeys
) || behavior
return this.hass.localize(
`ui.components.selectors.automation_behavior.${mode ?? "trigger"}.options.${behavior}.${field}` as LocalizeKeys
);
}
+46 -10
View File
@@ -1,10 +1,11 @@
import { mdiPlayBox, mdiPlus } from "@mdi/js";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { fireEvent } from "../../common/dom/fire_event";
import { supportsFeature } from "../../common/entity/supports-feature";
import { getSignedPath } from "../../data/auth";
import type { MediaPickedEvent } from "../../data/media-player";
import {
MediaClassBrowserSettings,
@@ -12,10 +13,14 @@ import {
} from "../../data/media-player";
import type { MediaSelector, MediaSelectorValue } from "../../data/selector";
import type { HomeAssistant } from "../../types";
import {
brandsUrl,
extractDomainFromBrandUrl,
isBrandUrl,
} from "../../util/brands-url";
import "../ha-alert";
import "../ha-form/ha-form";
import type { SchemaUnion } from "../ha-form/types";
import "../media-player/ha-media-browser-thumbnail";
import { showMediaBrowserDialog } from "../media-player/show-media-browser-dialog";
import { ensureArray } from "../../common/array/ensure-array";
import "../ha-picture-upload";
@@ -49,6 +54,8 @@ export class HaMediaSelector extends LitElement {
filter_entity?: string | string[];
};
@state() private _thumbnailUrl?: string | null;
private _contextEntities: string[] | undefined;
private get _hasAccept(): boolean {
@@ -61,6 +68,35 @@ export class HaMediaSelector extends LitElement {
this._contextEntities = ensureArray(this.context?.filter_entity);
}
}
if (changedProps.has("value")) {
const thumbnail = this.value?.metadata?.thumbnail;
const oldThumbnail = (changedProps.get("value") as this["value"])
?.metadata?.thumbnail;
if (thumbnail === oldThumbnail) {
return;
}
if (thumbnail && isBrandUrl(thumbnail)) {
// The backend is not aware of the theme used by the users,
// so we rewrite the URL to show a proper icon
this._thumbnailUrl = brandsUrl(
{
domain: extractDomainFromBrandUrl(thumbnail),
type: "icon",
darkOptimized: this.hass.themes?.darkMode,
},
this.hass.auth.data.hassUrl
);
} else if (thumbnail && thumbnail.startsWith("/")) {
this._thumbnailUrl = undefined;
// Thumbnails served by local API require authentication
getSignedPath(this.hass, thumbnail).then((signedPath) => {
this._thumbnailUrl = signedPath.path;
});
} else {
this._thumbnailUrl = thumbnail;
}
}
}
protected render() {
@@ -150,12 +186,10 @@ export class HaMediaSelector extends LitElement {
),
})}
image"
>
<ha-media-browser-thumbnail
.hass=${this.hass}
.url=${this.value.metadata.thumbnail}
></ha-media-browser-thumbnail>
</div>
style=${this._thumbnailUrl
? `background-image: url(${this._thumbnailUrl});`
: ""}
></div>
`
: html`
<div class="icon-holder image">
@@ -376,11 +410,13 @@ export class HaMediaSelector extends LitElement {
right: 0;
left: 0;
bottom: 0;
--ha-media-browser-thumbnail-fit: cover;
background-size: cover;
background-repeat: no-repeat;
background-position: center;
}
.centered-image {
margin: 4px;
--ha-media-browser-thumbnail-fit: contain;
background-size: contain;
}
.icon-holder {
display: flex;
@@ -96,6 +96,7 @@ export class HaSelectSelector extends LitElement {
.value=${this.value as string | undefined}
@value-changed=${this._selectChanged}
.maxColumns=${this.selector.select?.box_max_columns}
.hass=${this.hass}
></ha-select-box>
${this._renderHelper()}
`;
@@ -198,7 +199,6 @@ export class HaSelectSelector extends LitElement {
: nothing}
<ha-generic-picker
no-sort
.hass=${this.hass}
.helper=${this.helper}
.disabled=${this.disabled}
@@ -215,7 +215,6 @@ export class HaSelectSelector extends LitElement {
if (this.selector.select?.custom_value) {
return html`
<ha-generic-picker
no-sort
.hass=${this.hass}
.label=${this.label}
.helper=${this.helper}
+7 -14
View File
@@ -1,31 +1,24 @@
import { consume, type ContextType } from "@lit/context";
import { html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { customElement, property } from "lit/decorators";
import { until } from "lit/directives/until";
import { computeDomain } from "../common/entity/compute_domain";
import { configContext, connectionContext } from "../data/context";
import {
DEFAULT_SERVICE_ICON,
FALLBACK_DOMAIN_ICONS,
serviceIcon,
} from "../data/icons";
import type { HomeAssistant } from "../types";
import "./ha-icon";
import "./ha-svg-icon";
@customElement("ha-service-icon")
export class HaServiceIcon extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property() public service?: string;
@property() public icon?: string;
@state()
@consume({ context: connectionContext, subscribe: true })
protected _connection?: ContextType<typeof connectionContext>;
@state()
@consume({ context: configContext, subscribe: true })
protected _config?: ContextType<typeof configContext>;
protected render() {
if (this.icon) {
return html`<ha-icon .icon=${this.icon}></ha-icon>`;
@@ -35,13 +28,13 @@ export class HaServiceIcon extends LitElement {
return nothing;
}
if (!this._connection || !this._config) {
if (!this.hass) {
return this._renderFallback();
}
const icon = serviceIcon(
this._connection.connection,
this._config?.config,
this.hass.connection,
this.hass.config,
this.service
).then((icn) => {
if (icn) {
+10 -2
View File
@@ -62,7 +62,11 @@ class HaServicePicker extends LitElement {
index
) => html`
<ha-combo-box-item type="button" .borderTop=${index !== 0}>
<ha-service-icon slot="start" .service=${item.id}></ha-service-icon>
<ha-service-icon
slot="start"
.hass=${this.hass}
.service=${item.id}
></ha-service-icon>
<span slot="headline">${item.primary}</span>
<span slot="supporting-text">${item.secondary}</span>
${item.service_id && this.showServiceId
@@ -108,7 +112,11 @@ class HaServicePicker extends LitElement {
service;
return html`
<ha-service-icon slot="start" .service=${serviceId}></ha-service-icon>
<ha-service-icon
slot="start"
.hass=${this.hass}
.service=${serviceId}
></ha-service-icon>
<span slot="headline">${serviceName}</span>
${this.showServiceId
? html`<span slot="supporting-text" class="code"
+145 -132
View File
@@ -1,6 +1,5 @@
import "@home-assistant/webawesome/dist/components/popover/popover";
import { consume } from "@lit/context";
import { mdiPlus, mdiTextureBox } from "@mdi/js";
import { mdiPlaylistPlus, mdiPlus, mdiTextureBox } from "@mdi/js";
import Fuse from "fuse.js";
import type { HassServiceTarget } from "home-assistant-js-websocket";
import type { PropertyValues } from "lit";
@@ -53,19 +52,22 @@ import {
multiTermSortedSearch,
type FuseWeightedKey,
} from "../resources/fuseMultiTerm";
import type { HomeAssistant, ValueChangedEvent } from "../types";
import type { HomeAssistant } from "../types";
import { brandsUrl } from "../util/brands-url";
import type { HaDevicePickerDeviceFilterFunc } from "./device/ha-device-picker";
import "./ha-generic-picker";
import type { HaGenericPicker } from "./ha-generic-picker";
import "./ha-button";
import type { PickerComboBoxItem } from "./ha-picker-combo-box";
import "./ha-picker-list";
import type { PickerListEntry, PickerListItem } from "./ha-picker-list";
import "./ha-picker-popover";
import "./ha-picker-search";
import "./ha-picker-section-chips";
import "./ha-svg-icon";
import "./ha-tree-indicator";
import "./target-picker/ha-target-picker-item-group";
import "./target-picker/ha-target-picker-value-chip";
const SEPARATOR = "________";
const CREATE_ID = "___create-new-entity___";
const isTargetType = (value: string): value is TargetType =>
value === "entity" ||
value === "device" ||
@@ -122,11 +124,15 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
@state() private _configEntryLookup: Record<string, ConfigEntry> = {};
@state() private _pickerOpen = false;
@state() private _search = "";
@state()
@consume({ context: labelsContext, subscribe: true })
private _labelRegistry!: LabelRegistryEntry[];
@query("ha-generic-picker") private _picker?: HaGenericPicker;
@query(".add-target-wrapper") private _addTargetWrapper?: HTMLElement;
private _newTarget?: TargetItem;
@@ -412,56 +418,92 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
},
];
const items = this._buildListEntries(
this._search,
this._selectedSection,
this.createDomains
);
return html`
<div class="add-target-wrapper">
<ha-generic-picker
.hass=${this.hass}
<ha-button
class="add-target-button"
size="small"
appearance="filled"
.disabled=${this.disabled}
.autofocus=${this.autofocus}
.helper=${this.helper}
.sections=${sections}
.notFoundLabel=${this._noTargetFoundLabel}
.emptyLabel=${this.hass.localize(
"ui.components.target-picker.no_targets"
)}
.sectionTitleFunction=${this._sectionTitleFunction}
.selectedSection=${this._selectedSection}
.popoverAnchor=${this._replaceTargetAnchor}
.rowRenderer=${this._renderRow}
.getItems=${this._getItems}
@value-changed=${this._targetPicked}
@picker-closed=${this._handlePickerClosed}
.addButtonLabel=${this.hass.localize(
"ui.components.target-picker.add_target"
)}
.getAdditionalItems=${this._getAdditionalItems}
@click=${this._openPicker}
>
</ha-generic-picker>
<ha-svg-icon .path=${mdiPlaylistPlus} slot="start"></ha-svg-icon>
${this.hass.localize("ui.components.target-picker.add_target")}
</ha-button>
<ha-picker-popover
.open=${this._pickerOpen}
.anchor=${this._replaceTargetAnchor ?? this._addTargetWrapper}
.label=${this.hass.localize("ui.components.target-picker.add_target")}
@closed=${this._handlePickerClosed}
>
<div class="picker-body">
<ha-picker-search
autofocus
.value=${this._search}
.placeholder=${this.hass.localize("ui.common.search")}
@search-changed=${this._handleSearchChanged}
></ha-picker-search>
<ha-picker-section-chips
.sections=${sections}
.selected=${this._selectedSection}
@section-changed=${this._handleSectionChanged}
></ha-picker-section-chips>
<ha-picker-list
.items=${items}
.rowRenderer=${this._renderRow}
.currentSearch=${this._search}
.notFoundLabel=${this._noTargetFoundLabel}
.emptyLabel=${this.hass.localize(
"ui.components.target-picker.no_targets"
)}
@item-selected=${this._handleItemSelected}
></ha-picker-list>
</div>
</ha-picker-popover>
</div>
`;
}
private _targetPicked(ev: ValueChangedEvent<string>) {
ev.stopPropagation();
const value = ev.detail.value;
if (value.startsWith(CREATE_ID)) {
this._createNewDomainElement(value.substring(CREATE_ID.length));
return;
}
private _openPicker = () => {
if (this.disabled) return;
this._pickerOpen = true;
};
private _handleSearchChanged = (ev: HASSDomEvent<{ value: string }>) => {
this._search = ev.detail.value;
};
private _handleSectionChanged = (
ev: HASSDomEvent<{ section: string | undefined }>
) => {
this._selectedSection = ev.detail.section as
| TargetTypeFloorless
| undefined;
};
private _handleItemSelected = (
ev: HASSDomEvent<{ id: string; index: number; newTab?: boolean }>
) => {
ev.stopPropagation();
const value = ev.detail.id;
const [rawType, id] = value.split(SEPARATOR);
if (!id || !isTargetType(rawType)) {
return;
}
if (this._replaceTarget) {
this._replaceTargetItem(this._replaceTarget, { type: rawType, id });
return;
}
this._pickerOpen = false;
this._pendingPick = { type: rawType, id };
};
this._addTarget(id, rawType);
}
// Commit fires on @closed (after the hide animation) to avoid flicker.
private _pendingPick?: TargetItem;
private _replaceTargetItem(currentTarget: TargetItem, newTarget: TargetItem) {
const value = this._replaceTargetInValue(
@@ -734,18 +776,27 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
return;
}
this._replaceTarget = { type, id: ev.detail.id };
this._picker?.open(undefined, {
selectedValue: `${type}${SEPARATOR}${ev.detail.id}`,
});
this._pickerOpen = true;
}
private _handlePickerClosed() {
private _handlePickerClosed = () => {
if (this._pendingPick) {
const pick = this._pendingPick;
this._pendingPick = undefined;
if (this._replaceTarget) {
this._replaceTargetItem(this._replaceTarget, pick);
} else {
this._addTarget(pick.id, pick.type);
}
}
this._pickerOpen = false;
this._search = "";
if (this._replaceTarget) {
this._selectedSection = undefined;
}
this._replaceTarget = undefined;
this._replaceTargetAnchor = undefined;
}
};
private _addItems(
value: this["value"],
@@ -782,55 +833,12 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
return undefined;
}
private _sectionTitleFunction = ({
firstIndex,
lastIndex,
firstItem,
secondItem,
itemsCount,
}: {
firstIndex: number;
lastIndex: number;
firstItem: PickerComboBoxItem | string;
secondItem: PickerComboBoxItem | string;
itemsCount: number;
}) => {
if (
firstItem === undefined ||
secondItem === undefined ||
typeof firstItem === "string" ||
(typeof secondItem === "string" && secondItem !== "padding") ||
(firstIndex === 0 && lastIndex === itemsCount - 1)
) {
return undefined;
}
const type = getTargetComboBoxItemType(firstItem as PickerComboBoxItem);
const translationType:
| "areas"
| "entities"
| "devices"
| "labels"
| undefined =
type === "area" || type === "floor"
? "areas"
: type === "entity"
? "entities"
: type && type !== "empty"
? `${type}s`
: undefined;
return translationType
? this.hass.localize(
`ui.components.target-picker.type.${translationType}`
)
: undefined;
};
private _getItems = (searchString: string, section: string) => {
this._selectedSection = section as TargetTypeFloorless | undefined;
return this._getItemsMemoized(
private _buildListEntries(
searchString: string,
section: TargetTypeFloorless | undefined,
createDomains: this["createDomains"]
): PickerListEntry[] {
const items = this._getItemsMemoized(
this.hass.localize,
this.entityFilter,
this.deviceFilter,
@@ -840,9 +848,37 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
this._replaceTarget,
searchString,
this._configEntryLookup,
this._selectedSection
);
};
section
) as PickerListEntry[];
const actions = this._buildActionEntries(createDomains);
return actions.length ? [...items, ...actions] : items;
}
private _buildActionEntries = memoizeOne(
(createDomains: this["createDomains"]): PickerListItem[] => {
if (!createDomains?.length) return [];
return createDomains.map((domain) => ({
id: `__create-helper__${SEPARATOR}${domain}`,
primary: this.hass.localize(
"ui.components.entity.entity-picker.create_helper",
{
domain: isHelperDomain(domain)
? this.hass.localize(`ui.panel.config.helpers.types.${domain}`)
: domainToName(this.hass.localize, domain),
}
),
secondary: this.hass.localize(
"ui.components.entity.entity-picker.new_entity"
),
icon_path: mdiPlus,
onSelect: ({ close }) => {
close();
this._createNewDomainElement(domain);
},
}));
}
);
private _getItemsMemoized = memoizeOne(
(
@@ -1083,36 +1119,6 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
});
}
private _getAdditionalItems = () => this._getCreateItems(this.createDomains);
private _getCreateItems = memoizeOne(
(createDomains: this["createDomains"]) => {
if (!createDomains?.length) {
return [];
}
return createDomains.map((domain) => {
const primary = this.hass.localize(
"ui.components.entity.entity-picker.create_helper",
{
domain: isHelperDomain(domain)
? this.hass.localize(`ui.panel.config.helpers.types.${domain}`)
: domainToName(this.hass.localize, domain),
}
);
return {
id: CREATE_ID + domain,
primary: primary,
secondary: this.hass.localize(
"ui.components.entity.entity-picker.new_entity"
),
icon_path: mdiPlus,
} satisfies EntityComboBoxItem;
});
}
);
private async _loadConfigEntries() {
const configEntries = await getConfigEntries(this.hass);
this._configEntryLookup = Object.fromEntries(
@@ -1256,14 +1262,21 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
);
static styles = css`
:host {
display: block;
}
.add-target-wrapper {
display: flex;
justify-content: flex-start;
display: block;
margin-top: var(--ha-space-3);
}
ha-generic-picker {
width: 100%;
.picker-body {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
padding-top: var(--ha-space-4);
}
.items {
+37 -63
View File
@@ -1,17 +1,13 @@
import type { TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../common/dom/fire_event";
import { caseInsensitiveStringCompare } from "../common/string/compare";
import type { HomeAssistant, ValueChangedEvent } from "../types";
import "./ha-generic-picker";
import type { PickerComboBoxItem } from "./ha-picker-combo-box";
import type { HomeAssistant } from "../types";
import type { HaSelectOption, HaSelectSelectEvent } from "./ha-select";
import "./ha-select";
const DEFAULT_THEME = "default";
const SEARCH_KEYS = [{ name: "primary", weight: 1 }];
@customElement("ha-theme-picker")
export class HaThemePicker extends LitElement {
@property() public value?: string;
@@ -29,74 +25,52 @@ export class HaThemePicker extends LitElement {
@property({ type: Boolean }) public required = false;
@property({ attribute: "no-theme-label" }) public noThemeLabel?: string;
private _getThemeOptions = memoizeOne(
(
themes: Record<string, unknown>,
locale: string,
includeDefault: boolean
): PickerComboBoxItem[] => {
const items: PickerComboBoxItem[] = [];
if (includeDefault) {
items.push({ id: DEFAULT_THEME, primary: "Home Assistant" });
}
const themeNames = Object.keys(themes).sort((a, b) =>
caseInsensitiveStringCompare(a, b, locale)
);
for (const theme of themeNames) {
items.push({ id: theme, primary: theme });
}
return items;
}
);
private _getItems = () =>
this._getThemeOptions(
this.hass?.themes.themes || {},
this.hass?.locale.language || "en",
this.includeDefault
);
private _valueRenderer = (value: string): TemplateResult =>
html`<span slot="headline"
>${this._getItems().find((i) => i.id === value)?.primary ?? value}</span
>`;
protected render(): TemplateResult {
const options: HaSelectOption[] = Object.keys(
this.hass?.themes.themes || {}
).map((theme) => ({
value: theme,
}));
if (this.includeDefault) {
options.unshift({
value: DEFAULT_THEME,
label: "Home Assistant",
});
}
if (!this.required) {
options.unshift({
value: "remove",
label: this.hass!.localize("ui.components.theme-picker.no_theme"),
});
}
return html`
<ha-generic-picker
.label=${this.label ??
this.hass?.localize("ui.components.theme-picker.theme") ??
"Theme"}
.placeholder=${this.noThemeLabel ??
this.hass?.localize("ui.components.theme-picker.no_theme")}
.helper=${this.helper}
<ha-select
.label=${this.label ||
this.hass!.localize("ui.components.theme-picker.theme")}
.value=${this.value}
.valueRenderer=${this._valueRenderer}
.getItems=${this._getItems}
.searchKeys=${SEARCH_KEYS}
.disabled=${this.disabled}
.helper=${this.helper}
.required=${this.required}
@value-changed=${this._changed}
popover-placement="bottom"
></ha-generic-picker>
.disabled=${this.disabled}
@selected=${this._changed}
.options=${options}
></ha-select>
`;
}
static styles = css`
ha-generic-picker {
ha-select {
width: 100%;
display: block;
}
`;
private _changed(ev: ValueChangedEvent<string | undefined>): void {
ev.stopPropagation();
this.value = ev.detail.value;
private _changed(ev: HaSelectSelectEvent): void {
if (!this.hass || ev.detail.value === "") {
return;
}
this.value = ev.detail.value === "remove" ? undefined : ev.detail.value;
fireEvent(this, "value-changed", { value: this.value });
}
}
+7 -8
View File
@@ -1,25 +1,24 @@
import { mdiLightbulbOutline } from "@mdi/js";
import { css, html, LitElement, nothing } from "lit";
import { customElement, state } from "lit/decorators";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import type { LocalizeFunc } from "../common/translations/localize";
import { customElement, property } from "lit/decorators";
import type { HomeAssistant } from "../types";
import "./ha-svg-icon";
@customElement("ha-tip")
class HaTip extends LitElement {
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
@property({ attribute: false }) public hass!: HomeAssistant;
public render() {
if (!this._localize) {
if (!this.hass) {
return nothing;
}
return html`
<ha-svg-icon .path=${mdiLightbulbOutline}></ha-svg-icon>
<span class="prefix">${this._localize("ui.panel.config.tips.tip")}</span>
<span class="prefix"
>${this.hass.localize("ui.panel.config.tips.tip")}</span
>
<span class="text"><slot></slot></span>
`;
}
+57 -223
View File
@@ -1,230 +1,64 @@
import type { CSSResultGroup, PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, query } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
const PASSIVE_EVENT_OPTIONS = { passive: true } as const;
export const haTopAppBarFixedStyles = css`
:host {
display: block;
}
.top-app-bar {
box-sizing: border-box;
color: var(--app-header-text-color, #fff);
background-color: var(--app-header-background-color, var(--primary-color));
position: fixed;
top: 0;
inset-inline-end: 0;
width: var(--ha-top-app-bar-width, 100%);
z-index: 4;
padding-top: var(--safe-area-inset-top);
padding-right: var(--safe-area-inset-right);
transition:
width var(--ha-animation-duration-normal) ease,
padding-left var(--ha-animation-duration-normal) ease,
padding-right var(--ha-animation-duration-normal) ease;
}
:host([narrow]) .top-app-bar {
padding-left: var(--safe-area-inset-left);
}
.top-app-bar.scrolled:not(.pane-header) {
box-shadow: var(--ha-box-shadow-s);
}
.row {
display: flex;
align-items: center;
box-sizing: border-box;
width: 100%;
height: var(--header-height);
border-bottom: var(--app-header-border-bottom);
}
.section {
display: flex;
align-items: center;
box-sizing: border-box;
min-width: 0;
height: 100%;
padding: 0 var(--ha-space-3);
}
#navigation {
flex: 1 1 auto;
}
.section.center {
flex: 1 1 auto;
justify-content: center;
text-align: center;
}
.section.end {
flex: none;
justify-content: flex-end;
}
.title {
display: block;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: var(--ha-font-size-xl);
font-weight: var(--ha-font-weight-normal);
line-height: var(--header-height);
padding-inline-start: var(--ha-space-6);
}
:host([narrow]) .title {
padding-inline-start: var(--ha-space-2);
}
.top-app-bar-fixed-adjust {
padding-top: calc(
var(--header-height, 0px) + var(--safe-area-inset-top, 0px)
);
padding-bottom: var(--safe-area-inset-bottom);
padding-right: var(--safe-area-inset-right);
}
:host([narrow]) .top-app-bar-fixed-adjust {
padding-left: var(--safe-area-inset-left);
}
`;
import { TopAppBarFixedBase } from "@material/mwc-top-app-bar-fixed/mwc-top-app-bar-fixed-base";
import { styles } from "@material/mwc-top-app-bar/mwc-top-app-bar.css";
import { css } from "lit";
import { customElement, property } from "lit/decorators";
@customElement("ha-top-app-bar-fixed")
export class HaTopAppBarFixed extends LitElement {
export class HaTopAppBarFixed extends TopAppBarFixedBase {
@property({ type: Boolean, reflect: true }) public narrow = false;
@property({ attribute: "center-title", type: Boolean }) centerTitle = false;
@query(".top-app-bar") protected _barElement!: HTMLElement;
private _scrollTarget?: HTMLElement | Window;
@property({ attribute: false })
public get scrollTarget(): HTMLElement | Window {
return this._scrollTarget || window;
}
public set scrollTarget(value: HTMLElement | Window) {
const old = this.scrollTarget;
this._unregisterListeners();
this._scrollTarget = value;
this._updateBarPosition();
this.requestUpdate("scrollTarget", old);
if (this.isConnected) {
this._registerListeners();
this._syncScrollState();
}
}
protected _isPaneHeader(): boolean {
return false;
}
protected render() {
return html`${this._renderHeader()}${this._renderContent()}`;
}
override connectedCallback() {
super.connectedCallback();
if (this.hasUpdated) {
this._updateBarPosition();
this._registerListeners();
this._syncScrollState();
}
}
protected _renderHeader() {
const title = html`<span class="title">
<slot name="title"></slot>
</span>`;
const paneHeader = this._isPaneHeader();
return html`
<header
class="top-app-bar ${classMap({
"pane-header": paneHeader,
})}"
>
<div class="row">
${paneHeader
? html`<section class="section" id="title">
<slot name="navigationIcon"></slot>
${title}
</section>`
: nothing}
<section class="section" id="navigation">
${paneHeader
? nothing
: html`<slot name="navigationIcon"></slot> ${this.centerTitle
? nothing
: title}`}
</section>
${!paneHeader && this.centerTitle
? html`<section class="section center">${title}</section>`
: nothing}
<section class="section end" id="actions" role="toolbar">
<slot name="actionItems"></slot>
</section>
</div>
</header>
`;
}
protected _renderContent() {
return html`<div class="top-app-bar-fixed-adjust">
<slot></slot>
</div>`;
}
protected firstUpdated(changedProperties: PropertyValues<this>) {
super.firstUpdated(changedProperties);
this._updateBarPosition();
this._registerListeners();
this._syncScrollState();
}
override disconnectedCallback() {
super.disconnectedCallback();
this._unregisterListeners();
}
protected _updateBarPosition() {
if (this._barElement) {
this._barElement.style.position =
this.scrollTarget === window ? "" : "absolute";
}
}
protected _syncScrollState = () => {
const scrollTop =
this.scrollTarget instanceof Window
? this.scrollTarget.pageYOffset
: this.scrollTarget.scrollTop;
this._barElement?.classList.toggle("scrolled", scrollTop > 0);
};
protected _registerListeners() {
this.scrollTarget.addEventListener(
"scroll",
this._syncScrollState,
PASSIVE_EVENT_OPTIONS
);
}
protected _unregisterListeners() {
this.scrollTarget.removeEventListener("scroll", this._syncScrollState);
}
static override styles: CSSResultGroup = haTopAppBarFixedStyles;
static override styles = [
styles,
css`
header {
padding-top: var(--safe-area-inset-top);
}
.mdc-top-app-bar__row {
height: var(--header-height);
border-bottom: var(--app-header-border-bottom);
}
.mdc-top-app-bar--fixed-adjust {
padding-top: calc(
var(--header-height, 0px) + var(--safe-area-inset-top, 0px)
);
padding-bottom: var(--safe-area-inset-bottom);
padding-right: var(--safe-area-inset-right);
}
:host([narrow]) .mdc-top-app-bar--fixed-adjust {
padding-left: var(--safe-area-inset-left);
}
.mdc-top-app-bar {
--mdc-typography-headline6-font-weight: var(--ha-font-weight-normal);
color: var(--app-header-text-color, var(--mdc-theme-on-primary, #fff));
background-color: var(
--app-header-background-color,
var(--mdc-theme-primary)
);
padding-top: var(--safe-area-inset-top);
padding-right: var(--safe-area-inset-right);
transition:
width var(--ha-animation-duration-normal) ease,
padding-left var(--ha-animation-duration-normal) ease,
padding-right var(--ha-animation-duration-normal) ease;
}
:host([narrow]) .mdc-top-app-bar {
padding-left: var(--safe-area-inset-left);
}
@media (prefers-reduced-motion: reduce) {
.mdc-top-app-bar {
transition: 1ms;
}
}
.mdc-top-app-bar__title {
font-size: var(--ha-font-size-xl);
padding-inline-start: var(--ha-space-6);
padding-inline-end: initial;
}
:host([narrow]) .mdc-top-app-bar__title {
padding-inline-start: var(--ha-space-2);
}
`,
];
}
declare global {
+37
View File
@@ -0,0 +1,37 @@
import { TopAppBarBase } from "@material/mwc-top-app-bar/mwc-top-app-bar-base";
import { styles } from "@material/mwc-top-app-bar/mwc-top-app-bar.css";
import { css } from "lit";
import { customElement } from "lit/decorators";
@customElement("ha-top-app-bar")
export class HaTopAppBar extends TopAppBarBase {
static override styles = [
styles,
css`
.mdc-top-app-bar__row {
height: var(--header-height);
border-bottom: var(--app-header-border-bottom);
}
.mdc-top-app-bar--fixed-adjust {
padding-top: calc(var(--safe-area-inset-top) + var(--header-height));
}
.mdc-top-app-bar {
--mdc-typography-headline6-font-weight: var(--ha-font-weight-normal);
color: var(--app-header-text-color, var(--mdc-theme-on-primary, #fff));
background-color: var(
--app-header-background-color,
var(--mdc-theme-primary)
);
padding-top: var(--safe-area-inset-top);
padding-left: var(--safe-area-inset-left);
padding-right: var(--safe-area-inset-right);
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
"ha-top-app-bar": HaTopAppBar;
}
}
+242 -57
View File
@@ -1,37 +1,136 @@
import {
addHasRemoveClass,
BaseElement,
} from "@material/mwc-base/base-element";
import { supportsPassiveEventListener } from "@material/mwc-base/utils";
import type { MDCTopAppBarAdapter } from "@material/top-app-bar/adapter";
import { strings } from "@material/top-app-bar/constants";
// eslint-disable-next-line import-x/no-named-as-default
import MDCFixedTopAppBarFoundation from "@material/top-app-bar/fixed/foundation";
import type { PropertyValues } from "lit";
import { html, css, nothing } from "lit";
import { property, query, customElement } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { styles } from "@material/mwc-top-app-bar/mwc-top-app-bar.css";
import { haStyleScrollbar } from "../resources/styles";
import {
HaTopAppBarFixed,
haTopAppBarFixedStyles,
} from "./ha-top-app-bar-fixed";
const PASSIVE_EVENT_OPTIONS = { passive: true } as const;
export const passiveEventOptionsIfSupported = supportsPassiveEventListener
? { passive: true }
: undefined;
@customElement("ha-two-pane-top-app-bar-fixed")
export class HaTwoPaneTopAppBarFixed extends HaTopAppBarFixed {
export class TopAppBarBaseBase extends BaseElement {
protected override mdcFoundation!: MDCFixedTopAppBarFoundation;
protected override mdcFoundationClass = MDCFixedTopAppBarFoundation;
@query(".mdc-top-app-bar") protected mdcRoot!: HTMLElement;
// _actionItemsSlot should have type HTMLSlotElement, but when TypeScript's
// emitDecoratorMetadata is enabled, the HTMLSlotElement constructor will
// be emitted into the runtime, which will cause an "HTMLSlotElement is
// undefined" error in browsers that don't define it (e.g. IE11).
@query('slot[name="actionItems"]') protected _actionItemsSlot!: HTMLElement;
protected _scrollTarget!: HTMLElement | Window;
@property({ type: Boolean, reflect: true }) public narrow = false;
@property({ attribute: "center-title", type: Boolean }) centerTitle = false;
@property({ type: Boolean, reflect: true }) prominent = false;
@property({ type: Boolean, reflect: true }) dense = false;
@property({ type: Boolean }) pane = false;
@property({ type: Boolean }) footer = false;
@query(".content") private _contentElement?: HTMLElement;
@query(".content") private _contentElement!: HTMLElement;
@query(".pane .ha-scrollbar") private _paneElement?: HTMLElement;
protected override _isPaneHeader(): boolean {
return this.pane;
@property({ attribute: false })
get scrollTarget() {
return this._scrollTarget || window;
}
protected override _renderContent() {
set scrollTarget(value) {
this.unregisterListeners();
const old = this.scrollTarget;
this._scrollTarget = value;
this.updateRootPosition();
this.requestUpdate("scrollTarget", old);
this.registerListeners();
}
protected updateRootPosition() {
if (this.mdcRoot) {
const windowScroller = this.scrollTarget === window;
// we add support for top-app-bar's tied to an element scroller.
this.mdcRoot.style.position = windowScroller ? "" : "absolute";
}
}
protected barClasses() {
return {
"mdc-top-app-bar--dense": this.dense,
"mdc-top-app-bar--prominent": this.prominent,
"center-title": this.centerTitle,
"mdc-top-app-bar--fixed": true,
"mdc-top-app-bar--pane": this.pane,
};
}
protected contentClasses() {
return {
"mdc-top-app-bar--fixed-adjust": !this.dense && !this.prominent,
"mdc-top-app-bar--dense-fixed-adjust": this.dense && !this.prominent,
"mdc-top-app-bar--prominent-fixed-adjust": !this.dense && this.prominent,
"mdc-top-app-bar--dense-prominent-fixed-adjust":
this.dense && this.prominent,
"mdc-top-app-bar--pane": this.pane,
};
}
protected override render() {
const title = html`<span class="mdc-top-app-bar__title"
><slot name="title"></slot
></span>`;
return html`
<div
class=${classMap({
"top-app-bar-fixed-adjust": true,
"top-app-bar-fixed-adjust--pane": this.pane,
})}
>
<header class="mdc-top-app-bar ${classMap(this.barClasses())}">
<div class="mdc-top-app-bar__row">
${this.pane
? html`<section
class="mdc-top-app-bar__section mdc-top-app-bar__section--align-start"
id="title"
>
<slot
name="navigationIcon"
@click=${this.handleNavigationClick}
></slot>
${title}
</section>`
: nothing}
<section class="mdc-top-app-bar__section" id="navigation">
${this.pane
? nothing
: html`<slot
name="navigationIcon"
@click=${this.handleNavigationClick}
></slot
>${title}`}
</section>
<section
class="mdc-top-app-bar__section mdc-top-app-bar__section--align-end"
id="actions"
role="toolbar"
>
<slot name="actionItems"></slot>
</section>
</div>
</header>
<div class=${classMap(this.contentClasses())}>
${this.pane
? html`<div class="pane">
<div class="shadow-container"></div>
@@ -55,57 +154,117 @@ export class HaTwoPaneTopAppBarFixed extends HaTopAppBarFixed {
`;
}
protected override willUpdate(changedProperties: PropertyValues<this>) {
super.willUpdate(changedProperties);
if (changedProperties.has("pane") && this.hasUpdated) {
this._unregisterListeners();
}
}
protected override updated(changedProperties: PropertyValues<this>) {
protected updated(changedProperties: PropertyValues<this>) {
super.updated(changedProperties);
if (
changedProperties.has("pane") &&
changedProperties.get("pane") !== undefined
) {
this._registerListeners();
this._syncScrollState();
this.unregisterListeners();
this.registerListeners();
}
}
private _handlePaneScroll = (ev: Event) => {
const target = ev.currentTarget as HTMLElement;
target.parentElement?.classList.toggle("scrolled", target.scrollTop > 0);
protected createAdapter(): MDCTopAppBarAdapter {
return {
...addHasRemoveClass(this.mdcRoot),
setStyle: (prprty: string, value: string) =>
this.mdcRoot.style.setProperty(prprty, value),
getTopAppBarHeight: () => this.mdcRoot.clientHeight,
notifyNavigationIconClicked: () => {
this.dispatchEvent(
new Event(strings.NAVIGATION_EVENT, {
bubbles: true,
cancelable: true,
})
);
},
getViewportScrollY: () =>
this.scrollTarget instanceof Window
? this.scrollTarget.pageYOffset
: this.scrollTarget.scrollTop,
getTotalActionItems: () =>
(this._actionItemsSlot as HTMLSlotElement).assignedNodes({
flatten: true,
}).length,
};
}
protected handleTargetScroll = () => {
this.mdcFoundation.handleTargetScroll();
};
protected override _registerListeners() {
protected handlePaneScroll = (ev) => {
if (ev.target.scrollTop > 0) {
ev.target.parentElement.classList.add("scrolled");
} else {
ev.target.parentElement.classList.remove("scrolled");
}
};
protected handleNavigationClick = () => {
this.mdcFoundation.handleNavigationClick();
};
protected registerListeners() {
if (this.pane) {
this._paneElement?.addEventListener(
this._paneElement!.addEventListener(
"scroll",
this._handlePaneScroll,
PASSIVE_EVENT_OPTIONS
this.handlePaneScroll,
passiveEventOptionsIfSupported
);
this._contentElement?.addEventListener(
this._contentElement.addEventListener(
"scroll",
this._handlePaneScroll,
PASSIVE_EVENT_OPTIONS
this.handlePaneScroll,
passiveEventOptionsIfSupported
);
return;
}
super._registerListeners();
this.scrollTarget.addEventListener(
"scroll",
this.handleTargetScroll,
passiveEventOptionsIfSupported
);
}
protected override _unregisterListeners() {
this._paneElement?.removeEventListener("scroll", this._handlePaneScroll);
this._contentElement?.removeEventListener("scroll", this._handlePaneScroll);
super._unregisterListeners();
protected unregisterListeners() {
this._paneElement?.removeEventListener("scroll", this.handlePaneScroll);
this._contentElement.removeEventListener("scroll", this.handlePaneScroll);
this.scrollTarget.removeEventListener("scroll", this.handleTargetScroll);
}
protected override firstUpdated() {
super.firstUpdated();
this.updateRootPosition();
this.registerListeners();
}
override disconnectedCallback() {
super.disconnectedCallback();
this.unregisterListeners();
}
static override styles = [
haTopAppBarFixedStyles,
styles,
haStyleScrollbar,
css`
header {
padding-top: var(--safe-area-inset-top);
}
.mdc-top-app-bar__row {
height: var(--header-height);
border-bottom: var(--app-header-border-bottom);
}
.mdc-top-app-bar--fixed-adjust {
padding-top: calc(
var(--header-height, 0px) + var(--safe-area-inset-top, 0px)
);
padding-bottom: var(--safe-area-inset-bottom);
padding-right: var(--safe-area-inset-right);
}
:host([narrow]) .mdc-top-app-bar--fixed-adjust {
padding-left: var(--safe-area-inset-left);
}
.shadow-container {
position: absolute;
top: calc(-1 * var(--header-height));
@@ -114,11 +273,39 @@ export class HaTwoPaneTopAppBarFixed extends HaTopAppBarFixed {
z-index: 1;
transition: box-shadow 200ms linear;
}
.scrolled .shadow-container {
box-shadow: var(--ha-box-shadow-m);
box-shadow: var(
--mdc-top-app-bar-fixed-box-shadow,
0px 2px 4px -1px rgba(0, 0, 0, 0.2),
0px 4px 5px 0px rgba(0, 0, 0, 0.14),
0px 1px 10px 0px rgba(0, 0, 0, 0.12)
);
}
.mdc-top-app-bar {
--mdc-typography-headline6-font-weight: var(--ha-font-weight-normal);
color: var(--app-header-text-color, var(--mdc-theme-on-primary, #fff));
background-color: var(
--app-header-background-color,
var(--mdc-theme-primary)
);
padding-top: var(--safe-area-inset-top);
padding-right: var(--safe-area-inset-right);
transition:
width var(--ha-animation-duration-normal) ease,
padding-left var(--ha-animation-duration-normal) ease,
padding-right var(--ha-animation-duration-normal) ease;
}
:host([narrow]) .mdc-top-app-bar {
padding-left: var(--safe-area-inset-left);
}
@media (prefers-reduced-motion: reduce) {
.mdc-top-app-bar {
transition: 1ms;
}
}
.mdc-top-app-bar--pane.mdc-top-app-bar--fixed-scrolled {
box-shadow: none;
}
#title {
border-right: 1px solid rgba(255, 255, 255, 0.12);
border-inline-end: 1px solid rgba(255, 255, 255, 0.12);
@@ -127,8 +314,7 @@ export class HaTwoPaneTopAppBarFixed extends HaTopAppBarFixed {
flex: 0 0 var(--sidepane-width, 250px);
width: var(--sidepane-width, 250px);
}
.top-app-bar-fixed-adjust--pane {
div.mdc-top-app-bar--pane {
display: flex;
height: calc(
100vh - var(--header-height, 0px) - var(
@@ -137,7 +323,6 @@ export class HaTwoPaneTopAppBarFixed extends HaTopAppBarFixed {
) - var(--safe-area-inset-bottom, 0px)
);
}
.pane {
border-right: 1px solid var(--divider-color);
border-inline-end: 1px solid var(--divider-color);
@@ -149,36 +334,36 @@ export class HaTwoPaneTopAppBarFixed extends HaTopAppBarFixed {
flex-direction: column;
position: relative;
}
.pane .ha-scrollbar {
flex: 1;
}
.pane .footer {
border-top: 1px solid var(--divider-color);
padding-bottom: 8px;
}
.main {
min-height: 100%;
}
.top-app-bar-fixed-adjust--pane .main {
.mdc-top-app-bar--pane .main {
position: relative;
flex: 1;
height: 100%;
}
.top-app-bar-fixed-adjust--pane .content {
.mdc-top-app-bar--pane .content {
height: 100%;
overflow: auto;
}
.mdc-top-app-bar__title {
font-size: var(--ha-font-size-xl);
padding-inline-start: 24px;
padding-inline-end: initial;
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
"ha-two-pane-top-app-bar-fixed": HaTwoPaneTopAppBarFixed;
"ha-two-pane-top-app-bar-fixed": TopAppBarBaseBase;
}
}
+2 -2
View File
@@ -1,8 +1,8 @@
import { type LitElement, css } from "lit";
import { property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { nativeElementInternalsSupported } from "../../common/feature-detect/support-native-element-internals";
import type { Constructor } from "../../types";
import { nativeElementInternalsSupported } from "../../common/feature-detect/support-native-element-internals";
/**
* Minimal interface for the inner wa-input / wa-textarea element.
@@ -339,7 +339,7 @@ export const waInputStyles = css`
min-height: var(--ha-space-5);
margin-block-start: 0;
margin-inline-start: var(--ha-space-3);
font-size: var(--ha-font-size-s);
font-size: var(--ha-font-size-xs);
display: flex;
align-items: center;
color: var(--ha-color-text-secondary);
@@ -227,7 +227,7 @@ class DialogMediaManage extends LitElement {
</ha-list>
`}
${isComponentLoaded(this.hass.config, "hassio")
? html`<ha-tip>
? html`<ha-tip .hass=${this.hass}>
${this.hass.localize(
"ui.components.media-browser.file_management.tip_media_storage",
{
@@ -1,147 +0,0 @@
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import type { HomeAssistant } from "../../types";
import {
brandsUrl,
extractDomainFromBrandUrl,
isBrandUrl,
} from "../../util/brands-url";
const SMALL_THUMBNAIL_THRESHOLD = 16;
const isSvgUrl = (url: string): boolean =>
/\.svg(\?|#|$)/i.test(url) || url.startsWith("data:image/svg+xml");
const resolveThumbnailURL = (
hass: HomeAssistant,
thumbnailUrl: string
): Promise<string> => {
if (isBrandUrl(thumbnailUrl)) {
return Promise.resolve(
brandsUrl(
{
domain: extractDomainFromBrandUrl(thumbnailUrl),
type: "icon",
darkOptimized: hass.themes?.darkMode,
},
hass.auth.data.hassUrl
)
);
}
if (thumbnailUrl.startsWith("/")) {
// Local thumbnails require authentication; fetch and inline as base64.
return hass
.fetchWithAuth(thumbnailUrl)
.then((response) => response.blob())
.then(
(blob) =>
new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () =>
resolve(typeof reader.result === "string" ? reader.result : "");
reader.onerror = (e) => reject(e);
reader.readAsDataURL(blob);
})
);
}
return Promise.resolve(thumbnailUrl);
};
@customElement("ha-media-browser-thumbnail")
export class HaMediaBrowserThumbnail extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property() public url?: string;
@state() private _resolvedUrl?: string;
@state() private _small = false;
@state() private _brand = false;
protected willUpdate(changedProps: PropertyValues): void {
super.willUpdate(changedProps);
if (changedProps.has("url")) {
this._resolve();
}
}
private async _resolve(): Promise<void> {
this._small = false;
this._brand = !!this.url && isBrandUrl(this.url);
if (!this.url) {
this._resolvedUrl = undefined;
return;
}
const requested = this.url;
try {
const resolved = await resolveThumbnailURL(this.hass, requested);
if (requested !== this.url) return;
this._resolvedUrl = resolved;
this._probeSize(resolved);
} catch (_err) {
if (requested === this.url) this._resolvedUrl = undefined;
}
}
private _probeSize(url: string): void {
// SVGs (including brand icons) scale natively; pixelated rendering would
// break vector output.
if (this.url && isBrandUrl(this.url)) return;
if (isSvgUrl(url)) return;
const img = new Image();
img.addEventListener("load", () => {
if (this._resolvedUrl !== url) return;
if (
img.naturalWidth > 0 &&
img.naturalWidth <= SMALL_THUMBNAIL_THRESHOLD
) {
this._small = true;
}
});
img.src = url;
}
protected render(): TemplateResult | typeof nothing {
if (!this._resolvedUrl) return nothing;
return html`
<div
class=${classMap({
image: true,
small: this._small,
brand: this._brand,
})}
style="background-image: url(${this._resolvedUrl})"
></div>
`;
}
static readonly styles: CSSResultGroup = css`
:host {
display: block;
width: 100%;
height: 100%;
}
.image {
width: 100%;
height: 100%;
background-size: var(--ha-media-browser-thumbnail-fit, contain);
background-repeat: no-repeat;
background-position: center;
}
.image.brand {
background-size: 40%;
}
.image.small {
image-rendering: pixelated;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-media-browser-thumbnail": HaMediaBrowserThumbnail;
}
}
@@ -13,6 +13,7 @@ import {
} from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { styleMap } from "lit/directives/style-map";
import { until } from "lit/directives/until";
import { fireEvent } from "../../common/dom/fire_event";
import { slugify } from "../../common/string/slugify";
import { debounce } from "../../common/util/debounce";
@@ -38,6 +39,11 @@ import { showAlertDialog } from "../../dialogs/generic/show-dialog-box";
import { haStyle, haStyleScrollbar } from "../../resources/styles";
import { loadVirtualizer } from "../../resources/virtualizer";
import type { HomeAssistant } from "../../types";
import {
brandsUrl,
extractDomainFromBrandUrl,
isBrandUrl,
} from "../../util/brands-url";
import { documentationUrl } from "../../util/documentation-url";
import "../entity/ha-entity-picker";
import "../ha-alert";
@@ -46,7 +52,6 @@ import "../ha-card";
import "../ha-icon-button";
import "../ha-list";
import "../ha-list-item";
import "./ha-media-browser-thumbnail";
import "../ha-spinner";
import "../ha-svg-icon";
import "../ha-tooltip";
@@ -406,6 +411,12 @@ export class HaMediaPlayerBrowse extends LitElement {
? MediaClassBrowserSettings[currentItem.children_media_class]
: MediaClassBrowserSettings.directory;
const backgroundImage = currentItem.thumbnail
? this._getThumbnailURLorBase64(currentItem.thumbnail).then(
(value) => `url(${value})`
)
: "none";
return html`
${
currentItem.can_play
@@ -420,11 +431,13 @@ export class HaMediaPlayerBrowse extends LitElement {
<div class="header-content">
${currentItem.thumbnail
? html`
<div class="img">
<ha-media-browser-thumbnail
.hass=${this.hass}
.url=${currentItem.thumbnail}
></ha-media-browser-thumbnail>
<div
class="img"
style="background-image: ${until(
backgroundImage,
""
)}"
>
${this.narrow &&
currentItem?.can_play &&
(!this.accept ||
@@ -625,6 +638,12 @@ export class HaMediaPlayerBrowse extends LitElement {
}
private _renderGridItem = (child: MediaPlayerItem): TemplateResult => {
const backgroundImage = child.thumbnail
? this._getThumbnailURLorBase64(child.thumbnail).then(
(value) => `url(${value})`
)
: "none";
return html`
<div class="child" .item=${child} @click=${this._childClicked}>
<ha-card outlined>
@@ -636,13 +655,10 @@ export class HaMediaPlayerBrowse extends LitElement {
"centered-image": ["app", "directory"].includes(
child.media_class
),
"brand-image": isBrandUrl(child.thumbnail),
})} image"
>
<ha-media-browser-thumbnail
.hass=${this.hass}
.url=${child.thumbnail}
></ha-media-browser-thumbnail>
</div>
style="background-image: ${until(backgroundImage, "")}"
></div>
`
: html`
<div class="icon-holder image">
@@ -687,7 +703,13 @@ export class HaMediaPlayerBrowse extends LitElement {
private _renderListItem = (child: MediaPlayerItem): TemplateResult => {
const currentItem = this._currentItem;
const mediaClass = MediaClassBrowserSettings[currentItem!.media_class];
const showImage = mediaClass.show_list_images && !!child.thumbnail;
const backgroundImage =
mediaClass.show_list_images && child.thumbnail
? this._getThumbnailURLorBase64(child.thumbnail).then(
(value) => `url(${value})`
)
: "none";
return html`
<ha-list-item
@@ -695,7 +717,7 @@ export class HaMediaPlayerBrowse extends LitElement {
.item=${child}
.graphic=${mediaClass.show_list_images ? "medium" : "avatar"}
>
${!showImage && !child.can_play
${backgroundImage === "none" && !child.can_play
? html`<ha-svg-icon
.path=${MediaClassBrowserSettings[
child.media_class === "directory"
@@ -709,14 +731,9 @@ export class HaMediaPlayerBrowse extends LitElement {
graphic: true,
thumbnail: mediaClass.show_list_images === true,
})}
style="background-image: ${until(backgroundImage, "")}"
slot="graphic"
>
${showImage
? html`<ha-media-browser-thumbnail
.hass=${this.hass}
.url=${child.thumbnail}
></ha-media-browser-thumbnail>`
: nothing}
${child.can_play
? html`<ha-icon-button
class="play ${classMap({
@@ -736,6 +753,51 @@ export class HaMediaPlayerBrowse extends LitElement {
`;
};
private async _getThumbnailURLorBase64(
thumbnailUrl: string | undefined
): Promise<string> {
if (!thumbnailUrl) {
return "";
}
if (isBrandUrl(thumbnailUrl)) {
// The backend is not aware of the theme used by the users,
// so we rewrite the URL to show a proper icon
return brandsUrl(
{
domain: extractDomainFromBrandUrl(thumbnailUrl),
type: "icon",
darkOptimized: this.hass.themes?.darkMode,
},
this.hass.auth.data.hassUrl
);
}
if (thumbnailUrl.startsWith("/")) {
// Thumbnails served by local API require authentication
return new Promise((resolve, reject) => {
this.hass
.fetchWithAuth(thumbnailUrl!)
// Since we are fetching with an authorization header, we cannot just put the
// URL directly into the document; we need to embed the image. We could do this
// using blob URLs, but then we would need to keep track of them in order to
// release them properly. Instead, we embed the thumbnail using base64.
.then((response) => response.blob())
.then((blob) => {
const reader = new FileReader();
reader.onload = () => {
const result = reader.result;
resolve(typeof result === "string" ? result : "");
};
reader.onerror = (e) => reject(e);
reader.readAsDataURL(blob);
});
});
}
return thumbnailUrl;
}
private _actionClicked = (ev: MouseEvent): void => {
ev.stopPropagation();
const item = (ev.currentTarget as any).item;
@@ -986,20 +1048,14 @@ export class HaMediaPlayerBrowse extends LitElement {
align-items: flex-start;
}
.header-content .img {
position: relative;
height: 175px;
width: 175px;
margin-right: 16px;
background-size: cover;
border-radius: 2px;
overflow: hidden;
transition:
width 0.4s,
height 0.4s;
--ha-media-browser-thumbnail-fit: cover;
}
.header-content .img ha-media-browser-thumbnail {
position: absolute;
inset: 0;
}
.header-info {
display: flex;
@@ -1135,12 +1191,18 @@ export class HaMediaPlayerBrowse extends LitElement {
right: 0;
left: 0;
bottom: 0;
--ha-media-browser-thumbnail-fit: cover;
background-size: cover;
background-repeat: no-repeat;
background-position: center;
}
.centered-image {
margin: 0 8px;
--ha-media-browser-thumbnail-fit: contain;
background-size: contain;
}
.brand-image {
background-size: 40%;
}
.children ha-card .icon-holder {
@@ -1216,21 +1278,17 @@ export class HaMediaPlayerBrowse extends LitElement {
}
ha-list-item .graphic {
position: relative;
background-size: contain;
background-repeat: no-repeat;
background-position: center;
border-radius: var(--ha-border-radius-sm);
overflow: hidden;
display: flex;
align-content: center;
align-items: center;
line-height: initial;
}
ha-list-item .graphic ha-media-browser-thumbnail {
position: absolute;
inset: 0;
}
ha-list-item .graphic .play {
position: absolute;
inset: 0;
margin: auto;
opacity: 0;
transition: all 0.5s;
background-color: rgba(var(--rgb-card-background-color), 0.5);
-2
View File
@@ -99,8 +99,6 @@ export class HaRadioOption extends Radio {
--ha-radio-option-checked-background-color,
var(--ha-color-fill-primary-normal-resting)
);
color: var(--ha-color-fill-primary-loud-resting);
border-color: var(--ha-color-fill-primary-loud-resting);
}
[part~="label"] {
-1
View File
@@ -112,7 +112,6 @@ export class HaTileContainer extends LitElement {
flex-direction: column;
text-align: center;
justify-content: center;
padding: 10px 0;
}
.vertical ::slotted([slot="info"]) {
width: 100%;
+1
View File
@@ -451,6 +451,7 @@ export class HatScriptGraph extends LitElement {
${node.action
? html`<ha-service-icon
slot="icon"
.hass=${this.hass}
.service=${node.action}
></ha-service-icon>`
: nothing}
+4 -5
View File
@@ -95,7 +95,7 @@ export interface TriggerList {
export interface BaseTrigger {
alias?: string;
note?: string;
comment?: string;
/** @deprecated Use `trigger` instead */
platform?: string;
trigger: string;
@@ -241,7 +241,7 @@ export type Trigger = LegacyTrigger | TriggerList | PlatformTrigger;
interface BaseCondition {
condition: string;
alias?: string;
note?: string;
comment?: string;
enabled?: boolean;
options?: Record<string, unknown>;
}
@@ -609,7 +609,7 @@ export interface AutomationClipboard {
export interface BaseSidebarConfig {
delete: () => void;
close: (focus?: boolean) => void;
editNote: () => void;
editComment: () => void;
}
export interface TriggerSidebarConfig extends BaseSidebarConfig {
@@ -653,7 +653,6 @@ export interface ActionSidebarConfig extends BaseSidebarConfig {
disable: () => void;
continueOnError: () => void;
duplicate: () => void;
convert: () => void;
cut: () => void;
copy: () => void;
insertAfter: (value: Action | Action[]) => boolean;
@@ -672,7 +671,7 @@ export interface OptionSidebarConfig extends BaseSidebarConfig {
rename: () => void;
duplicate: () => void;
defaultOption?: boolean;
note?: string;
comment?: string;
}
export interface ScriptFieldSidebarConfig extends BaseSidebarConfig {
+2 -11
View File
@@ -17,7 +17,6 @@ export interface BluetoothDeviceData extends DataTableRowData {
source: string;
time: number;
tx_power: number;
raw: string | null;
}
export interface BluetoothConnectionData extends DataTableRowData {
@@ -59,21 +58,13 @@ export interface BluetoothAllocationsData {
allocated: string[];
}
export type BluetoothScannerMode = "active" | "passive";
export type BluetoothScannerRequestedMode = BluetoothScannerMode | "auto";
export interface BluetoothScannerState {
source: string;
adapter: string;
current_mode: BluetoothScannerMode | null;
requested_mode: BluetoothScannerRequestedMode | null;
current_mode: "active" | "passive" | null;
requested_mode: "active" | "passive" | null;
}
export const isScannerStateMismatch = (state: BluetoothScannerState): boolean =>
state.requested_mode !== "auto" &&
state.current_mode !== state.requested_mode;
export const subscribeBluetoothScannersDetailsUpdates = (
conn: Connection,
store: Store<BluetoothScannersDetails>
+1
View File
@@ -40,6 +40,7 @@ export const createConfigFlow = (
"config/config_entries/flow",
{
handler,
show_advanced_options: Boolean(hass.userData?.showAdvanced),
entry_id,
},
HEADERS
+1 -4
View File
@@ -5,10 +5,7 @@ export interface DataTableFilter {
export type DataTableFilters = Record<string, DataTableFilter>;
export type DataTableFiltersValue =
| string[]
| Record<"key" | string, string[]>
| undefined;
export type DataTableFiltersValue = string[] | { key: string[] } | undefined;
export type DataTableFiltersValues = Record<string, DataTableFiltersValue>;
+1 -1
View File
@@ -13,7 +13,7 @@ import {
export interface DeviceAutomation {
alias?: string;
note?: string;
comment?: string;
device_id: string;
domain: string;
entity_id?: string;
+3 -70
View File
@@ -2,23 +2,16 @@ import { computeAreaName } from "../../common/entity/compute_area_name";
import { computeDeviceNameDisplay } from "../../common/entity/compute_device_name";
import { computeDomain } from "../../common/entity/compute_domain";
import { getDeviceArea } from "../../common/entity/context/get_device_context";
import type { LocalizeFunc } from "../../common/translations/localize";
import { computeRTL } from "../../common/util/compute_rtl";
import type { HaDevicePickerDeviceFilterFunc } from "../../components/device/ha-device-picker";
import type { PickerComboBoxItem } from "../../components/ha-picker-combo-box";
import type { FuseWeightedKey } from "../../resources/fuseMultiTerm";
import type { HomeAssistant } from "../../types";
import type { ConfigEntry } from "../config_entries";
import type { HaEntityPickerEntityFilterFunc } from "../entity/entity";
import type {
EntityRegistryDisplayEntry,
EntityRegistryEntry,
} from "../entity/entity_registry";
import { domainToName } from "../integration";
import {
getDeviceEntityDisplayLookup,
type DeviceEntityDisplayLookup,
type DeviceRegistryEntry,
} from "./device_registry";
export interface DevicePickerItem extends PickerComboBoxItem {
@@ -26,46 +19,6 @@ export interface DevicePickerItem extends PickerComboBoxItem {
domain_name?: string;
}
export interface DeviceAreaLabel {
areaName?: string;
viaDeviceName?: string;
viaDeviceAreaName?: string;
}
export const computeDeviceAreaLabel = (
device: DeviceRegistryEntry,
areas: HomeAssistant["areas"],
devices: HomeAssistant["devices"],
states: HomeAssistant["states"],
localize: LocalizeFunc,
language: HomeAssistant["language"],
translationMetadata: HomeAssistant["translationMetadata"],
viaDeviceEntities?: EntityRegistryEntry[] | EntityRegistryDisplayEntry[]
): DeviceAreaLabel => {
const area = getDeviceArea(device, areas);
const viaDevice = device.via_device_id
? devices[device.via_device_id]
: undefined;
const viaDeviceName = viaDevice
? computeDeviceNameDisplay(viaDevice, localize, states, viaDeviceEntities)
: undefined;
const viaDeviceArea = viaDevice ? getDeviceArea(viaDevice, areas) : undefined;
const viaDeviceAreaName = viaDeviceArea
? computeAreaName(viaDeviceArea)
: undefined;
const isRTL = computeRTL(language, translationMetadata.translations);
const areaName = area
? computeAreaName(area)
: viaDeviceAreaName
? `${viaDeviceAreaName}${isRTL ? " ◂ " : " ▸ "}${viaDeviceName}`
: viaDeviceName || undefined;
return { areaName, viaDeviceName, viaDeviceAreaName };
};
export const deviceComboBoxKeys: FuseWeightedKey[] = [
{
name: "search_labels.deviceName",
@@ -83,14 +36,6 @@ export const deviceComboBoxKeys: FuseWeightedKey[] = [
name: "search_labels.domain",
weight: 4,
},
{
name: "search_labels.viaDeviceName",
weight: 3,
},
{
name: "search_labels.viaDeviceArea",
weight: 3,
},
];
export const getDevices = (
@@ -204,19 +149,9 @@ export const getDevices = (
deviceEntityLookup[device.id]
);
const { areaName, viaDeviceName, viaDeviceAreaName } =
computeDeviceAreaLabel(
device,
hass.areas,
hass.devices,
hass.states,
hass.localize,
hass.language,
hass.translationMetadata,
device.via_device_id
? deviceEntityLookup[device.via_device_id]
: undefined
);
const area = getDeviceArea(device, hass.areas);
const areaName = area ? computeAreaName(area) : undefined;
const configEntry = device.primary_config_entry
? configEntryLookup?.[device.primary_config_entry]
@@ -239,8 +174,6 @@ export const getDevices = (
areaName: areaName || null,
domain: domain || null,
domainName: domainName || null,
viaDeviceName: viaDeviceName || null,
viaDeviceArea: viaDeviceAreaName || null,
},
sorting_label: [primary, areaName, domainName].filter(Boolean).join("_"),
};
+1 -7
View File
@@ -161,10 +161,6 @@ export interface VacuumEntityOptions {
last_seen_segments?: Segment[];
}
export interface DeviceTrackerEntityOptions {
associated_zone?: string | null;
}
export interface EntityRegistryOptions {
number?: NumberEntityOptions;
sensor?: SensorEntityOptions;
@@ -176,7 +172,6 @@ export interface EntityRegistryOptions {
cover?: CoverEntityOptions;
valve?: ValveEntityOptions;
vacuum?: VacuumEntityOptions;
device_tracker?: DeviceTrackerEntityOptions;
switch_as_x?: SwitchAsXEntityOptions;
conversation?: Record<string, unknown>;
"cloud.alexa"?: Record<string, unknown>;
@@ -202,8 +197,7 @@ export interface EntityRegistryEntryUpdateParams {
| LightEntityOptions
| CoverEntityOptions
| ValveEntityOptions
| VacuumEntityOptions
| DeviceTrackerEntityOptions;
| VacuumEntityOptions;
aliases?: (string | null)[];
labels?: string[];
categories?: Record<string, string | null>;
+1
View File
@@ -2,6 +2,7 @@ import type { Connection } from "home-assistant-js-websocket";
import type { ShortcutItem } from "./home_shortcuts";
export interface CoreFrontendUserData {
showAdvanced?: boolean;
showEntityIdPicker?: boolean;
default_panel?: string;
apps_info_dismissed?: boolean;
-12
View File
@@ -1,14 +1,6 @@
import type { HassEntity } from "home-assistant-js-websocket";
import type { HomeAssistant } from "../types";
import type { LovelaceCardFeatureContext } from "../panels/lovelace/card-features/types";
import type { LovelaceCardConfig } from "./lovelace/config/card";
export interface CustomCardSuggestion<
T extends LovelaceCardConfig = LovelaceCardConfig,
> {
label?: string;
config: T;
}
export interface CustomCardEntry {
type: string;
@@ -16,10 +8,6 @@ export interface CustomCardEntry {
description?: string;
preview?: boolean;
documentationURL?: string;
getEntitySuggestion?: (
hass: HomeAssistant,
entityId: string
) => CustomCardSuggestion | CustomCardSuggestion[] | null;
}
export interface CustomBadgeEntry {
+5 -16
View File
@@ -2,10 +2,7 @@ import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import { isComponentLoaded } from "../common/config/is_component_loaded";
import { navigate } from "../common/navigate";
import type { HomeAssistant } from "../types";
import {
subscribeDeviceRegistry,
type DeviceRegistryEntry,
} from "./device/device_registry";
import { subscribeDeviceRegistry } from "./device/device_registry";
import { getThreadDataSetTLV, listThreadDataSets } from "./thread";
export enum NetworkType {
@@ -80,9 +77,9 @@ export const startExternalCommissioning = async (hass: HomeAssistant) => {
});
};
export const watchForNewMatterDevice = (
export const redirectOnNewMatterDevice = (
hass: HomeAssistant,
callback: (device: DeviceRegistryEntry) => void
callback?: () => void
): UnsubscribeFunc => {
let curMatterDevices: Set<string> | undefined;
const unsubDeviceReg = subscribeDeviceRegistry(hass.connection, (entries) => {
@@ -104,7 +101,8 @@ export const watchForNewMatterDevice = (
if (newMatterDevices.length) {
unsubDeviceReg();
curMatterDevices = undefined;
callback(newMatterDevices[0]);
callback?.();
navigate(`/config/devices/device/${newMatterDevices[0].id}`);
}
});
return () => {
@@ -113,15 +111,6 @@ export const watchForNewMatterDevice = (
};
};
export const redirectOnNewMatterDevice = (
hass: HomeAssistant,
callback?: () => void
): UnsubscribeFunc =>
watchForNewMatterDevice(hass, (device) => {
callback?.();
navigate(`/config/devices/device/${device.id}`);
});
export const addMatterDevice = (hass: HomeAssistant) => {
startExternalCommissioning(hass);
};
+3 -3
View File
@@ -15,7 +15,7 @@ import {
mdiPlaylistMusic,
mdiPlayPause,
mdiPodcast,
mdiPowerStandby,
mdiPower,
mdiPowerOff,
mdiPowerOn,
mdiRepeat,
@@ -295,7 +295,7 @@ export const computeMediaControls = (
return supportsFeature(stateObj, MediaPlayerEntityFeature.TURN_ON)
? [
{
icon: mdiPowerStandby,
icon: mdiPower,
action: "turn_on",
},
]
@@ -316,7 +316,7 @@ export const computeMediaControls = (
if (supportsFeature(stateObj, MediaPlayerEntityFeature.TURN_OFF)) {
buttons.push({
icon: assumedState ? mdiPowerOff : mdiPowerStandby,
icon: assumedState ? mdiPowerOff : mdiPower,
action: "turn_off",
});
}
+1
View File
@@ -7,6 +7,7 @@ export const createOptionsFlow = (hass: HomeAssistant, handler: string) =>
"config/config_entries/options/flow",
{
handler,
show_advanced_options: Boolean(hass.userData?.showAdvanced),
}
);
+3 -3
View File
@@ -36,7 +36,7 @@ export const isMaxMode = arrayLiteralIncludes(MODES_MAX);
export const baseActionStruct = object({
alias: optional(string()),
note: optional(string()),
comment: optional(string()),
continue_on_error: optional(boolean()),
enabled: optional(boolean()),
});
@@ -106,7 +106,7 @@ export interface Field {
interface BaseAction {
alias?: string;
note?: string;
comment?: string;
continue_on_error?: boolean;
enabled?: boolean;
}
@@ -197,7 +197,7 @@ export interface ForEachRepeat extends BaseRepeat {
export interface Option {
alias?: string;
note?: string;
comment?: string;
conditions: string | Condition[];
sequence: Action | Action[];
}
+1 -1
View File
@@ -125,7 +125,7 @@ export interface BooleanSelector {
boolean: {} | null;
}
export type AutomationBehaviorTriggerMode = "first" | "all" | "each";
export type AutomationBehaviorTriggerMode = "first" | "last" | "any";
export type AutomationBehaviorConditionMode = "all" | "any";
+1
View File
@@ -16,6 +16,7 @@ export const createSubConfigFlow = (
"config/config_entries/subentries/flow",
{
handler: [configEntryId, subFlowType],
show_advanced_options: Boolean(hass.userData?.showAdvanced),
subentry_id,
},
HEADERS
@@ -167,6 +167,7 @@ export interface DataEntryFlowDialogParams {
entryId?: string;
}) => void;
flowConfig: FlowConfig;
showAdvanced?: boolean;
dialogParentElement?: HTMLElement;
navigateToResult?: boolean;
carryOverDevices?: string[];
@@ -48,6 +48,7 @@ class StepFlowAbort extends LitElement {
showConfigFlowDialog(this.params.dialogParentElement!, {
dialogClosedCallback: this.params.dialogClosedCallback,
startFlowHandler: this.handler,
showAdvanced: this.hass.userData?.showAdvanced,
navigateToResult: this.params.navigateToResult,
});
},
-3
View File
@@ -3,7 +3,6 @@ import type { LocalizeFunc } from "../../common/translations/localize";
import { createSearchParam } from "../../common/url/search-params";
import type { SingleHassServiceTarget } from "../../data/target";
import {
ADD_AUTOMATION_ELEMENT_AREA_TARGET_PARAM,
ADD_AUTOMATION_ELEMENT_DEVICE_TARGET_PARAM,
ADD_AUTOMATION_ELEMENT_QUERY_PARAM,
ADD_AUTOMATION_ELEMENT_ENTITY_TARGET_PARAM,
@@ -106,8 +105,6 @@ export function addToActionHandler(
searchParams[ADD_AUTOMATION_ELEMENT_ENTITY_TARGET_PARAM] = target.entity_id;
} else if (target.device_id) {
searchParams[ADD_AUTOMATION_ELEMENT_DEVICE_TARGET_PARAM] = target.device_id;
} else if (target.area_id) {
searchParams[ADD_AUTOMATION_ELEMENT_AREA_TARGET_PARAM] = target.area_id;
}
const params = (addElement: string) =>
@@ -206,6 +206,8 @@ export class HuiNotificationDrawer extends KeyboardShortcutMixin(LitElement) {
static styles = css`
ha-header-bar {
--mdc-theme-on-primary: var(--primary-text-color);
--mdc-theme-primary: var(--primary-background-color);
--header-bar-padding: var(--safe-area-inset-top, 0px) 0 0
var(--safe-area-inset-left, 0px);
border-bottom: 1px solid var(--divider-color);
+1 -1
View File
@@ -267,7 +267,7 @@ export class QuickBar extends LitElement {
></ha-picker-combo-box>`
: nothing}
${this._showHint
? html`<ha-tip slot="footer"
? html`<ha-tip slot="footer" .hass=${this.hass}
>${this.hass.localize("ui.tips.key_shortcut_quick_search", {
keyboard_shortcut: html`<button
class="link"
@@ -147,6 +147,7 @@ class DialogEditSidebar extends LitElement {
return html`
<ha-items-display-editor
.hass=${this.hass}
.value=${{
order: this._order,
hidden: hiddenPanels,
@@ -343,9 +343,9 @@ export class HaVoiceAssistantSetupDialog extends LitElement {
this._step = this._previousSteps.pop()!;
}
private async _goToNextStep(ev?: CustomEvent) {
private _goToNextStep(ev?: CustomEvent) {
if (ev?.detail?.updateConfig) {
await this._fetchAssistConfiguration();
this._fetchAssistConfiguration();
}
if (ev?.detail?.nextStep) {
this._nextStep = ev.detail.nextStep;
+2 -2
View File
@@ -153,7 +153,7 @@ export class HomeAssistantMain extends LitElement {
/* remove the grey tap highlights in iOS on the fullscreen touch targets */
-webkit-tap-highlight-color: rgba(0, 0, 0, 0);
--ha-sidebar-width: calc(56px + var(--safe-area-inset-left, 0px));
--ha-top-app-bar-width: calc(100% - var(--ha-sidebar-width));
--mdc-top-app-bar-width: calc(100% - var(--ha-sidebar-width));
--safe-area-content-inset-left: 0px;
--safe-area-content-inset-right: var(--safe-area-inset-right);
}
@@ -162,7 +162,7 @@ export class HomeAssistantMain extends LitElement {
}
:host([modal]) {
--ha-sidebar-width: unset;
--ha-top-app-bar-width: 100%;
--mdc-top-app-bar-width: unset;
--safe-area-content-inset-left: var(--safe-area-inset-left);
}
partial-panel-resolver,
+7 -7
View File
@@ -1,8 +1,8 @@
import type { LitElement } from "lit";
import { state } from "lit/decorators";
import { listenMediaQuery } from "../common/dom/media_query";
import type { Constructor } from "../types";
import { isMobileClient } from "../util/is_mobile";
import { listenMediaQuery } from "../common/dom/media_query";
export const MobileAwareMixin = <T extends Constructor<LitElement>>(
superClass: T
@@ -12,16 +12,16 @@ export const MobileAwareMixin = <T extends Constructor<LitElement>>(
protected _isMobileClient = isMobileClient;
protected mobileSizeQuery =
"all and (max-width: 450px), all and (max-height: 500px)";
private _unsubMql?: () => void;
public connectedCallback() {
super.connectedCallback();
this._unsubMql = listenMediaQuery(this.mobileSizeQuery, (matches) => {
this._isMobileSize = matches;
});
this._unsubMql = listenMediaQuery(
"all and (max-width: 450px), all and (max-height: 500px)",
(matches) => {
this._isMobileSize = matches;
}
);
}
public disconnectedCallback() {
+23 -55
View File
@@ -1,25 +1,18 @@
import type { PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import {
HAS_RESOLVED_IANA_TIME_ZONE,
LOCAL_TIME_ZONE,
} from "../common/datetime/resolve-time-zone";
import { LOCAL_TIME_ZONE } from "../common/datetime/resolve-time-zone";
import { fireEvent } from "../common/dom/fire_event";
import type { LocalizeFunc } from "../common/translations/localize";
import "../components/ha-alert";
import "../components/ha-button";
import { COUNTRIES } from "../components/ha-country-picker";
import "../components/ha-form/ha-form";
import type { HaForm } from "../components/ha-form/ha-form";
import type { HaFormSchema } from "../components/ha-form/types";
import "../components/ha-spinner";
import type { ConfigUpdateValues } from "../data/core";
import { saveCoreConfig } from "../data/core";
import { countryCurrency } from "../data/currency";
import { onboardCoreConfigStep } from "../data/onboarding";
import type { HomeAssistant } from "../types";
import type { HomeAssistant, ValueChangedEvent } from "../types";
import { getLocalLanguage } from "../util/common-translation";
import "./onboarding-location";
@@ -35,9 +28,7 @@ class OnboardingCoreConfig extends LitElement {
private _elevation = "0";
@state() private _timeZone: ConfigUpdateValues["time_zone"] = LOCAL_TIME_ZONE;
@state() private _timeZoneDetected = HAS_RESOLVED_IANA_TIME_ZONE;
private _timeZone: ConfigUpdateValues["time_zone"] = LOCAL_TIME_ZONE;
private _language: ConfigUpdateValues["language"] = getLocalLanguage();
@@ -51,29 +42,7 @@ class OnboardingCoreConfig extends LitElement {
@state() private _skipCore = false;
@query("ha-form") private _form?: HaForm;
private _schema = memoizeOne((includeTimeZone: boolean): HaFormSchema[] => [
{
name: "country",
required: true,
selector: { country: null },
},
...(includeTimeZone
? ([
{
name: "time_zone",
required: true,
selector: { timezone: null },
},
] satisfies HaFormSchema[])
: []),
]);
private _computeLabel = (schema: HaFormSchema) =>
this.hass.localize(
`ui.panel.config.core.section.core.core_config.${schema.name}` as any
);
@query("ha-country-picker") private _countryPicker?: HTMLElement;
protected render(): TemplateResult {
if (!this._location) {
@@ -99,17 +68,17 @@ class OnboardingCoreConfig extends LitElement {
)}
</p>
<ha-form
<ha-country-picker
class="flex"
.hass=${this.hass}
.data=${{
country: this._country ?? "",
time_zone: this._timeZone,
}}
.schema=${this._schema(!this._timeZoneDetected)}
.computeLabel=${this._computeLabel}
.label=${this.hass.localize(
"ui.panel.config.core.section.core.core_config.country"
) || "Country"}
required
.disabled=${this._working}
@value-changed=${this._handleFormChanged}
></ha-form>
.value=${this._countryValue}
@value-changed=${this._handleCountryChanged}
></ha-country-picker>
<div class="footer">
<ha-button @click=${this._save} .disabled=${this._working}>
@@ -130,12 +99,12 @@ class OnboardingCoreConfig extends LitElement {
});
}
private _handleFormChanged(ev: CustomEvent) {
const value = ev.detail.value as { country?: string; time_zone?: string };
this._country = value.country || undefined;
if (value.time_zone) {
this._timeZone = value.time_zone;
}
private get _countryValue() {
return this._country || "";
}
private _handleCountryChanged(ev: ValueChangedEvent<string>) {
this._country = ev.detail.value;
}
private async _locationChanged(ev) {
@@ -154,12 +123,11 @@ class OnboardingCoreConfig extends LitElement {
}
if (ev.detail.value.timezone) {
this._timeZone = ev.detail.value.timezone;
this._timeZoneDetected = true;
}
if (ev.detail.value.unit_system) {
this._unitSystem = ev.detail.value.unit_system;
}
if (this._country && this._timeZoneDetected) {
if (this._country) {
this._skipCore = true;
this._save(ev);
return;
@@ -177,11 +145,11 @@ class OnboardingCoreConfig extends LitElement {
fireEvent(this, "onboarding-progress", { increase: 0.5 });
await this.updateComplete;
setTimeout(() => this._form?.focus(), 100);
setTimeout(() => this._countryPicker!.focus(), 100);
}
private async _save(ev) {
if (!this._location || !this._country || !this._timeZone) {
if (!this._location || !this._country) {
return;
}
ev.preventDefault();
@@ -198,7 +166,7 @@ class OnboardingCoreConfig extends LitElement {
this._unitSystem || ["US", "MM", "LR"].includes(this._country)
? "us_customary"
: "metric",
time_zone: this._timeZone,
time_zone: this._timeZone || "UTC",
currency: this._currency || countryCurrency[this._country] || "EUR",
country: this._country,
language: this._language,
+11 -14
View File
@@ -190,20 +190,16 @@ class PanelCalendar extends SubscribeMixin(LitElement) {
.label=${this.hass.localize("ui.common.refresh")}
@click=${this._handleRefresh}
></ha-icon-button>
${showPane
? html`<ha-list slot="pane" multi>${calendarItems}</ha-list>${this
.hass.user?.is_admin
? html`<ha-list-item
graphic="icon"
slot="pane-footer"
@click=${this._addCalendar}
>
<ha-svg-icon .path=${mdiPlus} slot="graphic"></ha-svg-icon>
${this.hass.localize(
"ui.components.calendar.create_calendar"
)}
</ha-list-item>`
: nothing}`
${showPane && this.hass.user?.is_admin
? html`<ha-list slot="pane" multi}>${calendarItems}</ha-list>
<ha-list-item
graphic="icon"
slot="pane-footer"
@click=${this._addCalendar}
>
<ha-svg-icon .path=${mdiPlus} slot="graphic"></ha-svg-icon>
${this.hass.localize("ui.components.calendar.create_calendar")}
</ha-list-item>`
: nothing}
<ha-full-calendar
add-fab
@@ -342,6 +338,7 @@ class PanelCalendar extends SubscribeMixin(LitElement) {
private _addCalendar = async (): Promise<void> => {
showConfigFlowDialog(this, {
startFlowHandler: "local_calendar",
showAdvanced: this.hass.userData?.showAdvanced,
manifest: await fetchIntegrationManifest(this.hass, "local_calendar"),
dialogClosedCallback: ({ flowFinished }) => {
if (flowFinished) {
+3 -3
View File
@@ -175,7 +175,7 @@ class PanelClimate extends LitElement {
position: fixed;
top: 0;
width: calc(
var(--ha-top-app-bar-width, 100%) - var(
var(--mdc-top-app-bar-width, 100%) - var(
--safe-area-inset-right,
0px
)
@@ -191,7 +191,7 @@ class PanelClimate extends LitElement {
}
:host([narrow]) .header {
width: calc(
var(--ha-top-app-bar-width, 100%) - var(
var(--mdc-top-app-bar-width, 100%) - var(
--safe-area-inset-left,
0px
) - var(--safe-area-inset-right, 0px)
@@ -200,7 +200,7 @@ class PanelClimate extends LitElement {
}
:host([scrolled]) .header {
box-shadow: var(
--bar-box-shadow,
--mdc-top-app-bar-fixed-box-shadow,
0px 2px 4px -1px rgba(0, 0, 0, 0.2),
0px 4px 5px 0px rgba(0, 0, 0, 0.14),
0px 1px 10px 0px rgba(0, 0, 0, 0.12)
@@ -112,6 +112,7 @@ export class HaConfigApplicationCredentials extends LitElement {
showNarrow: true,
template: (credential) => html`
<ha-icon-overflow-menu
.hass=${this.hass}
narrow
.items=${[
{
@@ -31,6 +31,7 @@ class SupervisorAppInfoDashboard extends LitElement {
<supervisor-app-info
.narrow=${this.narrow}
.route=${this.route}
.hass=${this.hass}
.addon=${this.addon}
.controlEnabled=${this.controlEnabled}
></supervisor-app-info>
@@ -92,15 +92,14 @@ import {
showConfirmationDialog,
} from "../../../../../dialogs/generic/show-dialog-box";
import { showMoreInfoDialog } from "../../../../../dialogs/more-info/show-ha-more-info-dialog";
import { MobileAwareMixin } from "../../../../../mixins/mobile-aware-mixin";
import { mdiHomeAssistant } from "../../../../../resources/home-assistant-logo-svg";
import { haStyle } from "../../../../../resources/styles";
import type { Route } from "../../../../../types";
import { bytesToString } from "../../../../../util/bytes-to-string";
import { getAppDisplayName } from "../../common/app";
import "../../components/supervisor-apps-state";
import "../../components/supervisor-apps-tag";
import "../components/supervisor-app-metric";
import "../../components/supervisor-apps-tag";
import "../../components/supervisor-apps-state";
import { extractChangelog } from "../util/supervisor-app";
import "./supervisor-app-system-managed";
@@ -124,7 +123,7 @@ const RATING_ICON = {
const POLL_INTERVAL_SECONDS = 5;
@customElement("supervisor-app-info")
class SupervisorAppInfo extends MobileAwareMixin(LitElement) {
class SupervisorAppInfo extends LitElement {
@property({ type: Boolean }) public narrow = false;
@property({ attribute: false }) public route!: Route;
@@ -164,9 +163,6 @@ class SupervisorAppInfo extends MobileAwareMixin(LitElement) {
private _pollInterval?: number;
protected mobileSizeQuery =
"all and (max-width: 1120px), all and (max-height: 500px)";
private get _currentAddon(): HassioAddonDetails | StoreAddonDetails {
return this._addon || this.addon;
}
@@ -867,11 +863,11 @@ class SupervisorAppInfo extends MobileAwareMixin(LitElement) {
`
: nothing}
<div
class="app ${this._isMobileSize || !this._currentAddon.version
class="app ${this.narrow || !this._currentAddon.version
? "column"
: ""}"
>
${this._isMobileSize || !this._currentAddon.version
${this.narrow || !this._currentAddon.version
? html`
${this._renderInfoCard()}
${this._currentAddon.version
@@ -1,225 +0,0 @@
import type { CSSResultGroup } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { consume, type ContextType } from "@lit/context";
import { customElement, state } from "lit/decorators";
import {
mdiPalette,
mdiPlayCircleOutline,
mdiPlaylistCheck,
mdiRobotOutline,
mdiScriptTextOutline,
} from "@mdi/js";
import { computeAreaName } from "../../../common/entity/compute_area_name";
import { fireEvent } from "../../../common/dom/fire_event";
import "../../../components/ha-adaptive-dialog";
import "../../../components/ha-list";
import "../../../components/ha-list-item";
import "../../../components/ha-svg-icon";
import {
areasContext,
internationalizationContext,
} from "../../../data/context";
import type { SceneEntities } from "../../../data/scene";
import { showSceneEditor } from "../../../data/scene";
import {
addToActionHandler,
type AddToActionKey,
} from "../../../dialogs/more-info/add-to";
import { haStyle, haStyleDialog } from "../../../resources/styles";
import type { AreaAddToDialogParams } from "./show-dialog-area-add-to";
@customElement("dialog-area-add-to")
class DialogAreaAddTo extends LitElement {
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n!: ContextType<typeof internationalizationContext>;
@state()
@consume({ context: areasContext, subscribe: true })
private _areas!: ContextType<typeof areasContext>;
@state() private _params?: AreaAddToDialogParams;
@state() private _open = false;
public showDialog(params: AreaAddToDialogParams): void {
this._params = params;
this._open = true;
}
public closeDialog(): void {
this._open = false;
}
private _dialogClosed(): void {
this._params = undefined;
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
protected render() {
if (!this._params) {
return nothing;
}
return html`
<ha-adaptive-dialog
.open=${this._open}
header-title=${this._i18n.localize(
"ui.dialogs.more_info_control.add_to.title"
)}
@closed=${this._dialogClosed}
>
${this._renderOptions()}
</ha-adaptive-dialog>
`;
}
private _renderOptions() {
if (!this._params) {
return nothing;
}
const area = this._areas[this._params.areaId];
const areaName = computeAreaName(area) || this._params.areaId;
return html`
<h3 class="section-header">
${this._i18n.localize(
"ui.panel.config.devices.automation.automations_heading"
)}
</h3>
<ha-list>
${this._renderActionItem(
"automation_trigger",
mdiRobotOutline,
"ui.dialogs.more_info_control.add_to.actions.automation_trigger",
areaName
)}
${this._renderActionItem(
"automation_condition",
mdiPlaylistCheck,
"ui.dialogs.more_info_control.add_to.actions.automation_condition",
areaName
)}
${this._renderActionItem(
"automation_action",
mdiPlayCircleOutline,
"ui.dialogs.more_info_control.add_to.actions.automation_action",
areaName
)}
</ha-list>
<h3 class="section-header">
${this._i18n.localize("ui.panel.config.devices.script.scripts_heading")}
</h3>
<ha-list>
${this._renderActionItem(
"script_action",
mdiScriptTextOutline,
"ui.dialogs.more_info_control.add_to.actions.script_action",
areaName
)}
</ha-list>
${this._renderSceneSection(areaName)}
`;
}
private _renderSceneSection(areaName: string) {
if (!this._params?.entityIds.length) {
return nothing;
}
return html`
<h3 class="section-header">
${this._i18n.localize("ui.panel.config.devices.scene.scenes_heading")}
</h3>
<ha-list>
<ha-list-item
graphic="icon"
@click=${this._handleCreateScene}
data-dialog="close"
>
<ha-svg-icon slot="graphic" .path=${mdiPalette}></ha-svg-icon>
${this._i18n.localize(
"ui.dialogs.more_info_control.add_to.actions.scene",
{ target: areaName }
)}
</ha-list-item>
</ha-list>
`;
}
private _renderActionItem(
key: AddToActionKey,
path: string,
translationKey:
| "ui.dialogs.more_info_control.add_to.actions.automation_trigger"
| "ui.dialogs.more_info_control.add_to.actions.automation_condition"
| "ui.dialogs.more_info_control.add_to.actions.automation_action"
| "ui.dialogs.more_info_control.add_to.actions.script_action",
areaName: string
) {
return html`
<ha-list-item
graphic="icon"
data-type=${key}
@click=${this._handleAction}
data-dialog="close"
>
<ha-svg-icon slot="graphic" .path=${path}></ha-svg-icon>
${this._i18n.localize(translationKey, { target: areaName })}
</ha-list-item>
`;
}
private _handleAction(ev: Event) {
if (!this._params) {
return;
}
const key = (ev.currentTarget as HTMLElement).dataset
.type as AddToActionKey;
this.closeDialog();
addToActionHandler(key, { area_id: this._params.areaId });
}
private _handleCreateScene() {
if (!this._params) {
return;
}
const entities: SceneEntities = {};
for (const entityId of this._params.entityIds) {
entities[entityId] = "";
}
this.closeDialog();
showSceneEditor({ entities }, this._params.areaId);
}
static get styles(): CSSResultGroup {
return [
haStyle,
haStyleDialog,
css`
ha-adaptive-dialog {
--dialog-content-padding: 0;
}
.section-header {
padding: var(--ha-space-2) var(--ha-space-4) 0;
margin: 0;
font-size: var(--ha-font-size-m);
font-weight: var(--ha-font-weight-medium);
color: var(--secondary-text-color);
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"dialog-area-add-to": DialogAreaAddTo;
}
}
+23 -229
View File
@@ -1,21 +1,6 @@
import { consume } from "@lit/context";
import {
mdiDelete,
mdiDevices,
mdiDotsVertical,
mdiImagePlus,
mdiPalette,
mdiPencil,
mdiPlus,
mdiRobot,
mdiScriptText,
mdiShape,
mdiTools,
} from "@mdi/js";
import type {
HassEntity,
UnsubscribeFunc,
} from "home-assistant-js-websocket/dist/types";
import { mdiDelete, mdiDotsVertical, mdiImagePlus, mdiPencil } from "@mdi/js";
import type { HassEntity } from "home-assistant-js-websocket/dist/types";
import type { CSSResultGroup, PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
@@ -25,7 +10,7 @@ import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import { computeDeviceNameDisplay } from "../../../common/entity/compute_device_name";
import { computeDomain } from "../../../common/entity/compute_domain";
import { computeStateName } from "../../../common/entity/compute_state_name";
import { goBack, navigate } from "../../../common/navigate";
import { goBack } from "../../../common/navigate";
import { caseInsensitiveStringCompare } from "../../../common/string/compare";
import { slugify } from "../../../common/string/slugify";
import { groupBy } from "../../../common/util/group-by";
@@ -33,13 +18,11 @@ import { afterNextRender } from "../../../common/util/render-status";
import "../../../components/ha-button";
import "../../../components/ha-card";
import "../../../components/ha-dropdown";
import type { HASSDomCurrentTargetEvent } from "../../../common/dom/fire_event";
import type { HaDropdownSelectEvent } from "../../../components/ha-dropdown";
import "../../../components/ha-dropdown-item";
import "../../../components/ha-icon-button";
import "../../../components/ha-icon-next";
import "../../../components/ha-list";
import "../../../components/ha-svg-icon";
import "../../../components/ha-tooltip";
import type { AreaRegistryEntry } from "../../../data/area/area_registry";
import {
@@ -55,7 +38,6 @@ import {
computeEntityRegistryName,
sortEntityRegistryByName,
} from "../../../data/entity/entity_registry";
import { subscribeLabFeature } from "../../../data/labs";
import type { SceneEntity } from "../../../data/scene";
import type { ScriptEntity } from "../../../data/script";
import type { RelatedResult } from "../../../data/search";
@@ -64,15 +46,9 @@ import { showConfirmationDialog } from "../../../dialogs/generic/show-dialog-box
import { showMoreInfoDialog } from "../../../dialogs/more-info/show-ha-more-info-dialog";
import "../../../layouts/hass-error-screen";
import "../../../layouts/hass-subpage";
import { SubscribeMixin } from "../../../mixins/subscribe-mixin";
import { haStyle } from "../../../resources/styles";
import type { HomeAssistant } from "../../../types";
import { isHelperDomain } from "../helpers/const";
import "../../logbook/ha-logbook";
import {
loadAreaAddToDialog,
showAreaAddToDialog,
} from "./show-dialog-area-add-to";
import {
loadAreaRegistryDetailDialog,
showAreaRegistryDetailDialog,
@@ -83,60 +59,8 @@ declare interface NameAndEntity<EntityType extends HassEntity> {
entity: EntityType;
}
type AreaQuickLinkKey =
| "devices"
| "entities"
| "helpers"
| "automations"
| "scenes"
| "scripts";
const NAVIGATION_ACTIONS: {
value: string;
path: string;
icon: string;
countKey: AreaQuickLinkKey;
}[] = [
{
value: "navigate-devices",
path: "/config/devices/dashboard",
icon: mdiDevices,
countKey: "devices",
},
{
value: "navigate-entities",
path: "/config/entities",
icon: mdiShape,
countKey: "entities",
},
{
value: "navigate-helpers",
path: "/config/helpers",
icon: mdiTools,
countKey: "helpers",
},
{
value: "navigate-automations",
path: "/config/automation/dashboard",
icon: mdiRobot,
countKey: "automations",
},
{
value: "navigate-scenes",
path: "/config/scene/dashboard",
icon: mdiPalette,
countKey: "scenes",
},
{
value: "navigate-scripts",
path: "/config/script/dashboard",
icon: mdiScriptText,
countKey: "scripts",
},
] as const;
@customElement("ha-config-area-page")
class HaConfigAreaPage extends SubscribeMixin(LitElement) {
class HaConfigAreaPage extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public areaId!: string;
@@ -145,14 +69,14 @@ class HaConfigAreaPage extends SubscribeMixin(LitElement) {
@property({ attribute: "is-wide", type: Boolean }) public isWide = false;
@property({ attribute: false }) public showAdvanced = false;
@state()
@consume({ context: fullEntitiesContext, subscribe: true })
_entityReg: EntityRegistryEntry[] = [];
@state() private _related?: RelatedResult;
@state() private _newTriggersConditions = false;
private _logbookTime = { recent: 86400 };
private _memberships = memoizeOne(
@@ -204,35 +128,9 @@ class HaConfigAreaPage extends SubscribeMixin(LitElement) {
.concat(memberships.indirectEntities.map((entry) => entry.entity_id))
);
private _getQuickLinkCounts = memoizeOne(
(
memberships: {
devices: DeviceRegistryEntry[];
entities: EntityRegistryEntry[];
indirectEntities: EntityRegistryEntry[];
},
related?: RelatedResult
) => {
const allEntityIds = this._allEntities(memberships);
const entityIds = related?.entity ?? allEntityIds;
return {
devices: related?.device?.length ?? memberships.devices.length,
entities: entityIds.length,
helpers: entityIds.filter((entityId) =>
isHelperDomain(computeDomain(entityId))
).length,
automations: related?.automation?.length ?? 0,
scenes: related?.scene?.length ?? 0,
scripts: related?.script?.length ?? 0,
};
}
);
protected firstUpdated(changedProps: PropertyValues<this>) {
super.firstUpdated(changedProps);
loadAreaRegistryDetailDialog();
loadAreaAddToDialog();
}
protected updated(changedProps: PropertyValues<this>) {
@@ -242,23 +140,6 @@ class HaConfigAreaPage extends SubscribeMixin(LitElement) {
}
}
// When new_triggers_conditions labs feature is promoted, this whole method can be removed.
protected hassSubscribe(): (UnsubscribeFunc | Promise<UnsubscribeFunc>)[] {
if (!isComponentLoaded(this.hass!.config, "automation")) {
return [];
}
return [
subscribeLabFeature(
this.hass!.connection,
"automation",
"new_triggers_conditions",
(feature) => {
this._newTriggersConditions = feature.enabled;
}
),
];
}
protected render() {
if (!this.hass.areas || !this.hass.devices || !this.hass.entities) {
return nothing;
@@ -281,10 +162,6 @@ class HaConfigAreaPage extends SubscribeMixin(LitElement) {
this._entityReg
);
const { devices, entities } = memberships;
const quickLinkCounts = this._getQuickLinkCounts(
memberships,
this._related
);
// Pre-compute the entity and device names, so we can sort by them
if (devices) {
@@ -368,21 +245,6 @@ class HaConfigAreaPage extends SubscribeMixin(LitElement) {
.path=${mdiDotsVertical}
></ha-icon-button>
${NAVIGATION_ACTIONS.map(
(action) => html`
<ha-dropdown-item value=${action.value}>
<ha-svg-icon slot="icon" .path=${action.icon}></ha-svg-icon>
${this.hass.localize(
`ui.panel.config.areas.quick_links.${action.countKey}`,
{ count: quickLinkCounts[action.countKey] }
)}
<ha-icon-next slot="details"></ha-icon-next>
</ha-dropdown-item>
`
)}
<wa-divider></wa-divider>
<ha-dropdown-item value="edit" .data=${area}>
<ha-svg-icon slot="icon" .path=${mdiPencil}> </ha-svg-icon>
${this.hass.localize("ui.panel.config.areas.edit_settings")}
@@ -409,41 +271,15 @@ class HaConfigAreaPage extends SubscribeMixin(LitElement) {
class="img-edit-btn"
></ha-icon-button>
</div>`
: nothing}
${area.picture && !this._newTriggersConditions
? nothing
: html`<div class="action-buttons">
${area.picture
? nothing
: html`<ha-button
appearance="filled"
.entry=${area}
@click=${this._showSettings}
>
<ha-svg-icon
.path=${mdiImagePlus}
slot="start"
></ha-svg-icon>
${this.hass.localize(
"ui.panel.config.areas.add_picture"
)}
</ha-button>`}
${this._newTriggersConditions
? html`<ha-button
appearance="filled"
variant="brand"
@click=${this._showAddToDialog}
>
<ha-svg-icon
slot="start"
.path=${mdiPlus}
></ha-svg-icon>
${this.hass.localize(
"ui.dialogs.more_info_control.add_to.title"
)}
</ha-button>`
: nothing}
</div>`}
: html`<ha-button
appearance="filled"
size="small"
.entry=${area}
@click=${this._showSettings}
>
<ha-svg-icon .path=${mdiImagePlus} slot="start"></ha-svg-icon>
${this.hass.localize("ui.panel.config.areas.add_picture")}
</ha-button>`}
<ha-card
outlined
.header=${this.hass.localize("ui.panel.config.devices.caption")}
@@ -776,39 +612,9 @@ class HaConfigAreaPage extends SubscribeMixin(LitElement) {
this._related = await findRelated(this.hass, "area", this.areaId);
}
private _showAddToDialog() {
const area = this.hass.areas[this.areaId];
if (!area) {
return;
}
showAreaAddToDialog(this, {
areaId: area.area_id,
entityIds: this._areaEntityIds,
});
}
private get _areaEntityIds(): string[] {
const memberships = this._memberships(
this.areaId,
Object.values(this.hass.devices),
this._entityReg
);
return this._allEntities(memberships);
}
private _handleMenuAction(
ev: HaDropdownSelectEvent<string, AreaRegistryEntry>
) {
private _handleMenuAction(ev: HaDropdownSelectEvent) {
const action = ev.detail?.item?.value;
const entry = ev.detail?.item?.data;
const navAction = NAVIGATION_ACTIONS.find((a) => a.value === action);
if (navAction) {
navigate(`${navAction.path}?historyBack=1&area=${this.areaId}`);
return;
}
const entry = (ev.detail?.item as any)?.data as AreaRegistryEntry;
switch (action) {
case "edit":
this._openDialog(entry);
@@ -819,19 +625,15 @@ class HaConfigAreaPage extends SubscribeMixin(LitElement) {
}
}
private _showSettings(
ev: HASSDomCurrentTargetEvent<
HTMLButtonElement & { entry: AreaRegistryEntry }
>
) {
this._openDialog(ev.currentTarget.entry);
private _showSettings(ev: MouseEvent) {
const entry: AreaRegistryEntry = (ev.currentTarget! as any).entry;
this._openDialog(entry);
}
private _openEntity(
ev: HASSDomCurrentTargetEvent<HTMLElement & { entity: EntityRegistryEntry }>
) {
private _openEntity(ev) {
const entry: EntityRegistryEntry = (ev.currentTarget as any).entity;
showMoreInfoDialog(this, {
entityId: ev.currentTarget.entity.entity_id,
entityId: entry.entity_id,
});
}
@@ -873,14 +675,6 @@ class HaConfigAreaPage extends SubscribeMixin(LitElement) {
font-weight: var(--ha-font-weight-medium);
color: var(--secondary-text-color);
}
.action-buttons {
display: flex;
gap: var(--ha-space-2);
flex-wrap: wrap;
justify-content: space-around;
}
img {
border-radius: var(
--ha-card-border-radius,
@@ -13,6 +13,8 @@ class HaConfigAreas extends HassRouterPage {
@property({ attribute: "is-wide", type: Boolean }) public isWide = false;
@property({ attribute: false }) public showAdvanced = false;
protected routerOptions: RouterOptions = {
defaultPage: "dashboard",
routes: {
@@ -35,6 +37,7 @@ class HaConfigAreas extends HassRouterPage {
pageEl.narrow = this.narrow;
pageEl.isWide = this.isWide;
pageEl.showAdvanced = this.showAdvanced;
pageEl.route = this.routeTail;
}
}
@@ -1,19 +0,0 @@
import { fireEvent } from "../../../common/dom/fire_event";
export interface AreaAddToDialogParams {
areaId: string;
entityIds: string[];
}
export const loadAreaAddToDialog = () => import("./dialog-area-add-to");
export const showAreaAddToDialog = (
element: HTMLElement,
params: AreaAddToDialogParams
): void => {
fireEvent(element, "show-dialog", {
dialogTag: "dialog-area-add-to",
dialogImport: loadAreaAddToDialog,
dialogParams: params,
});
};
@@ -1,196 +0,0 @@
import type { HassServiceTarget } from "home-assistant-js-websocket";
import { computeDomain } from "../../../../common/entity/compute_domain";
import { computeObjectId } from "../../../../common/entity/compute_object_id";
import {
ACTION_BUILDING_BLOCKS,
ACTION_COMBINED_BLOCKS,
} from "../../../../data/action";
import type { Action, ServiceAction } from "../../../../data/script";
import type { HomeAssistant } from "../../../../types";
import { getAutomationActionType } from "./ha-automation-action-row";
type FieldSelector = Record<string, unknown> | undefined;
const getSelectorType = (selector: FieldSelector): string | undefined => {
if (!selector) {
return undefined;
}
const keys = Object.keys(selector);
return keys.length === 1 ? keys[0] : undefined;
};
const getSelectOptionValues = (
selector: FieldSelector
): string[] | undefined => {
const config = selector?.select as
| { options?: readonly (string | { value: string })[] }
| null
| undefined;
if (!config?.options) {
return undefined;
}
return config.options.map((opt) =>
typeof opt === "string" ? opt : opt.value
);
};
/**
* A value is compatible with the new field if either:
* - the new field has no selector (unknown shape accept),
* - the old field had no selector but the new one does (best effort accept),
* - the selector types match. For `select` selectors, additionally require
* that every supplied value is present in the new option list.
*/
const isFieldValueCompatible = (
value: unknown,
oldSelector: FieldSelector,
newSelector: FieldSelector
): boolean => {
const newType = getSelectorType(newSelector);
if (newType === undefined) {
return true;
}
const oldType = getSelectorType(oldSelector);
if (oldType !== undefined && oldType !== newType) {
return false;
}
if (newType === "select") {
const allowed = getSelectOptionValues(newSelector);
if (!allowed) {
return true;
}
const allowedSet = new Set(allowed);
const values = Array.isArray(value) ? value : [value];
return values.every((v) => typeof v === "string" && allowedSet.has(v));
}
return true;
};
const filterTargetEntitiesByDomain = (
target: HassServiceTarget,
domain: string | undefined
): HassServiceTarget => {
if (!domain || target.entity_id === undefined) {
return target;
}
const entityIds = Array.isArray(target.entity_id)
? target.entity_id
: [target.entity_id];
const filtered = entityIds.filter((id) => computeDomain(id) === domain);
const { entity_id: _entityId, ...rest } = target;
if (filtered.length === 0) {
return rest;
}
return { ...rest, entity_id: filtered };
};
export const BASE_ACTION_FIELDS = [
"alias",
"note",
"enabled",
"continue_on_error",
] as const;
const BUILDING_BLOCK_TYPES = new Set<string>([
...ACTION_BUILDING_BLOCKS,
...ACTION_COMBINED_BLOCKS,
]);
export const isBuildingBlockAction = (action: Action): boolean => {
const type = getAutomationActionType(action);
return type !== undefined && BUILDING_BLOCK_TYPES.has(type);
};
/**
* Encode an action as a stable picker key.
* - Service actions `domain.service` (always contains a dot).
* - Everything else the action type identifier (no dot).
*/
export const getActionKey = (action: Action): string | undefined => {
const type = getAutomationActionType(action);
if (type === "service") {
return (action as ServiceAction).action || undefined;
}
return type;
};
const isServiceKey = (key: string) => key.includes(".");
/** Build a fresh action with default content for the given picker key. */
export const buildActionFromKey = (key: string): Action => {
if (isServiceKey(key)) {
return {
action: key,
metadata: {},
} as ServiceAction;
}
const elClass = customElements.get(`ha-automation-action-${key}`) as
| (CustomElementConstructor & { defaultConfig?: Action })
| undefined;
if (elClass?.defaultConfig) {
return { ...elClass.defaultConfig };
}
return { [key]: {} } as Action;
};
const isServiceAction = (action: Action): action is ServiceAction =>
"action" in action && !!(action as ServiceAction).action;
/**
* Merge fields from `oldAction` into `newAction` that are still compatible.
* Behavior: copy base fields if old has them, and for service service copy
* `target` and any `data` keys that the new service still supports.
*/
export const convertAction = (
oldAction: Action,
newAction: Action,
services: HomeAssistant["services"]
): Action => {
const merged: Action = { ...newAction };
for (const field of BASE_ACTION_FIELDS) {
const oldValue = (oldAction as Record<string, unknown>)[field];
if (oldValue !== undefined) {
(merged as Record<string, unknown>)[field] = oldValue;
}
}
if (isServiceAction(oldAction) && isServiceAction(merged)) {
if (oldAction.target) {
const newDomain = merged.action
? computeDomain(merged.action)
: undefined;
merged.target = filterTargetEntitiesByDomain(oldAction.target, newDomain);
}
if (oldAction.data && merged.action && oldAction.action) {
const newDomain = computeDomain(merged.action);
const newService = computeObjectId(merged.action);
const newFields = services[newDomain]?.[newService]?.fields;
const oldDomain = computeDomain(oldAction.action);
const oldService = computeObjectId(oldAction.action);
const oldFields = services[oldDomain]?.[oldService]?.fields;
if (newFields) {
const carried: Record<string, unknown> = {};
for (const [key, value] of Object.entries(oldAction.data)) {
const newField = newFields[key];
if (
newField &&
isFieldValueCompatible(
value,
oldFields?.[key]?.selector,
newField.selector
)
) {
carried[key] = value;
}
}
if (Object.keys(carried).length > 0) {
merged.data = carried;
}
}
}
}
return merged;
};
@@ -1,400 +0,0 @@
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
import { consume, type ContextType } from "@lit/context";
import { mdiArrowRight, mdiRoomService } from "@mdi/js";
import { css, html, LitElement, nothing } from "lit";
import { customElement, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import type { LocalizeFunc } from "../../../../common/translations/localize";
import "../../../../components/ha-alert";
import "../../../../components/ha-button";
import "../../../../components/ha-combo-box-item";
import "../../../../components/ha-dialog";
import "../../../../components/ha-dialog-footer";
import "../../../../components/ha-generic-picker";
import type { PickerComboBoxItem } from "../../../../components/ha-picker-combo-box";
import type { PickerValueRenderer } from "../../../../components/ha-picker-field";
import "../../../../components/ha-service-icon";
import "../../../../components/ha-svg-icon";
import "../../../../components/radio/ha-radio-group";
import "../../../../components/radio/ha-radio-option";
import {
ACTION_BUILDING_BLOCKS,
ACTION_COMBINED_BLOCKS,
ACTION_ICONS,
YAML_ONLY_ACTION_TYPES,
} from "../../../../data/action";
import {
internationalizationContext,
servicesContext,
} from "../../../../data/context";
import { domainToName } from "../../../../data/integration";
import { DialogMixin } from "../../../../dialogs/dialog-mixin";
import type { HomeAssistant } from "../../../../types";
import {
buildActionFromKey,
convertAction,
getActionKey,
} from "./convert-action";
import type { ConvertActionDialogParams } from "./show-dialog-convert-action";
interface ConvertItem extends PickerComboBoxItem {
isService: boolean;
}
const EXCLUDED_TYPES = new Set<string>([
...ACTION_BUILDING_BLOCKS,
...ACTION_COMBINED_BLOCKS,
...YAML_ONLY_ACTION_TYPES,
// Generic catch-all action types covered by the services list below
"service",
// Umbrella repeat — not a leaf type
"repeat",
]);
@customElement("dialog-convert-action")
class DialogConvertAction extends DialogMixin<ConvertActionDialogParams>(
LitElement
) {
@state() private _pickedKey?: string;
@state() private _mode: "current" | "new" = "current";
@state()
@consume({ context: internationalizationContext, subscribe: true })
private _i18n!: ContextType<typeof internationalizationContext>;
@state()
@consume({ context: servicesContext, subscribe: true })
private _services!: ContextType<typeof servicesContext>;
public connectedCallback(): void {
super.connectedCallback();
if (this.params) {
this._pickedKey = getActionKey(this.params.currentAction);
this._mode = "current";
}
}
protected render() {
if (!this.params) {
return nothing;
}
const currentKey = getActionKey(this.params.currentAction);
const canCommit =
this._pickedKey !== undefined && this._pickedKey !== currentKey;
return html`
<ha-dialog
open
header-title=${this._i18n.localize(
"ui.panel.config.automation.editor.actions.convert_dialog.title"
)}
>
<div class="content">
${currentKey
? html`
<div class="preview">
${this._renderActionChip(currentKey)}
<ha-svg-icon
class="arrow"
.path=${mdiArrowRight}
></ha-svg-icon>
${this._pickedKey && this._pickedKey !== currentKey
? this._renderActionChip(this._pickedKey)
: "?"}
</div>
`
: nothing}
<ha-generic-picker
required
.value=${this._pickedKey}
.getItems=${this._getItems}
.rowRenderer=${this._rowRenderer}
.valueRenderer=${this._valueRenderer(
this._i18n.localize,
this._services
)}
.notFoundLabel=${this._i18n.localize(
"ui.panel.config.automation.editor.actions.convert_dialog.no_matches"
)}
@value-changed=${this._pickedKeyChanged}
></ha-generic-picker>
<ha-alert alert-type="warning">
${this._i18n.localize(
"ui.panel.config.automation.editor.actions.convert_dialog.warning"
)}
</ha-alert>
<ha-radio-group
name="mode"
.value=${this._mode}
@change=${this._modeChanged}
>
<ha-radio-option value="current">
${this._i18n.localize(
"ui.panel.config.automation.editor.actions.convert_dialog.mode_current"
)}
</ha-radio-option>
<ha-radio-option value="new">
${this._i18n.localize(
"ui.panel.config.automation.editor.actions.convert_dialog.mode_new"
)}
</ha-radio-option>
</ha-radio-group>
</div>
<ha-dialog-footer slot="footer">
<ha-button
slot="secondaryAction"
appearance="plain"
@click=${this.closeDialog}
>
${this._i18n.localize("ui.common.cancel")}
</ha-button>
<ha-button
slot="primaryAction"
.disabled=${!canCommit}
@click=${this._convert}
>
${this._i18n.localize(
"ui.panel.config.automation.editor.actions.convert_dialog.convert"
)}
</ha-button>
</ha-dialog-footer>
</ha-dialog>
`;
}
private _items = memoizeOne(
(
localize: LocalizeFunc,
services: HomeAssistant["services"]
): ConvertItem[] => {
const items: ConvertItem[] = [];
for (const type of Object.keys(ACTION_ICONS)) {
if (EXCLUDED_TYPES.has(type)) {
continue;
}
const label =
localize(
`ui.panel.config.automation.editor.actions.type.${type}.label` as any
) || type;
items.push({
id: type,
primary: label,
icon_path: ACTION_ICONS[type as keyof typeof ACTION_ICONS],
isService: false,
sorting_label: `0_${label}`,
});
}
if (services) {
for (const domain of Object.keys(services)) {
const domainName = domainToName(localize, domain);
for (const service of Object.keys(services[domain])) {
const serviceId = `${domain}.${service}`;
const def = services[domain][service];
const serviceName =
localize(
`component.${domain}.services.${service}.name` as any,
def.description_placeholders
) ||
def.name ||
service;
const description =
localize(
`component.${domain}.services.${service}.description` as any,
def.description_placeholders
) ||
def.description ||
"";
items.push({
id: serviceId,
primary: `${domainName}: ${serviceName}`,
secondary: description,
isService: true,
search_labels: {
serviceId,
domainName,
serviceName,
description,
},
sorting_label: `1_${domainName}_${serviceName}`,
});
}
}
}
return items;
}
);
private _getItems = () => this._items(this._i18n.localize, this._services);
private _rowRenderer: RenderItemFunction<PickerComboBoxItem> = (
item,
index
) => {
const convertItem = item as ConvertItem;
return html`
<ha-combo-box-item type="button" .borderTop=${index !== 0}>
${convertItem.isService
? html`<ha-service-icon
slot="start"
.service=${item.id}
></ha-service-icon>`
: html`<ha-svg-icon
slot="start"
.path=${item.icon_path}
></ha-svg-icon>`}
<span slot="headline">${item.primary}</span>
${item.secondary
? html`<span slot="supporting-text">${item.secondary}</span>`
: nothing}
</ha-combo-box-item>
`;
};
private _valueRenderer = memoizeOne(
(
localize: LocalizeFunc,
services: HomeAssistant["services"]
): PickerValueRenderer =>
(value) => {
if (value.includes(".")) {
const [domain, service] = value.split(".");
const def = services?.[domain]?.[service];
const domainName = domainToName(localize, domain);
const serviceName =
localize(
`component.${domain}.services.${service}.name` as any,
def?.description_placeholders
) ||
def?.name ||
service;
return html`
<ha-service-icon slot="start" .service=${value}></ha-service-icon>
<span slot="headline">${domainName}: ${serviceName}</span>
`;
}
const iconPath = ACTION_ICONS[value as keyof typeof ACTION_ICONS];
const label =
localize(
`ui.panel.config.automation.editor.actions.type.${value}.label` as any
) || value;
return html`
${iconPath
? html`<ha-svg-icon slot="start" .path=${iconPath}></ha-svg-icon>`
: html`<ha-svg-icon
slot="start"
.path=${mdiRoomService}
></ha-svg-icon>`}
<span slot="headline">${label}</span>
`;
}
);
private _renderActionChip(key: string) {
const localize = this._i18n.localize;
if (key.includes(".")) {
const [domain, service] = key.split(".");
const def = this._services?.[domain]?.[service];
const domainName = domainToName(localize, domain);
const serviceName =
localize(
`component.${domain}.services.${service}.name` as any,
def?.description_placeholders
) ||
def?.name ||
service;
return html`
<div class="chip">
<ha-service-icon .service=${key}></ha-service-icon>
<span>${domainName}: ${serviceName}</span>
</div>
`;
}
const iconPath = ACTION_ICONS[key as keyof typeof ACTION_ICONS];
const label =
localize(
`ui.panel.config.automation.editor.actions.type.${key}.label` as any
) || key;
return html`
<div class="chip">
<ha-svg-icon .path=${iconPath || mdiRoomService}></ha-svg-icon>
<span>${label}</span>
</div>
`;
}
private _pickedKeyChanged = (ev: CustomEvent) => {
ev.stopPropagation();
this._pickedKey = ev.detail?.value || undefined;
};
private _modeChanged = (ev: Event) => {
const value = (ev.target as HTMLInputElement).value;
if (value === "current" || value === "new") {
this._mode = value;
}
};
private _convert = () => {
if (!this.params || !this._pickedKey) {
return;
}
const newAction = buildActionFromKey(this._pickedKey);
const merged = convertAction(
this.params.currentAction,
newAction,
this._services
);
if (this._mode === "new") {
this.params.duplicateConvert(merged);
} else {
this.params.convert(merged);
}
this.closeDialog();
};
static styles = css`
ha-dialog {
--mdc-dialog-min-width: min(560px, 100vw);
}
.content {
display: flex;
flex-direction: column;
gap: var(--ha-space-3);
}
.preview {
display: flex;
align-items: center;
gap: var(--ha-space-2);
flex-wrap: wrap;
}
.preview .chip {
display: inline-flex;
align-items: center;
gap: var(--ha-space-2);
padding: var(--ha-space-1) var(--ha-space-3);
border-radius: 999px;
background-color: var(--secondary-background-color);
min-width: 0;
}
.preview .chip span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.preview .arrow {
color: var(--secondary-text-color);
flex-shrink: 0;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"dialog-convert-action": DialogConvertAction;
}
}
@@ -107,7 +107,7 @@ export default class HaAutomationActionEditor extends LitElement {
ev.stopPropagation();
const value = {
...(this.action.alias ? { alias: this.action.alias } : {}),
...(this.action.note ? { note: this.action.note } : {}),
...(this.action.comment ? { comment: this.action.comment } : {}),
...ev.detail.value,
};
fireEvent(this, "value-changed", { value });
@@ -20,7 +20,6 @@ import {
mdiPlusCircleMultipleOutline,
mdiRenameBox,
mdiStopCircleOutline,
mdiSwapHorizontal,
} from "@mdi/js";
import deepClone from "deep-clone-simple";
import type { HassServiceTarget } from "home-assistant-js-websocket";
@@ -91,10 +90,8 @@ import { showEditorToast } from "../editor-toast";
import "../ha-automation-editor-warning";
import { overflowStyles, rowStyles } from "../styles";
import "../target/ha-automation-row-targets";
import { isBuildingBlockAction } from "./convert-action";
import "./ha-automation-action-editor";
import type HaAutomationActionEditor from "./ha-automation-action-editor";
import { showConvertActionDialog } from "./show-dialog-convert-action";
import "./types/ha-automation-action-choose";
import "./types/ha-automation-action-condition";
import "./types/ha-automation-action-delay";
@@ -300,8 +297,8 @@ export default class HaAutomationActionRow extends LitElement {
?.target
: undefined;
const noteTooltipText = truncateWithEllipsis(
this.action.note?.trim() || "",
const commentTooltipText = truncateWithEllipsis(
this.action.comment?.trim() || "",
250
);
@@ -311,6 +308,7 @@ export default class HaAutomationActionRow extends LitElement {
<ha-service-icon
slot="leading-icon"
class="action-icon"
.hass=${this.hass}
.service=${this.action.action}
></ha-service-icon>
`
@@ -339,18 +337,18 @@ export default class HaAutomationActionRow extends LitElement {
serviceTargetSpec
)
: nothing}
${noteTooltipText
${commentTooltipText
? html`
<ha-svg-icon
id="note-icon"
id="comment-icon"
.path=${mdiCommentTextOutline}
.label=${this.hass.localize(
"ui.panel.config.automation.editor.note.label"
"ui.panel.config.automation.editor.comment.label"
)}
class="note-indicator"
class="comment-indicator"
></ha-svg-icon
><ha-tooltip for="note-icon"
><p>${noteTooltipText}</p></ha-tooltip
><ha-tooltip for="comment-icon"
><p>${commentTooltipText}</p></ha-tooltip
>
`
: nothing}
@@ -409,11 +407,11 @@ export default class HaAutomationActionRow extends LitElement {
)
)}
</ha-dropdown-item>
<ha-dropdown-item value="edit_note">
<ha-dropdown-item value="edit_comment">
<ha-svg-icon slot="icon" .path=${mdiCommentEditOutline}></ha-svg-icon>
${this._renderOverflowLabel(
this.hass.localize(
`ui.panel.config.automation.editor.note.${this.action.note ? "edit" : "add"}`
`ui.panel.config.automation.editor.comment.${this.action.comment ? "edit" : "add"}`
)
)}
</ha-dropdown-item>
@@ -522,16 +520,6 @@ export default class HaAutomationActionRow extends LitElement {
></ha-dropdown-item>
`
: nothing}
${!isBuildingBlockAction(this.action)
? html`<ha-dropdown-item value="convert" .disabled=${this.disabled}>
<ha-svg-icon slot="icon" .path=${mdiSwapHorizontal}></ha-svg-icon>
${this._renderOverflowLabel(
this.hass.localize(
"ui.panel.config.automation.editor.actions.convert"
)
)}
</ha-dropdown-item>`
: nothing}
<ha-dropdown-item
value="toggle_yaml_mode"
@@ -953,25 +941,25 @@ export default class HaAutomationActionRow extends LitElement {
}
};
private _editNoteAction = async (): Promise<void> => {
const note = await showPromptDialog(this, {
private _editCommentAction = async (): Promise<void> => {
const comment = await showPromptDialog(this, {
title: this.hass.localize(
`ui.panel.config.automation.editor.note.${this.action.note ? "edit" : "add"}`
`ui.panel.config.automation.editor.comment.${this.action.comment ? "edit" : "add"}`
),
inputLabel: this.hass.localize(
"ui.panel.config.automation.editor.note.label"
"ui.panel.config.automation.editor.comment.label"
),
inputType: "string",
defaultValue: this.action.note,
defaultValue: this.action.comment,
confirmText: this.hass.localize("ui.common.submit"),
multiline: true,
});
if (note !== null) {
if (comment !== null) {
const value = { ...this.action };
if (note === "") {
delete value.note;
if (comment === "") {
delete value.comment;
} else {
value.note = note;
value.comment = comment;
}
fireEvent(this, "value-changed", {
value,
@@ -989,24 +977,6 @@ export default class HaAutomationActionRow extends LitElement {
fireEvent(this, "duplicate");
};
private _convertAction = () => {
showConvertActionDialog(this, {
currentAction: this.action,
convert: (newAction) => {
fireEvent(this, "value-changed", { value: newAction });
if (this._selected && this.optionsInSidebar) {
this.openSidebar(newAction);
} else if (this._yamlMode) {
this._actionEditor?.yamlEditor?.setValue(newAction);
}
},
duplicateConvert: (newAction) => {
this._insertAfter(newAction);
},
});
};
private _insertAfter = (value: Action | Action[]) => {
if (ensureArray(value).some((val) => !isAction(val))) {
return false;
@@ -1119,7 +1089,7 @@ export default class HaAutomationActionRow extends LitElement {
rename: () => {
this._renameAction();
},
editNote: this._editNoteAction,
editComment: this._editCommentAction,
toggleYamlMode: () => {
this._toggleYamlMode();
this.openSidebar();
@@ -1132,7 +1102,6 @@ export default class HaAutomationActionRow extends LitElement {
paste: this._pasteAction,
pasteAvailable: this._pasteAvailable,
duplicate: this._duplicateAction,
convert: this._convertAction,
insertAfter: this._insertAfter,
run: this._runAction,
config: {
@@ -1216,8 +1185,8 @@ export default class HaAutomationActionRow extends LitElement {
case "rename":
this._renameAction();
break;
case "edit_note":
this._editNoteAction();
case "edit_comment":
this._editCommentAction();
break;
case "duplicate":
this._duplicateAction();
@@ -1237,9 +1206,6 @@ export default class HaAutomationActionRow extends LitElement {
case "move_down":
this._moveDown();
break;
case "convert":
this._convertAction();
break;
case "toggle_yaml_mode":
this._toggleYamlMode(ev.target as HTMLElement);
break;
@@ -143,7 +143,6 @@ export default class HaAutomationAction extends AutomationSortableListMixin<Acti
const addActionTargetFromQuery = getAddAutomationElementTargetFromQuery(
this.hass.states,
this.hass.devices,
this.hass.areas,
"action"
);
@@ -1,21 +0,0 @@
import { fireEvent } from "../../../../common/dom/fire_event";
import type { Action } from "../../../../data/script";
export interface ConvertActionDialogParams {
currentAction: Action;
convert: (newAction: Action) => void;
duplicateConvert: (newAction: Action) => void;
}
const loadDialog = () => import("./dialog-convert-action");
export const showConvertActionDialog = (
element: HTMLElement,
dialogParams: ConvertActionDialogParams
): void => {
fireEvent(element, "show-dialog", {
dialogTag: "dialog-convert-action",
dialogImport: loadDialog,
dialogParams,
});
};
@@ -130,7 +130,6 @@ import "./add-automation-element/ha-automation-add-items";
import "./add-automation-element/ha-automation-add-search";
import type { AddAutomationElementDialogParams } from "./show-add-automation-element-dialog";
import {
ADD_AUTOMATION_ELEMENT_AREA_TARGET_PARAM,
ADD_AUTOMATION_ELEMENT_DEVICE_TARGET_PARAM,
ADD_AUTOMATION_ELEMENT_ENTITY_TARGET_PARAM,
ADD_AUTOMATION_ELEMENT_QUERY_PARAM,
@@ -312,7 +311,6 @@ class DialogAddAutomationElement
const queryTarget = getAddAutomationElementTargetFromQuery(
this.hass.states,
this.hass.devices,
this.hass.areas,
params.type
);
this._openedFromQuery = !!queryTarget;
@@ -322,7 +320,6 @@ class DialogAddAutomationElement
searchParams.delete(ADD_AUTOMATION_ELEMENT_QUERY_PARAM);
searchParams.delete(ADD_AUTOMATION_ELEMENT_ENTITY_TARGET_PARAM);
searchParams.delete(ADD_AUTOMATION_ELEMENT_DEVICE_TARGET_PARAM);
searchParams.delete(ADD_AUTOMATION_ELEMENT_AREA_TARGET_PARAM);
mainWindow.history.replaceState(
mainWindow.history.state,
"",
@@ -1638,6 +1635,7 @@ class DialogAddAutomationElement
result.push({
icon: html`
<ha-service-icon
.hass=${this.hass}
.service=${`${dmn}.${service}`}
></ha-service-icon>
`,
@@ -1956,6 +1954,7 @@ class DialogAddAutomationElement
items[domain].items.push({
icon: html`
<ha-service-icon
.hass=${this.hass}
.service=${`${domain}.${serviceName}`}
></ha-service-icon>
`,
@@ -95,6 +95,7 @@ class DialogAutomationMode extends LitElement implements HassDialog {
.value=${this._newMode}
@value-changed=${this._modeChanged}
.maxColumns=${1}
.hass=${this.hass}
></ha-select-box>
${isMaxMode(this._newMode)
@@ -123,7 +123,7 @@ export default class HaAutomationConditionEditor extends LitElement {
ev.stopPropagation();
const value = {
...(this.condition.alias ? { alias: this.condition.alias } : {}),
...(this.condition.note ? { note: this.condition.note } : {}),
...(this.condition.comment ? { comment: this.condition.comment } : {}),
...ev.detail.value,
};
fireEvent(this, "value-changed", { value });
@@ -44,7 +44,6 @@ import type { HaAutomationRow } from "../../../../components/automation/ha-autom
import "../../../../components/automation/ha-automation-row-event-chip";
import "../../../../components/automation/ha-automation-row-live-test";
import type { LiveTestState } from "../../../../components/automation/ha-automation-row-live-test";
import "../../../../components/ha-alert";
import "../../../../components/ha-card";
import "../../../../components/ha-condition-icon";
import "../../../../components/ha-dropdown";
@@ -154,10 +153,7 @@ export default class HaAutomationConditionRow extends LitElement {
@state() private _selected = false;
@state() private _liveTestResult: {
state: LiveTestState;
message?: string;
} = { state: "unknown" };
@state() private _liveTestResult: LiveTestState = "unknown";
@state()
@consume({ context: fullEntitiesContext, subscribe: true })
@@ -205,8 +201,8 @@ export default class HaAutomationConditionRow extends LitElement {
const conditionTargetSpec =
this.conditionDescriptions[this.condition.condition]?.target;
const noteTooltipText = truncateWithEllipsis(
this.condition.note?.trim() || "",
const commentTooltipText = truncateWithEllipsis(
this.condition.comment?.trim() || "",
250
);
@@ -227,17 +223,19 @@ export default class HaAutomationConditionRow extends LitElement {
conditionTargetSpec
)
: nothing}
${this.condition.note?.trim()
${this.condition.comment?.trim()
? html`
<ha-svg-icon
id="note-icon"
id="comment-icon"
.path=${mdiCommentTextOutline}
.label=${this.hass.localize(
"ui.panel.config.automation.editor.note.label"
"ui.panel.config.automation.editor.comment.label"
)}
class="note-indicator"
class="comment-indicator"
></ha-svg-icon>
<ha-tooltip for="note-icon"><p>${noteTooltipText}</p></ha-tooltip>
<ha-tooltip for="comment-icon"
><p>${commentTooltipText}</p></ha-tooltip
>
`
: nothing}
</h3>
@@ -287,11 +285,11 @@ export default class HaAutomationConditionRow extends LitElement {
)
)}
</ha-dropdown-item>
<ha-dropdown-item value="edit_note">
<ha-dropdown-item value="edit_comment">
<ha-svg-icon slot="icon" .path=${mdiCommentEditOutline}></ha-svg-icon>
${this._renderOverflowLabel(
this.hass.localize(
`ui.panel.config.automation.editor.note.${this.condition.note ? "edit" : "add"}`
`ui.panel.config.automation.editor.comment.${this.condition.comment ? "edit" : "add"}`
)
)}
</ha-dropdown-item>
@@ -531,13 +529,10 @@ export default class HaAutomationConditionRow extends LitElement {
>${this._renderRow()}
<ha-automation-row-live-test
slot="icons"
.state=${this.condition.condition !== "trigger"
? this._liveTestResult.state
: "unknown"}
.state=${this._liveTestResult}
.label=${this.hass.localize(
`ui.panel.config.automation.editor.conditions.live_test_state.${this.condition.condition !== "trigger" ? this._liveTestResult.state : "unknown"}`
`ui.panel.config.automation.editor.conditions.live_test_state.${this._liveTestResult}`
)}
.message=${this._liveTestResult.message}
></ha-automation-row-live-test
></ha-automation-row>`
: html`
@@ -624,12 +619,7 @@ export default class HaAutomationConditionRow extends LitElement {
}
private _resetSubscription() {
this._liveTestResult = {
state: "unknown",
message: this.hass.localize(
"ui.panel.config.automation.editor.conditions.live_test_state.unknown"
),
};
this._liveTestResult = "unknown";
if (this._conditionUnsub) {
this._conditionUnsub.then((unsub) => unsub());
this._conditionUnsub = undefined;
@@ -654,12 +644,7 @@ export default class HaAutomationConditionRow extends LitElement {
if (result.error) {
this._handleLiveTestError(result.error);
} else {
this._liveTestResult = {
state: result.result ? "pass" : "fail",
message: this.hass.localize(
`ui.panel.config.automation.editor.conditions.testing_${result.result ? "pass" : "error"}`
),
};
this._liveTestResult = result.result ? "pass" : "fail";
}
},
this.condition
@@ -676,12 +661,7 @@ export default class HaAutomationConditionRow extends LitElement {
private _handleLiveTestError(error: any) {
const invalid =
typeof error !== "string" && error.code === "invalid_format";
this._liveTestResult = {
state: invalid ? "invalid" : "unknown",
message: this.hass.localize(
`ui.panel.config.automation.editor.conditions.${invalid ? "invalid_condition" : "live_test_state.unknown"}`
),
};
this._liveTestResult = invalid ? "invalid" : "unknown";
}
private _onValueChange(event: CustomEvent) {
@@ -848,25 +828,25 @@ export default class HaAutomationConditionRow extends LitElement {
}
};
private _editNoteCondition = async (): Promise<void> => {
const note = await showPromptDialog(this, {
private _editCommentCondition = async (): Promise<void> => {
const comment = await showPromptDialog(this, {
title: this.hass.localize(
`ui.panel.config.automation.editor.note.${this.condition.note ? "edit" : "add"}`
`ui.panel.config.automation.editor.comment.${this.condition.comment ? "edit" : "add"}`
),
inputLabel: this.hass.localize(
"ui.panel.config.automation.editor.note.label"
"ui.panel.config.automation.editor.comment.label"
),
inputType: "string",
defaultValue: this.condition.note,
defaultValue: this.condition.comment,
confirmText: this.hass.localize("ui.common.submit"),
multiline: true,
});
if (note !== null) {
if (comment !== null) {
const value = { ...this.condition };
if (note === "") {
delete value.note;
if (comment === "") {
delete value.comment;
} else {
value.note = note;
value.comment = comment;
}
fireEvent(this, "value-changed", {
value,
@@ -1021,7 +1001,7 @@ export default class HaAutomationConditionRow extends LitElement {
rename: () => {
this._renameCondition();
},
editNote: this._editNoteCondition,
editComment: this._editCommentCondition,
toggleYamlMode: () => {
this._toggleYamlMode();
this.openSidebar();
@@ -1093,8 +1073,8 @@ export default class HaAutomationConditionRow extends LitElement {
case "rename":
this._renameCondition();
break;
case "edit_note":
this._editNoteCondition();
case "edit_comment":
this._editCommentCondition();
break;
case "duplicate":
this._duplicateCondition();
@@ -128,7 +128,6 @@ export default class HaAutomationCondition extends AutomationSortableListMixin<C
const addConditionTargetFromQuery = getAddAutomationElementTargetFromQuery(
this.hass.states,
this.hass.devices,
this.hass.areas,
"condition"
);
@@ -22,7 +22,7 @@ import type { HomeAssistant } from "../../../../../types";
const numericStateConditionStruct = object({
alias: optional(string()),
note: optional(string()),
comment: optional(string()),
condition: literal("numeric_state"),
entity_id: optional(string()),
attribute: optional(string()),
@@ -25,7 +25,7 @@ import type { ConditionElement } from "../ha-automation-condition-row";
const stateConditionStruct = object({
alias: optional(string()),
note: optional(string()),
comment: optional(string()),
condition: literal("state"),
entity_id: optional(string()),
attribute: optional(string()),
@@ -270,7 +270,7 @@ class DialogNewAutomation extends LitElement {
: nothing}
${processedBlueprints.length > 0
? html`
<ha-tip>
<ha-tip .hass=${this.hass}>
<a
href=${documentationUrl(
this.hass,
@@ -6,9 +6,9 @@ import "../../../components/ha-button";
import "../../../components/ha-settings-row";
import { internationalizationContext } from "../../../data/context";
@customElement("ha-automation-note")
export class HaAutomationNote extends LitElement {
@property() public note!: string;
@customElement("ha-automation-comment")
export class HaAutomationComment extends LitElement {
@property() public comment!: string;
@state()
@consume({ context: internationalizationContext, subscribe: true })
@@ -18,9 +18,9 @@ export class HaAutomationNote extends LitElement {
return html`
<ha-settings-row narrow>
<div class="heading" slot="heading">
<span class="title" id="note-label">
<span class="title" id="comment-label">
${this._i18n.localize(
"ui.panel.config.automation.editor.note.label"
"ui.panel.config.automation.editor.comment.label"
)}
</span>
<ha-button
@@ -31,13 +31,13 @@ export class HaAutomationNote extends LitElement {
${this._i18n.localize("ui.common.edit")}
</ha-button>
</div>
<p aria-labelledby="note-label">${this.note}</p>
<p aria-labelledby="comment-label">${this.comment}</p>
</ha-settings-row>
`;
}
private _handleClick() {
fireEvent(this, "edit-note");
fireEvent(this, "edit-comment");
}
static styles = css`
@@ -70,10 +70,10 @@ export class HaAutomationNote extends LitElement {
declare global {
interface HTMLElementTagNameMap {
"ha-automation-note": HaAutomationNote;
"ha-automation-comment": HaAutomationComment;
}
interface HASSDomEvents {
"edit-note": undefined;
"edit-comment": undefined;
}
}

Some files were not shown because too many files have changed in this diff Show More