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
282 changed files with 5552 additions and 7535 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"
}
}

Before

Width:  |  Height:  |  Size: 1.3 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 1.2 KiB

Before

Width:  |  Height:  |  Size: 2.3 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "home-assistant-frontend"
version = "20260527.2"
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
);
-13
View File
@@ -17,19 +17,6 @@ export interface NavigateOptions {
// max time to wait for dialogs to close before navigating
const DIALOG_WAIT_TIMEOUT = 500;
/**
* Stash a destination URL in the current history entry's state. If the page
* is refreshed while a dialog is open, urlSyncMixin will navigate to this URL
* on load instead of cleaning up the stale dialog state by going back.
* The current URL is not changed.
*/
export const setRefreshUrl = (path: string) => {
mainWindow.history.replaceState(
{ ...mainWindow.history.state, refreshUrl: path },
""
);
};
/**
* Ensures all dialogs are closed before navigation.
* Returns true if navigation can proceed, false if a dialog refused to close.
@@ -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);
+47 -82
View File
@@ -14,7 +14,6 @@ import type {
ECElementEvent,
LegendComponentOption,
LineSeriesOption,
TooltipOption,
XAXisOption,
YAXisOption,
} from "echarts/types/dist/shared";
@@ -30,59 +29,22 @@ import type { HASSDomEvent } from "../../common/dom/fire_event";
import { fireEvent } from "../../common/dom/fire_event";
import { listenMediaQuery } from "../../common/dom/media_query";
import { afterNextRender } from "../../common/util/render-status";
import { filterXSS } from "../../common/util/xss";
import { uiContext } from "../../data/context";
import type { Themes } from "../../data/ws-themes";
import type {
ECOption,
HaECOption,
HaECSeries,
HaECSeriesItem,
HaTooltipOption,
} from "../../resources/echarts/echarts";
import type { ECOption } from "../../resources/echarts/echarts";
import type { HomeAssistant, HomeAssistantUI } from "../../types";
import { isMac } from "../../util/is_mac";
import "../chips/ha-assist-chip";
import "../ha-icon-button";
import { formatTimeLabel } from "./axis-label";
import { downSampleLineData } from "./down-sample";
import { wrapLitTooltipFormatter } from "./lit-tooltip-formatter";
export const MIN_TIME_BETWEEN_UPDATES = 60 * 5 * 1000;
const LEGEND_OVERFLOW_LIMIT = 10;
const LEGEND_OVERFLOW_LIMIT_MOBILE = 6;
const DOUBLE_TAP_TIME = 300;
type RawSeriesOption = Exclude<
NonNullable<ECOption["series"]>,
readonly unknown[]
>;
const toEChartsFormatter = (
fn: ReturnType<typeof wrapLitTooltipFormatter>
): NonNullable<TooltipOption["formatter"]> =>
fn as NonNullable<TooltipOption["formatter"]>;
const convertHaTooltipFormatter = (tooltip: HaTooltipOption): TooltipOption => {
const { formatter, ...rest } = tooltip;
const next: TooltipOption = { ...rest };
if (typeof formatter === "function") {
next.formatter = toEChartsFormatter(wrapLitTooltipFormatter(formatter));
} else if (formatter !== undefined) {
next.formatter = formatter;
}
return next;
};
const processSeriesTooltipFormatter = (s: HaECSeriesItem): RawSeriesOption => {
if (s.tooltip && typeof s.tooltip.formatter === "function") {
return {
...s,
tooltip: convertHaTooltipFormatter(s.tooltip),
} as RawSeriesOption;
}
return s as RawSeriesOption;
};
export type CustomLegendOption = ECOption["legend"] & {
type: "custom";
data?: {
@@ -104,9 +66,9 @@ export class HaChartBase extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public data: HaECSeries = [];
@property({ attribute: false }) public data: ECOption["series"] = [];
@property({ attribute: false }) public options?: HaECOption;
@property({ attribute: false }) public options?: ECOption;
@property({ type: String }) public height?: string;
@@ -652,7 +614,7 @@ export class HaChartBase extends LitElement {
// Return an array of all IDs associated with the legend item of the primaryId
private _getAllIdsFromLegend(
options: HaECOption | undefined,
options: ECOption | undefined,
primaryId: string
): string[] {
if (!options) return [primaryId];
@@ -672,7 +634,7 @@ export class HaChartBase extends LitElement {
// Parses the options structure and adds all ids of unselected legend items to hiddenDatasets.
// No known need to remove items at this time.
private _updateHiddenStatsFromOptions(options: HaECOption | undefined) {
private _updateHiddenStatsFromOptions(options: ECOption | undefined) {
if (!options) return;
const legend = ensureArray(this.options?.legend || [])[0] as
| LegendComponentOption
@@ -795,34 +757,22 @@ export class HaChartBase extends LitElement {
xAxis,
};
if (options.tooltip) {
const isMobile = window.matchMedia(
"all and (max-width: 450px), all and (max-height: 500px)"
).matches;
// Shallow-copy each tooltip object so wrap/mobile mutations don't leak
// back into the caller's options.tooltip reference (callers may cache the
// options object via memoizeOne, in which case in-place mutation would
// pollute that cache across chart instances).
const processTooltip = (tooltip: HaTooltipOption): TooltipOption => {
const next = convertHaTooltipFormatter(tooltip);
if (isMobile) {
// mobile charts are full width so we need to confine the tooltip to the chart
next.confine = true;
next.appendTo = undefined;
next.triggerOn = "click";
}
return next;
};
const haTooltip = options.tooltip;
const processedTooltip = Array.isArray(haTooltip)
? haTooltip.map(processTooltip)
: processTooltip(haTooltip);
return {
...options,
tooltip: processedTooltip,
} as ECOption;
const isMobile = window.matchMedia(
"all and (max-width: 450px), all and (max-height: 500px)"
).matches;
if (isMobile && options.tooltip) {
// mobile charts are full width so we need to confine the tooltip to the chart
const tooltips = Array.isArray(options.tooltip)
? options.tooltip
: [options.tooltip];
tooltips.forEach((tooltip) => {
tooltip.confine = true;
tooltip.appendTo = undefined;
tooltip.triggerOn = "click";
});
options.tooltip = tooltips;
}
return options as ECOption;
return options;
}
private _createTheme(style: CSSStyleDeclaration) {
@@ -1006,16 +956,30 @@ 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;
let result = {
...s,
data,
} as HaECSeriesItem;
if (data && s.type === "line") {
if ((s as LineSeriesOption).sampling === "minmax") {
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
? xAxis.min.getTime()
@@ -1030,8 +994,8 @@ export class HaChartBase extends LitElement {
? xAxis.max
: undefined
: undefined;
result = {
...result,
return {
...s,
sampling: undefined,
data: downSampleLineData(
data as LineSeriesOption["data"],
@@ -1039,10 +1003,11 @@ export class HaChartBase extends LitElement {
minX,
maxX
),
} as HaECSeriesItem;
};
}
}
return processSeriesTooltipFormatter(result);
const name = filterXSS(String(s.name ?? s.id ?? ""));
return { ...s, name, data };
});
return series as ECOption["series"];
}
@@ -1379,8 +1344,8 @@ export class HaChartBase extends LitElement {
}
private _compareCustomLegendOptions(
oldOptions: HaECOption | undefined,
newOptions: HaECOption | undefined
oldOptions: ECOption | undefined,
newOptions: ECOption | undefined
): boolean {
const oldLegends = ensureArray(
oldOptions?.legend || []
@@ -1,41 +0,0 @@
import type { PropertyValues } from "lit";
import { css, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
@customElement("ha-chart-tooltip-marker")
class HaChartTooltipMarker extends LitElement {
@property() public color = "";
@property({ type: Boolean, reflect: true }) public rtl = false;
protected willUpdate(changed: PropertyValues) {
if (changed.has("color")) {
this.style.backgroundColor = this.color;
}
}
protected render() {
return nothing;
}
static styles = css`
:host {
display: inline-block;
margin-inline-end: 4px;
margin-inline-start: initial;
border-radius: 10px;
width: 10px;
height: 10px;
vertical-align: middle;
}
:host([rtl]) {
direction: rtl;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-chart-tooltip-marker": HaChartTooltipMarker;
}
}
+4 -4
View File
@@ -1,6 +1,6 @@
import type { EChartsType } from "echarts/core";
import type { GraphSeriesOption } from "echarts/charts";
import type { PropertyValues, TemplateResult } from "lit";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state, query } from "lit/decorators";
@@ -11,7 +11,7 @@ import type {
import { mdiFormatTextVariant, mdiGoogleCirclesGroup } from "@mdi/js";
import memoizeOne from "memoize-one";
import { listenMediaQuery } from "../../common/dom/media_query";
import type { HaECOption } from "../../resources/echarts/echarts";
import type { ECOption } from "../../resources/echarts/echarts";
import "./ha-chart-base";
import type { HaChartBase } from "./ha-chart-base";
import type { HomeAssistant } from "../../types";
@@ -78,7 +78,7 @@ export class HaNetworkGraph extends SubscribeMixin(LitElement) {
@property({ attribute: false }) public tooltipFormatter?: (
params: TopLevelFormatterParams
) => TemplateResult | typeof nothing | null;
) => string;
/**
* Optional callback that returns additional searchable strings for a node.
@@ -182,7 +182,7 @@ export class HaNetworkGraph extends SubscribeMixin(LitElement) {
}
private _createOptions = memoizeOne(
(categories?: NetworkData["categories"]): HaECOption => ({
(categories?: NetworkData["categories"]): ECOption => ({
tooltip: {
trigger: "item",
confine: true,
+6 -10
View File
@@ -11,10 +11,10 @@ import { ResizeController } from "@lit-labs/observers/resize-controller";
import { fireEvent } from "../../common/dom/fire_event";
import SankeyChart from "../../resources/echarts/components/sankey/install";
import type { HomeAssistant } from "../../types";
import type { HaECOption } from "../../resources/echarts/echarts";
import type { ECOption } from "../../resources/echarts/echarts";
import { measureTextWidth } from "../../util/text";
import { filterXSS } from "../../common/util/xss";
import "./ha-chart-base";
import "./ha-chart-tooltip-marker";
import { NODE_SIZE } from "../trace/hat-graph-const";
import "../ha-alert";
@@ -71,7 +71,7 @@ export class HaSankeyChart extends LitElement {
});
render() {
const options: HaECOption = {
const options = {
grid: {
top: 0,
bottom: 0,
@@ -83,7 +83,7 @@ export class HaSankeyChart extends LitElement {
formatter: this._renderTooltip,
appendTo: document.body,
},
};
} as ECOption;
return html`<ha-chart-base
.hass=${this.hass}
@@ -103,16 +103,12 @@ export class HaSankeyChart extends LitElement {
: data.value;
if (data.id) {
const node = this.data.nodes.find((n) => n.id === data.id);
return html`<ha-chart-tooltip-marker
.color=${String(params.color ?? "")}
></ha-chart-tooltip-marker>
${node?.label ?? data.id}<br />${value}`;
return `${params.marker} ${filterXSS(node?.label ?? data.id)}<br>${value}`;
}
if (data.source && data.target) {
const source = this.data.nodes.find((n) => n.id === data.source);
const target = this.data.nodes.find((n) => n.id === data.target);
return html`${source?.label ?? data.source}
${target?.label ?? data.target}<br />${value}`;
return `${filterXSS(source?.label ?? data.source)} ${filterXSS(target?.label ?? data.target)}<br>${value}`;
}
return null;
};
+5 -8
View File
@@ -5,10 +5,10 @@ import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import memoizeOne from "memoize-one";
import { getGraphColorByIndex } from "../../common/color/colors";
import type { HaECOption } from "../../resources/echarts/echarts";
import { filterXSS } from "../../common/util/xss";
import type { ECOption } from "../../resources/echarts/echarts";
import type { HomeAssistant } from "../../types";
import "./ha-chart-base";
import "./ha-chart-tooltip-marker";
// eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/consistent-type-imports
let SunburstChart: typeof import("echarts/lib/chart/sunburst/install");
@@ -50,13 +50,13 @@ export class HaSunburstChart extends LitElement {
return nothing;
}
const options: HaECOption = {
const options = {
tooltip: {
trigger: "item",
formatter: this._renderTooltip,
appendTo: document.body,
},
};
} as ECOption;
return html`<ha-chart-base
.data=${this._createData(this.data)}
@@ -71,10 +71,7 @@ export class HaSunburstChart extends LitElement {
const value = this.valueFormatter
? this.valueFormatter(data.value)
: data.value;
return html`<ha-chart-tooltip-marker
.color=${String(params.color ?? "")}
></ha-chart-tooltip-marker>
${data.name}<br />${value}`;
return `${params.marker} ${filterXSS(data.name)}<br>${value}`;
};
private _createData = memoizeOne(
@@ -1,41 +0,0 @@
import { nothing, render } from "lit";
import type { LitTooltipFormatter } from "../../resources/echarts/echarts";
type WrappedTooltipFormatter = (
params: unknown,
ticket?: string
) => HTMLElement | null;
export type { WrappedTooltipFormatter };
const litTooltipFormatterCache = new WeakMap<
LitTooltipFormatter | WrappedTooltipFormatter,
WrappedTooltipFormatter
>();
export const wrapLitTooltipFormatter = (
fn: LitTooltipFormatter | WrappedTooltipFormatter
): WrappedTooltipFormatter => {
const cached = litTooltipFormatterCache.get(fn);
if (cached) return cached;
const container = document.createElement("div");
// display:contents keeps the wrapper layout-invisible so its children act as
// direct children of echarts' tooltip box, matching the prior innerHTML behavior.
container.style.display = "contents";
const wrapped: WrappedTooltipFormatter = (params, ticket) => {
const result = (fn as LitTooltipFormatter)(params, ticket);
// `nothing` and null/undefined must all suppress the tooltip. Returning
// `nothing` to echarts via `render(nothing, container)` leaves a Lit
// comment marker behind so echarts would show an empty box; convert it to
// null instead so `setContent(null)` clears innerHTML and `show()` hides.
if (result === null || result === undefined || result === nothing) {
return null;
}
render(result, container);
return container;
};
litTooltipFormatterCache.set(fn, wrapped);
// Idempotent re-wrap: looking up the wrapped fn returns itself.
litTooltipFormatterCache.set(wrapped, wrapped);
return wrapped;
};
@@ -1,5 +1,5 @@
import type { PropertyValues, TemplateResult } from "lit";
import { html, LitElement, nothing } from "lit";
import type { PropertyValues } from "lit";
import { html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import type { VisualMapComponentOption } from "echarts/components";
import type { LineSeriesOption } from "echarts/charts";
@@ -12,9 +12,8 @@ import type { LineChartEntity, LineChartState } from "../../data/history";
import type { HomeAssistant } from "../../types";
import { MIN_TIME_BETWEEN_UPDATES } from "./ha-chart-base";
import { sideTooltipPosition } from "./chart-tooltip-position";
import "./ha-chart-tooltip-marker";
import { computeYAxisFractionDigits } from "./y-axis-fraction-digits";
import type { HaECOption } from "../../resources/echarts/echarts";
import type { ECOption } from "../../resources/echarts/echarts";
import { formatDateTimeWithSeconds } from "../../common/datetime/format_date_time";
import {
getNumberFormatOptions,
@@ -25,6 +24,7 @@ import type { HASSDomEvent } from "../../common/dom/fire_event";
import { fireEvent } from "../../common/dom/fire_event";
import { CLIMATE_HVAC_ACTION_TO_MODE } from "../../data/climate";
import { blankBeforeUnit } from "../../common/translations/blank_before_unit";
import { filterXSS } from "../../common/util/xss";
import { computeAttributeValueDisplay } from "../../common/entity/compute_attribute_display";
const safeParseFloat = (value) => {
@@ -108,7 +108,7 @@ export class StateHistoryChartLine extends LitElement {
private _datasetToDataIndex: number[] = [];
@state() private _chartOptions?: HaECOption;
@state() private _chartOptions?: ECOption;
private _hiddenStats = new Set<string>();
@@ -141,11 +141,12 @@ export class StateHistoryChartLine extends LitElement {
private _renderTooltip = (params: any) => {
const time = params[0].axisValue;
const title = formatDateTimeWithSeconds(
new Date(time),
this.hass.locale,
this.hass.config
);
const title =
formatDateTimeWithSeconds(
new Date(time),
this.hass.locale,
this.hass.config
) + "<br>";
const datapoints: Record<string, any>[] = [];
this._chartData.forEach((dataset, index) => {
if (
@@ -176,44 +177,52 @@ export class StateHistoryChartLine extends LitElement {
seriesName: dataset.name,
seriesIndex: index,
value: lastData,
color: dataset.color,
// HTML copied from echarts. May change based on options
marker: `<span style="display:inline-block;margin-right:4px;margin-inline-end:4px;margin-inline-start:initial;border-radius:10px;width:10px;height:10px;background-color:${dataset.color};"></span>`,
});
});
const unit = this.unit
? `${blankBeforeUnit(this.unit, this.hass.locale)}${this.unit}`
: "";
return html`${title}${datapoints.map((param) => {
const entityId = this._entityIds[param.seriesIndex];
const stateObj = this.hass.states[entityId];
const entry = this.hass.entities[entityId];
const stateValue = String(param.value[1]);
const value = stateObj
? this.hass.formatEntityState(stateObj, stateValue)
: `${formatNumber(
stateValue,
this.hass.locale,
getNumberFormatOptions(undefined, entry)
)}${unit}`;
const dataIndex = this._datasetToDataIndex[param.seriesIndex];
const data = this.data[dataIndex];
let statSuffix: TemplateResult | typeof nothing = nothing;
if (data.statistics && data.statistics.length > 0) {
const source =
data.states.length === 0 ||
param.value[0] < data.states[0].last_changed
? this.hass.localize("ui.components.history_charts.source_stats")
: this.hass.localize("ui.components.history_charts.source_history");
// Five non-breaking spaces indent the source label.
statSuffix = html`<br />${"\u00a0".repeat(5)}${source}`;
}
return html`<br /><ha-chart-tooltip-marker
.color=${String(param.color ?? "")}
></ha-chart-tooltip-marker>
${param.seriesName
? html`${param.seriesName}: `
: nothing}${value}${statSuffix}`;
})}`;
return (
title +
datapoints
.map((param) => {
const entityId = this._entityIds[param.seriesIndex];
const stateObj = this.hass.states[entityId];
const entry = this.hass.entities[entityId];
const stateValue = String(param.value[1]);
let value = stateObj
? this.hass.formatEntityState(stateObj, stateValue)
: `${formatNumber(
stateValue,
this.hass.locale,
getNumberFormatOptions(undefined, entry)
)}${unit}`;
const dataIndex = this._datasetToDataIndex[param.seriesIndex];
const data = this.data[dataIndex];
if (data.statistics && data.statistics.length > 0) {
value += "<br>&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;";
const source =
data.states.length === 0 ||
param.value[0] < data.states[0].last_changed
? `${this.hass.localize(
"ui.components.history_charts.source_stats"
)}`
: `${this.hass.localize(
"ui.components.history_charts.source_history"
)}`;
value += source;
}
if (param.seriesName) {
return `${param.marker} ${filterXSS(param.seriesName)}: ${value}`;
}
return `${param.marker} ${value}`;
})
.join("<br>")
);
};
private _datasetHidden(ev: CustomEvent) {
@@ -1,10 +1,11 @@
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import type {
CustomSeriesOption,
CustomSeriesRenderItem,
ECElementEvent,
TooltipFormatterCallback,
TooltipPositionCallbackParams,
} from "echarts/types/dist/shared";
import { formatDateTimeWithSeconds } from "../../common/datetime/format_date_time";
@@ -14,9 +15,8 @@ import type { TimelineEntity } from "../../data/history";
import type { HomeAssistant } from "../../types";
import { MIN_TIME_BETWEEN_UPDATES } from "./ha-chart-base";
import { sideTooltipPosition } from "./chart-tooltip-position";
import "./ha-chart-tooltip-marker";
import { computeTimelineColor } from "./timeline-color";
import type { HaECOption, HaECSeries } from "../../resources/echarts/echarts";
import type { ECOption } from "../../resources/echarts/echarts";
import echarts from "../../resources/echarts/echarts";
import { luminosity } from "../../common/color/rgb";
import { hex2rgb } from "../../common/color/convert-color";
@@ -57,7 +57,7 @@ export class StateHistoryChartTimeline extends LitElement {
@state() private _chartData: CustomSeriesOption[] = [];
@state() private _chartOptions?: HaECOption;
@state() private _chartOptions?: ECOption;
@state() private _yWidth = 0;
@@ -69,7 +69,7 @@ export class StateHistoryChartTimeline extends LitElement {
.hass=${this.hass}
.options=${this._chartOptions}
.height=${`${this.data.length * 30 + 30}px`}
.data=${this._chartData as HaECSeries}
.data=${this._chartData as ECOption["series"]}
small-controls
@chart-click=${this._handleChartClick}
@chart-zoom=${this._handleDataZoom}
@@ -132,35 +132,42 @@ export class StateHistoryChartTimeline extends LitElement {
return rect;
};
private _renderTooltip = (params: TooltipPositionCallbackParams) => {
const { value, name, seriesName, color } = Array.isArray(params)
? params[0]
: params;
const durationInMs = value![2] - value![1];
const formattedDuration = `${this.hass.localize(
"ui.components.history_charts.duration"
)}: ${millisecondsToDuration(durationInMs)}`;
private _renderTooltip: TooltipFormatterCallback<TooltipPositionCallbackParams> =
(params: TooltipPositionCallbackParams) => {
const { value, name, marker, seriesName, color } = Array.isArray(params)
? params[0]
: params;
const title = seriesName
? `<h4 style="text-align: center; margin: 0;">${seriesName}</h4>`
: "";
const durationInMs = value![2] - value![1];
const formattedDuration = `${this.hass.localize(
"ui.components.history_charts.duration"
)}: ${millisecondsToDuration(durationInMs)}`;
const rtl = computeRTL(
this.hass.language,
this.hass.translationMetadata.translations
);
return html`${seriesName
? html`<h4 style="text-align: center; margin: 0;">${seriesName}</h4>`
: nothing}<ha-chart-tooltip-marker
.color=${String(color ?? "")}
.rtl=${rtl}
></ha-chart-tooltip-marker
>${name}<br />${formatDateTimeWithSeconds(
new Date(value![1]),
this.hass.locale,
this.hass.config
)}<br />${formatDateTimeWithSeconds(
new Date(value![2]),
this.hass.locale,
this.hass.config
)}<br />${formattedDuration}`;
};
const markerLocalized = !computeRTL(
this.hass.language,
this.hass.translationMetadata.translations
)
? marker
: `<span style="direction: rtl;display:inline-block;margin-right:4px;margin-inline-end:4px;border-radius:10px;width:10px;height:10px;background-color:${color};"></span>`;
const lines = [
markerLocalized + name,
formatDateTimeWithSeconds(
new Date(value![1]),
this.hass.locale,
this.hass.config
),
formatDateTimeWithSeconds(
new Date(value![2]),
this.hass.locale,
this.hass.config
),
formattedDuration,
].join("<br>");
return [title, lines].join("");
};
public willUpdate(changedProps: PropertyValues) {
if (
+82 -93
View File
@@ -4,7 +4,7 @@ import type {
ZRColor,
} from "echarts/types/dist/shared";
import type { PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import { styleMap } from "lit/directives/style-map";
import memoizeOne from "memoize-one";
@@ -34,13 +34,12 @@ import {
isExternalStatistic,
statisticsHaveType,
} from "../../data/recorder";
import type { HaECOption } from "../../resources/echarts/echarts";
import type { ECOption } from "../../resources/echarts/echarts";
import type { HomeAssistant } from "../../types";
import { getPeriodicAxisLabelConfig } from "./axis-label";
import type { CustomLegendOption } from "./ha-chart-base";
import "./ha-chart-base";
import { sideTooltipPosition } from "./chart-tooltip-position";
import "./ha-chart-tooltip-marker";
import { fillDataGapsAndRoundCaps } from "./round-caps";
import { computeYAxisFractionDigits } from "./y-axis-fraction-digits";
@@ -127,7 +126,7 @@ export class StatisticsChart extends LitElement {
@state() private _statisticIds: string[] = [];
@state() private _chartOptions?: HaECOption;
@state() private _chartOptions?: ECOption;
@state() private _hiddenStats = new Set<string>();
@@ -252,101 +251,91 @@ export class StatisticsChart extends LitElement {
const unit = this.unit
? `${blankBeforeUnit(this.unit, this.hass.locale)}${this.unit}`
: "";
const rows: {
time?: string;
color: string;
seriesName?: string;
value: string;
}[] = [];
for (const param of params) {
if (rendered[param.seriesIndex]) continue;
rendered[param.seriesIndex] = true;
return params
.map((param, index: number) => {
if (rendered[param.seriesIndex]) return "";
rendered[param.seriesIndex] = true;
const statisticId = this._statisticIds[param.seriesIndex];
const stateObj = this.hass.states[statisticId];
const entry = this.hass.entities[statisticId];
let rawValue: string;
let rawTime: string;
if (chartIsBar) {
// For bar charts value is always second value.
rawValue = String(param.value[1]);
// Time value is third value (un-shifted date) if given, otherwise first value
let startTime: Date;
let endTime: Date | undefined;
if (param.value[2]) {
startTime = new Date(param.value[2]);
if (param.value[3]) {
endTime = new Date(param.value[3]);
const statisticId = this._statisticIds[param.seriesIndex];
const stateObj = this.hass.states[statisticId];
const entry = this.hass.entities[statisticId];
let rawValue: string;
let rawTime: string;
if (chartIsBar) {
// For bar charts value is always second value.
rawValue = String(param.value[1]);
// Time value is third value (un-shifted date) if given, otherwise first value
let startTime: Date;
let endTime: Date | undefined;
if (param.value[2]) {
startTime = new Date(param.value[2]);
if (param.value[3]) {
endTime = new Date(param.value[3]);
}
} else {
startTime = new Date(param.value[0]);
}
if (
period === "year" ||
period === "month" ||
period === "week" ||
period === "day"
) {
// For year/month/day periods, show only the date
rawTime =
formatDate(startTime, this.hass.locale, this.hass.config) +
(endTime && period !== "day"
? ` ${formatDate(
endTime,
this.hass.locale,
this.hass.config
)}`
: "") +
"<br>";
} else {
// For other time periods, include time in render, and optionally show range
// if we have an end time.
rawTime =
formatDateTimeWithSeconds(
startTime,
this.hass.locale,
this.hass.config
) +
(endTime
? ` ${formatTimeWithSeconds(
endTime,
this.hass.locale,
this.hass.config
)}`
: "") +
"<br>";
}
} else {
startTime = new Date(param.value[0]);
// For lines max series can have 3 values, as the second value is the max-min to form a band
rawValue = String(param.value[2] ?? param.value[1]);
// Time value is always first value
rawTime = `${formatDateTimeWithSeconds(
new Date(param.value[0]),
this.hass.locale,
this.hass.config
)} <br>`;
}
if (
period === "year" ||
period === "month" ||
period === "week" ||
period === "day"
) {
// For year/month/day periods, show only the date
rawTime =
formatDate(startTime, this.hass.locale, this.hass.config) +
(endTime && period !== "day"
? ` ${formatDate(endTime, this.hass.locale, this.hass.config)}`
: "");
} else {
// For other time periods, include time in render, and optionally show range
// if we have an end time.
rawTime =
formatDateTimeWithSeconds(
startTime,
this.hass.locale,
this.hass.config
) +
(endTime
? ` ${formatTimeWithSeconds(
endTime,
this.hass.locale,
this.hass.config
)}`
: "");
}
} else {
// For lines max series can have 3 values, as the second value is the max-min to form a band
rawValue = String(param.value[2] ?? param.value[1]);
// Time value is always first value
rawTime = formatDateTimeWithSeconds(
new Date(param.value[0]),
const options = getNumberFormatOptions(stateObj, entry) ?? {
maximumFractionDigits: 2,
};
const value = `${formatNumber(
rawValue,
this.hass.locale,
this.hass.config
);
}
options
)}${unit}`;
const options = getNumberFormatOptions(stateObj, entry) ?? {
maximumFractionDigits: 2,
};
const value = `${formatNumber(rawValue, this.hass.locale, options)}${unit}`;
rows.push({
time: rows.length === 0 ? rawTime : undefined,
color: String(param.color ?? ""),
seriesName: param.seriesName,
value,
});
}
if (rows.length === 0) return nothing;
return html`${rows.map(
(row, i) =>
html`${row.time
? html`${row.time}<br />`
: nothing}<ha-chart-tooltip-marker
.color=${row.color}
></ha-chart-tooltip-marker>
${row.seriesName}:
${row.value}${i < rows.length - 1 ? html`<br />` : nothing}`
)}`;
const time = index === 0 ? rawTime : "";
return `${time}${param.marker} ${param.seriesName}: ${value}`;
})
.filter(Boolean)
.join("<br>");
};
private _createOptions() {
@@ -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
@@ -20,7 +20,6 @@ export class HaCheckListItem extends CheckListItemBase {
separateCheckboxClick = false;
async onChange(event) {
event.stopPropagation();
super.onChange(event);
fireEvent(this, event.type);
}
-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}
+1 -4
View File
@@ -77,7 +77,7 @@ export class HaGenericPicker extends PickerMixin(LitElement) {
| "bottom-start"
| "bottom-end"
| "left-start"
| "left-end" = "bottom";
| "left-end" = "bottom-start";
/** If set picker shows an add button instead of textbox when value isn't set */
@property({ attribute: "add-button-label" }) public addButtonLabel?: string;
@@ -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
View File
@@ -121,6 +121,7 @@ export class HaIconPicker extends LitElement {
.label=${this.label}
.value=${this._value}
.searchFn=${this._filterIcons}
popover-placement="bottom-start"
@value-changed=${this._valueChanged}
>
<slot name="start"></slot>
+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
View File
@@ -152,6 +152,7 @@ export class HaLanguagePicker extends LitElement {
<ha-generic-picker
.hass=${this.hass}
.autofocus=${this.autofocus}
popover-placement="bottom-end"
.notFoundLabel=${this._notFoundLabel}
.emptyLabel=${this.hass?.localize(
"ui.components.language-picker.no_languages"
+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}
+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 -62
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,73 +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}
></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 -224
View File
@@ -1,231 +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:
box-shadow var(--ha-animation-duration-normal) ease,
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(--checked-icon-color);
border-color: var(--checked-icon-color);
}
[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%;
+4 -4
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 {
@@ -671,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
@@ -256,7 +256,6 @@ export const normalizeSubscriptionEventData = (
dtstart: eventStart,
dtend: eventEnd,
description: eventData.description ?? undefined,
location: eventData.location ?? undefined,
uid: eventData.uid ?? undefined,
recurrence_id: eventData.recurrence_id ?? undefined,
rrule: eventData.rrule ?? undefined,
+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;
}
@@ -185,25 +181,23 @@ class SupervisorAppInfo extends MobileAwareMixin(LitElement) {
private _renderInfoCard() {
const systemManaged = this._isSystemManaged(this._currentAddon);
return html` <ha-card outlined>
return html`<ha-card outlined>
<div class="card-content">
<div class="addon-header">
${this._currentAddon.logo
? html`
<img
class="logo"
alt=""
src="/api/hassio/addons/${this._currentAddon.slug}/logo"
/>
`
: nothing}
<div class="title">
${this._currentAddon.logo
? html`
<img
class="logo"
alt=""
src="/api/hassio/addons/${this._currentAddon.slug}/logo"
/>
`
: nothing}
${!this.narrow
? getAppDisplayName(
this._currentAddon.name,
this._currentAddon.stage
)
: nothing}
${getAppDisplayName(
this._currentAddon.name,
this._currentAddon.stage
)}
<div class="description">
${this._currentAddon.version
? html`
@@ -241,7 +235,17 @@ class SupervisorAppInfo extends MobileAwareMixin(LitElement) {
? html`<supervisor-apps-state
.state=${this._currentAddon.state}
></supervisor-apps-state>`
: nothing}
: html`
<ha-progress-button
.disabled=${!this._currentAddon.available}
@click=${this._installClicked}
.iconPath=${mdiApplicationImport}
>
${this.i18n.localize(
"ui.panel.config.apps.dashboard.install"
)}
</ha-progress-button>
`}
</div>
<ha-chip-set class="capabilities">
@@ -505,8 +509,7 @@ class SupervisorAppInfo extends MobileAwareMixin(LitElement) {
</div>
${(this._currentAddon.update_available && this._updateEntityId) ||
this._computeShowWebUI ||
this._computeShowIngressUI ||
!this._currentAddon.version
this._computeShowIngressUI
? html`
<div class="card-actions">
${this._currentAddon.update_available && this._updateEntityId
@@ -542,19 +545,6 @@ class SupervisorAppInfo extends MobileAwareMixin(LitElement) {
</ha-button>
`
: nothing}
${!this._currentAddon.version
? html`
<ha-progress-button
.disabled=${!this._currentAddon.available}
@click=${this._installClicked}
.iconPath=${mdiApplicationImport}
>
${this.i18n.localize(
"ui.panel.config.apps.dashboard.install"
)}
</ha-progress-button>
`
: nothing}
</div>
`
: nothing}
@@ -873,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
@@ -1503,17 +1493,16 @@ class SupervisorAppInfo extends MobileAwareMixin(LitElement) {
}
.addon-header {
display: flex;
padding-inline-start: var(--ha-space-2);
padding-inline-end: initial;
font-size: var(--ha-font-size-2xl);
color: var(--ha-card-header-color, var(--primary-text-color));
align-items: center;
gap: var(--ha-space-2);
flex-wrap: wrap;
margin-bottom: var(--ha-space-4);
}
.addon-header .title {
flex: 1;
margin-inline-end: var(--ha-space-4);
}
.addon-header .title .description {
@@ -1532,15 +1521,17 @@ class SupervisorAppInfo extends MobileAwareMixin(LitElement) {
color: var(--error-color);
margin-bottom: var(--ha-space-4);
}
.description {
margin-bottom: var(--ha-space-4);
}
.description a {
color: var(--primary-color);
}
img.logo {
max-width: 100%;
max-height: 40px;
max-height: 60px;
display: block;
margin-bottom: var(--ha-space-2);
}
ha-assist-chip {
--md-sys-color-primary: var(--text-primary-color);
@@ -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;
}
}
@@ -48,6 +48,8 @@ import type { AreaRegistryDetailDialogParams } from "./show-dialog-area-registry
const cropOptions: CropOptions = {
round: false,
type: "image/jpeg",
quality: 0.75,
};
const SENSOR_DOMAINS = ["sensor"];
+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,
});
};

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