Merge branch 'dev' of github.com:home-assistant/frontend into user-siderbar

This commit is contained in:
Wendelin 2025-02-24 09:58:13 +01:00
commit 188e82fa02
No known key found for this signature in database
84 changed files with 2810 additions and 987 deletions

View File

@ -5,7 +5,7 @@
"context": ".." "context": ".."
}, },
"appPort": "8124:8123", "appPort": "8124:8123",
"postCreateCommand": "sudo apt update && sudo apt upgrade -y && sudo apt install -y libpcap-dev", "postCreateCommand": "./.devcontainer/post_create.sh",
"postStartCommand": "script/bootstrap", "postStartCommand": "script/bootstrap",
"containerEnv": { "containerEnv": {
"DEV_CONTAINER": "1", "DEV_CONTAINER": "1",

22
.devcontainer/post_create.sh Executable file
View File

@ -0,0 +1,22 @@
#!/bin/bash
# This script will run after the container is created
# add github cli
(type -p wget >/dev/null || (sudo apt update && sudo apt-get install wget -y)) \
&& sudo mkdir -p -m 755 /etc/apt/keyrings \
&& out=$(mktemp) && wget -nv -O$out https://cli.github.com/packages/githubcli-archive-keyring.gpg \
&& cat $out | sudo tee /etc/apt/keyrings/githubcli-archive-keyring.gpg > /dev/null \
&& sudo chmod go+r /etc/apt/keyrings/githubcli-archive-keyring.gpg \
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | sudo tee /etc/apt/sources.list.d/github-cli.list > /dev/null
# Update package lists
sudo apt-get update
sudo apt upgrade -y
# Install necessary packages
sudo apt-get install -y libpcap-dev gh
# Display a message
echo "Post-create script has been executed successfully."

View File

@ -37,7 +37,7 @@ jobs:
- name: Build resources - name: Build resources
run: ./node_modules/.bin/gulp gen-icons-json build-translations build-locale-data gather-gallery-pages run: ./node_modules/.bin/gulp gen-icons-json build-translations build-locale-data gather-gallery-pages
- name: Setup lint cache - name: Setup lint cache
uses: actions/cache@v4.2.0 uses: actions/cache@v4.2.1
with: with:
path: | path: |
node_modules/.cache/prettier node_modules/.cache/prettier
@ -89,7 +89,7 @@ jobs:
env: env:
IS_TEST: "true" IS_TEST: "true"
- name: Upload bundle stats - name: Upload bundle stats
uses: actions/upload-artifact@v4.6.0 uses: actions/upload-artifact@v4.6.1
with: with:
name: frontend-bundle-stats name: frontend-bundle-stats
path: build/stats/*.json path: build/stats/*.json
@ -113,7 +113,7 @@ jobs:
env: env:
IS_TEST: "true" IS_TEST: "true"
- name: Upload bundle stats - name: Upload bundle stats
uses: actions/upload-artifact@v4.6.0 uses: actions/upload-artifact@v4.6.1
with: with:
name: supervisor-bundle-stats name: supervisor-bundle-stats
path: build/stats/*.json path: build/stats/*.json

View File

@ -57,14 +57,14 @@ jobs:
run: tar -czvf translations.tar.gz translations run: tar -czvf translations.tar.gz translations
- name: Upload build artifacts - name: Upload build artifacts
uses: actions/upload-artifact@v4.6.0 uses: actions/upload-artifact@v4.6.1
with: with:
name: wheels name: wheels
path: dist/home_assistant_frontend*.whl path: dist/home_assistant_frontend*.whl
if-no-files-found: error if-no-files-found: error
- name: Upload translations - name: Upload translations
uses: actions/upload-artifact@v4.6.0 uses: actions/upload-artifact@v4.6.1
with: with:
name: translations name: translations
path: translations.tar.gz path: translations.tar.gz

42
.vscode/tasks.json vendored
View File

@ -1,6 +1,42 @@
{ {
"version": "2.0.0", "version": "2.0.0",
"tasks": [ "tasks": [
{
"label": "Develop and serve Frontend",
"type": "shell",
"command": "script/develop_and_serve -c ${input:coreUrl}",
// Sync changes here to other tasks until issue resolved
// https://github.com/Microsoft/vscode/issues/61497
"problemMatcher": {
"owner": "ha-build",
"source": "ha-build",
"fileLocation": "absolute",
"severity": "error",
"pattern": [
{
"regexp": "(SyntaxError): (.+): (.+) \\((\\d+):(\\d+)\\)",
"severity": 1,
"file": 2,
"message": 3,
"line": 4,
"column": 5
}
],
"background": {
"activeOnStart": true,
"beginsPattern": "Changes detected. Starting compilation",
"endsPattern": "Build done @"
}
},
"isBackground": true,
"group": {
"kind": "build",
"isDefault": true
},
"runOptions": {
"instanceLimit": 1
}
},
{ {
"label": "Develop Frontend", "label": "Develop Frontend",
"type": "gulp", "type": "gulp",
@ -241,6 +277,12 @@
"id": "supervisorToken", "id": "supervisorToken",
"type": "promptString", "type": "promptString",
"description": "The token for the Remote API proxy add-on" "description": "The token for the Remote API proxy add-on"
},
{
"id": "coreUrl",
"type": "promptString",
"description": "The URL of the Home Assistant Core instance",
"default": "http://127.0.0.1:8123"
} }
] ]
} }

View File

@ -1,8 +1,9 @@
// @ts-check
import tseslint from "typescript-eslint";
import rootConfig from "../eslint.config.mjs"; import rootConfig from "../eslint.config.mjs";
export default [ export default tseslint.config(...rootConfig, {
...rootConfig,
{
rules: { rules: {
"no-console": "off", "no-console": "off",
"import/no-extraneous-dependencies": "off", "import/no-extraneous-dependencies": "off",
@ -12,5 +13,4 @@ export default [
"@typescript-eslint/no-require-imports": "off", "@typescript-eslint/no-require-imports": "off",
"prefer-arrow-callback": "off", "prefer-arrow-callback": "off",
}, },
}, });
];

View File

@ -90,6 +90,14 @@ function copyMapPanel(staticDir) {
npmPath("leaflet/dist/leaflet.css"), npmPath("leaflet/dist/leaflet.css"),
staticPath("images/leaflet/") staticPath("images/leaflet/")
); );
copyFileDir(
npmPath("leaflet.markercluster/dist/MarkerCluster.css"),
staticPath("images/leaflet/")
);
copyFileDir(
npmPath("leaflet.markercluster/dist/MarkerCluster.Default.css"),
staticPath("images/leaflet/")
);
fs.copySync( fs.copySync(
npmPath("leaflet/dist/images"), npmPath("leaflet/dist/images"),
staticPath("images/leaflet/images/") staticPath("images/leaflet/images/")

View File

@ -1,11 +1,16 @@
// @ts-check
/* eslint-disable import/no-extraneous-dependencies */ /* eslint-disable import/no-extraneous-dependencies */
import unusedImports from "eslint-plugin-unused-imports"; import unusedImports from "eslint-plugin-unused-imports";
import globals from "globals"; import globals from "globals";
import tsParser from "@typescript-eslint/parser";
import path from "node:path"; import path from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import js from "@eslint/js"; import js from "@eslint/js";
import { FlatCompat } from "@eslint/eslintrc"; import { FlatCompat } from "@eslint/eslintrc";
import tseslint from "typescript-eslint";
import eslintConfigPrettier from "eslint-config-prettier";
import { configs as litConfigs } from "eslint-plugin-lit";
import { configs as wcConfigs } from "eslint-plugin-wc";
const _filename = fileURLToPath(import.meta.url); const _filename = fileURLToPath(import.meta.url);
const _dirname = path.dirname(_filename); const _dirname = path.dirname(_filename);
@ -15,17 +20,14 @@ const compat = new FlatCompat({
allConfig: js.configs.all, allConfig: js.configs.all,
}); });
export default [ export default tseslint.config(
...compat.extends( ...compat.extends("airbnb-base", "plugin:lit-a11y/recommended"),
"airbnb-base", eslintConfigPrettier,
"plugin:@typescript-eslint/recommended", litConfigs["flat/all"],
"plugin:@typescript-eslint/strict", tseslint.configs.recommended,
"plugin:@typescript-eslint/stylistic", tseslint.configs.strict,
"plugin:wc/recommended", tseslint.configs.stylistic,
"plugin:lit/all", wcConfigs["flat/recommended"],
"plugin:lit-a11y/recommended",
"prettier"
),
{ {
plugins: { plugins: {
"unused-imports": unusedImports, "unused-imports": unusedImports,
@ -43,7 +45,7 @@ export default [
Polymer: true, Polymer: true,
}, },
parser: tsParser, parser: tseslint.parser,
ecmaVersion: 2020, ecmaVersion: 2020,
sourceType: "module", sourceType: "module",
@ -184,5 +186,5 @@ export default [
], ],
"no-use-before-define": "off", "no-use-before-define": "off",
}, },
}, }
]; );

View File

@ -1,10 +1,10 @@
// @ts-check
import tseslint from "typescript-eslint";
import rootConfig from "../eslint.config.mjs"; import rootConfig from "../eslint.config.mjs";
export default [ export default tseslint.config(...rootConfig, {
...rootConfig,
{
rules: { rules: {
"no-console": "off", "no-console": "off",
}, },
}, });
];

View File

