Compare commits

...

41 Commits

Author SHA1 Message Date
Paul Bottein
43374ef798 Extract saving badge config from badge editor 2025-02-25 10:30:42 +01:00
renovate[bot]
bb672d0272 Update dependency prettier to v3.5.2 (#24382)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-02-25 08:36:20 +02:00
renovate[bot]
e26d3d39f0 Update dependency eslint to v9.21.0 (#24381)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-02-25 01:36:23 +01:00
renovate[bot]
e54c3a69af Update dependency @lokalise/node-api to v13.2.1 (#24377)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-02-24 19:59:53 +01:00
karwosts
cc04457d72 Add 'last seen' to BT advertisement montior (#24361)
* Add 'last seen' to BT advertisement montior

* use ha-relative-time
2025-02-24 17:29:34 +02:00
Wendelin
9e1d64e728 shoelace tooltip (#24337)
* Add shoelace based ha-tooltip

* Use shoelace component

* Improve styles

* Add docs

* Fix tooltip docs

* Revert new global styles
2025-02-24 15:37:59 +01:00
Paul Bottein
0cfe7f8d12 Tile card editor improvements (#24373)
* Add selector support

* Feedbacks

* Use select box fields in tile card editor
2025-02-24 14:26:55 +00:00
karwosts
2b1f301db6 Push map strategy logic down into map card (#24303) 2025-02-24 15:13:52 +01:00
Paul Bottein
fc4996412e Add select box component (#24370)
* Add select box component

* Add selector support

* Use it in markdown card

* Add select box to gallery

* Feedbacks
2025-02-24 15:07:51 +01:00
Jan-Philipp Benecke
ece4a6345f Use custom styling for cluster marker (#24371)
* Use custom styling for cluster marker

* Process code review
2025-02-24 13:11:06 +00:00
Bram Kragten
a4c08a78b9 Check for updated frontend on connect too (#24368) 2025-02-24 13:16:54 +02:00
Joakim Sørensen
a438fc5e41 Add connection check and dialog with results for cloud login (#24301) 2025-02-24 09:37:17 +01:00
karwosts
783132ae46 Fix solar order in compare stack for usage graph (#24360)
* Fix solar order in compare stack for usage graph

* remove accidental commit
2025-02-24 09:08:55 +02:00
renovate[bot]
680d81001c Update rspack monorepo to v1.2.5 (#24353)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-02-24 09:00:19 +02:00
dependabot[bot]
a917383d7a Bump actions/cache from 4.2.0 to 4.2.1 (#24366)
Bumps [actions/cache](https://github.com/actions/cache) from 4.2.0 to 4.2.1.
- [Release notes](https://github.com/actions/cache/releases)
- [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md)
- [Commits](https://github.com/actions/cache/compare/v4.2.0...v4.2.1)

---
updated-dependencies:
- dependency-name: actions/cache
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-02-24 08:59:27 +02:00
dependabot[bot]
455a6761cd Bump actions/upload-artifact from 4.6.0 to 4.6.1 (#24365)
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4.6.0 to 4.6.1.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v4.6.0...v4.6.1)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-02-24 08:58:42 +02:00
renovate[bot]
acf42d7637 Update dependency globals to v16 (#24359)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-02-24 08:56:48 +02:00
renovate[bot]
3857c7321a Update dependency eslint-plugin-wc to v2.2.1 (#24362)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-02-24 07:08:27 +01:00
puddly
5eec814988 Hide hardware integrations from the "add integration" dialog (#24345) 2025-02-22 08:43:18 +02:00
renovate[bot]
edd37565a6 Update vitest monorepo to v3.0.6 (#24344)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-02-21 19:03:37 +01:00
renovate[bot]
fb3f779121 Update rspack monorepo to v1.2.4 (#24343)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-02-21 19:03:00 +01:00
Wendelin
4d7634ac67 Landing-page: ping supervisor before get network infos (#24330)
* Ping supervisor before get network infos

* Rename supervisor proxy prefix
2025-02-21 08:14:10 +02:00
renovate[bot]
ba5c1133c6 Lock file maintenance (#24306)
* Lock file maintenance

* Bump codemirror view to 6.36.3

---------

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Simon Lamon <32477463+silamon@users.noreply.github.com>
2025-02-20 19:36:32 +00:00
Wendelin
0a05dd8f71 Add more tests for common/entity (#24336)
* Use substring instead of deprecated substr

* Add more common entity tests
2025-02-20 20:11:53 +01:00
J. Nick Koston
400106ec09 Adjust WebSocket ping timeout to 15 seconds (#24339)
* Adjust WebSocket ping timeout to 15 seconds

5 seconds was too low to prevent the UI from reloading
when connecting the WebSocket during startup or on
a high latancy connection

This problem presented as the UI reloading over
and over again because it could never respond
to the ping in time on high latancy connections.

At startup it usually only did this once so it
went unnoticed in most cases.

This ping was added in #18934

* Update connection-mixin.ts

Co-authored-by: Jan-Philipp Benecke <jan-philipp@bnck.me>

---------

Co-authored-by: Jan-Philipp Benecke <jan-philipp@bnck.me>
2025-02-20 20:09:51 +01:00
Jan-Philipp Benecke
a7a4194e09 Add tile card feature for counter actions (#24340)
* Add tile card feature for counter actions

* Format

* Change icon

* Disable buttons when hit limit

* Change increment/decrement icons
2025-02-20 19:09:44 +00:00
renovate[bot]
0bd7d27c57 Update dependency @lokalise/node-api to v13.2.0 (#24335)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-02-20 16:14:54 +01:00
Jan-Philipp Benecke
8175e45921 Rename switch-toggle feature to toggle and improve (#24333)
* Rename `switch-toggle` feature to `toggle` and improve

* Format
2025-02-20 14:51:49 +01:00
Jan-Philipp Benecke
cae36b393b Focus alarm control panel PIN input on wider screens (#24324)
* Focus alarm control panel PIN input on wider screens

* Also apply on textfield
2025-02-20 15:20:28 +02:00
Paul Bottein
f84ad92356 Extract saving card config from card editor (#24319)
* Extract saving card config from card editor

* Await

* Add try/catch

* Remove unused translations

* Remove duration
2025-02-20 12:27:39 +01:00
Wendelin
fb1ee2ed1d Remove toggles from ha-icon-button (#24331) 2025-02-20 12:14:40 +01:00
Paul Bottein
9073282174 Add text only style to markdown card (#24329) 2025-02-20 11:40:39 +01:00
Jan-Philipp Benecke
91bd5cba08 Add switch toggle feature to tile card (#24325)
* Add tile switch toggle feature

* Remove _currentState
2025-02-20 10:16:14 +02:00
karwosts
a68bdbfe08 Fix siren advanced controls (#24318) 2025-02-20 08:50:00 +01:00
Jan-Philipp Benecke
f3d614b0d3 Make quick bar more keyboard accessible (#24321) 2025-02-20 08:44:49 +01:00
karwosts
f3c9e4a4a0 Fix catching errors in alarm-control-panel more-info (#24328) 2025-02-20 08:42:17 +01:00
karwosts
d22a82c4a6 Teardown and rebuild element editor when switching stack cards (#24065) 2025-02-20 07:57:34 +01:00
Jan-Philipp Benecke
5cddc6e5c6 Decrease max cluster radius (#24322) 2025-02-19 21:34:49 +02:00
Jan-Philipp Benecke
c5c067ef19 Create copyable textfield component (#24247) 2025-02-19 15:56:29 +01:00
Paul Bottein
694bb3088c Improve margin for inline tile card feature (#24316) 2025-02-19 16:07:27 +02:00
Petar Petrov
ad487470fd Enable downsampling in echarts (#24311)
* Enable downsampling in echarts

* remove unneeded symbol set
2025-02-19 16:05:32 +02:00
91 changed files with 2851 additions and 788 deletions

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,10 @@
<svg width="94" height="64" viewBox="0 0 94 64" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="94" height="64" rx="8" fill="white"/>
<rect x="0.5" y="0.5" width="93" height="63" rx="7.5" stroke="black" stroke-opacity="0.12"/>
<path d="M8 14C8 10.6863 10.6863 8 14 8H33C36.3137 8 39 10.6863 39 14C39 17.3137 36.3137 20 33 20H14C10.6863 20 8 17.3137 8 14Z" fill="black" fill-opacity="0.32"/>
<path d="M8 27C8 25.3431 9.34315 24 11 24H31C32.6569 24 34 25.3431 34 27V29C34 30.6569 32.6569 32 31 32H11C9.34315 32 8 30.6569 8 29V27Z" fill="black" fill-opacity="0.12"/>
<path d="M38 27C38 25.3431 39.3431 24 41 24H83C84.6569 24 86 25.3431 86 27V29C86 30.6569 84.6569 32 83 32H41C39.3431 32 38 30.6569 38 29V27Z" fill="black" fill-opacity="0.12"/>
<path d="M8 39C8 37.3431 9.34315 36 11 36H53C54.6569 36 56 37.3431 56 39V41C56 42.6569 54.6569 44 53 44H11C9.34315 44 8 42.6569 8 41V39Z" fill="black" fill-opacity="0.12"/>
<path d="M60 39C60 37.3431 61.3431 36 63 36H83C84.6569 36 86 37.3431 86 39V41C86 42.6569 84.6569 44 83 44H63C61.3431 44 60 42.6569 60 41V39Z" fill="black" fill-opacity="0.12"/>
<path d="M8 51C8 49.3431 9.34315 48 11 48H31C32.6569 48 34 49.3431 34 51V53C34 54.6569 32.6569 56 31 56H11C9.34315 56 8 54.6569 8 53V51Z" fill="black" fill-opacity="0.12"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -0,0 +1,7 @@
<svg width="94" height="48" viewBox="0 0 94 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0 11C0 9.34315 1.34315 8 3 8H23C24.6569 8 26 9.34315 26 11V13C26 14.6569 24.6569 16 23 16H3C1.34315 16 0 14.6569 0 13V11Z" fill="black" fill-opacity="0.12"/>
<path d="M30 11C30 9.34315 31.3431 8 33 8H91C92.6569 8 94 9.34315 94 11V13C94 14.6569 92.6569 16 91 16H33C31.3431 16 30 14.6569 30 13V11Z" fill="black" fill-opacity="0.12"/>
<path d="M0 23C0 21.3431 1.34315 20 3 20H61C62.6569 20 64 21.3431 64 23V25C64 26.6569 62.6569 28 61 28H3C1.34315 28 0 26.6569 0 25V23Z" fill="black" fill-opacity="0.12"/>
<path d="M68 23C68 21.3431 69.3431 20 71 20H91C92.6569 20 94 21.3431 94 23V25C94 26.6569 92.6569 28 91 28H71C69.3431 28 68 26.6569 68 25V23Z" fill="black" fill-opacity="0.12"/>
<path d="M0 35C0 33.3431 1.34315 32 3 32H23C24.6569 32 26 33.3431 26 35V37C26 38.6569 24.6569 40 23 40H3C1.34315 40 0 38.6569 0 37V35Z" fill="black" fill-opacity="0.12"/>
</svg>

After

Width:  |  Height:  |  Size: 964 B

View File

@@ -0,0 +1,3 @@
---
title: Select box
---

View File

@@ -0,0 +1,152 @@
import type { TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, state } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import "../../../../src/components/ha-card";
import "../../../../src/components/ha-select-box";
import type { SelectBoxOption } from "../../../../src/components/ha-select-box";
const basicOptions: SelectBoxOption[] = [
{
value: "text-only",
label: "Text only",
},
{
value: "card",
label: "Card",
},
{
value: "disabled",
label: "Disabled option",
disabled: true,
},
];
const fullOptions: SelectBoxOption[] = [
{
value: "text-only",
label: "Text only",
description: "Only text, no border and background",
image: "/images/select_box/text_only.svg",
},
{
value: "card",
label: "Card",
description: "With border and background",
image: "/images/select_box/card.svg",
},
{
value: "disabled",
label: "Disabled",
description: "Option that can not be selected",
disabled: true,
},
];
const selects: {
id: string;
label: string;
class?: string;
options: SelectBoxOption[];
disabled?: boolean;
}[] = [
{
id: "basic",
label: "Basic",
options: basicOptions,
},
{
id: "full",
label: "With description and image",
options: fullOptions,
},
];
@customElement("demo-components-ha-select-box")
export class DemoHaSelectBox extends LitElement {
@state() private value?: string = "off";
handleValueChanged(e: CustomEvent) {
this.value = e.detail.value as string;
}
protected render(): TemplateResult {
return html`
${repeat(selects, (select) => {
const { id, label, options } = select;
return html`
<ha-card>
<div class="card-content">
<label id=${id}>${label}</label>
<ha-select-box
.value=${this.value}
.options=${options}
@value-changed=${this.handleValueChanged}
>
</ha-select-box>
</div>
</ha-card>
`;
})}
<ha-card>
<div class="card-content">
<p class="title"><b>Column layout</b></p>
<div class="vertical-selects">
${repeat(selects, (select) => {
const { options } = select;
return html`
<ha-select-box
.value=${this.value}
.options=${options}
.maxColumns=${1}
@value-changed=${this.handleValueChanged}
>
</ha-select-box>
`;
})}
</div>
</div>
</ha-card>
`;
}
static styles = css`
ha-card {
max-width: 600px;
margin: 24px auto;
}
pre {
margin-top: 0;
margin-bottom: 8px;
}
p {
margin: 0;
}
label {
font-weight: 600;
margin-bottom: 8px;
display: block;
}
.custom {
--mdc-icon-size: 24px;
--control-select-color: var(--state-fan-active-color);
--control-select-thickness: 130px;
--control-select-border-radius: 36px;
}
p.title {
margin-bottom: 12px;
}
.vertical-selects ha-select-box {
display: block;
margin-bottom: 24px;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"demo-components-ha-select-box": DemoHaSelectBox;
}
}

View File

@@ -0,0 +1,30 @@
---
title: Tooltip
---
A tooltip's target is its _first child element_, so you should only wrap one element inside of the tooltip. If you need the tooltip to show up for multiple elements, nest them inside a container first.
Tooltips use `display: contents` so they won't interfere with how elements are positioned in a flex or grid layout.
<ha-tooltip content="This is a tooltip">
<ha-button>Hover Me</ha-button>
</ha-tooltip>
```
<ha-tooltip content="This is a tooltip">
<ha-button>Hover Me</ha-button>
</ha-tooltip>
```
## Documentation
This element is based on sholace `sl-tooltip` it only sets some css tokens and has a custom show/hide animation.
<a href="https://shoelace.style/components/tooltip" target="_blank" rel="noopener noreferrer">Shoelace documentation</a>
### HA style tokens
In your theme settings use this without the prefixed `--`.
- `--ha-tooltip-border-radius` (Default: 4px)
- `--ha-tooltip-arrow-size` (Default: 8px)

View File

@@ -0,0 +1,2 @@
import "../../../../src/components/ha-tooltip";
import "../../../../src/components/ha-button";

View File

@@ -17,6 +17,7 @@ import "../../../src/components/ha-alert";
import {
ALTERNATIVE_DNS_SERVERS,
getSupervisorNetworkInfo,
pingSupervisor,
setSupervisorNetworkDns,
} from "../data/supervisor";
import { fireEvent } from "../../../src/common/dom/fire_event";
@@ -85,7 +86,28 @@ class LandingPageNetwork extends LitElement {
protected firstUpdated(_changedProperties: PropertyValues): void {
super.firstUpdated(_changedProperties);
this._fetchSupervisorInfo();
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();
} catch (err) {
// eslint-disable-next-line no-console
console.error(err);
this._schedulePingSupervisor();
}
}
private _scheduleFetchSupervisorInfo() {

View File

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

View File

@@ -34,7 +34,7 @@
"@codemirror/legacy-modes": "6.4.3",
"@codemirror/search": "6.5.9",
"@codemirror/state": "6.5.2",
"@codemirror/view": "6.36.2",
"@codemirror/view": "6.36.3",
"@egjs/hammerjs": "2.0.17",
"@formatjs/intl-datetimeformat": "6.17.3",
"@formatjs/intl-displaynames": "6.8.10",
@@ -90,6 +90,7 @@
"@polymer/paper-tabs": "3.1.0",
"@polymer/polymer": "3.5.2",
"@replit/codemirror-indentation-markers": "6.5.3",
"@shoelace-style/shoelace": "2.20.0",
"@thomasloven/round-slider": "0.6.0",
"@vaadin/combo-box": "24.6.5",
"@vaadin/vaadin-themable-mixin": "24.6.5",
@@ -161,13 +162,13 @@
"@babel/preset-env": "7.26.9",
"@babel/preset-typescript": "7.26.0",
"@bundle-stats/plugin-webpack-filter": "4.18.2",
"@lokalise/node-api": "13.1.0",
"@lokalise/node-api": "13.2.1",
"@octokit/auth-oauth-device": "7.1.3",
"@octokit/plugin-retry": "7.1.4",
"@octokit/rest": "21.1.1",
"@rsdoctor/rspack-plugin": "0.4.13",
"@rspack/cli": "1.2.3",
"@rspack/core": "1.2.3",
"@rspack/cli": "1.2.5",
"@rspack/core": "1.2.5",
"@types/babel__plugin-transform-runtime": "7.9.5",
"@types/chromecast-caf-receiver": "6.0.21",
"@types/chromecast-caf-sender": "1.0.11",
@@ -186,12 +187,12 @@
"@types/tar": "6.1.13",
"@types/ua-parser-js": "0.7.39",
"@types/webspeechapi": "0.0.29",
"@vitest/coverage-v8": "3.0.5",
"@vitest/coverage-v8": "3.0.6",
"babel-loader": "9.2.1",
"babel-plugin-template-html-minifier": "4.1.0",
"browserslist-useragent-regexp": "4.1.3",
"del": "8.0.0",
"eslint": "9.20.1",
"eslint": "9.21.0",
"eslint-config-airbnb-base": "15.0.0",
"eslint-config-prettier": "10.0.1",
"eslint-import-resolver-webpack": "0.13.10",
@@ -199,7 +200,7 @@
"eslint-plugin-lit": "1.15.0",
"eslint-plugin-lit-a11y": "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",
"fs-extra": "11.3.0",
"glob": "11.0.1",
@@ -217,7 +218,7 @@
"lodash.template": "4.5.0",
"map-stream": "0.0.7",
"pinst": "3.0.0",
"prettier": "3.5.1",
"prettier": "3.5.2",
"rspack-manifest-plugin": "5.0.3",
"serve": "14.2.4",
"sinon": "19.0.2",
@@ -226,7 +227,8 @@
"ts-lit-plugin": "2.0.2",
"typescript": "5.7.3",
"typescript-eslint": "8.24.1",
"vitest": "3.0.5",
"vite-tsconfig-paths": "5.1.4",
"vitest": "3.0.6",
"webpack-stats-plugin": "1.1.3",
"webpackbar": "7.0.0",
"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",
"@lit/reactive-element": "1.6.3",
"@fullcalendar/daygrid": "6.1.15",
"globals": "15.15.0",
"globals": "16.0.0",
"tslib": "2.8.1"
},
"packageManager": "yarn@4.6.0"

View File

@@ -0,0 +1,10 @@
<svg width="94" height="64" viewBox="0 0 94 64" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="94" height="64" rx="8" fill="white"/>
<rect x="0.5" y="0.5" width="93" height="63" rx="7.5" stroke="black" stroke-opacity="0.12"/>
<path d="M8 14C8 10.6863 10.6863 8 14 8H33C36.3137 8 39 10.6863 39 14C39 17.3137 36.3137 20 33 20H14C10.6863 20 8 17.3137 8 14Z" fill="black" fill-opacity="0.32"/>
<path d="M8 27C8 25.3431 9.34315 24 11 24H31C32.6569 24 34 25.3431 34 27V29C34 30.6569 32.6569 32 31 32H11C9.34315 32 8 30.6569 8 29V27Z" fill="black" fill-opacity="0.12"/>
<path d="M38 27C38 25.3431 39.3431 24 41 24H83C84.6569 24 86 25.3431 86 27V29C86 30.6569 84.6569 32 83 32H41C39.3431 32 38 30.6569 38 29V27Z" fill="black" fill-opacity="0.12"/>
<path d="M8 39C8 37.3431 9.34315 36 11 36H53C54.6569 36 56 37.3431 56 39V41C56 42.6569 54.6569 44 53 44H11C9.34315 44 8 42.6569 8 41V39Z" fill="black" fill-opacity="0.12"/>
<path d="M60 39C60 37.3431 61.3431 36 63 36H83C84.6569 36 86 37.3431 86 39V41C86 42.6569 84.6569 44 83 44H63C61.3431 44 60 42.6569 60 41V39Z" fill="black" fill-opacity="0.12"/>
<path d="M8 51C8 49.3431 9.34315 48 11 48H31C32.6569 48 34 49.3431 34 51V53C34 54.6569 32.6569 56 31 56H11C9.34315 56 8 54.6569 8 53V51Z" fill="black" fill-opacity="0.12"/>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -0,0 +1,10 @@
<svg width="94" height="64" viewBox="0 0 94 64" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0 8C0 3.58172 3.58172 0 8 0H86C90.4183 0 94 3.58172 94 8V56C94 60.4183 90.4183 64 86 64H8C3.58172 64 0 60.4183 0 56V8Z" fill="black"/>
<path d="M0.5 8C0.5 3.85786 3.85786 0.5 8 0.5H86C90.1421 0.5 93.5 3.85786 93.5 8V56C93.5 60.1421 90.1421 63.5 86 63.5H8C3.85786 63.5 0.5 60.1421 0.5 56V8Z" stroke="white" stroke-opacity="0.24"/>
<path d="M8 14C8 10.6863 10.6863 8 14 8H33C36.3137 8 39 10.6863 39 14C39 17.3137 36.3137 20 33 20H14C10.6863 20 8 17.3137 8 14Z" fill="white" fill-opacity="0.48"/>
<path d="M8 27C8 25.3431 9.34315 24 11 24H31C32.6569 24 34 25.3431 34 27V29C34 30.6569 32.6569 32 31 32H11C9.34315 32 8 30.6569 8 29V27Z" fill="white" fill-opacity="0.24"/>
<path d="M38 27C38 25.3431 39.3431 24 41 24H83C84.6569 24 86 25.3431 86 27V29C86 30.6569 84.6569 32 83 32H41C39.3431 32 38 30.6569 38 29V27Z" fill="white" fill-opacity="0.24"/>
<path d="M8 39C8 37.3431 9.34315 36 11 36H53C54.6569 36 56 37.3431 56 39V41C56 42.6569 54.6569 44 53 44H11C9.34315 44 8 42.6569 8 41V39Z" fill="white" fill-opacity="0.24"/>
<path d="M60 39C60 37.3431 61.3431 36 63 36H83C84.6569 36 86 37.3431 86 39V41C86 42.6569 84.6569 44 83 44H63C61.3431 44 60 42.6569 60 41V39Z" fill="white" fill-opacity="0.24"/>
<path d="M8 51C8 49.3431 9.34315 48 11 48H31C32.6569 48 34 49.3431 34 51V53C34 54.6569 32.6569 56 31 56H11C9.34315 56 8 54.6569 8 53V51Z" fill="white" fill-opacity="0.24"/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -0,0 +1,7 @@
<svg width="94" height="48" viewBox="0 0 94 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0 11C0 9.34315 1.34315 8 3 8H23C24.6569 8 26 9.34315 26 11V13C26 14.6569 24.6569 16 23 16H3C1.34315 16 0 14.6569 0 13V11Z" fill="black" fill-opacity="0.12"/>
<path d="M30 11C30 9.34315 31.3431 8 33 8H91C92.6569 8 94 9.34315 94 11V13C94 14.6569 92.6569 16 91 16H33C31.3431 16 30 14.6569 30 13V11Z" fill="black" fill-opacity="0.12"/>
<path d="M0 23C0 21.3431 1.34315 20 3 20H61C62.6569 20 64 21.3431 64 23V25C64 26.6569 62.6569 28 61 28H3C1.34315 28 0 26.6569 0 25V23Z" fill="black" fill-opacity="0.12"/>
<path d="M68 23C68 21.3431 69.3431 20 71 20H91C92.6569 20 94 21.3431 94 23V25C94 26.6569 92.6569 28 91 28H71C69.3431 28 68 26.6569 68 25V23Z" fill="black" fill-opacity="0.12"/>
<path d="M0 35C0 33.3431 1.34315 32 3 32H23C24.6569 32 26 33.3431 26 35V37C26 38.6569 24.6569 40 23 40H3C1.34315 40 0 38.6569 0 37V35Z" fill="black" fill-opacity="0.12"/>
</svg>

After

Width:  |  Height:  |  Size: 964 B

View File

@@ -0,0 +1,7 @@
<svg width="94" height="48" viewBox="0 0 94 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M0 11C0 9.34315 1.34315 8 3 8H23C24.6569 8 26 9.34315 26 11V13C26 14.6569 24.6569 16 23 16H3C1.34315 16 0 14.6569 0 13V11Z" fill="white" fill-opacity="0.24"/>
<path d="M30 11C30 9.34315 31.3431 8 33 8H91C92.6569 8 94 9.34315 94 11V13C94 14.6569 92.6569 16 91 16H33C31.3431 16 30 14.6569 30 13V11Z" fill="white" fill-opacity="0.24"/>
<path d="M0 23C0 21.3431 1.34315 20 3 20H61C62.6569 20 64 21.3431 64 23V25C64 26.6569 62.6569 28 61 28H3C1.34315 28 0 26.6569 0 25V23Z" fill="white" fill-opacity="0.24"/>
<path d="M68 23C68 21.3431 69.3431 20 71 20H91C92.6569 20 94 21.3431 94 23V25C94 26.6569 92.6569 28 91 28H71C69.3431 28 68 26.6569 68 25V23Z" fill="white" fill-opacity="0.24"/>
<path d="M0 35C0 33.3431 1.34315 32 3 32H23C24.6569 32 26 33.3431 26 35V37C26 38.6569 24.6569 40 23 40H3C1.34315 40 0 38.6569 0 37V35Z" fill="white" fill-opacity="0.24"/>
</svg>

After

Width:  |  Height:  |  Size: 964 B

View File

@@ -0,0 +1,7 @@
<svg width="94" height="40" viewBox="0 0 94 40" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="94" height="40" rx="8" fill="white"/>
<rect x="0.5" y="0.5" width="93" height="39" rx="7.5" stroke="black" stroke-opacity="0.12"/>
<circle cx="20" cy="20" r="12" fill="black" fill-opacity="0.12"/>
<path d="M40 14C40 10.6863 42.6863 8 46 8H65C68.3137 8 71 10.6863 71 14C71 17.3137 68.3137 20 65 20H46C42.6863 20 40 17.3137 40 14Z" fill="black" fill-opacity="0.32"/>
<path d="M40 28C40 25.7909 41.7909 24 44 24H77C79.2091 24 81 25.7909 81 28C81 30.2091 79.2091 32 77 32H44C41.7909 32 40 30.2091 40 28Z" fill="black" fill-opacity="0.32"/>
</svg>

After

Width:  |  Height:  |  Size: 652 B

View File

@@ -0,0 +1,7 @@
<svg width="94" height="40" viewBox="0 0 94 40" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="94" height="40" rx="8" fill="black"/>
<rect x="0.5" y="0.5" width="93" height="39" rx="7.5" stroke="white" stroke-opacity="0.24"/>
<circle cx="20" cy="20" r="12" fill="white" fill-opacity="0.24"/>
<path d="M40 14C40 10.6863 42.6863 8 46 8H65C68.3137 8 71 10.6863 71 14C71 17.3137 68.3137 20 65 20H46C42.6863 20 40 17.3137 40 14Z" fill="white" fill-opacity="0.48"/>
<path d="M40 28C40 25.7909 41.7909 24 44 24H77C79.2091 24 81 25.7909 81 28C81 30.2091 79.2091 32 77 32H44C41.7909 32 40 30.2091 40 28Z" fill="white" fill-opacity="0.48"/>
</svg>

After

Width:  |  Height:  |  Size: 652 B

View File

@@ -0,0 +1,7 @@
<svg width="94" height="72" viewBox="0 0 94 72" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="94" height="72" rx="8" fill="white"/>
<rect x="0.5" y="0.5" width="93" height="71" rx="7.5" stroke="black" stroke-opacity="0.12"/>
<circle cx="47" cy="20" r="12" fill="black" fill-opacity="0.12"/>
<path d="M31.5 46C31.5 42.6863 34.1863 40 37.5 40H56.5C59.8137 40 62.5 42.6863 62.5 46C62.5 49.3137 59.8137 52 56.5 52H37.5C34.1863 52 31.5 49.3137 31.5 46Z" fill="black" fill-opacity="0.32"/>
<path d="M26.5 60C26.5 57.7909 28.2909 56 30.5 56H63.5C65.7091 56 67.5 57.7909 67.5 60C67.5 62.2091 65.7091 64 63.5 64H30.5C28.2909 64 26.5 62.2091 26.5 60Z" fill="black" fill-opacity="0.32"/>
</svg>

After

Width:  |  Height:  |  Size: 699 B

View File

@@ -0,0 +1,7 @@
<svg width="94" height="72" viewBox="0 0 94 72" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="94" height="72" rx="8" fill="black"/>
<rect x="0.5" y="0.5" width="93" height="71" rx="7.5" stroke="white" stroke-opacity="0.24"/>
<circle cx="47" cy="20" r="12" fill="white" fill-opacity="0.24"/>
<path d="M31.5 46C31.5 42.6863 34.1863 40 37.5 40H56.5C59.8137 40 62.5 42.6863 62.5 46C62.5 49.3137 59.8137 52 56.5 52H37.5C34.1863 52 31.5 49.3137 31.5 46Z" fill="white" fill-opacity="0.48"/>
<path d="M26.5 60C26.5 57.7909 28.2909 56 30.5 56H63.5C65.7091 56 67.5 57.7909 67.5 60C67.5 62.2091 65.7091 64 63.5 64H30.5C28.2909 64 26.5 62.2091 26.5 60Z" fill="white" fill-opacity="0.48"/>
</svg>

After

Width:  |  Height:  |  Size: 699 B

View File

@@ -0,0 +1,6 @@
<svg width="94" height="48" viewBox="0 0 94 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="94" height="48" rx="8" fill="white"/>
<rect x="0.5" y="0.5" width="93" height="47" rx="7.5" stroke="black" stroke-opacity="0.12"/>
<rect x="8" y="8" width="78" height="12" rx="3" fill="black" fill-opacity="0.12"/>
<rect x="8" y="28" width="78" height="12" rx="3" fill="black" fill-opacity="0.32"/>
</svg>

After

Width:  |  Height:  |  Size: 414 B

View File

@@ -0,0 +1,6 @@
<svg width="94" height="48" viewBox="0 0 94 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="94" height="48" rx="8" fill="black"/>
<rect x="0.5" y="0.5" width="93" height="47" rx="7.5" stroke="white" stroke-opacity="0.24"/>
<rect x="8" y="8" width="78" height="12" rx="3" fill="white" fill-opacity="0.24"/>
<rect x="8" y="28" width="78" height="12" rx="3" fill="white" fill-opacity="0.48"/>
</svg>

After

Width:  |  Height:  |  Size: 414 B

View File

@@ -0,0 +1,6 @@
<svg width="94" height="28" viewBox="0 0 94 28" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="94" height="28" rx="8" fill="white"/>
<rect x="0.5" y="0.5" width="93" height="27" rx="7.5" stroke="black" stroke-opacity="0.12"/>
<rect x="8" y="8" width="35" height="12" rx="3" fill="black" fill-opacity="0.12"/>
<rect x="51" y="8" width="35" height="12" rx="3" fill="black" fill-opacity="0.32"/>
</svg>

After

Width:  |  Height:  |  Size: 414 B

View File

@@ -0,0 +1,6 @@
<svg width="94" height="28" viewBox="0 0 94 28" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="94" height="28" rx="8" fill="black"/>
<rect x="0.5" y="0.5" width="93" height="27" rx="7.5" stroke="white" stroke-opacity="0.24"/>
<rect x="8" y="8" width="35" height="12" rx="3" fill="white" fill-opacity="0.24"/>
<rect x="51" y="8" width="35" height="12" rx="3" fill="white" fill-opacity="0.48"/>
</svg>

After

Width:  |  Height:  |  Size: 414 B

View File

@@ -32,14 +32,6 @@ export const setupLeafletMap = async (
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);
const tileLayer = createTileLayer(Leaflet).addTo(map);

View File

@@ -1,2 +1,2 @@
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;
}
if (domain === "datetime") {
const time = new Date(state);
return formatDateTime(time, locale, config);
}
if (["date", "input_datetime", "time"].includes(domain)) {
// 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`.
@@ -181,6 +176,7 @@ export const computeStateDisplayFromEntityAttributes = (
"tag",
"tts",
"wake_word",
"datetime",
].includes(domain) ||
(domain === "sensor" && attributes.device_class === "timestamp")
) {

View File

@@ -521,14 +521,14 @@ export class HaChartBase extends LitElement {
0
);
if (dataSize > 10000) {
// for large datasets zr.flush takes 30-40% of the render time
// so we delay it a bit to avoid blocking the main thread
// 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.bind(zr);
this._originalZrFlush = zr.flush;
zr.flush = () => {
setTimeout(() => {
this._originalZrFlush?.();
}, 10);
this._originalZrFlush?.call(zr);
}, 5);
};
}
}

View File

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

View File

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

View File

@@ -1,4 +1,3 @@
import "@lrnwebcomponents/simple-tooltip/simple-tooltip";
import type { CSSResultGroup, TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
@@ -8,6 +7,7 @@ import type { Analytics, AnalyticsPreferences } from "../data/analytics";
import { haStyle } from "../resources/styles";
import "./ha-settings-row";
import "./ha-switch";
import "./ha-tooltip";
import type { HaSwitch } from "./ha-switch";
const ADDITIONAL_PREFERENCES = ["usage", "statistics"] as const;
@@ -67,22 +67,21 @@ export class HaAnalytics extends LitElement {
)}
</span>
<span>
<ha-switch
@change=${this._handleRowClick}
.checked=${this.analytics?.preferences[preference]}
.preference=${preference}
name=${preference}
<ha-tooltip
content=${this.localize(
`ui.panel.${this.translationKeyPanel}.analytics.need_base_enabled`
)}
placement="right"
?disabled=${baseEnabled}
>
</ha-switch>
${!baseEnabled
? html`
<simple-tooltip animation-delay="0" position="right">
${this.localize(
`ui.panel.${this.translationKeyPanel}.analytics.need_base_enabled`
)}
</simple-tooltip>
`
: ""}
<ha-switch
@change=${this._handleRowClick}
.checked=${this.analytics?.preferences[preference]}
.preference=${preference}
name=${preference}
>
</ha-switch>
</ha-tooltip>
</span>
</ha-settings-row>
`

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;
return html`
<ha-icon-button
toggles
.label=${this.localize?.(
`${this.localizeBaseKey}.${
this.unmaskedPassword ? "hide_password" : "show_password"

View File

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

View File

@@ -0,0 +1,193 @@
import { customElement, property } from "lit/decorators";
import { css, html, LitElement, nothing } from "lit";
import "./ha-radio";
import { classMap } from "lit/directives/class-map";
import { styleMap } from "lit/directives/style-map";
import type { HaRadio } from "./ha-radio";
import { fireEvent } from "../common/dom/fire_event";
export interface SelectBoxOption {
label?: string;
description?: string;
image?: string;
value: string;
disabled?: boolean;
}
@customElement("ha-select-box")
export class HaSelectBox extends LitElement {
@property({ attribute: false }) public options: SelectBoxOption[] = [];
@property({ attribute: false }) public value?: string;
@property({ type: Boolean }) public disabled?: boolean;
@property({ type: Number, attribute: "max_columns" })
public maxColumns?: number;
render() {
const maxColumns = this.maxColumns ?? 3;
const columns = Math.min(maxColumns, this.options.length);
return html`
<div class="list" style=${styleMap({ "--columns": columns })}>
${this.options.map((option) => this._renderOption(option))}
</div>
`;
}
private _renderOption(option: SelectBoxOption) {
const horizontal = this.maxColumns === 1;
const disabled = option.disabled || this.disabled || false;
const selected = option.value === this.value;
return html`
<label
class="option ${classMap({
horizontal: horizontal,
selected: selected,
})}"
?disabled=${disabled}
@click=${this._labelClick}
>
<div class="content">
<ha-radio
.checked=${option.value === this.value}
.value=${option.value}
.disabled=${disabled}
@change=${this._radioChanged}
></ha-radio>
<div class="text">
<span class="label">${option.label}</span>
${option.description
? html`<span class="description">${option.description}</span>`
: nothing}
</div>
</div>
${option.image ? html`<img alt="" src=${option.image} />` : nothing}
</label>
`;
}
private _labelClick(ev) {
ev.stopPropagation();
ev.currentTarget.querySelector("ha-radio")?.click();
}
private _radioChanged(ev: CustomEvent) {
const radio = ev.currentTarget as HaRadio;
const value = radio.value;
if (this.disabled || value === undefined || value === (this.value ?? "")) {
return;
}
fireEvent(this, "value-changed", {
value: value,
});
}
static styles = css`
.list {
display: grid;
grid-template-columns: repeat(var(--columns, 1), minmax(0, 1fr));
gap: 12px;
}
.option {
position: relative;
display: block;
border: 1px solid var(--divider-color);
border-radius: var(--ha-card-border-radius, 12px);
display: flex;
flex-direction: column;
align-items: center;
justify-content: space-between;
padding: 12px;
gap: 8px;
overflow: hidden;
cursor: pointer;
}
.option .content {
position: relative;
display: flex;
flex-direction: row;
gap: 8px;
min-width: 0;
width: 100%;
}
.option .content ha-radio {
margin: -12px;
flex: none;
}
.option .content .text {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
flex: 1;
}
.option .content .text .label {
color: var(--primary-text-color);
font-size: 14px;
font-weight: 400;
line-height: 20px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.option .content .text .description {
color: var(--secondary-text-color);
font-size: 13px;
font-weight: 400;
line-height: 16px;
}
img {
position: relative;
max-width: var(--ha-select-box-image-size, 96px);
max-height: var(--ha-select-box-image-size, 96px);
margin: auto;
}
.option.horizontal {
flex-direction: row;
align-items: flex-start;
}
.option.horizontal img {
margin: 0;
}
.option:before {
content: "";
display: block;
inset: 0;
position: absolute;
background-color: transparent;
pointer-events: none;
opacity: 0.2;
transition:
background-color 180ms ease-in-out,
opacity 180ms ease-in-out;
}
.option:hover:before {
background-color: var(--divider-color);
}
.option.selected:before {
background-color: var(--primary-color);
}
.option[disabled] {
cursor: not-allowed;
}
.option[disabled] .content,
.option[disabled] img {
opacity: 0.5;
}
.option[disabled]:before {
background-color: var(--disabled-color);
opacity: 0.05;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-select-box": HaSelectBox;
}
}

View File

@@ -19,6 +19,7 @@ import "../ha-input-helper-text";
import "../ha-radio";
import "../ha-select";
import "../ha-sortable";
import "../ha-select-box";
@customElement("ha-selector-select")
export class HaSelectSelector extends LitElement {
@@ -91,6 +92,24 @@ export class HaSelectSelector extends LitElement {
);
}
if (
!this.selector.select?.multiple &&
!this.selector.select?.reorder &&
!this.selector.select?.custom_value &&
this._mode === "box"
) {
return html`
${this.label ? html`<span class="label">${this.label}</span>` : nothing}
<ha-select-box
.options=${options}
.value=${this.value as string | undefined}
@value-changed=${this._valueChanged}
.maxColumns=${this.selector.select?.box_max_columns}
></ha-select-box>
${this._renderHelper()}
`;
}
if (
!this.selector.select?.custom_value &&
!this.selector.select?.reorder &&
@@ -269,7 +288,7 @@ export class HaSelectSelector extends LitElement {
: "";
}
private get _mode(): "list" | "dropdown" {
private get _mode(): "list" | "dropdown" | "box" {
return (
this.selector.select?.mode ||
((this.selector.select?.options?.length || 0) < 6 ? "list" : "dropdown")
@@ -411,6 +430,15 @@ export class HaSelectSelector extends LitElement {
padding: 8px 0;
}
.label {
display: block;
margin: 0 0 8px;
}
ha-select-box + ha-input-helper-text {
margin-top: 4px;
}
.sortable-fallback {
display: none;
opacity: 0;

View File

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

View File

@@ -0,0 +1,41 @@
import SlTooltip from "@shoelace-style/shoelace/dist/components/tooltip/tooltip.component";
import styles from "@shoelace-style/shoelace/dist/components/tooltip/tooltip.styles";
import { css } from "lit";
import { customElement } from "lit/decorators";
import { setDefaultAnimation } from "@shoelace-style/shoelace/dist/utilities/animation-registry";
setDefaultAnimation("tooltip.show", {
keyframes: [{ opacity: 0 }, { opacity: 1 }],
options: { duration: 150, easing: "ease" },
});
setDefaultAnimation("tooltip.hide", {
keyframes: [{ opacity: 1 }, { opacity: 0 }],
options: { duration: 400, easing: "ease" },
});
@customElement("ha-tooltip")
export class HaTooltip extends SlTooltip {
static override styles = [
styles,
css`
:host {
--sl-tooltip-background-color: var(--secondary-background-color);
--sl-tooltip-color: var(--primary-text-color);
--sl-tooltip-font-family: Roboto, sans-serif;
--sl-tooltip-font-size: 12px;
--sl-tooltip-font-weight: normal;
--sl-tooltip-line-height: 1;
--sl-tooltip-padding: 8px;
--sl-tooltip-border-radius: var(--ha-tooltip-border-radius, 4px);
--sl-tooltip-arrow-size: var(--ha-tooltip-arrow-size, 8px);
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
"ha-tooltip": HaTooltip;
}
}

View File

@@ -581,6 +581,7 @@ export class HaMap extends ReactiveElement {
this._mapCluster = Leaflet.markerClusterGroup({
showCoverageOnHover: false,
removeOutsideVisibleBounds: false,
maxClusterRadius: 40,
});
this._mapCluster.addLayers(this._mapItems);
map.addLayer(this._mapCluster);
@@ -672,6 +673,22 @@ export class HaMap extends ReactiveElement {
box-shadow: none !important;
text-align: center;
}
.marker-cluster div {
background-clip: padding-box;
background-color: var(--primary-color);
border: 3px solid rgba(var(--rgb-primary-color), 0.2);
width: 32px;
height: 32px;
border-radius: 20px;
text-align: center;
color: var(--text-primary-color);
font-size: 14px;
}
.marker-cluster span {
line-height: 30px;
}
`;
}

View File

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

View File

@@ -346,6 +346,8 @@ export interface AssistPipelineSelector {
export interface SelectOption {
value: any;
label: string;
description?: string;
image?: string;
disabled?: boolean;
}
@@ -353,11 +355,12 @@ export interface SelectSelector {
select: {
multiple?: boolean;
custom_value?: boolean;
mode?: "list" | "dropdown";
mode?: "list" | "dropdown" | "box";
options: readonly string[] | readonly SelectOption[];
translation_key?: string;
sort?: boolean;
reorder?: boolean;
box_max_columns?: number;
} | null;
}

View File

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

View File

@@ -99,7 +99,12 @@ class MoreInfoSirenAdvancedControls extends LitElement {
this._stateObj.attributes.available_tones
).map(
([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
>
`
@@ -179,7 +184,7 @@ class MoreInfoSirenAdvancedControls extends LitElement {
await this.hass.callService("siren", "turn_on", {
entity_id: this._stateObj!.entity_id,
tone: this._tone,
volume: this._volume,
volume_level: this._volume,
duration: this._duration,
});
}

View File

@@ -1,10 +1,10 @@
import "@material/mwc-button";
import "@lrnwebcomponents/simple-tooltip/simple-tooltip";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { formatDateTime } from "../../common/datetime/format_date_time";
import "../../components/ha-markdown";
import "../../components/ha-relative-time";
import "../../components/ha-tooltip";
import "../../components/ha-button";
import type { PersistentNotification } from "../../data/persistent_notification";
import type { HomeAssistant } from "../../types";
import "./notification-item-template";
@@ -28,21 +28,23 @@ export class HuiPersistentNotificationItem extends LitElement {
<div class="time">
<span>
<ha-relative-time
.hass=${this.hass}
.datetime=${this.notification.created_at}
capitalize
></ha-relative-time>
<simple-tooltip animation-delay="0">
${this._computeTooltip(this.hass, this.notification)}
</simple-tooltip>
<ha-tooltip
.content=${this._computeTooltip(this.hass, this.notification)}
placement="bottom"
>
<ha-relative-time
.hass=${this.hass}
.datetime=${this.notification.created_at}
capitalize
></ha-relative-time>
</ha-tooltip>
</span>
</div>
<mwc-button slot="actions" @click=${this._handleDismiss}
<ha-button slot="actions" @click=${this._handleDismiss}
>${this.hass.localize(
"ui.card.persistent_notification.dismiss"
)}</mwc-button
)}</ha-button
>
</notification-item-template>
`;

View File

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

View File

@@ -10,6 +10,7 @@ import "../../../components/ha-svg-icon";
import "../../../components/ha-textfield";
import type { HaTextField } from "../../../components/ha-textfield";
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 {
showAlertDialog,
@@ -25,6 +26,8 @@ export class CloudStepSignin extends LitElement {
@state() private _error?: string;
@state() private _checkConnection = true;
@query("#email", true) private _emailField!: HaTextField;
@query("#password", true) private _passwordField!: HaPasswordField;
@@ -115,6 +118,7 @@ export class CloudStepSignin extends LitElement {
hass: this.hass,
email: username,
...(code ? { code } : { password }),
check_connection: this._checkConnection,
});
} catch (err: any) {
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()) {
await doLogin(username.toLowerCase());
return;

View File

@@ -1,6 +1,7 @@
import type { PropertyValues } from "lit";
import { html } from "lit";
import { customElement, state } from "lit/decorators";
import type { Connection } from "home-assistant-js-websocket";
import { isNavigationClick } from "../common/dom/is-navigation-click";
import { navigate } from "../common/navigate";
import { getStorageDefaultPanelUrlPath } from "../data/panel";
@@ -22,6 +23,7 @@ import {
} from "../util/register-service-worker";
import "./ha-init-page";
import "./home-assistant-main";
import { storage } from "../common/decorators/storage";
const useHash = __DEMO__;
const curPath = () =>
@@ -40,6 +42,7 @@ export class HomeAssistantAppEl extends QuickBarMixin(HassElement) {
private _panelUrl: string;
@storage({ key: "ha-version", state: false, subscribe: false })
private _haVersion?: string;
private _hiddenTimeout?: number;
@@ -182,9 +185,15 @@ export class HomeAssistantAppEl extends QuickBarMixin(HassElement) {
protected hassReconnected() {
super.hassReconnected();
this._checkUpdate(this.hass!.connection);
}
private _checkUpdate(connection: Connection) {
const oldVersion = this._haVersion;
const currentVersion = connection.haVersion;
// If backend has been upgraded, make sure we update frontend
if (this.hass!.connection.haVersion !== this._haVersion) {
if (currentVersion !== oldVersion) {
this._haVersion = currentVersion;
if (supportsServiceWorker()) {
navigator.serviceWorker.getRegistration().then((registration) => {
if (registration) {
@@ -244,7 +253,7 @@ export class HomeAssistantAppEl extends QuickBarMixin(HassElement) {
}
const { auth, conn } = result;
this._haVersion = conn.haVersion;
this._checkUpdate(conn);
this.initializeHass(auth, conn);
} catch (_err: any) {
this._renderInitInfo(true);

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 { customElement, property, state } from "lit/decorators";
import { customElement, property } from "lit/decorators";
import { fireEvent } from "../../../../common/dom/fire_event";
import { copyToClipboard } from "../../../../common/util/copy-clipboard";
import "../../../../components/ha-alert";
import "../../../../components/ha-button";
import "../../../../components/ha-card";
import "../../../../components/ha-expansion-panel";
import "../../../../components/ha-formfield";
import "../../../../components/ha-radio";
import "../../../../components/ha-settings-row";
import "../../../../components/ha-switch";
import "../../../../components/ha-textfield";
import { formatDate } from "../../../../common/datetime/format_date";
import type { HaSwitch } from "../../../../components/ha-switch";
@@ -25,6 +21,7 @@ import type { HomeAssistant } from "../../../../types";
import { showToast } from "../../../../util/toast";
import { showCloudCertificateDialog } from "../dialog-cloud-certificate/show-dialog-cloud-certificate";
import { obfuscateUrl } from "../../../../util/url";
import "../../../../components/ha-copy-textfield";
@customElement("cloud-remote-pref")
export class CloudRemotePref extends LitElement {
@@ -34,8 +31,6 @@ export class CloudRemotePref extends LitElement {
@property({ type: Boolean }) public narrow = false;
@state() private _unmaskedUrl = false;
protected render() {
if (!this.cloudStatus) {
return nothing;
@@ -139,37 +134,13 @@ export class CloudRemotePref extends LitElement {
)}
</p>
`}
<div class="url-container">
<div class="textfield-container">
<ha-textfield
.value=${this._unmaskedUrl
? `https://${remote_domain}`
: obfuscateUrl(`https://${remote_domain}`)}
readonly
.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-copy-textfield
.hass=${this.hass}
.value=${`https://${remote_domain}`}
.maskedValue=${obfuscateUrl(`https://${remote_domain}`)}
.label=${this.hass!.localize("ui.panel.config.common.copy_link")}
></ha-copy-textfield>
<ha-expansion-panel
outlined
@@ -234,10 +205,6 @@ export class CloudRemotePref extends LitElement {
});
}
private _toggleUnmaskedUrl(): void {
this._unmaskedUrl = !this._unmaskedUrl;
}
private async _toggleChanged(ev) {
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`
.preparing {
padding: 0 16px 16px;
@@ -335,30 +294,6 @@ export class CloudRemotePref extends LitElement {
display: block;
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 {
border: none;
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 { mdiContentCopy, mdiOpenInNew } from "@mdi/js";
import { mdiOpenInNew } from "@mdi/js";
import type { CSSResultGroup } 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 { copyToClipboard } from "../../../../common/util/copy-clipboard";
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 { haStyle, haStyleDialog } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
import { documentationUrl } from "../../../../util/documentation-url";
import { showToast } from "../../../../util/toast";
import type { WebhookDialogParams } from "./show-dialog-manage-cloudhook";
import "../../../../components/ha-copy-textfield";
export class DialogManageCloudhook extends LitElement {
protected hass?: HomeAssistant;
@state() private _params?: WebhookDialogParams;
@query("ha-textfield") _input!: HaTextField;
public showDialog(params: WebhookDialogParams) {
this._params = params;
}
@@ -82,21 +78,12 @@ export class DialogManageCloudhook extends LitElement {
<ha-svg-icon .path=${mdiOpenInNew}></ha-svg-icon>
</a>
</p>
<ha-textfield
.label=${this.hass!.localize(
"ui.panel.config.cloud.dialog_cloudhook.public_url"
)}
<ha-copy-textfield
.hass=${this.hass}
.value=${cloudhook.cloudhook_url}
iconTrailing
readOnly
@click=${this._focusInput}
>
<ha-icon-button
@click=${this._copyUrl}
slot="trailingIcon"
.path=${mdiContentCopy}
></ha-icon-button>
</ha-textfield>
.label=${this.hass!.localize("ui.panel.config.common.copy_link")}
></ha-copy-textfield>
</div>
<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 {
return [
haStyle,
@@ -163,13 +132,6 @@ export class DialogManageCloudhook extends LitElement {
ha-dialog {
width: 650px;
}
ha-textfield {
display: block;
}
ha-textfield > ha-icon-button {
--mdc-icon-button-size: 24px;
--mdc-icon-size: 18px;
}
button.link {
color: var(--primary-color);
text-decoration: none;

View File

@@ -28,6 +28,7 @@ import { haStyle } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
import "../../ha-config-section";
import { showSupportPackageDialog } from "../account/show-dialog-cloud-support-package";
import { showCloudAlreadyConnectedDialog } from "../dialog-cloud-already-connected/show-dialog-cloud-already-connected";
@customElement("cloud-login")
export class CloudLogin extends LitElement {
@@ -47,6 +48,8 @@ export class CloudLogin extends LitElement {
@state() private _error?: string;
@state() private _checkConnection = true;
@query("#email", true) private _emailField!: HaTextField;
@query("#password", true) private _passwordField!: HaPasswordField;
@@ -244,6 +247,7 @@ export class CloudLogin extends LitElement {
hass: this.hass,
email: username,
...(code ? { code } : { password }),
check_connection: this._checkConnection,
});
this.email = "";
this._password = "";
@@ -283,6 +287,21 @@ export class CloudLogin extends LitElement {
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") {
showAlertDialog(this, {
title: this.hass.localize(

View File

@@ -185,6 +185,14 @@ class AddIntegrationDialog extends LitElement {
const yamlIntegrations: IntegrationListItem[] = [];
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 (
"integration_type" in integration &&
(integration.config_flow ||

View File

@@ -12,6 +12,7 @@ import type {
} from "../../../../../components/data-table/ha-data-table";
import "../../../../../components/ha-fab";
import "../../../../../components/ha-icon-button";
import "../../../../../components/ha-relative-time";
import type {
BluetoothDeviceData,
BluetoothScannersDetails,
@@ -140,6 +141,18 @@ export class BluetoothAdvertisementMonitorPanel extends LitElement {
sortable: true,
defaultHidden: true,
},
time: {
title: localize("ui.panel.config.bluetooth.last_seen"),
filterable: false,
sortable: true,
defaultHidden: false,
template: (ad) =>
html`<ha-relative-time
.hass=${this.hass}
.datetime=${ad.datetime}
capitalize
></ha-relative-time>`,
},
rssi: {
title: localize("ui.panel.config.bluetooth.rssi"),
type: "numeric",
@@ -167,6 +180,7 @@ export class BluetoothAdvertisementMonitorPanel extends LitElement {
scanner?.name ||
row.source,
device: device?.name_by_user || device?.name || undefined,
datetime: new Date(row.time * 1000),
};
})
);

View File

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

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[];
}
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 {
type: "select-options";
options?: string[];
@@ -101,6 +110,10 @@ export interface TargetTemperatureCardFeatureConfig {
type: "target-temperature";
}
export interface ToggleCardFeatureConfig {
type: "toggle";
}
export interface WaterHeaterOperationModesCardFeatureConfig {
type: "water-heater-operation-modes";
operation_modes?: OperationMode[];
@@ -152,6 +165,7 @@ export type LovelaceCardFeatureConfig =
| ClimateSwingHorizontalModesCardFeatureConfig
| ClimateHvacModesCardFeatureConfig
| ClimatePresetModesCardFeatureConfig
| CounterActionsCardFeatureConfig
| CoverOpenCloseCardFeatureConfig
| CoverPositionCardFeatureConfig
| CoverTiltPositionCardFeatureConfig
@@ -170,6 +184,7 @@ export type LovelaceCardFeatureConfig =
| SelectOptionsCardFeatureConfig
| TargetHumidityCardFeatureConfig
| TargetTemperatureCardFeatureConfig
| ToggleCardFeatureConfig
| UpdateActionsCardFeatureConfig
| VacuumCommandsCardFeatureConfig
| WaterHeaterOperationModesCardFeatureConfig;

View File

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

View File

@@ -12,6 +12,7 @@ import memoizeOne from "memoize-one";
import { getColorByIndex } from "../../../common/color/colors";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import { computeDomain } from "../../../common/entity/compute_domain";
import { computeStateDomain } from "../../../common/entity/compute_state_domain";
import { computeStateName } from "../../../common/entity/compute_state_name";
import { deepEqual } from "../../../common/util/deep-equal";
import parseAspectRatio from "../../../common/util/parse-aspect-ratio";
@@ -80,14 +81,38 @@ class HuiMapCard extends LitElement implements LovelaceCard {
private _subscribed?: Promise<(() => Promise<void>) | undefined>;
private _getAllEntities(): string[] {
const hass = this.hass!;
const personSources = new Set<string>();
const locationEntities: string[] = [];
Object.values(hass.states).forEach((entity) => {
if (
!("latitude" in entity.attributes) ||
!("longitude" in entity.attributes)
) {
return;
}
locationEntities.push(entity.entity_id);
if (computeStateDomain(entity) === "person" && entity.attributes.source) {
personSources.add(entity.attributes.source);
}
});
return locationEntities.filter((entity) => !personSources.has(entity));
}
public setConfig(config: MapCardConfig): void {
if (!config) {
throw new Error("Error in card configuration.");
}
if (!config.entities?.length && !config.geo_location_sources) {
if (
!config.show_all &&
!config.entities?.length &&
!config.geo_location_sources
) {
throw new Error(
"Either entities or geo_location_sources must be specified"
"Either show_all, entities, or geo_location_sources must be specified"
);
}
if (config.entities && !Array.isArray(config.entities)) {
@@ -99,10 +124,17 @@ class HuiMapCard extends LitElement implements LovelaceCard {
) {
throw new Error("Parameter geo_location_sources needs to be an array");
}
this._config = config;
this._configEntities = config.entities
? processConfigEntities<MapEntityConfig>(config.entities)
if (config.show_all && (config.entities || config.geo_location_sources)) {
throw new Error(
"Cannot specify show_all and entities or geo_location_sources"
);
}
this._config = { ...config };
if (this.hass && config.show_all) {
this._config.entities = this._getAllEntities();
}
this._configEntities = this._config.entities
? processConfigEntities<MapEntityConfig>(this._config.entities)
: [];
this._mapEntities = this._getMapEntities();
}
@@ -239,6 +271,18 @@ class HuiMapCard extends LitElement implements LovelaceCard {
protected willUpdate(changedProps: PropertyValues): void {
super.willUpdate(changedProps);
if (
this._config?.show_all &&
!this._config?.entities &&
this.hass &&
changedProps.has("hass")
) {
this._config.entities = this._getAllEntities();
this._configEntities = processConfigEntities<MapEntityConfig>(
this._config.entities
);
this._mapEntities = this._getMapEntities();
}
if (
changedProps.has("hass") &&
this._config?.geo_location_sources &&

View File

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

View File

@@ -416,15 +416,12 @@ export class HuiTileCard extends LitElement implements LovelaceCard {
align-items: center;
padding: 10px;
flex: 1;
min-width: 0;
box-sizing: border-box;
pointer-events: none;
gap: 10px;
}
.container.horizontal .content {
width: 50%;
}
.vertical {
flex-direction: column;
text-align: center;
@@ -458,9 +455,10 @@ export class HuiTileCard extends LitElement implements LovelaceCard {
padding: 0 12px 12px 12px;
}
.container.horizontal hui-card-features {
width: 50%;
width: calc(50% - var(--column-gap, 0px) / 2 - 12px);
flex: none;
--feature-height: 36px;
padding: 10px;
padding: 0 12px;
padding-inline-start: 0;
}

View File

@@ -336,6 +336,7 @@ export interface MapCardConfig extends LovelaceCardConfig {
export interface MarkdownCardConfig extends LovelaceCardConfig {
type: "markdown";
content: string;
text_only?: boolean;
title?: string;
card_size?: number;
entity_ids?: string | string[];

View File

@@ -23,6 +23,7 @@ import type { LovelaceCardConfig } from "../../../data/lovelace/config/card";
import { haStyle } from "../../../resources/styles";
import type { HomeAssistant } from "../../../types";
import { showEditBadgeDialog } from "../editor/badge-editor/show-edit-badge-dialog";
import { addBadge } from "../editor/config-util";
import type { LovelaceCardPath } from "../editor/lovelace-path";
import {
findLovelaceItems,
@@ -221,11 +222,19 @@ export class HuiBadgeEditMode extends LitElement {
const { cardIndex } = parseLovelaceCardPath(this.path!);
const containerPath = getLovelaceContainerPath(this.path!);
const badgeConfig = ensureBadgeConfig(this._badges![cardIndex]);
showEditBadgeDialog(this, {
lovelaceConfig: this.lovelace!.config,
saveConfig: this.lovelace!.saveConfig,
path: containerPath,
saveBadgeConfig: async (config) => {
const newConfig = addBadge(
this.lovelace!.config,
containerPath,
config
);
await this.lovelace!.saveConfig(newConfig);
},
badgeConfig,
isNew: true,
});
}

View File

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

View File

@@ -278,9 +278,12 @@ export class HuiCardOptions extends LitElement {
const cardConfig = this._cards![cardIndex];
showEditCardDialog(this, {
lovelaceConfig: this.lovelace!.config,
saveConfig: this.lovelace!.saveConfig,
path: containerPath,
saveCardConfig: async (config) => {
const newConfig = addCard(this.lovelace!.config, containerPath, config);
await this.lovelace!.saveConfig(newConfig);
},
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-hvac-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-position-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-target-temperature-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-vacuum-commands-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-hvac-modes",
"climate-preset-modes",
"counter-actions",
"cover-open-close",
"cover-position",
"cover-tilt-position",
@@ -57,6 +60,7 @@ const TYPES = new Set<LovelaceCardFeatureConfig["type"]>([
"select-options",
"target-humidity",
"target-temperature",
"toggle",
"update-actions",
"vacuum-commands",
"water-heater-operation-modes",

View File

@@ -24,6 +24,7 @@ import "./hui-badge-picker";
import type { CreateBadgeDialogParams } from "./show-create-badge-dialog";
import { showEditBadgeDialog } from "./show-edit-badge-dialog";
import { showSuggestBadgeDialog } from "./show-suggest-badge-dialog";
import { addBadge } from "../config-util";
declare global {
interface HASSDomEvents {
@@ -223,11 +224,22 @@ export class HuiCreateDialogBadge
}
}
const lovelaceConfig = this._params!.lovelaceConfig;
const containerPath = this._params!.path;
const saveConfig = this._params!.saveConfig;
showEditBadgeDialog(this, {
lovelaceConfig: this._params!.lovelaceConfig,
saveConfig: this._params!.saveConfig,
path: this._params!.path,
lovelaceConfig,
saveBadgeConfig: async (newBadgeConfig) => {
const newConfig = addBadge(
lovelaceConfig,
containerPath,
newBadgeConfig
);
await saveConfig(newConfig);
},
badgeConfig: config,
isNew: true,
});
this.closeDialog();

View File

@@ -11,8 +11,6 @@ import "../../../../components/ha-dialog";
import "../../../../components/ha-dialog-header";
import "../../../../components/ha-icon-button";
import type { LovelaceBadgeConfig } from "../../../../data/lovelace/config/badge";
import { ensureBadgeConfig } from "../../../../data/lovelace/config/badge";
import type { LovelaceViewConfig } from "../../../../data/lovelace/config/view";
import {
getCustomBadgeEntry,
isCustomType,
@@ -22,13 +20,12 @@ import { showConfirmationDialog } from "../../../../dialogs/generic/show-dialog-
import type { HassDialog } from "../../../../dialogs/make-dialog-manager";
import { haStyleDialog } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
import { showToast } from "../../../../util/toast";
import { showSaveSuccessToast } from "../../../../util/toast-saved-success";
import "../../badges/hui-badge";
import "../../sections/hui-section";
import { addBadge, replaceBadge } from "../config-util";
import { getBadgeDocumentationURL } from "../get-dashboard-documentation-url";
import type { ConfigChangedEvent } from "../hui-element-editor";
import { findLovelaceContainer } from "../lovelace-path";
import type { GUIModeChangedEvent } from "../types";
import "./hui-badge-element-editor";
import type { HuiBadgeElementEditor } from "./hui-badge-element-editor";
@@ -58,8 +55,6 @@ export class HuiDialogEditBadge
@state() private _badgeConfig?: LovelaceBadgeConfig;
@state() private _containerConfig!: LovelaceViewConfig;
@state() private _saving = false;
@state() private _error?: string;
@@ -82,24 +77,7 @@ export class HuiDialogEditBadge
this._GUImode = true;
this._guiModeAvailable = true;
const containerConfig = findLovelaceContainer(
params.lovelaceConfig,
params.path
);
if ("strategy" in containerConfig) {
throw new Error("Can't edit strategy");
}
this._containerConfig = containerConfig;
if ("badgeConfig" in params) {
this._badgeConfig = params.badgeConfig;
this._dirty = true;
} else {
const badge = this._containerConfig.badges?.[params.badgeIndex];
this._badgeConfig = badge != null ? ensureBadgeConfig(badge) : badge;
}
this._badgeConfig = params.badgeConfig;
this.large = false;
if (this._badgeConfig && !Object.isFrozen(this._badgeConfig)) {
@@ -178,13 +156,6 @@ export class HuiDialogEditBadge
"ui.panel.lovelace.editor.edit_badge.typed_header",
{ type: badgeName }
);
} else if (!this._badgeConfig) {
heading = this._containerConfig.title
? this.hass!.localize(
"ui.panel.lovelace.editor.edit_badge.pick_badge_view_title",
{ name: this._containerConfig.title }
)
: this.hass!.localize("ui.panel.lovelace.editor.edit_badge.pick_badge");
} else {
heading = this.hass!.localize(
"ui.panel.lovelace.editor.edit_badge.header"
@@ -377,20 +348,18 @@ export class HuiDialogEditBadge
return;
}
this._saving = true;
const path = this._params!.path;
await this._params!.saveConfig(
"badgeConfig" in this._params!
? addBadge(this._params!.lovelaceConfig, path, this._badgeConfig!)
: replaceBadge(
this._params!.lovelaceConfig,
[...path, this._params!.badgeIndex],
this._badgeConfig!
)
);
this._saving = false;
this._dirty = false;
showSaveSuccessToast(this, this.hass);
this.closeDialog();
try {
await this._params!.saveBadgeConfig(this._badgeConfig!);
this._saving = false;
this._dirty = false;
showSaveSuccessToast(this, this.hass);
this.closeDialog();
} catch (err: any) {
showToast(this, {
message: err.message,
});
this._saving = false;
}
}
static get styles(): CSSResultGroup {

View File

@@ -1,20 +1,13 @@
import { fireEvent } from "../../../../common/dom/fire_event";
import type { LovelaceBadgeConfig } from "../../../../data/lovelace/config/badge";
import type { LovelaceConfig } from "../../../../data/lovelace/config/types";
import type { LovelaceContainerPath } from "../lovelace-path";
export type EditBadgeDialogParams = {
export interface EditBadgeDialogParams {
lovelaceConfig: LovelaceConfig;
saveConfig: (config: LovelaceConfig) => void;
path: LovelaceContainerPath;
} & (
| {
badgeIndex: number;
}
| {
badgeConfig: LovelaceBadgeConfig;
}
);
saveBadgeConfig: (badge: LovelaceBadgeConfig) => void;
badgeConfig: LovelaceBadgeConfig;
isNew?: boolean;
}
export const importEditBadgeDialog = () => import("./hui-dialog-edit-badge");

View File

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

View File

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

View File

@@ -1,20 +1,15 @@
import { fireEvent } from "../../../../common/dom/fire_event";
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 { LovelaceContainerPath } from "../lovelace-path";
export type EditCardDialogParams = {
export interface EditCardDialogParams {
lovelaceConfig: LovelaceConfig;
saveConfig: (config: LovelaceConfig) => void;
path: LovelaceContainerPath;
} & (
| {
cardIndex: number;
}
| {
cardConfig: LovelaceCardConfig;
}
);
saveCardConfig: (config: LovelaceCardConfig) => void;
cardConfig: LovelaceCardConfig;
sectionConfig?: LovelaceSectionConfig;
isNew?: boolean;
}
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 { supportsClimateSwingModesCardFeature } from "../../card-features/hui-climate-swing-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 { supportsCoverPositionCardFeature } from "../../card-features/hui-cover-position-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 { supportsTargetHumidityCardFeature } from "../../card-features/hui-target-humidity-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 { supportsVacuumCommandsCardFeature } from "../../card-features/hui-vacuum-commands-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-swing-modes",
"climate-swing-horizontal-modes",
"counter-actions",
"cover-open-close",
"cover-position",
"cover-tilt-position",
@@ -76,6 +79,7 @@ const UI_FEATURE_TYPES = [
"select-options",
"target-humidity",
"target-temperature",
"toggle",
"update-actions",
"vacuum-commands",
"water-heater-operation-modes",
@@ -90,6 +94,7 @@ const EDITABLES_FEATURE_TYPES = new Set<UiFeatureTypes>([
"climate-preset-modes",
"climate-swing-modes",
"climate-swing-horizontal-modes",
"counter-actions",
"fan-preset-modes",
"humidifier-modes",
"lawn-mower-commands",
@@ -111,6 +116,7 @@ const SUPPORTS_FEATURE_TYPES: Record<
supportsClimateSwingHorizontalModesCardFeature,
"climate-hvac-modes": supportsClimateHvacModesCardFeature,
"climate-preset-modes": supportsClimatePresetModesCardFeature,
"counter-actions": supportsCounterActionsCardFeature,
"cover-open-close": supportsCoverOpenCloseCardFeature,
"cover-position": supportsCoverPositionCardFeature,
"cover-tilt-position": supportsCoverTiltPositionCardFeature,
@@ -129,6 +135,7 @@ const SUPPORTS_FEATURE_TYPES: Record<
"select-options": supportsSelectOptionsCardFeature,
"target-humidity": supportsTargetHumidityCardFeature,
"target-temperature": supportsTargetTemperatureCardFeature,
toggle: supportsToggleCardFeature,
"update-actions": supportsUpdateActionsCardFeature,
"vacuum-commands": supportsVacuumCommandsCardFeature,
"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

@@ -61,7 +61,7 @@ const cardConfigStruct = assign(
aspect_ratio: optional(string()),
default_zoom: optional(number()),
dark_mode: optional(boolean()),
entities: array(mapEntitiesConfigStruct),
entities: optional(array(mapEntitiesConfigStruct)),
hours_to_show: optional(number()),
geo_location_sources: optional(array(geoSourcesConfigStruct)),
auto_fit: optional(boolean()),

View File

@@ -1,29 +1,28 @@
import { html, LitElement, nothing } from "lit";
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 "../../../../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 { MarkdownCardConfig } from "../../cards/types";
import type { LovelaceCardEditor } from "../../types";
import { baseLovelaceCardConfig } from "../structs/base-card-struct";
import type { LocalizeFunc } from "../../../../common/translations/localize";
const cardConfigStruct = assign(
baseLovelaceCardConfig,
object({
text_only: optional(boolean()),
title: optional(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")
export class HuiMarkdownCardEditor
extends LitElement
@@ -38,16 +37,53 @@ export class HuiMarkdownCardEditor
this._config = config;
}
private _schema = memoizeOne(
(localize: LocalizeFunc, text_only: boolean, isDark: boolean) =>
[
{
name: "style",
required: true,
selector: {
select: {
mode: "box",
options: ["card", "text-only"].map((style) => ({
label: localize(
`ui.panel.lovelace.editor.card.markdown.style_options.${style}`
),
image: `/static/images/form/markdown_${style.replace("-", "_")}${isDark ? "_dark" : ""}.svg`,
value: style,
})),
},
},
},
...(!text_only
? ([{ name: "title", selector: { text: {} } }] as const)
: []),
{ name: "content", required: true, selector: { template: {} } },
] as const satisfies HaFormSchema[]
);
protected render() {
if (!this.hass || !this._config) {
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,
this.hass.themes.darkMode
);
return html`
<ha-form
.hass=${this.hass}
.data=${this._config}
.schema=${SCHEMA}
.data=${data}
.schema=${schema}
.computeLabel=${this._computeLabelCallback}
@value-changed=${this._valueChanged}
></ha-form>
@@ -55,17 +91,23 @@ export class HuiMarkdownCardEditor
}
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) {
case "theme":
return `${this.hass!.localize(
"ui.panel.lovelace.editor.card.generic.theme"
)} (${this.hass!.localize(
"ui.panel.lovelace.editor.card.config.optional"
)})`;
case "style":
case "content":
return this.hass!.localize(
`ui.panel.lovelace.editor.card.markdown.${schema.name}`

View File

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

View File

@@ -115,7 +115,7 @@ export class HuiTileCardEditor
localize: LocalizeFunc,
entityId: string | undefined,
hideState: boolean,
vertical: boolean,
isDark: boolean,
displayActions: AdvancedActions[] = []
) =>
[
@@ -175,41 +175,20 @@ export class HuiTileCardEditor
] 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: "content_layout",
required: true,
selector: {
select: {
mode: "box",
options: ["horizontal", "vertical"].map((value) => ({
label: localize(
`ui.panel.lovelace.editor.card.tile.content_layout_options.${value}`
),
value,
image: `/static/images/form/tile_content_layout_${value}${isDark ? "_dark" : ""}.svg`,
})),
},
{
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",
})),
},
},
},
],
},
},
],
},
@@ -250,6 +229,29 @@ export class HuiTileCardEditor
] as const satisfies readonly HaFormSchema[]
);
private _featuresSchema = memoizeOne(
(localize: LocalizeFunc, vertical: boolean, isDark: boolean) =>
[
{
name: "features_position",
required: true,
selector: {
select: {
mode: "box",
options: ["bottom", "inline"].map((value) => ({
label: localize(
`ui.panel.lovelace.editor.card.tile.features_position_options.${value}`
),
value,
image: `/static/images/form/tile_features_position_${value}${isDark ? "_dark" : ""}.svg`,
disabled: vertical && value === "inline",
})),
},
},
},
] as const satisfies readonly HaFormSchema[]
);
protected render() {
if (!this.hass || !this._config) {
return nothing;
@@ -262,10 +264,16 @@ export class HuiTileCardEditor
this.hass.localize,
entityId,
this._config.hide_state ?? false,
this._config.vertical ?? false,
this.hass.themes.darkMode,
this._displayActions
);
const featuresSchema = this._featuresSchema(
this.hass.localize,
this._config.vertical ?? false,
this.hass.themes.darkMode
);
const data = {
...this._config,
content_layout: this._config.vertical ? "vertical" : "horizontal",
@@ -293,6 +301,15 @@ export class HuiTileCardEditor
)}
</h3>
<div class="content">
<ha-form
class="features-form"
.hass=${this.hass}
.data=${data}
.schema=${featuresSchema}
.computeLabel=${this._computeLabelCallback}
.computeHelper=${this._computeHelperCallback}
@value-changed=${this._valueChanged}
></ha-form>
<hui-card-features-editor
.hass=${this.hass}
.stateObj=${stateObj}
@@ -381,7 +398,9 @@ export class HuiTileCardEditor
}
private _computeLabelCallback = (
schema: SchemaUnion<ReturnType<typeof this._schema>>
schema:
| SchemaUnion<ReturnType<typeof this._schema>>
| SchemaUnion<ReturnType<typeof this._featuresSchema>>
) => {
switch (schema.name) {
case "color":
@@ -405,13 +424,22 @@ export class HuiTileCardEditor
};
private _computeHelperCallback = (
schema: SchemaUnion<ReturnType<typeof this._schema>>
schema:
| SchemaUnion<ReturnType<typeof this._schema>>
| SchemaUnion<ReturnType<typeof this._featuresSchema>>
) => {
switch (schema.name) {
case "color":
return this.hass!.localize(
`ui.panel.lovelace.editor.card.tile.${schema.name}_helper`
);
case "features_position":
if (this._config?.vertical) {
return this.hass!.localize(
`ui.panel.lovelace.editor.card.tile.${schema.name}_helper_vertical`
);
}
return undefined;
default:
return undefined;
}

View File

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

View File

@@ -1,6 +1,5 @@
import { ReactiveElement } from "lit";
import { customElement } from "lit/decorators";
import { computeStateDomain } from "../../../../common/entity/compute_state_domain";
import type { LovelaceViewConfig } from "../../../../data/lovelace/config/view";
import type { HomeAssistant } from "../../../../types";
import type { MapCardConfig } from "../../cards/types";
@@ -9,32 +8,12 @@ export interface MapViewStrategyConfig {
type: "map";
}
const getMapEntities = (hass: HomeAssistant) => {
const personSources = new Set<string>();
const locationEntities: string[] = [];
Object.values(hass.states).forEach((entity) => {
if (
!("latitude" in entity.attributes) ||
!("longitude" in entity.attributes)
) {
return;
}
locationEntities.push(entity.entity_id);
if (computeStateDomain(entity) === "person" && entity.attributes.source) {
personSources.add(entity.attributes.source);
}
});
return locationEntities.filter((entity) => !personSources.has(entity));
};
@customElement("map-view-strategy")
export class MapViewStrategy extends ReactiveElement {
static async generate(
_config: MapViewStrategyConfig,
hass: HomeAssistant
): Promise<LovelaceViewConfig> {
const entities = getMapEntities(hass);
return {
type: "panel",
title: hass.localize("panel.map"),
@@ -43,7 +22,7 @@ export class MapViewStrategy extends ReactiveElement {
{
type: "map",
auto_fit: true,
entities: entities,
show_all: true,
} as MapCardConfig,
],
};

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

View File

@@ -47,7 +47,12 @@ export class HaStateControlAlarmControlPanelModes extends LitElement {
}
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) {

View File

@@ -286,7 +286,10 @@ export const connectionMixin = <T extends Constructor<HassBaseEl>>(
clearInterval(this.__backendPingInterval);
this.__backendPingInterval = setInterval(() => {
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) {
return;
}
@@ -296,7 +299,7 @@ export const connectionMixin = <T extends Constructor<HassBaseEl>>(
this.hass?.connection.reconnect(true);
});
}
}, 10000);
}, 30000);
}
protected hassReconnected() {

View File

@@ -370,7 +370,10 @@
"name": "Name",
"optional": "optional",
"default": "Default",
"dont_save": "Don't save"
"dont_save": "Don't save",
"copy": "Copy",
"show": "Show",
"hide": "Hide"
},
"components": {
"selectors": {
@@ -4727,6 +4730,23 @@
"fingerprint": "Certificate fingerprint:",
"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": {
"webhook_for": "Webhook for {name}",
"managed_by_integration": "This webhook is managed by an integration and cannot be disabled.",
@@ -5349,6 +5369,7 @@
"source": "Source",
"rssi": "RSSI",
"source_address": "Source address",
"last_seen": "Last seen",
"device": "Device",
"device_information": "Device information",
"advertisement_data": "Advertisement data",
@@ -6998,7 +7019,8 @@
"suggested_cards": "Suggested cards",
"other_cards": "Other cards",
"custom_cards": "Custom cards",
"features": "Features"
"features": "Features",
"actions": "Actions"
},
"heading": {
"name": "Heading",
@@ -7045,6 +7067,11 @@
"markdown": {
"name": "Markdown",
"content": "Content",
"style": "Style",
"style_options": {
"card": "Card",
"text-only": "Text only"
},
"description": "The Markdown card is used to render Markdown."
},
"media-control": {
@@ -7132,6 +7159,7 @@
"bottom": "Bottom",
"inline": "Inline"
},
"features_position_helper_vertical": "Always displayed at the bottom if the content layout is vertical",
"content_layout": "Content layout",
"content_layout_options": {
"horizontal": "Horizontal",
@@ -7312,6 +7340,14 @@
"customize_modes": "Customize preset modes",
"preset_modes": "Preset modes"
},
"counter-actions": {
"label": "Counter actions",
"actions": {
"increment": "Increment",
"decrement": "Decrement",
"reset": "Reset"
}
},
"fan-preset-modes": {
"label": "Fan preset modes",
"style": "[%key:ui::panel::lovelace::editor::features::types::climate-preset-modes::style%]",
@@ -7340,6 +7376,9 @@
"options": "Options",
"customize_options": "Customize options"
},
"toggle": {
"label": "Toggle"
},
"numeric-input": {
"label": "Numeric input",
"style": "Style",

View File

@@ -63,4 +63,28 @@ describe("canToggleState", () => {
};
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 { computeStateDisplay } from "../../../src/common/entity/compute_state_display";
import type { HassConfig } from "home-assistant-js-websocket";
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 type { FrontendLocaleData } from "../../../src/data/translation";
import {
@@ -10,6 +14,7 @@ import {
TimeZone,
} from "../../../src/data/translation";
import { demoConfig } from "../../../src/fake_data/demo_config";
import type { EntityRegistryDisplayEntry } from "../../../src/data/entity_registry";
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 tsconfigPaths from "vite-tsconfig-paths";
export default defineConfig({
plugins: [tsconfigPaths()],
test: {
environment: "jsdom", // to run in browser-like environment
env: {

View File

@@ -49,6 +49,36 @@
"./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"
]
}
}
}

738
yarn.lock

File diff suppressed because it is too large Load Diff