Compare commits

..
Author SHA1 Message Date
dependabot[bot]andGitHub 604781b095 Bump the codeql-action group across 1 directory with 2 updates
Bumps the codeql-action group with 2 updates in the / directory: [github/codeql-action/init](https://github.com/github/codeql-action) and [github/codeql-action/analyze](https://github.com/github/codeql-action).


Updates `github/codeql-action/init` from 4.37.6 to 4.37.7
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/5595ccaf912efad79be6eef63a5619ff05969be3...ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd)

Updates `github/codeql-action/analyze` from 4.37.6 to 4.37.7
- [Release notes](https://github.com/github/codeql-action/releases)
- [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/github/codeql-action/compare/5595ccaf912efad79be6eef63a5619ff05969be3...ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd)

---
updated-dependencies:
- dependency-name: github/codeql-action/init
  dependency-version: 4.37.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: codeql-action
- dependency-name: github/codeql-action/analyze
  dependency-version: 4.37.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: codeql-action
...

Signed-off-by: dependabot[bot] <[email protected]>
2026-08-23 06:06:16 +00:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
b38d7bcb26 Update dependency hls.js to v1.7.1 (#53741)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-22 22:20:30 +02:00
Petar PetrovandGitHub 0572d71699 Replace a remembered yesterday energy period when picking now (#53735) 2026-08-22 17:36:40 +02:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
bcd13fcaf7 Update vitest monorepo to v4.1.11 (#53732)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-21 21:35:37 +02:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
308eac97ee Update dependency marked to v18.0.10 (#53725)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-21 06:37:46 +02:00
Paul BotteinandGitHub 6da851219d Fix stacked toasts when a dashboard is updated in another session (#53719) 2026-08-20 21:34:08 +02:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
970be43c3d Update dependency intl-messageformat to v11.2.14 (#53722)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-20 21:01:27 +02:00
karwostsandGitHub 8bb718a486 Cleanup some attributes code (#53713) 2026-08-20 20:54:59 +02:00
Josef ZweckandGitHub 0e0bc389f8 Show result of action for button presses (#53645)
* Show result of action on button feature

* Add button entity

* Add to button entities

* add to tile icon

* Delay for spinner

* Remove result indicator from tile icon
2026-08-20 16:49:55 +02:00
Petar PetrovandGitHub 70a482e0ed Add keyboard and screen reader navigation to charts (#53533)
* Add keyboard and screen reader navigation to charts via Chart2Music

* Mark charts as busy while the sonification chunk loads

* Skip chart sonification when there is nothing to navigate

* Drop the direct chart2music dependency

* Exclude legend-hidden series when judging chart sonifiability

* Push statistics chart times as numbers so sonification reads real dates
2026-08-20 16:55:11 +03:00
Maarten LakerveldandGitHub 5a0db4a796 Show Assist greeting in the assistant's language (#53707)
The greeting sits in the assistant's chat bubble, so render it in the pipeline's language instead of the interface language. Falls back to the interface language when no translation is available (issue #53703).
2026-08-20 12:49:40 +02:00
622f3e97e6 Add RepairsFlow as a next_flow from a RepairsFlow (#51744)
* fix flowType in config subentry flows/next_flow

* suggested changes

* Repair Flow Next Flow Repair

* Add repair flow support a new repair flow as the next_flow

* Add support for the next_flow being another repair flow

* fix lint errors

* Reviewer response and fix linter error

* Revert to generics to avoid multiple casts

* Move show-dialog-repair-flows to dialogs

* Update src/dialogs/config-flow/dialog-data-entry-flow.ts

* Apply suggestions from code review

* Apply suggestion from @MindFreeze

* format

---------

Co-authored-by: Petar Petrov <[email protected]>
Co-authored-by: Paulus Schoutsen <[email protected]>
2026-08-20 11:24:23 +03:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
3091362040 Update dependency @html-eslint/eslint-plugin to v0.65.0 (#53714)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-20 11:08:20 +03:00
Paul BotteinandGitHub 3c7560a4e2 Add indeterminate and controlled selection to lists (#53706) 2026-08-19 23:26:40 +02:00
a9e178cb0e Show per-mount storage usage on the storage page (#53683)
* Show per-mount storage usage on the storage page

Each active mount now fetches its own usage from the Supervisor and renders
a bar with a used-of-total line under its address. The requests are not
awaited, so a slow or unreachable server cannot hold up the rows, and a
failed one simply leaves that row without usage.

fetchHostDisksUsage now takes a disk and an optional max_depth. The data
disk callers keep their depth of 3; mounts send no depth at all, since
walking one costs a round trip per directory and the Supervisor already
has a sensible per-target default.

* Vertically align ha-bar

---------

Co-authored-by: Simon Lamon <[email protected]>
2026-08-19 21:18:37 +02:00
23b83fbd27 Update browserlist (#53691)
* update browserlist for better packaging

* add results of bundle changes

* update browserlist, budgets and ecma terser rewrite level

* remove results.md used for testing

* Apply suggestion from @silamon

---------

Co-authored-by: copilot-swe-agent[bot] <[email protected]>
Co-authored-by: Simon Lamon <[email protected]>
2026-08-19 18:45:22 +00:00
62849e2630 Fix outlined button border colors (#53704)
The brand outline pointed at a token that doesn't exist, so CSS fell back
to the neutral border and brand outlined buttons drew a gray ring.

Disabled outlined buttons only reset their background and label, keeping
the variant-coloured border.

Also add outlined to the gallery: it has always been a valid appearance
but was missing from the documented list the gallery grid is built from,
which is why both bugs went unnoticed.

Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
2026-08-19 20:37:09 +02:00
renovate[bot]GitHubrenovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
aa3e11f6e9 Update CodeMirror (#53705)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2026-08-19 20:34:12 +02:00
82ed13b7fc Fix filter pane expand animation and use a bottom sheet in narrow mode (#53684)
* Fix filter pane expand animation and use a bottom sheet in narrow mode

* Update src/common/controllers/filter-panel-controller.ts

Co-authored-by: Petar Petrov <[email protected]>

* Apply suggestion from @MindFreeze

---------

Co-authored-by: Petar Petrov <[email protected]>
2026-08-19 16:18:40 +03:00
65 changed files with 2777 additions and 2141 deletions
+40 -24
View File
@@ -1,36 +1,52 @@
[modern]
# Modern builds target recent browsers supporting the latest features to minimize transpilation, polyfills, etc.
# It is served to browsers meeting the following requirements:
# - released in the last year + current alpha/beta versions
# - Firefox extended support release (ESR)
# - with global utilization at or above 0.5%
# - released in the last 2 years + current alpha/beta versions
# - exclude dead browsers (no security maintenance for 2+ years)
# - exclude KaiOS, QQ, and UC browsers due to lack of sufficient feature support data
# - exclude QQ, and UC browsers due to lack of sufficient feature support data
unreleased versions
last 1 year
Firefox ESR
>= 0.5%
last 2 years
not dead
not KaiOS > 0
not QQAndroid > 0
not UCAndroid > 0
[legacy]
# Legacy builds are served when modern requirements are not met and support browsers:
# - released in the last 7 years + current alpha/beta versionss
# - with global utilization at or above 0.05%
# - exclude dead browsers (no security maintenance for 2+ years)
# - exclude Opera Mini which does not support web sockets
unreleased versions
last 7 years
>= 0.05%
not dead
not op_mini all
# Legacy builds are served when modern requirements are not met.
# Floors are pinned explicitly (not usage-based) so the support policy is
# deliberate and does not drift with global usage statistics, which do not
# represent old tablets and wall displays used as Home Assistant dashboards:
# - iOS/Safari >= 12: iPad Air 1 / mini 2 / mini 3 (last supported iOS).
# Older iPads (iPad 2/3/mini 1 on iOS 9.3, iPad 4 on iOS 10.3) cannot run
# the app: their engines lack custom elements, shadow DOM, and/or CSS grid.
# - Chrome >= 59: Fire OS 5 tablets (Fire 7/HD 8/HD 10 through ~2017) have
# their system WebView pinned at Chromium 59 and commonly run Fully Kiosk;
# also covers old kiosk browsers and no-longer-updating webviews above it.
# - Edge >= 79: all Chromium-based Edge. Costs nothing (above the Chrome
# floor); mainly catches Edge 109 on Windows 7/8.1 and update-frozen
# enterprise installs, whose engines can run the legacy build fine.
# - Firefox >= 94: the zero-cost floor, not a chased population — pinning it
# adds no babel transforms, core-js modules, or Lightning CSS prefixes to
# the output. Mozilla-supported Firefox (incl. current ESR) always matches
# [modern]; Firefox on old OSes is not supported (use Chrome 109 instead).
# If Firefox ever becomes the pin forcing extra output, raise it first.
# - Samsung >= 9: Samsung Internet on old Galaxy tablets
Chrome >= 59
ChromeAndroid >= 59
Edge >= 79
Firefox >= 94
FirefoxAndroid >= 94
iOS >= 12
Safari >= 12
Samsung >= 9
[legacy-sw]
# Same as legacy plus supports service workers
unreleased versions
last 7 years
>= 0.05% and supports serviceworkers
not dead
not op_mini all
# Same as legacy, restricted to browsers that support service workers
# (currently resolves to the same set; guards the service worker build if the legacy floor ever drops below them)
Chrome >= 59 and supports serviceworkers
ChromeAndroid >= 59 and supports serviceworkers
Edge >= 79 and supports serviceworkers
Firefox >= 94 and supports serviceworkers
FirefoxAndroid >= 94 and supports serviceworkers
iOS >= 12 and supports serviceworkers
Safari >= 12 and supports serviceworkers
Samsung >= 9 and supports serviceworkers
+2 -2
View File
@@ -32,12 +32,12 @@ jobs:
persist-credentials: false
- name: Initialize CodeQL
uses: github/codeql-action/init@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
uses: github/codeql-action/init@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
with:
languages: javascript-typescript
build-mode: none
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@5595ccaf912efad79be6eef63a5619ff05969be3 # v4.37.6
uses: github/codeql-action/analyze@ff2f1c621b7f889edc0d3c761ac2e6a3f8cdb0dd # v4.37.7
with:
category: "/language:javascript-typescript"
+8 -8
View File
@@ -1,15 +1,15 @@
{
"_comment": "Initial JS budget (raw/uncompressed bytes) for the cold-load critical entrypoints. Enforced by build-scripts/check-bundle-size.cjs in CI. Re-seed after an intentional change with `--update --headroom=<percent>`.",
"frontend-modern": {
"app": 585518,
"core": 57048,
"authorize": 552423,
"onboarding": 666818
"app": 576583,
"core": 54790,
"authorize": 543547,
"onboarding": 655556
},
"frontend-legacy": {
"app": 887384,
"core": 244482,
"authorize": 844995,
"onboarding": 1008816
"app": 717124,
"core": 181583,
"authorize": 699175,
"onboarding": 816133
}
}
+4 -2
View File
@@ -63,8 +63,10 @@ module.exports.htmlMinifierOptions = {
};
module.exports.terserOptions = ({ latestBuild, isTestBuild }) => ({
safari10: !latestBuild,
ecma: latestBuild ? 2015 : 5,
// Highest syntax the minifier may emit; it never downlevels. Every browser
// in [modern] is well past ES2020 (universal since spring 2020); the
// [legacy] floors (Chrome 59 / Safari 12) top out at ES2017.
ecma: latestBuild ? 2020 : 2017,
module: latestBuild,
format: { comments: false },
sourceMap: !isTestBuild,
-1
View File
@@ -57,7 +57,6 @@ export default [
"ha-control-select",
"ha-control-select-menu",
"ha-hs-color-picker",
"ha-color-temp-brightness-picker",
],
},
{
@@ -25,6 +25,9 @@ title: Button
<ha-button appearance="filled">
filled button
</ha-button>
<ha-button appearance="outlined">
outlined button
</ha-button>
<ha-button size="s">
small
@@ -65,7 +68,7 @@ Check the [webawesome documentation](https://webawesome.com/docs/components/butt
| Name | Type | Default | Description |
| ---------- | ---------------------------------------------- | -------- | --------------------------------------------------------------------------------- |
| appearance | "accent"/"filled"/"plain" | "accent" | Sets the button appearance. |
| appearance | "accent"/"filled"/"outlined"/"plain" | "accent" | Sets the button appearance. |
| variants | "brand"/"danger"/"neutral"/"warning"/"success" | "brand" | Sets the button color variant. "brand" is default. |
| size | "xs"/"s"/"m"/"l"/"xl" | "m" | Sets the button size. |
| loading | Boolean | false | Shows a loading indicator instead of the buttons label and disable buttons click. |
+1 -1
View File
@@ -9,7 +9,7 @@ import "../../../../src/components/ha-svg-icon";
import { mdiHomeAssistant } from "../../../../src/resources/home-assistant-logo-svg";
import { THEME_COMPARISON_PANELS } from "../../components/demo-theme-comparison";
const appearances = ["accent", "filled", "plain"];
const appearances = ["accent", "filled", "outlined", "plain"];
const variants = ["brand", "danger", "neutral", "warning", "success"];
@customElement("demo-components-ha-button")
@@ -1,4 +0,0 @@
---
title: Color temperature and brightness picker
subtitle: Dual-axis pad controlling color temperature and brightness together
---
@@ -1,102 +0,0 @@
import "../../../../src/components/ha-color-temp-brightness-picker";
import type { TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, state } from "lit/decorators";
import "../../../../src/components/ha-card";
import "../../../../src/components/ha-slider";
const MIN_KELVIN = 2000;
const MAX_KELVIN = 6500;
@customElement("demo-components-ha-color-temp-brightness-picker")
export class DemoHaColorTempBrightnessPicker extends LitElement {
@state()
value: [number, number] = [3000, 50];
@state()
liveValue?: [number, number];
private _valueCursor(ev) {
this.liveValue = ev.detail.value;
}
private _valueChanged(ev) {
this.value = ev.detail.value;
}
private _kelvinChanged(ev) {
this.value = [Number(ev.target.value), this.value[1]];
}
private _brightnessChanged(ev) {
this.value = [this.value[0], Number(ev.target.value)];
}
protected render(): TemplateResult {
const [kelvin, brightness] = this.liveValue ?? this.value;
return html`
<ha-card>
<div class="card-content">
<p class="value">${kelvin} K - ${brightness}%</p>
<ha-color-temp-brightness-picker
.value=${this.value}
.minKelvin=${MIN_KELVIN}
.maxKelvin=${MAX_KELVIN}
@value-changed=${this._valueChanged}
@cursor-moved=${this._valueCursor}
></ha-color-temp-brightness-picker>
<p>Color temperature: ${this.value[0]} K</p>
<ha-slider
labeled
step="1"
.min=${MIN_KELVIN}
.max=${MAX_KELVIN}
.value=${this.value[0]}
@change=${this._kelvinChanged}
>
</ha-slider>
<p>Brightness: ${this.value[1]}%</p>
<ha-slider
labeled
step="1"
min="1"
max="100"
.value=${this.value[1]}
@change=${this._brightnessChanged}
>
</ha-slider>
</div>
</ha-card>
`;
}
static styles = css`
ha-card {
max-width: 600px;
margin: 24px auto;
}
.card-content {
display: flex;
align-items: center;
flex-direction: column;
}
ha-color-temp-brightness-picker {
width: 320px;
height: 320px;
}
.value {
font-size: var(--ha-font-size-xl);
font-weight: var(--ha-font-weight-bold);
margin: 0 0 12px 0;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"demo-components-ha-color-temp-brightness-picker": DemoHaColorTempBrightnessPicker;
}
}
+125
View File
@@ -29,6 +29,50 @@ const positions: Position[] = ["start", "end"];
const selectedStates = [false, true];
const disabledStates = [false, true];
interface TreeChild {
key: string;
label: string;
}
interface TreeGroup {
key: string;
label: string;
children: TreeChild[];
}
const treeGroups: TreeGroup[] = [
{
key: "binary_sensor",
label: "Binary sensor",
children: [
{ key: "door", label: "Door" },
{ key: "motion", label: "Motion" },
{ key: "window", label: "Window" },
],
},
{
key: "cover",
label: "Cover",
children: [
{ key: "garage", label: "Garage" },
{ key: "shutter", label: "Shutter" },
],
},
];
interface TreeRow {
group: TreeGroup;
child?: TreeChild;
}
const treeRows: TreeRow[] = treeGroups.flatMap((group) => [
{ group },
...group.children.map((child) => ({ group, child })),
]);
const treeKey = (group: TreeGroup, child: TreeChild) =>
`${group.key}/${child.key}`;
@customElement("demo-components-ha-list")
export class DemoHaList extends LitElement {
@state() private _buttonClicks = 0;
@@ -41,6 +85,8 @@ export class DemoHaList extends LitElement {
@state() private _multiCheckEnd: number | Set<number> = new Set();
@state() private _tree = new Set<string>();
private _options = ["Alpha", "Beta", "Gamma", "Delta", "Epsilon"];
protected render(): TemplateResult {
@@ -274,6 +320,45 @@ selected: ${JSON.stringify(this._toJson(this._multiCheckStart))}</pre>
selected: ${JSON.stringify(this._toJson(this._multiCheckEnd))}</pre>
</ha-card>
<ha-card header="Controlled selection with indeterminate groups">
<ha-list-selectable
multi
controlled
aria-label="Controlled tree"
@ha-list-item-selected=${this._onTreeToggle}
@ha-list-item-deselected=${this._onTreeToggle}
>
${treeGroups.map((group) => {
const groupState = this._groupState(group);
return html`
<ha-list-item-option
appearance="checkbox"
selection-position="end"
.value=${group.key}
?selected=${groupState === "all"}
?indeterminate=${groupState === "some"}
>
<span slot="headline">${group.label}</span>
</ha-list-item-option>
${group.children.map(
(child) => html`
<ha-list-item-option
class="child"
appearance="checkbox"
selection-position="end"
.value=${treeKey(group, child)}
?selected=${this._tree.has(treeKey(group, child))}
>
<span slot="headline">${child.label}</span>
</ha-list-item-option>
`
)}
`;
})}
</ha-list-selectable>
<pre>selected: ${JSON.stringify([...this._tree])}</pre>
</ha-card>
<ha-card header="Option: all combinations">
<div class="grid">
${appearances.map((appearance) =>
@@ -361,6 +446,43 @@ selected: ${JSON.stringify(this._toJson(this._multiCheckEnd))}</pre>
return next;
}
private _groupState(group: TreeGroup): "none" | "some" | "all" {
const selected = group.children.filter((child) =>
this._tree.has(treeKey(group, child))
).length;
if (selected === 0) {
return "none";
}
return selected === group.children.length ? "all" : "some";
}
private _onTreeToggle = (ev: CustomEvent<number>) => {
const row = treeRows[ev.detail];
if (!row) {
return;
}
const next = new Set(this._tree);
if (row.child) {
const key = treeKey(row.group, row.child);
if (next.has(key)) {
next.delete(key);
} else {
next.add(key);
}
} else {
const select = this._groupState(row.group) !== "all";
row.group.children.forEach((child) => {
const key = treeKey(row.group, child);
if (select) {
next.add(key);
} else {
next.delete(key);
}
});
}
this._tree = next;
};
private _onSingle = (ev: CustomEvent<number>) => {
this._single = ev.detail;
};
@@ -443,6 +565,9 @@ selected: ${JSON.stringify(this._toJson(this._multiCheckEnd))}</pre>
.drag-handle {
cursor: grab;
}
.child::part(base) {
padding-inline-start: var(--ha-space-12);
}
`;
}
+11 -8
View File
@@ -42,14 +42,14 @@
"@babel/runtime": "8.0.0",
"@braintree/sanitize-url": "7.1.2",
"@codemirror/autocomplete": "6.20.3",
"@codemirror/commands": "6.10.4",
"@codemirror/commands": "6.11.0",
"@codemirror/lang-jinja": "6.0.1",
"@codemirror/lang-yaml": "6.1.3",
"@codemirror/language": "6.12.4",
"@codemirror/lint": "6.9.7",
"@codemirror/search": "6.7.1",
"@codemirror/state": "6.7.1",
"@codemirror/view": "6.43.8",
"@codemirror/view": "6.43.9",
"@date-fns/tz": "1.5.0",
"@egjs/hammerjs": "2.0.17",
"@formatjs/intl-datetimeformat": "7.6.0",
@@ -101,12 +101,13 @@
"deep-freeze": "0.0.1",
"dialog-polyfill": "0.5.6",
"echarts": "6.1.0",
"echarts-extension-chart2music": "0.1.0",
"element-internals-polyfill": "3.0.2",
"fuse.js": "7.5.0",
"hls.js": "1.7.0",
"hls.js": "1.7.1",
"home-assistant-js-websocket": "9.6.0",
"idb-keyval": "6.3.0",
"intl-messageformat": "11.2.13",
"intl-messageformat": "11.2.14",
"js-yaml": "5.3.0",
"leaflet": "1.9.4",
"leaflet-draw": "patch:leaflet-draw@npm%3A1.0.4#./.yarn/patches/leaflet-draw-npm-1.0.4-0ca0ebcf65.patch",
@@ -114,7 +115,7 @@
"lit": "3.3.3",
"lit-html": "3.3.3",
"luxon": "3.7.2",
"marked": "18.0.9",
"marked": "18.0.10",
"memoize-one": "6.0.0",
"node-vibrant": "4.0.4",
"object-hash": "3.0.0",
@@ -145,7 +146,7 @@
"@bundle-stats/plugin-webpack-filter": "4.22.2",
"@eslint/js": "10.0.1",
"@gfx/zopfli": "1.0.15",
"@html-eslint/eslint-plugin": "0.64.0",
"@html-eslint/eslint-plugin": "0.65.0",
"@lokalise/node-api": "16.3.0",
"@octokit/auth-oauth-device": "8.0.4",
"@octokit/plugin-retry": "8.1.1",
@@ -169,9 +170,10 @@
"@types/sortablejs": "1.15.9",
"@types/tar": "7.0.87",
"@typescript/native": "npm:[email protected]",
"@vitest/coverage-v8": "4.1.10",
"@vitest/coverage-v8": "4.1.11",
"babel-loader": "10.1.1",
"babel-plugin-polyfill-corejs3": "1.0.0",
"browserslist": "4.28.8",
"browserslist-useragent-regexp": "4.1.4",
"del": "8.0.1",
"eslint": "10.8.1",
@@ -195,6 +197,7 @@
"jsdom": "30.0.1",
"jszip": "3.10.1",
"license-checker-rseidelsohn": "5.0.1",
"lightningcss": "1.33.0",
"lint-staged": "17.3.0",
"lit-analyzer": "2.0.3",
"lodash.merge": "4.6.2",
@@ -212,7 +215,7 @@
"typescript": "6.0.3",
"typescript-eslint": "8.67.0",
"vite-tsconfig-paths": "6.1.1",
"vitest": "4.1.10",
"vitest": "4.1.11",
"webpack-stats-plugin": "1.1.3",
"webpackbar": "7.0.0",
"workbox-build": "patch:workbox-build@npm%3A7.4.1#~/.yarn/patches/workbox-build-npm-7.4.1-c84561662c.patch"
@@ -0,0 +1,217 @@
import type {
ReactiveController,
ReactiveControllerHost,
} from "@lit/reactive-element/reactive-controller";
import { css, type LitElement } from "lit";
import type { Ref } from "lit/directives/ref";
import { parseAnimationDuration } from "../util/parse-animation-duration";
type FilterPanelHost = ReactiveControllerHost &
LitElement & { expanded: boolean };
const EASING = "cubic-bezier(0.4, 0, 0.2, 1)";
/**
* Layout the controller relies on: the filter is a flex column made of its
* header (`ha-expansion-panel`) and a `.content` wrapper. Collapsed, it is as
* tall as its header; expanded, it fills what is left of the pane and hands
* that space down to the list through the wrapper.
*/
export const filterPanelStyles = css`
:host {
display: flex;
flex-direction: column;
box-sizing: border-box;
border-bottom: 1px solid var(--divider-color);
}
:host([expanded]) {
flex: 1;
height: 0;
}
ha-expansion-panel {
flex: none;
--ha-card-border-radius: var(--ha-border-radius-square);
}
ha-expansion-panel::part(summary) {
-webkit-user-select: none;
user-select: none;
}
.content {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
}
`;
const panels = new Set<FilterPanelController>();
let pending: Set<FilterPanelController> | undefined;
const flush = () => {
const batch = [...pending!].filter((panel) => panel.host.isConnected);
pending = undefined;
batch.forEach((panel) => panel.prepare());
batch.forEach((panel) => panel.measure());
batch.forEach((panel) => panel.play());
};
// Filters that change in the same frame (one closing while another opens) are
// animated as one batch: every filter is measured before any of them has
// changed the DOM, and the closing ones are parked at their final height so
// the opening one reads its own final height from the real layout.
const enqueue = (panel: FilterPanelController) => {
if (!pending) {
pending = new Set();
panels.forEach((other) => other.snapshot());
requestAnimationFrame(flush);
}
pending.add(panel);
};
const clearInlineStyles = (element?: HTMLElement) => {
element?.style.removeProperty("height");
element?.style.removeProperty("flex");
element?.style.removeProperty("overflow");
};
/**
* Animates a filter of the filter pane between its collapsed and expanded
* heights whenever `expanded` changes, and tells the host when to render its
* content: from the moment it expands until its collapse animation has ended.
*
* During the animation the content keeps its final size and the host clips
* it, so the list is revealed rather than resized.
*/
export class FilterPanelController implements ReactiveController {
public showContent = false;
public host: FilterPanelHost;
private _content: Ref<HTMLElement>;
private _expanded?: boolean;
private _first = 0;
private _last = 0;
private _contentHeight = 0;
private _animation?: Animation;
constructor(host: FilterPanelHost, content: Ref<HTMLElement>) {
this.host = host;
this._content = content;
host.addController(this);
}
public hostConnected() {
panels.add(this);
}
public hostDisconnected() {
panels.delete(this);
this._animation?.cancel();
this._animation = undefined;
clearInlineStyles(this.host);
clearInlineStyles(this._content.value);
}
public hostUpdate() {
const expanded = this.host.expanded;
if (this._expanded === undefined) {
this._expanded = expanded;
this.showContent = expanded;
return;
}
if (expanded === this._expanded) {
return;
}
this._expanded = expanded;
if (!this.host.isConnected) {
this.showContent = expanded;
return;
}
if (expanded) {
this.showContent = true;
}
enqueue(this);
}
public snapshot() {
this._first = this.host.getBoundingClientRect().height;
}
public prepare() {
this._animation?.cancel();
this._animation = undefined;
const host = this.host;
const content = this._content.value;
clearInlineStyles(host);
clearInlineStyles(content);
if (host.expanded) {
return;
}
this._last =
host.getBoundingClientRect().height -
(content?.getBoundingClientRect().height ?? 0);
this._contentHeight = this._first - this._last;
host.style.flex = "none";
host.style.height = `${this._last}px`;
}
public measure() {
if (!this.host.expanded) {
return;
}
this._last = this.host.getBoundingClientRect().height;
this._contentHeight =
this._content.value?.getBoundingClientRect().height ?? 0;
}
public play() {
const host = this.host;
if (this._first === this._last) {
this._finish();
return;
}
const content = this._content.value;
host.style.flex = "none";
host.style.overflow = "hidden";
if (content) {
content.style.flex = "none";
content.style.height = `${this._contentHeight}px`;
}
const animation = host.animate(
[{ height: `${this._first}px` }, { height: `${this._last}px` }],
{
duration:
parseAnimationDuration(
getComputedStyle(host).getPropertyValue(
"--ha-animation-duration-normal"
)
) || 250,
easing: EASING,
fill: "forwards",
}
);
animation.onfinish = () => this._finish(animation);
this._animation = animation;
}
private async _finish(animation?: Animation) {
if (!this.host.expanded) {
this.showContent = false;
this.host.requestUpdate();
await this.host.updateComplete;
}
if (this._animation !== animation) {
return;
}
clearInlineStyles(this.host);
clearInlineStyles(this._content.value);
this._animation?.cancel();
this._animation = undefined;
}
}
+268
View File
@@ -0,0 +1,268 @@
import type { HassConfig } from "home-assistant-js-websocket";
import type { EChartsType } from "echarts/core";
import type { XAXisOption, YAXisOption } from "echarts/types/dist/shared";
import { ensureArray } from "../../common/array/ensure-array";
import { formatDateTime } from "../../common/datetime/format_date_time";
import type { LocalizeFunc } from "../../common/translations/localize";
import type { FrontendLocaleData } from "../../data/translation";
import type {
HaECSeries,
HaECSeriesItem,
} from "../../resources/echarts/echarts";
export interface ChartSonification {
update: () => void;
dispose: () => void;
}
// Series types the Chart2Music ECharts extension can turn into data points. Our
// other series (custom timelines, sankey, network graphs) have no equivalent, and
// the extension refuses the whole chart if a single series is unsupported.
const SONIFIABLE_SERIES_TYPES = new Set(["bar", "line", "pie", "scatter"]);
// Languages shipped by chart2music. Anything else falls back to its English.
const SONIFICATION_LANGUAGES = new Set(["de", "en", "es", "fr", "hmn", "it"]);
// Fewer than this and there is nothing to walk between, so a focus stop would
// lead nowhere.
const MIN_NAVIGABLE_POINTS = 2;
// Mirrors the extension's own reading of a point: it takes `value` as [x, y] and
// drops anything whose y is not a real number. That rejects gap-only series, and
// also value-first pairs like the energy device charts' [amount, "sensor.foo"].
// Counts no further than `limit` so this stays cheap on charts with many points.
const countNumericPoints = (data: unknown, limit: number): number => {
if (!Array.isArray(data)) {
return 0;
}
let found = 0;
for (const raw of data) {
let y: unknown = raw;
if (Array.isArray(raw)) {
y = raw.length > 1 ? raw[1] : raw[0];
} else if (raw && typeof raw === "object") {
const { value } = raw as { value?: unknown };
y = Array.isArray(value)
? value.length > 1
? value[1]
: value[0]
: value;
}
if (typeof y === "number" && !Number.isNaN(y)) {
found += 1;
if (found >= limit) {
break;
}
}
}
return found;
};
const countNavigablePoints = (
series: readonly ({ data?: unknown } | undefined)[]
): number => {
let total = 0;
for (const s of series) {
total += countNumericPoints(s?.data, MIN_NAVIGABLE_POINTS - total);
if (total >= MIN_NAVIGABLE_POINTS) {
break;
}
}
return total;
};
export const canSonifyChart = (
data: HaECSeries,
// Legend-hidden series reach ECharts with their data stripped, so they cannot
// be sonified either.
hiddenDatasets?: ReadonlySet<string>
): boolean => {
const series = ensureArray(data);
const visible = hiddenDatasets?.size
? series.filter((s) => !hiddenDatasets.has(String(s.id ?? s.name)))
: series;
return (
// Cards commonly push empty placeholder series, so judge the chart by the
// points the extension can actually read — but every type has to be
// convertible too.
countNavigablePoints(visible) >= MIN_NAVIGABLE_POINTS &&
series.every((s) => SONIFIABLE_SERIES_TYPES.has(s.type as string))
);
};
interface SonifyChartOptions {
cc: HTMLElement;
localize: LocalizeFunc;
locale: FrontendLocaleData;
config: HassConfig;
onError: (error: string) => void;
}
// Chart2Music appends its help and options dialogs straight to document.body, so
// they can only be themed from a document-level stylesheet.
let stylesAppended = false;
const appendSonificationStyles = () => {
if (stylesAppended) {
return;
}
stylesAppended = true;
const style = document.createElement("style");
style.textContent = `
dialog.chart2music-dialog {
box-sizing: border-box;
max-width: min(600px, calc(100vw - 32px));
max-height: calc(100vh - 32px);
overflow: auto;
padding: var(--ha-space-6);
border: none;
border-radius: var(--ha-border-radius-lg);
background-color: var(--card-background-color);
color: var(--primary-text-color);
font-family: var(--ha-font-family-body);
font-size: var(--ha-font-size-m);
box-shadow: var(--ha-box-shadow-l);
}
dialog.chart2music-dialog::backdrop {
background-color: rgba(0, 0, 0, 0.5);
}
dialog.chart2music-dialog h1 {
font-size: var(--ha-font-size-2xl);
font-weight: var(--ha-font-weight-normal);
margin-block: 0 var(--ha-space-4);
padding-inline-end: var(--ha-space-8);
}
dialog.chart2music-dialog table {
border-collapse: collapse;
width: 100%;
}
dialog.chart2music-dialog th,
dialog.chart2music-dialog td {
text-align: start;
padding: var(--ha-space-1) var(--ha-space-2);
border-bottom: 1px solid var(--divider-color);
}
dialog.chart2music-dialog a {
color: var(--primary-color);
}
dialog.chart2music-dialog > button {
/* The extension inlines "right", which does not mirror in RTL, and inline
styles can only be beaten with !important. */
inset-inline-end: var(--ha-space-4) !important;
inset-inline-start: auto !important;
top: var(--ha-space-4);
min-width: 32px;
min-height: 32px;
cursor: pointer;
border: 1px solid var(--divider-color);
border-radius: var(--ha-border-radius-sm);
background-color: transparent;
color: var(--primary-text-color);
font: inherit;
}
`;
document.head.append(style);
};
export const sonifyChart = async (
chart: EChartsType,
options: SonifyChartOptions
): Promise<ChartSonification | null> => {
const { connect } = await import("echarts-extension-chart2music");
const { localize, locale, config } = options;
appendSonificationStyles();
// ECharts nulls its model on dispose, and the instance can be disposed while
// the chunk is in flight.
const chartOptions = chart.getOption() as ReturnType<
EChartsType["getOption"]
> | null;
if (!chartOptions) {
return null;
}
const xAxis = ensureArray(chartOptions.xAxis)[0] as XAXisOption | undefined;
const yAxis = ensureArray(chartOptions.yAxis)[0] as YAXisOption | undefined;
// Chart2Music throws while validating a group with no points, which is what
// placeholder, legend-hidden and all-null series turn into, so only offer it
// the series carrying points it can read.
const allSeries = ensureArray(chartOptions.series) as (
HaECSeriesItem | undefined
)[];
const readable = allSeries.filter((s) => countNumericPoints(s?.data, 1));
// A single point is not navigable, so it does not earn a focus stop either.
if (countNavigablePoints(readable) < MIN_NAVIGABLE_POINTS) {
return null;
}
const seriesIndex = readable.map((s) => allSeries.indexOf(s));
// Chart2Music always reads out an axis label, and the extension picks the wrong
// axis to name when there is no category axis, so label both explicitly.
const isTimeAxis = xAxis?.type === "time";
const x = {
label:
xAxis?.name ||
localize(
isTimeAxis
? "ui.components.history_charts.time"
: "ui.components.history_charts.category"
),
// Time series carry raw timestamps, which would otherwise be announced as
// epoch milliseconds.
format: isTimeAxis
? (value: number) => formatDateTime(new Date(value), locale, config)
: undefined,
};
const y = {
label: yAxis?.name || localize("ui.components.history_charts.value"),
};
let connection: ReturnType<typeof connect>;
try {
connection = connect(chart, {
cc: options.cc,
seriesIndex,
title: localize("ui.components.history_charts.chart"),
lang: SONIFICATION_LANGUAGES.has(locale.language)
? locale.language
: "en",
errorCallback: options.onError,
axes: { x, y },
});
} catch (err) {
options.onError(err instanceof Error ? err.message : String(err));
return null;
}
if (!connection) {
return null;
}
// Chart2Music bails out silently on mobile user agents, returning an instance
// that never wired anything up. Turning the caption container into a live
// region is the last thing it does, so use that as the "really connected" test
// rather than leaving a focus stop that does nothing.
if (!options.cc.hasAttribute("aria-live")) {
connection.dispose();
return null;
}
const connected = connection;
// The extension re-reads the chart from ECharts' own "finished" event. Run that
// through a guard of our own so a conversion failure cannot escape into
// ECharts' event dispatch and leave the chart half-rendered.
const update = () => {
try {
connected.update();
} catch (_err) {
// Keep whatever Chart2Music last read successfully.
}
};
chart.off("finished", connected.update);
chart.on("finished", update);
return {
update,
dispose: () => {
chart.off("finished", update);
connected.dispose();
},
};
};
+120 -1
View File
@@ -22,6 +22,7 @@ import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { ifDefined } from "lit/directives/if-defined";
import { styleMap } from "lit/directives/style-map";
import { ensureArray } from "../../common/array/ensure-array";
import { getAllGraphColors } from "../../common/color/colors";
@@ -47,6 +48,8 @@ import { isMac } from "../../util/is_mac";
import "../chips/ha-assist-chip";
import "../ha-icon-button";
import { formatTimeLabel } from "./axis-label";
import type { ChartSonification } from "./chart-sonification";
import { canSonifyChart, sonifyChart } from "./chart-sonification";
import { downSampleLineData } from "./down-sample";
import { wrapLitTooltipFormatter } from "./lit-tooltip-formatter";
@@ -146,6 +149,17 @@ export class HaChartBase extends LitElement {
@query(".chart") private _chartContainer?: HTMLDivElement;
@query(".sonification-output")
private _sonificationOutput?: HTMLDivElement;
private _sonification?: ChartSonification;
@state() private _sonificationLoading = false;
@state() private _sonificationUnavailable = false;
@state() private _sonificationFocusHeld = false;
private _modifierPressed = false;
private _isTouchDevice = "ontouchstart" in window;
@@ -198,6 +212,7 @@ export class HaChartBase extends LitElement {
while (this._listeners.length) {
this._listeners.pop()!();
}
this._disposeSonification();
this.chart?.dispose();
this.chart = undefined;
this._originalZrFlush = undefined;
@@ -312,6 +327,18 @@ export class HaChartBase extends LitElement {
}
if (changedProps.has("data") || changedProps.has("_hiddenDatasets")) {
chartOptions.series = this._getSeries();
// New data, or a series shown again, may well be convertible where the
// last set was not.
this._sonificationUnavailable = false;
// The connection is built from the series that had data at the time, so
// drop it and let the next focus rebuild it against the current set.
if (
this._sonification &&
(changedProps.has("_hiddenDatasets") ||
!canSonifyChart(this.data, this._hiddenDatasets))
) {
this._disposeSonification();
}
}
if (changedProps.has("options")) {
chartOptions = { ...chartOptions, ...this._createOptions() };
@@ -337,6 +364,9 @@ export class HaChartBase extends LitElement {
}
protected render() {
const sonifiable =
!this._sonificationUnavailable &&
canSonifyChart(this.data, this._hiddenDatasets);
return html`
<div
class="container ${classMap({ "has-height": !!this.height })}"
@@ -348,8 +378,23 @@ export class HaChartBase extends LitElement {
height: this.height ? undefined : `${this._getDefaultHeight()}px`,
})}
>
<div class="chart"></div>
<div
class="chart"
role=${ifDefined(sonifiable ? "application" : undefined)}
tabindex=${ifDefined(
sonifiable ? "0" : this._sonificationFocusHeld ? "-1" : undefined
)}
aria-label=${ifDefined(
sonifiable
? this.hass.localize("ui.components.history_charts.chart")
: undefined
)}
aria-busy=${ifDefined(this._sonificationLoading ? "true" : undefined)}
@focus=${this._handleChartFocus}
@blur=${this._handleChartBlur}
></div>
</div>
<div class="sonification-output"></div>
${this._renderLegend()}
<div class="top-controls ${classMap({ small: this.smallControls })}">
<slot name="search"></slot>
@@ -521,6 +566,60 @@ export class HaChartBase extends LitElement {
</div>`;
}
// Chart2Music adds ~45 kB gzipped, so it is only fetched once someone actually
// moves keyboard focus into a chart.
private async _handleChartFocus() {
// Dropping tabindex off the active element resets focus to the document and
// costs the user their place in the tab order, so stay programmatically
// focusable for as long as we hold focus, however we stop being sonifiable.
this._sonificationFocusHeld = true;
if (this._sonification || this._sonificationLoading || !this.chart) {
return;
}
this._sonificationLoading = true;
try {
const sonification = await sonifyChart(this.chart, {
cc: this._sonificationOutput!,
localize: this.hass.localize,
locale: this.hass.locale,
config: this.hass.config,
onError: () => {
// Charts the extension cannot describe stay silent rather than
// dropping an error on someone who only pressed Tab.
},
});
if (!this.isConnected || !this.chart) {
sonification?.dispose();
return;
}
if (!sonification) {
// Nothing came back, so stop offering a focus stop that leads nowhere.
this._sonificationUnavailable = true;
return;
}
this._sonification = sonification;
if (this.shadowRoot?.activeElement === this._chartContainer) {
// Chart2Music reads its summary and key hints on focus, which already
// happened while it was still being fetched.
this._chartContainer!.dispatchEvent(new FocusEvent("focus"));
}
} catch (_err) {
// Never let a failure here escape a focus handler. The tab stop stays, so
// focusing the chart again retries.
} finally {
this._sonificationLoading = false;
}
}
private _handleChartBlur() {
this._sonificationFocusHeld = false;
}
private _disposeSonification() {
this._sonification?.dispose();
this._sonification = undefined;
}
private _formatTimeLabel = (value: number | Date) =>
formatTimeLabel(
value,
@@ -533,6 +632,9 @@ export class HaChartBase extends LitElement {
if (this._loading) return;
this._loading = true;
try {
// The connection holds a reference to the chart instance, so it cannot
// outlive it. Focusing the chart again reconnects.
this._disposeSonification();
if (this.chart) {
this.chart.dispose();
}
@@ -1450,6 +1552,23 @@ export class HaChartBase extends LitElement {
height: 100%;
width: 100%;
}
.chart:focus-visible {
outline: 2px solid var(--primary-color);
outline-offset: 2px;
border-radius: var(--ha-border-radius-sm);
}
/* Chart2Music renders its announcements here. It must stay in the layout for
screen readers to pick up the live region, so hide it visually only. */
.sonification-output {
position: absolute;
overflow: hidden;
clip: rect(0 0 0 0);
height: 1px;
width: 1px;
margin: -1px;
padding: 0;
border: 0;
}
.top-controls {
position: absolute;
top: var(--ha-space-4);
+10 -7
View File
@@ -163,12 +163,15 @@ export function generateStatisticsChartData(
return;
}
const isLineChart = chartType === "line";
// Points carry their time as epoch milliseconds, not Date objects:
// ECharts accepts both, but Chart2Music only reads a numeric x, and a
// Date would make it announce points by index instead of time.
// For bar charts, optionally center the bar within its time range. The
// centered time is shared by every series of this data point.
const barTime =
!isLineChart && centerBars
? new Date((start.getTime() + end.getTime()) / 2)
: start;
? (start.getTime() + end.getTime()) / 2
: start.getTime();
// Whether a gap needs to be drawn before this data point (line charts).
const drawGap =
isLineChart &&
@@ -182,10 +185,10 @@ export function generateStatisticsChartData(
if (drawGap) {
// if the end of the previous data doesn't match the start of the current data,
// we have to draw a gap so add a value at the end time, and then an empty value.
d.data!.push([prevEndTime!, ...prevValues![i]!]);
d.data!.push([prevEndTime!, null]);
d.data!.push([prevEndTime!.getTime(), ...prevValues![i]!]);
d.data!.push([prevEndTime!.getTime(), null]);
}
d.data!.push([start, ...dataValue!]);
d.data!.push([start.getTime(), ...dataValue!]);
// For band-top rows dataValues[i] is [diff, top]; the actual Y is
// the last element. For regular rows it's [value]. Same call works.
trackY(dataValue[dataValue.length - 1]);
@@ -387,7 +390,7 @@ export function generateStatisticsChartData(
const lastValues = prevValues;
if (chartType === "line" && lastEndTime && lastValues) {
statDataSets.forEach((d, i) => {
d.data!.push([lastEndTime, ...lastValues[i]!]);
d.data!.push([lastEndTime.getTime(), ...lastValues[i]!]);
});
}
@@ -423,7 +426,7 @@ export function generateStatisticsChartData(
} else {
val.push(currentValue);
}
statDataSets[i].data!.push([now, ...val]);
statDataSets[i].data!.push([now.getTime(), ...val]);
trackY(val[val.length - 1]);
});
}
+141
View File
@@ -0,0 +1,141 @@
import { mdiAlertOctagram, mdiCheckBold } from "@mdi/js";
import { css, html, LitElement, nothing } from "lit";
import { customElement, state } from "lit/decorators";
import "./ha-spinner";
import "./ha-svg-icon";
type ActionResult = "success" | "error";
// Keep in sync with ha-progress-button
const RESULT_DURATION = 2000;
// Long enough that fast actions go straight to their result without a flash
const SPINNER_DELAY = 150;
/**
* Home Assistant action result component
*
* @element ha-action-result
*
* @summary
* Wraps the content of an action trigger, for example an `ha-control-button`,
* and swaps it for a spinner while a slow action runs and for a success or
* error icon once it settles.
*
* @slot - Content of the trigger.
*/
@customElement("ha-action-result")
export class HaActionResult extends LitElement {
@state() private _loading = false;
@state() private _showSpinner = false;
@state() private _result?: ActionResult;
private _timeout?: number;
private _spinnerTimeout?: number;
public get busy(): boolean {
return this._loading;
}
public async run(action: Promise<unknown>): Promise<void> {
clearTimeout(this._timeout);
clearTimeout(this._spinnerTimeout);
this._result = undefined;
this._loading = true;
this._spinnerTimeout = window.setTimeout(() => {
this._showSpinner = true;
}, SPINNER_DELAY);
try {
await action;
this._result = "success";
} catch (_err) {
this._result = "error";
} finally {
clearTimeout(this._spinnerTimeout);
this._loading = false;
this._showSpinner = false;
this._timeout = window.setTimeout(() => {
this._result = undefined;
}, RESULT_DURATION);
}
}
public disconnectedCallback(): void {
super.disconnectedCallback();
clearTimeout(this._timeout);
clearTimeout(this._spinnerTimeout);
this._showSpinner = false;
this._result = undefined;
}
protected render() {
const busy = this._showSpinner || this._result !== undefined;
return html`
<span class="content ${busy ? "hidden" : ""}"><slot></slot></span>
${
busy
? html`<div class="indicator">${this._renderIndicator()}</div>`
: nothing
}
`;
}
private _renderIndicator() {
if (!this._result) {
return html`<ha-spinner></ha-spinner>`;
}
return html`
<ha-svg-icon
class=${this._result}
.path=${this._result === "success" ? mdiCheckBold : mdiAlertOctagram}
></ha-svg-icon>
`;
}
static styles = css`
/* Prefer no box so the host inherits the layout of the slot it sits in.
A ::slotted() rule in the host component can still override this. */
:host {
display: contents;
}
.content {
transition: opacity var(--ha-animation-duration-instant) ease-in-out;
}
.content.hidden {
opacity: 0;
}
.indicator {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
animation: fade-in var(--ha-animation-duration-instant) ease-in-out;
}
ha-spinner {
--ha-spinner-size: var(--mdc-icon-size, 24px);
--track-width: 2px;
}
/* Overshoot so the icon lands with a small pop */
ha-svg-icon {
animation: scale var(--ha-animation-duration-fast)
cubic-bezier(0.34, 1.56, 0.64, 1);
}
ha-svg-icon.success {
color: var(--ha-color-on-success-quiet);
}
ha-svg-icon.error {
color: var(--ha-color-on-danger-quiet);
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-action-result": HaActionResult;
}
}
+63 -9
View File
@@ -12,6 +12,7 @@ import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import { transform } from "../common/decorators/transform";
import { supportsFeature } from "../common/entity/supports-feature";
import type { LocalizeFunc } from "../common/translations/localize";
import {
@@ -24,6 +25,7 @@ import {
import {
configContext,
connectionContext,
internationalizationContext,
statesContext,
} from "../data/context";
import { ConversationEntityFeature } from "../data/conversation";
@@ -33,8 +35,13 @@ import type {
HomeAssistant,
HomeAssistantConfig,
HomeAssistantConnection,
HomeAssistantInternationalization,
} from "../types";
import { AudioRecorder } from "../util/audio-recorder";
import {
findAvailableLanguage,
getTranslation,
} from "../util/common-translation";
import { documentationUrl } from "../util/documentation-url";
import "./ha-alert";
import "./ha-markdown";
@@ -67,6 +74,17 @@ export const assistPipelineChanged = (
current: AssistPipeline | undefined
): boolean => previous?.id !== current?.id;
export const greetingTranslationLanguage = (
pipelineLanguage: string | undefined,
interfaceLanguage: string | undefined
): string | undefined => {
if (!pipelineLanguage || pipelineLanguage === interfaceLanguage) {
return undefined;
}
const language = findAvailableLanguage(pipelineLanguage);
return language && language !== interfaceLanguage ? language : undefined;
};
@customElement("ha-assist-chat")
export class HaAssistChat extends LitElement {
@property({ attribute: false }) public pipeline?: AssistPipeline;
@@ -101,6 +119,13 @@ export class HaAssistChat extends LitElement {
@consumeLocalize()
private _localize!: LocalizeFunc;
@state()
@consume({ context: internationalizationContext, subscribe: true })
@transform<HomeAssistantInternationalization, string>({
transformer: ({ language }) => language,
})
private _language!: string;
@state()
@consume({ context: statesContext, subscribe: true })
private _states!: HomeAssistant["states"];
@@ -115,6 +140,8 @@ export class HaAssistChat extends LitElement {
private _conversationId: string | null = null;
private _greetingLoadToken = 0;
private _initialPromptSubmitted = false;
private _audioRecorder?: AudioRecorder;
@@ -131,17 +158,44 @@ export class HaAssistChat extends LitElement {
(changedProperties.has("pipeline") &&
assistPipelineChanged(changedProperties.get("pipeline"), this.pipeline))
) {
this._conversation = [
{
who: "hass",
text: this._localize("ui.dialogs.voice_command.how_can_i_help"),
thinking: "",
tool_calls: {},
},
];
this._conversation = [];
this._loadGreeting();
}
}
private async _loadGreeting(): Promise<void> {
const token = ++this._greetingLoadToken;
const language = greetingTranslationLanguage(
this.pipeline?.language,
this._language
);
let greeting: string | undefined;
if (language) {
try {
const result = await getTranslation(null, language, false);
if (result.language === language) {
greeting = result.data["ui.dialogs.voice_command.how_can_i_help"];
}
} catch (_err) {
// Translation failed to load; fall back to the interface language.
}
}
if (token !== this._greetingLoadToken) {
// The pipeline changed while loading; a newer load owns the greeting.
return;
}
this._conversation = [
{
who: "hass",
text:
greeting || this._localize("ui.dialogs.voice_command.how_can_i_help"),
thinking: "",
tool_calls: {},
},
...this._conversation,
];
}
protected firstUpdated(changedProperties: PropertyValues<this>): void {
super.firstUpdated(changedProperties);
if (
@@ -157,7 +211,7 @@ export class HaAssistChat extends LitElement {
protected updated(changedProps: PropertyValues) {
super.updated(changedProps);
if (changedProps.has("_conversation")) {
if (changedProps.has("_conversation") && this._conversation.length) {
this._scrollMessagesBottom();
}
if (
-161
View File
@@ -1,161 +0,0 @@
import type { HassEntity } from "home-assistant-js-websocket";
import type { CSSResultGroup, PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { computeAttributeNameDisplay } from "../common/entity/compute_attribute_display";
import { computeStateDomain } from "../common/entity/compute_state_domain";
import {
STATE_ATTRIBUTES,
STATE_ATTRIBUTES_DOMAIN_CLASS,
} from "../data/entity/entity_attributes";
import { haStyle } from "../resources/styles";
import type { HomeAssistant } from "../types";
import "./ha-attribute-value";
import "./ha-expansion-panel";
@customElement("ha-attributes")
class HaAttributes extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public stateObj?: HassEntity;
@property({ attribute: "extra-filters" }) public extraFilters?: string;
@state() private _expanded = false;
private get _filteredAttributes() {
return this._computeDisplayAttributes(
STATE_ATTRIBUTES.concat(
this.extraFilters ? this.extraFilters.split(",") : [],
(this.stateObj &&
STATE_ATTRIBUTES_DOMAIN_CLASS[computeStateDomain(this.stateObj)]?.[
this.stateObj.attributes?.device_class
]) ||
[]
)
);
}
protected willUpdate(changedProperties: PropertyValues<this>): void {
if (
changedProperties.has("extraFilters") ||
changedProperties.has("stateObj")
) {
this.toggleAttribute("empty", this._filteredAttributes.length === 0);
}
}
protected render() {
if (!this.stateObj) {
return nothing;
}
const attributes = this._filteredAttributes;
if (attributes.length === 0) {
return nothing;
}
return html`
<ha-expansion-panel
.header=${this.hass.localize(
"ui.components.attributes.expansion_header"
)}
outlined
@expanded-will-change=${this._expandedChanged}
>
<div class="attribute-container">
${
this._expanded
? html`
${attributes.map(
(attribute) => html`
<div class="data-entry">
<div class="key">
${computeAttributeNameDisplay(
this.hass.localize,
this.stateObj!,
this.hass.entities,
attribute
)}
</div>
<div class="value">
<ha-attribute-value
.attribute=${attribute}
.stateObj=${this.stateObj}
></ha-attribute-value>
</div>
</div>
`
)}
`
: ""
}
</div>
</ha-expansion-panel>
${
this.stateObj.attributes.attribution
? html`
<div class="attribution">
${this.stateObj.attributes.attribution}
</div>
`
: ""
}
`;
}
static get styles(): CSSResultGroup {
return [
haStyle,
css`
.attribute-container {
margin-bottom: 8px;
direction: ltr;
}
.data-entry {
display: flex;
flex-direction: row;
justify-content: space-between;
}
.data-entry .value {
max-width: 60%;
overflow-wrap: break-word;
text-align: right;
}
.key {
flex-grow: 1;
}
.attribution {
color: var(--secondary-text-color);
text-align: center;
margin-top: 16px;
}
hr {
border-color: var(--divider-color);
border-bottom: none;
margin: 16px 0;
}
`,
];
}
private _computeDisplayAttributes(filtersArray: string[]): string[] {
if (!this.stateObj) {
return [];
}
return Object.keys(this.stateObj.attributes).filter(
(key) => filtersArray.indexOf(key) === -1
);
}
private _expandedChanged(ev) {
this._expanded = ev.detail.expanded;
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-attributes": HaAttributes;
}
}
+2 -1
View File
@@ -29,7 +29,7 @@ export type Appearance = "accent" | "filled" | "outlined" | "plain";
*
* @attr {("xs"|"s"|"m"|"l"|"xl")} size - Sets the button size.
* @attr {("brand"|"neutral"|"danger"|"warning"|"success")} variant - Sets the button color variant. "primary" is default.
* @attr {("accent"|"filled"|"plain")} appearance - Sets the button appearance.
* @attr {("accent"|"filled"|"outlined"|"plain")} appearance - Sets the button appearance.
* @attr {boolean} loading - shows a loading indicator instead of the buttons label and disable buttons click.
* @attr {boolean} disabled - Disables the button and prevents user interaction.
*/
@@ -199,6 +199,7 @@ export class HaButton extends Button {
:host([appearance~="outlined"]) .button.disabled {
background-color: transparent;
color: var(--ha-color-on-disabled-quiet);
border-color: var(--ha-color-on-disabled-quiet);
}
@media (hover: hover) {
@@ -1,315 +0,0 @@
import { DIRECTION_ALL, Manager, Pan, Tap } from "@egjs/hammerjs";
import type { PropertyValues } from "lit";
import { css, html, LitElement } from "lit";
import memoizeOne from "memoize-one";
import { customElement, property, query, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { styleMap } from "lit/directives/style-map";
import { rgb2hex } from "../common/color/convert-color";
import {
DEFAULT_MAX_KELVIN,
DEFAULT_MIN_KELVIN,
temperature2rgb,
} from "../common/color/convert-light-color";
import { fireEvent } from "../common/dom/fire_event";
import { mainWindow } from "../common/dom/get_main_window";
import { generateColorTemperatureGradient } from "../dialogs/more-info/components/lights/light-color-temp-picker";
const clamp = (value: number, min: number, max: number) =>
Math.min(max, Math.max(min, value));
// Dark end of the vertical brightness gradient; not fully black so the
// temperature hue stays visible at the bottom of the pad
const PAD_DARK_COLOR = "rgb(26, 26, 26)";
// The marker color's luminance scales with brightness down to this floor,
// so the marker never renders fully black at minimum brightness
const MARKER_LUMINANCE_FLOOR = 0.2;
@customElement("ha-color-temp-brightness-picker")
export class HaColorTempBrightnessPicker extends LitElement {
@property({ type: Boolean, reflect: true })
public disabled = false;
/** [color temperature in kelvin, brightness percentage] */
@property({ attribute: false })
public value?: [number, number];
@property({ attribute: false })
public minKelvin?: number;
@property({ attribute: false })
public maxKelvin?: number;
@query("#pad") private _pad?: HTMLElement;
private _mc?: HammerManager;
@state()
private _pressed?: string;
// Normalized physical position on the pad, [0, 1] left-to-right/top-to-bottom
@state()
private _cursorPosition?: [number, number];
@state()
private _localValue?: [number, number];
private get _minKelvin() {
return this.minKelvin ?? DEFAULT_MIN_KELVIN;
}
private get _maxKelvin() {
return this.maxKelvin ?? DEFAULT_MAX_KELVIN;
}
protected firstUpdated(changedProps: PropertyValues<this>): void {
super.firstUpdated(changedProps);
this._setupListeners();
}
connectedCallback(): void {
super.connectedCallback();
this._setupListeners();
}
disconnectedCallback(): void {
super.disconnectedCallback();
this._destroyListeners();
}
protected willUpdate(changedProps: PropertyValues<this>): void {
super.willUpdate(changedProps);
const rangeChanged =
changedProps.has("minKelvin") || changedProps.has("maxKelvin");
if (
rangeChanged ||
(changedProps.has("value") &&
(this._localValue?.[0] !== this.value?.[0] ||
this._localValue?.[1] !== this.value?.[1]))
) {
this._resetPosition();
}
}
private _setupListeners() {
if (this._pad && !this._mc) {
this._mc = new Manager(this._pad);
this._mc.add(
new Pan({
direction: DIRECTION_ALL,
enable: true,
})
);
this._mc.add(new Tap({ event: "singletap" }));
let savedPosition;
this._mc.on("panstart", (e) => {
if (this.disabled) return;
this._pressed = e.pointerType;
savedPosition = this._cursorPosition;
});
this._mc.on("pancancel", () => {
if (this.disabled) return;
this._pressed = undefined;
this._cursorPosition = savedPosition;
});
this._mc.on("panmove", (e) => {
if (this.disabled) return;
this._cursorPosition = this._getPositionFromEvent(e);
this._localValue = this._getValueFromCoord(...this._cursorPosition);
fireEvent(this, "cursor-moved", { value: this._localValue });
});
this._mc.on("panend", (e) => {
if (this.disabled) return;
this._pressed = undefined;
this._cursorPosition = this._getPositionFromEvent(e);
this._localValue = this._getValueFromCoord(...this._cursorPosition);
fireEvent(this, "cursor-moved", { value: undefined });
fireEvent(this, "value-changed", { value: this._localValue });
});
this._mc.on("singletap", (e) => {
if (this.disabled) return;
this._cursorPosition = this._getPositionFromEvent(e);
this._localValue = this._getValueFromCoord(...this._cursorPosition);
fireEvent(this, "value-changed", { value: this._localValue });
});
}
}
private _destroyListeners() {
if (this._mc) {
this._mc.destroy();
this._mc = undefined;
}
}
private _resetPosition() {
if (this.value === undefined) {
this._cursorPosition = undefined;
this._localValue = undefined;
return;
}
this._cursorPosition = this._getCoordsFromValue(this.value);
this._localValue = this.value;
}
private get _rtl() {
return mainWindow.document.dir === "rtl";
}
private _getCoordsFromValue = (value: [number, number]): [number, number] => {
const xValue =
(clamp(value[0], this._minKelvin, this._maxKelvin) - this._minKelvin) /
(this._maxKelvin - this._minKelvin);
const x = this._rtl ? 1 - xValue : xValue;
const y = 1 - clamp(value[1], 1, 100) / 100;
return [x, y];
};
private _getValueFromCoord = (x: number, y: number): [number, number] => {
const xValue = this._rtl ? 1 - x : x;
const kelvin = Math.round(
this._minKelvin + xValue * (this._maxKelvin - this._minKelvin)
);
const brightness = clamp(Math.round((1 - y) * 100), 1, 100);
return [kelvin, brightness];
};
private _getPositionFromEvent = (e: HammerInput): [number, number] => {
const boundingRect = this._pad!.getBoundingClientRect();
const x = clamp(
(e.center.x - boundingRect.left) / boundingRect.width,
0,
1
);
const y = clamp(
(e.center.y - boundingRect.top) / boundingRect.height,
0,
1
);
return [x, y];
};
private _generateTemperatureGradient = memoizeOne(
(min: number, max: number) => generateColorTemperatureGradient(min, max)
);
render() {
const gradient = this._generateTemperatureGradient(
this._minKelvin,
this._maxKelvin
);
const [x, y] = this._cursorPosition ?? [0, 0];
const markerScale = this._pressed
? this._pressed === "touch"
? "2.5"
: "1.5"
: "1";
const markerOffset = this._pressed === "touch" ? "0px, -24px" : "0px, 0px";
let markerColor = "#ffffff";
if (this._localValue) {
const rgb = temperature2rgb(this._localValue[0]);
const factor =
MARKER_LUMINANCE_FLOOR +
(1 - MARKER_LUMINANCE_FLOOR) * (this._localValue[1] / 100);
markerColor = rgb2hex([
Math.round(rgb[0] * factor),
Math.round(rgb[1] * factor),
Math.round(rgb[2] * factor),
]);
}
return html`
<div class="container ${classMap({ pressed: Boolean(this._pressed) })}">
<div
id="pad"
style=${styleMap({
"background-image": `linear-gradient(to top, ${PAD_DARK_COLOR}, white), linear-gradient(to ${this._rtl ? "left" : "right"}, ${gradient})`,
})}
></div>
<div
class="cursor"
style=${styleMap({
left: `${x * 100}%`,
top: `${y * 100}%`,
visibility: this._cursorPosition ? undefined : "hidden",
})}
>
<div
class="marker"
style=${styleMap({
"background-color": markerColor,
transform: `translate(${markerOffset}) scale(${markerScale})`,
})}
></div>
</div>
</div>
`;
}
static styles = css`
:host {
display: block;
outline: none;
}
.container {
position: relative;
width: 100%;
height: 100%;
display: flex;
}
#pad {
width: 100%;
height: 100%;
clip-path: inset(0 round var(--ha-border-radius-6xl));
background-blend-mode: multiply;
background-repeat: no-repeat;
cursor: pointer;
touch-action: none;
}
:host([disabled]) #pad {
cursor: initial;
filter: grayscale(1) opacity(0.5);
}
.cursor {
position: absolute;
width: 0;
height: 0;
pointer-events: none;
}
.marker {
position: absolute;
top: -16px;
left: -16px;
width: 32px;
height: 32px;
border-radius: var(--ha-border-radius-circle);
border: 2px solid white;
box-shadow:
0 1px 4px rgba(0, 0, 0, 0.3),
0 1px 6px rgba(0, 0, 0, 0.15);
}
.container:not(.pressed) .marker {
transition:
transform var(--ha-animation-duration-instant) ease-in-out,
background-color var(--ha-animation-duration-instant) ease-in-out;
}
.container:not(.pressed) .cursor {
transition:
left var(--ha-animation-duration-fast) ease-in-out,
top var(--ha-animation-duration-fast) ease-in-out;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-color-temp-brightness-picker": HaColorTempBrightnessPicker;
}
}
+43 -49
View File
@@ -2,7 +2,12 @@ import type { SelectedDetail } from "@material/mwc-list";
import { mdiFilterVariantRemove } from "@mdi/js";
import type { CSSResultGroup, PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { customElement, property, state } from "lit/decorators";
import { createRef, ref } from "lit/directives/ref";
import {
FilterPanelController,
filterPanelStyles,
} from "../common/controllers/filter-panel-controller";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import type { LocalizeFunc } from "../common/translations/localize";
import { fireEvent } from "../common/dom/fire_event";
@@ -34,11 +39,11 @@ export class HaFilterBlueprints extends LitElement {
@property({ type: Boolean, reflect: true }) public expanded = false;
@state() private _shouldRender = false;
@state() private _blueprints?: Blueprints;
@query("ha-list") private _list?: HTMLElement;
private _content = createRef<HTMLElement>();
private _panel = new FilterPanelController(this, this._content);
public willUpdate(properties: PropertyValues<this>) {
super.willUpdate(properties);
@@ -56,7 +61,6 @@ export class HaFilterBlueprints extends LitElement {
<ha-expansion-panel
left-chevron
.expanded=${this.expanded}
@expanded-will-change=${this._expandedWillChange}
@expanded-changed=${this._expandedChanged}
>
<div slot="header" class="header">
@@ -71,29 +75,38 @@ export class HaFilterBlueprints extends LitElement {
: nothing
}
</div>
${
this._blueprints && this._shouldRender
? html`
<ha-list
@selected=${this._blueprintsSelected}
multi
class="ha-scrollbar"
>
${Object.entries(this._blueprints).map(([id, blueprint]) =>
"error" in blueprint
? nothing
: html`<ha-check-list-item
.value=${id}
.selected=${(this.value || []).includes(id)}
>
${blueprint.metadata.name || id}
</ha-check-list-item>`
)}
</ha-list>
`
: nothing
}
</ha-expansion-panel>
${
this._panel.showContent
? html`
<div class="content" ${ref(this._content)}>
${
this._blueprints
? html`
<ha-list
@selected=${this._blueprintsSelected}
multi
class="ha-scrollbar"
>
${Object.entries(this._blueprints).map(
([id, blueprint]) =>
"error" in blueprint
? nothing
: html`<ha-check-list-item
.value=${id}
.selected=${(this.value || []).includes(id)}
>
${blueprint.metadata.name || id}
</ha-check-list-item>`
)}
</ha-list>
`
: nothing
}
</div>
`
: nothing
}
`;
}
@@ -104,19 +117,6 @@ export class HaFilterBlueprints extends LitElement {
this._blueprints = await fetchBlueprints(this.hass, this.type);
}
protected updated(changed: PropertyValues<this>) {
if (changed.has("expanded") && this.expanded) {
setTimeout(() => {
if (this.narrow || !this.expanded) return;
this._list!.style.height = `${this.clientHeight - 49}px`;
}, 300);
}
}
private _expandedWillChange(ev) {
this._shouldRender = ev.detail.expanded;
}
private _expandedChanged(ev) {
this.expanded = ev.detail.expanded;
}
@@ -191,17 +191,11 @@ export class HaFilterBlueprints extends LitElement {
static get styles(): CSSResultGroup {
return [
haStyleScrollbar,
filterPanelStyles,
css`
:host {
border-bottom: 1px solid var(--divider-color);
}
:host([expanded]) {
ha-list {
flex: 1;
height: 0;
}
ha-expansion-panel {
--ha-card-border-radius: var(--ha-border-radius-square);
--expansion-panel-content-padding: 0;
min-height: 0;
}
.header {
display: flex;
+23 -53
View File
@@ -8,9 +8,14 @@ import {
mdiTag,
} from "@mdi/js";
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import type { CSSResultGroup, PropertyValues } from "lit";
import type { CSSResultGroup } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { customElement, property, state } from "lit/decorators";
import { createRef, ref } from "lit/directives/ref";
import {
FilterPanelController,
filterPanelStyles,
} from "../common/controllers/filter-panel-controller";
import { fireEvent } from "../common/dom/fire_event";
import { stopPropagation } from "../common/dom/stop_propagation";
import type { CategoryRegistryEntry } from "../data/category_registry";
@@ -47,9 +52,9 @@ export class HaFilterCategories extends SubscribeMixin(LitElement) {
@state() private _categories: CategoryRegistryEntry[] = [];
@state() private _shouldRender = false;
private _content = createRef<HTMLElement>();
@query("ha-list") private _list?: HTMLElement;
private _panel = new FilterPanelController(this, this._content);
protected hassSubscribeRequiredHostProps = ["scope"];
@@ -70,7 +75,6 @@ export class HaFilterCategories extends SubscribeMixin(LitElement) {
<ha-expansion-panel
left-chevron
.expanded=${this.expanded}
@expanded-will-change=${this._expandedWillChange}
@expanded-changed=${this._expandedChanged}
>
<div slot="header" class="header">
@@ -85,9 +89,11 @@ export class HaFilterCategories extends SubscribeMixin(LitElement) {
: nothing
}
</div>
${
this._shouldRender
? html`
</ha-expansion-panel>
${
this._panel.showContent
? html`
<div class="content" ${ref(this._content)}>
<ha-list
@selected=${this._categorySelected}
class="ha-scrollbar"
@@ -158,34 +164,17 @@ export class HaFilterCategories extends SubscribeMixin(LitElement) {
</ha-list-item>`
)}
</ha-list>
`
: nothing
}
</ha-expansion-panel>
${
this.expanded
? html`<ha-list-item
graphic="icon"
@click=${this._addCategory}
class="add"
>
<ha-svg-icon slot="graphic" .path=${mdiPlus}></ha-svg-icon>
${this.hass.localize("ui.panel.config.category.editor.add")}
</ha-list-item>`
<ha-list-item graphic="icon" @click=${this._addCategory}>
<ha-svg-icon slot="graphic" .path=${mdiPlus}></ha-svg-icon>
${this.hass.localize("ui.panel.config.category.editor.add")}
</ha-list-item>
</div>
`
: nothing
}
`;
}
protected updated(changed: PropertyValues<this>) {
if (changed.has("expanded") && this.expanded) {
setTimeout(() => {
if (!this.expanded) return;
this._list!.style.height = `${this.clientHeight - (49 + 48)}px`;
}, 300);
}
}
private _handleAction(ev: HaDropdownSelectEvent) {
const categoryId = (ev.currentTarget as any).categoryId;
const action = ev.detail.item.value;
@@ -244,10 +233,6 @@ export class HaFilterCategories extends SubscribeMixin(LitElement) {
});
}
private _expandedWillChange(ev) {
this._shouldRender = ev.detail.expanded;
}
private _expandedChanged(ev) {
this.expanded = ev.detail.expanded;
}
@@ -287,19 +272,8 @@ export class HaFilterCategories extends SubscribeMixin(LitElement) {
static get styles(): CSSResultGroup {
return [
haStyleScrollbar,
filterPanelStyles,
css`
:host {
border-bottom: 1px solid var(--divider-color);
position: relative;
}
:host([expanded]) {
flex: 1;
height: 0;
}
ha-expansion-panel {
--ha-card-border-radius: var(--ha-border-radius-square);
--expansion-panel-content-padding: 0;
}
.header {
display: flex;
align-items: center;
@@ -325,6 +299,8 @@ export class HaFilterCategories extends SubscribeMixin(LitElement) {
color: var(--text-primary-color);
}
ha-list {
flex: 1;
min-height: 0;
--mdc-list-item-meta-size: auto;
--mdc-list-side-padding-right: var(--ha-space-1);
--mdc-list-side-padding-left: var(--ha-space-4);
@@ -339,12 +315,6 @@ export class HaFilterCategories extends SubscribeMixin(LitElement) {
.warning {
color: var(--error-color);
}
.add {
position: absolute;
bottom: 0;
right: 0;
left: 0;
}
`,
];
}
+72 -97
View File
@@ -3,7 +3,12 @@ import { mdiFilterVariantRemove } from "@mdi/js";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { createRef, ref } from "lit/directives/ref";
import memoizeOne from "memoize-one";
import {
FilterPanelController,
filterPanelStyles,
} from "../common/controllers/filter-panel-controller";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import { fireEvent } from "../common/dom/fire_event";
import { computeDeviceNameDisplay } from "../common/entity/compute_device_name";
@@ -59,13 +64,15 @@ export class HaFilterDevices extends LitElement {
@property({ type: Boolean }) public narrow = false;
@state() private _shouldRender = false;
@state() private _filter?: string;
@query("ha-list-selectable-virtualized")
private _listElement?: HaListSelectableVirtualized;
private _content = createRef<HTMLElement>();
private _panel = new FilterPanelController(this, this._content);
public willUpdate(properties: PropertyValues<this>) {
super.willUpdate(properties);
@@ -77,26 +84,11 @@ export class HaFilterDevices extends LitElement {
}
}
protected updated(changed: PropertyValues<this>) {
if (changed.has("expanded") && this.expanded) {
setTimeout(() => {
if (!this.expanded || !this._listElement) {
return;
}
this._listElement.style.height = `${this.clientHeight - 49 - 4 - 38}px`;
// 49px - height of a header + 1px
// 4px - padding-top of the search-input
// 38px - height of the search input
}, 300);
}
}
protected render() {
return html`
<ha-expansion-panel
left-chevron
.expanded=${this.expanded}
@expanded-will-change=${this._expandedWillChange}
@expanded-changed=${this._expandedChanged}
>
<div slot="header" class="header">
@@ -112,31 +104,33 @@ export class HaFilterDevices extends LitElement {
: nothing
}
</div>
${
this._shouldRender
? html`<ha-input-search
appearance="outlined"
.value=${this._filter}
@input=${this._handleSearchChange}
@keydown=${this._handleSearchKeydown}
>
</ha-input-search>
<ha-list-selectable-virtualized
multi
.rows=${this._devices(
this._devicesReg,
this._filter || "",
this._localize,
this._states,
this._i18n.locale.language
)}
.rowRenderer=${this._renderItem}
@ha-list-item-selected=${this._handleAdded}
@ha-list-item-deselected=${this._handleRemoved}
></ha-list-selectable-virtualized>`
: nothing
}
</ha-expansion-panel>
${
this._panel.showContent
? html`<div class="content" ${ref(this._content)}>
<ha-input-search
appearance="outlined"
.value=${this._filter}
@input=${this._handleSearchChange}
@keydown=${this._handleSearchKeydown}
>
</ha-input-search>
<ha-list-selectable-virtualized
multi
.rows=${this._devices(
this._devicesReg,
this._filter || "",
this._localize,
this._states,
this._i18n.locale.language
)}
.rowRenderer=${this._renderItem}
@ha-list-item-selected=${this._handleAdded}
@ha-list-item-deselected=${this._handleRemoved}
></ha-list-selectable-virtualized>
</div>`
: nothing
}
`;
}
@@ -177,10 +171,6 @@ export class HaFilterDevices extends LitElement {
this.value = (this.value ?? []).filter((deviceId) => deviceId !== id);
}
private _expandedWillChange(ev) {
this._shouldRender = ev.detail.expanded;
}
private _expandedChanged(ev) {
this.expanded = ev.detail.expanded;
}
@@ -271,58 +261,43 @@ export class HaFilterDevices extends LitElement {
this._listElement?.clearSelection();
}
static styles = css`
:host {
border-bottom: 1px solid var(--divider-color);
}
:host([expanded]) {
flex: 1;
height: 0;
display: flex;
flex-direction: column;
}
ha-expansion-panel {
--ha-card-border-radius: var(--ha-border-radius-square);
--expansion-panel-content-padding: 0;
}
:host([expanded]) ha-expansion-panel {
flex: 1;
min-height: 0;
}
ha-list-selectable-virtualized {
flex: 1;
min-height: 0;
}
.header {
display: flex;
align-items: center;
}
.header ha-icon-button {
margin-inline-start: auto;
margin-inline-end: 8px;
}
.badge {
display: inline-block;
margin-left: 8px;
margin-inline-start: 8px;
margin-inline-end: 0;
min-width: 16px;
box-sizing: border-box;
border-radius: var(--ha-border-radius-circle);
font-size: var(--ha-font-size-xs);
font-weight: var(--ha-font-weight-normal);
background-color: var(--primary-color);
line-height: var(--ha-line-height-normal);
text-align: center;
padding: 0px 2px;
color: var(--text-primary-color);
}
ha-input-search {
display: block;
padding: var(--ha-space-1) var(--ha-space-2) 0;
}
`;
static styles = [
filterPanelStyles,
css`
ha-list-selectable-virtualized {
flex: 1;
min-height: 0;
}
.header {
display: flex;
align-items: center;
}
.header ha-icon-button {
margin-inline-start: auto;
margin-inline-end: 8px;
}
.badge {
display: inline-block;
margin-left: 8px;
margin-inline-start: 8px;
margin-inline-end: 0;
min-width: 16px;
box-sizing: border-box;
border-radius: var(--ha-border-radius-circle);
font-size: var(--ha-font-size-xs);
font-weight: var(--ha-font-weight-normal);
background-color: var(--primary-color);
line-height: var(--ha-line-height-normal);
text-align: center;
padding: 0px 2px;
color: var(--text-primary-color);
}
ha-input-search {
display: block;
padding: var(--ha-space-1) var(--ha-space-2) 0;
}
`,
];
}
declare global {
+54 -70
View File
@@ -1,11 +1,16 @@
import { consume, type ContextType } from "@lit/context";
import type { SelectedDetail } from "@material/mwc-list";
import { mdiFilterVariantRemove } from "@mdi/js";
import type { CSSResultGroup, PropertyValues } from "lit";
import type { CSSResultGroup } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { customElement, property, state } from "lit/decorators";
import { createRef, ref } from "lit/directives/ref";
import { repeat } from "lit/directives/repeat";
import memoizeOne from "memoize-one";
import {
FilterPanelController,
filterPanelStyles,
} from "../common/controllers/filter-panel-controller";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import { fireEvent } from "../common/dom/fire_event";
import { computeDomain } from "../common/entity/compute_domain";
@@ -41,18 +46,17 @@ export class HaFilterDomains extends LitElement {
@property({ type: Boolean, reflect: true }) public expanded = false;
@state() private _shouldRender = false;
@state() private _filter?: string;
@query("ha-list") private _list?: HTMLElement;
private _content = createRef<HTMLElement>();
private _panel = new FilterPanelController(this, this._content);
protected render() {
return html`
<ha-expansion-panel
left-chevron
.expanded=${this.expanded}
@expanded-will-change=${this._expandedWillChange}
@expanded-changed=${this._expandedChanged}
>
<div slot="header" class="header">
@@ -67,46 +71,48 @@ export class HaFilterDomains extends LitElement {
: nothing
}
</div>
${
this._shouldRender
? html`<ha-input-search
appearance="outlined"
.value=${this._filter}
@input=${this._handleSearchChange}
>
</ha-input-search>
<ha-list
class="ha-scrollbar"
@selected=${this._handleItemSelected}
multi
>
${repeat(
this._domains(
this._states,
this._localize,
this._i18n.locale.language,
this._filter,
this.value
),
(i) => i,
(domain) =>
html`<ha-check-list-item
.value=${domain}
.selected=${(this.value || []).includes(domain)}
graphic="icon"
>
<ha-domain-icon
slot="graphic"
.domain=${domain}
brand-fallback
></ha-domain-icon>
${domainToName(this._localize, domain)}
</ha-check-list-item>`
)}
</ha-list> `
: nothing
}
</ha-expansion-panel>
${
this._panel.showContent
? html`<div class="content" ${ref(this._content)}>
<ha-input-search
appearance="outlined"
.value=${this._filter}
@input=${this._handleSearchChange}
>
</ha-input-search>
<ha-list
class="ha-scrollbar"
@selected=${this._handleItemSelected}
multi
>
${repeat(
this._domains(
this._states,
this._localize,
this._i18n.locale.language,
this._filter,
this.value
),
(i) => i,
(domain) =>
html`<ha-check-list-item
.value=${domain}
.selected=${(this.value || []).includes(domain)}
graphic="icon"
>
<ha-domain-icon
slot="graphic"
.domain=${domain}
brand-fallback
></ha-domain-icon>
${domainToName(this._localize, domain)}
</ha-check-list-item>`
)}
</ha-list>
</div>`
: nothing
}
`;
}
@@ -139,22 +145,6 @@ export class HaFilterDomains extends LitElement {
}
);
protected updated(changed: PropertyValues<this>) {
if (changed.has("expanded") && this.expanded) {
setTimeout(() => {
if (!this.expanded) return;
this._list!.style.height = `${this.clientHeight - 49 - 4 - 32}px`;
// 49px - height of a header + 1px
// 4px - padding-top of the search-input
// 32px - height of the search input
}, 300);
}
}
private _expandedWillChange(ev) {
this._shouldRender = ev.detail.expanded;
}
private _expandedChanged(ev) {
this.expanded = ev.detail.expanded;
}
@@ -199,17 +189,11 @@ export class HaFilterDomains extends LitElement {
static get styles(): CSSResultGroup {
return [
haStyleScrollbar,
filterPanelStyles,
css`
:host {
border-bottom: 1px solid var(--divider-color);
}
:host([expanded]) {
ha-list {
flex: 1;
height: 0;
}
ha-expansion-panel {
--ha-card-border-radius: var(--ha-border-radius-square);
--expansion-panel-content-padding: 0;
min-height: 0;
}
.header {
display: flex;
+21 -37
View File
@@ -2,8 +2,13 @@ import { consume, type ContextType } from "@lit/context";
import { mdiFilterVariantRemove } from "@mdi/js";
import type { CSSResultGroup, PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { customElement, property, state } from "lit/decorators";
import { createRef, ref } from "lit/directives/ref";
import memoizeOne from "memoize-one";
import {
FilterPanelController,
filterPanelStyles,
} from "../common/controllers/filter-panel-controller";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import { fireEvent } from "../common/dom/fire_event";
import { computeStateDomain } from "../common/entity/compute_state_domain";
@@ -52,11 +57,11 @@ export class HaFilterEntities extends LitElement {
@property({ type: Boolean, reflect: true }) public expanded = false;
@state() private _shouldRender = false;
@state() private _filter?: string;
@query("ha-list") private _list?: HTMLElement;
private _content = createRef<HTMLElement>();
private _panel = new FilterPanelController(this, this._content);
public willUpdate(properties: PropertyValues<this>) {
super.willUpdate(properties);
@@ -78,7 +83,6 @@ export class HaFilterEntities extends LitElement {
<ha-expansion-panel
left-chevron
.expanded=${this.expanded}
@expanded-will-change=${this._expandedWillChange}
@expanded-changed=${this._expandedChanged}
>
<div slot="header" class="header">
@@ -93,9 +97,11 @@ export class HaFilterEntities extends LitElement {
: nothing
}
</div>
${
this._shouldRender
? html`
</ha-expansion-panel>
${
this._panel.showContent
? html`
<div class="content" ${ref(this._content)}>
<ha-input-search
appearance="outlined"
.value=${this._filter}
@@ -118,25 +124,13 @@ export class HaFilterEntities extends LitElement {
>
</lit-virtualizer>
</ha-list>
`
: nothing
}
</ha-expansion-panel>
</div>
`
: nothing
}
`;
}
protected updated(changed: PropertyValues<this>) {
if (changed.has("expanded") && this.expanded) {
setTimeout(() => {
if (!this.expanded) return;
this._list!.style.height = `${this.clientHeight - 49 - 4 - 32}px`;
// 49px - height of a header + 1px
// 4px - padding-top of the search-input
// 32px - height of the search input
}, 300);
}
}
private _keyFunction = (entity) => entity?.entity_id;
private _renderItem = (entity) =>
@@ -173,10 +167,6 @@ export class HaFilterEntities extends LitElement {
listItem.selected = this.value?.includes(value);
}
private _expandedWillChange(ev) {
this._shouldRender = ev.detail.expanded;
}
private _expandedChanged(ev) {
this.expanded = ev.detail.expanded;
}
@@ -255,17 +245,11 @@ export class HaFilterEntities extends LitElement {
static get styles(): CSSResultGroup {
return [
haStyleScrollbar,
filterPanelStyles,
css`
:host {
border-bottom: 1px solid var(--divider-color);
}
:host([expanded]) {
ha-list {
flex: 1;
height: 0;
}
ha-expansion-panel {
--ha-card-border-radius: var(--ha-border-radius-square);
--expansion-panel-content-padding: 0;
min-height: 0;
}
.header {
display: flex;
+21 -32
View File
@@ -4,8 +4,13 @@ import type { CSSResultGroup, PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { createRef, ref } from "lit/directives/ref";
import { repeat } from "lit/directives/repeat";
import memoizeOne from "memoize-one";
import {
FilterPanelController,
filterPanelStyles,
} from "../common/controllers/filter-panel-controller";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import { fireEvent } from "../common/dom/fire_event";
import { computeRTL } from "../common/util/compute_rtl";
@@ -64,10 +69,12 @@ export class HaFilterFloorAreas extends LitElement {
@property({ type: Boolean, reflect: true }) public expanded = false;
@state() private _shouldRender = false;
@query("ha-list-selectable") private _list?: HaListSelectable;
private _content = createRef<HTMLElement>();
private _panel = new FilterPanelController(this, this._content);
public willUpdate(properties: PropertyValues<this>) {
super.willUpdate(properties);
@@ -86,7 +93,6 @@ export class HaFilterFloorAreas extends LitElement {
<ha-expansion-panel
left-chevron
.expanded=${this.expanded}
@expanded-will-change=${this._expandedWillChange}
@expanded-changed=${this._expandedChanged}
>
<div slot="header" class="header">
@@ -107,9 +113,11 @@ export class HaFilterFloorAreas extends LitElement {
: nothing
}
</div>
${
this._shouldRender
? html`
</ha-expansion-panel>
${
this._panel.showContent
? html`
<div class="content" ${ref(this._content)}>
<ha-list-selectable
class="ha-scrollbar"
multi
@@ -154,10 +162,10 @@ export class HaFilterFloorAreas extends LitElement {
(area) => this._renderArea(area)
)}
</ha-list-selectable>
`
: nothing
}
</ha-expansion-panel>
</div>
`
: nothing
}
`;
}
@@ -243,19 +251,6 @@ export class HaFilterFloorAreas extends LitElement {
};
}
protected updated(changed: PropertyValues<this>) {
if (changed.has("expanded") && this.expanded) {
setTimeout(() => {
if (!this.expanded) return;
this._list!.style.height = `${this.clientHeight - 49}px`;
}, 300);
}
}
private _expandedWillChange(ev) {
this._shouldRender = ev.detail.expanded;
}
private _expandedChanged(ev) {
this.expanded = ev.detail.expanded;
}
@@ -347,17 +342,11 @@ export class HaFilterFloorAreas extends LitElement {
static get styles(): CSSResultGroup {
return [
haStyleScrollbar,
filterPanelStyles,
css`
:host {
border-bottom: 1px solid var(--divider-color);
}
:host([expanded]) {
ha-list-selectable {
flex: 1;
height: 0;
}
ha-expansion-panel {
--ha-card-border-radius: var(--ha-border-radius-square);
--expansion-panel-content-padding: 0;
min-height: 0;
}
.header {
display: flex;
+60 -72
View File
@@ -1,11 +1,16 @@
import type { SelectedDetail } from "@material/mwc-list";
import { consume, type ContextType } from "@lit/context";
import { mdiFilterVariantRemove } from "@mdi/js";
import type { CSSResultGroup, PropertyValues } from "lit";
import type { CSSResultGroup } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { customElement, property, state } from "lit/decorators";
import { createRef, ref } from "lit/directives/ref";
import { repeat } from "lit/directives/repeat";
import memoizeOne from "memoize-one";
import {
FilterPanelController,
filterPanelStyles,
} from "../common/controllers/filter-panel-controller";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import { fireEvent } from "../common/dom/fire_event";
import { stringCompare } from "../common/string/compare";
@@ -44,11 +49,11 @@ export class HaFilterIntegrations extends LitElement {
Object.values(manifests)
);
@state() private _shouldRender = false;
@state() private _filter?: string;
@query("ha-list") private _list?: HTMLElement;
private _content = createRef<HTMLElement>();
private _panel = new FilterPanelController(this, this._content);
protected render() {
const manifests = this._manifests
@@ -59,7 +64,6 @@ export class HaFilterIntegrations extends LitElement {
<ha-expansion-panel
left-chevron
.expanded=${this.expanded}
@expanded-will-change=${this._expandedWillChange}
@expanded-changed=${this._expandedChanged}
>
<div slot="header" class="header">
@@ -74,67 +78,57 @@ export class HaFilterIntegrations extends LitElement {
: nothing
}
</div>
${
manifests && this._shouldRender
? html`<ha-input-search
appearance="outlined"
.value=${this._filter}
@input=${this._handleSearchChange}
>
</ha-input-search>
<ha-list
class="ha-scrollbar"
@selected=${this._itemSelected}
multi
>
${repeat(
this._integrations(
this._localize,
manifests,
this._filter,
this.value,
this._i18n.locale.language
),
(i) => i.domain,
(integration) =>
html`<ha-check-list-item
.value=${integration.domain}
.selected=${(this.value || []).includes(
integration.domain
)}
graphic="icon"
>
<ha-domain-icon
slot="graphic"
.domain=${integration.domain}
brand-fallback
></ha-domain-icon>
${integration.name}
</ha-check-list-item>`
)}
</ha-list> `
: nothing
}
</ha-expansion-panel>
${
this._panel.showContent
? html`<div class="content" ${ref(this._content)}>
${
manifests
? html`<ha-input-search
appearance="outlined"
.value=${this._filter}
@input=${this._handleSearchChange}
>
</ha-input-search>
<ha-list
class="ha-scrollbar"
@selected=${this._itemSelected}
multi
>
${repeat(
this._integrations(
this._localize,
manifests,
this._filter,
this.value,
this._i18n.locale.language
),
(i) => i.domain,
(integration) =>
html`<ha-check-list-item
.value=${integration.domain}
.selected=${(this.value || []).includes(
integration.domain
)}
graphic="icon"
>
<ha-domain-icon
slot="graphic"
.domain=${integration.domain}
brand-fallback
></ha-domain-icon>
${integration.name}
</ha-check-list-item>`
)}
</ha-list>`
: nothing
}
</div>`
: nothing
}
`;
}
protected updated(changed: PropertyValues<this>) {
if (changed.has("expanded") && this.expanded) {
setTimeout(() => {
if (!this.expanded) return;
this._list!.style.height = `${this.clientHeight - 49 - 4 - 32}px`;
// 49px - height of a header + 1px
// 4px - padding-top of the search-input
// 32px - height of the search input
}, 300);
}
}
private _expandedWillChange(ev) {
this._shouldRender = ev.detail.expanded;
}
private _expandedChanged(ev) {
this.expanded = ev.detail.expanded;
}
@@ -213,17 +207,11 @@ export class HaFilterIntegrations extends LitElement {
static get styles(): CSSResultGroup {
return [
haStyleScrollbar,
filterPanelStyles,
css`
:host {
border-bottom: 1px solid var(--divider-color);
}
:host([expanded]) {
ha-list {
flex: 1;
height: 0;
}
ha-expansion-panel {
--ha-card-border-radius: var(--ha-border-radius-square);
--expansion-panel-content-padding: 0;
min-height: 0;
}
.header {
display: flex;
+62 -94
View File
@@ -1,11 +1,16 @@
import { consume, type ContextType } from "@lit/context";
import type { SelectedDetail } from "@material/mwc-list";
import { mdiCog, mdiFilterVariantRemove } from "@mdi/js";
import type { CSSResultGroup, PropertyValues } from "lit";
import type { CSSResultGroup } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { customElement, property, state } from "lit/decorators";
import { createRef, ref } from "lit/directives/ref";
import { repeat } from "lit/directives/repeat";
import memoizeOne from "memoize-one";
import {
FilterPanelController,
filterPanelStyles,
} from "../common/controllers/filter-panel-controller";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import { fireEvent } from "../common/dom/fire_event";
import { navigate } from "../common/navigate";
@@ -44,11 +49,11 @@ export class HaFilterLabels extends LitElement {
@state()
private _labels?: LabelRegistryEntry[];
@state() private _shouldRender = false;
@state() private _filter?: string;
@query("ha-list") private _list?: HTMLElement;
private _content = createRef<HTMLElement>();
private _panel = new FilterPanelController(this, this._content);
private _filteredLabels = memoizeOne(
// `_value` used to recalculate the memoization when the selection changes
@@ -75,7 +80,6 @@ export class HaFilterLabels extends LitElement {
<ha-expansion-panel
left-chevron
.expanded=${this.expanded}
@expanded-will-change=${this._expandedWillChange}
@expanded-changed=${this._expandedChanged}
>
<div slot="header" class="header">
@@ -90,89 +94,66 @@ export class HaFilterLabels extends LitElement {
: nothing
}
</div>
${
this._shouldRender
? html`<ha-input-search
appearance="outlined"
.value=${this._filter}
@input=${this._handleSearchChange}
>
</ha-input-search>
<ha-list
@selected=${this._labelSelected}
class="ha-scrollbar"
multi
>
${repeat(
this._filteredLabels(
this._labels || [],
this._filter,
this._i18n.locale.language,
this.value
),
(label) => label.label_id,
(label) =>
html`<ha-check-list-item
.value=${label.label_id}
.selected=${(this.value || []).includes(label.label_id)}
hasMeta
>
<ha-label
.color=${label.color}
.description=${label.description}
>
${
label.icon
? html`<ha-icon
slot="icon"
.icon=${label.icon}
></ha-icon>`
: nothing
}
${label.name}
</ha-label>
</ha-check-list-item>`
)}
</ha-list> `
: nothing
}
</ha-expansion-panel>
${
this.expanded
? html`<ha-list-item
graphic="icon"
@click=${this._manageLabels}
class="add"
>
<ha-svg-icon slot="graphic" .path=${mdiCog}></ha-svg-icon>
${this._localize("ui.panel.config.labels.manage_labels")}
</ha-list-item>`
this._panel.showContent
? html`<div class="content" ${ref(this._content)}>
<ha-input-search
appearance="outlined"
.value=${this._filter}
@input=${this._handleSearchChange}
>
</ha-input-search>
<ha-list
@selected=${this._labelSelected}
class="ha-scrollbar"
multi
>
${repeat(
this._filteredLabels(
this._labels || [],
this._filter,
this._i18n.locale.language,
this.value
),
(label) => label.label_id,
(label) =>
html`<ha-check-list-item
.value=${label.label_id}
.selected=${(this.value || []).includes(label.label_id)}
hasMeta
>
<ha-label
.color=${label.color}
.description=${label.description}
>
${
label.icon
? html`<ha-icon
slot="icon"
.icon=${label.icon}
></ha-icon>`
: nothing
}
${label.name}
</ha-label>
</ha-check-list-item>`
)}
</ha-list>
<ha-list-item graphic="icon" @click=${this._manageLabels}>
<ha-svg-icon slot="graphic" .path=${mdiCog}></ha-svg-icon>
${this._localize("ui.panel.config.labels.manage_labels")}
</ha-list-item>
</div>`
: nothing
}
`;
}
protected updated(changed: PropertyValues<this>) {
if (changed.has("expanded") && this.expanded) {
setTimeout(() => {
if (!this.expanded) return;
this._list!.style.height = `${this.clientHeight - (49 + 48 + 32 + 4)}px`;
// 49px - height of a header + 1px
// 4px - padding-top of the search-input
// 32px - height of the search input
// 48px - height of ha-list-item
}, 300);
}
}
private _manageLabels() {
navigate("/config/labels");
}
private _expandedWillChange(ev) {
this._shouldRender = ev.detail.expanded;
}
private _expandedChanged(ev) {
this.expanded = ev.detail.expanded;
}
@@ -227,18 +208,11 @@ export class HaFilterLabels extends LitElement {
static get styles(): CSSResultGroup {
return [
haStyleScrollbar,
filterPanelStyles,
css`
:host {
position: relative;
border-bottom: 1px solid var(--divider-color);
}
:host([expanded]) {
ha-list {
flex: 1;
height: 0;
}
ha-expansion-panel {
--ha-card-border-radius: var(--ha-border-radius-square);
--expansion-panel-content-padding: 0;
min-height: 0;
}
.header {
display: flex;
@@ -267,12 +241,6 @@ export class HaFilterLabels extends LitElement {
.warning {
color: var(--error-color);
}
.add {
position: absolute;
bottom: 0;
right: 0;
left: 0;
}
ha-input-search {
display: block;
padding: var(--ha-space-1) var(--ha-space-2) 0;
+22 -45
View File
@@ -1,8 +1,13 @@
import type { List, SelectedDetail } from "@material/mwc-list";
import type { SelectedDetail } from "@material/mwc-list";
import { mdiFilterVariantRemove } from "@mdi/js";
import type { CSSResultGroup, PropertyValues } from "lit";
import type { CSSResultGroup } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { customElement, property } from "lit/decorators";
import { createRef, ref } from "lit/directives/ref";
import {
FilterPanelController,
filterPanelStyles,
} from "../common/controllers/filter-panel-controller";
import { fireEvent } from "../common/dom/fire_event";
import { haStyleScrollbar } from "../resources/styles";
import "./ha-check-list-item";
@@ -27,9 +32,9 @@ export class HaFilterStates extends LitElement {
@property({ type: Boolean, reflect: true }) public expanded = false;
@state() private _shouldRender = false;
private _content = createRef<HTMLElement>();
@query("ha-list") private _list!: List;
private _panel = new FilterPanelController(this, this._content);
protected render() {
if (!this.states) {
@@ -40,7 +45,6 @@ export class HaFilterStates extends LitElement {
<ha-expansion-panel
left-chevron
.expanded=${this.expanded}
@expanded-will-change=${this._expandedWillChange}
@expanded-changed=${this._expandedChanged}
>
<div slot="header" class="header">
@@ -55,9 +59,11 @@ export class HaFilterStates extends LitElement {
: nothing
}
</div>
${
this._shouldRender
? html`
</ha-expansion-panel>
${
this._panel.showContent
? html`
<div class="content" ${ref(this._content)}>
<ha-list
@selected=${this._statesSelected}
multi
@@ -82,36 +88,13 @@ export class HaFilterStates extends LitElement {
</ha-check-list-item>`
)}
</ha-list>
`
: nothing
}
</ha-expansion-panel>
</div>
`
: nothing
}
`;
}
protected willUpdate(changed: PropertyValues<this>) {
if (changed.has("expanded") && this.expanded) {
this._shouldRender = true;
}
}
protected updated(changed: PropertyValues<this>) {
if ((changed.has("expanded") || changed.has("states")) && this.expanded) {
setTimeout(async () => {
if (!this.expanded) return;
const list = this._list;
if (!list) {
return;
}
list.style.height = `${this.clientHeight - 49}px`;
}, 300);
}
}
private _expandedWillChange(ev) {
this._shouldRender = ev.detail.expanded;
}
private _expandedChanged(ev) {
this.expanded = ev.detail.expanded;
}
@@ -152,17 +135,11 @@ export class HaFilterStates extends LitElement {
static get styles(): CSSResultGroup {
return [
haStyleScrollbar,
filterPanelStyles,
css`
:host {
border-bottom: 1px solid var(--divider-color);
}
:host([expanded]) {
ha-list {
flex: 1;
height: 0;
}
ha-expansion-panel {
--ha-card-border-radius: var(--ha-border-radius-square);
--expansion-panel-content-padding: 0;
min-height: 0;
}
.header {
display: flex;
+20 -34
View File
@@ -2,8 +2,13 @@ import type { SelectedDetail } from "@material/mwc-list";
import { mdiFilterVariantRemove } from "@mdi/js";
import type { CSSResultGroup, PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { customElement, property, state } from "lit/decorators";
import { createRef, ref } from "lit/directives/ref";
import { repeat } from "lit/directives/repeat";
import {
FilterPanelController,
filterPanelStyles,
} from "../common/controllers/filter-panel-controller";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import type { LocalizeFunc } from "../common/translations/localize";
import { fireEvent } from "../common/dom/fire_event";
@@ -34,16 +39,15 @@ export class HaFilterVoiceAssistants extends LitElement {
@state() private _voiceAssistantOptions: string[] = [];
@state() private _shouldRender = false;
private _content = createRef<HTMLElement>();
@query("ha-list") private _list?: HTMLElement;
private _panel = new FilterPanelController(this, this._content);
protected render() {
return html`
<ha-expansion-panel
left-chevron
.expanded=${this.expanded}
@expanded-will-change=${this._expandedWillChange}
@expanded-changed=${this._expandedChanged}
>
<div slot="header" class="header">
@@ -58,9 +62,11 @@ export class HaFilterVoiceAssistants extends LitElement {
: nothing
}
</div>
${
this._shouldRender
? html`<ha-list
</ha-expansion-panel>
${
this._panel.showContent
? html`<div class="content" ${ref(this._content)}>
<ha-list
@selected=${this._assistantsSelected}
class="ha-scrollbar"
multi
@@ -83,10 +89,10 @@ export class HaFilterVoiceAssistants extends LitElement {
${voiceAssistants[voiceAssistantId].name}
</ha-check-list-item>`
)}
</ha-list> `
: nothing
}
</ha-expansion-panel>
</ha-list>
</div>`
: nothing
}
`;
}
@@ -95,19 +101,6 @@ export class HaFilterVoiceAssistants extends LitElement {
this._voiceAssistantOptions = Object.keys(voiceAssistants);
}
protected updated(changed: PropertyValues<this>) {
if (changed.has("expanded") && this.expanded) {
setTimeout(() => {
if (!this.expanded) return;
this._list!.style.height = `${this.clientHeight - 49}px`;
}, 300);
}
}
private _expandedWillChange(ev) {
this._shouldRender = ev.detail.expanded;
}
private _expandedChanged(ev) {
this.expanded = ev.detail.expanded;
}
@@ -148,18 +141,11 @@ export class HaFilterVoiceAssistants extends LitElement {
static get styles(): CSSResultGroup {
return [
haStyleScrollbar,
filterPanelStyles,
css`
:host {
position: relative;
border-bottom: 1px solid var(--divider-color);
}
:host([expanded]) {
ha-list {
flex: 1;
height: 0;
}
ha-expansion-panel {
--ha-card-border-radius: var(--ha-border-radius-square);
--expansion-panel-content-padding: 0;
min-height: 0;
}
.header {
display: flex;
@@ -24,6 +24,7 @@ export type HaListItemOptionSelectionPosition = "start" | "end";
* @cssprop --ha-list-item-selected-background - Background color when selected (`appearance="line"`).
*
* @attr {boolean} selected - Whether the option is selected. Set by the parent `ha-list-selectable`.
* @attr {boolean} indeterminate - Draws the checkbox in an indeterminate state, for a row that stands for a partially selected set.
* @attr {string} value - Value identifying the option.
* @attr {("line"|"checkbox")} appearance - Visual style. "line" highlights the row; "checkbox" renders an `ha-checkbox`.
* @attr {("start"|"end")} selection-position - Side the checkbox sits on when `appearance="checkbox"`.
@@ -32,6 +33,8 @@ export type HaListItemOptionSelectionPosition = "start" | "end";
export class HaListItemOption extends HaListItemBase {
@property({ type: Boolean, reflect: true }) public selected = false;
@property({ type: Boolean, reflect: true }) public indeterminate = false;
@property({ type: String }) public value?: string;
@property({ type: String, reflect: true })
@@ -80,6 +83,7 @@ export class HaListItemOption extends HaListItemBase {
return html`<div part="checkbox" class="checkbox" inert>
<ha-checkbox
.checked=${this.selected}
.indeterminate=${this.indeterminate}
.disabled=${this.disabled}
></ha-checkbox>
</div>`;
@@ -10,6 +10,8 @@ export const SelectableMixin = <T extends Constructor<HaListBase>>(
class SelectableClass extends superClass {
@property({ type: Boolean, reflect: true }) public multi = false;
@property({ type: Boolean, reflect: true }) public controlled = false;
protected override readonly hostRole = "listbox";
public connectedCallback(): void {
@@ -67,15 +69,19 @@ export const SelectableMixin = <T extends Constructor<HaListBase>>(
`ha-list-item-${el.selected ? "deselected" : "selected"}`,
index
);
el.toggleAttribute("selected");
if (!this.controlled) {
el.toggleAttribute("selected");
}
return;
}
if (!el.selected) {
fireEvent(this, "ha-list-item-selected", index);
// deselect the other optional selected item
this.clearSelection();
el.toggleAttribute("selected", true);
if (!this.controlled) {
// deselect the other optional selected item
this.clearSelection();
el.toggleAttribute("selected", true);
}
}
}
}
@@ -20,6 +20,9 @@ import { HaListVirtualized } from "./ha-list-virtualized";
*
* @attr {boolean} multi - Whether multiple options can be selected at once. In
* single-select mode, selecting a row clears any previous selection.
* @attr {boolean} controlled - Only notify on click, never toggle a row's `selected` state.
* Set it when the consumer derives `selected` from its own state, for example when a row stands
* for a group whose click also changes its children.
*
* @fires ha-list-item-selected - Fires when the user selects a row.
* `detail` is the row's index (number).
@@ -11,6 +11,9 @@ import { SelectableMixin } from "./ha-list-selectable-mixin";
* Toggle single vs multi selection via the `multi` attribute.
*
* @attr {boolean} multi - Whether multiple options can be selected at once.
* @attr {boolean} controlled - Only notify on click, never toggle an option's `selected` state.
* Set it when the consumer derives `selected` from its own state, for example when a row stands
* for a group whose click also changes its children.
*
* @fires ha-list-item-selected - An option was selected. `detail: number` (option index).
* @fires ha-list-item-deselected - An option was deselected (multi mode only). `detail: number` (option index).
+9 -3
View File
@@ -1,6 +1,7 @@
import type { Connection } from "home-assistant-js-websocket";
import type { HaFormSchema } from "../components/ha-form/types";
import type { ConfigEntry } from "./config_entries";
import type { RepairsIssue } from "./repairs";
export type FlowType =
"config_flow" | "config_subentries_flow" | "options_flow" | "repair_flow";
@@ -56,26 +57,31 @@ export interface DataEntryFlowStepExternal {
translation_domain?: string;
}
export interface DataEntryFlowStepCreateEntry {
export interface DataEntryFlowStepCreateEntry<
TResult extends ConfigEntry | RepairsIssue = ConfigEntry,
> {
type: "create_entry";
version: number;
flow_id: string;
next_flow?: [FlowType, string]; // [flow_type, flow_id]
handler: string;
title: string;
result?: ConfigEntry;
result?: TResult;
description: string;
description_placeholders?: Record<string, string>;
translation_domain?: string;
}
export interface DataEntryFlowStepAbort {
export interface DataEntryFlowStepAbort<
TResult extends ConfigEntry | RepairsIssue = ConfigEntry,
> {
type: "abort";
flow_id: string;
handler: string;
reason: string;
description_placeholders?: Record<string, string>;
translation_domain?: string;
result?: TResult;
next_flow?: [FlowType, string]; // [flow_type, flow_id]
}
-45
View File
@@ -1,38 +1,5 @@
import type { HassEntity } from "home-assistant-js-websocket";
import { formatDurationDigital } from "../../common/datetime/format_duration";
import type { FrontendLocaleData } from "../translation";
import { computeStateDomain } from "../../common/entity/compute_state_domain";
export const STATE_ATTRIBUTES = [
"entity_id",
"assumed_state",
"attribution",
"custom_ui_more_info",
"custom_ui_state_card",
"device_class",
"editable",
"emulated_hue_name",
"emulated_hue",
"entity_picture",
"event_types",
"friendly_name",
"haaska_hidden",
"haaska_name",
"icon",
"initial_state",
"last_reset",
"restored",
"state_class",
"supported_features",
"unit_of_measurement",
"available_tones",
];
export const STATE_ATTRIBUTES_DOMAIN_CLASS = {
sensor: {
enum: ["options"],
},
};
export const TEMPERATURE_ATTRIBUTES = new Set([
"temperature",
@@ -210,15 +177,3 @@ export const STATE_CONDITION_HIDDEN_ATTRIBUTES = [
"swing_modes",
"token",
];
export const computeShownAttributes = (stateObj: HassEntity) => {
const domain = computeStateDomain(stateObj);
const filtersArray = STATE_ATTRIBUTES.concat(
STATE_ATTRIBUTES_DOMAIN_CLASS[domain]?.[
stateObj.attributes?.device_class
] || []
);
return Object.keys(stateObj.attributes).filter(
(key) => filtersArray.indexOf(key) === -1
);
};
+10 -3
View File
@@ -126,11 +126,18 @@ export const listDatadisks = async (
timeout: null,
});
export const fetchHostDisksUsage = async (hass: HomeAssistant) =>
// `disk` is "default" for the data disk, or a mount name. Omitting maxDepth
// leaves the depth to the Supervisor, which defaults per target — walking a
// mount costs a round trip per directory, so mounts want no depth at all.
export const fetchHostDisksUsage = async (
hass: HomeAssistant,
disk = "default",
maxDepth?: number
) =>
hass.callWS<HostDisksUsage>({
type: "supervisor/api",
endpoint: "/host/disks/default/usage",
endpoint: `/host/disks/${disk}/usage`,
method: "get",
timeout: 3600, // seconds. This can take a while
params: { max_depth: 3 },
...(maxDepth === undefined ? {} : { params: { max_depth: maxDepth } }),
});
@@ -12,12 +12,14 @@ import "../../components/ha-button";
import "../../components/ha-dialog";
import "../../components/ha-dialog-footer";
import "../../components/ha-icon-button";
import type { ConfigEntry } from "../../data/config_entries";
import type { DataEntryFlowStep } from "../../data/data_entry_flow";
import {
subscribeDataEntryFlowProgress,
subscribeDataEntryFlowProgressed,
} from "../../data/data_entry_flow";
import type { DeviceRegistryEntry } from "../../data/device/device_registry";
import type { RepairsIssue } from "../../data/repairs";
import { DirtyStateProviderMixin } from "../../mixins/dirty-state-provider-mixin";
import { haStyleDialog } from "../../resources/styles";
import type { HomeAssistant } from "../../types";
@@ -30,6 +32,7 @@ import type {
} from "./show-dialog-data-entry-flow";
import { showOptionsFlowDialog } from "./show-dialog-options-flow";
import { showSubConfigFlowDialog } from "./show-dialog-sub-config-flow";
import { showRepairsFlowDialog } from "../repairs-flow/show-dialog-repair-flow";
import "./step-flow-abort";
import "./step-flow-create-entry";
import "./step-flow-external";
@@ -218,7 +221,9 @@ class DataEntryFlowDialog extends DirtyStateProviderMixin<
this._params.dialogClosedCallback({
flowFinished,
entryId:
"result" in this._step ? this._step.result?.entry_id : undefined,
"result" in this._step
? (this._step.result as ConfigEntry)?.entry_id
: undefined,
});
}
@@ -279,7 +284,7 @@ class DataEntryFlowDialog extends DirtyStateProviderMixin<
const devicesLength = this._devices(
this._params.flowConfig.showDevices,
Object.values(this.hass.devices),
this._step.result?.entry_id,
(this._step.result as ConfigEntry)?.entry_id,
this._params.carryOverDevices
).length;
return this.hass.localize(
@@ -485,7 +490,8 @@ class DataEntryFlowDialog extends DirtyStateProviderMixin<
.devices=${this._devices(
this._params.flowConfig.showDevices,
Object.values(this.hass.devices),
this._step.result?.entry_id,
(this._step.result as ConfigEntry)
?.entry_id,
this._params.carryOverDevices
)}
></step-flow-create-entry>
@@ -575,7 +581,7 @@ class DataEntryFlowDialog extends DirtyStateProviderMixin<
const devices = this._devices(
this._params!.flowConfig.showDevices,
Object.values(this.hass.devices),
this._step.result?.entry_id,
(this._step.result as ConfigEntry)?.entry_id,
this._params!.carryOverDevices
);
@@ -681,21 +687,23 @@ class DataEntryFlowDialog extends DirtyStateProviderMixin<
dialogClosedCallback: this._params!.dialogClosedCallback,
});
} else if (_step.next_flow[0] === "options_flow") {
if (_step.type === "create_entry") {
showOptionsFlowDialog(this, _step.result!, {
continueFlowId: _step.next_flow[1],
navigateToResult: this._params!.navigateToResult,
dialogClosedCallback: this._params!.dialogClosedCallback,
});
}
showOptionsFlowDialog(this, _step.result!, {
continueFlowId: _step.next_flow[1],
navigateToResult: this._params!.navigateToResult,
dialogClosedCallback: this._params!.dialogClosedCallback,
});
} else if (_step.next_flow[0] === "config_subentries_flow") {
if (_step.type === "create_entry") {
showSubConfigFlowDialog(this, _step.result!, "", {
continueFlowId: _step.next_flow[1],
navigateToResult: this._params!.navigateToResult,
dialogClosedCallback: this._params!.dialogClosedCallback,
});
}
showSubConfigFlowDialog(this, _step.result!, "", {
continueFlowId: _step.next_flow[1],
navigateToResult: this._params!.navigateToResult,
dialogClosedCallback: this._params!.dialogClosedCallback,
});
} else if (_step.next_flow[0] === "repair_flow") {
showRepairsFlowDialog(this, _step.result as unknown as RepairsIssue, {
continueFlowId: _step.next_flow[1],
navigateToResult: this._params!.navigateToResult,
dialogClosedCallback: this._params!.dialogClosedCallback,
});
} else {
this.closeDialog();
showAlertDialog(this, {
@@ -15,6 +15,7 @@ import type { HaInput } from "../../components/input/ha-input";
import { assistSatelliteSupportsSetupFlow } from "../../data/assist_satellite";
import { getConfigEntries } from "../../data/config_entries";
import type { DataEntryFlowStepCreateEntry } from "../../data/data_entry_flow";
import type { ConfigEntry } from "../../data/config_entries";
import type { DeviceRegistryEntry } from "../../data/device/device_registry";
import { updateDeviceRegistryEntry } from "../../data/device/device_registry";
import {
@@ -40,7 +41,8 @@ class StepFlowCreateEntry extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public step!: DataEntryFlowStepCreateEntry;
@property({ attribute: false })
public step!: DataEntryFlowStepCreateEntry<ConfigEntry>;
@property({ attribute: false }) public devices!: DeviceRegistryEntry[];
@@ -79,7 +81,7 @@ class StepFlowCreateEntry extends LitElement {
if (
this.devices.length !== 1 ||
this.devices[0].primary_config_entry !== this.step.result?.entry_id ||
this.step.result.domain === "voip"
this.step.result?.domain === "voip"
) {
return;
}
@@ -1,138 +0,0 @@
import { consume, type ContextType } from "@lit/context";
import type { CSSResultGroup, PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { throttle } from "../../../../common/util/throttle";
import "../../../../components/ha-color-temp-brightness-picker";
import { apiContext } from "../../../../data/context";
import { UNAVAILABLE } from "../../../../data/entity/entity";
import type { LightEntity } from "../../../../data/light";
import { LightColorMode } from "../../../../data/light";
@customElement("light-color-temp-brightness-picker")
class LightColorTempBrightnessPicker extends LitElement {
@state()
@consume({ context: apiContext, subscribe: true })
private _api!: ContextType<typeof apiContext>;
@property({ attribute: false }) public stateObj!: LightEntity;
@state() private _value?: [number, number];
@state() private _isInteracting?: boolean;
protected render() {
if (!this.stateObj) {
return nothing;
}
return html`
<ha-color-temp-brightness-picker
.value=${this._value}
.minKelvin=${this.stateObj.attributes.min_color_temp_kelvin}
.maxKelvin=${this.stateObj.attributes.max_color_temp_kelvin}
.disabled=${this.stateObj.state === UNAVAILABLE}
@cursor-moved=${this._cursorMoved}
@value-changed=${this._valueChanged}
>
</ha-color-temp-brightness-picker>
`;
}
private _updateValues() {
const stateObj = this.stateObj;
if (stateObj.state !== "on") {
this._value = undefined;
return;
}
const kelvin =
stateObj.attributes.color_mode === LightColorMode.COLOR_TEMP
? stateObj.attributes.color_temp_kelvin
: undefined;
const brightness = stateObj.attributes.brightness;
this._value =
kelvin != null && brightness != null
? [kelvin, Math.max(Math.round((brightness * 100) / 255), 1)]
: undefined;
}
public willUpdate(changedProps: PropertyValues<this>) {
super.willUpdate(changedProps);
if (this._isInteracting || !changedProps.has("stateObj")) {
return;
}
this._updateValues();
}
private _cursorMoved(ev: CustomEvent) {
const value = ev.detail.value;
this._isInteracting = value !== undefined;
if (value === undefined) {
return;
}
this._value = value;
this._throttleUpdateLight();
}
private _throttleUpdateLight = throttle(() => {
this._updateLight();
}, 500);
private _valueChanged(ev: CustomEvent) {
const value = ev.detail.value;
if (value === undefined) {
return;
}
this._value = value;
this._throttleUpdateLight.cancel();
this._updateLight();
}
private _updateLight() {
const [color_temp_kelvin, brightness_pct] = this._value!;
this._api.callService("light", "turn_on", {
entity_id: this.stateObj!.entity_id,
color_temp_kelvin,
brightness_pct,
});
}
static get styles(): CSSResultGroup {
return [
css`
:host {
display: flex;
flex-direction: column;
align-items: center;
}
ha-color-temp-brightness-picker {
height: 45vh;
max-height: 320px;
min-height: 200px;
aspect-ratio: 1;
max-width: 100%;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"light-color-temp-brightness-picker": LightColorTempBrightnessPicker;
}
}
@@ -34,12 +34,4 @@ export const moreInfoControlStyle = css`
.buttons > * {
margin: var(--ha-space-2);
}
ha-attributes {
display: block;
width: 100%;
}
ha-more-info-control-select-container + ha-attributes:not([empty]) {
margin-top: var(--ha-space-4);
}
`;
@@ -41,13 +41,11 @@ import "../components/ha-more-info-state-header";
import "../components/lights/ha-favorite-color-button";
import "../components/lights/ha-more-info-light-favorite-colors";
import "../components/lights/light-color-rgb-picker";
import "../components/lights/light-color-temp-brightness-picker";
import "../components/lights/light-color-temp-picker";
import { moreInfoControlStyle } from "../components/more-info-control-style";
import type { HaDropdownSelectEvent } from "../../../components/ha-dropdown";
type MainControl =
"brightness" | "color_temp" | "color" | "color_temp_brightness";
type MainControl = "brightness" | "color_temp" | "color";
@customElement("more-info-light")
class MoreInfoLight extends LitElement {
@@ -80,31 +78,12 @@ class MoreInfoLight extends LitElement {
.attributeValue=${value}
></ha-attribute-icon>`;
protected willUpdate(changedProps: PropertyValues<this>): void {
super.willUpdate(changedProps);
if (
changedProps.has("stateObj") &&
this.stateObj &&
this._mainControl === "color_temp_brightness" &&
!this._supportsDualAxis(this.stateObj)
) {
this._mainControl = "brightness";
}
}
protected updated(changedProps: PropertyValues<this>): void {
if (changedProps.has("stateObj")) {
this._effect = this.stateObj?.attributes.effect;
}
}
private _supportsDualAxis(stateObj: LightEntity): boolean {
return (
lightSupportsBrightness(stateObj) &&
lightSupportsColorMode(stateObj, LightColorMode.COLOR_TEMP)
);
}
private _setMainControl(ev: any) {
ev.stopPropagation();
this._mainControl = ev.currentTarget.control;
@@ -149,8 +128,6 @@ class MoreInfoLight extends LitElement {
LightEntityFeature.EFFECT
);
const supportsDualAxis = this._supportsDualAxis(this.stateObj);
const showFavoriteColors = Boolean(
this.entry &&
(this.editMode ||
@@ -206,17 +183,6 @@ class MoreInfoLight extends LitElement {
`
: nothing
}
${
supportsDualAxis &&
this._mainControl === "color_temp_brightness"
? html`
<light-color-temp-brightness-picker
.stateObj=${this.stateObj}
>
</light-color-temp-brightness-picker>
`
: nothing
}
<ha-icon-button-group>
${
supportsBrightness
@@ -288,26 +254,6 @@ class MoreInfoLight extends LitElement {
`
: nothing
}
${
supportsDualAxis
? html`
<ha-icon-button-toggle
border-only
.selected=${
this._mainControl === "color_temp_brightness"
}
.disabled=${this.stateObj!.state === UNAVAILABLE}
.label=${this._localize(
"ui.dialogs.more_info_control.light.color_temp_brightness"
)}
.control=${"color_temp_brightness"}
@click=${this._setMainControl}
>
<span class="wheel color-temp-brightness"></span>
</ha-icon-button-toggle>
`
: nothing
}
${
supportsWhite
? html`
@@ -438,15 +384,6 @@ class MoreInfoLight extends LitElement {
rgb(255, 160, 0) 100%
);
}
.wheel.color-temp-brightness {
background-image:
linear-gradient(to top, rgb(102, 102, 102), white),
linear-gradient(90deg, rgb(255, 160, 0), white, rgb(166, 209, 255));
background-blend-mode: multiply;
/* Mask the blended result as one layer; radius clipping bleeds
per background layer at the edge in Gecko */
clip-path: inset(0 round var(--ha-border-radius-circle));
}
*[disabled] .wheel {
filter: grayscale(1) opacity(0.5);
}
+15 -20
View File
@@ -5,7 +5,6 @@ import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { computeAreaName } from "../../common/entity/compute_area_name";
import { computeAttributeNameDisplay } from "../../common/entity/compute_attribute_display";
import { computeDeviceNameDisplay } from "../../common/entity/compute_device_name";
import { computeFloorName } from "../../common/entity/compute_floor_name";
import { getEntityContext } from "../../common/entity/context/get_entity_context";
@@ -15,7 +14,6 @@ import "../../components/ha-attribute-value";
import "../../components/item/ha-list-item-value";
import "../../components/list/ha-grouped-list";
import type { LocalizeKeys } from "../../common/translations/localize";
import { computeShownAttributes } from "../../data/entity/entity_attributes";
import { labelsContext } from "../../data/context";
import type { ExtEntityRegistryEntry } from "../../data/entity/entity_registry";
import type { LabelRegistryEntry } from "../../data/label/label_registry";
@@ -26,6 +24,7 @@ import type { FeatureEnum } from "../../common/entity/get_domain_features";
import { getFeatures } from "../../common/entity/get_domain_features";
import { supportsFeature } from "../../common/entity/supports-feature";
import { titleCase } from "../../common/string/title-case";
import { stringCompare } from "../../common/string/compare";
interface DetailsViewParams {
entityId: string;
@@ -215,7 +214,7 @@ class HaMoreInfoDetails extends LitElement {
stateObj: HassEntity
): {
stateEntries: DetailEntry[];
attributes: string[];
attributes: { name: string; label: string }[];
yamlData: {
state: {
translated: string;
@@ -228,11 +227,14 @@ class HaMoreInfoDetails extends LitElement {
} => {
const translatedState = this.hass.formatEntityState(stateObj);
const detailsAttributes = computeShownAttributes(stateObj);
const detailsAttributeSet = new Set(detailsAttributes);
const builtInAttributes = Object.keys(stateObj.attributes).filter(
(attribute) => !detailsAttributeSet.has(attribute)
);
const attributes = Object.keys(stateObj.attributes)
.map((a) => ({
name: a,
label: this.hass.formatEntityAttributeName(stateObj, a),
}))
.sort((a, b) =>
stringCompare(a.label, b.label, this.hass.locale.language)
);
return {
stateEntries: [
@@ -253,7 +255,7 @@ class HaMoreInfoDetails extends LitElement {
value: this._formatTimestamp(stateObj.last_updated),
},
],
attributes: [...detailsAttributes, ...builtInAttributes],
attributes,
yamlData: {
state: {
translated: translatedState,
@@ -289,7 +291,7 @@ class HaMoreInfoDetails extends LitElement {
);
}
private _renderAttributes(attributes: string[]) {
private _renderAttributes(attributes: { name: string; label: string }[]) {
if (attributes.length === 0) {
return html`<div class="empty">
${this.hass.localize("ui.common.none")}
@@ -304,20 +306,13 @@ class HaMoreInfoDetails extends LitElement {
return attributes.map(
(attribute) => html`
<ha-list-item-value
.label=${computeAttributeNameDisplay(
this.hass.localize,
this._stateObj!,
this.hass.entities,
attribute
)}
>
<ha-list-item-value .label=${attribute.label}>
${
attribute === "supported_features" && featureEnum
attribute.name === "supported_features" && featureEnum
? this._renderFeatures(featureEnum, this._stateObj!)
: html`
<ha-attribute-value
.attribute=${attribute}
.attribute=${attribute.name}
.stateObj=${this._stateObj}
></ha-attribute-value>
`
@@ -1,8 +1,8 @@
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import type { HomeAssistant } from "../../../types";
import { domainToName } from "../../../data/integration";
import type { RepairsIssue } from "../../../data/repairs";
import type { HomeAssistant } from "../../types";
import { domainToName } from "../../data/integration";
import type { RepairsIssue } from "../../data/repairs";
@customElement("dialog-repairs-issue-subtitle")
class DialogRepairsIssueSubtitle extends LitElement {
@@ -1,18 +1,19 @@
import { html, nothing } from "lit";
import type { DataEntryFlowStep } from "../../../data/data_entry_flow";
import { domainToName } from "../../../data/integration";
import type { RepairsIssue } from "../../../data/repairs";
import type { DataEntryFlowStep } from "../../data/data_entry_flow";
import { domainToName } from "../../data/integration";
import type { RepairsIssue } from "../../data/repairs";
import {
createRepairsFlow,
deleteRepairsFlow,
fetchRepairsFlow,
handleRepairsFlowStep,
} from "../../../data/repairs";
} from "../../data/repairs";
import type { DataEntryFlowDialogParams } from "../config-flow/show-dialog-data-entry-flow";
import {
loadDataEntryFlowDialog,
showFlowDialog,
} from "../../../dialogs/config-flow/show-dialog-data-entry-flow";
import type { HomeAssistant } from "../../../types";
} from "../config-flow/show-dialog-data-entry-flow";
import type { HomeAssistant } from "../../types";
import "./dialog-repairs-issue-subtitle";
const mergePlaceholders = (issue: RepairsIssue, step: DataEntryFlowStep) =>
@@ -36,14 +37,14 @@ export const loadRepairFlowDialog = loadDataEntryFlowDialog;
export const showRepairsFlowDialog = (
element: HTMLElement,
issue: RepairsIssue,
dialogClosedCallback?: (params: { flowFinished: boolean }) => void
dialogParams?: Omit<DataEntryFlowDialogParams, "flowConfig">
): void =>
showFlowDialog(
element,
{
startFlowHandler: issue.domain,
domain: issue.domain,
dialogClosedCallback,
...dialogParams,
},
{
flowType: "repair_flow",
+14 -14
View File
@@ -28,8 +28,8 @@ import type {
SortingDirection,
} from "../components/data-table/ha-data-table";
import { showDataTableSettingsDialog } from "../components/data-table/show-dialog-data-table-settings";
import "../components/ha-adaptive-dialog";
import "../components/ha-button";
import "../components/ha-dialog";
import "../components/ha-dialog-footer";
import "../components/ha-dropdown";
import type { HaDropdownSelectEvent } from "../components/ha-dropdown";
@@ -566,16 +566,17 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
</hass-tabs-subpage>
${
this.showFilters && !showPane
? html`<ha-dialog
.open=${true}
? html`<ha-adaptive-dialog
open
flexcontent
width="full"
header-title=${localize("ui.components.subpage-data-table.filters")}
@closed=${this._closeFilters}
>
<ha-icon-button
slot="headerNavigationIcon"
data-dialog="close"
.path=${mdiClose}
@click=${this._closeFilters}
.label=${localize(
"ui.components.subpage-data-table.close_filter"
)}
@@ -596,13 +597,13 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
<slot name="filter-pane"></slot>
</div>
<ha-dialog-footer slot="footer">
<ha-button slot="primaryAction" @click=${this._closeFilters}>
<ha-button slot="primaryAction" data-dialog="close">
${localize("ui.components.subpage-data-table.show_results", {
number: this.data.length,
})}
</ha-button>
</ha-dialog-footer>
</ha-dialog>`
</ha-adaptive-dialog>`
: nothing
}
`;
@@ -934,20 +935,19 @@ export class HaTabsSubpageDataTable extends KeyboardShortcutMixin(LitElement) {
--md-assist-chip-trailing-space: 8px;
}
ha-dialog {
ha-adaptive-dialog {
--dialog-content-padding: 0;
/* Fixed height so the sheet does not resize while filtering. */
--ha-bottom-sheet-height: calc(100dvh - var(--ha-space-12));
--ha-dialog-min-height: calc(var(--safe-height) - var(--ha-space-20));
}
.filter-dialog-content {
height: calc(
100vh -
70px - var(--header-height, 0px) - var(
--safe-area-inset-top,
0px
) - var(--safe-area-inset-bottom, 0px)
);
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
overflow-y: auto;
}
ha-dropdown ha-assist-chip {
@@ -124,7 +124,7 @@ class HaBackupConfigData extends LitElement {
private async _fetchStorageInfo() {
try {
this._storageInfo = await fetchHostDisksUsage(this.hass);
this._storageInfo = await fetchHostDisksUsage(this.hass, "default", 3);
} catch (_err: any) {
this._storageInfo = null;
}
@@ -1393,7 +1393,7 @@ export class EntityRegistrySettingsEditor extends LitElement {
invert: this._switchAsInvert,
target_domain: this._switchAsDomain,
}
)) as DataEntryFlowStepCreateEntry;
)) as DataEntryFlowStepCreateEntry<ConfigEntry>;
if (configFlowResult.result?.entry_id) {
try {
const entry = await this._waitForEntityRegistryUpdate(
@@ -1459,7 +1459,7 @@ export class EntityRegistrySettingsEditor extends LitElement {
invert: this._switchAsInvert,
target_domain: this._switchAsDomain,
}
)) as DataEntryFlowStepCreateEntry;
)) as DataEntryFlowStepCreateEntry<ConfigEntry>;
if (configFlowResult.result?.entry_id) {
try {
const entry = await this._waitForEntityRegistryUpdate(
@@ -13,7 +13,7 @@ import "../../../components/ha-dialog";
import "../../../components/ha-button";
import "../../../components/ha-svg-icon";
import "../../../components/ha-dialog-footer";
import "./dialog-repairs-issue-subtitle";
import "../../../dialogs/repairs-flow/dialog-repairs-issue-subtitle";
import "../../../components/ha-markdown";
import type { RepairsIssue } from "../../../data/repairs";
import { ignoreRepairsIssue } from "../../../data/repairs";
@@ -20,7 +20,7 @@ import type { HomeAssistant } from "../../../types";
import { brandsUrl } from "../../../util/brands-url";
import { fixStatisticsIssue } from "../tools/statistics/fix-statistics";
import { showVacuumSegmentMappingDialog } from "../entities/dialogs/show-dialog-vacuum-segment-mapping";
import { showRepairsFlowDialog } from "./show-dialog-repair-flow";
import { showRepairsFlowDialog } from "../../../dialogs/repairs-flow/show-dialog-repair-flow";
import { showRepairsIssueDialog } from "./show-repair-issue-dialog";
@customElement("ha-config-repairs")
@@ -9,10 +9,12 @@ import {
import type { PropertyValues, TemplateResult } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import { navigate } from "../../../common/navigate";
import { blankBeforePercent } from "../../../common/translations/blank_before_percent";
import "../../../components/ha-alert";
import "../../../components/ha-bar";
import "../../../components/ha-button";
import "../../../components/ha-icon-button";
import "../../../components/ha-icon-next";
@@ -20,6 +22,7 @@ import "../../../components/ha-list";
import "../../../components/ha-list-item";
import "../../../components/ha-segmented-bar";
import type { Segment } from "../../../components/ha-segmented-bar";
import "../../../components/ha-spinner";
import "../../../components/ha-svg-icon";
import { extractApiErrorMessage } from "../../../data/hassio/common";
import type { HassioHostInfo, HostDisksUsage } from "../../../data/hassio/host";
@@ -41,6 +44,7 @@ import {
import { showAlertDialog } from "../../../dialogs/generic/show-dialog-box";
import "../../../layouts/hass-subpage";
import type { HomeAssistant, Route } from "../../../types";
import { bytesToString } from "../../../util/bytes-to-string";
import "../core/ha-config-analytics";
import { showMoveDatadiskDialog } from "./show-dialog-move-datadisk";
import { showMountViewDialog } from "./show-dialog-view-mount";
@@ -62,6 +66,13 @@ class HaConfigSectionStorage extends LitElement {
@state() private _mountsInfo?: SupervisorMounts | null;
// Keyed by mount name. A missing key means the request is still in flight;
// null means it failed, and that row simply shows no usage.
@state() private _mountUsage: Record<string, HostDisksUsage | null> = {};
// Guards against a slow response from a previous reload landing in a newer one.
private _mountUsageGeneration = 0;
protected firstUpdated(changedProps: PropertyValues<this>) {
super.firstUpdated(changedProps);
if (isComponentLoaded(this.hass.config, "hassio")) {
@@ -173,6 +184,7 @@ class HaConfigSectionStorage extends LitElement {
graphic="avatar"
.mount=${mount}
twoline
multiline-secondary
hasMeta
@click=${this._changeMount}
>
@@ -193,13 +205,16 @@ class HaConfigSectionStorage extends LitElement {
${mount.name}
</span>
<span slot="secondary">
${mount.server}${
mount.port ? `:${mount.port}` : nothing
}${
mount.type === SupervisorMountType.NFS
? mount.path
: `:${mount.share}`
}
<span class="mount-address">
${mount.server}${
mount.port ? `:${mount.port}` : ""
}${
mount.type === SupervisorMountType.NFS
? mount.path
: `:${mount.share}`
}
</span>
${this._renderMountUsage(mount)}
</span>
${
mount.state !== SupervisorMountState.ACTIVE
@@ -289,6 +304,39 @@ class HaConfigSectionStorage extends LitElement {
`;
}
private _renderMountUsage(mount: SupervisorMount) {
if (mount.state !== SupervisorMountState.ACTIVE) {
return nothing;
}
if (!(mount.name in this._mountUsage)) {
return html`<div class="mount-usage">
<ha-spinner size="tiny"></ha-spinner>
</div>`;
}
const usage = this._mountUsage[mount.name];
// Without a total there is no ratio to show, so show nothing rather than a
// bar that means something else.
if (!usage?.total_bytes) {
return nothing;
}
const percent = (usage.used_bytes / usage.total_bytes) * 100;
return html`<div class="mount-usage">
<ha-bar
class=${classMap({
"target-warning": percent > 85,
"target-critical": percent > 95,
})}
.value=${percent}
></ha-bar>
<span>
${this.hass.localize("ui.panel.config.storage.detailed_description", {
used: bytesToString(usage.used_bytes),
total: bytesToString(usage.total_bytes),
})}
</span>
</div>`;
}
private async _load() {
this._loadStorageInfo();
try {
@@ -305,7 +353,7 @@ class HaConfigSectionStorage extends LitElement {
private async _loadStorageInfo() {
try {
this._storageInfo = await fetchHostDisksUsage(this.hass);
this._storageInfo = await fetchHostDisksUsage(this.hass, "default", 3);
} catch (err: any) {
this._error = err.message || err;
this._storageInfo = null;
@@ -361,6 +409,34 @@ class HaConfigSectionStorage extends LitElement {
this._error = err.message || err;
this._mountsInfo = null;
}
this._loadMountUsage();
}
// Deliberately not awaited: a mount on a slow or unreachable server can take
// ~30 s to answer, and the rows must paint before then. Only active mounts are
// asked, since the endpoint has nothing to report for the others.
private _loadMountUsage(): void {
const generation = ++this._mountUsageGeneration;
this._mountUsage = {};
this._mountsInfo?.mounts
.filter((mount) => mount.state === SupervisorMountState.ACTIVE)
.forEach((mount) => {
fetchHostDisksUsage(this.hass, mount.name).then(
(usage) => this._setMountUsage(generation, mount.name, usage),
() => this._setMountUsage(generation, mount.name, null)
);
});
}
private _setMountUsage(
generation: number,
name: string,
usage: HostDisksUsage | null
): void {
if (generation !== this._mountUsageGeneration) {
return;
}
this._mountUsage = { ...this._mountUsage, [name]: usage };
}
static styles = css`
@@ -409,6 +485,43 @@ class HaConfigSectionStorage extends LitElement {
color: var(--warning-color);
}
/* multiline-secondary lets the secondary slot wrap, so the address keeps its
own single ellipsized line and only the usage sits below it. */
.mount-address {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mount-usage {
display: flex;
align-items: center;
gap: var(--ha-space-2);
margin-top: var(--ha-space-1);
}
.mount-usage ha-bar {
flex: 0 0 72px;
display: flex;
align-self: center;
--ha-bar-primary-color: var(--metric-bar-ok-color, var(--success-color));
}
.mount-usage ha-bar.target-warning {
--ha-bar-primary-color: var(
--metric-bar-warning-color,
var(--warning-color)
);
}
.mount-usage ha-bar.target-critical {
--ha-bar-primary-color: var(
--metric-bar-critical-color,
var(--error-color)
);
}
.mounts-not-supported {
padding: 0 16px 16px;
}
@@ -1,13 +1,15 @@
import { consume } from "@lit/context";
import type { HassEntity } from "home-assistant-js-websocket";
import { html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { customElement, property, query, state } from "lit/decorators";
import {
consumeEntityState,
consumeLocalize,
} from "../../../common/decorators/consume-context-entry";
import { computeDomain } from "../../../common/entity/compute_domain";
import type { LocalizeFunc } from "../../../common/translations/localize";
import "../../../components/ha-action-result";
import type { HaActionResult } from "../../../components/ha-action-result";
import "../../../components/ha-control-button";
import "../../../components/ha-control-button-group";
import { apiContext, servicesContext } from "../../../data/context";
@@ -63,8 +65,10 @@ class HuiButtonCardFeature extends LitElement implements LovelaceCardFeature {
@state() private _config?: ButtonCardFeatureConfig;
@query("ha-action-result") private _result!: HaActionResult;
private _pressButton() {
if (!this._stateObj) return;
if (!this._stateObj || this._result.busy) return;
const domain = computeDomain(this._stateObj.entity_id);
const service =
@@ -99,7 +103,7 @@ class HuiButtonCardFeature extends LitElement implements LovelaceCardFeature {
forwardHaptic(this, "light");
this._api.callService(domain, service, serviceData);
this._result.run(this._api.callService(domain, service, serviceData));
}
static getStubConfig(): ButtonCardFeatureConfig {
@@ -132,7 +136,9 @@ class HuiButtonCardFeature extends LitElement implements LovelaceCardFeature {
class="press-button"
@click=${this._pressButton}
>
${this._config.action_name ?? this._localize("ui.card.button.press")}
<ha-action-result>
${this._config.action_name ?? this._localize("ui.card.button.press")}
</ha-action-result>
</ha-control-button>
</ha-control-button-group>
`;
@@ -675,6 +675,16 @@ export class HuiEnergyPeriodSelector extends SubscribeMixin(LitElement) {
}
this._updateCollectionPeriod();
// "yesterday" is the only preset whose range never includes today, making
// it the only remembered default that keeps reopening in the past.
const storageKey = getEnergyDefaultPeriodStorageKey(
this.hass,
this.collectionKey
);
if (localStorage.getItem(storageKey) === "yesterday") {
localStorage.setItem(storageKey, "today");
}
}
private _pickPrevious() {
+3
View File
@@ -49,6 +49,8 @@ interface LovelacePanelConfig {
mode: "yaml" | "storage";
}
const EXTERNALLY_UPDATED_TOAST_ID = "lovelace-externally-updated";
let editorLoaded = false;
let resourcesLoaded = false;
@@ -265,6 +267,7 @@ export class LovelacePanel extends LitElement {
return;
}
showToast(this, {
id: EXTERNALLY_UPDATED_TOAST_ID,
message: this.hass!.localize(
"ui.panel.lovelace.externally_updated_toast.message"
),
+2 -2
View File
@@ -5,9 +5,9 @@ export const waColorStyles = css`
--wa-color-brand-fill-loud: var(--ha-color-fill-primary-loud-resting);
--wa-color-brand-fill-normal: var(--ha-color-fill-primary-normal-resting);
--wa-color-brand-fill-quiet: var(--ha-color-fill-primary-quiet-hover);
--wa-color-brand-border-loud: var(--ha-color-border-loud);
--wa-color-brand-border-loud: var(--ha-color-border-primary-loud);
--wa-color-brand-border-normal: var(--ha-color-primary-50);
--wa-color-brand-border-quiet: var(--ha-color-border-quiet);
--wa-color-brand-border-quiet: var(--ha-color-border-primary-quiet);
--wa-color-brand-on-loud: var(--ha-color-on-primary-loud);
--wa-color-brand-on-normal: var(--ha-color-on-primary-normal);
--wa-color-brand-on-quiet: var(--ha-color-on-primary-quiet);
+17 -7
View File
@@ -1,22 +1,26 @@
import type { HassEntity } from "home-assistant-js-websocket";
import type { CSSResultGroup } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import "../components/entity/ha-entity-toggle";
import { customElement, property, query } from "lit/decorators";
import { computeDomain } from "../common/entity/compute_domain";
import "../components/entity/state-info";
import "../components/ha-action-result";
import type { HaActionResult } from "../components/ha-action-result";
import "../components/ha-control-button";
import { UNAVAILABLE } from "../data/entity/entity";
import { haStyle } from "../resources/styles";
import type { HomeAssistant } from "../types";
@customElement("state-card-button")
class StateCardButton extends LitElement {
export class StateCardButton extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public stateObj!: HassEntity;
@property({ attribute: "in-dialog", type: Boolean }) public inDialog = false;
@query("ha-action-result") private _result!: HaActionResult;
protected render() {
const stateObj = this.stateObj;
return html`
@@ -30,7 +34,9 @@ class StateCardButton extends LitElement {
.disabled=${stateObj.state === UNAVAILABLE}
@click=${this._pressButton}
>
${this.hass.localize("ui.card.button.press")}
<ha-action-result>
${this.hass.localize("ui.card.button.press")}
</ha-action-result>
</ha-control-button>
</div>
`;
@@ -38,9 +44,13 @@ class StateCardButton extends LitElement {
private _pressButton(ev: Event) {
ev.stopPropagation();
this.hass.callService("button", "press", {
entity_id: this.stateObj.entity_id,
});
if (this._result.busy) return;
this._result.run(
this.hass.callService(computeDomain(this.stateObj.entity_id), "press", {
entity_id: this.stateObj.entity_id,
})
);
}
static get styles(): CSSResultGroup {
+3 -58
View File
@@ -1,63 +1,8 @@
import type { HassEntity } from "home-assistant-js-websocket";
import type { CSSResultGroup } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import "../components/entity/ha-entity-toggle";
import "../components/entity/state-info";
import "../components/ha-control-button";
import { UNAVAILABLE } from "../data/entity/entity";
import { haStyle } from "../resources/styles";
import type { HomeAssistant } from "../types";
import { customElement } from "lit/decorators";
import { StateCardButton } from "./state-card-button";
@customElement("state-card-input_button")
class StateCardInputButton extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public stateObj!: HassEntity;
@property({ attribute: "in-dialog", type: Boolean }) public inDialog = false;
protected render() {
const stateObj = this.stateObj;
return html`
<div class="horizontal justified layout">
<state-info
.hass=${this.hass}
.stateObj=${stateObj}
.inDialog=${this.inDialog}
></state-info>
<ha-control-button
.disabled=${stateObj.state === UNAVAILABLE}
@click=${this._pressButton}
>
${this.hass.localize("ui.card.button.press")}
</ha-control-button>
</div>
`;
}
private _pressButton(ev: Event) {
ev.stopPropagation();
this.hass.callService("input_button", "press", {
entity_id: this.stateObj.entity_id,
});
}
static get styles(): CSSResultGroup {
return [
haStyle,
css`
ha-control-button {
width: auto;
min-width: 40px;
--control-button-padding: 0 var(--ha-space-4);
--control-button-focus-color: var(--primary-text-color);
--control-button-icon-color: var(--feature-color);
}
`,
];
}
}
class StateCardInputButton extends StateCardButton {}
declare global {
interface HTMLElementTagNameMap {
+5 -2
View File
@@ -1126,7 +1126,11 @@
"zoom_reset": "Reset zoom",
"expand_legend": "More",
"collapse_legend": "Less",
"toggle_visibility": "Toggle visibility"
"toggle_visibility": "Toggle visibility",
"chart": "Chart",
"time": "Time",
"value": "Value",
"category": "Category"
},
"map": {
"error": "Unable to load map"
@@ -1816,7 +1820,6 @@
"toggle": "Toggle",
"color": "Color",
"color_temp": "Temperature",
"color_temp_brightness": "Temperature and brightness",
"set_white": "Set white",
"select_effect": "Select effect",
"color_picker": {
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,192 @@
import { describe, expect, it } from "vitest";
import { canSonifyChart } from "../../../src/components/chart/chart-sonification";
import type { HaECSeries } from "../../../src/resources/echarts/echarts";
const series = (
items: { type: string; id?: string; name?: string; data?: unknown[] }[]
) => items as unknown as HaECSeries;
describe("canSonifyChart", () => {
it("accepts line and bar series that carry data", () => {
expect(
canSonifyChart(
series([
{
type: "line",
data: [
[0, 1],
[1, 2],
],
},
])
)
).toBe(true);
expect(
canSonifyChart(
series([
{
type: "bar",
data: [
[0, 1],
[1, 2],
],
},
])
)
).toBe(true);
});
it("accepts a chart whose empty placeholder series sits beside real data", () => {
expect(
canSonifyChart(
series([
{ type: "bar", data: [] },
{
type: "bar",
data: [
[0, 1],
[1, 2],
],
},
])
)
).toBe(true);
});
it("rejects series types the extension cannot convert", () => {
expect(
canSonifyChart(
series([
{
type: "custom",
data: [
[0, 1],
[1, 2],
],
},
])
)
).toBe(false);
expect(
canSonifyChart(
series([
{
type: "line",
data: [
[0, 1],
[1, 2],
],
},
{
type: "sankey",
data: [
[0, 1],
[1, 2],
],
},
])
)
).toBe(false);
});
it("rejects charts with nothing plotted", () => {
expect(canSonifyChart(series([]))).toBe(false);
expect(canSonifyChart(series([{ type: "line", data: [] }]))).toBe(false);
expect(canSonifyChart(series([{ type: "line" }]))).toBe(false);
});
it("rejects value-first pairs, which the extension reads as a non-numeric y", () => {
// The energy device charts encode [amount, categoryName]. Chart2Music takes
// value as [x, y], so every one of those points is dropped.
expect(
canSonifyChart(
series([
{
type: "bar",
data: [
{ value: [12.5, "sensor.a"] },
{ value: [8.25, "sensor.b"] },
],
},
])
)
).toBe(false);
});
it("rejects a chart left with a single readable point", () => {
// The device pie's slices are all unreadable, leaving only its one-number
// total series — nothing the arrow keys could move between.
expect(
canSonifyChart(
series([
{ type: "pie", data: [{ value: [12.5, "sensor.a"] }] },
{ type: "pie", data: [24.5] },
])
)
).toBe(false);
});
it("ignores the points of legend-hidden series", () => {
// Hiding a series strips its data before it reaches ECharts, so it cannot
// be navigated either.
const chart = series([
{ type: "line", id: "a", data: [[0, 1]] },
{ type: "line", name: "b", data: [[1, 2]] },
]);
expect(canSonifyChart(chart, new Set())).toBe(true);
expect(canSonifyChart(chart, new Set(["b"]))).toBe(false);
expect(canSonifyChart(chart, new Set(["a", "b"]))).toBe(false);
});
it("rejects a series whose points are all gaps", () => {
// Chart2Music drops any point without a numeric y, so a series of nothing
// but nulls converts to an empty group and makes it throw.
expect(
canSonifyChart(
series([
{
type: "line",
data: [
[0, null],
[1, null],
],
},
])
)
).toBe(false);
});
it("accepts a series that only becomes numeric partway through", () => {
expect(
canSonifyChart(
series([
{
type: "line",
data: [
[0, null],
[1, 21.5],
[2, 21.9],
],
},
])
)
).toBe(true);
});
it("reads the y out of object-form points", () => {
expect(
canSonifyChart(
series([
{ type: "bar", data: [{ value: [0, 0.28] }, { value: [1, 0.31] }] },
])
)
).toBe(true);
expect(
canSonifyChart(
series([
{ type: "bar", data: [{ value: [0, null] }, { value: [1, null] }] },
])
)
).toBe(false);
});
});
+32 -1
View File
@@ -1,10 +1,19 @@
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import type { AssistPipeline } from "../../src/data/assist_pipeline";
import {
assistPipelineChanged,
greetingTranslationLanguage,
initialPromptToSubmit,
} from "../../src/components/ha-assist-chat";
// common-translation depends on build-time defines and generated translation
// metadata that are not available in unit tests.
vi.mock("../../src/util/common-translation", () => ({
findAvailableLanguage: (language: string) =>
({ en: "en", "en-US": "en", nl: "nl", pl: "pl" })[language],
getTranslation: vi.fn(),
}));
describe("initialPromptToSubmit", () => {
it("returns a trimmed prompt when submission is requested", () => {
expect(initialPromptToSubmit(" Turn on the lights ", true)).toBe(
@@ -36,3 +45,25 @@ describe("ha-assist-chat pipeline updates", () => {
).toBe(true);
});
});
describe("greetingTranslationLanguage", () => {
it("returns the pipeline language when it differs from the interface language", () => {
expect(greetingTranslationLanguage("pl", "en")).toBe("pl");
});
it("returns undefined when the pipeline language matches the interface language", () => {
expect(greetingTranslationLanguage("nl", "nl")).toBeUndefined();
});
it("returns undefined when the pipeline language resolves to the interface language", () => {
expect(greetingTranslationLanguage("en-US", "en")).toBeUndefined();
});
it("returns undefined when there is no pipeline language", () => {
expect(greetingTranslationLanguage(undefined, "en")).toBeUndefined();
});
it("returns undefined when the pipeline language has no available translation", () => {
expect(greetingTranslationLanguage("xx", "en")).toBeUndefined();
});
});
-52
View File
@@ -1,52 +0,0 @@
import type { HassEntity } from "home-assistant-js-websocket";
import { describe, expect, it } from "vitest";
import { computeShownAttributes } from "../../src/data/entity/entity_attributes";
describe("computeShownAttributes", () => {
it("filters globally hidden attributes", () => {
const stateObj = {
entity_id: "sensor.temperature",
attributes: {
friendly_name: "Office temperature",
unit_of_measurement: "°C",
temperature: 21,
custom_value: "shown",
},
} as unknown as HassEntity;
expect(computeShownAttributes(stateObj)).toEqual([
"temperature",
"custom_value",
]);
});
it("filters domain and device class specific attributes", () => {
const stateObj = {
entity_id: "sensor.status",
attributes: {
device_class: "enum",
options: ["home", "away"],
current_option: "home",
},
} as unknown as HassEntity;
expect(computeShownAttributes(stateObj)).toEqual(["current_option"]);
});
it("keeps device-class attributes for other device classes", () => {
const stateObj = {
entity_id: "sensor.status",
attributes: {
device_class: "temperature",
options: ["home", "away"],
current_option: "home",
},
} as unknown as HassEntity;
expect(computeShownAttributes(stateObj)).toEqual([
"options",
"current_option",
]);
});
});
+65
View File
@@ -0,0 +1,65 @@
import { describe, expect, it, vi } from "vitest";
import { fetchHostDisksUsage } from "../../src/data/hassio/host";
import type { HomeAssistant } from "../../src/types";
const mockHass = () => {
const callWS = vi.fn().mockResolvedValue({
id: "root",
label: "Total",
total_bytes: 2000398934016,
used_bytes: 1240247081779,
});
return { hass: { callWS } as unknown as HomeAssistant, callWS };
};
const sentMessage = (callWS: ReturnType<typeof vi.fn>) =>
callWS.mock.calls[0][0];
describe("fetchHostDisksUsage", () => {
it("targets the data disk and sends no depth by default", async () => {
const { hass, callWS } = mockHass();
await fetchHostDisksUsage(hass);
expect(callWS).toHaveBeenCalledWith({
type: "supervisor/api",
endpoint: "/host/disks/default/usage",
method: "get",
timeout: 3600,
});
});
it("addresses a mount by name", async () => {
const { hass, callWS } = mockHass();
await fetchHostDisksUsage(hass, "media_nas");
expect(sentMessage(callWS).endpoint).toBe("/host/disks/media_nas/usage");
});
// Walking a mount costs a round trip per directory, so the row fetch must let
// the Supervisor apply its own per-target default rather than pinning one.
it("omits max_depth for a mount", async () => {
const { hass, callWS } = mockHass();
await fetchHostDisksUsage(hass, "media_nas");
expect(sentMessage(callWS)).not.toHaveProperty("params");
});
it("sends max_depth only when a caller asks for it", async () => {
const { hass, callWS } = mockHass();
await fetchHostDisksUsage(hass, "default", 3);
expect(sentMessage(callWS).params).toEqual({ max_depth: 3 });
});
it("sends max_depth 0 when explicitly asked, rather than dropping it", async () => {
const { hass, callWS } = mockHass();
await fetchHostDisksUsage(hass, "media_nas", 0);
expect(sentMessage(callWS).params).toEqual({ max_depth: 0 });
});
it("returns the usage tree", async () => {
const { hass } = mockHass();
await expect(fetchHostDisksUsage(hass, "media_nas")).resolves.toMatchObject(
{
total_bytes: 2000398934016,
used_bytes: 1240247081779,
}
);
});
});
@@ -0,0 +1,241 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { HomeAssistant } from "../../../../src/types";
import type * as HostModule from "../../../../src/data/hassio/host";
import type * as MountsModule from "../../../../src/data/supervisor/mounts";
import {
fetchHassioHostInfo,
fetchHostDisksUsage,
} from "../../../../src/data/hassio/host";
import {
fetchSupervisorMounts,
SupervisorMountState,
SupervisorMountType,
SupervisorMountUsage,
} from "../../../../src/data/supervisor/mounts";
import "../../../../src/panels/config/storage/ha-config-section-storage";
// Heavy children are irrelevant here and drag in echarts / ResizeObserver.
vi.mock("../../../../src/panels/config/storage/storage-breakdown-chart", () => {
customElements.define(
"storage-breakdown-chart",
class extends HTMLElement {}
);
return {};
});
vi.mock("../../../../src/layouts/hass-subpage", () => {
customElements.define("hass-subpage", class extends HTMLElement {});
return {};
});
vi.mock("../../../../src/panels/config/core/ha-config-analytics", () => ({}));
vi.mock("../../../../src/components/ha-segmented-bar", () => {
customElements.define("ha-segmented-bar", class extends HTMLElement {});
return {};
});
vi.mock("../../../../src/components/ha-list", () => {
customElements.define("ha-list", class extends HTMLElement {});
return {};
});
vi.mock("../../../../src/components/ha-list-item", () => {
customElements.define("ha-list-item", class extends HTMLElement {});
return {};
});
vi.mock("../../../../src/components/ha-card", () => {
customElements.define("ha-card", class extends HTMLElement {});
return {};
});
vi.mock("../../../../src/components/ha-alert", () => {
customElements.define("ha-alert", class extends HTMLElement {});
return {};
});
vi.mock("../../../../src/components/ha-button", () => {
customElements.define("ha-button", class extends HTMLElement {});
return {};
});
vi.mock("../../../../src/components/ha-icon-button", () => {
customElements.define("ha-icon-button", class extends HTMLElement {});
return {};
});
vi.mock("../../../../src/components/ha-icon-next", () => {
customElements.define("ha-icon-next", class extends HTMLElement {});
return {};
});
vi.mock("../../../../src/components/ha-svg-icon", () => {
customElements.define("ha-svg-icon", class extends HTMLElement {});
return {};
});
vi.mock("../../../../src/components/ha-bar", () => {
customElements.define("ha-bar", class extends HTMLElement {});
return {};
});
vi.mock("../../../../src/components/ha-spinner", () => {
customElements.define("ha-spinner", class extends HTMLElement {});
return {};
});
vi.mock("../../../../src/data/hassio/host", async (importOriginal) => ({
...(await importOriginal<typeof HostModule>()),
fetchHassioHostInfo: vi.fn(),
fetchHostDisksUsage: vi.fn(),
}));
vi.mock("../../../../src/data/supervisor/mounts", async (importOriginal) => ({
...(await importOriginal<typeof MountsModule>()),
fetchSupervisorMounts: vi.fn(),
reloadSupervisorMount: vi.fn(),
}));
interface Deferred<T> {
promise: Promise<T>;
resolve: (value: T) => void;
reject: (reason?: unknown) => void;
}
const deferred = <T>(): Deferred<T> => {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
};
const mount = (name: string, state: SupervisorMountState) => ({
name,
usage: SupervisorMountUsage.MEDIA,
type: SupervisorMountType.CIFS,
server: `${name}.local`,
port: 445,
share: "media",
state,
});
const hass = {
localize: (key: string, params?: Record<string, unknown>) =>
key === "ui.panel.config.storage.detailed_description"
? `${params!.used} of ${params!.total} used`
: key,
config: { components: ["hassio"] },
// _renderDiskLifeTime formats a percentage, which needs the locale.
locale: { language: "en" },
} as unknown as HomeAssistant;
const nextTask = () =>
new Promise<void>((resolve) => {
setTimeout(resolve, 0);
});
const rows = (el: HTMLElement) =>
Array.from(el.shadowRoot!.querySelectorAll("ha-list-item"));
const rowFor = (el: HTMLElement, name: string) =>
rows(el).find((row) => row.textContent?.includes(name));
describe("ha-config-section-storage per-mount usage", () => {
let usageCalls: Record<string, Deferred<any>>;
beforeEach(() => {
usageCalls = {};
vi.mocked(fetchHassioHostInfo).mockResolvedValue({
features: ["mount"],
disk_life_time: 5,
} as any);
vi.mocked(fetchSupervisorMounts).mockResolvedValue({
default_backup_mount: null,
mounts: [
mount("alpha", SupervisorMountState.ACTIVE),
mount("beta", SupervisorMountState.ACTIVE),
mount("broken", SupervisorMountState.FAILED),
],
} as any);
vi.mocked(fetchHostDisksUsage).mockImplementation(
(_hass, disk = "default") => {
// The panel's own disk-metrics call must not interfere with the row calls.
if (disk === "default") {
return new Promise(() => {
// never settles
});
}
usageCalls[disk] = deferred<any>();
return usageCalls[disk].promise;
}
);
});
afterEach(() => {
document.body.replaceChildren();
vi.clearAllMocks();
});
const render = async () => {
const el = document.createElement("ha-config-section-storage");
(el as any).hass = hass;
document.body.append(el);
await nextTask();
await (el as any).updateComplete;
return el as HTMLElement;
};
it("asks only active mounts for usage", async () => {
await render();
const asked = vi
.mocked(fetchHostDisksUsage)
.mock.calls.map((call) => call[1])
.filter((disk) => disk !== "default");
expect(asked).toEqual(["alpha", "beta"]);
expect(asked).not.toContain("broken");
});
it("renders the rows before any usage arrives", async () => {
const el = await render();
expect(rows(el)).toHaveLength(3);
expect(rowFor(el, "alpha")!.querySelector("ha-spinner")).not.toBeNull();
});
it("fills in each row as its own request settles", async () => {
const el = await render();
usageCalls.alpha.resolve({
id: "alpha",
label: "alpha",
total_bytes: 1000,
used_bytes: 500,
});
await nextTask();
await (el as any).updateComplete;
expect(rowFor(el, "alpha")!.textContent).toContain(
"500 Bytes of 1000 Bytes used"
);
// beta has not answered yet, so it must still be pending, not blanked.
expect(rowFor(el, "beta")!.querySelector("ha-spinner")).not.toBeNull();
expect(rowFor(el, "alpha")!.querySelector("ha-spinner")).toBeNull();
});
it("leaves a row without usage when its request fails, and keeps the others", async () => {
const el = await render();
usageCalls.alpha.resolve({
id: "alpha",
label: "alpha",
total_bytes: 1000,
used_bytes: 500,
});
usageCalls.beta.reject(new Error("dead server"));
await nextTask();
await (el as any).updateComplete;
const beta = rowFor(el, "beta")!;
expect(beta.querySelector("ha-spinner")).toBeNull();
expect(beta.querySelector("ha-bar")).toBeNull();
expect(beta.textContent).not.toContain("used");
expect(rowFor(el, "alpha")!.querySelector("ha-bar")).not.toBeNull();
});
it("never shows usage for a mount that is not active", async () => {
const el = await render();
const broken = rowFor(el, "broken")!;
expect(broken.querySelector("ha-spinner")).toBeNull();
expect(broken.querySelector("ha-bar")).toBeNull();
});
});
+233 -129
View File
@@ -2306,15 +2306,15 @@ __metadata:
languageName: node
linkType: hard
"@codemirror/commands@npm:6.10.4":
version: 6.10.4
resolution: "@codemirror/commands@npm:6.10.4"
"@codemirror/commands@npm:6.11.0":
version: 6.11.0
resolution: "@codemirror/commands@npm:6.11.0"
dependencies:
"@codemirror/language": "npm:^6.0.0"
"@codemirror/state": "npm:^6.7.0"
"@codemirror/view": "npm:^6.27.0"
"@lezer/common": "npm:^1.1.0"
checksum: 10/384808f8767967f7a2bf4007cb57e2b137e98fbeb6bb7239502b79ad06fb8039b67b43ab2f5eaf955a2569c65203c7a0e59ad758e7d90ba727936f9b2a77e47b
checksum: 10/a503625e6b5baeb748f391ff9fc83d875d65a0be303f4eefa84b2c128598ce1b792057bf0102276786afdc9e135d2da54074fc7138b5bc2a9be90e3054c6a271
languageName: node
linkType: hard
@@ -2439,15 +2439,15 @@ __metadata:
languageName: node
linkType: hard
"@codemirror/view@npm:6.43.8, @codemirror/view@npm:^6.0.0, @codemirror/view@npm:^6.17.0, @codemirror/view@npm:^6.23.0, @codemirror/view@npm:^6.27.0, @codemirror/view@npm:^6.37.0, @codemirror/view@npm:^6.42.0":
version: 6.43.8
resolution: "@codemirror/view@npm:6.43.8"
"@codemirror/view@npm:6.43.9, @codemirror/view@npm:^6.0.0, @codemirror/view@npm:^6.17.0, @codemirror/view@npm:^6.23.0, @codemirror/view@npm:^6.27.0, @codemirror/view@npm:^6.37.0, @codemirror/view@npm:^6.42.0":
version: 6.43.9
resolution: "@codemirror/view@npm:6.43.9"
dependencies:
"@codemirror/state": "npm:^6.7.0"
crelt: "npm:^1.0.6"
style-mod: "npm:^4.1.0"
w3c-keyname: "npm:^2.2.4"
checksum: 10/88cd5543b09c4b945862ff128aeea48e259a582e1106fbc22370ddf773e629810b43baaf3abdf743150098e07c601ca481186cdf46b537f0312452fa2e7e8f51
checksum: 10/b653dddf81278311985a8f7723f6b0ac50f4ff019725dc4020c40c4d4707f209067e6edbd1f022cb40176d8a0b327c72d3234d2a5c815be689ca2cc1272466d6
languageName: node
linkType: hard
@@ -2737,6 +2737,27 @@ __metadata:
languageName: node
linkType: hard
"@formatjs/ecma402-abstract@npm:2.3.4":
version: 2.3.4
resolution: "@formatjs/ecma402-abstract@npm:2.3.4"
dependencies:
"@formatjs/fast-memoize": "npm:2.2.7"
"@formatjs/intl-localematcher": "npm:0.6.1"
decimal.js: "npm:^10.4.3"
tslib: "npm:^2.8.0"
checksum: 10/573971ffc291096a4b9fcc80b4708124e89bf2e3ac50e0f78b41eb797e9aa1b842f4dc3665e4467a853c738386821769d9e40408a1d25bc73323a1f057a16cf2
languageName: node
linkType: hard
"@formatjs/fast-memoize@npm:2.2.7":
version: 2.2.7
resolution: "@formatjs/fast-memoize@npm:2.2.7"
dependencies:
tslib: "npm:^2.8.0"
checksum: 10/e7e6efc677d63a13d99a854305db471b69f64cbfebdcb6dbe507dab9aa7eaae482ca5de86f343c856ca0a2c8f251672bd1f37c572ce14af602c0287378097d43
languageName: node
linkType: hard
"@formatjs/fast-memoize@npm:3.1.7":
version: 3.1.7
resolution: "@formatjs/fast-memoize@npm:3.1.7"
@@ -2744,12 +2765,33 @@ __metadata:
languageName: node
linkType: hard
"@formatjs/icu-messageformat-parser@npm:3.5.16":
version: 3.5.16
resolution: "@formatjs/icu-messageformat-parser@npm:3.5.16"
"@formatjs/icu-messageformat-parser@npm:2.11.2":
version: 2.11.2
resolution: "@formatjs/icu-messageformat-parser@npm:2.11.2"
dependencies:
"@formatjs/ecma402-abstract": "npm:2.3.4"
"@formatjs/icu-skeleton-parser": "npm:1.8.14"
tslib: "npm:^2.8.0"
checksum: 10/e919eb2a132ac1d54fb1a7e3a3254007649b55196d3818090df92a4268dcddf20cbdf863c06039fbbe7a35a8a3f17bdc172dade99d1f17c1d8a95dcec444c3e3
languageName: node
linkType: hard
"@formatjs/icu-messageformat-parser@npm:3.5.17":
version: 3.5.17
resolution: "@formatjs/icu-messageformat-parser@npm:3.5.17"
dependencies:
"@formatjs/icu-skeleton-parser": "npm:2.1.11"
checksum: 10/406cf08cf01a68e244c7077b726b199dde6ba4c95ecb2d807bff9ec62a9acc77b7f47fad196f02dfbd7a7b8110cccf5d5392e9693069e2484cf96f00899a728f
checksum: 10/cbb9daf23f65e4ef3697eae3be4c6888eda942fcde18929dfc6e96ef6c45052409d20340ece13476eeadd062e222809c46a2f26e3b453c9cbd4979bb6c0b0ae5
languageName: node
linkType: hard
"@formatjs/icu-skeleton-parser@npm:1.8.14":
version: 1.8.14
resolution: "@formatjs/icu-skeleton-parser@npm:1.8.14"
dependencies:
"@formatjs/ecma402-abstract": "npm:2.3.4"
tslib: "npm:^2.8.0"
checksum: 10/2fbe3155c310358820b118d8c9844f314eff3500a82f1c65402434a3095823e1afeaab8d1762b4a59cc5679d82dc4c8c134683565d7cdae4daace23251f46a47
languageName: node
linkType: hard
@@ -2815,6 +2857,15 @@ __metadata:
languageName: node
linkType: hard
"@formatjs/intl-localematcher@npm:0.6.1":
version: 0.6.1
resolution: "@formatjs/intl-localematcher@npm:0.6.1"
dependencies:
tslib: "npm:^2.8.0"
checksum: 10/c7b3bc8395d18670677f207b2fd107561fff5d6394a9b4273c29e0bea920300ec3a2eefead600ebb7761c04a770cada28f78ac059f84d00520bfb57a9db36998
languageName: node
linkType: hard
"@formatjs/intl-localematcher@npm:0.8.13":
version: 0.8.13
resolution: "@formatjs/intl-localematcher@npm:0.8.13"
@@ -2862,6 +2913,24 @@ __metadata:
languageName: node
linkType: hard
"@formatjs/intl@npm:3.1.6":
version: 3.1.6
resolution: "@formatjs/intl@npm:3.1.6"
dependencies:
"@formatjs/ecma402-abstract": "npm:2.3.4"
"@formatjs/fast-memoize": "npm:2.2.7"
"@formatjs/icu-messageformat-parser": "npm:2.11.2"
intl-messageformat: "npm:10.7.16"
tslib: "npm:^2.8.0"
peerDependencies:
typescript: ^5.6.0
peerDependenciesMeta:
typescript:
optional: true
checksum: 10/10ebdce088898ad7de59c10890f7c02fa9f5aa50518ed3fae7e0ae1c392f3973da00464d2a42229cce97916c2eff1e41630e95c18bab01713d2b6ef5c7fd80c1
languageName: node
linkType: hard
"@fullcalendar/core@npm:6.1.21":
version: 6.1.21
resolution: "@fullcalendar/core@npm:6.1.21"
@@ -2969,73 +3038,73 @@ __metadata:
languageName: node
linkType: hard
"@html-eslint/core@npm:^0.64.0":
version: 0.64.0
resolution: "@html-eslint/core@npm:0.64.0"
"@html-eslint/core@npm:^0.65.0":
version: 0.65.0
resolution: "@html-eslint/core@npm:0.65.0"
dependencies:
"@html-eslint/types": "npm:^0.64.0"
"@html-eslint/types": "npm:^0.65.0"
html-standard: "npm:^0.0.13"
checksum: 10/682d735353acd711ee74e681b5949a35f650e425be670509e1ddca5beeb708347b9c3a740b88eb13d19f6cbf54a903e4dcc173988f6c1c6c9fd212b997df6f17
checksum: 10/a41d60108adaf874d5911a0082054df7ec9e23fcec43ff8d304e070f8e995f56350785d3a918256abdf3e25b0ec5dd7b565981b03b7326c6c83a98c3258a4700
languageName: node
linkType: hard
"@html-eslint/eslint-plugin@npm:0.64.0":
version: 0.64.0
resolution: "@html-eslint/eslint-plugin@npm:0.64.0"
"@html-eslint/eslint-plugin@npm:0.65.0":
version: 0.65.0
resolution: "@html-eslint/eslint-plugin@npm:0.65.0"
dependencies:
"@eslint/plugin-kit": "npm:^0.4.1"
"@html-eslint/core": "npm:^0.64.0"
"@html-eslint/parser": "npm:^0.64.0"
"@html-eslint/template-parser": "npm:^0.64.0"
"@html-eslint/template-syntax-parser": "npm:^0.64.0"
"@html-eslint/types": "npm:^0.64.0"
"@html-eslint/core": "npm:^0.65.0"
"@html-eslint/parser": "npm:^0.65.0"
"@html-eslint/template-parser": "npm:^0.65.0"
"@html-eslint/template-syntax-parser": "npm:^0.65.0"
"@html-eslint/types": "npm:^0.65.0"
"@rviscomi/capo.js": "npm:^2.1.0"
html-standard: "npm:^0.0.13"
peerDependencies:
eslint: ">=8.0.0 || ^10.0.0-0"
checksum: 10/3badf5dab3fd62be5f28ca5703004271224a684273ba419cadd7712b47fd57724f6b5ec5e3081e47c3e700fe6ae4066cd71bbde0041d2cb70b3e784a11949eec
checksum: 10/d82ed3be3871044cd1ffc2067bf5a8e81a62fe3c26aff767feb84a89b903a2dada55133feb957326aa0072b4573a4d5c737e5bda87f337542ac0e059e0387a10
languageName: node
linkType: hard
"@html-eslint/parser@npm:^0.64.0":
version: 0.64.0
resolution: "@html-eslint/parser@npm:0.64.0"
"@html-eslint/parser@npm:^0.65.0":
version: 0.65.0
resolution: "@html-eslint/parser@npm:0.65.0"
dependencies:
"@html-eslint/template-syntax-parser": "npm:^0.64.0"
"@html-eslint/types": "npm:^0.64.0"
"@html-eslint/template-syntax-parser": "npm:^0.65.0"
"@html-eslint/types": "npm:^0.65.0"
css-tree: "npm:^3.1.0"
es-html-parser: "npm:0.3.1"
checksum: 10/7ce0205a07cbddf63959ad2bcf39cf8c4252c5a88abce20326c5b02fd13cd0d81ec6ebe569c4c57b4f9a27842ae62592ad5e47bca58d00cb91d413ad1dad98d0
checksum: 10/3cbbd5a3ddc47834324b22119860db3fbc1e0a104baf183ef0aae102bfcd0e9fc48520a16cefe6b67eb2b23524b7720a5729018273862ed7812fd2ac5d0d3a62
languageName: node
linkType: hard
"@html-eslint/template-parser@npm:^0.64.0":
version: 0.64.0
resolution: "@html-eslint/template-parser@npm:0.64.0"
"@html-eslint/template-parser@npm:^0.65.0":
version: 0.65.0
resolution: "@html-eslint/template-parser@npm:0.65.0"
dependencies:
"@html-eslint/types": "npm:^0.64.0"
"@html-eslint/types": "npm:^0.65.0"
es-html-parser: "npm:0.3.1"
checksum: 10/2dcdc87c2355af6f45814217a812d54acbab7eca6edb6c0cf7ef0fbafca5a7c75c03b6f4acb2dd4f843abb2e34dae2720cd623f0ec0ccfd4570961b025f461a1
checksum: 10/ae5f8c50bd006459974a62806a28e5c0efee38ec6dfd529613792545cc2659ba7e97dbfb72c1940be77d3f378dc6a17d973a4e923bda71658bc1629ecd2c7199
languageName: node
linkType: hard
"@html-eslint/template-syntax-parser@npm:^0.64.0":
version: 0.64.0
resolution: "@html-eslint/template-syntax-parser@npm:0.64.0"
"@html-eslint/template-syntax-parser@npm:^0.65.0":
version: 0.65.0
resolution: "@html-eslint/template-syntax-parser@npm:0.65.0"
dependencies:
"@html-eslint/types": "npm:^0.64.0"
checksum: 10/774d319f8485cbd20b351767c693e1e281d8e3b7d8c6bf01a2db407684c0656d8789535df0f2357d9bc0df2299023d2d1045a425b72dea1144d265340cfa9031
"@html-eslint/types": "npm:^0.65.0"
checksum: 10/bf633583d7c18d7c1d5d5bb7974d61a69ffb616936eb12072e2f5228d832548152423d8be48a0e4dce7b52e9909b6ebc7816b8de5be8eab414fabe31d1d6e146
languageName: node
linkType: hard
"@html-eslint/types@npm:^0.64.0":
version: 0.64.0
resolution: "@html-eslint/types@npm:0.64.0"
"@html-eslint/types@npm:^0.65.0":
version: 0.65.0
resolution: "@html-eslint/types@npm:0.65.0"
dependencies:
"@types/css-tree": "npm:^2.3.11"
"@types/estree": "npm:^1.0.6"
es-html-parser: "npm:0.3.1"
checksum: 10/9813e675f06fb950911a0b51e113ff05b3eabe73e6396a3ac2afeeab557c7608d883d092f40fd77f843f362f6cbfefa3cd45df3e0135ce71b5f9dd281eef0caa
checksum: 10/224f2e85eaa08181021acf73c5d915d58e9731085a785db084177c2b899a1e8e39c0e61d0ac28a277bf0f3d53f97ecab9657f2a998cfbc1e4f511981ebe64fb9
languageName: node
linkType: hard
@@ -6383,12 +6452,12 @@ __metadata:
languageName: node
linkType: hard
"@vitest/coverage-v8@npm:4.1.10":
version: 4.1.10
resolution: "@vitest/coverage-v8@npm:4.1.10"
"@vitest/coverage-v8@npm:4.1.11":
version: 4.1.11
resolution: "@vitest/coverage-v8@npm:4.1.11"
dependencies:
"@bcoe/v8-coverage": "npm:^1.0.2"
"@vitest/utils": "npm:4.1.10"
"@vitest/utils": "npm:4.1.11"
ast-v8-to-istanbul: "npm:^1.0.0"
istanbul-lib-coverage: "npm:^3.2.2"
istanbul-lib-report: "npm:^3.0.1"
@@ -6398,34 +6467,34 @@ __metadata:
std-env: "npm:^4.0.0-rc.1"
tinyrainbow: "npm:^3.1.0"
peerDependencies:
"@vitest/browser": 4.1.10
vitest: 4.1.10
"@vitest/browser": 4.1.11
vitest: 4.1.11
peerDependenciesMeta:
"@vitest/browser":
optional: true
checksum: 10/e593f5205a65d10f200e68a99e720d7a9f5e9be65d8160e4f0b6b5f1a7a87f0453c03e79fd1c022dbd7fb26a22657ca4d9410ae27b36da90d41d7ca04f681ab1
checksum: 10/b6171ec592e0017c3b10954a9400b10af0becf944a0533af01b002a4cc35b6e562a353b4837451a44b73c5561de358c23a2c135d4e1e79bc9041ebb241a68440
languageName: node
linkType: hard
"@vitest/expect@npm:4.1.10":
version: 4.1.10
resolution: "@vitest/expect@npm:4.1.10"
"@vitest/expect@npm:4.1.11":
version: 4.1.11
resolution: "@vitest/expect@npm:4.1.11"
dependencies:
"@standard-schema/spec": "npm:^1.1.0"
"@types/chai": "npm:^5.2.2"
"@vitest/spy": "npm:4.1.10"
"@vitest/utils": "npm:4.1.10"
"@vitest/spy": "npm:4.1.11"
"@vitest/utils": "npm:4.1.11"
chai: "npm:^6.2.2"
tinyrainbow: "npm:^3.1.0"
checksum: 10/487fcad404a68968a54ae5fb9d099f12170cd793420a04b34a5606516317090c50a8303ab687c70166ee181864e3e138941d4a96d0405434dcd37696b3105350
checksum: 10/9bfcfe5ad926ab58beea1c700dc057f17422f14516506f8fc12c9881ed3e81d4c2faadb768042c8497fdee7007e201c1bd3e7d2e91157dbb47fb5c07c4c02aaa
languageName: node
linkType: hard
"@vitest/mocker@npm:4.1.10":
version: 4.1.10
resolution: "@vitest/mocker@npm:4.1.10"
"@vitest/mocker@npm:4.1.11":
version: 4.1.11
resolution: "@vitest/mocker@npm:4.1.11"
dependencies:
"@vitest/spy": "npm:4.1.10"
"@vitest/spy": "npm:4.1.11"
estree-walker: "npm:^3.0.3"
magic-string: "npm:^0.30.21"
peerDependencies:
@@ -6436,56 +6505,56 @@ __metadata:
optional: true
vite:
optional: true
checksum: 10/ae9645d1bcdad3ab7de7182feb4f1c9148a5ff97cef19581eec9257112aace94889eee9a1ad12e40ce59453ac05f52453b5fdb49ff76a31af8ccdbaaa4471ef3
checksum: 10/00b6e1266d8403194b49313e3a9a1af0dff2c773f4b2df11f4955fa0f244fd4b59484cd23381dfef8af30298e2651c1aaa42b439fdbc871bb4bb911de38a9509
languageName: node
linkType: hard
"@vitest/pretty-format@npm:4.1.10":
version: 4.1.10
resolution: "@vitest/pretty-format@npm:4.1.10"
"@vitest/pretty-format@npm:4.1.11":
version: 4.1.11
resolution: "@vitest/pretty-format@npm:4.1.11"
dependencies:
tinyrainbow: "npm:^3.1.0"
checksum: 10/e4f6907143ab0e40dda29d70b17027586c92921d622091321f10512e660b3995dcee7aa56e17b750b72560f295e25f96035372348415f18ebfd39b66a55b4704
checksum: 10/2dfc2f20dbe1c4dbea33ec42e85a8b5648aa6585521bea47573406f5cefb81cd3b86b71f981c4e3d69946e77252cb42a700416c1dc656bb0478cb2932c953cdc
languageName: node
linkType: hard
"@vitest/runner@npm:4.1.10":
version: 4.1.10
resolution: "@vitest/runner@npm:4.1.10"
"@vitest/runner@npm:4.1.11":
version: 4.1.11
resolution: "@vitest/runner@npm:4.1.11"
dependencies:
"@vitest/utils": "npm:4.1.10"
"@vitest/utils": "npm:4.1.11"
pathe: "npm:^2.0.3"
checksum: 10/2c962cb13af0880990036808a35679b7ac6657c8f542490234c2faa6ffd2ab080ac6bf21b487c64d84aa635cfb37b49eb679098c2003a100dfc6c4d5e87bf055
checksum: 10/5247df824fa28b458ba0102592dfec50707982193b62076db941fbe5d7c88fb7067e68a33c194db0092bbe35459cfbeaed33b3c667e5f02192c18baeb4f56239
languageName: node
linkType: hard
"@vitest/snapshot@npm:4.1.10":
version: 4.1.10
resolution: "@vitest/snapshot@npm:4.1.10"
"@vitest/snapshot@npm:4.1.11":
version: 4.1.11
resolution: "@vitest/snapshot@npm:4.1.11"
dependencies:
"@vitest/pretty-format": "npm:4.1.10"
"@vitest/utils": "npm:4.1.10"
"@vitest/pretty-format": "npm:4.1.11"
"@vitest/utils": "npm:4.1.11"
magic-string: "npm:^0.30.21"
pathe: "npm:^2.0.3"
checksum: 10/7940d83ffd2fbebf9a04ea31e196b7e8bf981093ec739950959fe8dd29caa33c80823780fb4b1063d9459c44a0a8d8b2748c00dfb6941becd7404e6d687eea01
checksum: 10/5d096373fb4b102f65ff884844a18c2d2e7d88caf68a64a842a245573311b2d531ca5a94ebf7e4fe39324e71a78670f74c949d4ec2cad3764c3f4c272b84d982
languageName: node
linkType: hard
"@vitest/spy@npm:4.1.10":
version: 4.1.10
resolution: "@vitest/spy@npm:4.1.10"
checksum: 10/7c1b79a95474338e0659f0f2e43be4df1ef7939ff5b37b044954e0287582947803bd417508f44a7f244809672309d9b3dd67660b704ec3fe7f323cc958ae47a3
"@vitest/spy@npm:4.1.11":
version: 4.1.11
resolution: "@vitest/spy@npm:4.1.11"
checksum: 10/d49a7ed7501080e5f817d61250a169a46fcc7901887e4985a1e08705ce79aea8d1edcffd74f4dc6669ea1bc3d717a39354ce89c67188d81a63dc439d42f195f6
languageName: node
linkType: hard
"@vitest/utils@npm:4.1.10":
version: 4.1.10
resolution: "@vitest/utils@npm:4.1.10"
"@vitest/utils@npm:4.1.11":
version: 4.1.11
resolution: "@vitest/utils@npm:4.1.11"
dependencies:
"@vitest/pretty-format": "npm:4.1.10"
"@vitest/pretty-format": "npm:4.1.11"
convert-source-map: "npm:^2.0.0"
tinyrainbow: "npm:^3.1.0"
checksum: 10/95484aad55c7b00bbcd4963e27cbb86fe207620a6093973d68da9d0a06bad37c388d84c9ab43d5f35d88e46c8f376a5592d9c54c025c958361160f4802bb25ee
checksum: 10/f05381e12d0926db7b01bfaae9a577fa664d36b96c23df185e4b0f6dad3a9fb59ac00931613da53b4511ee6ab473a14ac500c72c5ec5e9b3c3042875051f20c4
languageName: node
linkType: hard
@@ -7297,7 +7366,7 @@ __metadata:
languageName: node
linkType: hard
"browserslist@npm:^4.24.0, browserslist@npm:^4.28.2, browserslist@npm:^4.28.7":
"browserslist@npm:4.28.8, browserslist@npm:^4.24.0, browserslist@npm:^4.28.2, browserslist@npm:^4.28.7":
version: 4.28.8
resolution: "browserslist@npm:4.28.8"
dependencies:
@@ -7501,6 +7570,15 @@ __metadata:
languageName: node
linkType: hard
"chart2music@npm:^1.20.0":
version: 1.20.0
resolution: "chart2music@npm:1.20.0"
dependencies:
"@formatjs/intl": "npm:3.1.6"
checksum: 10/ed421708740e3644a72356c9f313d3bcb7cac26d94a7fb209fa4bc8f9bc24061ed63f98da89277684e71f9402b5414cd45bb60f01c01e576803ba3dca755f4ba
languageName: node
linkType: hard
"chokidar@npm:^3.5.3":
version: 3.6.0
resolution: "chokidar@npm:3.6.0"
@@ -8098,7 +8176,7 @@ __metadata:
languageName: node
linkType: hard
"decimal.js@npm:^10.6.0":
"decimal.js@npm:^10.4.3, decimal.js@npm:^10.6.0":
version: 10.6.0
resolution: "decimal.js@npm:10.6.0"
checksum: 10/c0d45842d47c311d11b38ce7ccc911121953d4df3ebb1465d92b31970eb4f6738a065426a06094af59bee4b0d64e42e7c8984abd57b6767c64ea90cf90bb4a69
@@ -8364,6 +8442,17 @@ __metadata:
languageName: node
linkType: hard
"echarts-extension-chart2music@npm:0.1.0":
version: 0.1.0
resolution: "echarts-extension-chart2music@npm:0.1.0"
dependencies:
chart2music: "npm:^1.20.0"
peerDependencies:
echarts: ">=5.0.0 <7"
checksum: 10/55a82c770355228b2a899007e488e5b60171cbaf5d83363a298c4214d9e4a5df1afd1c7805495f42f2ad6edd7c062c8b366f1f8ba7b1a902f2db7eb47fbfcccf
languageName: node
linkType: hard
"echarts@npm:6.1.0":
version: 6.1.0
resolution: "echarts@npm:6.1.0"
@@ -9868,10 +9957,10 @@ __metadata:
languageName: node
linkType: hard
"hls.js@npm:1.7.0":
version: 1.7.0
resolution: "hls.js@npm:1.7.0"
checksum: 10/8bb97ba86b72a3be057bd0036b01df83adfe7dc10c76a5e177b24bae3f380e19d4180e061d8140b3b826e10c9f61a7b230e10019dcb81619ab2fc41e4d292ed9
"hls.js@npm:1.7.1":
version: 1.7.1
resolution: "hls.js@npm:1.7.1"
checksum: 10/5bf1c5ba1cbb82a4274f55afe62c361137bad7a60e6c7a3cfd881ca47a6883357b660c080982a7393c840bcd710c6ca97ad12094a2db72d0c0dbe1cc9e48edb1
languageName: node
linkType: hard
@@ -9888,14 +9977,14 @@ __metadata:
"@braintree/sanitize-url": "npm:7.1.2"
"@bundle-stats/plugin-webpack-filter": "npm:4.22.2"
"@codemirror/autocomplete": "npm:6.20.3"
"@codemirror/commands": "npm:6.10.4"
"@codemirror/commands": "npm:6.11.0"
"@codemirror/lang-jinja": "npm:6.0.1"
"@codemirror/lang-yaml": "npm:6.1.3"
"@codemirror/language": "npm:6.12.4"
"@codemirror/lint": "npm:6.9.7"
"@codemirror/search": "npm:6.7.1"
"@codemirror/state": "npm:6.7.1"
"@codemirror/view": "npm:6.43.8"
"@codemirror/view": "npm:6.43.9"
"@date-fns/tz": "npm:1.5.0"
"@egjs/hammerjs": "npm:2.0.17"
"@eslint/js": "npm:10.0.1"
@@ -9916,7 +10005,7 @@ __metadata:
"@fullcalendar/timegrid": "npm:6.1.21"
"@gfx/zopfli": "npm:1.0.15"
"@home-assistant/webawesome": "npm:3.7.0-ha.0"
"@html-eslint/eslint-plugin": "npm:0.64.0"
"@html-eslint/eslint-plugin": "npm:0.65.0"
"@lezer/highlight": "npm:1.2.3"
"@lit-labs/motion": "npm:1.1.0"
"@lit-labs/observers": "npm:2.1.0"
@@ -9958,13 +10047,14 @@ __metadata:
"@types/tar": "npm:7.0.87"
"@typescript/native": "npm:[email protected]"
"@vibrant/color": "npm:4.0.4"
"@vitest/coverage-v8": "npm:4.1.10"
"@vitest/coverage-v8": "npm:4.1.11"
"@vvo/tzdb": "npm:6.198.0"
"@webcomponents/scoped-custom-element-registry": "npm:0.0.10"
"@webcomponents/webcomponentsjs": "npm:2.8.0"
babel-loader: "npm:10.1.1"
babel-plugin-polyfill-corejs3: "npm:1.0.0"
barcode-detector: "npm:3.2.2"
browserslist: "npm:4.28.8"
browserslist-useragent-regexp: "npm:4.1.4"
cally: "npm:0.9.2"
color-name: "npm:2.1.1"
@@ -9978,6 +10068,7 @@ __metadata:
del: "npm:8.0.1"
dialog-polyfill: "npm:0.5.6"
echarts: "npm:6.1.0"
echarts-extension-chart2music: "npm:0.1.0"
element-internals-polyfill: "npm:3.0.2"
eslint: "npm:10.8.1"
eslint-config-prettier: "npm:10.1.8"
@@ -9996,12 +10087,12 @@ __metadata:
gulp: "npm:5.0.1"
gulp-json-transform: "npm:0.5.0"
gulp-rename: "npm:2.1.0"
hls.js: "npm:1.7.0"
hls.js: "npm:1.7.1"
home-assistant-js-websocket: "npm:9.6.0"
html-minifier-terser: "npm:7.2.0"
husky: "npm:9.1.7"
idb-keyval: "npm:6.3.0"
intl-messageformat: "npm:11.2.13"
intl-messageformat: "npm:11.2.14"
js-yaml: "npm:5.3.0"
jsdom: "npm:30.0.1"
jszip: "npm:3.10.1"
@@ -10009,6 +10100,7 @@ __metadata:
leaflet-draw: "patch:leaflet-draw@npm%3A1.0.4#./.yarn/patches/leaflet-draw-npm-1.0.4-0ca0ebcf65.patch"
leaflet.markercluster: "npm:1.5.3"
license-checker-rseidelsohn: "npm:5.0.1"
lightningcss: "npm:1.33.0"
lint-staged: "npm:17.3.0"
lit: "npm:3.3.3"
lit-analyzer: "npm:2.0.3"
@@ -10017,7 +10109,7 @@ __metadata:
lodash.template: "npm:4.18.1"
luxon: "npm:3.7.2"
map-stream: "npm:0.0.7"
marked: "npm:18.0.9"
marked: "npm:18.0.10"
memoize-one: "npm:6.0.0"
minify-literals: "npm:2.1.0"
node-vibrant: "npm:4.0.4"
@@ -10042,7 +10134,7 @@ __metadata:
typescript: "npm:6.0.3"
typescript-eslint: "npm:8.67.0"
vite-tsconfig-paths: "npm:6.1.1"
vitest: "npm:4.1.10"
vitest: "npm:4.1.11"
webpack-stats-plugin: "npm:1.1.3"
webpackbar: "npm:7.0.0"
weekstart: "npm:2.0.0"
@@ -10349,13 +10441,25 @@ __metadata:
languageName: node
linkType: hard
"intl-messageformat@npm:11.2.13":
version: 11.2.13
resolution: "intl-messageformat@npm:11.2.13"
"intl-messageformat@npm:10.7.16":
version: 10.7.16
resolution: "intl-messageformat@npm:10.7.16"
dependencies:
"@formatjs/ecma402-abstract": "npm:2.3.4"
"@formatjs/fast-memoize": "npm:2.2.7"
"@formatjs/icu-messageformat-parser": "npm:2.11.2"
tslib: "npm:^2.8.0"
checksum: 10/c19b77c5e495ce8b0d1aa0d95444bf3a4f73886805f1e08d7159b364abcf2f63686b2ccf202eaafb0e39a0e9fde61848b8dd2db1679efd4f6ec8f6a3d0e77928
languageName: node
linkType: hard
"intl-messageformat@npm:11.2.14":
version: 11.2.14
resolution: "intl-messageformat@npm:11.2.14"
dependencies:
"@formatjs/fast-memoize": "npm:3.1.7"
"@formatjs/icu-messageformat-parser": "npm:3.5.16"
checksum: 10/7da1b2e01258ae310cd3aeabd6302497a70755ef860cc666405e89ede2927f5d5995d8a6f9be556d7dfc4f7b1eb0a13b26c9633234fdb4afea762013bb25af65
"@formatjs/icu-messageformat-parser": "npm:3.5.17"
checksum: 10/2720155c42bfd99a00d41f7bc9e2f5c2c83c2b6cb2353006b7d8e736a0f1ed20d0147406c1aeba0748731a0ce588c67304b52eb2fe92b42b928bf1ef6bffdcdf
languageName: node
linkType: hard
@@ -11345,7 +11449,7 @@ __metadata:
languageName: node
linkType: hard
"lightningcss@npm:^1.32.0, lightningcss@npm:^1.33.0":
"lightningcss@npm:1.33.0, lightningcss@npm:^1.32.0, lightningcss@npm:^1.33.0":
version: 1.33.0
resolution: "lightningcss@npm:1.33.0"
dependencies:
@@ -11653,12 +11757,12 @@ __metadata:
languageName: node
linkType: hard
"marked@npm:18.0.9":
version: 18.0.9
resolution: "marked@npm:18.0.9"
"marked@npm:18.0.10":
version: 18.0.10
resolution: "marked@npm:18.0.10"
bin:
marked: bin/marked.js
checksum: 10/99d337c50acd57034734f8460f25b28e9658b15627f950092707cb443b840f0bd29fe343bf211b948e643303c34abdb5b5a12cf5245a846635750697524bb747
checksum: 10/0d4b560e0773fd6ba30a4e7560ba7ae1f05d47b23926ad8337d9f80c598dfdbffdf2f81e773a1789e8bc9d9c2c2d3d0c9143a54d32581c336a3bbb1bad80461c
languageName: node
linkType: hard
@@ -15366,17 +15470,17 @@ __metadata:
languageName: node
linkType: hard
"vitest@npm:4.1.10":
version: 4.1.10
resolution: "vitest@npm:4.1.10"
"vitest@npm:4.1.11":
version: 4.1.11
resolution: "vitest@npm:4.1.11"
dependencies:
"@vitest/expect": "npm:4.1.10"
"@vitest/mocker": "npm:4.1.10"
"@vitest/pretty-format": "npm:4.1.10"
"@vitest/runner": "npm:4.1.10"
"@vitest/snapshot": "npm:4.1.10"
"@vitest/spy": "npm:4.1.10"
"@vitest/utils": "npm:4.1.10"
"@vitest/expect": "npm:4.1.11"
"@vitest/mocker": "npm:4.1.11"
"@vitest/pretty-format": "npm:4.1.11"
"@vitest/runner": "npm:4.1.11"
"@vitest/snapshot": "npm:4.1.11"
"@vitest/spy": "npm:4.1.11"
"@vitest/utils": "npm:4.1.11"
es-module-lexer: "npm:^2.0.0"
expect-type: "npm:^1.3.0"
magic-string: "npm:^0.30.21"
@@ -15394,12 +15498,12 @@ __metadata:
"@edge-runtime/vm": "*"
"@opentelemetry/api": ^1.9.0
"@types/node": ^20.0.0 || ^22.0.0 || >=24.0.0
"@vitest/browser-playwright": 4.1.10
"@vitest/browser-preview": 4.1.10
"@vitest/browser-webdriverio": 4.1.10
"@vitest/coverage-istanbul": 4.1.10
"@vitest/coverage-v8": 4.1.10
"@vitest/ui": 4.1.10
"@vitest/browser-playwright": 4.1.11
"@vitest/browser-preview": 4.1.11
"@vitest/browser-webdriverio": 4.1.11
"@vitest/coverage-istanbul": 4.1.11
"@vitest/coverage-v8": 4.1.11
"@vitest/ui": 4.1.11
happy-dom: "*"
jsdom: "*"
vite: ^6.0.0 || ^7.0.0 || ^8.0.0
@@ -15430,7 +15534,7 @@ __metadata:
optional: false
bin:
vitest: ./vitest.mjs
checksum: 10/020843460fe696c23be2a363634dde4daf54625f1c443c24066ba3f87c478b0ccfdd5124343ba30eb092f54902ffea09f4bed0af4a16a9ee805e494ee2dce34e
checksum: 10/054f1e25d90d911693b0b93c5b85a7c3105775aa3e39c3279c7d3e7af719e2d94070a898d6d74292445366c1215bb91d07063989ac0965973f0c5ae22ad3b06b
languageName: node
linkType: hard