@ -17,6 +17,7 @@ import "../../../src/components/ha-alert";
import { import {
ALTERNATIVE_DNS_SERVERS, ALTERNATIVE_DNS_SERVERS,
getSupervisorNetworkInfo, getSupervisorNetworkInfo,
pingSupervisor,
setSupervisorNetworkDns, setSupervisorNetworkDns,
} from "../data/supervisor"; } from "../data/supervisor";
import { fireEvent } from "../../../src/common/dom/fire_event"; import { fireEvent } from "../../../src/common/dom/fire_event";
@ -85,7 +86,28 @@ class LandingPageNetwork extends LitElement {
protected firstUpdated(_changedProperties: PropertyValues): void { protected firstUpdated(_changedProperties: PropertyValues): void {
super.firstUpdated(_changedProperties); super.firstUpdated(_changedProperties);
this._pingSupervisor();
}
private _schedulePingSupervisor() {
setTimeout(
() => this._pingSupervisor(),
SCHEDULE_FETCH_NETWORK_INFO_SECONDS * 1000
);
}
private async _pingSupervisor() {
try {
const response = await pingSupervisor();
if (!response.ok) {
throw new Error("Failed to ping supervisor, assume update in progress");
}
this._fetchSupervisorInfo(); this._fetchSupervisorInfo();
} catch (err) {
// eslint-disable-next-line no-console
console.error(err);
this._schedulePingSupervisor();
}
} }
private _scheduleFetchSupervisorInfo() { private _scheduleFetchSupervisorInfo() {

View File

@ -18,7 +18,7 @@ export const ALTERNATIVE_DNS_SERVERS: {
]; ];
export async function getSupervisorLogs(lines = 100) { export async function getSupervisorLogs(lines = 100) {
return fetch(`/supervisor/supervisor/logs?lines=${lines}`, { return fetch(`/supervisor-api/supervisor/logs?lines=${lines}`, {
headers: { headers: {
Accept: "text/plain", Accept: "text/plain",
}, },
@ -26,22 +26,26 @@ export async function getSupervisorLogs(lines = 100) {
} }
export async function getSupervisorLogsFollow(lines = 500) { export async function getSupervisorLogsFollow(lines = 500) {
return fetch(`/supervisor/supervisor/logs/follow?lines=${lines}`, { return fetch(`/supervisor-api/supervisor/logs/follow?lines=${lines}`, {
headers: { headers: {
Accept: "text/plain", Accept: "text/plain",
}, },
}); });
} }
export async function pingSupervisor() {
return fetch("/supervisor-api/supervisor/ping");
}
export async function getSupervisorNetworkInfo() { export async function getSupervisorNetworkInfo() {
return fetch("/supervisor/network/info"); return fetch("/supervisor-api/network/info");
} }
export const setSupervisorNetworkDns = async ( export const setSupervisorNetworkDns = async (
dnsServerIndex: number, dnsServerIndex: number,
primaryInterface: string primaryInterface: string
) => ) =>
fetch(`/supervisor/network/interface/${primaryInterface}/update`, { fetch(`/supervisor-api/network/interface/${primaryInterface}/update`, {
method: "POST", method: "POST",
body: JSON.stringify({ body: JSON.stringify({
ipv4: { ipv4: {

View File

@ -26,7 +26,7 @@
"license": "Apache-2.0", "license": "Apache-2.0",
"type": "module", "type": "module",
"dependencies": { "dependencies": {
"@babel/runtime": "7.26.7", "@babel/runtime": "7.26.9",
"@braintree/sanitize-url": "7.1.1", "@braintree/sanitize-url": "7.1.1",
"@codemirror/autocomplete": "6.18.6", "@codemirror/autocomplete": "6.18.6",
"@codemirror/commands": "6.8.0", "@codemirror/commands": "6.8.0",
@ -34,7 +34,7 @@
"@codemirror/legacy-modes": "6.4.3", "@codemirror/legacy-modes": "6.4.3",
"@codemirror/search": "6.5.9", "@codemirror/search": "6.5.9",
"@codemirror/state": "6.5.2", "@codemirror/state": "6.5.2",
"@codemirror/view": "6.36.2", "@codemirror/view": "6.36.3",
"@egjs/hammerjs": "2.0.17", "@egjs/hammerjs": "2.0.17",
"@formatjs/intl-datetimeformat": "6.17.3", "@formatjs/intl-datetimeformat": "6.17.3",
"@formatjs/intl-displaynames": "6.8.10", "@formatjs/intl-displaynames": "6.8.10",
@ -53,9 +53,9 @@
"@fullcalendar/timegrid": "6.1.15", "@fullcalendar/timegrid": "6.1.15",
"@lezer/highlight": "1.2.1", "@lezer/highlight": "1.2.1",
"@lit-labs/context": "0.4.1", "@lit-labs/context": "0.4.1",
"@lit-labs/motion": "1.0.7", "@lit-labs/motion": "1.0.8",
"@lit-labs/observers": "2.0.4", "@lit-labs/observers": "2.0.5",
"@lit-labs/virtualizer": "2.0.15", "@lit-labs/virtualizer": "2.1.0",
"@lrnwebcomponents/simple-tooltip": "8.0.2", "@lrnwebcomponents/simple-tooltip": "8.0.2",
"@material/chips": "=14.0.0-canary.53b3cad2f.0", "@material/chips": "=14.0.0-canary.53b3cad2f.0",
"@material/data-table": "=14.0.0-canary.53b3cad2f.0", "@material/data-table": "=14.0.0-canary.53b3cad2f.0",
@ -92,8 +92,8 @@
"@replit/codemirror-indentation-markers": "6.5.3", "@replit/codemirror-indentation-markers": "6.5.3",
"@shoelace-style/shoelace": "2.20.0", "@shoelace-style/shoelace": "2.20.0",
"@thomasloven/round-slider": "0.6.0", "@thomasloven/round-slider": "0.6.0",
"@vaadin/combo-box": "24.6.4", "@vaadin/combo-box": "24.6.5",
"@vaadin/vaadin-themable-mixin": "24.6.4", "@vaadin/vaadin-themable-mixin": "24.6.5",
"@vibrant/color": "4.0.0", "@vibrant/color": "4.0.0",
"@vue/web-component-wrapper": "1.3.0", "@vue/web-component-wrapper": "1.3.0",
"@webcomponents/scoped-custom-element-registry": "0.0.9", "@webcomponents/scoped-custom-element-registry": "0.0.9",
@ -121,6 +121,7 @@
"js-yaml": "4.1.0", "js-yaml": "4.1.0",
"leaflet": "1.9.4", "leaflet": "1.9.4",
"leaflet-draw": "patch:leaflet-draw@npm%3A1.0.4#./.yarn/patches/leaflet-draw-npm-1.0.4-0ca0ebcf65.patch", "leaflet-draw": "patch:leaflet-draw@npm%3A1.0.4#./.yarn/patches/leaflet-draw-npm-1.0.4-0ca0ebcf65.patch",
"leaflet.markercluster": "1.5.3",
"lit": "2.8.0", "lit": "2.8.0",
"lit-html": "2.8.0", "lit-html": "2.8.0",
"luxon": "3.5.0", "luxon": "3.5.0",
@ -154,20 +155,20 @@
"xss": "1.0.15" "xss": "1.0.15"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "7.26.8", "@babel/core": "7.26.9",
"@babel/helper-define-polyfill-provider": "0.6.3", "@babel/helper-define-polyfill-provider": "0.6.3",
"@babel/plugin-proposal-decorators": "7.25.9", "@babel/plugin-proposal-decorators": "7.25.9",
"@babel/plugin-transform-runtime": "7.26.8", "@babel/plugin-transform-runtime": "7.26.9",
"@babel/preset-env": "7.26.8", "@babel/preset-env": "7.26.9",
"@babel/preset-typescript": "7.26.0", "@babel/preset-typescript": "7.26.0",
"@bundle-stats/plugin-webpack-filter": "4.18.2", "@bundle-stats/plugin-webpack-filter": "4.18.2",
"@lokalise/node-api": "13.1.0", "@lokalise/node-api": "13.2.0",
"@octokit/auth-oauth-device": "7.1.3", "@octokit/auth-oauth-device": "7.1.3",
"@octokit/plugin-retry": "7.1.3", "@octokit/plugin-retry": "7.1.4",
"@octokit/rest": "21.1.0", "@octokit/rest": "21.1.1",
"@rsdoctor/rspack-plugin": "0.4.13", "@rsdoctor/rspack-plugin": "0.4.13",
"@rspack/cli": "1.2.3", "@rspack/cli": "1.2.5",
"@rspack/core": "1.2.3", "@rspack/core": "1.2.5",
"@types/babel__plugin-transform-runtime": "7.9.5", "@types/babel__plugin-transform-runtime": "7.9.5",
"@types/chromecast-caf-receiver": "6.0.21", "@types/chromecast-caf-receiver": "6.0.21",
"@types/chromecast-caf-sender": "1.0.11", "@types/chromecast-caf-sender": "1.0.11",
@ -177,6 +178,7 @@
"@types/js-yaml": "4.0.9", "@types/js-yaml": "4.0.9",
"@types/leaflet": "1.9.16", "@types/leaflet": "1.9.16",
"@types/leaflet-draw": "1.0.11", "@types/leaflet-draw": "1.0.11",
"@types/leaflet.markercluster": "1.5.5",
"@types/lodash.merge": "4.6.9", "@types/lodash.merge": "4.6.9",
"@types/luxon": "3.4.2", "@types/luxon": "3.4.2",
"@types/mocha": "10.0.10", "@types/mocha": "10.0.10",
@ -185,9 +187,7 @@
"@types/tar": "6.1.13", "@types/tar": "6.1.13",
"@types/ua-parser-js": "0.7.39", "@types/ua-parser-js": "0.7.39",
"@types/webspeechapi": "0.0.29", "@types/webspeechapi": "0.0.29",
"@typescript-eslint/eslint-plugin": "8.24.0", "@vitest/coverage-v8": "3.0.6",
"@typescript-eslint/parser": "8.24.0",
"@vitest/coverage-v8": "3.0.5",
"babel-loader": "9.2.1", "babel-loader": "9.2.1",
"babel-plugin-template-html-minifier": "4.1.0", "babel-plugin-template-html-minifier": "4.1.0",
"browserslist-useragent-regexp": "4.1.3", "browserslist-useragent-regexp": "4.1.3",
@ -200,7 +200,7 @@
"eslint-plugin-lit": "1.15.0", "eslint-plugin-lit": "1.15.0",
"eslint-plugin-lit-a11y": "4.1.4", "eslint-plugin-lit-a11y": "4.1.4",
"eslint-plugin-unused-imports": "4.1.4", "eslint-plugin-unused-imports": "4.1.4",
"eslint-plugin-wc": "2.2.0", "eslint-plugin-wc": "2.2.1",
"fancy-log": "2.0.0", "fancy-log": "2.0.0",
"fs-extra": "11.3.0", "fs-extra": "11.3.0",
"glob": "11.0.1", "glob": "11.0.1",
@ -226,7 +226,9 @@
"terser-webpack-plugin": "5.3.11", "terser-webpack-plugin": "5.3.11",
"ts-lit-plugin": "2.0.2", "ts-lit-plugin": "2.0.2",
"typescript": "5.7.3", "typescript": "5.7.3",
"vitest": "3.0.5", "typescript-eslint": "8.24.1",
"vite-tsconfig-paths": "5.1.4",
"vitest": "3.0.6",
"webpack-stats-plugin": "1.1.3", "webpack-stats-plugin": "1.1.3",
"webpackbar": "7.0.0", "webpackbar": "7.0.0",
"workbox-build": "patch:workbox-build@npm%3A7.1.1#~/.yarn/patches/workbox-build-npm-7.1.1-a854f3faae.patch" "workbox-build": "patch:workbox-build@npm%3A7.1.1#~/.yarn/patches/workbox-build-npm-7.1.1-a854f3faae.patch"
@ -240,7 +242,7 @@
"clean-css": "5.3.3", "clean-css": "5.3.3",
"@lit/reactive-element": "1.6.3", "@lit/reactive-element": "1.6.3",
"@fullcalendar/daygrid": "6.1.15", "@fullcalendar/daygrid": "6.1.15",
"globals": "15.15.0", "globals": "16.0.0",
"tslib": "2.8.1" "tslib": "2.8.1"
}, },
"packageManager": "yarn@4.6.0" "packageManager": "yarn@4.6.0"

View File

@ -136,11 +136,18 @@ export function theme2hex(themeColor: string): string {
} }
const rgbFromColorName = colors[themeColor]; const rgbFromColorName = colors[themeColor];
if (!rgbFromColorName) { if (rgbFromColorName) {
return rgb2hex(rgbFromColorName);
}
const rgbMatch = themeColor.match(/^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/);
if (rgbMatch) {
const [, r, g, b] = rgbMatch.map(Number);
return rgb2hex([r, g, b]);
}
// We have a named color, and there's nothing in the table, // We have a named color, and there's nothing in the table,
// so nothing further we can do with it. // so nothing further we can do with it.
// Compare/border/background color will all be the same. // Compare/border/background color will all be the same.
return themeColor; return themeColor;
} }
return rgb2hex(rgbFromColorName);
}

View File

@ -16,11 +16,30 @@ export const setupLeafletMap = async (
const Leaflet = (await import("leaflet")).default as LeafletModuleType; const Leaflet = (await import("leaflet")).default as LeafletModuleType;
Leaflet.Icon.Default.imagePath = "/static/images/leaflet/images/"; Leaflet.Icon.Default.imagePath = "/static/images/leaflet/images/";
await import("leaflet.markercluster");
const map = Leaflet.map(mapElement); const map = Leaflet.map(mapElement);
const style = document.createElement("link"); const style = document.createElement("link");
style.setAttribute("href", "/static/images/leaflet/leaflet.css"); style.setAttribute("href", "/static/images/leaflet/leaflet.css");
style.setAttribute("rel", "stylesheet"); style.setAttribute("rel", "stylesheet");
mapElement.parentNode.appendChild(style); mapElement.parentNode.appendChild(style);
const markerClusterStyle = document.createElement("link");
markerClusterStyle.setAttribute(
"href",
"/static/images/leaflet/MarkerCluster.css"
);
markerClusterStyle.setAttribute("rel", "stylesheet");
mapElement.parentNode.appendChild(markerClusterStyle);
const defaultMarkerClusterStyle = document.createElement("link");
defaultMarkerClusterStyle.setAttribute(
"href",
"/static/images/leaflet/MarkerCluster.Default.css"
);
defaultMarkerClusterStyle.setAttribute("rel", "stylesheet");
mapElement.parentNode.appendChild(defaultMarkerClusterStyle);
map.setView([52.3731339, 4.8903147], 13); map.setView([52.3731339, 4.8903147], 13);
const tileLayer = createTileLayer(Leaflet).addTo(map); const tileLayer = createTileLayer(Leaflet).addTo(map);

View File

@ -1,2 +1,2 @@
export const computeDomain = (entityId: string): string => export const computeDomain = (entityId: string): string =>
entityId.substr(0, entityId.indexOf(".")); entityId.substring(0, entityId.indexOf("."));

View File

@ -120,11 +120,6 @@ export const computeStateDisplayFromEntityAttributes = (
return value; return value;
} }
if (domain === "datetime") {
const time = new Date(state);
return formatDateTime(time, locale, config);
}
if (["date", "input_datetime", "time"].includes(domain)) { if (["date", "input_datetime", "time"].includes(domain)) {
// If trying to display an explicit state, need to parse the explicit state to `Date` then format. // If trying to display an explicit state, need to parse the explicit state to `Date` then format.
// Attributes aren't available, we have to use `state`. // Attributes aren't available, we have to use `state`.
@ -181,6 +176,7 @@ export const computeStateDisplayFromEntityAttributes = (
"tag", "tag",
"tts", "tts",
"wake_word", "wake_word",
"datetime",
].includes(domain) || ].includes(domain) ||
(domain === "sensor" && attributes.device_class === "timestamp") (domain === "sensor" && attributes.device_class === "timestamp")
) { ) {

View File

@ -0,0 +1,32 @@
import type { LatLngExpression, Layer, Map, MarkerOptions } from "leaflet";
import { Marker } from "leaflet";
export class DecoratedMarker extends Marker {
decorationLayer: Layer | undefined;
constructor(
latlng: LatLngExpression,
decorationLayer?: Layer,
options?: MarkerOptions
) {
super(latlng, options);
this.decorationLayer = decorationLayer;
}
onAdd(map: Map) {
super.onAdd(map);
// If decoration has been provided, add it to the map as well
this.decorationLayer?.addTo(map);
return this;
}
onRemove(map: Map) {
// If decoration has been provided, remove it from the map as well
this.decorationLayer?.remove();
return super.onRemove(map);
}
}

View File

@ -6,7 +6,6 @@ import type { DataZoomComponentOption } from "echarts/components";
import type { EChartsType } from "echarts/core"; import type { EChartsType } from "echarts/core";
import type { import type {
ECElementEvent, ECElementEvent,
SetOptionOpts,
XAXisOption, XAXisOption,
YAXisOption, YAXisOption,
} from "echarts/types/dist/shared"; } from "echarts/types/dist/shared";
@ -25,6 +24,7 @@ import type { HomeAssistant } from "../../types";
import { isMac } from "../../util/is_mac"; import { isMac } from "../../util/is_mac";
import "../ha-icon-button"; import "../ha-icon-button";
import { formatTimeLabel } from "./axis-label"; import { formatTimeLabel } from "./axis-label";
import { ensureArray } from "../../common/array/ensure-array";
export const MIN_TIME_BETWEEN_UPDATES = 60 * 5 * 1000; export const MIN_TIME_BETWEEN_UPDATES = 60 * 5 * 1000;
@ -68,12 +68,16 @@ export class HaChartBase extends LitElement {
private _listeners: (() => void)[] = []; private _listeners: (() => void)[] = [];
private _originalZrFlush?: () => void;
public disconnectedCallback() { public disconnectedCallback() {
super.disconnectedCallback(); super.disconnectedCallback();
while (this._listeners.length) { while (this._listeners.length) {
this._listeners.pop()!(); this._listeners.pop()!();
} }
this.chart?.dispose(); this.chart?.dispose();
this.chart = undefined;
this._originalZrFlush = undefined;
} }
public connectedCallback() { public connectedCallback() {
@ -86,7 +90,7 @@ export class HaChartBase extends LitElement {
listenMediaQuery("(prefers-reduced-motion)", (matches) => { listenMediaQuery("(prefers-reduced-motion)", (matches) => {
if (this._reducedMotion !== matches) { if (this._reducedMotion !== matches) {
this._reducedMotion = matches; this._reducedMotion = matches;
this.chart?.setOption({ animation: !this._reducedMotion }); this._setChartOptions({ animation: !this._reducedMotion });
} }
}) })
); );
@ -96,7 +100,7 @@ export class HaChartBase extends LitElement {
if ((isMac && ev.key === "Meta") || (!isMac && ev.key === "Control")) { if ((isMac && ev.key === "Meta") || (!isMac && ev.key === "Control")) {
this._modifierPressed = true; this._modifierPressed = true;
if (!this.options?.dataZoom) { if (!this.options?.dataZoom) {
this.chart?.setOption({ dataZoom: this._getDataZoomConfig() }); this._setChartOptions({ dataZoom: this._getDataZoomConfig() });
} }
} }
}; };
@ -105,7 +109,7 @@ export class HaChartBase extends LitElement {
if ((isMac && ev.key === "Meta") || (!isMac && ev.key === "Control")) { if ((isMac && ev.key === "Meta") || (!isMac && ev.key === "Control")) {
this._modifierPressed = false; this._modifierPressed = false;
if (!this.options?.dataZoom) { if (!this.options?.dataZoom) {
this.chart?.setOption({ dataZoom: this._getDataZoomConfig() }); this._setChartOptions({ dataZoom: this._getDataZoomConfig() });
} }
} }
}; };
@ -131,10 +135,8 @@ export class HaChartBase extends LitElement {
return; return;
} }
let chartOptions: ECOption = {}; let chartOptions: ECOption = {};
const chartUpdateParams: SetOptionOpts = { lazyUpdate: true };
if (changedProps.has("data")) { if (changedProps.has("data")) {
chartOptions.series = this.data; chartOptions.series = this.data;
chartUpdateParams.replaceMerge = ["series"];
} }
if (changedProps.has("options")) { if (changedProps.has("options")) {
chartOptions = { ...chartOptions, ...this._createOptions() }; chartOptions = { ...chartOptions, ...this._createOptions() };
@ -142,7 +144,7 @@ export class HaChartBase extends LitElement {
chartOptions.dataZoom = this._getDataZoomConfig(); chartOptions.dataZoom = this._getDataZoomConfig();
} }
if (Object.keys(chartOptions).length > 0) { if (Object.keys(chartOptions).length > 0) {
this.chart.setOption(chartOptions, chartUpdateParams); this._setChartOptions(chartOptions);
} }
} }
@ -509,6 +511,31 @@ export class HaChartBase extends LitElement {
return Math.max(this.clientWidth / 2, 200); return Math.max(this.clientWidth / 2, 200);
} }
private _setChartOptions(options: ECOption) {
if (!this.chart) {
return;
}
if (!this._originalZrFlush) {
const dataSize = ensureArray(this.data).reduce(
(acc, series) => acc + (series.data as any[]).length,
0
);
if (dataSize > 10000) {
// delay the last bit of the render to avoid blocking the main thread
// this is not that impactful with sampling enabled but it doesn't hurt to have it
const zr = this.chart.getZr();
this._originalZrFlush = zr.flush;
zr.flush = () => {
setTimeout(() => {
this._originalZrFlush?.call(zr);
}, 5);
};
}
}
const replaceMerge = options.series ? ["series"] : [];
this.chart.setOption(options, { replaceMerge });
}
private _handleZoomReset() { private _handleZoomReset() {
this.chart?.dispatchAction({ type: "dataZoom", start: 0, end: 100 }); this.chart?.dispatchAction({ type: "dataZoom", start: 0, end: 100 });
} }

View File

@ -354,9 +354,10 @@ export class StateHistoryChartLine extends LitElement {
name: nameY, name: nameY,
color, color,
symbol: "circle", symbol: "circle",
step: "end",
animationDurationUpdate: 0,
symbolSize: 1, symbolSize: 1,
step: "end",
sampling: "minmax",
animationDurationUpdate: 0,
lineStyle: { lineStyle: {
width: fill ? 0 : 1.5, width: fill ? 0 : 1.5,
}, },

View File

@ -492,8 +492,8 @@ export class StatisticsChart extends LitElement {
: this.hass.localize( : this.hass.localize(
`ui.components.statistics_charts.statistic_types.${type}` `ui.components.statistics_charts.statistic_types.${type}`
), ),
symbol: "circle", symbol: "none",
symbolSize: 0, sampling: "minmax",
animationDurationUpdate: 0, animationDurationUpdate: 0,
lineStyle: { lineStyle: {
width: 1.5, width: 1.5,
@ -511,7 +511,6 @@ export class StatisticsChart extends LitElement {
if (band && this.chartType === "line") { if (band && this.chartType === "line") {
series.stack = `band-${statistic_id}`; series.stack = `band-${statistic_id}`;
series.stackStrategy = "all"; series.stackStrategy = "all";
(series as LineSeriesOption).symbol = "none";
if (drawBands && type === "max") { if (drawBands && type === "max") {
(series as LineSeriesOption).areaStyle = { (series as LineSeriesOption).areaStyle = {
color: color + "3F", color: color + "3F",

View File

@ -0,0 +1,110 @@
import { customElement, property, state } from "lit/decorators";
import { css, html, LitElement, nothing } from "lit";
import { mdiContentCopy, mdiEye, mdiEyeOff } from "@mdi/js";
import "./ha-button";
import "./ha-icon-button";
import "./ha-svg-icon";
import "./ha-textfield";
import type { HomeAssistant } from "../types";
import { copyToClipboard } from "../common/util/copy-clipboard";
import { showToast } from "../util/toast";
import type { HaTextField } from "./ha-textfield";
@customElement("ha-copy-textfield")
export class HaCopyTextfield extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: "value" }) public value!: string;
@property({ attribute: "masked-value" }) public maskedValue?: string;
@property({ attribute: "label" }) public label?: string;
@state() private _showMasked = true;
public render() {
return html`
<div class="container">
<div class="textfield-container">
<ha-textfield
.value=${this._showMasked && this.maskedValue
? this.maskedValue
: this.value}
readonly
.suffix=${this.maskedValue
? html`<div style="width: 24px"></div>`
: nothing}
@click=${this._focusInput}
></ha-textfield>
${this.maskedValue
? html`<ha-icon-button
class="toggle-unmasked"
.label=${this.hass.localize(
`ui.common.${this._showMasked ? "show" : "hide"}`
)}
@click=${this._toggleMasked}
.path=${this._showMasked ? mdiEye : mdiEyeOff}
></ha-icon-button>`
: nothing}
</div>
<ha-button @click=${this._copy} unelevated>
<ha-svg-icon slot="icon" .path=${mdiContentCopy}></ha-svg-icon>
${this.label || this.hass.localize("ui.common.copy")}
</ha-button>
</div>
`;
}
private _focusInput(ev) {
const inputElement = ev.currentTarget as HaTextField;
inputElement.select();
}
private _toggleMasked(): void {
this._showMasked = !this._showMasked;
}
private async _copy(): Promise<void> {
await copyToClipboard(this.value);
showToast(this, {
message: this.hass.localize("ui.common.copied_clipboard"),
});
}
static styles = css`
.container {
display: flex;
align-items: center;
gap: 8px;
margin-top: 8px;
}
.textfield-container {
position: relative;
flex: 1;
}
.textfield-container ha-textfield {
display: block;
}
.toggle-unmasked {
position: absolute;
top: 8px;
right: 8px;
inset-inline-start: initial;
inset-inline-end: 8px;
--mdc-icon-button-size: 40px;
--mdc-icon-size: 20px;
color: var(--secondary-text-color);
direction: var(--direction);
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-copy-textfield": HaCopyTextfield;
}
}

View File

@ -80,7 +80,6 @@ export class HaFormString extends LitElement implements HaFormElement {
if (!this.isPassword) return nothing; if (!this.isPassword) return nothing;
return html` return html`
<ha-icon-button <ha-icon-button
toggles
.label=${this.localize?.( .label=${this.localize?.(
`${this.localizeBaseKey}.${ `${this.localizeBaseKey}.${
this.unmaskedPassword ? "hide_password" : "show_password" this.unmaskedPassword ? "hide_password" : "show_password"

View File

@ -132,7 +132,6 @@ export class HaPasswordField extends LitElement {
@change=${this._handleChangeEvent} @change=${this._handleChangeEvent}
></ha-textfield> ></ha-textfield>
<ha-icon-button <ha-icon-button
toggles
.label=${this.hass?.localize( .label=${this.hass?.localize(
this._unmaskedPassword this._unmaskedPassword
? "ui.components.selectors.text.hide_password" ? "ui.components.selectors.text.hide_password"

View File

@ -95,7 +95,6 @@ export class HaTextSelector extends LitElement {
></ha-textfield> ></ha-textfield>
${this.selector.text?.type === "password" ${this.selector.text?.type === "password"
? html`<ha-icon-button ? html`<ha-icon-button
toggles
.label=${this.hass?.localize( .label=${this.hass?.localize(
this._unmaskedPassword this._unmaskedPassword
? "ui.components.selectors.text.hide_password" ? "ui.components.selectors.text.hide_password"

View File

@ -8,9 +8,10 @@ import type {
Map, Map,
Marker, Marker,
Polyline, Polyline,
MarkerClusterGroup,
} from "leaflet"; } from "leaflet";
import type { PropertyValues } from "lit"; import type { PropertyValues } from "lit";
import { ReactiveElement, css } from "lit"; import { css, ReactiveElement } from "lit";
import { customElement, property, state } from "lit/decorators"; import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../common/dom/fire_event"; import { fireEvent } from "../../common/dom/fire_event";
import { formatDateTime } from "../../common/datetime/format_date_time"; import { formatDateTime } from "../../common/datetime/format_date_time";
@ -26,6 +27,7 @@ import type { HomeAssistant, ThemeMode } from "../../types";
import { isTouch } from "../../util/is_touch"; import { isTouch } from "../../util/is_touch";
import "../ha-icon-button"; import "../ha-icon-button";
import "./ha-entity-marker"; import "./ha-entity-marker";
import { DecoratedMarker } from "../../common/map/decorated_marker";
declare global { declare global {
// for fire event // for fire event
@ -84,6 +86,9 @@ export class HaMap extends ReactiveElement {
@property({ type: Number }) public zoom = 14; @property({ type: Number }) public zoom = 14;
@property({ attribute: "cluster-markers", type: Boolean })
public clusterMarkers = true;
@state() private _loaded = false; @state() private _loaded = false;
public leafletMap?: Map; public leafletMap?: Map;
@ -96,10 +101,12 @@ export class HaMap extends ReactiveElement {
private _mapFocusItems: (Marker | Circle)[] = []; private _mapFocusItems: (Marker | Circle)[] = [];
private _mapZones: (Marker | Circle)[] = []; private _mapZones: DecoratedMarker[] = [];
private _mapFocusZones: (Marker | Circle)[] = []; private _mapFocusZones: (Marker | Circle)[] = [];
private _mapCluster: MarkerClusterGroup | undefined;
private _mapPaths: (Polyline | CircleMarker)[] = []; private _mapPaths: (Polyline | CircleMarker)[] = [];
private _clickCount = 0; private _clickCount = 0;
@ -151,6 +158,10 @@ export class HaMap extends ReactiveElement {
} }
} }
if (changedProps.has("clusterMarkers")) {
this._drawEntities();
}
if (changedProps.has("_loaded") || changedProps.has("paths")) { if (changedProps.has("_loaded") || changedProps.has("paths")) {
this._drawPaths(); this._drawPaths();
} }
@ -175,6 +186,7 @@ export class HaMap extends ReactiveElement {
) { ) {
return; return;
} }
this._updateMapStyle(); this._updateMapStyle();
} }
@ -426,6 +438,11 @@ export class HaMap extends ReactiveElement {
this._mapFocusZones = []; this._mapFocusZones = [];
} }
if (this._mapCluster) {
this._mapCluster.remove();
this._mapCluster = undefined;
}
if (!this.entities) { if (!this.entities) {
return; return;
} }
@ -481,9 +498,14 @@ export class HaMap extends ReactiveElement {
iconHTML = el.outerHTML; iconHTML = el.outerHTML;
} }
// create marker with the icon // create circle around it
this._mapZones.push( const circle = Leaflet.circle([latitude, longitude], {
Leaflet.marker([latitude, longitude], { interactive: false,
color: passive ? passiveZoneColor : zoneColor,
radius,
});
const marker = new DecoratedMarker([latitude, longitude], circle, {
icon: Leaflet.divIcon({ icon: Leaflet.divIcon({
html: iconHTML, html: iconHTML,
iconSize: [24, 24], iconSize: [24, 24],
@ -491,16 +513,9 @@ export class HaMap extends ReactiveElement {
}), }),
interactive: this.interactiveZones, interactive: this.interactiveZones,
title, title,
})
);
// create circle around it
const circle = Leaflet.circle([latitude, longitude], {
interactive: false,
color: passive ? passiveZoneColor : zoneColor,
radius,
}); });
this._mapZones.push(circle);
this._mapZones.push(marker);
if ( if (
this.fitZones && this.fitZones &&
(typeof entity === "string" || entity.focus !== false) (typeof entity === "string" || entity.focus !== false)
@ -538,7 +553,7 @@ export class HaMap extends ReactiveElement {
} }
// create marker with the icon // create marker with the icon
const marker = Leaflet.marker([latitude, longitude], { const marker = new DecoratedMarker([latitude, longitude], undefined, {
icon: Leaflet.divIcon({ icon: Leaflet.divIcon({
html: entityMarker, html: entityMarker,
iconSize: [48, 48], iconSize: [48, 48],
@ -546,24 +561,34 @@ export class HaMap extends ReactiveElement {
}), }),
title: title, title: title,
}); });
this._mapItems.push(marker);
if (typeof entity === "string" || entity.focus !== false) { if (typeof entity === "string" || entity.focus !== false) {
this._mapFocusItems.push(marker); this._mapFocusItems.push(marker);
} }
// create circle around if entity has accuracy // create circle around if entity has accuracy
if (gpsAccuracy) { if (gpsAccuracy) {
this._mapItems.push( marker.decorationLayer = Leaflet.circle([latitude, longitude], {
Leaflet.circle([latitude, longitude], {
interactive: false, interactive: false,
color: darkPrimaryColor, color: darkPrimaryColor,
radius: gpsAccuracy, radius: gpsAccuracy,
}) });
);
}
} }
this._mapItems.push(marker);
}
if (this.clusterMarkers) {
this._mapCluster = Leaflet.markerClusterGroup({
showCoverageOnHover: false,
removeOutsideVisibleBounds: false,
maxClusterRadius: 40,
});
this._mapCluster.addLayers(this._mapItems);
map.addLayer(this._mapCluster);
} else {
this._mapItems.forEach((marker) => map.addLayer(marker)); this._mapItems.forEach((marker) => map.addLayer(marker));
}
this._mapZones.forEach((marker) => map.addLayer(marker)); this._mapZones.forEach((marker) => map.addLayer(marker));
} }

View File

@ -12,6 +12,7 @@ import type { HomeAssistant } from "../types";
import { fileDownload } from "../util/file_download"; import { fileDownload } from "../util/file_download";
import { domainToName } from "./integration"; import { domainToName } from "./integration";
import type { FrontendLocaleData } from "./translation"; import type { FrontendLocaleData } from "./translation";
import type { BackupManagerState, ManagerStateEvent } from "./backup_manager";
import checkValidDate from "../common/datetime/check_valid_date"; import checkValidDate from "../common/datetime/check_valid_date";
import { handleFetchPromise } from "../util/hass-call-api"; import { handleFetchPromise } from "../util/hass-call-api";
@ -130,7 +131,13 @@ export interface BackupContentExtended extends BackupContent, BackupData {}
export interface BackupInfo { export interface BackupInfo {
backups: BackupContent[]; backups: BackupContent[];
backing_up: boolean; agent_errors: Record<string, string>;
last_attempted_automatic_backup: string | null;
last_completed_automatic_backup: string | null;
last_non_idle_event: ManagerStateEvent | null;
next_automatic_backup: string | null;
next_automatic_backup_additional: boolean;
state: BackupManagerState;
} }
export interface BackupDetails { export interface BackupDetails {

View File

@ -73,6 +73,7 @@ export interface CloudWebhook {
interface CloudLoginBase { interface CloudLoginBase {
hass: HomeAssistant; hass: HomeAssistant;
email: string; email: string;
check_connection?: boolean;
} }
export interface CloudLoginPassword extends CloudLoginBase { export interface CloudLoginPassword extends CloudLoginBase {

View File

@ -233,11 +233,11 @@ export const restoreBackup = async (
type: HassioBackupDetail["type"], type: HassioBackupDetail["type"],
backupSlug: string, backupSlug: string,
backupDetails: HassioPartialBackupCreateParams | HassioFullBackupCreateParams, backupDetails: HassioPartialBackupCreateParams | HassioFullBackupCreateParams,
useSnapshotUrl: boolean useBackupUrl: boolean
): Promise<void> => { ): Promise<void> => {
await hass.callApi<HassioResponse<{ job_id: string }>>( await hass.callApi<HassioResponse<{ job_id: string }>>(
"POST", "POST",
`hassio/${useSnapshotUrl ? "snapshots" : "backups"}/${backupSlug}/restore/${type}`, `hassio/${useBackupUrl ? "backups" : "snapshots"}/${backupSlug}/restore/${type}`,
backupDetails backupDetails
); );
}; };

View File

@ -85,6 +85,7 @@ class StepFlowCreateEntry extends LitElement {
assistSatelliteSupportsSetupFlow(this.hass.states[satellite.entity_id]) assistSatelliteSupportsSetupFlow(this.hass.states[satellite.entity_id])
) )
) { ) {
this.navigateToResult = false;
this._flowDone(); this._flowDone();
showVoiceAssistantSetupDialog(this, { showVoiceAssistantSetupDialog(this, {
deviceId: devices[0].id, deviceId: devices[0].id,

View File

@ -40,8 +40,13 @@ export class DialogEnterCode
@state() private _showClearButton = false; @state() private _showClearButton = false;
@state() private _narrow = false;
public async showDialog(dialogParams: EnterCodeDialogParams): Promise<void> { public async showDialog(dialogParams: EnterCodeDialogParams): Promise<void> {
this._dialogParams = dialogParams; this._dialogParams = dialogParams;
this._narrow = matchMedia(
"all and (max-width: 450px), all and (max-height: 500px)"
).matches;
await this.updateComplete; await this.updateComplete;
} }
@ -96,7 +101,7 @@ export class DialogEnterCode
> >
<ha-textfield <ha-textfield
class="input" class="input"
dialogInitialFocus ?dialogInitialFocus=${!this._narrow}
id="code" id="code"
.label=${this.hass.localize("ui.dialogs.enter_code.input_label")} .label=${this.hass.localize("ui.dialogs.enter_code.input_label")}
type="password" type="password"
@ -134,6 +139,7 @@ export class DialogEnterCode
.label=${this.hass.localize("ui.dialogs.enter_code.input_label")} .label=${this.hass.localize("ui.dialogs.enter_code.input_label")}
type="password" type="password"
inputmode="numeric" inputmode="numeric"
?dialogInitialFocus=${!this._narrow}
></ha-textfield> ></ha-textfield>
<div class="keypad"> <div class="keypad">
${BUTTONS.map((value) => ${BUTTONS.map((value) =>

View File

@ -49,6 +49,8 @@ class LightRgbColorPicker extends LitElement {
@state() private _hsPickerValue?: [number, number]; @state() private _hsPickerValue?: [number, number];
@state() private _isInteracting?: boolean;
protected render() { protected render() {
if (!this.stateObj) { if (!this.stateObj) {
return nothing; return nothing;
@ -211,7 +213,10 @@ class LightRgbColorPicker extends LitElement {
public willUpdate(changedProps: PropertyValues) { public willUpdate(changedProps: PropertyValues) {
super.willUpdate(changedProps); super.willUpdate(changedProps);
if (!changedProps.has("entityId") && !changedProps.has("hass")) { if (
this._isInteracting ||
(!changedProps.has("entityId") && !changedProps.has("hass"))
) {
return; return;
} }
@ -219,10 +224,13 @@ class LightRgbColorPicker extends LitElement {
} }
private _hsColorCursorMoved(ev: CustomEvent) { private _hsColorCursorMoved(ev: CustomEvent) {
if (!ev.detail.value) { const color = ev.detail.value;
this._isInteracting = color !== undefined;
if (color === undefined) {
return; return;
} }
this._hsPickerValue = ev.detail.value; this._hsPickerValue = color;
this._throttleUpdateColor(); this._throttleUpdateColor();
} }

View File

@ -22,7 +22,6 @@ import { DOMAIN_ATTRIBUTES_UNITS } from "../../../../data/entity_attributes";
declare global { declare global {
interface HASSDomEvents { interface HASSDomEvents {
"color-changed": LightColor; "color-changed": LightColor;
"color-hovered": LightColor | undefined;
} }
} }
@ -54,6 +53,8 @@ class LightColorTempPicker extends LitElement {
@state() private _ctPickerValue?: number; @state() private _ctPickerValue?: number;
@state() private _isInteracting?: boolean;
protected render() { protected render() {
if (!this.stateObj) { if (!this.stateObj) {
return nothing; return nothing;
@ -113,7 +114,7 @@ class LightColorTempPicker extends LitElement {
public willUpdate(changedProps: PropertyValues) { public willUpdate(changedProps: PropertyValues) {
super.willUpdate(changedProps); super.willUpdate(changedProps);
if (!changedProps.has("stateObj")) { if (this._isInteracting || !changedProps.has("stateObj")) {
return; return;
} }
@ -123,16 +124,14 @@ class LightColorTempPicker extends LitElement {
private _ctColorCursorMoved(ev: CustomEvent) { private _ctColorCursorMoved(ev: CustomEvent) {
const ct = ev.detail.value; const ct = ev.detail.value;
this._isInteracting = ct !== undefined;
if (isNaN(ct) || this._ctPickerValue === ct) { if (isNaN(ct) || this._ctPickerValue === ct) {
return; return;
} }
this._ctPickerValue = ct; this._ctPickerValue = ct;
fireEvent(this, "color-hovered", {
color_temp_kelvin: ct,
});
this._throttleUpdateColorTemp(); this._throttleUpdateColorTemp();
} }
@ -143,8 +142,6 @@ class LightColorTempPicker extends LitElement {
private _ctColorChanged(ev: CustomEvent) { private _ctColorChanged(ev: CustomEvent) {
const ct = ev.detail.value; const ct = ev.detail.value;
fireEvent(this, "color-hovered", undefined);
if (isNaN(ct) || this._ctPickerValue === ct) { if (isNaN(ct) || this._ctPickerValue === ct) {
return; return;
} }

View File

@ -99,7 +99,12 @@ class MoreInfoSirenAdvancedControls extends LitElement {
this._stateObj.attributes.available_tones this._stateObj.attributes.available_tones
).map( ).map(
([toneId, toneName]) => html` ([toneId, toneName]) => html`
<ha-list-item .value=${toneId} <ha-list-item
.value=${Array.isArray(
this._stateObj!.attributes.available_tones
)
? toneName
: toneId}
>${toneName}</ha-list-item >${toneName}</ha-list-item
> >
` `
@ -179,7 +184,7 @@ class MoreInfoSirenAdvancedControls extends LitElement {
await this.hass.callService("siren", "turn_on", { await this.hass.callService("siren", "turn_on", {
entity_id: this._stateObj!.entity_id, entity_id: this._stateObj!.entity_id,
tone: this._tone, tone: this._tone,
volume: this._volume, volume_level: this._volume,
duration: this._duration, duration: this._duration,
}); });
} }

View File

@ -251,6 +251,7 @@ export class QuickBar extends LitElement {
<mwc-list> <mwc-list>
${this._opened ${this._opened
? html`<lit-virtualizer ? html`<lit-virtualizer
tabindex="-1"
scroller scroller
@keydown=${this._handleListItemKeyDown} @keydown=${this._handleListItemKeyDown}
@rangechange=${this._handleRangeChanged} @rangechange=${this._handleRangeChanged}
@ -326,6 +327,7 @@ export class QuickBar extends LitElement {
.twoline=${Boolean(item.area)} .twoline=${Boolean(item.area)}
.item=${item} .item=${item}
index=${ifDefined(index)} index=${ifDefined(index)}
tabindex="0"
> >
<span>${item.primaryText}</span> <span>${item.primaryText}</span>
${item.area ${item.area
@ -346,6 +348,7 @@ export class QuickBar extends LitElement {
.item=${item} .item=${item}
index=${ifDefined(index)} index=${ifDefined(index)}
graphic="icon" graphic="icon"
tabindex="0"
> >
${item.iconPath ${item.iconPath
? html` ? html`
@ -375,6 +378,7 @@ export class QuickBar extends LitElement {
index=${ifDefined(index)} index=${ifDefined(index)}
class="command-item" class="command-item"
hasMeta hasMeta
tabindex="0"
> >
<span> <span>
<ha-label <ha-label

View File

@ -10,6 +10,7 @@ import "../../../components/ha-svg-icon";
import "../../../components/ha-textfield"; import "../../../components/ha-textfield";
import type { HaTextField } from "../../../components/ha-textfield"; import type { HaTextField } from "../../../components/ha-textfield";
import { cloudLogin } from "../../../data/cloud"; import { cloudLogin } from "../../../data/cloud";
import { showCloudAlreadyConnectedDialog } from "../../../panels/config/cloud/dialog-cloud-already-connected/show-dialog-cloud-already-connected";
import type { HomeAssistant } from "../../../types"; import type { HomeAssistant } from "../../../types";
import { import {
showAlertDialog, showAlertDialog,
@ -25,6 +26,8 @@ export class CloudStepSignin extends LitElement {
@state() private _error?: string; @state() private _error?: string;
@state() private _checkConnection = true;
@query("#email", true) private _emailField!: HaTextField; @query("#email", true) private _emailField!: HaTextField;
@query("#password", true) private _passwordField!: HaPasswordField; @query("#password", true) private _passwordField!: HaPasswordField;
@ -115,6 +118,7 @@ export class CloudStepSignin extends LitElement {
hass: this.hass, hass: this.hass,
email: username, email: username,
...(code ? { code } : { password }), ...(code ? { code } : { password }),
check_connection: this._checkConnection,
}); });
} catch (err: any) { } catch (err: any) {
const errCode = err && err.body && err.body.code; const errCode = err && err.body && err.body.code;
@ -139,6 +143,20 @@ export class CloudStepSignin extends LitElement {
} }
} }
if (errCode === "alreadyconnectederror") {
showCloudAlreadyConnectedDialog(this, {
details: JSON.parse(err.body.message),
logInHereAction: () => {
this._checkConnection = false;
doLogin(username);
},
closeDialog: () => {
this._requestInProgress = false;
},
});
return;
}
if (errCode === "usernotfound" && username !== username.toLowerCase()) { if (errCode === "usernotfound" && username !== username.toLowerCase()) {
await doLogin(username.toLowerCase()); await doLogin(username.toLowerCase());
return; return;

View File

@ -4,6 +4,7 @@ import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one"; import memoizeOne from "memoize-one";
import { fireEvent } from "../../../../../common/dom/fire_event"; import { fireEvent } from "../../../../../common/dom/fire_event";
import { computeDomain } from "../../../../../common/entity/compute_domain"; import { computeDomain } from "../../../../../common/entity/compute_domain";
import { navigate } from "../../../../../common/navigate";
import "../../../../../components/ha-icon-button"; import "../../../../../components/ha-icon-button";
import "../../../../../components/ha-md-list"; import "../../../../../components/ha-md-list";
import "../../../../../components/ha-md-list-item"; import "../../../../../components/ha-md-list-item";
@ -22,7 +23,6 @@ import {
import type { CloudStatus } from "../../../../../data/cloud"; import type { CloudStatus } from "../../../../../data/cloud";
import type { HomeAssistant } from "../../../../../types"; import type { HomeAssistant } from "../../../../../types";
import { brandsUrl } from "../../../../../util/brands-url"; import { brandsUrl } from "../../../../../util/brands-url";
import { navigate } from "../../../../../common/navigate";
const DEFAULT_AGENTS = []; const DEFAULT_AGENTS = [];

View File

@ -46,7 +46,7 @@ enum BackupScheduleTime {
} }
interface RetentionData { interface RetentionData {
type: "copies" | "days"; type: "copies" | "days" | "forever";
value: number; value: number;
} }
@ -55,7 +55,7 @@ const RETENTION_PRESETS: Record<
RetentionData RetentionData
> = { > = {
copies_3: { type: "copies", value: 3 }, copies_3: { type: "copies", value: 3 },
forever: { type: "days", value: 0 }, forever: { type: "forever", value: 0 },
}; };
const SCHEDULE_OPTIONS = [ const SCHEDULE_OPTIONS = [
@ -79,7 +79,10 @@ const computeRetentionPreset = (
data: RetentionData data: RetentionData
): RetentionPreset | undefined => { ): RetentionPreset | undefined => {
for (const [key, value] of Object.entries(RETENTION_PRESETS)) { for (const [key, value] of Object.entries(RETENTION_PRESETS)) {
if (value.type === data.type && value.value === data.value) { if (
value.type === data.type &&
(value.type === RetentionPreset.FOREVER || value.value === data.value)
) {
return key as RetentionPreset; return key as RetentionPreset;
} }
} }
@ -92,7 +95,7 @@ interface FormData {
time?: string | null; time?: string | null;
days: BackupDay[]; days: BackupDay[];
retention: { retention: {
type: "copies" | "days"; type: "copies" | "days" | "forever";
value: number; value: number;
}; };
} }
@ -142,7 +145,12 @@ class HaBackupConfigSchedule extends LitElement {
? config.schedule.days ? config.schedule.days
: [], : [],
retention: { retention: {
type: config.retention.days != null ? "days" : "copies", type:
config.retention.days === null && config.retention.copies === null
? "forever"
: config.retention.days != null
? "days"
: "copies",
value: config.retention.days ?? config.retention.copies ?? 3, value: config.retention.days ?? config.retention.copies ?? 3,
}, },
}; };
@ -160,7 +168,9 @@ class HaBackupConfigSchedule extends LitElement {
: [], : [],
}, },
retention: retention:
data.retention.type === "days" data.retention.type === "forever"
? { days: null, copies: null }
: data.retention.type === "days"
? { days: data.retention.value, copies: null } ? { days: data.retention.value, copies: null }
: { copies: data.retention.value, days: null }, : { copies: data.retention.value, days: null },
}; };
@ -481,9 +491,19 @@ class HaBackupConfigSchedule extends LitElement {
private _retentionPresetChanged(ev) { private _retentionPresetChanged(ev) {
ev.stopPropagation(); ev.stopPropagation();
const target = ev.currentTarget as HaMdSelect; const target = ev.currentTarget as HaMdSelect;
const value = target.value as RetentionPreset; let value = target.value as RetentionPreset;
// custom needs to have a type of days or copies, set it to default copies 3
if (
value === RetentionPreset.CUSTOM &&
this._retentionPreset === RetentionPreset.FOREVER
) {
this._retentionPreset = value; this._retentionPreset = value;
value = RetentionPreset.COPIES_3;
} else {
this._retentionPreset = value;
}
if (value !== RetentionPreset.CUSTOM) { if (value !== RetentionPreset.CUSTOM) {
const data = this._getData(this.value); const data = this._getData(this.value);
const retention = RETENTION_PRESETS[value]; const retention = RETENTION_PRESETS[value];
@ -493,7 +513,7 @@ class HaBackupConfigSchedule extends LitElement {
} }
this._setData({ this._setData({
...data, ...data,
retention: RETENTION_PRESETS[value], retention,
}); });
} }
} }
@ -504,6 +524,7 @@ class HaBackupConfigSchedule extends LitElement {
const value = parseInt(target.value); const value = parseInt(target.value);
const clamped = clamp(value, MIN_VALUE, MAX_VALUE); const clamped = clamp(value, MIN_VALUE, MAX_VALUE);
const data = this._getData(this.value); const data = this._getData(this.value);
target.value = clamped.toString();
this._setData({ this._setData({
...data, ...data,
retention: { retention: {

View File

@ -8,7 +8,7 @@ import {
mdiUpload, mdiUpload,
} from "@mdi/js"; } from "@mdi/js";
import type { CSSResultGroup, TemplateResult } from "lit"; import type { CSSResultGroup, TemplateResult } from "lit";
import { html, LitElement, nothing } from "lit"; import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators"; import { customElement, property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one"; import memoizeOne from "memoize-one";
import { isComponentLoaded } from "../../../common/config/is_component_loaded"; import { isComponentLoaded } from "../../../common/config/is_component_loaded";
@ -27,6 +27,7 @@ import type {
} from "../../../components/data-table/ha-data-table"; } from "../../../components/data-table/ha-data-table";
import "../../../components/ha-button"; import "../../../components/ha-button";
import "../../../components/ha-button-menu"; import "../../../components/ha-button-menu";
import "../../../components/ha-circular-progress";
import "../../../components/ha-fab"; import "../../../components/ha-fab";
import "../../../components/ha-filter-states"; import "../../../components/ha-filter-states";
import "../../../components/ha-icon"; import "../../../components/ha-icon";
@ -460,7 +461,17 @@ class HaConfigBackupBackups extends SubscribeMixin(LitElement) {
extended extended
@click=${this._newBackup} @click=${this._newBackup}
> >
<ha-svg-icon slot="icon" .path=${mdiPlus}></ha-svg-icon> ${backupInProgress
? html`<div slot="icon">
<ha-circular-progress
.size=${"small"}
indeterminate
></ha-circular-progress>
</div>`
: html`<ha-svg-icon
slot="icon"
.path=${mdiPlus}
></ha-svg-icon>`}
</ha-fab> </ha-fab>
` `
: nothing} : nothing}
@ -605,7 +616,14 @@ class HaConfigBackupBackups extends SubscribeMixin(LitElement) {
} }
static get styles(): CSSResultGroup { static get styles(): CSSResultGroup {
return haStyle; return [
haStyle,
css`
ha-circular-progress {
--md-sys-color-primary: var(--mdc-theme-on-secondary);
}
`,
];
} }
} }

View File

@ -8,6 +8,7 @@ import "../../../components/ha-button";
import "../../../components/ha-button-menu"; import "../../../components/ha-button-menu";
import "../../../components/ha-card"; import "../../../components/ha-card";
import "../../../components/ha-fab"; import "../../../components/ha-fab";
import "../../../components/ha-circular-progress";
import "../../../components/ha-icon"; import "../../../components/ha-icon";
import "../../../components/ha-icon-next"; import "../../../components/ha-icon-next";
import "../../../components/ha-icon-overflow-menu"; import "../../../components/ha-icon-overflow-menu";
@ -17,8 +18,10 @@ import type {
BackupAgent, BackupAgent,
BackupConfig, BackupConfig,
BackupContent, BackupContent,
BackupInfo,
} from "../../../data/backup"; } from "../../../data/backup";
import { import {
computeBackupAgentName,
generateBackup, generateBackup,
generateBackupWithAutomaticSettings, generateBackupWithAutomaticSettings,
} from "../../../data/backup"; } from "../../../data/backup";
@ -50,6 +53,8 @@ class HaConfigBackupOverview extends LitElement {
@property({ attribute: false }) public manager!: ManagerStateEvent; @property({ attribute: false }) public manager!: ManagerStateEvent;
@property({ attribute: false }) public info?: BackupInfo;
@property({ attribute: false }) public backups: BackupContent[] = []; @property({ attribute: false }) public backups: BackupContent[] = [];
@property({ attribute: false }) public fetching = false; @property({ attribute: false }) public fetching = false;
@ -151,6 +156,26 @@ class HaConfigBackupOverview extends LitElement {
</ha-list-item> </ha-list-item>
</ha-button-menu> </ha-button-menu>
<div class="content"> <div class="content">
${this.info && Object.keys(this.info.agent_errors).length
? html`${Object.entries(this.info.agent_errors).map(
([agentId, error]) =>
html`<ha-alert
alert-type="error"
.title=${this.hass.localize(
"ui.panel.config.backup.overview.agent_error",
{
name: computeBackupAgentName(
this.hass.localize,
agentId,
this.agents
),
}
)}
>
${error}
</ha-alert>`
)}`
: nothing}
${backupInProgress ${backupInProgress
? html` ? html`
<ha-backup-overview-progress <ha-backup-overview-progress
@ -204,7 +229,14 @@ class HaConfigBackupOverview extends LitElement {
extended extended
@click=${this._newBackup} @click=${this._newBackup}
> >
<ha-svg-icon slot="icon" .path=${mdiPlus}></ha-svg-icon> ${backupInProgress
? html`<div slot="icon">
<ha-circular-progress
.size=${"small"}
indeterminate
></ha-circular-progress>
</div>`
: html`<ha-svg-icon slot="icon" .path=${mdiPlus}></ha-svg-icon>`}
</ha-fab> </ha-fab>
</hass-subpage> </hass-subpage>
`; `;
@ -231,6 +263,9 @@ class HaConfigBackupOverview extends LitElement {
padding-left: 0; padding-left: 0;
padding-right: 0; padding-right: 0;
} }
ha-circular-progress {
--md-sys-color-primary: var(--mdc-theme-on-secondary);
}
`, `,
]; ];
} }

View File

@ -1,4 +1,4 @@
import { mdiDotsVertical, mdiHarddisk } from "@mdi/js"; import { mdiDotsVertical, mdiHarddisk, mdiOpenInNew } from "@mdi/js";
import type { PropertyValues } from "lit"; import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit"; import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators"; import { customElement, property, state } from "lit/decorators";
@ -28,6 +28,7 @@ import "./components/config/ha-backup-config-encryption-key";
import "./components/config/ha-backup-config-schedule"; import "./components/config/ha-backup-config-schedule";
import type { BackupConfigSchedule } from "./components/config/ha-backup-config-schedule"; import type { BackupConfigSchedule } from "./components/config/ha-backup-config-schedule";
import { showLocalBackupLocationDialog } from "./dialogs/show-dialog-local-backup-location"; import { showLocalBackupLocationDialog } from "./dialogs/show-dialog-local-backup-location";
import { documentationUrl } from "../../../util/documentation-url";
@customElement("ha-config-backup-settings") @customElement("ha-config-backup-settings")
class HaConfigBackupSettings extends LitElement { class HaConfigBackupSettings extends LitElement {
@ -98,6 +99,8 @@ class HaConfigBackupSettings extends LitElement {
return nothing; return nothing;
} }
const supervisor = isComponentLoaded(this.hass, "hassio");
return html` return html`
<hass-subpage <hass-subpage
back-path="/config/backup" back-path="/config/backup"
@ -105,7 +108,7 @@ class HaConfigBackupSettings extends LitElement {
.narrow=${this.narrow} .narrow=${this.narrow}
.header=${this.hass.localize("ui.panel.config.backup.settings.header")} .header=${this.hass.localize("ui.panel.config.backup.settings.header")}
> >
${isComponentLoaded(this.hass, "hassio") ${supervisor
? html` ? html`
<ha-button-menu slot="toolbar-icon"> <ha-button-menu slot="toolbar-icon">
<ha-icon-button <ha-icon-button
@ -203,6 +206,29 @@ class HaConfigBackupSettings extends LitElement {
` `
: nothing} : nothing}
</div> </div>
<div class="card-actions">
<a
href=${documentationUrl(this.hass, "/integrations/#backup")}
target="_blank"
rel="noreferrer"
>
<ha-button>
<ha-svg-icon slot="icon" .path=${mdiOpenInNew}></ha-svg-icon>
${this.hass.localize(
"ui.panel.config.backup.settings.locations.more_locations"
)}
</ha-button>
</a>
${supervisor
? html`<a href="/config/storage">
<ha-button>
${this.hass.localize(
"ui.panel.config.backup.settings.locations.manage_network_storage"
)}
</ha-button>
</a>`
: nothing}
</div>
</ha-card> </ha-card>
<ha-card> <ha-card>
<div class="card-header"> <div class="card-header">
@ -342,6 +368,9 @@ class HaConfigBackupSettings extends LitElement {
.card-content { .card-content {
padding-bottom: 0; padding-bottom: 0;
} }
a {
text-decoration: none;
}
`; `;
} }

View File

@ -4,7 +4,7 @@ import { customElement, property, state } from "lit/decorators";
import type { import type {
BackupAgent, BackupAgent,
BackupConfig, BackupConfig,
BackupContent, BackupInfo,
} from "../../../data/backup"; } from "../../../data/backup";
import { import {
compareAgents, compareAgents,
@ -44,7 +44,7 @@ class HaConfigBackup extends SubscribeMixin(HassRouterPage) {
@state() private _manager: ManagerStateEvent = DEFAULT_MANAGER_STATE; @state() private _manager: ManagerStateEvent = DEFAULT_MANAGER_STATE;
@state() private _backups: BackupContent[] = []; @state() private _info?: BackupInfo;
@state() private _agents: BackupAgent[] = []; @state() private _agents: BackupAgent[] = [];
@ -87,8 +87,7 @@ class HaConfigBackup extends SubscribeMixin(HassRouterPage) {
} }
private async _fetchBackupInfo() { private async _fetchBackupInfo() {
const info = await fetchBackupInfo(this.hass); this._info = await fetchBackupInfo(this.hass);
this._backups = info.backups;
} }
private async _fetchBackupConfig() { private async _fetchBackupConfig() {
@ -134,7 +133,8 @@ class HaConfigBackup extends SubscribeMixin(HassRouterPage) {
pageEl.narrow = this.narrow; pageEl.narrow = this.narrow;
pageEl.cloudStatus = this.cloudStatus; pageEl.cloudStatus = this.cloudStatus;
pageEl.manager = this._manager; pageEl.manager = this._manager;
pageEl.backups = this._backups; pageEl.info = this._info;
pageEl.backups = this._info?.backups || [];
pageEl.config = this._config; pageEl.config = this._config;
pageEl.agents = this._agents; pageEl.agents = this._agents;
pageEl.fetching = this._fetching; pageEl.fetching = this._fetching;

View File

@ -1,17 +1,13 @@
import { mdiContentCopy, mdiEye, mdiEyeOff, mdiHelpCircle } from "@mdi/js"; import { mdiHelpCircle } from "@mdi/js";
import { LitElement, css, html, nothing } from "lit"; import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators"; import { customElement, property } from "lit/decorators";
import { fireEvent } from "../../../../common/dom/fire_event"; import { fireEvent } from "../../../../common/dom/fire_event";
import { copyToClipboard } from "../../../../common/util/copy-clipboard";
import "../../../../components/ha-alert"; import "../../../../components/ha-alert";
import "../../../../components/ha-button"; import "../../../../components/ha-button";
import "../../../../components/ha-card"; import "../../../../components/ha-card";
import "../../../../components/ha-expansion-panel"; import "../../../../components/ha-expansion-panel";
import "../../../../components/ha-formfield";
import "../../../../components/ha-radio";
import "../../../../components/ha-settings-row"; import "../../../../components/ha-settings-row";
import "../../../../components/ha-switch"; import "../../../../components/ha-switch";
import "../../../../components/ha-textfield";
import { formatDate } from "../../../../common/datetime/format_date"; import { formatDate } from "../../../../common/datetime/format_date";
import type { HaSwitch } from "../../../../components/ha-switch"; import type { HaSwitch } from "../../../../components/ha-switch";
@ -25,6 +21,7 @@ import type { HomeAssistant } from "../../../../types";
import { showToast } from "../../../../util/toast"; import { showToast } from "../../../../util/toast";
import { showCloudCertificateDialog } from "../dialog-cloud-certificate/show-dialog-cloud-certificate"; import { showCloudCertificateDialog } from "../dialog-cloud-certificate/show-dialog-cloud-certificate";
import { obfuscateUrl } from "../../../../util/url"; import { obfuscateUrl } from "../../../../util/url";
import "../../../../components/ha-copy-textfield";
@customElement("cloud-remote-pref") @customElement("cloud-remote-pref")
export class CloudRemotePref extends LitElement { export class CloudRemotePref extends LitElement {
@ -34,8 +31,6 @@ export class CloudRemotePref extends LitElement {
@property({ type: Boolean }) public narrow = false; @property({ type: Boolean }) public narrow = false;
@state() private _unmaskedUrl = false;
protected render() { protected render() {
if (!this.cloudStatus) { if (!this.cloudStatus) {
return nothing; return nothing;
@ -139,37 +134,13 @@ export class CloudRemotePref extends LitElement {
)} )}
</p> </p>
`} `}
<div class="url-container">
<div class="textfield-container"> <ha-copy-textfield
<ha-textfield .hass=${this.hass}
.value=${this._unmaskedUrl .value=${`https://${remote_domain}`}
? `https://${remote_domain}` .maskedValue=${obfuscateUrl(`https://${remote_domain}`)}
: obfuscateUrl(`https://${remote_domain}`)} .label=${this.hass!.localize("ui.panel.config.common.copy_link")}
readonly ></ha-copy-textfield>
.suffix=${
// reserve some space for the icon.
html`<div style="width: 24px"></div>`
}
></ha-textfield>
<ha-icon-button
class="toggle-unmasked-url"
toggles
.label=${this.hass.localize(
`ui.panel.config.common.${this._unmaskedUrl ? "hide" : "show"}_url`
)}
@click=${this._toggleUnmaskedUrl}
.path=${this._unmaskedUrl ? mdiEyeOff : mdiEye}
></ha-icon-button>
</div>
<ha-button
.url=${`https://${remote_domain}`}
@click=${this._copyURL}
unelevated
>
<ha-svg-icon slot="icon" .path=${mdiContentCopy}></ha-svg-icon>
${this.hass.localize("ui.panel.config.common.copy_link")}
</ha-button>
</div>
<ha-expansion-panel <ha-expansion-panel
outlined outlined
@ -234,10 +205,6 @@ export class CloudRemotePref extends LitElement {
}); });
} }
private _toggleUnmaskedUrl(): void {
this._unmaskedUrl = !this._unmaskedUrl;
}
private async _toggleChanged(ev) { private async _toggleChanged(ev) {
const toggle = ev.target as HaSwitch; const toggle = ev.target as HaSwitch;
@ -268,14 +235,6 @@ export class CloudRemotePref extends LitElement {
} }
} }
private async _copyURL(ev): Promise<void> {
const url = ev.currentTarget.url;
await copyToClipboard(url);
showToast(this, {
message: this.hass.localize("ui.common.copied_clipboard"),
});
}
static styles = css` static styles = css`
.preparing { .preparing {
padding: 0 16px 16px; padding: 0 16px 16px;
@ -335,30 +294,6 @@ export class CloudRemotePref extends LitElement {
display: block; display: block;
margin-bottom: 16px; margin-bottom: 16px;
} }
.url-container {
display: flex;
align-items: center;
gap: 8px;
margin-top: 8px;
}
.textfield-container {
position: relative;
flex: 1;
}
.textfield-container ha-textfield {
display: block;
}
.toggle-unmasked-url {
position: absolute;
top: 8px;
right: 8px;
inset-inline-start: initial;
inset-inline-end: 8px;
--mdc-icon-button-size: 40px;
--mdc-icon-size: 20px;
color: var(--secondary-text-color);
direction: var(--direction);
}
hr { hr {
border: none; border: none;
height: 1px; height: 1px;

View File

@ -0,0 +1,171 @@
import type { CSSResultGroup } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, state } from "lit/decorators";
import { mdiEye, mdiEyeOff } from "@mdi/js";
import { formatDateTime } from "../../../../common/datetime/format_date_time";
import { fireEvent } from "../../../../common/dom/fire_event";
import "../../../../components/ha-alert";
import "../../../../components/ha-button";
import "../../../../components/ha-icon-button";
import { createCloseHeading } from "../../../../components/ha-dialog";
import { haStyleDialog } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
import type { CloudAlreadyConnectedParams as CloudAlreadyConnectedDialogParams } from "./show-dialog-cloud-already-connected";
import { obfuscateUrl } from "../../../../util/url";
@customElement("dialog-cloud-already-connected")
class DialogCloudAlreadyConnected extends LitElement {
public hass!: HomeAssistant;
@state() private _params?: CloudAlreadyConnectedDialogParams;
@state() private _obfuscateIp = true;
public showDialog(params: CloudAlreadyConnectedDialogParams) {
this._params = params;
}
public closeDialog() {
this._params?.closeDialog();
this._params = undefined;
this._obfuscateIp = true;
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
protected render() {
if (!this._params) {
return nothing;
}
const { details } = this._params;
return html`
<ha-dialog
open
@closed=${this.closeDialog}
.heading=${createCloseHeading(
this.hass,
this.hass.localize(
"ui.panel.config.cloud.dialog_already_connected.heading"
)
)}
>
<div class="intro">
<span>
${this.hass.localize(
"ui.panel.config.cloud.dialog_already_connected.description"
)}
</span>
<b>
${this.hass.localize(
"ui.panel.config.cloud.dialog_already_connected.other_home_assistant"
)}
</b>
</div>
<div class="instance-details">
<div class="instance-detail">
<span>
${this.hass.localize(
"ui.panel.config.cloud.dialog_already_connected.ip_address"
)}:
</span>
<div class="obfuscated">
<span>
${this._obfuscateIp
? obfuscateUrl(details.remote_ip_address)
: details.remote_ip_address}
</span>
<ha-icon-button
class="toggle-unmasked-url"
.label=${this.hass.localize(
`ui.panel.config.cloud.dialog_already_connected.obfuscated_ip.${this._obfuscateIp ? "hide" : "show"}`
)}
@click=${this._toggleObfuscateIp}
.path=${this._obfuscateIp ? mdiEye : mdiEyeOff}
></ha-icon-button>
</div>
</div>
<div class="instance-detail">
<span>
${this.hass.localize(
"ui.panel.config.cloud.dialog_already_connected.connected_at"
)}:
</span>
<span>
${formatDateTime(
new Date(details.connected_at),
this.hass.locale,
this.hass.config
)}
</span>
</div>
</div>
<ha-alert
alert-type="info"
.title=${this.hass.localize(
"ui.panel.config.cloud.dialog_already_connected.info_backups.title"
)}
>
${this.hass.localize(
"ui.panel.config.cloud.dialog_already_connected.info_backups.description"
)}
</ha-alert>
<ha-button @click=${this.closeDialog} slot="secondaryAction">
${this.hass!.localize("ui.common.cancel")}
</ha-button>
<ha-button @click=${this._logInHere} slot="primaryAction">
${this.hass!.localize(
"ui.panel.config.cloud.dialog_already_connected.login_here"
)}
</ha-button>
</ha-dialog>
`;
}
private _toggleObfuscateIp() {
this._obfuscateIp = !this._obfuscateIp;
}
private _logInHere() {
this._params?.logInHereAction();
this.closeDialog();
}
static get styles(): CSSResultGroup {
return [
haStyleDialog,
css`
ha-dialog {
--mdc-dialog-max-width: 535px;
}
.intro b {
display: block;
margin-top: 16px;
}
.instance-details {
display: flex;
flex-direction: column;
margin-bottom: 16px;
}
.instance-detail {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
}
.obfuscated {
align-items: center;
display: flex;
flex-direction: row;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"dialog-cloud-already-connected": DialogCloudAlreadyConnected;
}
}

View File

@ -0,0 +1,21 @@
import { fireEvent } from "../../../../common/dom/fire_event";
export interface CloudAlreadyConnectedParams {
details: {
remote_ip_address: string;
connected_at: string;
};
logInHereAction: () => void;
closeDialog: () => void;
}
export const showCloudAlreadyConnectedDialog = (
element: HTMLElement,
webhookDialogParams: CloudAlreadyConnectedParams
): void => {
fireEvent(element, "show-dialog", {
dialogTag: "dialog-cloud-already-connected",
dialogImport: () => import("./dialog-cloud-already-connected"),
dialogParams: webhookDialogParams,
});
};

View File

@ -1,27 +1,23 @@
import "@material/mwc-button"; import "@material/mwc-button";
import { mdiContentCopy, mdiOpenInNew } from "@mdi/js"; import { mdiOpenInNew } from "@mdi/js";
import type { CSSResultGroup } from "lit"; import type { CSSResultGroup } from "lit";
import { css, html, LitElement, nothing } from "lit"; import { css, html, LitElement, nothing } from "lit";
import { query, state } from "lit/decorators"; import { state } from "lit/decorators";
import { fireEvent } from "../../../../common/dom/fire_event"; import { fireEvent } from "../../../../common/dom/fire_event";
import { copyToClipboard } from "../../../../common/util/copy-clipboard";
import { createCloseHeading } from "../../../../components/ha-dialog"; import { createCloseHeading } from "../../../../components/ha-dialog";
import "../../../../components/ha-textfield";
import type { HaTextField } from "../../../../components/ha-textfield";
import { showConfirmationDialog } from "../../../../dialogs/generic/show-dialog-box"; import { showConfirmationDialog } from "../../../../dialogs/generic/show-dialog-box";
import { haStyle, haStyleDialog } from "../../../../resources/styles"; import { haStyle, haStyleDialog } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types"; import type { HomeAssistant } from "../../../../types";
import { documentationUrl } from "../../../../util/documentation-url"; import { documentationUrl } from "../../../../util/documentation-url";
import { showToast } from "../../../../util/toast";
import type { WebhookDialogParams } from "./show-dialog-manage-cloudhook"; import type { WebhookDialogParams } from "./show-dialog-manage-cloudhook";
import "../../../../components/ha-copy-textfield";
export class DialogManageCloudhook extends LitElement { export class DialogManageCloudhook extends LitElement {
protected hass?: HomeAssistant; protected hass?: HomeAssistant;
@state() private _params?: WebhookDialogParams; @state() private _params?: WebhookDialogParams;
@query("ha-textfield") _input!: HaTextField;
public showDialog(params: WebhookDialogParams) { public showDialog(params: WebhookDialogParams) {
this._params = params; this._params = params;
} }
@ -82,21 +78,12 @@ export class DialogManageCloudhook extends LitElement {
<ha-svg-icon .path=${mdiOpenInNew}></ha-svg-icon> <ha-svg-icon .path=${mdiOpenInNew}></ha-svg-icon>
</a> </a>
</p> </p>
<ha-textfield
.label=${this.hass!.localize( <ha-copy-textfield
"ui.panel.config.cloud.dialog_cloudhook.public_url" .hass=${this.hass}
)}
.value=${cloudhook.cloudhook_url} .value=${cloudhook.cloudhook_url}
iconTrailing .label=${this.hass!.localize("ui.panel.config.common.copy_link")}
readOnly ></ha-copy-textfield>
@click=${this._focusInput}
>
<ha-icon-button
@click=${this._copyUrl}
slot="trailingIcon"
.path=${mdiContentCopy}
></ha-icon-button>
</ha-textfield>
</div> </div>
<a <a
@ -137,24 +124,6 @@ export class DialogManageCloudhook extends LitElement {
} }
} }
private _focusInput(ev) {
const inputElement = ev.currentTarget as HaTextField;
inputElement.select();
}
private async _copyUrl(ev): Promise<void> {
if (!this.hass) return;
ev.stopPropagation();
const inputElement = ev.target.parentElement as HaTextField;
inputElement.select();
const url = this.hass.hassUrl(inputElement.value);
await copyToClipboard(url);
showToast(this, {
message: this.hass.localize("ui.common.copied_clipboard"),
});
}
static get styles(): CSSResultGroup { static get styles(): CSSResultGroup {
return [ return [
haStyle, haStyle,
@ -163,13 +132,6 @@ export class DialogManageCloudhook extends LitElement {
ha-dialog { ha-dialog {
width: 650px; width: 650px;
} }
ha-textfield {
display: block;
}
ha-textfield > ha-icon-button {
--mdc-icon-button-size: 24px;
--mdc-icon-size: 18px;
}
button.link { button.link {
color: var(--primary-color); color: var(--primary-color);
text-decoration: none; text-decoration: none;

View File

@ -28,6 +28,7 @@ import { haStyle } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types"; import type { HomeAssistant } from "../../../../types";
import "../../ha-config-section"; import "../../ha-config-section";
import { showSupportPackageDialog } from "../account/show-dialog-cloud-support-package"; import { showSupportPackageDialog } from "../account/show-dialog-cloud-support-package";
import { showCloudAlreadyConnectedDialog } from "../dialog-cloud-already-connected/show-dialog-cloud-already-connected";
@customElement("cloud-login") @customElement("cloud-login")
export class CloudLogin extends LitElement { export class CloudLogin extends LitElement {
@ -47,6 +48,8 @@ export class CloudLogin extends LitElement {
@state() private _error?: string; @state() private _error?: string;
@state() private _checkConnection = true;
@query("#email", true) private _emailField!: HaTextField; @query("#email", true) private _emailField!: HaTextField;
@query("#password", true) private _passwordField!: HaPasswordField; @query("#password", true) private _passwordField!: HaPasswordField;
@ -244,6 +247,7 @@ export class CloudLogin extends LitElement {
hass: this.hass, hass: this.hass,
email: username, email: username,
...(code ? { code } : { password }), ...(code ? { code } : { password }),
check_connection: this._checkConnection,
}); });
this.email = ""; this.email = "";
this._password = ""; this._password = "";
@ -283,6 +287,21 @@ export class CloudLogin extends LitElement {
return; return;
} }
} }
if (errCode === "alreadyconnectederror") {
showCloudAlreadyConnectedDialog(this, {
details: JSON.parse(err.body.message),
logInHereAction: () => {
this._checkConnection = false;
doLogin(username);
},
closeDialog: () => {
this._requestInProgress = false;
this.email = "";
this._password = "";
},
});
return;
}
if (errCode === "PasswordChangeRequired") { if (errCode === "PasswordChangeRequired") {
showAlertDialog(this, { showAlertDialog(this, {
title: this.hass.localize( title: this.hass.localize(

View File

@ -185,6 +185,14 @@ class AddIntegrationDialog extends LitElement {
const yamlIntegrations: IntegrationListItem[] = []; const yamlIntegrations: IntegrationListItem[] = [];
Object.entries(i).forEach(([domain, integration]) => { Object.entries(i).forEach(([domain, integration]) => {
if (
"integration_type" in integration &&
integration.integration_type === "hardware"
) {
// Ignore hardware integrations, they cannot be added via UI
return;
}
if ( if (
"integration_type" in integration && "integration_type" in integration &&
(integration.config_flow || (integration.config_flow ||

View File

@ -153,7 +153,6 @@ class ConfigUrlForm extends LitElement {
? html` ? html`
<ha-icon-button <ha-icon-button
class="toggle-unmasked-url" class="toggle-unmasked-url"
toggles
.label=${this.hass.localize( .label=${this.hass.localize(
`ui.panel.config.common.${this._unmaskedExternalUrl ? "hide" : "show"}_url` `ui.panel.config.common.${this._unmaskedExternalUrl ? "hide" : "show"}_url`
)} )}
@ -254,7 +253,6 @@ class ConfigUrlForm extends LitElement {
? html` ? html`
<ha-icon-button <ha-icon-button
class="toggle-unmasked-url" class="toggle-unmasked-url"
toggles
.label=${this.hass.localize( .label=${this.hass.localize(
`ui.panel.config.common.${this._unmaskedInternalUrl ? "hide" : "show"}_url` `ui.panel.config.common.${this._unmaskedInternalUrl ? "hide" : "show"}_url`
)} )}

View File

@ -20,7 +20,6 @@ export class HuiCardFeatures extends LitElement {
return nothing; return nothing;
} }
return html` return html`
<div class="container">
${this.features.map( ${this.features.map(
(feature) => html` (feature) => html`
<hui-card-feature <hui-card-feature
@ -31,29 +30,21 @@ export class HuiCardFeatures extends LitElement {
></hui-card-feature> ></hui-card-feature>
` `
)} )}
</div>
`; `;
} }
static styles = css` static styles = css`
:host { :host {
--feature-color: var(--state-icon-color); --feature-color: var(--state-icon-color);
--feature-padding: 12px;
--feature-height: 42px; --feature-height: 42px;
--feature-border-radius: 12px; --feature-border-radius: 12px;
--feature-button-spacing: 12px; --feature-button-spacing: 12px;
position: relative; position: relative;
width: 100%; width: 100%;
}
.container {
position: relative;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
padding: var(--feature-padding); gap: 12px;
padding-top: 0px;
gap: var(--feature-padding);
width: 100%; width: 100%;
height: 100%;
box-sizing: border-box; box-sizing: border-box;
justify-content: space-evenly; justify-content: space-evenly;
} }

View File

@ -0,0 +1,134 @@
import { mdiRestore, mdiPlus, mdiMinus } from "@mdi/js";
import type { HassEntity } from "home-assistant-js-websocket";
import type { TemplateResult } from "lit";
import { LitElement, html } from "lit";
import { customElement, property, state } from "lit/decorators";
import { computeDomain } from "../../../common/entity/compute_domain";
import "../../../components/ha-control-select";
import { UNAVAILABLE } from "../../../data/entity";
import type { HomeAssistant } from "../../../types";
import type { LovelaceCardFeature, LovelaceCardFeatureEditor } from "../types";
import { cardFeatureStyles } from "./common/card-feature-styles";
import { COUNTER_ACTIONS, type CounterActionsCardFeatureConfig } from "./types";
import "../../../components/ha-control-button-group";
import "../../../components/ha-control-button";
export const supportsCounterActionsCardFeature = (stateObj: HassEntity) => {
const domain = computeDomain(stateObj.entity_id);
return domain === "counter";
};
interface CounterButton {
translationKey: string;
icon: string;
serviceName: string;
disabled: boolean;
}
export const COUNTER_ACTIONS_BUTTON: Record<
string,
(stateObj: HassEntity) => CounterButton
> = {
increment: (stateObj) => ({
translationKey: "increment",
icon: mdiPlus,
serviceName: "increment",
disabled: parseInt(stateObj.state) === stateObj.attributes.maximum,
}),
reset: () => ({
translationKey: "reset",
icon: mdiRestore,
serviceName: "reset",
disabled: false,
}),
decrement: (stateObj) => ({
translationKey: "decrement",
icon: mdiMinus,
serviceName: "decrement",
disabled: parseInt(stateObj.state) === stateObj.attributes.minimum,
}),
};
@customElement("hui-counter-actions-card-feature")
class HuiCounterActionsCardFeature
extends LitElement
implements LovelaceCardFeature
{
@property({ attribute: false }) public hass?: HomeAssistant;
@property({ attribute: false }) public stateObj?: HassEntity;
@state() private _config?: CounterActionsCardFeatureConfig;
public static async getConfigElement(): Promise<LovelaceCardFeatureEditor> {
await import(
"../editor/config-elements/hui-counter-actions-card-feature-editor"
);
return document.createElement("hui-counter-actions-card-feature-editor");
}
static getStubConfig(): CounterActionsCardFeatureConfig {
return {
type: "counter-actions",
actions: COUNTER_ACTIONS.map((action) => action),
};
}
public setConfig(config: CounterActionsCardFeatureConfig): void {
if (!config) {
throw new Error("Invalid configuration");
}
this._config = config;
}
protected render(): TemplateResult | null {
if (
!this._config ||
!this.hass ||
!this.stateObj ||
!supportsCounterActionsCardFeature(this.stateObj)
) {
return null;
}
return html`
<ha-control-button-group>
${this._config?.actions
?.filter((action) => COUNTER_ACTIONS.includes(action))
.map((action) => {
const button = COUNTER_ACTIONS_BUTTON[action](this.stateObj!);
return html`
<ha-control-button
.entry=${button}
.label=${this.hass!.localize(
// @ts-ignore
`ui.card.counter.actions.${button.translationKey}`
)}
@click=${this._onActionTap}
.disabled=${button.disabled ||
this.stateObj?.state === UNAVAILABLE}
>
<ha-svg-icon .path=${button.icon}></ha-svg-icon>
</ha-control-button>
`;
})}
</ha-control-button-group>
`;
}
private _onActionTap(ev): void {
ev.stopPropagation();
const entry = (ev.target! as any).entry as CounterButton;
this.hass!.callService("counter", entry.serviceName, {
entity_id: this.stateObj!.entity_id,
});
}
static styles = cardFeatureStyles;
}
declare global {
interface HTMLElementTagNameMap {
"hui-counter-actions-card-feature": HuiCounterActionsCardFeature;
}
}

View File

@ -0,0 +1,111 @@
import { mdiPowerOff, mdiPower } from "@mdi/js";
import type { HassEntity } from "home-assistant-js-websocket";
import type { TemplateResult } from "lit";
import { LitElement, html } from "lit";
import { customElement, property, state } from "lit/decorators";
import { styleMap } from "lit/directives/style-map";
import { computeDomain } from "../../../common/entity/compute_domain";
import { stateColorCss } from "../../../common/entity/state_color";
import "../../../components/ha-control-select";
import type { ControlSelectOption } from "../../../components/ha-control-select";
import { UNAVAILABLE } from "../../../data/entity";
import type { HomeAssistant } from "../../../types";
import type { LovelaceCardFeature } from "../types";
import { cardFeatureStyles } from "./common/card-feature-styles";
import type { ToggleCardFeatureConfig } from "./types";
import { showToast } from "../../../util/toast";
export const supportsToggleCardFeature = (stateObj: HassEntity) => {
const domain = computeDomain(stateObj.entity_id);
return ["switch", "input_boolean"].includes(domain);
};
@customElement("hui-toggle-card-feature")
class HuiToggleCardFeature extends LitElement implements LovelaceCardFeature {
@property({ attribute: false }) public hass?: HomeAssistant;
@property({ attribute: false }) public stateObj?: HassEntity;
@state() private _config?: ToggleCardFeatureConfig;
static getStubConfig(): ToggleCardFeatureConfig {
return {
type: "toggle",
};
}
public setConfig(config: ToggleCardFeatureConfig): void {
if (!config) {
throw new Error("Invalid configuration");
}
this._config = config;
}
protected render(): TemplateResult | null {
if (
!this._config ||
!this.hass ||
!this.stateObj ||
!supportsToggleCardFeature(this.stateObj)
) {
return null;
}
const color = stateColorCss(this.stateObj);
const options = ["on", "off"].map<ControlSelectOption>((entityState) => ({
value: entityState,
label: this.hass!.formatEntityState(this.stateObj!, entityState),
path: entityState === "on" ? mdiPower : mdiPowerOff,
}));
return html`
<ha-control-select
.options=${options}
.value=${this.stateObj.state}
@value-changed=${this._valueChanged}
hide-label
.ariaLabel=${this.hass.localize("ui.card.humidifier.state")}
style=${styleMap({
"--control-select-color": color,
})}
.disabled=${this.stateObj!.state === UNAVAILABLE}
>
</ha-control-select>
`;
}
private async _valueChanged(ev: CustomEvent) {
const newState = (ev.detail as any).value;
if (
newState === this.stateObj!.state &&
!this.stateObj!.attributes.assumed_state
)
return;
const service = newState === "on" ? "turn_on" : "turn_off";
const domain = computeDomain(this.stateObj!.entity_id);
try {
await this.hass!.callService(domain, service, {
entity_id: this.stateObj!.entity_id,
});
} catch (_err) {
showToast(this, {
message: this.hass!.localize("ui.notification_toast.action_failed", {
service: domain + "." + service,
}),
duration: 5000,
dismissable: true,
});
}
}
static styles = cardFeatureStyles;
}
declare global {
interface HTMLElementTagNameMap {
"hui-toggle-card-feature": HuiToggleCardFeature;
}
}

View File

@ -83,6 +83,15 @@ export interface ClimatePresetModesCardFeatureConfig {
preset_modes?: string[]; preset_modes?: string[];
} }
export const COUNTER_ACTIONS = ["increment", "reset", "decrement"] as const;
export type CounterActions = (typeof COUNTER_ACTIONS)[number];
export interface CounterActionsCardFeatureConfig {
type: "counter-actions";
actions?: CounterActions[];
}
export interface SelectOptionsCardFeatureConfig { export interface SelectOptionsCardFeatureConfig {
type: "select-options"; type: "select-options";
options?: string[]; options?: string[];
@ -101,6 +110,10 @@ export interface TargetTemperatureCardFeatureConfig {
type: "target-temperature"; type: "target-temperature";
} }
export interface ToggleCardFeatureConfig {
type: "toggle";
}
export interface WaterHeaterOperationModesCardFeatureConfig { export interface WaterHeaterOperationModesCardFeatureConfig {
type: "water-heater-operation-modes"; type: "water-heater-operation-modes";
operation_modes?: OperationMode[]; operation_modes?: OperationMode[];
@ -152,6 +165,7 @@ export type LovelaceCardFeatureConfig =
| ClimateSwingHorizontalModesCardFeatureConfig | ClimateSwingHorizontalModesCardFeatureConfig
| ClimateHvacModesCardFeatureConfig | ClimateHvacModesCardFeatureConfig
| ClimatePresetModesCardFeatureConfig | ClimatePresetModesCardFeatureConfig
| CounterActionsCardFeatureConfig
| CoverOpenCloseCardFeatureConfig | CoverOpenCloseCardFeatureConfig
| CoverPositionCardFeatureConfig | CoverPositionCardFeatureConfig
| CoverTiltPositionCardFeatureConfig | CoverTiltPositionCardFeatureConfig
@ -170,6 +184,7 @@ export type LovelaceCardFeatureConfig =
| SelectOptionsCardFeatureConfig | SelectOptionsCardFeatureConfig
| TargetHumidityCardFeatureConfig | TargetHumidityCardFeatureConfig
| TargetTemperatureCardFeatureConfig | TargetTemperatureCardFeatureConfig
| ToggleCardFeatureConfig
| UpdateActionsCardFeatureConfig | UpdateActionsCardFeatureConfig
| VacuumCommandsCardFeatureConfig | VacuumCommandsCardFeatureConfig
| WaterHeaterOperationModesCardFeatureConfig; | WaterHeaterOperationModesCardFeatureConfig;

View File

@ -327,7 +327,9 @@ export class HuiEnergyDevicesDetailGraphCard
); );
const untrackedConsumption: BarSeriesOption["data"] = []; const untrackedConsumption: BarSeriesOption["data"] = [];
Object.keys(consumptionData.total).forEach((time) => { Object.keys(consumptionData.total)
.sort((a, b) => Number(a) - Number(b))
.forEach((time) => {
const ts = Number(time); const ts = Number(time);
const value = const value =
consumptionData.total[time] - (totalDeviceConsumption[time] || 0); consumptionData.total[time] - (totalDeviceConsumption[time] || 0);

View File

@ -291,19 +291,18 @@ export class HuiEnergyUsageGraphCard
true true
) )
); );
} else { }
// add empty dataset so compare bars are first // add empty dataset so compare bars are first
// `stack: usage` so it doesn't take up space yet // `stack: usage` so it doesn't take up space yet
const firstId = statIds.from_grid?.[0] ?? "placeholder";
datasets.push({ datasets.push({
id: "compare-" + firstId, id: "compare-placeholder",
type: "bar", type: "bar",
stack: "usage", stack: energyData.statsCompare ? "compare" : "usage",
data: [], data: [],
// @ts-expect-error // @ts-expect-error
order: 0, order: 0,
}); });
}
datasets.push( datasets.push(
...this._processDataSet( ...this._processDataSet(

View File

@ -256,6 +256,7 @@ export class HuiHumidifierCard extends LitElement implements LovelaceCard {
hui-card-features { hui-card-features {
width: 100%; width: 100%;
flex: none; flex: none;
padding: 0 12px 12px 12px;
} }
`; `;
} }

View File

@ -1,4 +1,8 @@
import { mdiImageFilterCenterFocus } from "@mdi/js"; import {
mdiDotsHexagon,
mdiGoogleCirclesCommunities,
mdiImageFilterCenterFocus,
} from "@mdi/js";
import type { HassEntities } from "home-assistant-js-websocket"; import type { HassEntities } from "home-assistant-js-websocket";
import type { LatLngTuple } from "leaflet"; import type { LatLngTuple } from "leaflet";
import type { PropertyValues } from "lit"; import type { PropertyValues } from "lit";
@ -72,6 +76,8 @@ class HuiMapCard extends LitElement implements LovelaceCard {
@state() private _error?: { code: string; message: string }; @state() private _error?: { code: string; message: string };
@state() private _clusterMarkers = true;
private _subscribed?: Promise<(() => Promise<void>) | undefined>; private _subscribed?: Promise<(() => Promise<void>) | undefined>;
public setConfig(config: MapCardConfig): void { public setConfig(config: MapCardConfig): void {
@ -170,9 +176,22 @@ class HuiMapCard extends LitElement implements LovelaceCard {
.autoFit=${this._config.auto_fit || false} .autoFit=${this._config.auto_fit || false}
.fitZones=${this._config.fit_zones} .fitZones=${this._config.fit_zones}
.themeMode=${themeMode} .themeMode=${themeMode}
.clusterMarkers=${this._clusterMarkers}
interactive-zones interactive-zones
render-passive render-passive
></ha-map> ></ha-map>
<div id="buttons">
<ha-icon-button
.label=${this.hass!.localize(
"ui.panel.lovelace.cards.map.toggle_grouping"
)}
.path=${this._clusterMarkers
? mdiGoogleCirclesCommunities
: mdiDotsHexagon}
style=${isDarkMode ? "color:#ffffff" : "color:#000000"}
@click=${this._toggleClusterMarkers}
tabindex="0"
></ha-icon-button>
<ha-icon-button <ha-icon-button
.label=${this.hass!.localize( .label=${this.hass!.localize(
"ui.panel.lovelace.cards.map.reset_focus" "ui.panel.lovelace.cards.map.reset_focus"
@ -183,6 +202,7 @@ class HuiMapCard extends LitElement implements LovelaceCard {
tabindex="0" tabindex="0"
></ha-icon-button> ></ha-icon-button>
</div> </div>
</div>
</ha-card> </ha-card>
`; `;
} }
@ -320,6 +340,10 @@ class HuiMapCard extends LitElement implements LovelaceCard {
this._map?.fitMap(); this._map?.fitMap();
} }
private _toggleClusterMarkers() {
this._clusterMarkers = !this._clusterMarkers;
}
private _getColor(entityId: string): string { private _getColor(entityId: string): string {
let color = this._colorDict[entityId]; let color = this._colorDict[entityId];
if (color) { if (color) {
@ -464,11 +488,12 @@ class HuiMapCard extends LitElement implements LovelaceCard {
overflow: hidden; overflow: hidden;
} }
ha-icon-button { #buttons {
position: absolute; position: absolute;
top: 75px; top: 75px;
left: 3px; left: 3px;
outline: none; display: flex;
flex-direction: column;
} }
#root { #root {

View File

@ -107,18 +107,26 @@ export class HuiMarkdownCard extends LitElement implements LovelaceCard {
return html` return html`
${this._error ${this._error
? html`<ha-alert ? html`
alert-type=${this._errorLevel?.toLowerCase() || "error"} <ha-alert
>${this._error}</ha-alert .alertType=${(this._errorLevel?.toLowerCase() as
>` | "error"
| "warning") || "error"}
>
${this._error}
</ha-alert>
`
: nothing} : nothing}
<ha-card .header=${this._config.title}> <ha-card
.header=${!this._config.text_only ? this._config.title : undefined}
class=${classMap({
"with-header": !!this._config.title,
"text-only": this._config.text_only ?? false,
})}
>
<ha-markdown <ha-markdown
cache cache
breaks breaks
class=${classMap({
"no-header": !this._config.title,
})}
.content=${this._templateResult?.result} .content=${this._templateResult?.result}
></ha-markdown> ></ha-markdown>
</ha-card> </ha-card>
@ -135,7 +143,7 @@ export class HuiMarkdownCard extends LitElement implements LovelaceCard {
this._tryConnect(); this._tryConnect();
} }
const shouldBeHidden = const shouldBeHidden =
this._templateResult && !!this._templateResult &&
this._config.show_empty === false && this._config.show_empty === false &&
this._templateResult.result.length === 0; this._templateResult.result.length === 0;
if (shouldBeHidden !== this.hidden) { if (shouldBeHidden !== this.hidden) {
@ -228,11 +236,19 @@ export class HuiMarkdownCard extends LitElement implements LovelaceCard {
margin-bottom: 8px; margin-bottom: 8px;
} }
ha-markdown { ha-markdown {
padding: 0 16px 16px; padding: 16px;
word-wrap: break-word; word-wrap: break-word;
} }
ha-markdown.no-header { .with-header ha-markdown {
padding-top: 16px; padding: 0 16px 16px;
}
.text-only {
background: none;
box-shadow: none;
border: none;
}
.text-only ha-markdown {
padding: 2px 4px;
} }
`; `;
} }

View File

@ -248,6 +248,7 @@ export class HuiThermostatCard extends LitElement implements LovelaceCard {
hui-card-features { hui-card-features {
width: 100%; width: 100%;
flex: none; flex: none;
padding: 0 12px 12px 12px;
} }
`; `;
} }

View File

@ -100,10 +100,13 @@ export class HuiTileCard extends LitElement implements LovelaceCard {
} }
public getCardSize(): number { public getCardSize(): number {
const featuresPosition =
this._config && this._featurePosition(this._config);
const featuresCount = this._config?.features?.length || 0;
return ( return (
1 + 1 +
(this._config?.vertical ? 1 : 0) + (this._config?.vertical ? 1 : 0) +
(this._config?.features?.length || 0) (featuresPosition === "inline" ? 0 : featuresCount)
); );
} }
@ -111,9 +114,16 @@ export class HuiTileCard extends LitElement implements LovelaceCard {
const columns = 6; const columns = 6;
let min_columns = 6; let min_columns = 6;
let rows = 1; let rows = 1;
if (this._config?.features?.length) { const featurePosition = this._config && this._featurePosition(this._config);
rows += this._config.features.length; const featuresCount = this._config?.features?.length || 0;
if (featuresCount) {
if (featurePosition === "inline") {
min_columns = 12;
} else {
rows += featuresCount;
} }
}
if (this._config?.vertical) { if (this._config?.vertical) {
rows++; rows++;
min_columns = 3; min_columns = 3;
@ -210,6 +220,23 @@ export class HuiTileCard extends LitElement implements LovelaceCard {
); );
} }
private _featurePosition = memoizeOne((config: TileCardConfig) => {
if (config.vertical) {
return "bottom";
}
return config.features_position || "bottom";
});
private _displayedFeatures = memoizeOne((config: TileCardConfig) => {
const features = config.features || [];
const featurePosition = this._featurePosition(config);
if (featurePosition === "inline") {
return features.slice(0, 1);
}
return features;
});
protected render() { protected render() {
if (!this._config || !this.hass) { if (!this._config || !this.hass) {
return nothing; return nothing;
@ -263,6 +290,12 @@ export class HuiTileCard extends LitElement implements LovelaceCard {
? this._getImageUrl(stateObj) ? this._getImageUrl(stateObj)
: undefined; : undefined;
const featurePosition = this._featurePosition(this._config);
const features = this._displayedFeatures(this._config);
const containerOrientationClass =
featurePosition === "inline" ? "horizontal" : "";
return html` return html`
<ha-card style=${styleMap(style)} class=${classMap({ active })}> <ha-card style=${styleMap(style)} class=${classMap({ active })}>
<div <div
@ -278,7 +311,7 @@ export class HuiTileCard extends LitElement implements LovelaceCard {
> >
<ha-ripple .disabled=${!this._hasCardAction}></ha-ripple> <ha-ripple .disabled=${!this._hasCardAction}></ha-ripple>
</div> </div>
<div class="container"> <div class="container ${containerOrientationClass}">
<div class="content ${classMap(contentClasses)}"> <div class="content ${classMap(contentClasses)}">
<ha-tile-icon <ha-tile-icon
role=${ifDefined(this._hasIconAction ? "button" : undefined)} role=${ifDefined(this._hasIconAction ? "button" : undefined)}
@ -308,13 +341,13 @@ export class HuiTileCard extends LitElement implements LovelaceCard {
.secondary=${stateDisplay} .secondary=${stateDisplay}
></ha-tile-info> ></ha-tile-info>
</div> </div>
${this._config.features ${features.length > 0
? html` ? html`
<hui-card-features <hui-card-features
.hass=${this.hass} .hass=${this.hass}
.stateObj=${stateObj} .stateObj=${stateObj}
.color=${this._config.color} .color=${this._config.color}
.features=${this._config.features} .features=${features}
></hui-card-features> ></hui-card-features>
` `
: nothing} : nothing}
@ -372,6 +405,10 @@ export class HuiTileCard extends LitElement implements LovelaceCard {
flex-direction: column; flex-direction: column;
flex: 1; flex: 1;
} }
.container.horizontal {
flex-direction: row;
}
.content { .content {
position: relative; position: relative;
display: flex; display: flex;
@ -379,10 +416,12 @@ export class HuiTileCard extends LitElement implements LovelaceCard {
align-items: center; align-items: center;
padding: 10px; padding: 10px;
flex: 1; flex: 1;
min-width: 0;
box-sizing: border-box; box-sizing: border-box;
pointer-events: none; pointer-events: none;
gap: 10px; gap: 10px;
} }
.vertical { .vertical {
flex-direction: column; flex-direction: column;
text-align: center; text-align: center;
@ -413,6 +452,14 @@ export class HuiTileCard extends LitElement implements LovelaceCard {
} }
hui-card-features { hui-card-features {
--feature-color: var(--tile-color); --feature-color: var(--tile-color);
padding: 0 12px 12px 12px;
}
.container.horizontal hui-card-features {
width: calc(50% - var(--column-gap, 0px) / 2 - 12px);
flex: none;
--feature-height: 36px;
padding: 0 12px;
padding-inline-start: 0;
} }
ha-tile-icon[data-domain="alarm_control_panel"][data-state="pending"], ha-tile-icon[data-domain="alarm_control_panel"][data-state="pending"],

View File

@ -336,6 +336,7 @@ export interface MapCardConfig extends LovelaceCardConfig {
export interface MarkdownCardConfig extends LovelaceCardConfig { export interface MarkdownCardConfig extends LovelaceCardConfig {
type: "markdown"; type: "markdown";
content: string; content: string;
text_only?: boolean;
title?: string; title?: string;
card_size?: number; card_size?: number;
entity_ids?: string | string[]; entity_ids?: string | string[];
@ -533,6 +534,7 @@ export interface TileCardConfig extends LovelaceCardConfig {
icon_hold_action?: ActionConfig; icon_hold_action?: ActionConfig;
icon_double_tap_action?: ActionConfig; icon_double_tap_action?: ActionConfig;
features?: LovelaceCardFeatureConfig[]; features?: LovelaceCardFeatureConfig[];
features_position?: "bottom" | "inline";
} }
export interface HeadingCardConfig extends LovelaceCardConfig { export interface HeadingCardConfig extends LovelaceCardConfig {

View File

@ -23,8 +23,10 @@ import type { LovelaceCardConfig } from "../../../data/lovelace/config/card";
import { haStyle } from "../../../resources/styles"; import { haStyle } from "../../../resources/styles";
import type { HomeAssistant } from "../../../types"; import type { HomeAssistant } from "../../../types";
import { showEditCardDialog } from "../editor/card-editor/show-edit-card-dialog"; import { showEditCardDialog } from "../editor/card-editor/show-edit-card-dialog";
import { addCard } from "../editor/config-util";
import type { LovelaceCardPath } from "../editor/lovelace-path"; import type { LovelaceCardPath } from "../editor/lovelace-path";
import { import {
findLovelaceContainer,
findLovelaceItems, findLovelaceItems,
getLovelaceContainerPath, getLovelaceContainerPath,
parseLovelaceCardPath, parseLovelaceCardPath,
@ -253,14 +255,24 @@ export class HuiCardEditMode extends LitElement {
} }
private _duplicateCard(): void { private _duplicateCard(): void {
const { cardIndex } = parseLovelaceCardPath(this.path!); const { cardIndex, sectionIndex } = parseLovelaceCardPath(this.path!);
const containerPath = getLovelaceContainerPath(this.path!); const containerPath = getLovelaceContainerPath(this.path!);
const sectionConfig =
sectionIndex !== undefined
? findLovelaceContainer(this.lovelace!.config, containerPath)
: undefined;
const cardConfig = this._cards![cardIndex]; const cardConfig = this._cards![cardIndex];
showEditCardDialog(this, { showEditCardDialog(this, {
lovelaceConfig: this.lovelace!.config, lovelaceConfig: this.lovelace!.config,
saveConfig: this.lovelace!.saveConfig, saveCardConfig: async (config) => {
path: containerPath, const newConfig = addCard(this.lovelace!.config, containerPath, config);
await this.lovelace!.saveConfig(newConfig);
},
cardConfig, cardConfig,
sectionConfig,
isNew: true,
}); });
} }

View File

@ -278,9 +278,12 @@ export class HuiCardOptions extends LitElement {
const cardConfig = this._cards![cardIndex]; const cardConfig = this._cards![cardIndex];
showEditCardDialog(this, { showEditCardDialog(this, {
lovelaceConfig: this.lovelace!.config, lovelaceConfig: this.lovelace!.config,
saveConfig: this.lovelace!.saveConfig, saveCardConfig: async (config) => {
path: containerPath, const newConfig = addCard(this.lovelace!.config, containerPath, config);
await this.lovelace!.saveConfig(newConfig);
},
cardConfig, cardConfig,
isNew: true,
}); });
} }

View File

@ -4,6 +4,7 @@ import "../card-features/hui-climate-swing-modes-card-feature";
import "../card-features/hui-climate-swing-horizontal-modes-card-feature"; import "../card-features/hui-climate-swing-horizontal-modes-card-feature";
import "../card-features/hui-climate-hvac-modes-card-feature"; import "../card-features/hui-climate-hvac-modes-card-feature";
import "../card-features/hui-climate-preset-modes-card-feature"; import "../card-features/hui-climate-preset-modes-card-feature";
import "../card-features/hui-counter-actions-card-feature";
import "../card-features/hui-cover-open-close-card-feature"; import "../card-features/hui-cover-open-close-card-feature";
import "../card-features/hui-cover-position-card-feature"; import "../card-features/hui-cover-position-card-feature";
import "../card-features/hui-cover-tilt-card-feature"; import "../card-features/hui-cover-tilt-card-feature";
@ -22,6 +23,7 @@ import "../card-features/hui-numeric-input-card-feature";
import "../card-features/hui-select-options-card-feature"; import "../card-features/hui-select-options-card-feature";
import "../card-features/hui-target-temperature-card-feature"; import "../card-features/hui-target-temperature-card-feature";
import "../card-features/hui-target-humidity-card-feature"; import "../card-features/hui-target-humidity-card-feature";
import "../card-features/hui-toggle-card-feature";
import "../card-features/hui-update-actions-card-feature"; import "../card-features/hui-update-actions-card-feature";
import "../card-features/hui-vacuum-commands-card-feature"; import "../card-features/hui-vacuum-commands-card-feature";
import "../card-features/hui-water-heater-operation-modes-card-feature"; import "../card-features/hui-water-heater-operation-modes-card-feature";
@ -39,6 +41,7 @@ const TYPES = new Set<LovelaceCardFeatureConfig["type"]>([
"climate-swing-horizontal-modes", "climate-swing-horizontal-modes",
"climate-hvac-modes", "climate-hvac-modes",
"climate-preset-modes", "climate-preset-modes",
"counter-actions",
"cover-open-close", "cover-open-close",
"cover-position", "cover-position",
"cover-tilt-position", "cover-tilt-position",
@ -57,6 +60,7 @@ const TYPES = new Set<LovelaceCardFeatureConfig["type"]>([
"select-options", "select-options",
"target-humidity", "target-humidity",
"target-temperature", "target-temperature",
"toggle",
"update-actions", "update-actions",
"vacuum-commands", "vacuum-commands",
"water-heater-operation-modes", "water-heater-operation-modes",

View File

@ -3,10 +3,10 @@ import "@material/mwc-tab/mwc-tab";
import { mdiClose } from "@mdi/js"; import { mdiClose } from "@mdi/js";
import type { CSSResultGroup } from "lit"; import type { CSSResultGroup } from "lit";
import { css, html, LitElement, nothing } from "lit"; import { css, html, LitElement, nothing } from "lit";
import { ifDefined } from "lit/directives/if-defined";
import { customElement, property, state } from "lit/decorators"; import { customElement, property, state } from "lit/decorators";
import { cache } from "lit/directives/cache"; import { cache } from "lit/directives/cache";
import { classMap } from "lit/directives/class-map"; import { classMap } from "lit/directives/class-map";
import { ifDefined } from "lit/directives/if-defined";
import memoize from "memoize-one"; import memoize from "memoize-one";
import { fireEvent } from "../../../../common/dom/fire_event"; import { fireEvent } from "../../../../common/dom/fire_event";
import { computeDomain } from "../../../../common/entity/compute_domain"; import { computeDomain } from "../../../../common/entity/compute_domain";
@ -24,6 +24,7 @@ import {
computeCards, computeCards,
computeSection, computeSection,
} from "../../common/generate-lovelace-config"; } from "../../common/generate-lovelace-config";
import { addCard } from "../config-util";
import { import {
findLovelaceContainer, findLovelaceContainer,
parseLovelaceContainerPath, parseLovelaceContainerPath,
@ -241,11 +242,24 @@ export class HuiCreateDialogCard
} }
} }
const lovelaceConfig = this._params!.lovelaceConfig;
const containerPath = this._params!.path;
const saveConfig = this._params!.saveConfig;
const sectionConfig =
containerPath.length === 2
? findLovelaceContainer(lovelaceConfig, containerPath)
: undefined;
showEditCardDialog(this, { showEditCardDialog(this, {
lovelaceConfig: this._params!.lovelaceConfig, lovelaceConfig,
saveConfig: this._params!.saveConfig, saveCardConfig: async (newCardConfig) => {
path: this._params!.path, const newConfig = addCard(lovelaceConfig, containerPath, newCardConfig);
await saveConfig(newConfig);
},
cardConfig: config, cardConfig: config,
sectionConfig,
isNew: true,
}); });
this.closeDialog(); this.closeDialog();

View File

@ -13,7 +13,6 @@ import "../../../../components/ha-dialog-header";
import "../../../../components/ha-icon-button"; import "../../../../components/ha-icon-button";
import type { LovelaceCardConfig } from "../../../../data/lovelace/config/card"; import type { LovelaceCardConfig } from "../../../../data/lovelace/config/card";
import type { LovelaceSectionConfig } from "../../../../data/lovelace/config/section"; import type { LovelaceSectionConfig } from "../../../../data/lovelace/config/section";
import type { LovelaceViewConfig } from "../../../../data/lovelace/config/view";
import { import {
getCustomCardEntry, getCustomCardEntry,
isCustomType, isCustomType,
@ -23,13 +22,12 @@ import { showConfirmationDialog } from "../../../../dialogs/generic/show-dialog-
import type { HassDialog } from "../../../../dialogs/make-dialog-manager"; import type { HassDialog } from "../../../../dialogs/make-dialog-manager";
import { haStyleDialog } from "../../../../resources/styles"; import { haStyleDialog } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types"; import type { HomeAssistant } from "../../../../types";
import { showToast } from "../../../../util/toast";
import { showSaveSuccessToast } from "../../../../util/toast-saved-success"; import { showSaveSuccessToast } from "../../../../util/toast-saved-success";
import "../../cards/hui-card"; import "../../cards/hui-card";
import "../../sections/hui-section"; import "../../sections/hui-section";
import { addCard, replaceCard } from "../config-util";
import { getCardDocumentationURL } from "../get-dashboard-documentation-url"; import { getCardDocumentationURL } from "../get-dashboard-documentation-url";
import type { ConfigChangedEvent } from "../hui-element-editor"; import type { ConfigChangedEvent } from "../hui-element-editor";
import { findLovelaceContainer } from "../lovelace-path";
import type { GUIModeChangedEvent } from "../types"; import type { GUIModeChangedEvent } from "../types";
import "./hui-card-element-editor"; import "./hui-card-element-editor";
import type { HuiCardElementEditor } from "./hui-card-element-editor"; import type { HuiCardElementEditor } from "./hui-card-element-editor";
@ -59,9 +57,7 @@ export class HuiDialogEditCard
@state() private _cardConfig?: LovelaceCardConfig; @state() private _cardConfig?: LovelaceCardConfig;
@state() private _containerConfig!: @state() private _sectionConfig?: LovelaceSectionConfig;
| LovelaceViewConfig
| LovelaceSectionConfig;
@state() private _saving = false; @state() private _saving = false;
@ -85,23 +81,10 @@ export class HuiDialogEditCard
this._GUImode = true; this._GUImode = true;
this._guiModeAvailable = true; this._guiModeAvailable = true;
const containerConfig = findLovelaceContainer( this._sectionConfig = this._params.sectionConfig;
params.lovelaceConfig,
params.path
);
if ("strategy" in containerConfig) {
throw new Error("Can't edit strategy");
}
this._containerConfig = containerConfig;
if ("cardConfig" in params) {
this._cardConfig = params.cardConfig; this._cardConfig = params.cardConfig;
this._dirty = true; this._dirty = Boolean(this._params.isNew);
} else {
this._cardConfig = this._containerConfig.cards?.[params.cardIndex];
}
this.large = false; this.large = false;
if (this._cardConfig && !Object.isFrozen(this._cardConfig)) { if (this._cardConfig && !Object.isFrozen(this._cardConfig)) {
@ -156,12 +139,12 @@ export class HuiDialogEditCard
}; };
protected render() { protected render() {
if (!this._params) { if (!this._params || !this._cardConfig) {
return nothing; return nothing;
} }
let heading: string; let heading: string;
if (this._cardConfig && this._cardConfig.type) { if (this._cardConfig.type) {
let cardName: string | undefined; let cardName: string | undefined;
if (isCustomType(this._cardConfig.type)) { if (isCustomType(this._cardConfig.type)) {
// prettier-ignore // prettier-ignore
@ -181,13 +164,6 @@ export class HuiDialogEditCard
"ui.panel.lovelace.editor.edit_card.typed_header", "ui.panel.lovelace.editor.edit_card.typed_header",
{ type: cardName } { type: cardName }
); );
} else if (!this._cardConfig) {
heading = this._containerConfig.title
? this.hass!.localize(
"ui.panel.lovelace.editor.edit_card.pick_card_view_title",
{ name: this._containerConfig.title }
)
: this.hass!.localize("ui.panel.lovelace.editor.edit_card.pick_card");
} else { } else {
heading = this.hass!.localize( heading = this.hass!.localize(
"ui.panel.lovelace.editor.edit_card.header" "ui.panel.lovelace.editor.edit_card.header"
@ -230,10 +206,8 @@ export class HuiDialogEditCard
<div class="content"> <div class="content">
<div class="element-editor"> <div class="element-editor">
<hui-card-element-editor <hui-card-element-editor
.showVisibilityTab=${this._cardConfig?.type !== "conditional"} .showVisibilityTab=${this._cardConfig.type !== "conditional"}
.sectionConfig=${this._isInSection .sectionConfig=${this._sectionConfig}
? this._containerConfig
: undefined}
.hass=${this.hass} .hass=${this.hass}
.lovelace=${this._params.lovelaceConfig} .lovelace=${this._params.lovelaceConfig}
.value=${this._cardConfig} .value=${this._cardConfig}
@ -244,7 +218,7 @@ export class HuiDialogEditCard
></hui-card-element-editor> ></hui-card-element-editor>
</div> </div>
<div class="element-preview"> <div class="element-preview">
${this._isInSection ${this._sectionConfig
? html` ? html`
<hui-section <hui-section
.hass=${this.hass} .hass=${this.hass}
@ -345,14 +319,10 @@ export class HuiDialogEditCard
this._cardEditorEl?.focusYamlEditor(); this._cardEditorEl?.focusYamlEditor();
} }
private get _isInSection() {
return this._params!.path.length === 2;
}
private _cardConfigInSection = memoizeOne( private _cardConfigInSection = memoizeOne(
(cardConfig?: LovelaceCardConfig) => { (cardConfig: LovelaceCardConfig) => {
const { cards, title, ...containerConfig } = this const { cards, title, ...containerConfig } = this
._containerConfig as LovelaceSectionConfig; ._sectionConfig as LovelaceSectionConfig;
return { return {
...containerConfig, ...containerConfig,
@ -411,20 +381,18 @@ export class HuiDialogEditCard
return; return;
} }
this._saving = true; this._saving = true;
const path = this._params!.path; try {
await this._params!.saveConfig( await this._params!.saveCardConfig(this._cardConfig!);
"cardConfig" in this._params!
? addCard(this._params!.lovelaceConfig, path, this._cardConfig!)
: replaceCard(
this._params!.lovelaceConfig,
[...path, this._params!.cardIndex],
this._cardConfig!
)
);
this._saving = false; this._saving = false;
this._dirty = false; this._dirty = false;
showSaveSuccessToast(this, this.hass); showSaveSuccessToast(this, this.hass);
this.closeDialog(); this.closeDialog();
} catch (err: any) {
showToast(this, {
message: err.message,
});
this._saving = false;
}
} }
static get styles(): CSSResultGroup { static get styles(): CSSResultGroup {

View File

@ -1,20 +1,15 @@
import { fireEvent } from "../../../../common/dom/fire_event"; import { fireEvent } from "../../../../common/dom/fire_event";
import type { LovelaceCardConfig } from "../../../../data/lovelace/config/card"; import type { LovelaceCardConfig } from "../../../../data/lovelace/config/card";
import type { LovelaceSectionConfig } from "../../../../data/lovelace/config/section";
import type { LovelaceConfig } from "../../../../data/lovelace/config/types"; import type { LovelaceConfig } from "../../../../data/lovelace/config/types";
import type { LovelaceContainerPath } from "../lovelace-path";
export type EditCardDialogParams = { export interface EditCardDialogParams {
lovelaceConfig: LovelaceConfig; lovelaceConfig: LovelaceConfig;
saveConfig: (config: LovelaceConfig) => void; saveCardConfig: (config: LovelaceCardConfig) => void;
path: LovelaceContainerPath;
} & (
| {
cardIndex: number;
}
| {
cardConfig: LovelaceCardConfig; cardConfig: LovelaceCardConfig;
sectionConfig?: LovelaceSectionConfig;
isNew?: boolean;
} }
);
export const importEditCardDialog = () => import("./hui-dialog-edit-card"); export const importEditCardDialog = () => import("./hui-dialog-edit-card");

View File

@ -24,6 +24,7 @@ import { supportsClimateHvacModesCardFeature } from "../../card-features/hui-cli
import { supportsClimatePresetModesCardFeature } from "../../card-features/hui-climate-preset-modes-card-feature"; import { supportsClimatePresetModesCardFeature } from "../../card-features/hui-climate-preset-modes-card-feature";
import { supportsClimateSwingModesCardFeature } from "../../card-features/hui-climate-swing-modes-card-feature"; import { supportsClimateSwingModesCardFeature } from "../../card-features/hui-climate-swing-modes-card-feature";
import { supportsClimateSwingHorizontalModesCardFeature } from "../../card-features/hui-climate-swing-horizontal-modes-card-feature"; import { supportsClimateSwingHorizontalModesCardFeature } from "../../card-features/hui-climate-swing-horizontal-modes-card-feature";
import { supportsCounterActionsCardFeature } from "../../card-features/hui-counter-actions-card-feature";
import { supportsCoverOpenCloseCardFeature } from "../../card-features/hui-cover-open-close-card-feature"; import { supportsCoverOpenCloseCardFeature } from "../../card-features/hui-cover-open-close-card-feature";
import { supportsCoverPositionCardFeature } from "../../card-features/hui-cover-position-card-feature"; import { supportsCoverPositionCardFeature } from "../../card-features/hui-cover-position-card-feature";
import { supportsCoverTiltCardFeature } from "../../card-features/hui-cover-tilt-card-feature"; import { supportsCoverTiltCardFeature } from "../../card-features/hui-cover-tilt-card-feature";
@ -42,6 +43,7 @@ import { supportsNumericInputCardFeature } from "../../card-features/hui-numeric
import { supportsSelectOptionsCardFeature } from "../../card-features/hui-select-options-card-feature"; import { supportsSelectOptionsCardFeature } from "../../card-features/hui-select-options-card-feature";
import { supportsTargetHumidityCardFeature } from "../../card-features/hui-target-humidity-card-feature"; import { supportsTargetHumidityCardFeature } from "../../card-features/hui-target-humidity-card-feature";
import { supportsTargetTemperatureCardFeature } from "../../card-features/hui-target-temperature-card-feature"; import { supportsTargetTemperatureCardFeature } from "../../card-features/hui-target-temperature-card-feature";
import { supportsToggleCardFeature } from "../../card-features/hui-toggle-card-feature";
import { supportsUpdateActionsCardFeature } from "../../card-features/hui-update-actions-card-feature"; import { supportsUpdateActionsCardFeature } from "../../card-features/hui-update-actions-card-feature";
import { supportsVacuumCommandsCardFeature } from "../../card-features/hui-vacuum-commands-card-feature"; import { supportsVacuumCommandsCardFeature } from "../../card-features/hui-vacuum-commands-card-feature";
import { supportsWaterHeaterOperationModesCardFeature } from "../../card-features/hui-water-heater-operation-modes-card-feature"; import { supportsWaterHeaterOperationModesCardFeature } from "../../card-features/hui-water-heater-operation-modes-card-feature";
@ -58,6 +60,7 @@ const UI_FEATURE_TYPES = [
"climate-preset-modes", "climate-preset-modes",
"climate-swing-modes", "climate-swing-modes",
"climate-swing-horizontal-modes", "climate-swing-horizontal-modes",
"counter-actions",
"cover-open-close", "cover-open-close",
"cover-position", "cover-position",
"cover-tilt-position", "cover-tilt-position",
@ -76,6 +79,7 @@ const UI_FEATURE_TYPES = [
"select-options", "select-options",
"target-humidity", "target-humidity",
"target-temperature", "target-temperature",
"toggle",
"update-actions", "update-actions",
"vacuum-commands", "vacuum-commands",
"water-heater-operation-modes", "water-heater-operation-modes",
@ -90,6 +94,7 @@ const EDITABLES_FEATURE_TYPES = new Set<UiFeatureTypes>([
"climate-preset-modes", "climate-preset-modes",
"climate-swing-modes", "climate-swing-modes",
"climate-swing-horizontal-modes", "climate-swing-horizontal-modes",
"counter-actions",
"fan-preset-modes", "fan-preset-modes",
"humidifier-modes", "humidifier-modes",
"lawn-mower-commands", "lawn-mower-commands",
@ -111,6 +116,7 @@ const SUPPORTS_FEATURE_TYPES: Record<
supportsClimateSwingHorizontalModesCardFeature, supportsClimateSwingHorizontalModesCardFeature,
"climate-hvac-modes": supportsClimateHvacModesCardFeature, "climate-hvac-modes": supportsClimateHvacModesCardFeature,
"climate-preset-modes": supportsClimatePresetModesCardFeature, "climate-preset-modes": supportsClimatePresetModesCardFeature,
"counter-actions": supportsCounterActionsCardFeature,
"cover-open-close": supportsCoverOpenCloseCardFeature, "cover-open-close": supportsCoverOpenCloseCardFeature,
"cover-position": supportsCoverPositionCardFeature, "cover-position": supportsCoverPositionCardFeature,
"cover-tilt-position": supportsCoverTiltPositionCardFeature, "cover-tilt-position": supportsCoverTiltPositionCardFeature,
@ -129,6 +135,7 @@ const SUPPORTS_FEATURE_TYPES: Record<
"select-options": supportsSelectOptionsCardFeature, "select-options": supportsSelectOptionsCardFeature,
"target-humidity": supportsTargetHumidityCardFeature, "target-humidity": supportsTargetHumidityCardFeature,
"target-temperature": supportsTargetTemperatureCardFeature, "target-temperature": supportsTargetTemperatureCardFeature,
toggle: supportsToggleCardFeature,
"update-actions": supportsUpdateActionsCardFeature, "update-actions": supportsUpdateActionsCardFeature,
"vacuum-commands": supportsVacuumCommandsCardFeature, "vacuum-commands": supportsVacuumCommandsCardFeature,
"water-heater-operation-modes": supportsWaterHeaterOperationModesCardFeature, "water-heater-operation-modes": supportsWaterHeaterOperationModesCardFeature,

View File

@ -0,0 +1,91 @@
import { html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../../../common/dom/fire_event";
import type { LocalizeFunc } from "../../../../common/translations/localize";
import type { SchemaUnion } from "../../../../components/ha-form/types";
import "../../../../components/ha-form/ha-form";
import type { HomeAssistant } from "../../../../types";
import {
COUNTER_ACTIONS,
type LovelaceCardFeatureContext,
type CounterActionsCardFeatureConfig,
} from "../../card-features/types";
import type { LovelaceCardFeatureEditor } from "../../types";
@customElement("hui-counter-actions-card-feature-editor")
export class HuiCounterActionsCardFeatureEditor
extends LitElement
implements LovelaceCardFeatureEditor
{
@property({ attribute: false }) public hass?: HomeAssistant;
@property({ attribute: false }) public context?: LovelaceCardFeatureContext;
@state() private _config?: CounterActionsCardFeatureConfig;
public setConfig(config: CounterActionsCardFeatureConfig): void {
this._config = config;
}
private _schema = memoizeOne(
(localize: LocalizeFunc) =>
[
{
name: "actions",
selector: {
select: {
multiple: true,
mode: "list",
reorder: true,
options: COUNTER_ACTIONS.map((action) => ({
value: action,
label: `${localize(
`ui.panel.lovelace.editor.features.types.counter-actions.actions.${action}`
)}`,
})),
},
},
},
] as const
);
protected render() {
if (!this.hass || !this._config) {
return nothing;
}
const schema = this._schema(this.hass.localize);
return html`
<ha-form
.hass=${this.hass}
.data=${this._config}
.schema=${schema}
.computeLabel=${this._computeLabelCallback}
@value-changed=${this._valueChanged}
></ha-form>
`;
}
private _valueChanged(ev: CustomEvent): void {
fireEvent(this, "config-changed", { config: ev.detail.value });
}
private _computeLabelCallback = (
schema: SchemaUnion<ReturnType<typeof this._schema>>
) => {
switch (schema.name) {
default:
return this.hass!.localize(
`ui.panel.lovelace.editor.card.generic.${schema.name}`
);
}
};
}
declare global {
interface HTMLElementTagNameMap {
"hui-counter-actions-card-feature-editor": HuiCounterActionsCardFeatureEditor;
}
}

View File

@ -1,29 +1,28 @@
import { html, LitElement, nothing } from "lit"; import { html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators"; import { customElement, property, state } from "lit/decorators";
import { assert, assign, object, optional, string } from "superstruct"; import { assert, assign, boolean, object, optional, string } from "superstruct";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../../../common/dom/fire_event"; import { fireEvent } from "../../../../common/dom/fire_event";
import "../../../../components/ha-form/ha-form"; import "../../../../components/ha-form/ha-form";
import type { SchemaUnion } from "../../../../components/ha-form/types"; import type {
HaFormSchema,
SchemaUnion,
} from "../../../../components/ha-form/types";
import type { HomeAssistant } from "../../../../types"; import type { HomeAssistant } from "../../../../types";
import type { MarkdownCardConfig } from "../../cards/types"; import type { MarkdownCardConfig } from "../../cards/types";
import type { LovelaceCardEditor } from "../../types"; import type { LovelaceCardEditor } from "../../types";
import { baseLovelaceCardConfig } from "../structs/base-card-struct"; import { baseLovelaceCardConfig } from "../structs/base-card-struct";
import type { LocalizeFunc } from "../../../../common/translations/localize";
const cardConfigStruct = assign( const cardConfigStruct = assign(
baseLovelaceCardConfig, baseLovelaceCardConfig,
object({ object({
text_only: optional(boolean()),
title: optional(string()), title: optional(string()),
content: string(), content: string(),
theme: optional(string()),
}) })
); );
const SCHEMA = [
{ name: "title", selector: { text: {} } },
{ name: "content", required: true, selector: { template: {} } },
{ name: "theme", selector: { theme: {} } },
] as const;
@customElement("hui-markdown-card-editor") @customElement("hui-markdown-card-editor")
export class HuiMarkdownCardEditor export class HuiMarkdownCardEditor
extends LitElement extends LitElement
@ -38,16 +37,51 @@ export class HuiMarkdownCardEditor
this._config = config; this._config = config;
} }
private _schema = memoizeOne(
(localize: LocalizeFunc, text_only: boolean) =>
[
{
name: "style",
required: true,
selector: {
select: {
mode: "dropdown",
options: ["card", "text-only"].map((style) => ({
label: localize(
`ui.panel.lovelace.editor.card.markdown.style_options.${style}`
),
value: style,
})),
},
},
},
...(!text_only
? ([{ name: "title", selector: { text: {} } }] as const)
: []),
{ name: "content", required: true, selector: { template: {} } },
] as const satisfies HaFormSchema[]
);
protected render() { protected render() {
if (!this.hass || !this._config) { if (!this.hass || !this._config) {
return nothing; return nothing;
} }
const data = {
...this._config,
style: this._config.text_only ? "text-only" : "card",
};
const schema = this._schema(
this.hass.localize,
this._config.text_only || false
);
return html` return html`
<ha-form <ha-form
.hass=${this.hass} .hass=${this.hass}
.data=${this._config} .data=${data}
.schema=${SCHEMA} .schema=${schema}
.computeLabel=${this._computeLabelCallback} .computeLabel=${this._computeLabelCallback}
@value-changed=${this._valueChanged} @value-changed=${this._valueChanged}
></ha-form> ></ha-form>
@ -55,17 +89,23 @@ export class HuiMarkdownCardEditor
} }
private _valueChanged(ev: CustomEvent): void { private _valueChanged(ev: CustomEvent): void {
fireEvent(this, "config-changed", { config: ev.detail.value }); const config = { ...ev.detail.value };
if (config.style === "text-only") {
config.text_only = true;
} else {
delete config.text_only;
}
delete config.style;
fireEvent(this, "config-changed", { config });
} }
private _computeLabelCallback = (schema: SchemaUnion<typeof SCHEMA>) => { private _computeLabelCallback = (
schema: SchemaUnion<ReturnType<typeof this._schema>>
) => {
switch (schema.name) { switch (schema.name) {
case "theme": case "style":
return `${this.hass!.localize(
"ui.panel.lovelace.editor.card.generic.theme"
)} (${this.hass!.localize(
"ui.panel.lovelace.editor.card.config.optional"
)})`;
case "content": case "content":
return this.hass!.localize( return this.hass!.localize(
`ui.panel.lovelace.editor.card.markdown.${schema.name}` `ui.panel.lovelace.editor.card.markdown.${schema.name}`

View File

@ -21,6 +21,7 @@ import {
optional, optional,
string, string,
} from "superstruct"; } from "superstruct";
import { keyed } from "lit/directives/keyed";
import type { import type {
HaFormSchema, HaFormSchema,
SchemaUnion, SchemaUnion,
@ -84,6 +85,8 @@ export class HuiStackCardEditor
@state() protected _guiModeAvailable? = true; @state() protected _guiModeAvailable? = true;
protected _keys = new WeakMap<LovelaceCardConfig, string>();
protected _schema: readonly HaFormSchema[] = SCHEMA; protected _schema: readonly HaFormSchema[] = SCHEMA;
@query("hui-card-element-editor") @query("hui-card-element-editor")
@ -199,14 +202,16 @@ export class HuiStackCardEditor
@click=${this._handleDeleteCard} @click=${this._handleDeleteCard}
></ha-icon-button> ></ha-icon-button>
</div> </div>
${keyed(
<hui-card-element-editor this._getKey(this._config.cards[selected]),
html`<hui-card-element-editor
.hass=${this.hass} .hass=${this.hass}
.value=${this._config.cards[selected]} .value=${this._config.cards[selected]}
.lovelace=${this.lovelace} .lovelace=${this.lovelace}
@config-changed=${this._handleConfigChanged} @config-changed=${this._handleConfigChanged}
@GUImode-changed=${this._handleGUIModeChanged} @GUImode-changed=${this._handleGUIModeChanged}
></hui-card-element-editor> ></hui-card-element-editor>`
)}
` `
: html` : html`
<hui-card-picker <hui-card-picker
@ -220,6 +225,14 @@ export class HuiStackCardEditor
`; `;
} }
private _getKey(card: LovelaceCardConfig) {
if (!this._keys.has(card)) {
this._keys.set(card, Math.random().toString());
}
return this._keys.get(card)!;
}
protected _handleSelectedCard(ev) { protected _handleSelectedCard(ev) {
if (ev.target.id === "add-card") { if (ev.target.id === "add-card") {
this._selectedCard = this._config!.cards.length; this._selectedCard = this._config!.cards.length;
@ -236,7 +249,10 @@ export class HuiStackCardEditor
return; return;
} }
const cards = [...this._config.cards]; const cards = [...this._config.cards];
cards[this._selectedCard] = ev.detail.config as LovelaceCardConfig; const key = this._getKey(cards[this._selectedCard]);
const newCard = ev.detail.config as LovelaceCardConfig;
cards[this._selectedCard] = newCard;
this._keys.set(newCard, key);
this._config = { ...this._config, cards }; this._config = { ...this._config, cards };
this._guiModeAvailable = ev.detail.guiModeAvailable; this._guiModeAvailable = ev.detail.guiModeAvailable;
fireEvent(this, "config-changed", { config: this._config }); fireEvent(this, "config-changed", { config: this._config });

View File

@ -8,6 +8,7 @@ import {
assert, assert,
assign, assign,
boolean, boolean,
enums,
object, object,
optional, optional,
string, string,
@ -15,6 +16,7 @@ import {
} from "superstruct"; } from "superstruct";
import type { HASSDomEvent } from "../../../../common/dom/fire_event"; import type { HASSDomEvent } from "../../../../common/dom/fire_event";
import { fireEvent } from "../../../../common/dom/fire_event"; import { fireEvent } from "../../../../common/dom/fire_event";
import type { LocalizeFunc } from "../../../../common/translations/localize";
import "../../../../components/ha-expansion-panel"; import "../../../../components/ha-expansion-panel";
import "../../../../components/ha-form/ha-form"; import "../../../../components/ha-form/ha-form";
import type { import type {
@ -54,6 +56,7 @@ const cardConfigStruct = assign(
icon_hold_action: optional(actionConfigStruct), icon_hold_action: optional(actionConfigStruct),
icon_double_tap_action: optional(actionConfigStruct), icon_double_tap_action: optional(actionConfigStruct),
features: optional(array(any())), features: optional(array(any())),
features_position: optional(enums(["bottom", "inline"])),
}) })
); );
@ -109,8 +112,10 @@ export class HuiTileCardEditor
private _schema = memoizeOne( private _schema = memoizeOne(
( (
localize: LocalizeFunc,
entityId: string | undefined, entityId: string | undefined,
hideState: boolean, hideState: boolean,
vertical: boolean,
displayActions: AdvancedActions[] = [] displayActions: AdvancedActions[] = []
) => ) =>
[ [
@ -148,12 +153,6 @@ export class HuiTileCardEditor
boolean: {}, boolean: {},
}, },
}, },
{
name: "vertical",
selector: {
boolean: {},
},
},
{ {
name: "hide_state", name: "hide_state",
selector: { selector: {
@ -175,6 +174,43 @@ export class HuiTileCardEditor
}, },
] as const satisfies readonly HaFormSchema[]) ] as const satisfies readonly HaFormSchema[])
: []), : []),
{
name: "",
type: "grid",
schema: [
{
name: "content_layout",
required: true,
selector: {
select: {
mode: "dropdown",
options: ["horizontal", "vertical"].map((value) => ({
label: localize(
`ui.panel.lovelace.editor.card.tile.content_layout_options.${value}`
),
value,
})),
},
},
},
{
name: "features_position",
required: true,
selector: {
select: {
mode: "dropdown",
options: ["bottom", "inline"].map((value) => ({
label: localize(
`ui.panel.lovelace.editor.card.tile.features_position_options.${value}`
),
value,
disabled: vertical && value === "inline",
})),
},
},
},
],
},
], ],
}, },
{ {
@ -223,12 +259,22 @@ export class HuiTileCardEditor
const stateObj = entityId ? this.hass!.states[entityId] : undefined; const stateObj = entityId ? this.hass!.states[entityId] : undefined;
const schema = this._schema( const schema = this._schema(
this.hass.localize,
entityId, entityId,
this._config!.hide_state ?? false, this._config.hide_state ?? false,
this._config.vertical ?? false,
this._displayActions this._displayActions
); );
const data = this._config; const data = {
...this._config,
content_layout: this._config.vertical ? "vertical" : "horizontal",
};
// Default features position to bottom and force it to bottom in vertical mode
if (!data.features_position || data.vertical) {
data.features_position = "bottom";
}
return html` return html`
<ha-form <ha-form
@ -280,6 +326,12 @@ export class HuiTileCardEditor
delete config.state_content; delete config.state_content;
} }
// Convert content_layout to vertical
if (config.content_layout) {
config.vertical = config.content_layout === "vertical";
delete config.content_layout;
}
fireEvent(this, "config-changed", { config }); fireEvent(this, "config-changed", { config });
} }
@ -337,11 +389,11 @@ export class HuiTileCardEditor
case "icon_hold_action": case "icon_hold_action":
case "icon_double_tap_action": case "icon_double_tap_action":
case "show_entity_picture": case "show_entity_picture":
case "vertical":
case "hide_state": case "hide_state":
case "state_content": case "state_content":
case "content_layout":
case "appearance": case "appearance":
case "interactions": case "features_position":
return this.hass!.localize( return this.hass!.localize(
`ui.panel.lovelace.editor.card.tile.${schema.name}` `ui.panel.lovelace.editor.card.tile.${schema.name}`
); );
@ -377,6 +429,14 @@ export class HuiTileCardEditor
display: block; display: block;
margin-bottom: 24px; margin-bottom: 24px;
} }
.info {
color: var(--secondary-text-color);
margin-top: 0;
margin-bottom: 8px;
}
.features-form {
margin-bottom: 8px;
}
`, `,
]; ];
} }

View File

@ -21,6 +21,7 @@ import {
import { createSectionElement } from "../create-element/create-section-element"; import { createSectionElement } from "../create-element/create-section-element";
import { showCreateCardDialog } from "../editor/card-editor/show-create-card-dialog"; import { showCreateCardDialog } from "../editor/card-editor/show-create-card-dialog";
import { showEditCardDialog } from "../editor/card-editor/show-edit-card-dialog"; import { showEditCardDialog } from "../editor/card-editor/show-edit-card-dialog";
import { replaceCard } from "../editor/config-util";
import { performDeleteCard } from "../editor/delete-card"; import { performDeleteCard } from "../editor/delete-card";
import { parseLovelaceCardPath } from "../editor/lovelace-path"; import { parseLovelaceCardPath } from "../editor/lovelace-path";
import { generateLovelaceSectionStrategy } from "../strategies/get-strategy"; import { generateLovelaceSectionStrategy } from "../strategies/get-strategy";
@ -253,11 +254,23 @@ export class HuiSection extends ReactiveElement {
ev.stopPropagation(); ev.stopPropagation();
if (!this.lovelace) return; if (!this.lovelace) return;
const { cardIndex } = parseLovelaceCardPath(ev.detail.path); const { cardIndex } = parseLovelaceCardPath(ev.detail.path);
const sectionConfig = this.config;
if (isStrategySection(sectionConfig)) {
return;
}
const cardConfig = sectionConfig.cards![cardIndex];
showEditCardDialog(this, { showEditCardDialog(this, {
lovelaceConfig: this.lovelace.config, lovelaceConfig: this.lovelace.config,
saveConfig: this.lovelace.saveConfig, saveCardConfig: async (newCardConfig) => {
path: [this.viewIndex, this.index], const newConfig = replaceCard(
cardIndex, this.lovelace!.config,
[this.viewIndex, this.index, cardIndex],
newCardConfig
);
await this.lovelace!.saveConfig(newConfig);
},
sectionConfig,
cardConfig,
}); });
}); });
this._layoutElement.addEventListener("ll-delete-card", (ev) => { this._layoutElement.addEventListener("ll-delete-card", (ev) => {

View File

@ -21,6 +21,7 @@ import { showCreateBadgeDialog } from "../editor/badge-editor/show-create-badge-
import { showEditBadgeDialog } from "../editor/badge-editor/show-edit-badge-dialog"; import { showEditBadgeDialog } from "../editor/badge-editor/show-edit-badge-dialog";
import { showCreateCardDialog } from "../editor/card-editor/show-create-card-dialog"; import { showCreateCardDialog } from "../editor/card-editor/show-create-card-dialog";
import { showEditCardDialog } from "../editor/card-editor/show-edit-card-dialog"; import { showEditCardDialog } from "../editor/card-editor/show-edit-card-dialog";
import { replaceCard } from "../editor/config-util";
import { import {
type DeleteBadgeParams, type DeleteBadgeParams,
performDeleteBadge, performDeleteBadge,
@ -270,11 +271,22 @@ export class HUIView extends ReactiveElement {
}); });
this._layoutElement.addEventListener("ll-edit-card", (ev) => { this._layoutElement.addEventListener("ll-edit-card", (ev) => {
const { cardIndex } = parseLovelaceCardPath(ev.detail.path); const { cardIndex } = parseLovelaceCardPath(ev.detail.path);
const viewConfig = this.lovelace!.config.views[this.index];
if (isStrategyView(viewConfig)) {
return;
}
const cardConfig = viewConfig.cards![cardIndex];
showEditCardDialog(this, { showEditCardDialog(this, {
lovelaceConfig: this.lovelace.config, lovelaceConfig: this.lovelace.config,
saveConfig: this.lovelace.saveConfig, saveCardConfig: async (newCardConfig) => {
path: [this.index], const newConfig = replaceCard(
cardIndex, this.lovelace!.config,
[this.index, cardIndex],
newCardConfig
);
await this.lovelace.saveConfig(newConfig);
},
cardConfig,
}); });
}); });
this._layoutElement.addEventListener("ll-delete-card", (ev) => { this._layoutElement.addEventListener("ll-delete-card", (ev) => {

View File

@ -47,7 +47,12 @@ export class HaStateControlAlarmControlPanelModes extends LitElement {
} }
private async _setMode(mode: AlarmMode) { private async _setMode(mode: AlarmMode) {
setProtectedAlarmControlPanelMode(this, this.hass!, this.stateObj!, mode); await setProtectedAlarmControlPanelMode(
this,
this.hass!,
this.stateObj!,
mode
);
} }
private async _valueChanged(ev: CustomEvent) { private async _valueChanged(ev: CustomEvent) {

View File

@ -286,7 +286,10 @@ export const connectionMixin = <T extends Constructor<HassBaseEl>>(
clearInterval(this.__backendPingInterval); clearInterval(this.__backendPingInterval);
this.__backendPingInterval = setInterval(() => { this.__backendPingInterval = setInterval(() => {
if (this.hass?.connected) { if (this.hass?.connected) {
promiseTimeout(5000, this.hass?.connection.ping()).catch(() => { // If the backend is busy, or the connection is latent,
// it can take more than 10 seconds for the ping to return.
// We give it a 15 second timeout to be safe.
promiseTimeout(15000, this.hass?.connection.ping()).catch(() => {
if (!this.hass?.connected) { if (!this.hass?.connected) {
return; return;
} }
@ -296,7 +299,7 @@ export const connectionMixin = <T extends Constructor<HassBaseEl>>(
this.hass?.connection.reconnect(true); this.hass?.connection.reconnect(true);
}); });
} }
}, 10000); }, 30000);
} }
protected hassReconnected() { protected hassReconnected() {

View File

@ -370,7 +370,10 @@
"name": "Name", "name": "Name",
"optional": "optional", "optional": "optional",
"default": "Default", "default": "Default",
"dont_save": "Don't save" "dont_save": "Don't save",
"copy": "Copy",
"show": "Show",
"hide": "Hide"
}, },
"components": { "components": {
"selectors": { "selectors": {
@ -2469,7 +2472,7 @@
}, },
"retention": "Retention", "retention": "Retention",
"custom_retention": "Custom retention", "custom_retention": "Custom retention",
"custom_retention_label": "Clean up every", "custom_retention_label": "Keep only",
"retention_description": "Based on the maximum number of backups or how many days they should be kept.", "retention_description": "Based on the maximum number of backups or how many days they should be kept.",
"retention_presets": { "retention_presets": {
"copies_3": "3 backups", "copies_3": "3 backups",
@ -2512,6 +2515,7 @@
"menu": { "menu": {
"upload_backup": "Upload backup" "upload_backup": "Upload backup"
}, },
"agent_error": "Error in location {name}",
"new_backup": "Backup now", "new_backup": "Backup now",
"onboarding": { "onboarding": {
"title": "Set up backups", "title": "Set up backups",
@ -2650,7 +2654,9 @@
"title": "Locations", "title": "Locations",
"description": "Your backup will be stored on these locations when this default backup is created. You can use all locations for custom backups.", "description": "Your backup will be stored on these locations when this default backup is created. You can use all locations for custom backups.",
"no_location": "No location selected", "no_location": "No location selected",
"no_location_description": "You have to select at least one location to create a backup." "no_location_description": "You have to select at least one location to create a backup.",
"more_locations": "Explore more locations",
"manage_network_storage": "Manage network storage"
}, },
"encryption_key": { "encryption_key": {
"title": "Encryption key", "title": "Encryption key",
@ -4724,6 +4730,23 @@
"fingerprint": "Certificate fingerprint:", "fingerprint": "Certificate fingerprint:",
"close": "Close" "close": "Close"
}, },
"dialog_already_connected": {
"heading": "Account linked to other Home Assistant",
"description": "We noticed that another instance is currently connected to your Home Assistant Cloud account. Your Home Assistant Cloud account can only be signed into one Home Assistant instance at a time. If you log in here, the other instance will be disconnected along with its Cloud services.",
"other_home_assistant": "Other Home Assistant",
"ip_address": "IP Address",
"connected_at": "Connected at",
"obfuscated_ip": {
"show": "Show IP address",
"hide": "Hide IP address"
},
"info_backups": {
"title": "Home Assistant Cloud backups",
"description": "Your Cloud backup may be overwritten if you proceed. We strongly recommend downloading your current backup from your Nabu Casa account page before continuing."
},
"close": "Close",
"login_here": "Log in here"
},
"dialog_cloudhook": { "dialog_cloudhook": {
"webhook_for": "Webhook for {name}", "webhook_for": "Webhook for {name}",
"managed_by_integration": "This webhook is managed by an integration and cannot be disabled.", "managed_by_integration": "This webhook is managed by an integration and cannot be disabled.",
@ -6307,7 +6330,8 @@
"description": "Home Assistant is starting, please wait…" "description": "Home Assistant is starting, please wait…"
}, },
"map": { "map": {
"reset_focus": "Reset focus" "reset_focus": "Reset focus",
"toggle_grouping": "Toggle grouping"
}, },
"energy": { "energy": {
"loading": "Loading…", "loading": "Loading…",
@ -6994,7 +7018,8 @@
"suggested_cards": "Suggested cards", "suggested_cards": "Suggested cards",
"other_cards": "Other cards", "other_cards": "Other cards",
"custom_cards": "Custom cards", "custom_cards": "Custom cards",
"features": "Features" "features": "Features",
"actions": "Actions"
}, },
"heading": { "heading": {
"name": "Heading", "name": "Heading",
@ -7041,6 +7066,11 @@
"markdown": { "markdown": {
"name": "Markdown", "name": "Markdown",
"content": "Content", "content": "Content",
"style": "Style",
"style_options": {
"card": "Card",
"text-only": "Text only"
},
"description": "The Markdown card is used to render Markdown." "description": "The Markdown card is used to render Markdown."
}, },
"media-control": { "media-control": {
@ -7119,12 +7149,20 @@
"icon_tap_action": "Icon tap behavior", "icon_tap_action": "Icon tap behavior",
"icon_hold_action": "Icon hold behavior", "icon_hold_action": "Icon hold behavior",
"icon_double_tap_action": "Icon double tap behavior", "icon_double_tap_action": "Icon double tap behavior",
"interactions": "Interactions",
"appearance": "Appearance", "appearance": "Appearance",
"show_entity_picture": "Show entity picture", "show_entity_picture": "Show entity picture",
"vertical": "Vertical",
"hide_state": "Hide state", "hide_state": "Hide state",
"state_content": "State content" "state_content": "State content",
"features_position": "Features position",
"features_position_options": {
"bottom": "Bottom",
"inline": "Inline"
},
"content_layout": "Content layout",
"content_layout_options": {
"horizontal": "Horizontal",
"vertical": "Vertical"
}
}, },
"vertical-stack": { "vertical-stack": {
"name": "Vertical stack", "name": "Vertical stack",
@ -7300,6 +7338,14 @@
"customize_modes": "Customize preset modes", "customize_modes": "Customize preset modes",
"preset_modes": "Preset modes" "preset_modes": "Preset modes"
}, },
"counter-actions": {
"label": "Counter actions",
"actions": {
"increment": "Increment",
"decrement": "Decrement",
"reset": "Reset"
}
},
"fan-preset-modes": { "fan-preset-modes": {
"label": "Fan preset modes", "label": "Fan preset modes",
"style": "[%key:ui::panel::lovelace::editor::features::types::climate-preset-modes::style%]", "style": "[%key:ui::panel::lovelace::editor::features::types::climate-preset-modes::style%]",
@ -7328,6 +7374,9 @@
"options": "Options", "options": "Options",
"customize_options": "Customize options" "customize_options": "Customize options"
}, },
"toggle": {
"label": "Toggle"
},
"numeric-input": { "numeric-input": {
"label": "Numeric input", "label": "Numeric input",
"style": "Style", "style": "Style",

View File

@ -1,5 +0,0 @@
{
"rules": {
"import/no-extraneous-dependencies": 0
}
}

View File

@ -51,4 +51,16 @@ describe("Color Conversion Tests", () => {
expect(theme2hex("#ff0000")).toBe("#ff0000"); expect(theme2hex("#ff0000")).toBe("#ff0000");
expect(theme2hex("unicorn")).toBe("unicorn"); expect(theme2hex("unicorn")).toBe("unicorn");
}); });
it("should convert rgb theme color to hex", () => {
expect(theme2hex("rgb( 255, 0, 0)")).toBe("#ff0000");
expect(theme2hex("rgb(0,255, 0)")).toBe("#00ff00");
expect(theme2hex("rgb(0, 0,255 )")).toBe("#0000ff");
});
it("should convert rgba theme color to hex by ignoring alpha", () => {
expect(theme2hex("rgba( 255, 0, 0, 0.5)")).toBe("#ff0000");
expect(theme2hex("rgba(0,255, 0, 0.3)")).toBe("#00ff00");
expect(theme2hex("rgba(0, 0,255 , 0.7)")).toBe("#0000ff");
});
}); });

View File

@ -63,4 +63,28 @@ describe("canToggleState", () => {
}; };
assert.isFalse(canToggleState(hass, stateObj)); assert.isFalse(canToggleState(hass, stateObj));
}); });
it("Detects group with missing entity", () => {
const stateObj: any = {
entity_id: "group.bla",
state: "on",
attributes: {
entity_id: ["light.non_existing"],
},
};
assert.isFalse(canToggleState(hass, stateObj));
});
it("Detects group with off state", () => {
const stateObj: any = {
entity_id: "group.bla",
state: "off",
attributes: {
entity_id: ["light.test"],
},
};
assert.isTrue(canToggleState(hass, stateObj));
});
}); });

View File

@ -0,0 +1,371 @@
import type {
HassConfig,
HassEntity,
HassEntityBase,
} from "home-assistant-js-websocket";
import { describe, it, expect } from "vitest";
import {
computeAttributeValueDisplay,
computeAttributeNameDisplay,
} from "../../../src/common/entity/compute_attribute_display";
import type { FrontendLocaleData } from "../../../src/data/translation";
import type { HomeAssistant } from "../../../src/types";
export const localizeMock = (key: string) => {
const translations = {
"state.default.unknown": "Unknown",
"component.test_platform.entity.sensor.test_translation_key.state_attributes.attribute.state.42":
"42",
"component.test_platform.entity.sensor.test_translation_key.state_attributes.attribute.state.attributeValue":
"Localized Attribute Name",
"component.media_player.entity_component.media_player.state_attributes.attribute.state.attributeValue":
"Localized Media Player Attribute Name",
"component.media_player.entity_component._.state_attributes.attribute.state.attributeValue":
"Media Player Attribute Name",
};
return translations[key] || "";
};
export const stateObjMock = {
entity_id: "sensor.test",
attributes: {
device_class: "temperature",
},
} as HassEntityBase;
export const localeMock = {
language: "en",
} as FrontendLocaleData;
export const configMock = {
unit_system: {
temperature: "°C",
},
} as HassConfig;
export const entitiesMock = {
"sensor.test": {
platform: "test_platform",
translation_key: "test_translation_key",
},
"media_player.test": {
platform: "media_player",
},
} as unknown as HomeAssistant["entities"];
describe("computeAttributeValueDisplay", () => {
it("should return unknown state for null value", () => {
const result = computeAttributeValueDisplay(
localizeMock,
stateObjMock,
localeMock,
configMock,
entitiesMock,
"attribute",
null
);
expect(result).toBe("Unknown");
});
it("should return formatted number for numeric value", () => {
const result = computeAttributeValueDisplay(
localizeMock,
stateObjMock,
localeMock,
configMock,
entitiesMock,
"attribute",
42
);
expect(result).toBe("42");
});
it("should return number from formatter", () => {
const stateObj = {
entity_id: "media_player.test",
attributes: {
device_class: "media_player",
volume_level: 0.42,
},
} as unknown as HassEntityBase;
const result = computeAttributeValueDisplay(
localizeMock,
stateObj,
localeMock,
configMock,
entitiesMock,
"volume_level"
);
expect(result).toBe("42%");
});
it("should return formatted date for date string", () => {
const result = computeAttributeValueDisplay(
localizeMock,
stateObjMock,
localeMock,
configMock,
entitiesMock,
"attribute",
"2023-10-10"
);
expect(result).toBe("October 10, 2023");
});
it("should return formatted datetime for timestamp", () => {
const result = computeAttributeValueDisplay(
localizeMock,
stateObjMock,
localeMock,
configMock,
entitiesMock,
"attribute",
"2023-10-10T10:10:10"
);
expect(result).toBe("October 10, 2023 at 10:10:10");
});
it("should return JSON string for object value", () => {
const result = computeAttributeValueDisplay(
localizeMock,
stateObjMock,
localeMock,
configMock,
entitiesMock,
"attribute",
{ key: "value" }
);
expect(result).toBe('{"key":"value"}');
});
it("should return concatenated values for array", () => {
const result = computeAttributeValueDisplay(
localizeMock,
stateObjMock,
localeMock,
configMock,
entitiesMock,
"attribute",
[1, 2, 3]
);
expect(result).toBe("1, 2, 3");
});
it("should set special unit for weather domain", () => {
const stateObj = {
entity_id: "weather.test",
attributes: {
temperature: 42,
},
} as unknown as HassEntityBase;
const result = computeAttributeValueDisplay(
localizeMock,
stateObj,
localeMock,
configMock,
entitiesMock,
"temperature"
);
expect(result).toBe("42 °C");
});
it("should set temperature unit for temperature attribute", () => {
const stateObj = {
entity_id: "sensor.test",
attributes: {
temperature: 42,
},
} as unknown as HassEntityBase;
const result = computeAttributeValueDisplay(
localizeMock,
stateObj,
localeMock,
configMock,
entitiesMock,
"temperature"
);
expect(result).toBe("42 °C");
});
it("should return translation from translation key", () => {
const result = computeAttributeValueDisplay(
localizeMock,
stateObjMock,
localeMock,
configMock,
entitiesMock,
"attribute",
"attributeValue"
);
expect(result).toBe("Localized Attribute Name");
});
it("should return device class translation", () => {
const stateObj = {
entity_id: "media_player.test",
attributes: {
device_class: "media_player",
volume_level: 0.42,
},
} as unknown as HassEntityBase;
const result = computeAttributeValueDisplay(
localizeMock,
stateObj,
localeMock,
configMock,
entitiesMock,
"attribute",
"attributeValue"
);
expect(result).toBe("Localized Media Player Attribute Name");
});
it("should return attribute value translation", () => {
const stateObj = {
entity_id: "media_player.test",
attributes: {
volume_level: 0.42,
},
} as unknown as HassEntityBase;
const result = computeAttributeValueDisplay(
localizeMock,
stateObj,
localeMock,
configMock,
entitiesMock,
"attribute",
"attributeValue"
);
expect(result).toBe("Media Player Attribute Name");
});
it("should return attribute value", () => {
const stateObj = {
entity_id: "media_player.test",
attributes: {
volume_level: 0.42,
},
} as unknown as HassEntityBase;
const result = computeAttributeValueDisplay(
localizeMock,
stateObj,
localeMock,
configMock,
entitiesMock,
"attribute",
"attributeValue2"
);
expect(result).toBe("attributeValue2");
});
});
describe("computeAttributeNameDisplay", () => {
it("should return localized name for attribute", () => {
const localize = (key: string) => {
if (
key ===
"component.light.entity.light.entity_translation_key.state_attributes.updated_at.name"
) {
return "Updated at";
}
return "unknown";
};
const stateObj = {
entity_id: "light.test",
attributes: {
device_class: "light",
},
} as HassEntity;
const entities = {
"light.test": {
translation_key: "entity_translation_key",
platform: "light",
},
} as unknown as HomeAssistant["entities"];
const result = computeAttributeNameDisplay(
localize,
stateObj,
entities,
"updated_at"
);
expect(result).toBe("Updated at");
});
it("should return device class translation", () => {
const localize = (key: string) => {
if (
key ===
"component.light.entity_component.light.state_attributes.brightness.name"
) {
return "Brightness";
}
return "unknown";
};
const stateObj = {
entity_id: "light.test",
attributes: {
device_class: "light",
},
} as HassEntity;
const entities = {} as unknown as HomeAssistant["entities"];
const result = computeAttributeNameDisplay(
localize,
stateObj,
entities,
"brightness"
);
expect(result).toBe("Brightness");
});
it("should return default attribute name", () => {
const localize = (key: string) => {
if (
key ===
"component.light.entity_component._.state_attributes.brightness.name"
) {
return "Brightness";
}
return "unknown";
};
const stateObj = {
entity_id: "light.test",
attributes: {},
} as HassEntity;
const entities = {} as unknown as HomeAssistant["entities"];
const result = computeAttributeNameDisplay(
localize,
stateObj,
entities,
"brightness"
);
expect(result).toBe("Brightness");
});
it("should return capitalized attribute name", () => {
const localize = () => "";
const stateObj = {
entity_id: "light.test",
attributes: {},
} as HassEntity;
const entities = {} as unknown as HomeAssistant["entities"];
const result = computeAttributeNameDisplay(
localize,
stateObj,
entities,
"brightness__ip_id_mac_gps_GPS"
);
expect(result).toBe("Brightness IP ID MAC GPS GPS");
});
});

View File

@ -1,5 +1,9 @@
import { assert, describe, it, beforeEach } from "vitest"; import type { HassConfig } from "home-assistant-js-websocket";
import { computeStateDisplay } from "../../../src/common/entity/compute_state_display"; import { assert, describe, it, beforeEach, expect } from "vitest";
import {
computeStateDisplay,
computeStateDisplayFromEntityAttributes,
} from "../../../src/common/entity/compute_state_display";
import { UNKNOWN } from "../../../src/data/entity"; import { UNKNOWN } from "../../../src/data/entity";
import type { FrontendLocaleData } from "../../../src/data/translation"; import type { FrontendLocaleData } from "../../../src/data/translation";
import { import {
@ -10,6 +14,7 @@ import {
TimeZone, TimeZone,
} from "../../../src/data/translation"; } from "../../../src/data/translation";
import { demoConfig } from "../../../src/fake_data/demo_config"; import { demoConfig } from "../../../src/fake_data/demo_config";
import type { EntityRegistryDisplayEntry } from "../../../src/data/entity_registry";
let localeData: FrontendLocaleData; let localeData: FrontendLocaleData;
@ -617,3 +622,85 @@ describe("computeStateDisplay", () => {
); );
}); });
}); });
describe("computeStateDisplayFromEntityAttributes with numeric device classes", () => {
it("Should format duration sensor", () => {
const result = computeStateDisplayFromEntityAttributes(
// eslint-disable-next-line @typescript-eslint/no-empty-function
(() => {}) as any,
{
language: "en",
} as FrontendLocaleData,
[],
{} as HassConfig,
{
display_precision: 2,
} as EntityRegistryDisplayEntry,
"number.test",
{
device_class: "duration",
unit_of_measurement: "min",
},
"12"
);
expect(result).toBe("12.00 min");
});
it("Should format duration sensor with seconds", () => {
const result = computeStateDisplayFromEntityAttributes(
// eslint-disable-next-line @typescript-eslint/no-empty-function
(() => {}) as any,
{
language: "en",
} as FrontendLocaleData,
[],
{} as HassConfig,
undefined,
"number.test",
{
device_class: "duration",
unit_of_measurement: "s",
},
"12"
);
expect(result).toBe("12 s");
});
it("Should format monetary device_class", () => {
const result = computeStateDisplayFromEntityAttributes(
// eslint-disable-next-line @typescript-eslint/no-empty-function
(() => {}) as any,
{
language: "en",
} as FrontendLocaleData,
[],
{} as HassConfig,
undefined,
"number.test",
{
device_class: "monetary",
unit_of_measurement: "$",
},
"12"
);
expect(result).toBe("12 $");
});
});
describe("computeStateDisplayFromEntityAttributes datetime device calss", () => {
it("Should format datetime sensor", () => {
const result = computeStateDisplayFromEntityAttributes(
// eslint-disable-next-line @typescript-eslint/no-empty-function
(() => {}) as any,
{
language: "en",
} as FrontendLocaleData,
[],
{} as HassConfig,
undefined,
"button.test",
{},
"2020-01-01T12:00:00+00:00"
);
expect(result).toBe("January 1, 2020 at 12:00");
});
});

View File

@ -1,6 +1,8 @@
import { defineConfig } from "vitest/config"; import { defineConfig } from "vitest/config";
import tsconfigPaths from "vite-tsconfig-paths";
export default defineConfig({ export default defineConfig({
plugins: [tsconfigPaths()],
test: { test: {
environment: "jsdom", // to run in browser-like environment environment: "jsdom", // to run in browser-like environment
env: { env: {

View File

@ -49,6 +49,36 @@
"./node_modules/@lrnwebcomponents/simple-tooltip/custom-elements.json" "./node_modules/@lrnwebcomponents/simple-tooltip/custom-elements.json"
] ]
} }
],
"paths": {
"lit/static-html": ["./node_modules/lit/static-html.js"],
"lit/decorators": ["./node_modules/lit/decorators.js"],
"lit/directive": ["./node_modules/lit/directive.js"],
"lit/directives/until": ["./node_modules/lit/directives/until.js"],
"lit/directives/class-map": [
"./node_modules/lit/directives/class-map.js"
],
"lit/directives/style-map": [
"./node_modules/lit/directives/style-map.js"
],
"lit/directives/if-defined": [
"./node_modules/lit/directives/if-defined.js"
],
"lit/directives/guard": ["./node_modules/lit/directives/guard.js"],
"lit/directives/cache": ["./node_modules/lit/directives/cache.js"],
"lit/directives/repeat": ["./node_modules/lit/directives/repeat.js"],
"lit/directives/live": ["./node_modules/lit/directives/live.js"],
"lit/directives/keyed": ["./node_modules/lit/directives/keyed.js"],
"lit/polyfill-support": ["./node_modules/lit/polyfill-support.js"],
"@lit-labs/virtualizer/layouts/grid": [
"./node_modules/@lit-labs/virtualizer/layouts/grid.js"
],
"@lit-labs/virtualizer/polyfills/resize-observer-polyfill/ResizeObserver": [
"./node_modules/@lit-labs/virtualizer/polyfills/resize-observer-polyfill/ResizeObserver.js"
],
"@lit-labs/observers/resize-controller": [
"./node_modules/@lit-labs/observers/resize-controller.js"
] ]
} }
} }
}

1067
yarn.lock

File diff suppressed because it is too large Load Diff