Compare commits

..

47 Commits

Author SHA1 Message Date
Paul Bottein
e0a24ca641 Remove floors from the area overview dashboard 2025-08-20 18:50:11 +02:00
Copilot
057fad55e8 Rename "Logbook" to "Activity" in user-facing strings (#26619)
* Initial plan

* Update user-facing strings from "Logbook" to "Activity"

Co-authored-by: balloob <1444314+balloob@users.noreply.github.com>

* Apply suggestions from code review

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: balloob <1444314+balloob@users.noreply.github.com>
Co-authored-by: Paulus Schoutsen <balloob@gmail.com>
2025-08-20 17:36:32 +03:00
Aidan Timson
d1a8165de2 Allow clock to show without background (#26554) 2025-08-20 17:31:25 +03:00
Petar Petrov
31c555445d Bar gauge card feature for sensors (#26585)
* Bar graph card feature for sensors

* use state color

* tweak name

* rename and improve colors

* Update src/panels/lovelace/card-features/hui-progress-bar-card-feature.ts

Co-authored-by: Paul Bottein <paul.bottein@gmail.com>

* rename to Bar gauge

* name fix

* Force dark-only themes to be in dark mode (#26583)

* Fetch translations for switch-as-x domains (#26566)

* Fetch translations for switch-as-x domains

* no cache

* Migrate ha-progress-ring to webawesome (#26542)

* Migrate all sl-animation usages to wa-animation (#26544)

* Remove unused shoelace animation import (#26584)

* Bump actions/checkout from 4.2.2 to 5.0.0 (#26586)

Bumps [actions/checkout](https://github.com/actions/checkout) from 4.2.2 to 5.0.0.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4.2.2...v5.0.0)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: 5.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>

* Add date card feature (#26531)

* Add date card feature

* Rename from "date" to "date-set"

* Better label

* Fix

* Parse date for datetime

* Show accurate state of show_header_toggle in entities card editor (#26564)

* Add clipboard support to condition card conditions (#26535)

* Add copy paste support to condition card conditions

* Don't clear the clipboard

* Fix selection

* Add cut and duplicate

* Remove

* Fix event

* Fix picture glance mouse pointer (#26391)

* Fix picture glance mouse pointer

* More action updates

* Capitalize "Samba" as the name of the Open Source project (#26590)

Capitalize "Samba" as name for the Open Source project

* Update dependency @codemirror/language to v6.11.3 (#26589)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>

* Set default forecast option in weather forecast card editor (#26592)

* Add fan oscillate feature (#26519)

* First working fan-oscillate feature

This a first working impl, need at least to do:
- Tooltip not yet "Yes/No"
- Need implementation verification

* Use same strings as more info label for control tooltip

* Add missing label for editor

* Rename some variables

* Add fan features in gallery

* Fix lint:types by applying suggestions from code review

Co-authored-by: Aidan Timson <aidan@timmo.dev>

* fix lint new line after import

* fix typo

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>

* fix event value type treating

* remove type magic as suggested

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>

* Update localize.ts

Complete suggestion change to have tooltip

* fix lint by removing unused import

---------

Co-authored-by: Aidan Timson <aidan@timmo.dev>
Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>

* Lock file maintenance (#26598)

Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>

* Fix legend in devices-detail-graph (#26596)

* Change Suggest with AI label to Suggest (#26603)

* Automation row remember collapsed status (#26604)

* Summaries overview dashboard (#26594)

* Change categories to lights, climate and security

* Add entities grouped by devices

* Fix garage cover device class

* Rename category to summary

* Add media players categories

* Reduce spacing

* Remove person

* Remove translations

* Use media player cards

* Display entities without device

* Add missing entity category

* Update src/panels/lovelace/strategies/overview/helpers/overview-summaries.ts

Co-authored-by: Paulus Schoutsen <balloob@gmail.com>

* Add white space

---------

Co-authored-by: Paulus Schoutsen <balloob@gmail.com>

* Fix entities not showing due to JavaScript crash (fixes #25363) (#26599)

* Fix entity UI crash from undefined entity names (fixes #25363)

* Fix mock function type compatibility in test

- Update mock to handle string | undefined parameter
- Maintain test functionality while satisfying type checker

* Simplify approach based on reviewer feedback

- Use String() coercion to preserve numeric entity names (e.g., power strip outlets)
- Single line change instead of complex type validation across multiple files
- Revert stripPrefixFromEntityName to original (no longer needs null handling)
- Remove separate test file, update existing test to expect stringified numbers
- More conservative approach that preserves data rather than replacing with fallbacks

* History chart card feature (#26555)

Co-authored-by: Bram Kragten <mail@bramkragten.nl>

* Fix space in overview welcome caption (#26618)

* Fix space in overview welcome caption

* Update src/panels/lovelace/strategies/overview/overview-home-view-strategy.ts

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Paul Bottein <paul.bottein@gmail.com>
Co-authored-by: karwosts <32912880+karwosts@users.noreply.github.com>
Co-authored-by: Simon Lamon <32477463+silamon@users.noreply.github.com>
Co-authored-by: Wendelin <12148533+wendevlin@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Aidan Timson <aidan@timmo.dev>
Co-authored-by: Norbert Rittel <norbert@rittel.de>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: pcan08 <155250376+pcan08@users.noreply.github.com>
Co-authored-by: Paulus Schoutsen <balloob@gmail.com>
Co-authored-by: Drinor Dalipi <45405217+Drinory@users.noreply.github.com>
Co-authored-by: Bram Kragten <mail@bramkragten.nl>
2025-08-20 17:27:05 +03:00
Paul Bottein
61bb5d33e5 Add home structure helper to overview dashboard (#26620)
* Use home structure in overview home

* Use home structure in summary views
2025-08-20 14:53:05 +02:00
Paulus Schoutsen
20a3bab5bc Fix space in overview welcome caption (#26618)
* Fix space in overview welcome caption

* Update src/panels/lovelace/strategies/overview/overview-home-view-strategy.ts
2025-08-20 09:48:59 +00:00
Petar Petrov
e8d916acd7 History chart card feature (#26555)
Co-authored-by: Bram Kragten <mail@bramkragten.nl>
2025-08-20 11:30:42 +02:00
Drinor Dalipi
8b73d664b4 Fix entities not showing due to JavaScript crash (fixes #25363) (#26599)
* Fix entity UI crash from undefined entity names (fixes #25363)

* Fix mock function type compatibility in test

- Update mock to handle string | undefined parameter
- Maintain test functionality while satisfying type checker

* Simplify approach based on reviewer feedback

- Use String() coercion to preserve numeric entity names (e.g., power strip outlets)
- Single line change instead of complex type validation across multiple files
- Revert stripPrefixFromEntityName to original (no longer needs null handling)
- Remove separate test file, update existing test to expect stringified numbers
- More conservative approach that preserves data rather than replacing with fallbacks
2025-08-20 06:10:13 +00:00
Paul Bottein
10dcc08068 Summaries overview dashboard (#26594)
* Change categories to lights, climate and security

* Add entities grouped by devices

* Fix garage cover device class

* Rename category to summary

* Add media players categories

* Reduce spacing

* Remove person

* Remove translations

* Use media player cards

* Display entities without device

* Add missing entity category

* Update src/panels/lovelace/strategies/overview/helpers/overview-summaries.ts

Co-authored-by: Paulus Schoutsen <balloob@gmail.com>

* Add white space

---------

Co-authored-by: Paulus Schoutsen <balloob@gmail.com>
2025-08-19 19:48:43 +00:00
Wendelin
fc5adc3753 Automation row remember collapsed status (#26604) 2025-08-19 12:13:35 +02:00
Paulus Schoutsen
d1db8f456f Change Suggest with AI label to Suggest (#26603) 2025-08-19 11:39:12 +02:00
karwosts
0dd07a395a Fix legend in devices-detail-graph (#26596) 2025-08-19 09:12:30 +03:00
renovate[bot]
589771df5c Lock file maintenance (#26598)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-19 08:44:22 +03:00
pcan08
92812048dc Add fan oscillate feature (#26519)
* First working fan-oscillate feature

This a first working impl, need at least to do:
- Tooltip not yet "Yes/No"
- Need implementation verification

* Use same strings as more info label for control tooltip

* Add missing label for editor

* Rename some variables

* Add fan features in gallery

* Fix lint:types by applying suggestions from code review

Co-authored-by: Aidan Timson <aidan@timmo.dev>

* fix lint new line after import

* fix typo

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>

* fix event value type treating

* remove type magic as suggested

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>

* Update localize.ts

Complete suggestion change to have tooltip

* fix lint by removing unused import

---------

Co-authored-by: Aidan Timson <aidan@timmo.dev>
Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2025-08-18 18:22:41 +03:00
Aidan Timson
9fc14d6627 Set default forecast option in weather forecast card editor (#26592) 2025-08-18 16:18:50 +03:00
renovate[bot]
3da6b85593 Update dependency @codemirror/language to v6.11.3 (#26589)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-18 15:59:13 +03:00
Norbert Rittel
d2a4f481be Capitalize "Samba" as the name of the Open Source project (#26590)
Capitalize "Samba" as name for the Open Source project
2025-08-18 14:54:51 +02:00
karwosts
e37f67c548 Fix picture glance mouse pointer (#26391)
* Fix picture glance mouse pointer

* More action updates
2025-08-18 13:33:10 +03:00
Aidan Timson
e775a6770b Add clipboard support to condition card conditions (#26535)
* Add copy paste support to condition card conditions

* Don't clear the clipboard

* Fix selection

* Add cut and duplicate

* Remove

* Fix event
2025-08-18 13:10:58 +03:00
karwosts
4ba5ef6c37 Show accurate state of show_header_toggle in entities card editor (#26564) 2025-08-18 13:01:25 +03:00
Aidan Timson
d528ab06d9 Add date card feature (#26531)
* Add date card feature

* Rename from "date" to "date-set"

* Better label

* Fix

* Parse date for datetime
2025-08-18 09:57:09 +00:00
dependabot[bot]
03a628cfe2 Bump actions/checkout from 4.2.2 to 5.0.0 (#26586)
Bumps [actions/checkout](https://github.com/actions/checkout) from 4.2.2 to 5.0.0.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4.2.2...v5.0.0)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: 5.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-18 12:38:04 +03:00
Wendelin
973851b332 Remove unused shoelace animation import (#26584) 2025-08-18 10:42:59 +03:00
Simon Lamon
4c5795c276 Migrate all sl-animation usages to wa-animation (#26544) 2025-08-18 09:04:42 +02:00
Simon Lamon
41d016d96a Migrate ha-progress-ring to webawesome (#26542) 2025-08-18 09:02:32 +02:00
karwosts
22e647cad4 Fetch translations for switch-as-x domains (#26566)
* Fetch translations for switch-as-x domains

* no cache
2025-08-18 08:40:31 +03:00
karwosts
b63dd9dbbf Force dark-only themes to be in dark mode (#26583) 2025-08-18 08:39:32 +03:00
renovate[bot]
3b3b9e269d Update dependency hls.js to v1.6.10 (#26581)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-18 08:33:39 +03:00
renovate[bot]
d2c3b9ee83 Update dependency @lokalise/node-api to v15.2.1 (#26577)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-17 15:33:15 +02:00
renovate[bot]
5b50a8692b Update babel monorepo to v7.28.3 (#26576)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-17 15:32:52 +02:00
Paulus Schoutsen
8a8bbee8e0 Update theme color in web app manifests (#26572) 2025-08-17 10:19:40 +02:00
TheJulianJES
28e28d1417 Fix restrict-task-creation workflow (#26569) 2025-08-17 10:19:16 +02:00
renovate[bot]
ea77a0f3d6 Update dependency @lokalise/node-api to v15.1.0 (#26567)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-16 21:23:51 +02:00
karwosts
10e09b238a Configurable todo item tap action (#26551)
* Configurable todo item tap action

* string
2025-08-15 16:33:50 +03:00
Simon Lamon
f9cd2b66cb Migrate ha-fade-in to webawesome (#26543)
fade in
2025-08-15 13:29:34 +03:00
renovate[bot]
b1c0fba8cf Update dependency @rsdoctor/rspack-plugin to v1.2.2 (#26553)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-15 13:27:29 +03:00
Norbert Rittel
fdae6257b3 Consistently use "Home Assistant OS" as name (#26552) 2025-08-15 10:02:00 +02:00
Dav15Jr
6a48aea128 Fix media manage button default visibilty (#26548)
* Fix Inconsistent naming of "Input button" in Quickbar navigation

* Fix Media Manage button default visibilty.

---------

Co-authored-by: Simon Lamon <32477463+silamon@users.noreply.github.com>
2025-08-15 07:19:09 +00:00
Norbert Rittel
a90b173671 Fix missing sentence-case in a few strings (#26547)
* Fix missing sentence-case in a few strings

* One more

* And another one
2025-08-15 09:10:00 +02:00
Dav15Jr
d9971bfaa9 Fix Inconsistent naming of "Input button" in Quickbar navigation (#26541)
* Fix Inconsistent naming of "Input button" in Quickbar navigation

* Fix Media Manage button default visibilty.

* undo wrong change
2025-08-15 07:09:54 +00:00
renovate[bot]
369d56a809 Update dependency typescript-eslint to v8.39.1 (#26546)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-15 09:04:30 +02:00
Norbert Rittel
939a3cdf63 Fix missing sentence-casing in People settings (#26539)
* Fix missing sentence-casing in People settings

* Remove hyphen from "presence detection integration"
2025-08-14 16:56:02 +02:00
Wendelin
208fd0662c Migrate ha-button-toggle-group to webawesome (#26506)
Co-authored-by: Bram Kragten <mail@bramkragten.nl>
2025-08-14 14:52:39 +02:00
renovate[bot]
f133f246cb Update vaadinWebComponents monorepo to v24.8.5 (#25954)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-08-14 14:51:25 +02:00
Paul Bottein
b9b8997d68 Makes ha-combo-box clear value compatible with vaadin 24.8 (#26530) 2025-08-14 14:50:24 +02:00
Aidan Timson
46c4a19a13 Fix conditional card options alignment (#26534) 2025-08-14 12:27:21 +00:00
Paul Bottein
8d63654211 Create overview dashboard with lights, covers, energy and favorite entities (#26504)
* Create overview dashboard with light, cover, energy and favorite

* Add welcome message

* Add person entities

* Add editor for favorite entities

* Don't use property
2025-08-14 11:56:35 +02:00
111 changed files with 4714 additions and 3258 deletions

View File

@@ -21,7 +21,7 @@ jobs:
url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }}
steps:
- name: Check out files from GitHub
uses: actions/checkout@v4.2.2
uses: actions/checkout@v5.0.0
with:
ref: dev
@@ -56,7 +56,7 @@ jobs:
url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }}
steps:
- name: Check out files from GitHub
uses: actions/checkout@v4.2.2
uses: actions/checkout@v5.0.0
with:
ref: master

View File

@@ -24,7 +24,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out files from GitHub
uses: actions/checkout@v4.2.2
uses: actions/checkout@v5.0.0
- name: Setup Node
uses: actions/setup-node@v4.4.0
with:
@@ -58,7 +58,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out files from GitHub
uses: actions/checkout@v4.2.2
uses: actions/checkout@v5.0.0
- name: Setup Node
uses: actions/setup-node@v4.4.0
with:
@@ -76,7 +76,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out files from GitHub
uses: actions/checkout@v4.2.2
uses: actions/checkout@v5.0.0
- name: Setup Node
uses: actions/setup-node@v4.4.0
with:
@@ -100,7 +100,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Check out files from GitHub
uses: actions/checkout@v4.2.2
uses: actions/checkout@v5.0.0
- name: Setup Node
uses: actions/setup-node@v4.4.0
with:

View File

@@ -23,7 +23,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v4.2.2
uses: actions/checkout@v5.0.0
with:
# We must fetch at least the immediate parents so that if this is
# a pull request then we can checkout the head.

View File

@@ -22,7 +22,7 @@ jobs:
url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }}
steps:
- name: Check out files from GitHub
uses: actions/checkout@v4.2.2
uses: actions/checkout@v5.0.0
with:
ref: dev
@@ -57,7 +57,7 @@ jobs:
url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }}
steps:
- name: Check out files from GitHub
uses: actions/checkout@v4.2.2
uses: actions/checkout@v5.0.0
with:
ref: master

View File

@@ -16,7 +16,7 @@ jobs:
url: ${{ steps.deploy.outputs.NETLIFY_LIVE_URL || steps.deploy.outputs.NETLIFY_URL }}
steps:
- name: Check out files from GitHub
uses: actions/checkout@v4.2.2
uses: actions/checkout@v5.0.0
- name: Setup Node
uses: actions/setup-node@v4.4.0

View File

@@ -21,7 +21,7 @@ jobs:
if: github.repository == 'home-assistant/frontend' && contains(github.event.pull_request.labels.*.name, 'needs design preview')
steps:
- name: Check out files from GitHub
uses: actions/checkout@v4.2.2
uses: actions/checkout@v5.0.0
- name: Setup Node
uses: actions/setup-node@v4.4.0

View File

@@ -20,7 +20,7 @@ jobs:
contents: write
steps:
- name: Checkout the repository
uses: actions/checkout@v4.2.2
uses: actions/checkout@v5.0.0
- name: Set up Python ${{ env.PYTHON_VERSION }}
uses: actions/setup-python@v5

View File

@@ -23,7 +23,7 @@ jobs:
contents: write # Required to upload release assets
steps:
- name: Checkout the repository
uses: actions/checkout@v4.2.2
uses: actions/checkout@v5.0.0
- name: Set up Python ${{ env.PYTHON_VERSION }}
uses: actions/setup-python@v5
@@ -90,7 +90,7 @@ jobs:
contents: write # Required to upload release assets
steps:
- name: Checkout the repository
uses: actions/checkout@v4.2.2
uses: actions/checkout@v5.0.0
- name: Setup Node
uses: actions/setup-node@v4.4.0
with:
@@ -119,7 +119,7 @@ jobs:
contents: write # Required to upload release assets
steps:
- name: Checkout the repository
uses: actions/checkout@v4.2.2
uses: actions/checkout@v5.0.0
- name: Setup Node
uses: actions/setup-node@v4.4.0
with:

View File

@@ -9,7 +9,7 @@ jobs:
check-authorization:
runs-on: ubuntu-latest
# Only run if this is a Task issue type (from the issue form)
if: github.event.issue.issue_type == 'Task'
if: github.event.issue.type.name == 'Task'
steps:
- name: Check if user is authorized
uses: actions/github-script@v7

View File

@@ -14,7 +14,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout the repository
uses: actions/checkout@v4.2.2
uses: actions/checkout@v5.0.0
- name: Upload Translations
run: |

View File

@@ -14,5 +14,5 @@
"name": "Home Assistant Cast",
"short_name": "HA Cast",
"start_url": "/?homescreen=1",
"theme_color": "#03A9F4"
"theme_color": "#009ac7"
}

View File

@@ -75,5 +75,5 @@
"name": "Home Assistant Demo",
"short_name": "HA Demo",
"start_url": "/?homescreen=1",
"theme_color": "#03A9F4"
"theme_color": "#009ac7"
}

View File

@@ -101,11 +101,14 @@ const ENTITIES = [
ClimateEntityFeature.FAN_MODE +
ClimateEntityFeature.TARGET_TEMPERATURE_RANGE,
}),
getEntity("fan", "fan_direction", "on", {
getEntity("fan", "fan_demo", "on", {
friendly_name: "Ceiling fan",
device_class: "fan",
direction: "reverse",
supported_features: [FanEntityFeature.DIRECTION],
supported_features:
FanEntityFeature.DIRECTION +
FanEntityFeature.SET_SPEED +
FanEntityFeature.OSCILLATE,
}),
];
@@ -272,11 +275,29 @@ const CONFIGS = [
heading: "Fan direction feature",
config: `
- type: tile
entity: fan.fan_direction
entity: fan.fan_demo
features:
- type: fan-direction
`,
},
{
heading: "Fan speed feature",
config: `
- type: tile
entity: fan.fan_demo
features:
- type: fan-speed
`,
},
{
heading: "Fan oscillate feature",
config: `
- type: tile
entity: fan.fan_demo
features:
- type: fan-oscillate
`,
},
];
@customElement("demo-lovelace-tile-card")

View File

@@ -0,0 +1,3 @@
---
title: Fan
---

View File

@@ -0,0 +1,50 @@
import type { PropertyValues, TemplateResult } from "lit";
import { html, LitElement } from "lit";
import { customElement, property, query } from "lit/decorators";
import "../../../../src/components/ha-card";
import "../../../../src/dialogs/more-info/more-info-content";
import { getEntity } from "../../../../src/fake_data/entity";
import type { MockHomeAssistant } from "../../../../src/fake_data/provide_hass";
import { provideHass } from "../../../../src/fake_data/provide_hass";
import "../../components/demo-more-infos";
import { FanEntityFeature } from "../../../../src/data/fan";
const ENTITIES = [
getEntity("fan", "fan", "on", {
friendly_name: "Fan",
device_class: "fan",
supported_features:
FanEntityFeature.OSCILLATE +
FanEntityFeature.DIRECTION +
FanEntityFeature.SET_SPEED,
}),
];
@customElement("demo-more-info-fan")
class DemoMoreInfoFan extends LitElement {
@property({ attribute: false }) public hass!: MockHomeAssistant;
@query("demo-more-infos") private _demoRoot!: HTMLElement;
protected render(): TemplateResult {
return html`
<demo-more-infos
.hass=${this.hass}
.entities=${ENTITIES.map((ent) => ent.entityId)}
></demo-more-infos>
`;
}
protected firstUpdated(changedProperties: PropertyValues) {
super.firstUpdated(changedProperties);
const hass = provideHass(this._demoRoot);
hass.updateTranslations(null, "en");
hass.addEntities(ENTITIES);
}
}
declare global {
interface HTMLElementTagNameMap {
"demo-more-info-fan": DemoMoreInfoFan;
}
}

View File

@@ -27,11 +27,11 @@
"type": "module",
"dependencies": {
"@awesome.me/webawesome": "3.0.0-beta.4",
"@babel/runtime": "7.28.2",
"@babel/runtime": "7.28.3",
"@braintree/sanitize-url": "7.1.1",
"@codemirror/autocomplete": "6.18.6",
"@codemirror/commands": "6.8.1",
"@codemirror/language": "6.11.2",
"@codemirror/language": "6.11.3",
"@codemirror/legacy-modes": "6.5.1",
"@codemirror/search": "6.5.11",
"@codemirror/state": "6.5.2",
@@ -61,7 +61,6 @@
"@material/chips": "=14.0.0-canary.53b3cad2f.0",
"@material/data-table": "=14.0.0-canary.53b3cad2f.0",
"@material/mwc-base": "0.27.0",
"@material/mwc-button": "0.27.0",
"@material/mwc-checkbox": "0.27.0",
"@material/mwc-dialog": "0.27.0",
"@material/mwc-drawer": "0.27.0",
@@ -90,8 +89,8 @@
"@thomasloven/round-slider": "0.6.0",
"@tsparticles/engine": "3.9.1",
"@tsparticles/preset-links": "3.2.0",
"@vaadin/combo-box": "24.7.9",
"@vaadin/vaadin-themable-mixin": "24.7.9",
"@vaadin/combo-box": "24.8.5",
"@vaadin/vaadin-themable-mixin": "24.8.5",
"@vibrant/color": "4.0.0",
"@vue/web-component-wrapper": "1.3.0",
"@webcomponents/scoped-custom-element-registry": "0.0.10",
@@ -113,7 +112,7 @@
"fuse.js": "7.1.0",
"google-timezones-json": "1.2.0",
"gulp-zopfli-green": "6.0.2",
"hls.js": "1.6.9",
"hls.js": "1.6.10",
"home-assistant-js-websocket": "9.5.0",
"idb-keyval": "6.2.2",
"intl-messageformat": "10.7.16",
@@ -150,16 +149,16 @@
"xss": "1.0.15"
},
"devDependencies": {
"@babel/core": "7.28.0",
"@babel/core": "7.28.3",
"@babel/helper-define-polyfill-provider": "0.6.5",
"@babel/plugin-transform-runtime": "7.28.0",
"@babel/preset-env": "7.28.0",
"@babel/plugin-transform-runtime": "7.28.3",
"@babel/preset-env": "7.28.3",
"@bundle-stats/plugin-webpack-filter": "4.21.2",
"@lokalise/node-api": "15.0.0",
"@lokalise/node-api": "15.2.1",
"@octokit/auth-oauth-device": "8.0.1",
"@octokit/plugin-retry": "8.0.1",
"@octokit/rest": "22.0.0",
"@rsdoctor/rspack-plugin": "1.2.1",
"@rsdoctor/rspack-plugin": "1.2.2",
"@rspack/cli": "1.4.11",
"@rspack/core": "1.4.11",
"@types/babel__plugin-transform-runtime": "7.9.5",
@@ -219,7 +218,7 @@
"terser-webpack-plugin": "5.3.14",
"ts-lit-plugin": "2.0.2",
"typescript": "5.9.2",
"typescript-eslint": "8.39.0",
"typescript-eslint": "8.39.1",
"vite-tsconfig-paths": "5.1.4",
"vitest": "3.2.4",
"webpack-stats-plugin": "1.1.3",
@@ -236,7 +235,7 @@
"globals": "16.3.0",
"tslib": "2.8.1",
"@material/mwc-list@^0.27.0": "patch:@material/mwc-list@npm%3A0.27.0#~/.yarn/patches/@material-mwc-list-npm-0.27.0-5344fc9de4.patch",
"@vaadin/vaadin-themable-mixin": "24.7.9"
"@vaadin/vaadin-themable-mixin": "24.8.5"
},
"packageManager": "yarn@4.9.2"
}

View File

@@ -28,7 +28,10 @@ export const computeEntityEntryName = (
hass: HomeAssistant
): string | undefined => {
const name =
entry.name || ("original_name" in entry ? entry.original_name : undefined);
entry.name ||
("original_name" in entry && entry.original_name != null
? String(entry.original_name)
: "");
const device = entry.device_id ? hass.devices[entry.device_id] : undefined;

View File

@@ -14,6 +14,7 @@ export type LocalizeKeys =
| `ui.card.weather.attributes.${string}`
| `ui.card.weather.cardinal_direction.${string}`
| `ui.card.lawn_mower.actions.${string}`
| `ui.common.${string}`
| `ui.components.calendar.event.rrule.${string}`
| `ui.components.selectors.file.${string}`
| `ui.components.logbook.messages.detected_device_classes.${string}`

View File

@@ -39,6 +39,7 @@ export type CustomLegendOption = ECOption["legend"] & {
type: "custom";
data?: {
id?: string;
secondaryIds?: string[]; // Other dataset IDs that should be controlled by this legend item.
name: string;
itemStyle?: Record<string, any>;
}[];
@@ -181,6 +182,10 @@ export class HaChartBase extends LitElement {
return;
}
let chartOptions: ECOption = {};
if (changedProps.has("options")) {
// Separate 'if' from below since this must updated before _getSeries()
this._updateHiddenStatsFromOptions(this.options);
}
if (changedProps.has("data") || changedProps.has("_hiddenDatasets")) {
chartOptions.series = this._getSeries();
}
@@ -451,14 +456,7 @@ export class HaChartBase extends LitElement {
});
}
const legend = ensureArray(this.options?.legend || [])[0] as
| LegendComponentOption
| undefined;
Object.entries(legend?.selected || {}).forEach(([stat, selected]) => {
if (selected === false) {
this._hiddenDatasets.add(stat);
}
});
this._updateHiddenStatsFromOptions(this.options);
this.chart.setOption({
...this._createOptions(),
@@ -469,6 +467,42 @@ export class HaChartBase extends LitElement {
}
}
// Return an array of all IDs associated with the legend item of the primaryId
private _getAllIdsFromLegend(
options: ECOption | undefined,
primaryId: string
): string[] {
if (!options) return [primaryId];
const legend = ensureArray(this.options?.legend || [])[0] as
| LegendComponentOption
| undefined;
let customLegendItem;
if (legend?.type === "custom") {
customLegendItem = (legend as CustomLegendOption).data?.find(
(li) => typeof li === "object" && li.id === primaryId
);
}
return [primaryId, ...(customLegendItem?.secondaryIds || [])];
}
// Parses the options structure and adds all ids of unselected legend items to hiddenDatasets.
// No known need to remove items at this time.
private _updateHiddenStatsFromOptions(options: ECOption | undefined) {
if (!options) return;
const legend = ensureArray(this.options?.legend || [])[0] as
| LegendComponentOption
| undefined;
Object.entries(legend?.selected || {}).forEach(([stat, selected]) => {
if (selected === false) {
this._getAllIdsFromLegend(options, stat).forEach((id) =>
this._hiddenDatasets.add(id)
);
}
});
}
private _getDataZoomConfig(): DataZoomComponentOption | undefined {
const xAxis = (this.options?.xAxis?.[0] ?? this.options?.xAxis) as
| XAXisOption
@@ -844,10 +878,14 @@ export class HaChartBase extends LitElement {
}
const id = ev.currentTarget?.id;
if (this._hiddenDatasets.has(id)) {
this._hiddenDatasets.delete(id);
this._getAllIdsFromLegend(this.options, id).forEach((i) =>
this._hiddenDatasets.delete(i)
);
fireEvent(this, "dataset-unhidden", { id });
} else {
this._hiddenDatasets.add(id);
this._getAllIdsFromLegend(this.options, id).forEach((i) =>
this._hiddenDatasets.add(i)
);
fireEvent(this, "dataset-hidden", { id });
}
this.requestUpdate("_hiddenDatasets");

View File

@@ -0,0 +1,82 @@
import ButtonGroup from "@awesome.me/webawesome/dist/components/button-group/button-group";
import { customElement } from "lit/decorators";
import type { HaButton } from "./ha-button";
import { StateSet } from "../resources/polyfills/stateset";
export type Appearance = "accent" | "filled" | "outlined" | "plain";
/**
* Finds an ha-button element either as the current element or within its descendants.
* @param el - The HTML element to search from
* @returns The found HaButton element, or null if not found
*/
function findButton(el: HTMLElement) {
const selector = "ha-button";
return (el.closest(selector) ?? el.querySelector(selector)) as HaButton;
}
/**
* @element ha-button-group
* @extends {ButtonGroup}
* @summary
* Group buttons. Extend Webawesome to be able to work with ha-button tags
*
* @documentation https://webawesome.com/components/button-group
*/
@customElement("ha-button-group") // @ts-expect-error Intentionally overriding private methods
export class HaButtonGroup extends ButtonGroup {
attachInternals() {
const internals = super.attachInternals();
Object.defineProperty(internals, "states", {
value: new StateSet(this, internals.states),
});
return internals;
}
// @ts-expect-error updateClassNames is used in super class
// eslint-disable-next-line @typescript-eslint/naming-convention
private override updateClassNames() {
const slottedElements = [
...this.defaultSlot.assignedElements({ flatten: true }),
] as HTMLElement[];
this.hasOutlined = false;
slottedElements.forEach((el) => {
const index = slottedElements.indexOf(el);
const button = findButton(el);
if (button) {
if ((button as HaButton).appearance === "outlined")
this.hasOutlined = true;
if (this.size) button.setAttribute("size", this.size);
button.classList.add("wa-button-group__button");
button.classList.toggle(
"wa-button-group__horizontal",
this.orientation === "horizontal"
);
button.classList.toggle(
"wa-button-group__vertical",
this.orientation === "vertical"
);
button.classList.toggle("wa-button-group__button-first", index === 0);
button.classList.toggle(
"wa-button-group__button-inner",
index > 0 && index < slottedElements.length - 1
);
button.classList.toggle(
"wa-button-group__button-last",
index === slottedElements.length - 1
);
// use button-group variant
button.setAttribute("variant", this.variant);
}
});
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-button-group": HaButtonGroup;
}
}

View File

@@ -1,141 +1,72 @@
import "@material/mwc-button/mwc-button";
import type { Button } from "@material/mwc-button/mwc-button";
import type { TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property, queryAll } from "lit/decorators";
import { styleMap } from "lit/directives/style-map";
import { customElement, property } from "lit/decorators";
import { fireEvent } from "../common/dom/fire_event";
import type { ToggleButton } from "../types";
import "./ha-icon-button";
import "./ha-svg-icon";
import "./ha-button";
import "./ha-button-group";
/**
* @element ha-button-toggle-group
*
* @summary
* A button-group with one active selection.
*
* @attr {ToggleButton[]} buttons - the button config
* @attr {string} active - The value of the currently active button.
* @attr {("small"|"medium")} size - The size of the buttons in the group.
* @attr {("brand"|"neutral"|"success"|"warning"|"danger")} variant - The variant of the buttons in the group.
*
* @fires value-changed - Dispatched when the active button changes.
*/
@customElement("ha-button-toggle-group")
export class HaButtonToggleGroup extends LitElement {
@property({ attribute: false }) public buttons!: ToggleButton[];
@property() public active?: string;
@property({ attribute: "full-width", type: Boolean })
public fullWidth = false;
@property({ reflect: true }) size: "small" | "medium" = "medium";
@property({ type: Boolean }) public dense = false;
@queryAll("mwc-button") private _buttons?: Button[];
@property() public variant:
| "brand"
| "neutral"
| "success"
| "warning"
| "danger" = "brand";
protected render(): TemplateResult {
return html`
<div>
${this.buttons.map((button) =>
button.iconPath
? html`<ha-icon-button
.label=${button.label}
.path=${button.iconPath}
.value=${button.value}
?active=${this.active === button.value}
@click=${this._handleClick}
></ha-icon-button>`
: html`<mwc-button
style=${styleMap({
width: this.fullWidth
? `${100 / this.buttons.length}%`
: "initial",
})}
outlined
.dense=${this.dense}
.value=${button.value}
?active=${this.active === button.value}
@click=${this._handleClick}
>${button.label}</mwc-button
>`
<ha-button-group .variant=${this.variant} .size=${this.size}>
${this.buttons.map(
(button) =>
html`<ha-button
class="icon"
.value=${button.value}
@click=${this._handleClick}
.title=${button.label}
.appearance=${this.active === button.value ? "accent" : "filled"}
>
${button.iconPath
? html`<ha-svg-icon
aria-label=${button.label}
.path=${button.iconPath}
></ha-svg-icon>`
: button.label}
</ha-button>`
)}
</div>
</ha-button-group>
`;
}
protected updated() {
// Work around Safari default margin that is not reset in mwc-button as of aug 2021
this._buttons?.forEach(async (button) => {
await button.updateComplete;
(
button.shadowRoot!.querySelector("button") as HTMLButtonElement
).style.margin = "0";
});
}
private _handleClick(ev): void {
this.active = ev.currentTarget.value;
fireEvent(this, "value-changed", { value: this.active });
}
static styles = css`
div {
display: flex;
--mdc-icon-button-size: var(--button-toggle-size, 36px);
:host {
--mdc-icon-size: var(--button-toggle-icon-size, 20px);
direction: ltr;
}
mwc-button {
flex: 1;
--mdc-shape-small: 0;
--mdc-button-outline-width: 1px 0 1px 1px;
--mdc-button-outline-color: var(--primary-color);
}
ha-icon-button {
border: 1px solid var(--primary-color);
border-right-width: 0px;
}
ha-icon-button,
mwc-button {
position: relative;
cursor: pointer;
}
ha-icon-button::before,
mwc-button::before {
top: 0;
left: 0;
width: 100%;
height: 100%;
position: absolute;
background-color: var(--primary-color);
opacity: 0;
pointer-events: none;
content: "";
transition:
opacity 15ms linear,
background-color 15ms linear;
}
ha-icon-button[active]::before,
mwc-button[active]::before {
opacity: 1;
}
ha-icon-button[active] {
--icon-primary-color: var(--text-primary-color);
}
mwc-button[active] {
--mdc-theme-primary: var(--text-primary-color);
}
ha-icon-button:first-child,
mwc-button:first-child {
--mdc-shape-small: 4px 0 0 4px;
border-radius: 4px 0 0 4px;
--mdc-button-outline-width: 1px;
}
mwc-button:first-child::before {
border-radius: 4px 0 0 4px;
}
ha-icon-button:last-child,
mwc-button:last-child {
border-radius: 0 4px 4px 0;
border-right-width: 1px;
--mdc-shape-small: 0 4px 4px 0;
--mdc-button-outline-width: 1px;
}
mwc-button:last-child::before {
border-radius: 0 4px 4px 0;
}
ha-icon-button:only-child,
mwc-button:only-child {
--mdc-shape-small: 4px;
border-right-width: 1px;
}
`;
}

View File

@@ -35,7 +35,7 @@ export type Appearance = "accent" | "filled" | "outlined" | "plain";
* @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.
*/
@customElement("ha-button")
@customElement("ha-button") // @ts-expect-error Intentionally overriding private methods
export class HaButton extends Button {
variant: "brand" | "neutral" | "success" | "warning" | "danger" = "brand";
@@ -47,6 +47,42 @@ export class HaButton extends Button {
return internals;
}
// @ts-expect-error handleLabelSlotChange is used in super class
// eslint-disable-next-line @typescript-eslint/naming-convention
private override handleLabelSlotChange() {
const nodes = this.labelSlot.assignedNodes({ flatten: true });
let hasIconLabel = false;
let hasIcon = false;
let text = "";
// If there's only an icon and no text, it's an icon button
[...nodes].forEach((node) => {
if (
node.nodeType === Node.ELEMENT_NODE &&
(node as HTMLElement).localName === "ha-svg-icon"
) {
hasIcon = true;
if (!hasIconLabel)
hasIconLabel = (node as HTMLElement).hasAttribute("aria-label");
}
// Concatenate text nodes
if (node.nodeType === Node.TEXT_NODE) {
text += node.textContent;
}
});
this.isIconButton = text.trim() === "" && hasIcon;
if (__DEV__ && this.isIconButton && !hasIconLabel) {
// eslint-disable-next-line no-console
console.warn(
'Icon buttons must have a label for screen readers. Add <ha-svg-icon aria-label="..."> to remove this warning.',
this
);
}
}
static get styles(): CSSResultGroup {
return [
Button.styles,
@@ -181,6 +217,11 @@ export class HaButton extends Button {
color: var(--wa-color-on-normal);
}
}
:host([appearance~="filled"]) .button {
color: var(--wa-color-on-normal);
background-color: var(--wa-color-fill-normal);
border-color: transparent;
}
:host([appearance~="filled"])
.button:not(.disabled):not(.loading):active {
background-color: var(--button-color-fill-normal-active);

View File

@@ -4,14 +4,14 @@ import { HaTextField } from "./ha-textfield";
@customElement("ha-combo-box-textfield")
export class HaComboBoxTextField extends HaTextField {
@property({ type: Boolean, attribute: "disable-set-value" })
public disableSetValue = false;
@property({ type: Boolean, attribute: "force-blank-value" })
public forceBlankValue = false;
protected willUpdate(changedProps: PropertyValues): void {
super.willUpdate(changedProps);
if (changedProps.has("value")) {
if (this.disableSetValue) {
this.value = changedProps.get("value") as string;
if (changedProps.has("value") || changedProps.has("forceBlankValue")) {
if (this.forceBlankValue && this.value) {
this.value = "";
}
}
}

View File

@@ -117,7 +117,7 @@ export class HaComboBox extends LitElement {
@query("ha-combo-box-textfield", true) private _inputElement!: HaTextField;
@state({ type: Boolean }) private _disableSetValue = false;
@state({ type: Boolean }) private _forceBlankValue = false;
private _overlayMutationObserver?: MutationObserver;
@@ -196,7 +196,7 @@ export class HaComboBox extends LitElement {
></div>`}
.icon=${this.icon}
.invalid=${this.invalid}
.disableSetValue=${this._disableSetValue}
.forceBlankValue=${this._forceBlankValue}
>
<slot name="icon" slot="leadingIcon"></slot>
</ha-combo-box-textfield>
@@ -270,10 +270,10 @@ export class HaComboBox extends LitElement {
if (opened) {
// Wait 100ms to be sure vaddin-combo-box-light already tried to set the value
setTimeout(() => {
this._disableSetValue = false;
this._forceBlankValue = false;
}, 100);
} else {
this._disableSetValue = true;
this._forceBlankValue = true;
}
}

View File

@@ -1,8 +1,8 @@
import SlAnimation from "@shoelace-style/shoelace/dist/components/animation/animation.component";
import WaAnimation from "@awesome.me/webawesome/dist/components/animation/animation";
import { customElement, property } from "lit/decorators";
@customElement("ha-fade-in")
export class HaFadeIn extends SlAnimation {
export class HaFadeIn extends WaAnimation {
@property() public name = "fadeIn";
@property() public fill: FillMode = "both";

View File

@@ -1,12 +1,21 @@
import ProgressRing from "@shoelace-style/shoelace/dist/components/progress-ring/progress-ring.component";
import progressRingStyles from "@shoelace-style/shoelace/dist/components/progress-ring/progress-ring.styles";
import ProgressRing from "@awesome.me/webawesome/dist/components/progress-ring/progress-ring";
import { css } from "lit";
import type { CSSResultGroup } from "lit";
import { customElement, property } from "lit/decorators";
import { StateSet } from "../resources/polyfills/stateset";
@customElement("ha-progress-ring")
export class HaProgressRing extends ProgressRing {
@property() public size?: "tiny" | "small" | "medium" | "large";
attachInternals() {
const internals = super.attachInternals();
Object.defineProperty(internals, "states", {
value: new StateSet(this, internals.states),
});
return internals;
}
public updated(changedProps) {
super.updated(changedProps);
@@ -31,24 +40,26 @@ export class HaProgressRing extends ProgressRing {
}
}
static override styles = [
progressRingStyles,
css`
:host {
--indicator-color: var(
--ha-progress-ring-indicator-color,
var(--primary-color)
);
--track-color: var(
--ha-progress-ring-divider-color,
var(--divider-color)
);
--track-width: 4px;
--speed: 3.5s;
--size: var(--ha-progress-ring-size, 48px);
}
`,
];
static get styles(): CSSResultGroup {
return [
ProgressRing.styles,
css`
:host {
--indicator-color: var(
--ha-progress-ring-indicator-color,
var(--primary-color)
);
--track-color: var(
--ha-progress-ring-divider-color,
var(--divider-color)
);
--track-width: 4px;
--speed: 3.5s;
--size: var(--ha-progress-ring-size, 48px);
}
`,
];
}
}
declare global {

View File

@@ -8,6 +8,7 @@ import type {
LocalizeKeys,
} from "../../common/translations/localize";
import type { HomeAssistant } from "../../types";
import "../ha-alert";
import "../ha-form/ha-form";
const SELECTOR_DEFAULTS = {
@@ -155,8 +156,6 @@ export class HaSelectorSelector extends LitElement {
@property({ type: Boolean, reflect: true }) public disabled = false;
@property({ type: Boolean }) public narrow = false;
@property({ type: Boolean, reflect: true }) public required = true;
private _yamlMode = false;
@@ -173,10 +172,10 @@ export class HaSelectorSelector extends LitElement {
[
{
name: "type",
required: true,
selector: {
select: {
mode: "dropdown",
required: true,
options: Object.keys(SELECTOR_SCHEMAS)
.concat("manual")
.map((key) => ({
@@ -229,17 +228,17 @@ export class HaSelectorSelector extends LitElement {
const schema = this._schema(type, this.hass.localize);
return html`<div>
<p>${this.label ? this.label : ""}</p>
<ha-form
.hass=${this.hass}
.data=${data}
.schema=${schema}
.computeLabel=${this._computeLabelCallback}
@value-changed=${this._valueChanged}
.narrow=${this.narrow}
></ha-form>
</div>`;
return html`<ha-card>
<div class="card-content">
<p>${this.label ? this.label : ""}</p>
<ha-form
.hass=${this.hass}
.data=${data}
.schema=${schema}
.computeLabel=${this._computeLabelCallback}
@value-changed=${this._valueChanged}
></ha-form></div
></ha-card>`;
}
private _valueChanged(ev: CustomEvent) {
@@ -286,6 +285,23 @@ export class HaSelectorSelector extends LitElement {
) || schema.name;
static styles = css`
:host {
--expansion-panel-summary-padding: 0 16px;
}
ha-alert {
display: block;
margin-bottom: 16px;
}
ha-card {
margin: 0 0 16px 0;
}
ha-card.disabled {
pointer-events: none;
color: var(--disabled-text-color);
}
.card-content {
padding: 0px 16px 16px 16px;
}
.title {
font-size: var(--ha-font-size-l);
padding-top: 16px;

View File

@@ -38,7 +38,7 @@ class MediaManageButton extends LitElement {
return nothing;
}
return html`
<ha-button appearance="plain" size="small" @click=${this._manage}>
<ha-button appearance="filled" size="small" @click=${this._manage}>
<ha-svg-icon .path=${mdiFolderEdit} slot="start"></ha-svg-icon>
${this.hass.localize(
"ui.components.media-browser.file_management.manage"

View File

@@ -7,10 +7,10 @@ import { navigate } from "../common/navigate";
import { createSearchParam } from "../common/url/search-params";
import type { Context, HomeAssistant } from "../types";
import type { BlueprintInput } from "./blueprint";
import { CONDITION_BUILDING_BLOCKS } from "./condition";
import type { DeviceCondition, DeviceTrigger } from "./device_automation";
import type { Action, Field, MODES } from "./script";
import type { Action, MODES } from "./script";
import { migrateAutomationAction } from "./script";
import { CONDITION_BUILDING_BLOCKS } from "./condition";
export const AUTOMATION_DEFAULT_MODE: (typeof MODES)[number] = "single";
export const AUTOMATION_DEFAULT_MAX = 10;
@@ -513,14 +513,6 @@ export const isCondition = (config: unknown): boolean => {
return "condition" in condition && typeof condition.condition === "string";
};
export const isScriptField = (config: unknown): boolean => {
if (!config || typeof config !== "object") {
return false;
}
const field = config as Record<string, unknown>;
return "field" in field && typeof field.field === "object";
};
export const subscribeTrigger = (
hass: HomeAssistant,
onChange: (result: {
@@ -554,62 +546,3 @@ export interface AutomationClipboard {
condition?: Condition;
action?: Action;
}
export interface BaseSidebarConfig {
toggleYamlMode: () => boolean;
delete: () => void;
}
export interface TriggerSidebarConfig extends BaseSidebarConfig {
save: (value: Trigger) => void;
close: () => void;
rename: () => void;
disable: () => void;
config: Trigger;
yamlMode: boolean;
uiSupported: boolean;
}
export interface ConditionSidebarConfig extends BaseSidebarConfig {
save: (value: Condition) => void;
close: () => void;
rename: () => void;
disable: () => void;
config: Condition;
yamlMode: boolean;
uiSupported: boolean;
}
export interface ActionSidebarConfig extends BaseSidebarConfig {
save: (value: Action) => void;
close: () => void;
rename: () => void;
disable: () => void;
config: Action;
yamlMode: boolean;
uiSupported: boolean;
}
export interface OptionSidebarConfig extends BaseSidebarConfig {
close: () => void;
rename: () => void;
}
export interface ScriptFieldSidebarConfig extends BaseSidebarConfig {
save: (value: Field) => void;
close: () => void;
config: {
field: Field;
selector: boolean;
key: string;
excludeKeys: string[];
};
yamlMode: boolean;
}
export type SidebarConfig =
| TriggerSidebarConfig
| ConditionSidebarConfig
| ActionSidebarConfig
| OptionSidebarConfig
| ScriptFieldSidebarConfig;

View File

@@ -1,6 +1,7 @@
import type { HASSDomEvent } from "../../common/dom/fire_event";
export interface ActionHandlerOptions {
hasTap?: boolean;
hasHold?: boolean;
hasDoubleClick?: boolean;
disabled?: boolean;

View File

@@ -1 +0,0 @@
export const SELECTOR_SELECTOR_BUILDING_BLOCKS = ["condition", "action"];

View File

@@ -441,6 +441,35 @@ class WaterHeaterEntity extends Entity {
}
}
class FanEntity extends Entity {
static CAPABILITY_ATTRIBUTES = new Set([
...CAPABILITY_ATTRIBUTES,
"direction",
"oscillating",
"percentage",
]);
public async handleService(domain, service, data) {
if (domain !== this.domain) {
return;
}
if (["turn_on", "turn_off"].includes(service)) {
this.update(service === "turn_on" ? "on" : "off");
} else if (
["set_direction", "oscillate", "set_percentage"].includes(service)
) {
const { entity_id, ...toSet } = data;
this.update(this.state, {
...this.attributes,
...toSet,
});
} else {
super.handleService(domain, service, data);
}
}
}
class GroupEntity extends Entity {
public async handleService(domain, service, data) {
if (!["homeassistant", this.domain].includes(domain)) {
@@ -463,6 +492,7 @@ const TYPES = {
alarm_control_panel: AlarmControlPanelEntity,
climate: ClimateEntity,
cover: CoverEntity,
fan: FanEntity,
group: GroupEntity,
input_boolean: ToggleEntity,
input_number: InputNumberEntity,

View File

@@ -385,30 +385,22 @@ export class HAFullCalendar extends LitElement {
if (!this._viewButtons) {
this._viewButtons = [
{
label: localize(
"ui.panel.lovelace.editor.card.calendar.views.dayGridMonth"
),
label: localize("ui.components.calendar.views.dayGridMonth"),
value: "dayGridMonth",
iconPath: mdiViewModule,
},
{
label: localize(
"ui.panel.lovelace.editor.card.calendar.views.dayGridWeek"
),
label: localize("ui.components.calendar.views.dayGridWeek"),
value: "dayGridWeek",
iconPath: mdiViewWeek,
},
{
label: localize(
"ui.panel.lovelace.editor.card.calendar.views.dayGridDay"
),
label: localize("ui.components.calendar.views.dayGridDay"),
value: "dayGridDay",
iconPath: mdiViewDay,
},
{
label: localize(
"ui.panel.lovelace.editor.card.calendar.views.listWeek"
),
label: localize("ui.components.calendar.views.listWeek"),
value: "listWeek",
iconPath: mdiViewAgenda,
},
@@ -493,10 +485,6 @@ export class HAFullCalendar extends LitElement {
--mdc-icon-button-size: 32px;
}
ha-button-toggle-group {
color: var(--primary-color);
}
ha-fab {
position: absolute;
bottom: 16px;

View File

@@ -41,7 +41,6 @@ import {
YAML_ONLY_ACTION_TYPES,
} from "../../../../data/action";
import type {
ActionSidebarConfig,
AutomationClipboard,
Condition,
} from "../../../../data/automation";
@@ -442,9 +441,9 @@ export default class HaAutomationActionRow extends LitElement {
(blockType === "condition" &&
CONDITION_BUILDING_BLOCKS.includes(
(this.action as Condition).condition
))) &&
!this._collapsed
)))
? html`<ha-automation-action-editor
class=${this._collapsed ? "hidden" : ""}
.hass=${this.hass}
.action=${this.action}
.narrow=${this.narrow}
@@ -666,9 +665,10 @@ export default class HaAutomationActionRow extends LitElement {
disable: this._onDisable,
delete: this._onDelete,
config: sidebarAction,
type: "action",
uiSupported: actionType ? this._uiSupported(actionType) : false,
yamlMode: this._yamlMode,
} satisfies ActionSidebarConfig);
});
this._selected = true;
}

View File

@@ -167,9 +167,7 @@ export default class HaAutomationAction extends LitElement {
} else if (!this.optionsInSidebar) {
row.expand();
}
if (this.narrow) {
row.scrollIntoView();
}
row.scrollIntoView();
row.focus();
});
}

View File

@@ -1,31 +1,24 @@
import { mdiClose, mdiPlus } from "@mdi/js";
import { dump } from "js-yaml";
import type { CSSResultGroup } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { mdiClose, mdiPlus } from "@mdi/js";
import { dump } from "js-yaml";
import { fireEvent } from "../../../../common/dom/fire_event";
import "../../../../components/chips/ha-assist-chip";
import "../../../../components/chips/ha-chip-set";
import "../../../../components/ha-alert";
import "../../../../components/ha-area-picker";
import "../../../../components/ha-domain-icon";
import "../../../../components/ha-expansion-panel";
import "../../../../components/ha-icon-picker";
import "../../../../components/ha-labels-picker";
import "../../../../components/ha-suggest-with-ai-button";
import type { SuggestWithAIGenerateTask } from "../../../../components/ha-suggest-with-ai-button";
import "../../../../components/ha-svg-icon";
import "../../../../components/ha-textarea";
import "../../../../components/ha-textfield";
import "../../../../components/ha-labels-picker";
import "../../../../components/ha-suggest-with-ai-button";
import type { SuggestWithAIGenerateTask } from "../../../../components/ha-suggest-with-ai-button";
import "../../category/ha-category-picker";
import "../../../../components/ha-expansion-panel";
import "../../../../components/chips/ha-chip-set";
import "../../../../components/chips/ha-assist-chip";
import "../../../../components/ha-area-picker";
import { computeStateDomain } from "../../../../common/entity/compute_state_domain";
import { supportsMarkdownHelper } from "../../../../common/translations/markdown_support";
import { subscribeOne } from "../../../../common/util/subscribe-one";
import type { GenDataTaskResult } from "../../../../data/ai_task";
import { fetchCategoryRegistry } from "../../../../data/category_registry";
import { subscribeEntityRegistry } from "../../../../data/entity_registry";
import { subscribeLabelRegistry } from "../../../../data/label_registry";
import type { HassDialog } from "../../../../dialogs/make-dialog-manager";
import { haStyle, haStyleDialog } from "../../../../resources/styles";
import type { HomeAssistant } from "../../../../types";
@@ -33,6 +26,13 @@ import type {
EntityRegistryUpdate,
SaveDialogParams,
} from "./show-dialog-automation-save";
import { supportsMarkdownHelper } from "../../../../common/translations/markdown_support";
import type { GenDataTaskResult } from "../../../../data/ai_task";
import { computeStateDomain } from "../../../../common/entity/compute_state_domain";
import { subscribeOne } from "../../../../common/util/subscribe-one";
import { subscribeLabelRegistry } from "../../../../data/label_registry";
import { subscribeEntityRegistry } from "../../../../data/entity_registry";
import { fetchCategoryRegistry } from "../../../../data/category_registry";
@customElement("ha-dialog-automation-save")
class DialogAutomationSave extends LitElement implements HassDialog {
@@ -242,7 +242,7 @@ class DialogAutomationSave extends LitElement implements HassDialog {
const title = this.hass.localize(
this._params.config.alias
? "ui.panel.config.automation.editor.rename"
: "ui.common.save"
: "ui.panel.config.automation.editor.save"
);
return html`
@@ -289,7 +289,7 @@ class DialogAutomationSave extends LitElement implements HassDialog {
${this.hass.localize(
this._params.config.alias && !this._params.onDiscard
? "ui.panel.config.automation.editor.rename"
: "ui.common.save"
: "ui.panel.config.automation.editor.save"
)}
</ha-button>
</div>

View File

@@ -6,7 +6,6 @@ import { fireEvent } from "../../../common/dom/fire_event";
import "../../../components/ha-alert";
import "../../../components/ha-button";
import "../../../components/ha-markdown";
import "../../../components/ha-fab";
import type { BlueprintAutomationConfig } from "../../../data/automation";
import { fetchBlueprints } from "../../../data/blueprint";
import { HaBlueprintGenericEditor } from "../blueprint/blueprint-generic-editor";
@@ -59,7 +58,7 @@ export class HaBlueprintAutomationEditor extends HaBlueprintGenericEditor {
<ha-fab
slot="fab"
class=${this.dirty ? "dirty" : ""}
.label=${this.hass.localize("ui.common.save")}
.label=${this.hass.localize("ui.panel.config.automation.editor.save")}
.disabled=${this.saving}
extended
@click=${this._saveAutomation}

View File

@@ -35,7 +35,6 @@ import "../../../../components/ha-md-menu-item";
import type {
AutomationClipboard,
Condition,
ConditionSidebarConfig,
} from "../../../../data/automation";
import { testCondition } from "../../../../data/automation";
import { describeCondition } from "../../../../data/automation_i18n";
@@ -371,9 +370,9 @@ export default class HaAutomationConditionRow extends LitElement {
</ha-card>
${this.optionsInSidebar &&
CONDITION_BUILDING_BLOCKS.includes(this.condition.condition) &&
!this._collapsed
CONDITION_BUILDING_BLOCKS.includes(this.condition.condition)
? html`<ha-automation-condition-editor
class=${this._collapsed ? "hidden" : ""}
.hass=${this.hass}
.condition=${this.condition}
.disabled=${this.disabled}
@@ -622,9 +621,10 @@ export default class HaAutomationConditionRow extends LitElement {
disable: this._onDisable,
delete: this._onDelete,
config: sidebarCondition,
type: "condition",
uiSupported: this._uiSupported(sidebarCondition.condition),
yamlMode: this._yamlMode,
} satisfies ConditionSidebarConfig);
});
this._selected = true;
}

View File

@@ -16,7 +16,6 @@ import type {
AutomationClipboard,
Condition,
} from "../../../../data/automation";
import { CONDITION_BUILDING_BLOCKS } from "../../../../data/condition";
import type { HomeAssistant } from "../../../../types";
import {
PASTE_VALUE,
@@ -24,6 +23,7 @@ import {
} from "../show-add-automation-element-dialog";
import "./ha-automation-condition-row";
import type HaAutomationConditionRow from "./ha-automation-condition-row";
import { CONDITION_BUILDING_BLOCKS } from "../../../../data/condition";
@customElement("ha-automation-condition")
export default class HaAutomationCondition extends LitElement {
@@ -111,9 +111,7 @@ export default class HaAutomationCondition extends LitElement {
} else if (!this.optionsInSidebar) {
row.expand();
}
if (this.narrow) {
row.scrollIntoView();
}
row.scrollIntoView();
row.focus();
});
}

View File

@@ -535,10 +535,12 @@ export class HaAutomationEditor extends PreventUnsavedMixin(
<ha-fab
slot="fab"
class=${this._dirty ? "dirty" : ""}
.label=${this.hass.localize("ui.common.save")}
.label=${this.hass.localize(
"ui.panel.config.automation.editor.save"
)}
.disabled=${this._saving}
extended
@click=${this._handleSaveAutomation}
@click=${this._saveAutomation}
>
<ha-svg-icon
slot="icon"

View File

@@ -1,32 +1,59 @@
import {
mdiClose,
mdiDelete,
mdiDotsVertical,
mdiIdentifier,
mdiPlayCircleOutline,
mdiPlaylistEdit,
mdiRenameBox,
mdiStopCircleOutline,
} from "@mdi/js";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { fireEvent } from "../../../common/dom/fire_event";
import {
isCondition,
isScriptField,
isTrigger,
type ActionSidebarConfig,
type ConditionSidebarConfig,
type ScriptFieldSidebarConfig,
type SidebarConfig,
type TriggerSidebarConfig,
} from "../../../data/automation";
import { stopPropagation } from "../../../common/dom/stop_propagation";
import { handleStructError } from "../../../common/structs/handle-errors";
import type { LocalizeKeys } from "../../../common/translations/localize";
import "../../../components/ha-card";
import "../../../components/ha-dialog-header";
import "../../../components/ha-icon-button";
import "../../../components/ha-md-button-menu";
import "../../../components/ha-md-divider";
import "../../../components/ha-md-menu-item";
import type { Condition, Trigger } from "../../../data/automation";
import type { Action, RepeatAction } from "../../../data/script";
import { isTriggerList } from "../../../data/trigger";
import type { HomeAssistant } from "../../../types";
import "./action/ha-automation-action-editor";
import { getAutomationActionType } from "./action/ha-automation-action-row";
import { getRepeatType } from "./action/types/ha-automation-action-repeat";
import "./condition/ha-automation-condition-editor";
import type HaAutomationConditionEditor from "./condition/ha-automation-condition-editor";
import "./sidebar/ha-automation-sidebar-action";
import "./sidebar/ha-automation-sidebar-condition";
import "./sidebar/ha-automation-sidebar-option";
import "./sidebar/ha-automation-sidebar-script-field";
import "./sidebar/ha-automation-sidebar-script-field-selector";
import "./sidebar/ha-automation-sidebar-trigger";
import "./ha-automation-editor-warning";
import "./trigger/ha-automation-trigger-editor";
import type HaAutomationTriggerEditor from "./trigger/ha-automation-trigger-editor";
import { ACTION_BUILDING_BLOCKS } from "../../../data/action";
import { CONDITION_BUILDING_BLOCKS } from "../../../data/condition";
export interface OpenSidebarConfig {
save: (config: Trigger | Condition | Action) => void;
close: () => void;
rename: () => void;
toggleYamlMode: () => boolean;
disable: () => void;
delete: () => void;
config: Trigger | Condition | Action;
type: "trigger" | "condition" | "action" | "option";
uiSupported: boolean;
yamlMode: boolean;
}
@customElement("ha-automation-sidebar")
export default class HaAutomationSidebar extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public config?: SidebarConfig;
@property({ attribute: false }) public config?: OpenSidebarConfig;
@property({ type: Boolean, attribute: "wide" }) public isWide = false;
@@ -34,134 +61,270 @@ export default class HaAutomationSidebar extends LitElement {
@state() private _yamlMode = false;
@state() private _requestShowId = false;
@state() private _warnings?: string[];
@query(".sidebar-editor")
public editor?: HaAutomationTriggerEditor | HaAutomationConditionEditor;
protected willUpdate(changedProperties) {
if (changedProperties.has("config")) {
this._requestShowId = false;
this._warnings = undefined;
if (this.config) {
this._yamlMode = this.config.yamlMode;
if (this._yamlMode) {
this.editor?.yamlEditor?.setValue(this.config.config);
}
}
}
}
protected render() {
if (!this.config) {
return nothing;
}
// get config type
const type = this._getType();
const disabled =
this.disabled ||
("enabled" in this.config.config && this.config.config.enabled === false);
let type = isTriggerList(this.config.config as Trigger)
? "list"
: this.config.type === "action"
? getAutomationActionType(this.config.config as Action)
: this.config.config[this.config.type];
if (type === "trigger") {
return html`
<ha-automation-sidebar-trigger
.hass=${this.hass}
.config=${this.config}
.isWide=${this.isWide}
.disabled=${this.disabled}
.yamlMode=${this._yamlMode}
@toggle-yaml-mode=${this._toggleYamlMode}
@close-sidebar=${this._closeSidebar}
></ha-automation-sidebar-trigger>
`;
}
if (type === "condition") {
return html`
<ha-automation-sidebar-condition
.hass=${this.hass}
.config=${this.config}
.isWide=${this.isWide}
.disabled=${this.disabled}
.yamlMode=${this._yamlMode}
@toggle-yaml-mode=${this._toggleYamlMode}
@close-sidebar=${this._closeSidebar}
></ha-automation-sidebar-condition>
`;
}
if (type === "action") {
return html`
<ha-automation-sidebar-action
.hass=${this.hass}
.config=${this.config}
.isWide=${this.isWide}
.disabled=${this.disabled}
.yamlMode=${this._yamlMode}
@toggle-yaml-mode=${this._toggleYamlMode}
@close-sidebar=${this._closeSidebar}
></ha-automation-sidebar-action>
`;
}
if (type === "option") {
return html`
<ha-automation-sidebar-option
.hass=${this.hass}
.config=${this.config}
.isWide=${this.isWide}
.disabled=${this.disabled}
@close-sidebar=${this._closeSidebar}
></ha-automation-sidebar-option>
`;
}
if (type === "script-field-selector") {
return html`
<ha-automation-sidebar-script-field-selector
.hass=${this.hass}
.config=${this.config}
.isWide=${this.isWide}
.disabled=${this.disabled}
.yamlMode=${this._yamlMode}
@toggle-yaml-mode=${this._toggleYamlMode}
@close-sidebar=${this._closeSidebar}
></ha-automation-sidebar-script-field-selector>
`;
}
if (type === "script-field") {
return html`
<ha-automation-sidebar-script-field
.hass=${this.hass}
.config=${this.config}
.isWide=${this.isWide}
.disabled=${this.disabled}
.yamlMode=${this._yamlMode}
@toggle-yaml-mode=${this._toggleYamlMode}
@close-sidebar=${this._closeSidebar}
></ha-automation-sidebar-script-field>
`;
if (this.config.type === "action" && type === "repeat") {
type = `repeat_${getRepeatType((this.config.config as RepeatAction).repeat)}`;
}
return nothing;
const isBuildingBlock = [
...CONDITION_BUILDING_BLOCKS,
...ACTION_BUILDING_BLOCKS,
].includes(type);
const subtitle = this.hass.localize(
(this.config.type === "option"
? "ui.panel.config.automation.editor.actions.type.choose.label"
: `ui.panel.config.automation.editor.${this.config.type}s.${this.config.type}`) as LocalizeKeys
);
const title =
this.hass.localize(
(this.config.type === "option"
? "ui.panel.config.automation.editor.actions.type.choose.option_label"
: `ui.panel.config.automation.editor.${this.config.type}s.type.${type}.label`) as LocalizeKeys
) || type;
const description =
isBuildingBlock || this.config.type === "option"
? this.hass.localize(
(this.config.type === "option"
? "ui.panel.config.automation.editor.actions.type.choose.option_description"
: `ui.panel.config.automation.editor.${this.config.type}s.type.${type}.description.picker`) as LocalizeKeys
)
: "";
return html`
<ha-card
outlined
class=${classMap({
mobile: !this.isWide,
yaml: this._yamlMode,
})}
>
<ha-dialog-header>
<ha-icon-button
slot="navigationIcon"
.label=${this.hass.localize("ui.common.close")}
.path=${mdiClose}
@click=${this._closeSidebar}
></ha-icon-button>
<span slot="title">${title}</span>
<span slot="subtitle">${subtitle}</span>
<ha-md-button-menu
slot="actionItems"
@click=${this._openOverflowMenu}
@keydown=${stopPropagation}
@closed=${stopPropagation}
positioning="fixed"
>
<ha-icon-button
slot="trigger"
.label=${this.hass.localize("ui.common.menu")}
.path=${mdiDotsVertical}
></ha-icon-button>
<ha-md-menu-item
.clickAction=${this.config.rename}
.disabled=${disabled || type === "list"}
>
${this.hass.localize(
"ui.panel.config.automation.editor.triggers.rename"
)}
<ha-svg-icon slot="start" .path=${mdiRenameBox}></ha-svg-icon>
</ha-md-menu-item>
${this.config.type === "trigger" &&
!this._yamlMode &&
!("id" in this.config.config) &&
!this._requestShowId
? html`<ha-md-menu-item
.clickAction=${this._showTriggerId}
.disabled=${disabled || type === "list"}
>
${this.hass.localize(
"ui.panel.config.automation.editor.triggers.edit_id"
)}
<ha-svg-icon
slot="start"
.path=${mdiIdentifier}
></ha-svg-icon>
</ha-md-menu-item>`
: nothing}
${this.config.type !== "option"
? html`
<ha-md-menu-item
.clickAction=${this._toggleYamlMode}
.disabled=${!this.config.uiSupported || !!this._warnings}
>
${this.hass.localize(
`ui.panel.config.automation.editor.edit_${!this._yamlMode ? "yaml" : "ui"}`
)}
<ha-svg-icon
slot="start"
.path=${mdiPlaylistEdit}
></ha-svg-icon>
</ha-md-menu-item>
`
: nothing}
<ha-md-divider role="separator" tabindex="-1"></ha-md-divider>
${this.config.type !== "option"
? html`
<ha-md-menu-item
.clickAction=${this.config.disable}
.disabled=${this.disabled || type === "list"}
>
${disabled
? this.hass.localize(
"ui.panel.config.automation.editor.actions.enable"
)
: this.hass.localize(
"ui.panel.config.automation.editor.actions.disable"
)}
<ha-svg-icon
slot="start"
.path=${disabled
? mdiPlayCircleOutline
: mdiStopCircleOutline}
></ha-svg-icon>
</ha-md-menu-item>
`
: nothing}
<ha-md-menu-item
.clickAction=${this.config.delete}
class="warning"
.disabled=${this.disabled}
>
${this.hass.localize(
`ui.panel.config.automation.editor.actions.${this.config.type !== "option" ? "delete" : "type.choose.remove_option"}`
)}
<ha-svg-icon
class="warning"
slot="start"
.path=${mdiDelete}
></ha-svg-icon>
</ha-md-menu-item>
</ha-md-button-menu>
</ha-dialog-header>
${this._warnings
? html`<ha-automation-editor-warning
.localize=${this.hass.localize}
.warnings=${this._warnings}
>
</ha-automation-editor-warning>`
: nothing}
<div class="card-content">
${this.config.type === "trigger"
? html`<ha-automation-trigger-editor
class="sidebar-editor"
.hass=${this.hass}
.trigger=${this.config.config as Trigger}
@value-changed=${this._valueChangedSidebar}
.uiSupported=${this.config.uiSupported}
.showId=${this._requestShowId}
.yamlMode=${this._yamlMode}
.disabled=${this.disabled}
@ui-mode-not-available=${this._handleUiModeNotAvailable}
></ha-automation-trigger-editor>`
: this.config.type === "condition" &&
(this._yamlMode || !CONDITION_BUILDING_BLOCKS.includes(type))
? html`
<ha-automation-condition-editor
class="sidebar-editor"
.hass=${this.hass}
.condition=${this.config.config as Condition}
.yamlMode=${this._yamlMode}
.uiSupported=${this.config.uiSupported}
@value-changed=${this._valueChangedSidebar}
.disabled=${this.disabled}
@ui-mode-not-available=${this._handleUiModeNotAvailable}
></ha-automation-condition-editor>
`
: this.config.type === "action" &&
(this._yamlMode || !ACTION_BUILDING_BLOCKS.includes(type))
? html`
<ha-automation-action-editor
class="sidebar-editor"
.hass=${this.hass}
.action=${this.config.config as Action}
.yamlMode=${this._yamlMode}
.uiSupported=${this.config.uiSupported}
@value-changed=${this._valueChangedSidebar}
sidebar
narrow
.disabled=${this.disabled}
@ui-mode-not-available=${this._handleUiModeNotAvailable}
></ha-automation-action-editor>
`
: description || nothing}
</div>
</ha-card>
`;
}
private _getType() {
if (
(this.config as TriggerSidebarConfig)?.config &&
(isTrigger((this.config as TriggerSidebarConfig)?.config) ||
isTriggerList((this.config as TriggerSidebarConfig)?.config))
) {
return "trigger";
private _handleUiModeNotAvailable(ev: CustomEvent) {
this._warnings = handleStructError(this.hass, ev.detail).warnings;
if (!this._yamlMode) {
this._yamlMode = true;
}
if (isCondition((this.config as ConditionSidebarConfig)?.config)) {
return "condition";
}
if (
(this.config as ScriptFieldSidebarConfig)?.config &&
isScriptField((this.config as ScriptFieldSidebarConfig)?.config)
) {
return (this.config as ScriptFieldSidebarConfig)?.config.selector
? "script-field-selector"
: "script-field";
}
// option is always a building block and doesn't have a config
if (this.config && !(this.config as any)?.config) {
return "option";
}
if ((this.config as ActionSidebarConfig)?.config) {
return "action";
}
return undefined;
}
private _closeSidebar(ev: CustomEvent) {
private _valueChangedSidebar(ev: CustomEvent) {
ev.stopPropagation();
this.config?.save(ev.detail.value);
if (this.config) {
fireEvent(this, "value-changed", {
value: {
...this.config,
config: ev.detail.value,
},
});
}
}
private _closeSidebar() {
this.config?.close();
}
private _openOverflowMenu(ev: MouseEvent) {
ev.stopPropagation();
ev.preventDefault();
}
private _toggleYamlMode = () => {
this._yamlMode = this.config!.toggleYamlMode();
fireEvent(this, "value-changed", {
@@ -172,6 +335,10 @@ export default class HaAutomationSidebar extends LitElement {
});
};
private _showTriggerId = () => {
this._requestShowId = true;
};
static styles = css`
:host {
height: 100%;
@@ -181,6 +348,60 @@ export default class HaAutomationSidebar extends LitElement {
);
border-radius: var(--ha-card-border-radius);
}
ha-card {
height: 100%;
width: 100%;
border-color: var(--primary-color);
border-width: 2px;
display: block;
}
ha-card.mobile {
border-bottom-right-radius: var(--ha-border-radius-square);
border-bottom-left-radius: var(--ha-border-radius-square);
}
@media all and (max-width: 870px) {
ha-card.mobile {
max-height: 70vh;
max-height: 70dvh;
border-width: 2px 2px 0;
}
ha-card.mobile.yaml {
height: 70vh;
height: 70dvh;
}
}
ha-dialog-header {
border-radius: var(--ha-card-border-radius);
}
.sidebar-editor {
padding-top: 64px;
}
.card-content {
max-height: calc(100% - 80px);
overflow: auto;
}
@media (min-width: 450px) and (min-height: 500px) {
.card-content {
max-height: calc(100% - 104px);
overflow: auto;
}
}
@media all and (max-width: 870px) {
ha-card.mobile .card-content {
max-height: calc(
70vh - 88px - max(var(--safe-area-inset-bottom), 16px)
);
max-height: calc(
70dvh - 88px - max(var(--safe-area-inset-bottom), 16px)
);
}
}
`;
}
@@ -188,8 +409,4 @@ declare global {
interface HTMLElementTagNameMap {
"ha-automation-sidebar": HaAutomationSidebar;
}
interface HASSDomEvents {
"toggle-yaml-mode": undefined;
}
}

View File

@@ -1,7 +1,7 @@
import { mdiContentSave, mdiHelpCircle } from "@mdi/js";
import type { HassEntity } from "home-assistant-js-websocket";
import { load } from "js-yaml";
import type { CSSResultGroup } from "lit";
import type { CSSResultGroup, PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
@@ -18,6 +18,11 @@ import {
import { ensureArray } from "../../../common/array/ensure-array";
import { canOverrideAlphanumericInput } from "../../../common/dom/can-override-input";
import { fireEvent } from "../../../common/dom/fire_event";
import { constructUrlCurrentPath } from "../../../common/url/construct-url";
import {
extractSearchParam,
removeSearchParam,
} from "../../../common/url/search-params";
import "../../../components/ha-button";
import "../../../components/ha-fab";
import "../../../components/ha-icon-button";
@@ -26,7 +31,6 @@ import type {
AutomationConfig,
Condition,
ManualAutomationConfig,
SidebarConfig,
Trigger,
} from "../../../data/automation";
import {
@@ -39,11 +43,15 @@ import type { HomeAssistant } from "../../../types";
import { documentationUrl } from "../../../util/documentation-url";
import { showToast } from "../../../util/toast";
import "./action/ha-automation-action";
import type HaAutomationAction from "./action/ha-automation-action";
import "./condition/ha-automation-condition";
import type HaAutomationCondition from "./condition/ha-automation-condition";
import "./ha-automation-sidebar";
import type { OpenSidebarConfig } from "./ha-automation-sidebar";
import { showPasteReplaceDialog } from "./paste-replace-dialog/show-dialog-paste-replace";
import { saveFabStyles } from "./styles";
import "./trigger/ha-automation-trigger";
import type HaAutomationTrigger from "./trigger/ha-automation-trigger";
const baseConfigStruct = object({
alias: optional(string()),
@@ -82,7 +90,7 @@ export class HaManualAutomationEditor extends LitElement {
@state() private _pastedConfig?: ManualAutomationConfig;
@state() private _sidebarConfig?: SidebarConfig;
@state() private _sidebarConfig?: OpenSidebarConfig;
private _previousConfig?: ManualAutomationConfig;
@@ -96,6 +104,31 @@ export class HaManualAutomationEditor extends LitElement {
super.disconnectedCallback();
}
protected firstUpdated(changedProps: PropertyValues): void {
super.firstUpdated(changedProps);
const expanded = extractSearchParam("expanded");
if (expanded === "1") {
this._clearParam("expanded");
const items = this.shadowRoot!.querySelectorAll<
HaAutomationTrigger | HaAutomationCondition | HaAutomationAction
>("ha-automation-trigger, ha-automation-condition, ha-automation-action");
items.forEach((el) => {
el.updateComplete.then(() => {
el.expandAll();
});
});
}
}
private _clearParam(param: string) {
window.history.replaceState(
null,
"",
constructUrlCurrentPath(removeSearchParam(param))
);
}
private _renderContent() {
return html`
${this.stateObj?.state === "off"
@@ -261,7 +294,9 @@ export class HaManualAutomationEditor extends LitElement {
<ha-fab
slot="fab"
class=${this.dirty ? "dirty" : ""}
.label=${this.hass.localize("ui.common.save")}
.label=${this.hass.localize(
"ui.panel.config.automation.editor.save"
)}
.disabled=${this.saving}
extended
@click=${this._saveAutomation}
@@ -285,13 +320,13 @@ export class HaManualAutomationEditor extends LitElement {
`;
}
private _openSidebar(ev: CustomEvent<SidebarConfig>) {
private _openSidebar(ev: CustomEvent<OpenSidebarConfig>) {
// deselect previous selected row
this._sidebarConfig?.close?.();
this._sidebarConfig = ev.detail;
}
private _sidebarConfigChanged(ev: CustomEvent<{ value: SidebarConfig }>) {
private _sidebarConfigChanged(ev: CustomEvent<{ value: OpenSidebarConfig }>) {
ev.stopPropagation();
if (!this._sidebarConfig) {
return;
@@ -683,7 +718,7 @@ declare global {
}
interface HASSDomEvents {
"open-sidebar": SidebarConfig;
"open-sidebar": OpenSidebarConfig;
"close-sidebar": undefined;
}
}

View File

@@ -23,10 +23,7 @@ import "../../../../components/ha-icon-button";
import "../../../../components/ha-md-button-menu";
import "../../../../components/ha-md-menu-item";
import "../../../../components/ha-svg-icon";
import type {
Condition,
OptionSidebarConfig,
} from "../../../../data/automation";
import type { Condition } from "../../../../data/automation";
import { describeCondition } from "../../../../data/automation_i18n";
import { fullEntitiesContext } from "../../../../data/context";
import type { EntityRegistryEntry } from "../../../../data/entity_registry";
@@ -192,6 +189,7 @@ export default class HaAutomationOptionRow extends LitElement {
"card-content": true,
indent: this.optionsInSidebar,
selected: this._selected,
hidden: this._collapsed,
})}
>
<h4>
@@ -248,9 +246,7 @@ export default class HaAutomationOptionRow extends LitElement {
`}
</ha-card>
${this.optionsInSidebar && !this._collapsed
? this._renderContent()
: nothing}
${this.optionsInSidebar ? this._renderContent() : nothing}
`;
}
@@ -349,6 +345,9 @@ export default class HaAutomationOptionRow extends LitElement {
}
fireEvent(this, "open-sidebar", {
save: () => {
// nothing to save for an option in the sidebar
},
close: () => {
this._selected = false;
fireEvent(this, "close-sidebar");
@@ -357,8 +356,15 @@ export default class HaAutomationOptionRow extends LitElement {
this._renameOption();
},
toggleYamlMode: () => false, // no yaml mode for options
disable: () => {
// option cannot be disabled
},
delete: this._removeOption,
} satisfies OptionSidebarConfig);
config: {},
type: "option",
uiSupported: true,
yamlMode: false,
});
this._selected = true;
}

View File

@@ -133,10 +133,7 @@ export default class HaAutomationOption extends LitElement {
if (!this.optionsInSidebar) {
row.expand();
}
if (this.narrow) {
row.scrollIntoView();
}
row.scrollIntoView();
row.focus();
});
}

View File

@@ -1,179 +0,0 @@
import {
mdiDelete,
mdiPlayCircleOutline,
mdiPlaylistEdit,
mdiRenameBox,
mdiStopCircleOutline,
} from "@mdi/js";
import { css, html, LitElement } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { fireEvent } from "../../../../common/dom/fire_event";
import { handleStructError } from "../../../../common/structs/handle-errors";
import type { LocalizeKeys } from "../../../../common/translations/localize";
import { ACTION_BUILDING_BLOCKS } from "../../../../data/action";
import type { ActionSidebarConfig } from "../../../../data/automation";
import type { RepeatAction } from "../../../../data/script";
import type { HomeAssistant } from "../../../../types";
import type HaAutomationConditionEditor from "../action/ha-automation-action-editor";
import { getAutomationActionType } from "../action/ha-automation-action-row";
import { getRepeatType } from "../action/types/ha-automation-action-repeat";
import "../trigger/ha-automation-trigger-editor";
import "./ha-automation-sidebar-card";
import type { SidebarOverflowMenu } from "./ha-automation-sidebar-card";
@customElement("ha-automation-sidebar-action")
export default class HaAutomationSidebarAction extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public config!: ActionSidebarConfig;
@property({ type: Boolean, attribute: "wide" }) public isWide = false;
@property({ type: Boolean }) public disabled = false;
@property({ type: Boolean, attribute: "yaml-mode" }) public yamlMode = false;
@state() private _warnings?: string[];
@query(".sidebar-editor")
public editor?: HaAutomationConditionEditor;
protected willUpdate(changedProperties) {
if (changedProperties.has("config")) {
this._warnings = undefined;
if (this.config) {
this.yamlMode = this.config.yamlMode;
if (this.yamlMode) {
this.editor?.yamlEditor?.setValue(this.config.config);
}
}
}
}
protected render() {
const disabled = this.disabled || this.config.config.enabled;
const actionType = getAutomationActionType(this.config.config);
const type =
actionType !== "repeat"
? actionType
: `repeat_${getRepeatType((this.config.config as RepeatAction).repeat)}`;
const isBuildingBlock = ACTION_BUILDING_BLOCKS.includes(type || "");
const subtitle = this.hass.localize(
"ui.panel.config.automation.editor.actions.action"
);
const title =
this.hass.localize(
`ui.panel.config.automation.editor.actions.type.${type}.label` as LocalizeKeys
) || type;
const description = isBuildingBlock
? this.hass.localize(
`ui.panel.config.automation.editor.actions.type.${type}.description.picker` as LocalizeKeys
)
: "";
const menuEntries: SidebarOverflowMenu = [
{
clickAction: this.config.rename,
disabled: disabled,
label: this.hass.localize(
"ui.panel.config.automation.editor.triggers.rename"
),
icon: mdiRenameBox,
},
{
clickAction: this._toggleYamlMode,
disabled: !this.config.uiSupported || !!this._warnings,
label: this.hass.localize(
`ui.panel.config.automation.editor.edit_${!this.yamlMode ? "yaml" : "ui"}`
),
icon: mdiPlaylistEdit,
},
"separator",
{
clickAction: this.config.disable,
disabled: disabled,
label: this.hass.localize(
`ui.panel.config.automation.editor.actions.${disabled ? "enable" : "disable"}`
),
icon: this.disabled ? mdiPlayCircleOutline : mdiStopCircleOutline,
},
{
clickAction: this.config.delete,
danger: true,
disabled: this.disabled,
label: this.hass.localize(
"ui.panel.config.automation.editor.actions.delete"
),
icon: mdiDelete,
},
];
return html`<ha-automation-sidebar-card
.hass=${this.hass}
.isWide=${this.isWide}
.yamlMode=${this.yamlMode}
.warnings=${this._warnings}
.menuEntries=${menuEntries}
>
<span slot="title">${title}</span>
<span slot="subtitle">${subtitle}</span>
${description ||
html`<ha-automation-action-editor
class="sidebar-editor"
.hass=${this.hass}
.action=${this.config.config}
.yamlMode=${this.yamlMode}
.uiSupported=${this.config.uiSupported}
@value-changed=${this._valueChangedSidebar}
sidebar
narrow
.disabled=${this.disabled}
@ui-mode-not-available=${this._handleUiModeNotAvailable}
></ha-automation-action-editor>`}
</ha-automation-sidebar-card>`;
}
private _handleUiModeNotAvailable(ev: CustomEvent) {
this._warnings = handleStructError(this.hass, ev.detail).warnings;
if (!this.yamlMode) {
this.yamlMode = true;
}
}
private _valueChangedSidebar(ev: CustomEvent) {
ev.stopPropagation();
this.config?.save?.(ev.detail.value);
if (this.config) {
fireEvent(this, "value-changed", {
value: {
...this.config,
config: ev.detail.value,
},
});
}
}
private _toggleYamlMode = () => {
fireEvent(this, "toggle-yaml-mode");
};
static styles = css`
.sidebar-editor {
padding-top: 64px;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-automation-sidebar-action": HaAutomationSidebarAction;
}
}

View File

@@ -1,179 +0,0 @@
import { mdiClose, mdiDotsVertical } from "@mdi/js";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { fireEvent } from "../../../../common/dom/fire_event";
import { stopPropagation } from "../../../../common/dom/stop_propagation";
import "../../../../components/ha-card";
import "../../../../components/ha-dialog-header";
import "../../../../components/ha-icon-button";
import "../../../../components/ha-md-button-menu";
import "../../../../components/ha-md-divider";
import "../../../../components/ha-md-menu-item";
import type { HomeAssistant } from "../../../../types";
import "../ha-automation-editor-warning";
export interface SidebarOverflowMenuEntry {
clickAction: () => void;
disabled?: boolean;
label: string;
icon?: string;
danger?: boolean;
}
export type SidebarOverflowMenu = (SidebarOverflowMenuEntry | "separator")[];
@customElement("ha-automation-sidebar-card")
export default class HaAutomationSidebarCard extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ type: Boolean, attribute: "wide" }) public isWide = false;
@property({ type: Boolean, attribute: "yaml-mode" }) public yamlMode = false;
@property({ attribute: false }) public menuEntries: SidebarOverflowMenu = [];
@property({ attribute: false }) public warnings?: string[];
protected render() {
return html`
<ha-card
outlined
class=${classMap({
mobile: !this.isWide,
yaml: this.yamlMode,
})}
>
<ha-dialog-header>
<ha-icon-button
slot="navigationIcon"
.label=${this.hass.localize("ui.common.close")}
.path=${mdiClose}
@click=${this._closeSidebar}
></ha-icon-button>
<slot slot="title" name="title"></slot>
<slot slot="subtitle" name="subtitle"></slot>
${this.menuEntries.filter((entry) => entry !== "separator").length
? html`
<ha-md-button-menu
slot="actionItems"
@click=${this._openOverflowMenu}
@keydown=${stopPropagation}
@closed=${stopPropagation}
positioning="fixed"
>
<ha-icon-button
slot="trigger"
.label=${this.hass.localize("ui.common.menu")}
.path=${mdiDotsVertical}
></ha-icon-button>
${this.menuEntries.map((menuEntry) =>
menuEntry !== "separator"
? html`
<ha-md-menu-item
.clickAction=${menuEntry.clickAction}
.disabled=${!!menuEntry.disabled}
class=${menuEntry.danger ? "warning" : ""}
>
${menuEntry.label}
${menuEntry.icon
? html`<ha-svg-icon
slot="start"
.path=${menuEntry.icon}
></ha-svg-icon>`
: nothing}
</ha-md-menu-item>
`
: html`
<ha-md-divider
role="separator"
tabindex="-1"
></ha-md-divider>
`
)}
</ha-md-button-menu>
`
: nothing}
</ha-dialog-header>
${this.warnings
? html`<ha-automation-editor-warning
.localize=${this.hass.localize}
.warnings=${this.warnings}
>
</ha-automation-editor-warning>`
: nothing}
<div class="card-content">
<slot></slot>
</div>
</ha-card>
`;
}
private _closeSidebar() {
fireEvent(this, "close-sidebar");
}
private _openOverflowMenu(ev: MouseEvent) {
ev.stopPropagation();
ev.preventDefault();
}
static styles = css`
ha-card {
height: 100%;
width: 100%;
border-color: var(--primary-color);
border-width: 2px;
display: block;
}
ha-card.mobile {
border-bottom-right-radius: var(--ha-border-radius-square);
border-bottom-left-radius: var(--ha-border-radius-square);
}
@media all and (max-width: 870px) {
ha-card.mobile {
max-height: 70vh;
max-height: 70dvh;
border-width: 2px 2px 0;
}
ha-card.mobile.yaml {
height: 70vh;
height: 70dvh;
}
}
ha-dialog-header {
border-radius: var(--ha-card-border-radius);
}
.card-content {
max-height: calc(100% - 80px);
overflow: auto;
}
@media (min-width: 450px) and (min-height: 500px) {
.card-content {
max-height: calc(100% - 104px);
overflow: auto;
}
}
@media all and (max-width: 870px) {
ha-card.mobile .card-content {
max-height: calc(
70vh - 88px - max(var(--safe-area-inset-bottom), 16px)
);
max-height: calc(
70dvh - 88px - max(var(--safe-area-inset-bottom), 16px)
);
}
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-automation-sidebar-card": HaAutomationSidebarCard;
}
}

View File

@@ -1,168 +0,0 @@
import {
mdiDelete,
mdiPlayCircleOutline,
mdiPlaylistEdit,
mdiRenameBox,
mdiStopCircleOutline,
} from "@mdi/js";
import { css, html, LitElement } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { fireEvent } from "../../../../common/dom/fire_event";
import { handleStructError } from "../../../../common/structs/handle-errors";
import type { ConditionSidebarConfig } from "../../../../data/automation";
import { CONDITION_BUILDING_BLOCKS } from "../../../../data/condition";
import type { HomeAssistant } from "../../../../types";
import "../condition/ha-automation-condition-editor";
import type HaAutomationConditionEditor from "../condition/ha-automation-condition-editor";
import "./ha-automation-sidebar-card";
import type { SidebarOverflowMenu } from "./ha-automation-sidebar-card";
@customElement("ha-automation-sidebar-condition")
export default class HaAutomationSidebarCondition extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public config!: ConditionSidebarConfig;
@property({ type: Boolean, attribute: "wide" }) public isWide = false;
@property({ type: Boolean }) public disabled = false;
@property({ type: Boolean, attribute: "yaml-mode" }) public yamlMode = false;
@state() private _warnings?: string[];
@query(".sidebar-editor")
public editor?: HaAutomationConditionEditor;
protected willUpdate(changedProperties) {
if (changedProperties.has("config")) {
this._warnings = undefined;
if (this.config) {
this.yamlMode = this.config.yamlMode;
if (this.yamlMode) {
this.editor?.yamlEditor?.setValue(this.config.config);
}
}
}
}
protected render() {
const disabled = this.disabled || this.config.config.enabled;
const type = this.config.config.condition;
const isBuildingBlock = CONDITION_BUILDING_BLOCKS.includes(type);
const subtitle = this.hass.localize(
"ui.panel.config.automation.editor.conditions.condition"
);
const title =
this.hass.localize(
`ui.panel.config.automation.editor.conditions.type.${type}.label`
) || type;
const description = isBuildingBlock
? this.hass.localize(
`ui.panel.config.automation.editor.conditions.type.${type}.description.picker`
)
: "";
const menuEntries: SidebarOverflowMenu = [
{
clickAction: this.config.rename,
disabled: disabled,
label: this.hass.localize(
"ui.panel.config.automation.editor.triggers.rename"
),
icon: mdiRenameBox,
},
{
clickAction: this._toggleYamlMode,
disabled: !this.config.uiSupported || !!this._warnings,
label: this.hass.localize(
`ui.panel.config.automation.editor.edit_${!this.yamlMode ? "yaml" : "ui"}`
),
icon: mdiPlaylistEdit,
},
"separator",
{
clickAction: this.config.disable,
disabled: disabled,
label: this.hass.localize(
`ui.panel.config.automation.editor.actions.${disabled ? "enable" : "disable"}`
),
icon: this.disabled ? mdiPlayCircleOutline : mdiStopCircleOutline,
},
{
clickAction: this.config.delete,
danger: true,
disabled: this.disabled,
label: this.hass.localize(
"ui.panel.config.automation.editor.actions.delete"
),
icon: mdiDelete,
},
];
return html`<ha-automation-sidebar-card
.hass=${this.hass}
.isWide=${this.isWide}
.yamlMode=${this.yamlMode}
.warnings=${this._warnings}
.menuEntries=${menuEntries}
>
<span slot="title">${title}</span>
<span slot="subtitle">${subtitle}</span>
${description ||
html`<ha-automation-condition-editor
class="sidebar-editor"
.hass=${this.hass}
.condition=${this.config.config}
.yamlMode=${this.yamlMode}
.uiSupported=${this.config.uiSupported}
@value-changed=${this._valueChangedSidebar}
.disabled=${this.disabled}
@ui-mode-not-available=${this._handleUiModeNotAvailable}
></ha-automation-condition-editor> `}
</ha-automation-sidebar-card>`;
}
private _handleUiModeNotAvailable(ev: CustomEvent) {
this._warnings = handleStructError(this.hass, ev.detail).warnings;
if (!this.yamlMode) {
this.yamlMode = true;
}
}
private _valueChangedSidebar(ev: CustomEvent) {
ev.stopPropagation();
this.config?.save?.(ev.detail.value);
if (this.config) {
fireEvent(this, "value-changed", {
value: {
...this.config,
config: ev.detail.value,
},
});
}
}
private _toggleYamlMode = () => {
fireEvent(this, "toggle-yaml-mode");
};
static styles = css`
.sidebar-editor {
padding-top: 64px;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-automation-sidebar-condition": HaAutomationSidebarCondition;
}
}

View File

@@ -1,80 +0,0 @@
import { mdiDelete, mdiRenameBox } from "@mdi/js";
import { css, html, LitElement } from "lit";
import { customElement, property, query } from "lit/decorators";
import type { OptionSidebarConfig } from "../../../../data/automation";
import type { HomeAssistant } from "../../../../types";
import type HaAutomationConditionEditor from "../action/ha-automation-action-editor";
import "./ha-automation-sidebar-card";
import type { SidebarOverflowMenu } from "./ha-automation-sidebar-card";
@customElement("ha-automation-sidebar-option")
export default class HaAutomationSidebarOption extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public config!: OptionSidebarConfig;
@property({ type: Boolean, attribute: "wide" }) public isWide = false;
@property({ type: Boolean }) public disabled = false;
@query(".sidebar-editor")
public editor?: HaAutomationConditionEditor;
protected render() {
const disabled = this.disabled;
const subtitle = this.hass.localize(
"ui.panel.config.automation.editor.actions.type.choose.label"
);
const title = this.hass.localize(
"ui.panel.config.automation.editor.actions.type.choose.option_label"
);
const description = this.hass.localize(
"ui.panel.config.automation.editor.actions.type.choose.option_description"
);
const menuEntries: SidebarOverflowMenu = [
{
clickAction: this.config.rename,
disabled: disabled,
label: this.hass.localize(
"ui.panel.config.automation.editor.triggers.rename"
),
icon: mdiRenameBox,
},
"separator",
{
clickAction: this.config.delete,
danger: true,
disabled: this.disabled,
label: this.hass.localize(
"ui.panel.config.automation.editor.actions.type.choose.remove_option"
),
icon: mdiDelete,
},
];
return html`<ha-automation-sidebar-card
.hass=${this.hass}
.isWide=${this.isWide}
.menuEntries=${menuEntries}
>
<span slot="title">${title}</span>
<span slot="subtitle">${subtitle}</span>
${description}
</ha-automation-sidebar-card>`;
}
static styles = css`
.sidebar-editor {
padding-top: 64px;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-automation-sidebar-option": HaAutomationSidebarOption;
}
}

View File

@@ -1,132 +0,0 @@
import { mdiDelete, mdiPlaylistEdit } from "@mdi/js";
import { css, html, LitElement } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { fireEvent } from "../../../../common/dom/fire_event";
import type { LocalizeKeys } from "../../../../common/translations/localize";
import type { ScriptFieldSidebarConfig } from "../../../../data/automation";
import type { HomeAssistant } from "../../../../types";
import "../../script/ha-script-field-selector-editor";
import type HaAutomationConditionEditor from "../action/ha-automation-action-editor";
import "./ha-automation-sidebar-card";
import type { SidebarOverflowMenu } from "./ha-automation-sidebar-card";
@customElement("ha-automation-sidebar-script-field-selector")
export default class HaAutomationSidebarScriptFieldSelector extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public config!: ScriptFieldSidebarConfig;
@property({ type: Boolean, attribute: "wide" }) public isWide = false;
@property({ type: Boolean }) public disabled = false;
@property({ type: Boolean, attribute: "yaml-mode" }) public yamlMode = false;
@state() private _warnings?: string[];
@query(".sidebar-editor")
public editor?: HaAutomationConditionEditor;
protected willUpdate(changedProperties) {
if (changedProperties.has("config")) {
this._warnings = undefined;
if (this.config) {
this.yamlMode = this.config.yamlMode;
if (this.yamlMode) {
this.editor?.yamlEditor?.setValue(this.config.config);
}
}
}
}
protected render() {
const subtitle = this.hass.localize(
"ui.panel.config.script.editor.field.field_selector"
);
const title =
this.hass.localize(
`ui.components.selectors.selector.types.${Object.keys(this.config.config.field.selector)[0]}` as LocalizeKeys
) || Object.keys(this.config.config.field.selector)[0];
const menuEntries: SidebarOverflowMenu = [
{
clickAction: this._toggleYamlMode,
disabled: !!this._warnings,
label: this.hass.localize(
`ui.panel.config.automation.editor.edit_${!this.yamlMode ? "yaml" : "ui"}`
),
icon: mdiPlaylistEdit,
},
"separator",
{
clickAction: this.config.delete,
danger: true,
disabled: this.disabled,
label: this.hass.localize(
"ui.panel.config.automation.editor.actions.delete"
),
icon: mdiDelete,
},
];
return html`<ha-automation-sidebar-card
.hass=${this.hass}
.isWide=${this.isWide}
.yamlMode=${this.yamlMode}
.warnings=${this._warnings}
.menuEntries=${menuEntries}
>
<span slot="title">${title}</span>
<span slot="subtitle">${subtitle}</span>
<ha-script-field-selector-editor
class="sidebar-editor"
.hass=${this.hass}
.field=${this.config.config.field}
.disabled=${this.disabled}
@value-changed=${this._valueChangedSidebar}
.yamlMode=${this.yamlMode}
></ha-script-field-selector-editor>
</ha-automation-sidebar-card>`;
}
private _valueChangedSidebar(ev: CustomEvent) {
ev.stopPropagation();
this.config?.save?.({
...this.config.config.field,
key: this.config.config.key,
...ev.detail.value,
});
if (this.config) {
fireEvent(this, "value-changed", {
value: {
...this.config,
config: {
field: ev.detail.value,
key: this.config.config.key,
excludeKeys: this.config.config.excludeKeys,
selector: true,
},
},
});
}
}
private _toggleYamlMode = () => {
fireEvent(this, "toggle-yaml-mode");
};
static styles = css`
.sidebar-editor {
padding-top: 64px;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-automation-sidebar-script-field-selector": HaAutomationSidebarScriptFieldSelector;
}
}

View File

@@ -1,126 +0,0 @@
import { mdiDelete, mdiPlaylistEdit } from "@mdi/js";
import { css, html, LitElement } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { fireEvent } from "../../../../common/dom/fire_event";
import type { ScriptFieldSidebarConfig } from "../../../../data/automation";
import type { HomeAssistant } from "../../../../types";
import "../../script/ha-script-field-editor";
import type HaAutomationConditionEditor from "../action/ha-automation-action-editor";
import "./ha-automation-sidebar-card";
import type { SidebarOverflowMenu } from "./ha-automation-sidebar-card";
@customElement("ha-automation-sidebar-script-field")
export default class HaAutomationSidebarScriptField extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public config!: ScriptFieldSidebarConfig;
@property({ type: Boolean, attribute: "wide" }) public isWide = false;
@property({ type: Boolean }) public disabled = false;
@property({ type: Boolean, attribute: "yaml-mode" }) public yamlMode = false;
@state() private _warnings?: string[];
@query(".sidebar-editor")
public editor?: HaAutomationConditionEditor;
protected willUpdate(changedProperties) {
if (changedProperties.has("config")) {
this._warnings = undefined;
if (this.config) {
this.yamlMode = this.config.yamlMode;
if (this.yamlMode) {
this.editor?.yamlEditor?.setValue(this.config.config);
}
}
}
}
protected render() {
const title = this.hass.localize(
"ui.panel.config.script.editor.field.label"
);
const menuEntries: SidebarOverflowMenu = [
{
clickAction: this._toggleYamlMode,
disabled: !!this._warnings,
label: this.hass.localize(
`ui.panel.config.automation.editor.edit_${!this.yamlMode ? "yaml" : "ui"}`
),
icon: mdiPlaylistEdit,
},
"separator",
{
clickAction: this.config.delete,
danger: true,
disabled: this.disabled,
label: this.hass.localize(
"ui.panel.config.automation.editor.actions.delete"
),
icon: mdiDelete,
},
];
return html`<ha-automation-sidebar-card
.hass=${this.hass}
.isWide=${this.isWide}
.yamlMode=${this.yamlMode}
.warnings=${this._warnings}
.menuEntries=${menuEntries}
>
<span slot="title">${title}</span>
<ha-script-field-editor
class="sidebar-editor"
.hass=${this.hass}
.field=${this.config.config.field}
.key=${this.config.config.key}
.excludeKeys=${this.config.config.excludeKeys}
.disabled=${this.disabled}
.yamlMode=${this.yamlMode}
@value-changed=${this._valueChangedSidebar}
></ha-script-field-editor>
</ha-automation-sidebar-card>`;
}
private _valueChangedSidebar(ev: CustomEvent) {
ev.stopPropagation();
this.config?.save?.({
...this.config.config.field,
key: ev.detail.value.key ?? this.config.config.key,
...ev.detail.value,
});
if (this.config) {
fireEvent(this, "value-changed", {
value: {
...this.config,
config: {
field: ev.detail.value,
key: ev.detail.value.key ?? this.config.config.key,
excludeKeys: this.config.config.excludeKeys,
},
},
});
}
}
private _toggleYamlMode = () => {
fireEvent(this, "toggle-yaml-mode");
};
static styles = css`
.sidebar-editor {
padding-top: 64px;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-automation-sidebar-script-field": HaAutomationSidebarScriptField;
}
}

View File

@@ -1,188 +0,0 @@
import {
mdiDelete,
mdiIdentifier,
mdiPlayCircleOutline,
mdiPlaylistEdit,
mdiRenameBox,
mdiStopCircleOutline,
} from "@mdi/js";
import { css, html, LitElement } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { fireEvent } from "../../../../common/dom/fire_event";
import { handleStructError } from "../../../../common/structs/handle-errors";
import type { TriggerSidebarConfig } from "../../../../data/automation";
import { isTriggerList } from "../../../../data/trigger";
import type { HomeAssistant } from "../../../../types";
import "../trigger/ha-automation-trigger-editor";
import type HaAutomationTriggerEditor from "../trigger/ha-automation-trigger-editor";
import "./ha-automation-sidebar-card";
import type { SidebarOverflowMenu } from "./ha-automation-sidebar-card";
@customElement("ha-automation-sidebar-trigger")
export default class HaAutomationSidebarTrigger extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public config!: TriggerSidebarConfig;
@property({ type: Boolean, attribute: "wide" }) public isWide = false;
@property({ type: Boolean }) public disabled = false;
@property({ type: Boolean, attribute: "yaml-mode" }) public yamlMode = false;
@state() private _requestShowId = false;
@state() private _warnings?: string[];
@query(".sidebar-editor")
public editor?: HaAutomationTriggerEditor;
protected willUpdate(changedProperties) {
if (changedProperties.has("config")) {
this._requestShowId = false;
this._warnings = undefined;
if (this.config) {
this.yamlMode = this.config.yamlMode;
if (this.yamlMode) {
this.editor?.yamlEditor?.setValue(this.config.config);
}
}
}
}
protected render() {
const disabled =
this.disabled ||
("enabled" in this.config.config && this.config.config.enabled === false);
const type = isTriggerList(this.config.config)
? "list"
: this.config.config.trigger;
const subtitle = this.hass.localize(
"ui.panel.config.automation.editor.triggers.trigger"
);
const title = this.hass.localize(
`ui.panel.config.automation.editor.triggers.type.${type}.label`
);
const menuEntries: SidebarOverflowMenu = [
{
clickAction: this.config.rename,
disabled: disabled || type === "list",
label: this.hass.localize(
"ui.panel.config.automation.editor.triggers.rename"
),
icon: mdiRenameBox,
},
];
if (
!this.yamlMode &&
!("id" in this.config.config) &&
!this._requestShowId
) {
menuEntries.push({
clickAction: this._showTriggerId,
disabled: disabled || type === "list",
label: this.hass.localize(
"ui.panel.config.automation.editor.triggers.edit_id"
),
icon: mdiIdentifier,
});
}
menuEntries.push({
clickAction: this._toggleYamlMode,
disabled: !this.config.uiSupported || !!this._warnings,
label: this.hass.localize(
`ui.panel.config.automation.editor.edit_${!this.yamlMode ? "yaml" : "ui"}`
),
icon: mdiPlaylistEdit,
});
menuEntries.push("separator");
menuEntries.push({
clickAction: this.config.disable,
disabled: disabled || type === "list",
label: this.hass.localize(
`ui.panel.config.automation.editor.actions.${disabled ? "enable" : "disable"}`
),
icon: this.disabled ? mdiPlayCircleOutline : mdiStopCircleOutline,
});
menuEntries.push({
clickAction: this.config.delete,
danger: true,
disabled: this.disabled,
label: this.hass.localize(
"ui.panel.config.automation.editor.actions.delete"
),
icon: mdiDelete,
});
return html`
<ha-automation-sidebar-card
.hass=${this.hass}
.isWide=${this.isWide}
.yamlMode=${this.yamlMode}
.warnings=${this._warnings}
.menuEntries=${menuEntries}
>
<span slot="title">${title}</span>
<span slot="subtitle">${subtitle}</span>
<ha-automation-trigger-editor
class="sidebar-editor"
.hass=${this.hass}
.trigger=${this.config.config}
@value-changed=${this._valueChangedSidebar}
.uiSupported=${this.config.uiSupported}
.showId=${this._requestShowId}
.yamlMode=${this.yamlMode}
.disabled=${this.disabled}
@ui-mode-not-available=${this._handleUiModeNotAvailable}
></ha-automation-trigger-editor>
</ha-automation-sidebar-card>
`;
}
private _handleUiModeNotAvailable(ev: CustomEvent) {
this._warnings = handleStructError(this.hass, ev.detail).warnings;
if (!this.yamlMode) {
this.yamlMode = true;
}
}
private _valueChangedSidebar(ev: CustomEvent) {
ev.stopPropagation();
this.config?.save?.(ev.detail.value);
if (this.config) {
fireEvent(this, "value-changed", {
value: {
...this.config,
config: ev.detail.value,
},
});
}
}
private _toggleYamlMode = () => {
fireEvent(this, "toggle-yaml-mode");
};
private _showTriggerId = () => {
this._requestShowId = true;
};
static styles = css`
.sidebar-editor {
padding-top: 64px;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-automation-sidebar-trigger": HaAutomationSidebarTrigger;
}
}

View File

@@ -43,6 +43,9 @@ export const rowStyles = css`
border-color: var(--state-inactive-color);
box-shadow: var(--shadow-default), var(--shadow-focus);
}
.hidden {
display: none;
}
`;
export const editorStyles = css`

View File

@@ -34,11 +34,7 @@ import "../../../../components/ha-icon-button";
import "../../../../components/ha-md-button-menu";
import "../../../../components/ha-md-divider";
import "../../../../components/ha-md-menu-item";
import type {
AutomationClipboard,
Trigger,
TriggerSidebarConfig,
} from "../../../../data/automation";
import type { AutomationClipboard, Trigger } from "../../../../data/automation";
import { subscribeTrigger } from "../../../../data/automation";
import { describeTrigger } from "../../../../data/automation_i18n";
import { validateConfig } from "../../../../data/config";
@@ -490,9 +486,10 @@ export default class HaAutomationTriggerRow extends LitElement {
disable: this._onDisable,
delete: this._onDelete,
config: trigger || this.trigger,
type: "trigger",
uiSupported: this._uiSupported(this._getType(trigger || this.trigger)),
yamlMode: this._yamlMode,
} satisfies TriggerSidebarConfig);
});
this._selected = true;
}

View File

@@ -180,9 +180,7 @@ export default class HaAutomationTrigger extends LitElement {
} else {
row.expand();
}
if (this.narrow) {
row.scrollIntoView();
}
row.scrollIntoView();
row.focus();
});
}

View File

@@ -1,4 +1,3 @@
import "@material/mwc-button";
import { mdiHelpCircle, mdiStarFourPoints } from "@mdi/js";
import { css, html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";

View File

@@ -46,6 +46,16 @@ const STRATEGIES = [
description:
"ui.panel.config.lovelace.dashboards.dialog_new.strategy.areas.description",
},
{
type: "overview",
images: {
light: "/static/images/dashboard-options/light/icon-dashboard-areas.svg",
dark: "/static/images/dashboard-options/dark/icon-dashboard-areas.svg",
},
name: "ui.panel.config.lovelace.dashboards.dialog_new.strategy.overview.title",
description:
"ui.panel.config.lovelace.dashboards.dialog_new.strategy.overview.description",
},
{
type: "map",
images: {

View File

@@ -331,6 +331,9 @@ export class EntityRegistrySettingsEditor extends LitElement {
}
if (changedProps.has("_entityId")) {
const domain = computeDomain(this.entry.entity_id);
if (domain === "switch") {
this.hass.loadBackendTranslation("title", SWITCH_AS_DOMAINS, false);
}
if (domain === "weather") {
const { units } = await getWeatherConvertibleUnits(this.hass);
@@ -342,6 +345,7 @@ export class EntityRegistrySettingsEditor extends LitElement {
if (changedProps.has("helperConfigEntry")) {
if (this.helperConfigEntry?.domain === "switch_as_x") {
this._switchAsDomain = computeDomain(this.entry.entity_id);
this.hass.loadBackendTranslation("title", SWITCH_AS_DOMAINS, false);
} else {
this._switchAsDomain = "switch";
this._switchAsInvert = false;

View File

@@ -1,5 +1,4 @@
import { mdiChevronLeft, mdiClose } from "@mdi/js";
import "@shoelace-style/shoelace/dist/components/animation/animation";
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import type { CSSResultGroup, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";

View File

@@ -1,6 +1,6 @@
import { mdiCheckCircleOutline } from "@mdi/js";
import { customElement, property } from "lit/decorators";
import "@shoelace-style/shoelace/dist/components/animation/animation";
import "@awesome.me/webawesome/dist/components/animation/animation";
import { css, html, LitElement } from "lit";
import type { HomeAssistant } from "../../../../../../types";
@@ -17,9 +17,9 @@ export class ZWaveJsAddNodeFinished extends LitElement {
render() {
return html`
<sl-animation name="zoomIn" .iterations=${1} play>
<wa-animation name="zoomIn" .iterations=${1} play>
<ha-svg-icon .path=${mdiCheckCircleOutline}></ha-svg-icon>
</sl-animation>
</wa-animation>
<ha-alert alert-type="warning">
${this.reason
? this.hass.localize(

View File

@@ -1,5 +1,4 @@
import { customElement, property } from "lit/decorators";
import "@shoelace-style/shoelace/dist/components/animation/animation";
import { css, html, LitElement, nothing } from "lit";
import type { HomeAssistant } from "../../../../../../types";
import { SecurityClass } from "../../../../../../data/zwave_js";

View File

@@ -1,4 +1,4 @@
import "@shoelace-style/shoelace/dist/components/animation/animation";
import "@awesome.me/webawesome/dist/components/animation/animation";
import { mdiRestart } from "@mdi/js";
import { customElement, property } from "lit/decorators";
@@ -48,9 +48,9 @@ export class ZWaveJsAddNodeSearchingDevices extends WakeLockMixin(LitElement) {
<div class="spinner">
<ha-spinner></ha-spinner>
</div>
<sl-animation name="pulse" easing="linear" .duration=${2000} play>
<wa-animation name="pulse" easing="linear" .duration=${2000} play>
<div class="circle"></div>
</sl-animation>
</wa-animation>
</div>
${this.smartStart
? html`<ha-alert

View File

@@ -1,22 +1,14 @@
import { mdiContentSave } from "@mdi/js";
import { css, html, nothing, type CSSResultGroup } from "lit";
import { html, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { fireEvent } from "../../../common/dom/fire_event";
import "../../../components/ha-fab";
import "../../../components/ha-markdown";
import { fetchBlueprints } from "../../../data/blueprint";
import type { BlueprintScriptConfig } from "../../../data/script";
import { saveFabStyles } from "../automation/styles";
import { HaBlueprintGenericEditor } from "../blueprint/blueprint-generic-editor";
@customElement("blueprint-script-editor")
export class HaBlueprintScriptEditor extends HaBlueprintGenericEditor {
@property({ attribute: false }) public config!: BlueprintScriptConfig;
@property({ type: Boolean }) public saving = false;
@property({ type: Boolean }) public dirty = false;
protected get _config(): BlueprintScriptConfig {
return this.config;
}
@@ -31,45 +23,12 @@ export class HaBlueprintScriptEditor extends HaBlueprintGenericEditor {
></ha-markdown>`
: nothing}
${this.renderCard()}
<ha-fab
slot="fab"
class=${this.dirty ? "dirty" : ""}
.label=${this.hass.localize("ui.common.save")}
.disabled=${this.saving}
extended
@click=${this._saveScript}
>
<ha-svg-icon slot="icon" .path=${mdiContentSave}></ha-svg-icon>
</ha-fab>
`;
}
private _saveScript() {
fireEvent(this, "save-script");
}
protected async _getBlueprints() {
this._blueprints = await fetchBlueprints(this.hass, "script");
}
static get styles(): CSSResultGroup {
return [
HaBlueprintGenericEditor.styles,
saveFabStyles,
css`
:host {
position: relative;
height: 100%;
min-height: calc(100vh - 85px);
min-height: calc(100dvh - 85px);
}
ha-fab {
position: fixed;
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {

View File

@@ -358,61 +358,59 @@ export class HaScriptEditor extends SubscribeMixin(
</ha-svg-icon>
</ha-list-item>
</ha-button-menu>
<div class=${this._mode === "yaml" ? "yaml-mode" : ""}>
<div class="error-wrapper">
${this._errors || stateObj?.state === UNAVAILABLE
? html`<ha-alert
alert-type="error"
.title=${stateObj?.state === UNAVAILABLE
? this.hass.localize(
"ui.panel.config.script.editor.unavailable"
)
: undefined}
>
${this._errors || this._validationErrors}
${stateObj?.state === UNAVAILABLE
? html`<ha-svg-icon
slot="icon"
.path=${mdiRobotConfused}
></ha-svg-icon>`
: nothing}
<div
class="content ${classMap({
"yaml-mode": this._mode === "yaml",
})}"
>
${this._errors || stateObj?.state === UNAVAILABLE
? html`<ha-alert
alert-type="error"
.title=${stateObj?.state === UNAVAILABLE
? this.hass.localize(
"ui.panel.config.script.editor.unavailable"
)
: undefined}
>
${this._errors || this._validationErrors}
${stateObj?.state === UNAVAILABLE
? html`<ha-svg-icon
slot="icon"
.path=${mdiRobotConfused}
></ha-svg-icon>`
: nothing}
</ha-alert>`
: ""}
${this._blueprintConfig
? html`<ha-alert alert-type="info">
${this.hass.localize(
"ui.panel.config.script.editor.confirm_take_control"
)}
<div slot="action" style="display: flex;">
<ha-button appearance="plain" @click=${this._takeControlSave}
>${this.hass.localize("ui.common.yes")}</ha-button
>
<ha-button appearance="plain" @click=${this._revertBlueprint}
>${this.hass.localize("ui.common.no")}</ha-button
>
</div>
</ha-alert>`
: this._readOnly
? html`<ha-alert alert-type="warning" dismissable
>${this.hass.localize(
"ui.panel.config.script.editor.read_only"
)}
<ha-button
appearance="plain"
slot="action"
@click=${this._duplicate}
>
${this.hass.localize(
"ui.panel.config.script.editor.migrate"
)}
</ha-button>
</ha-alert>`
: nothing}
${this._blueprintConfig
? html`<ha-alert alert-type="info">
${this.hass.localize(
"ui.panel.config.script.editor.confirm_take_control"
)}
<div slot="action" style="display: flex;">
<ha-button
appearance="plain"
@click=${this._takeControlSave}
>${this.hass.localize("ui.common.yes")}</ha-button
>
<ha-button
appearance="plain"
@click=${this._revertBlueprint}
>${this.hass.localize("ui.common.no")}</ha-button
>
</div>
</ha-alert>`
: this._readOnly
? html`<ha-alert alert-type="warning" dismissable
>${this.hass.localize(
"ui.panel.config.script.editor.read_only"
)}
<ha-button
appearance="plain"
slot="action"
@click=${this._duplicate}
>
${this.hass.localize(
"ui.panel.config.script.editor.migrate"
)}
</ha-button>
</ha-alert>`
: nothing}
</div>
${this._mode === "gui"
? html`
<div
@@ -428,10 +426,7 @@ export class HaScriptEditor extends SubscribeMixin(
.isWide=${this.isWide}
.config=${this._config}
.disabled=${this._readOnly}
.saving=${this._saving}
.dirty=${this._dirty}
@value-changed=${this._valueChanged}
@save-script=${this._handleSaveScript}
></blueprint-script-editor>
`
: html`
@@ -442,40 +437,39 @@ export class HaScriptEditor extends SubscribeMixin(
.config=${this._config}
.disabled=${this._readOnly}
.dirty=${this._dirty}
.saving=${this._saving}
@value-changed=${this._valueChanged}
@editor-save=${this._handleSaveScript}
@save-script=${this._handleSaveScript}
@editor-save=${this._handleSave}
></manual-script-editor>
`}
</div>
`
: this._mode === "yaml"
? html`<ha-yaml-editor
copy-clipboard
.hass=${this.hass}
.defaultValue=${this._preprocessYaml()}
.readOnly=${this._readOnly}
disable-fullscreen
@value-changed=${this._yamlChanged}
@editor-save=${this._handleSaveScript}
.showErrors=${false}
></ha-yaml-editor>
<ha-fab
slot="fab"
class=${!this._readOnly && this._dirty ? "dirty" : ""}
.label=${this.hass.localize("ui.common.save")}
.disabled=${this._saving}
extended
@click=${this._handleSaveScript}
>
<ha-svg-icon
slot="icon"
.path=${mdiContentSave}
></ha-svg-icon>
</ha-fab>`
copy-clipboard
.hass=${this.hass}
.defaultValue=${this._preprocessYaml()}
.readOnly=${this._readOnly}
disable-fullscreen
@value-changed=${this._yamlChanged}
@editor-save=${this._handleSave}
.showErrors=${false}
></ha-yaml-editor>`
: nothing}
</div>
<ha-fab
slot="fab"
class=${classMap({
dirty: !this._readOnly && this._dirty,
})}
.label=${this.hass.localize(
"ui.panel.config.script.editor.save_script"
)}
.disabled=${this._saving}
extended
@click=${this._handleSave}
>
<ha-svg-icon slot="icon" .path=${mdiContentSave}></ha-svg-icon>
</ha-fab>
</hass-subpage>
`;
}
@@ -911,7 +905,7 @@ export class HaScriptEditor extends SubscribeMixin(
});
}
private async _handleSaveScript() {
private async _handleSave() {
if (this._yamlErrors) {
showToast(this, {
message: this._yamlErrors,
@@ -1018,7 +1012,7 @@ export class HaScriptEditor extends SubscribeMixin(
protected supportedShortcuts(): SupportedShortcuts {
return {
s: () => this._handleSaveScript(),
s: () => this._handleSave(),
};
}
@@ -1034,40 +1028,33 @@ export class HaScriptEditor extends SubscribeMixin(
return [
haStyle,
css`
p {
margin-bottom: 0;
}
.errors {
padding: 20px;
font-weight: var(--ha-font-weight-bold);
color: var(--error-color);
}
.yaml-mode {
height: 100%;
display: flex;
flex-direction: column;
padding-bottom: 0;
}
.config-container,
manual-script-editor,
blueprint-script-editor {
blueprint-script-editor,
:not(.yaml-mode) > ha-alert {
margin: 0 auto;
max-width: 1040px;
padding: 28px 20px 0;
display: block;
}
:not(.yaml-mode) > .error-wrapper {
position: absolute;
top: 4px;
z-index: 3;
width: 100%;
display: flex;
flex-direction: column;
align-items: center;
.config-container ha-alert {
margin-bottom: 16px;
display: block;
}
:not(.yaml-mode) > .error-wrapper ha-alert {
background-color: var(--card-background-color);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
border-radius: var(--ha-border-radius-sm);
}
manual-script-editor {
max-width: 1540px;
padding: 0 12px;
}
ha-yaml-editor {
flex-grow: 1;
--actions-border-radius: 0;
@@ -1076,20 +1063,16 @@ export class HaScriptEditor extends SubscribeMixin(
display: flex;
flex-direction: column;
}
p {
margin-bottom: 0;
}
span[slot="introduction"] a {
color: var(--primary-color);
}
ha-fab {
position: fixed;
right: 16px;
position: relative;
bottom: calc(-80px - var(--safe-area-inset-bottom));
transition: bottom 0.3s;
}
ha-fab.dirty {
bottom: 16px;
bottom: 0;
}
li[role="separator"] {
border-bottom-color: var(--divider-color);
@@ -1122,8 +1105,4 @@ declare global {
interface HTMLElementTagNameMap {
"ha-script-editor": HaScriptEditor;
}
interface HASSDomEvents {
"save-script": undefined;
}
}

View File

@@ -1,187 +0,0 @@
import { LitElement, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../../common/dom/fire_event";
import { slugify } from "../../../common/string/slugify";
import "../../../components/ha-alert";
import "../../../components/ha-form/ha-form";
import type { SchemaUnion } from "../../../components/ha-form/types";
import "../../../components/ha-yaml-editor";
import type { Field } from "../../../data/script";
import { haStyle } from "../../../resources/styles";
import type { HomeAssistant } from "../../../types";
@customElement("ha-script-field-editor")
export default class HaScriptFieldEditor extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property() public key!: string;
@property({ attribute: false, type: Array }) public excludeKeys: string[] =
[];
@property({ attribute: false }) public field!: Field;
@property({ type: Boolean }) public disabled = false;
@property({ type: Boolean, attribute: "yaml-mode" }) public yamlMode = false;
@state() private _uiError?: Record<string, string>;
@state() private _yamlError?: undefined | "yaml_error" | "key_not_unique";
private _errorKey?: string;
private _schema = memoizeOne(
() =>
[
{
name: "name",
selector: { text: {} },
},
{
name: "key",
selector: { text: {} },
},
{
name: "description",
selector: { text: {} },
},
{
name: "required",
selector: { boolean: {} },
},
] as const
);
protected render() {
const schema = this._schema();
const data = { ...this.field, key: this._errorKey ?? this.key };
const yamlValue = { [this.key]: this.field };
return html`
${this.yamlMode
? html`${this._yamlError
? html`<ha-alert alert-type="error">
${this.hass.localize(
`ui.panel.config.script.editor.field.${this._yamlError}`
)}
</ha-alert>`
: nothing}
<ha-yaml-editor
.hass=${this.hass}
.defaultValue=${yamlValue}
@value-changed=${this._onYamlChange}
></ha-yaml-editor>`
: html`<ha-form
.schema=${schema}
.data=${data}
.error=${this._uiError}
.hass=${this.hass}
.disabled=${this.disabled}
.computeLabel=${this._computeLabelCallback}
.computeError=${this._computeError}
@value-changed=${this._valueChanged}
></ha-form>`}
`;
}
private _maybeSetKey(value): void {
const nameChanged = value.name !== this.field.name;
const keyChanged = value.key !== this.key;
if (!nameChanged || keyChanged) {
return;
}
const slugifyName = this.field.name
? slugify(this.field.name)
: this.hass.localize("ui.panel.config.script.editor.field.field") ||
"field";
const regex = new RegExp(`^${slugifyName}(_\\d)?$`);
if (regex.test(this.key)) {
let key = !value.name
? this.hass.localize("ui.panel.config.script.editor.field.field") ||
"field"
: slugify(value.name);
if (this.excludeKeys.includes(key)) {
let uniqueKey = key;
let i = 2;
do {
uniqueKey = `${key}_${i}`;
i++;
} while (this.excludeKeys.includes(uniqueKey));
key = uniqueKey;
}
value.key = key;
}
}
private _valueChanged(ev: CustomEvent) {
ev.stopPropagation();
const value = { ...ev.detail.value };
this._maybeSetKey(value);
// Don't allow to set an empty key, or duplicate an existing key.
if (!value.key || this.excludeKeys.includes(value.key)) {
this._uiError = value.key
? {
key: "key_not_unique",
}
: {
key: "key_not_null",
};
this._errorKey = value.key ?? "";
return;
}
this._errorKey = undefined;
this._uiError = undefined;
// If we render the default with an incompatible selector, it risks throwing an exception and not rendering.
// Clear the default when changing the selector type.
if (
Object.keys(this.field.selector)[0] !== Object.keys(value.selector)[0]
) {
delete value.default;
}
fireEvent(this, "value-changed", { value });
}
private _onYamlChange(ev: CustomEvent) {
ev.stopPropagation();
const value = { ...ev.detail.value };
if (typeof value !== "object" || Object.keys(value).length !== 1) {
this._yamlError = "yaml_error";
return;
}
const key = Object.keys(value)[0];
if (this.excludeKeys.includes(key)) {
this._yamlError = "key_not_unique";
return;
}
this._yamlError = undefined;
const newValue = { ...value[key], key };
fireEvent(this, "value-changed", { value: newValue });
}
private _computeLabelCallback = (
schema: SchemaUnion<ReturnType<typeof this._schema>>
): string =>
this.hass.localize(`ui.panel.config.script.editor.field.${schema.name}`);
private _computeError = (error: string) =>
this.hass.localize(`ui.panel.config.script.editor.field.${error}` as any) ||
error;
static styles = haStyle;
}
declare global {
interface HTMLElementTagNameMap {
"ha-script-field-editor": HaScriptFieldEditor;
}
}

View File

@@ -1,24 +1,27 @@
import { mdiDelete, mdiDotsVertical } from "@mdi/js";
import type { ActionDetail } from "@material/mwc-list/mwc-list-foundation";
import { mdiDelete, mdiDotsVertical, mdiPlaylistEdit } from "@mdi/js";
import type { CSSResultGroup } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../../common/dom/fire_event";
import { preventDefaultStopPropagation } from "../../../common/dom/prevent_default_stop_propagation";
import { stopPropagation } from "../../../common/dom/stop_propagation";
import type { LocalizeKeys } from "../../../common/translations/localize";
import "../../../components/ha-automation-row";
import { slugify } from "../../../common/string/slugify";
import "../../../components/ha-alert";
import "../../../components/ha-button-menu";
import "../../../components/ha-card";
import "../../../components/ha-form/ha-form";
import "../../../components/ha-expansion-panel";
import "../../../components/ha-list-item";
import type { SchemaUnion } from "../../../components/ha-form/types";
import "../../../components/ha-icon-button";
import "../../../components/ha-md-button-menu";
import "../../../components/ha-md-menu-item";
import type { ScriptFieldSidebarConfig } from "../../../data/automation";
import "../../../components/ha-yaml-editor";
import type { Field } from "../../../data/script";
import { SELECTOR_SELECTOR_BUILDING_BLOCKS } from "../../../data/selector/selector_selector";
import { showConfirmationDialog } from "../../../dialogs/generic/show-dialog-box";
import { haStyle } from "../../../resources/styles";
import type { HomeAssistant } from "../../../types";
import "./ha-script-field-selector-editor";
const preventDefault = (ev) => ev.preventDefault();
@customElement("ha-script-field-row")
export default class HaScriptFieldRow extends LitElement {
@@ -33,38 +36,61 @@ export default class HaScriptFieldRow extends LitElement {
@property({ type: Boolean }) public disabled = false;
@property({ type: Boolean }) public narrow = false;
@state() private _uiError?: Record<string, string>;
@state() private _yamlError?: undefined | "yaml_error" | "key_not_unique";
@state() private _yamlMode = false;
@state() private _selected = false;
private _errorKey?: string;
@state() private _collapsed = false;
@state() private _selectorRowSelected = false;
@state() private _selectorRowCollapsed = false;
private _schema = memoizeOne(
(selector: any) =>
[
{
name: "name",
selector: { text: {} },
},
{
name: "key",
selector: { text: {} },
},
{
name: "description",
selector: { text: {} },
},
{
name: "selector",
selector: { selector: {} },
},
{
name: "default",
selector: selector && typeof selector === "object" ? selector : {},
},
{
name: "required",
selector: { boolean: {} },
},
] as const
);
protected render() {
const schema = this._schema(this.field.selector);
const data = { ...this.field, key: this._errorKey ?? this.key };
const yamlValue = { [this.key]: this.field };
return html`
<ha-card outlined>
<ha-automation-row
.disabled=${this.disabled}
@click=${this._toggleSidebar}
.selected=${this._selected}
left-chevron
@toggle-collapsed=${this._toggleCollapse}
.collapsed=${this._collapsed}
>
<ha-expansion-panel left-chevron>
<h3 slot="header">${this.key}</h3>
<slot name="icons" slot="icons"></slot>
<ha-md-button-menu
<ha-button-menu
slot="icons"
@click=${preventDefaultStopPropagation}
@keydown=${stopPropagation}
@closed=${stopPropagation}
positioning="fixed"
@action=${this._handleAction}
@click=${preventDefault}
fixed
>
<ha-icon-button
slot="trigger"
@@ -72,9 +98,19 @@ export default class HaScriptFieldRow extends LitElement {
.path=${mdiDotsVertical}
></ha-icon-button>
<ha-md-menu-item
<ha-list-item graphic="icon">
${this.hass.localize(
`ui.panel.config.automation.editor.edit_${!this._yamlMode ? "yaml" : "ui"}`
)}
<ha-svg-icon
slot="graphic"
.path=${mdiPlaylistEdit}
></ha-svg-icon>
</ha-list-item>
<ha-list-item
class="warning"
.clickAction=${this._onDelete}
graphic="icon"
.disabled=${this.disabled}
>
${this.hass.localize(
@@ -85,144 +121,54 @@ export default class HaScriptFieldRow extends LitElement {
slot="graphic"
.path=${mdiDelete}
></ha-svg-icon>
</ha-md-menu-item>
</ha-md-button-menu>
</ha-automation-row>
</ha-card>
<div
class=${classMap({
"selector-row": true,
"parent-selected": this._selected,
hidden: this._collapsed,
})}
>
<ha-card>
<ha-automation-row
.selected=${this._selectorRowSelected}
@click=${this._toggleSelectorSidebar}
.collapsed=${this._selectorRowCollapsed}
@toggle-collapsed=${this._toggleSelectorRowCollapse}
.leftChevron=${SELECTOR_SELECTOR_BUILDING_BLOCKS.includes(
Object.keys(this.field.selector)[0]
)}
</ha-list-item>
</ha-button-menu>
<div
class=${classMap({
"card-content": true,
})}
>
<h3 slot="header">
${this.hass.localize(
`ui.components.selectors.selector.types.${Object.keys(this.field.selector)[0]}` as LocalizeKeys
)}
${this.hass.localize(
"ui.panel.config.script.editor.field.selector"
)}
</h3>
</ha-automation-row>
</ha-card>
${typeof this.field.selector === "object" &&
SELECTOR_SELECTOR_BUILDING_BLOCKS.includes(
Object.keys(this.field.selector)[0]
)
? html`
<ha-script-field-selector-editor
class=${this._selectorRowCollapsed ? "hidden" : ""}
.selected=${this._selectorRowSelected}
.hass=${this.hass}
.field=${this.field}
.disabled=${this.disabled}
indent
@value-changed=${this._selectorValueChanged}
.narrow=${this.narrow}
></ha-script-field-selector-editor>
`
: nothing}
</div>
${this._yamlMode
? html` ${this._yamlError
? html`<ha-alert alert-type="error">
${this.hass.localize(
`ui.panel.config.script.editor.field.${this._yamlError}`
)}
</ha-alert>`
: nothing}
<ha-yaml-editor
.hass=${this.hass}
.defaultValue=${yamlValue}
@value-changed=${this._onYamlChange}
></ha-yaml-editor>`
: html`<ha-form
.schema=${schema}
.data=${data}
.error=${this._uiError}
.hass=${this.hass}
.disabled=${this.disabled}
.computeLabel=${this._computeLabelCallback}
.computeError=${this._computeError}
@value-changed=${this._valueChanged}
></ha-form>`}
</div>
</ha-expansion-panel>
</ha-card>
`;
}
private _toggleCollapse() {
this._collapsed = !this._collapsed;
}
private _toggleSelectorRowCollapse() {
this._selectorRowCollapsed = !this._selectorRowCollapsed;
}
private _toggleSidebar(ev: Event) {
ev?.stopPropagation();
if (this._selected) {
this._selected = false;
fireEvent(this, "close-sidebar");
return;
private async _handleAction(ev: CustomEvent<ActionDetail>) {
switch (ev.detail.index) {
case 0:
this._yamlMode = !this._yamlMode;
break;
case 1:
this._onDelete();
break;
}
this._selected = true;
this.openSidebar();
}
private _toggleSelectorSidebar(ev: Event) {
ev?.stopPropagation();
if (this._selectorRowSelected) {
this._selectorRowSelected = false;
fireEvent(this, "close-sidebar");
return;
}
this._selectorRowSelected = true;
this.openSidebar(true);
}
private _selectorValueChanged(ev: CustomEvent) {
ev.stopPropagation();
fireEvent(this, "value-changed", {
value: {
...this.field,
key: this.key,
...ev.detail.value,
},
});
}
public openSidebar(selectorEditor = false): void {
if (!selectorEditor) {
this._selected = true;
}
if (this.narrow) {
this.scrollIntoView();
}
fireEvent(this, "open-sidebar", {
save: (value) => {
fireEvent(this, "value-changed", { value });
},
close: () => {
if (selectorEditor) {
this._selectorRowSelected = false;
} else {
this._selected = false;
}
fireEvent(this, "close-sidebar");
},
toggleYamlMode: () => {
this._toggleYamlMode();
return this._yamlMode;
},
delete: this._onDelete,
config: {
field: this.field,
selector: selectorEditor,
key: this.key,
excludeKeys: this.excludeKeys,
},
yamlMode: this._yamlMode,
} satisfies ScriptFieldSidebarConfig);
}
private _toggleYamlMode = () => {
this._yamlMode = !this._yamlMode;
};
private _onDelete = () => {
private _onDelete() {
showConfirmationDialog(this, {
title: this.hass.localize(
"ui.panel.config.script.editor.field_delete_confirm_title"
@@ -235,13 +181,112 @@ export default class HaScriptFieldRow extends LitElement {
destructive: true,
confirm: () => {
fireEvent(this, "value-changed", { value: null });
if (this._selected) {
fireEvent(this, "close-sidebar");
}
},
});
}
private _onYamlChange(ev: CustomEvent) {
ev.stopPropagation();
const value = { ...ev.detail.value };
if (typeof value !== "object" || Object.keys(value).length !== 1) {
this._yamlError = "yaml_error";
return;
}
const key = Object.keys(value)[0];
if (this.excludeKeys.includes(key)) {
this._yamlError = "key_not_unique";
return;
}
this._yamlError = undefined;
const newValue = { ...value[key], key };
fireEvent(this, "value-changed", { value: newValue });
}
private _maybeSetKey(value): void {
const nameChanged = value.name !== this.field.name;
const keyChanged = value.key !== this.key;
if (!nameChanged || keyChanged) {
return;
}
const slugifyName = this.field.name
? slugify(this.field.name)
: this.hass.localize("ui.panel.config.script.editor.field.field") ||
"field";
const regex = new RegExp(`^${slugifyName}(_\\d)?$`);
if (regex.test(this.key)) {
let key = !value.name
? this.hass.localize("ui.panel.config.script.editor.field.field") ||
"field"
: slugify(value.name);
if (this.excludeKeys.includes(key)) {
let uniqueKey = key;
let i = 2;
do {
uniqueKey = `${key}_${i}`;
i++;
} while (this.excludeKeys.includes(uniqueKey));
key = uniqueKey;
}
value.key = key;
}
}
private _valueChanged(ev: CustomEvent) {
ev.stopPropagation();
const value = { ...ev.detail.value };
this._maybeSetKey(value);
// Don't allow to set an empty key, or duplicate an existing key.
if (!value.key || this.excludeKeys.includes(value.key)) {
this._uiError = value.key
? {
key: "key_not_unique",
}
: {
key: "key_not_null",
};
this._errorKey = value.key ?? "";
return;
}
this._errorKey = undefined;
this._uiError = undefined;
// If we render the default with an incompatible selector, it risks throwing an exception and not rendering.
// Clear the default when changing the selector type.
if (
Object.keys(this.field.selector)[0] !== Object.keys(value.selector)[0]
) {
delete value.default;
}
fireEvent(this, "value-changed", { value });
}
public expand() {
this.updateComplete.then(() => {
this.shadowRoot!.querySelector("ha-expansion-panel")!.expanded = true;
});
}
private _computeLabelCallback = (
schema: SchemaUnion<ReturnType<typeof this._schema>>
): string => {
switch (schema.name) {
default:
return this.hass.localize(
`ui.panel.config.script.editor.field.${schema.name}`
);
}
};
private _computeError = (error: string) =>
this.hass.localize(`ui.panel.config.script.editor.field.${error}` as any) ||
error;
static get styles(): CSSResultGroup {
return [
haStyle,
@@ -254,9 +299,6 @@ export default class HaScriptFieldRow extends LitElement {
opacity: 0.5;
pointer-events: none;
}
.hidden {
display: none;
}
ha-expansion-panel {
--expansion-panel-summary-padding: 0 0 0 8px;
--expansion-panel-content-padding: 0;
@@ -299,7 +341,7 @@ export default class HaScriptFieldRow extends LitElement {
);
}
ha-md-menu-item[disabled] {
ha-list-item[disabled] {
--mdc-theme-text-primary-on-background: var(--disabled-text-color);
}
.warning ul {
@@ -317,18 +359,6 @@ export default class HaScriptFieldRow extends LitElement {
border-color: var(--state-inactive-color);
box-shadow: var(--shadow-default), var(--shadow-focus);
}
.selector-row {
margin-left: 12px;
padding: 12px 4px 16px 16px;
margin-right: -4px;
border-left: 2px solid var(--ha-color-border-neutral-quiet);
}
.selector-row.parent-selected {
border-color: var(--primary-color);
background-color: var(--ha-color-fill-primary-quiet-resting);
border-top-right-radius: var(--ha-border-radius-xl);
border-bottom-right-radius: var(--ha-border-radius-xl);
}
`,
];
}

View File

@@ -1,167 +0,0 @@
import type { CSSResultGroup } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../../common/dom/fire_event";
import type { LocalizeKeys } from "../../../common/translations/localize";
import "../../../components/ha-alert";
import "../../../components/ha-form/ha-form";
import type { SchemaUnion } from "../../../components/ha-form/types";
import "../../../components/ha-yaml-editor";
import type { Field } from "../../../data/script";
import { SELECTOR_SELECTOR_BUILDING_BLOCKS } from "../../../data/selector/selector_selector";
import { haStyle } from "../../../resources/styles";
import type { HomeAssistant } from "../../../types";
@customElement("ha-script-field-selector-editor")
export default class HaScriptFieldSelectorEditor extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public field!: Field;
@property({ type: Boolean }) public narrow = false;
@property({ type: Boolean, reflect: true }) public selected = false;
@property({ type: Boolean }) public disabled = false;
@property({ type: Boolean }) public indent = false;
@property({ type: Boolean, attribute: "yaml-mode" }) public yamlMode = false;
@state() private _uiError?: Record<string, string>;
@state() private _yamlError?: undefined | "yaml_error" | "key_not_unique";
private _schema = memoizeOne(
(selector: any) =>
[
...(!this.indent
? [
{
name: "selector",
selector: { selector: {} },
},
]
: []),
...(selector &&
typeof selector === "object" &&
(this.indent ||
!SELECTOR_SELECTOR_BUILDING_BLOCKS.includes(Object.keys(selector)[0]))
? [
{
name: "default",
selector: !this.indent
? selector
: {
[Object.keys(selector)[0]]: {
...selector[Object.keys(selector)[0]],
optionsInSidebar: true,
},
},
},
]
: []),
] as const
);
protected render() {
const schema = this._schema(this.field.selector);
const data = { selector: this.field.selector, default: this.field.default };
return html`
${this.yamlMode
? html`${this._yamlError
? html`<ha-alert alert-type="error">
${this.hass.localize(
`ui.panel.config.script.editor.field.${this._yamlError}`
)}
</ha-alert>`
: nothing}
<ha-yaml-editor
.hass=${this.hass}
.defaultValue=${data}
@value-changed=${this._onYamlChange}
></ha-yaml-editor>`
: html`<ha-form
.schema=${schema}
.data=${data}
.error=${this._uiError}
.hass=${this.hass}
.disabled=${this.disabled}
.computeLabel=${this._computeLabelCallback}
.computeError=${this._computeError}
@value-changed=${this._valueChanged}
.narrow=${this.narrow}
></ha-form>`}
`;
}
private _valueChanged(ev: CustomEvent) {
ev.stopPropagation();
const value = { ...ev.detail.value };
this._uiError = undefined;
// If we render the default with an incompatible selector, it risks throwing an exception and not rendering.
// Clear the default when changing the selector type.
if (
!this.indent &&
Object.keys(this.field.selector)[0] !== Object.keys(value.selector)[0]
) {
value.default = undefined;
}
fireEvent(this, "value-changed", { value });
}
private _onYamlChange(ev: CustomEvent) {
ev.stopPropagation();
const value = { ...ev.detail.value };
if (typeof value !== "object" || Object.keys(value).length !== 2) {
this._yamlError = "yaml_error";
return;
}
fireEvent(this, "value-changed", { value });
}
private _computeLabelCallback = (
schema: SchemaUnion<ReturnType<typeof this._schema>>
): string =>
this.hass.localize(
`ui.panel.config.script.editor.field.${schema.name}` as LocalizeKeys
) ?? schema.name;
private _computeError = (error: string) =>
this.hass.localize(`ui.panel.config.script.editor.field.${error}` as any) ||
error;
static get styles(): CSSResultGroup {
return [
haStyle,
css`
:host([indent]) ha-form {
display: block;
margin-left: 12px;
padding: 12px 20px 16px 16px;
margin-right: -4px;
border-left: 2px solid var(--ha-color-border-neutral-quiet);
}
:host([selected]) ha-form {
border-color: var(--primary-color);
background-color: var(--ha-color-fill-primary-quiet-resting);
border-top-right-radius: var(--ha-border-radius-xl);
border-bottom-right-radius: var(--ha-border-radius-xl);
}
`,
];
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-script-field-selector-editor": HaScriptFieldSelectorEditor;
}
}

View File

@@ -21,8 +21,6 @@ export default class HaScriptFields extends LitElement {
@property({ attribute: false }) public highlightedFields?: Fields;
@property({ type: Boolean }) public narrow = false;
private _focusLastActionOnChange = false;
protected render() {
@@ -41,7 +39,6 @@ export default class HaScriptFields extends LitElement {
@value-changed=${this._fieldChanged}
.hass=${this.hass}
?highlight=${this.highlightedFields?.[key] !== undefined}
.narrow=${this.narrow}
>
</ha-script-field-row>
`
@@ -74,11 +71,8 @@ export default class HaScriptFields extends LitElement {
"ha-script-field-row:last-of-type"
)!;
row.updateComplete.then(() => {
row.openSidebar();
if (this.narrow) {
row.scrollIntoView();
}
row.expand();
row.scrollIntoView();
row.focus();
});
}

View File

@@ -1,9 +1,8 @@
import { mdiContentSave, mdiHelpCircle } from "@mdi/js";
import { load } from "js-yaml";
import type { CSSResultGroup } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { mdiHelpCircle } 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 { classMap } from "lit/directives/class-map";
import { load } from "js-yaml";
import {
any,
array,
@@ -14,27 +13,32 @@ import {
optional,
string,
} from "superstruct";
import { ensureArray } from "../../../common/array/ensure-array";
import { canOverrideAlphanumericInput } from "../../../common/dom/can-override-input";
import { fireEvent } from "../../../common/dom/fire_event";
import { constructUrlCurrentPath } from "../../../common/url/construct-url";
import {
extractSearchParam,
removeSearchParam,
} from "../../../common/url/search-params";
import "../../../components/ha-card";
import "../../../components/ha-icon-button";
import "../../../components/ha-markdown";
import type { SidebarConfig } from "../../../data/automation";
import type { Action, Fields, ScriptConfig } from "../../../data/script";
import {
getActionType,
MODES,
normalizeScriptConfig,
} from "../../../data/script";
import { haStyle } from "../../../resources/styles";
import type { HomeAssistant } from "../../../types";
import { documentationUrl } from "../../../util/documentation-url";
import { showToast } from "../../../util/toast";
import "../automation/action/ha-automation-action";
import "../automation/ha-automation-sidebar";
import { showPasteReplaceDialog } from "../automation/paste-replace-dialog/show-dialog-paste-replace";
import { saveFabStyles } from "../automation/styles";
import type HaAutomationAction from "../automation/action/ha-automation-action";
import "./ha-script-fields";
import type HaScriptFields from "./ha-script-fields";
import { canOverrideAlphanumericInput } from "../../../common/dom/can-override-input";
import { showToast } from "../../../util/toast";
import { showPasteReplaceDialog } from "../automation/paste-replace-dialog/show-dialog-paste-replace";
import { ensureArray } from "../../../common/array/ensure-array";
const scriptConfigStruct = object({
alias: optional(string()),
@@ -56,8 +60,6 @@ export class HaManualScriptEditor extends LitElement {
@property({ type: Boolean }) public disabled = false;
@property({ type: Boolean }) public saving = false;
@property({ attribute: false }) public config!: ScriptConfig;
@property({ attribute: false }) public dirty = false;
@@ -69,8 +71,6 @@ export class HaManualScriptEditor extends LitElement {
@state() private _pastedConfig?: ScriptConfig;
@state() private _sidebarConfig?: SidebarConfig;
private _previousConfig?: ScriptConfig;
public addFields() {
@@ -99,19 +99,41 @@ export class HaManualScriptEditor extends LitElement {
}
}
private _renderContent() {
protected firstUpdated(changedProps: PropertyValues): void {
super.firstUpdated(changedProps);
const expanded = extractSearchParam("expanded");
if (expanded === "1") {
this._clearParam("expanded");
const items = this.shadowRoot!.querySelectorAll<HaAutomationAction>(
"ha-automation-action"
);
items.forEach((el) => {
el.updateComplete.then(() => {
el.expandAll();
});
});
}
}
private _clearParam(param: string) {
window.history.replaceState(
null,
"",
constructUrlCurrentPath(removeSearchParam(param))
);
}
protected render() {
return html`
${
this.config.description
? html`<ha-markdown
class="description"
breaks
.content=${this.config.description}
></ha-markdown>`
: nothing
}
${
this.config.fields
${this.config.description
? html`<ha-markdown
class="description"
breaks
.content=${this.config.description}
></ha-markdown>`
: nothing}
${this.config.fields
? html`<div class="header">
<h2 id="fields-heading" class="name">
${this.hass.localize(
@@ -143,77 +165,39 @@ export class HaManualScriptEditor extends LitElement {
@value-changed=${this._fieldsChanged}
.hass=${this.hass}
.disabled=${this.disabled}
.narrow=${this.narrow}
@open-sidebar=${this._openSidebar}
@close-sidebar=${this._handleCloseSidebar}
></ha-script-fields>`
: nothing
}
: nothing}
<div class="header">
<h2 id="sequence-heading" class="name">
${this.hass.localize("ui.panel.config.script.editor.sequence")}
</h2>
<a
href=${documentationUrl(this.hass, "/docs/scripts/")}
target="_blank"
rel="noreferrer"
>
<ha-icon-button
.path=${mdiHelpCircle}
.label=${this.hass.localize(
"ui.panel.config.script.editor.link_available_actions"
)}
></ha-icon-button>
</a>
</div>
<ha-automation-action
role="region"
aria-labelledby="sequence-heading"
.actions=${this.config.sequence || []}
.highlightedActions=${this._pastedConfig?.sequence || []}
@value-changed=${this._sequenceChanged}
@open-sidebar=${this._openSidebar}
@close-sidebar=${this._handleCloseSidebar}
.hass=${this.hass}
.narrow=${this.narrow}
.disabled=${this.disabled || this.saving}
root
sidebar
></ha-automation-action>
</div>`;
}
protected render() {
return html`
<div class="split-view">
<div class="content-wrapper">
<div class="content">${this._renderContent()}</div>
<ha-fab
slot="fab"
class=${this.dirty ? "dirty" : ""}
.label=${this.hass.localize("ui.common.save")}
.disabled=${this.saving}
extended
@click=${this._saveScript}
>
<ha-svg-icon slot="icon" .path=${mdiContentSave}></ha-svg-icon>
</ha-fab>
</div>
<ha-automation-sidebar
class=${classMap({
sidebar: true,
hidden: !this._sidebarConfig,
overlay: !this.isWide,
})}
.isWide=${this.isWide}
.hass=${this.hass}
.config=${this._sidebarConfig}
@value-changed=${this._sidebarConfigChanged}
.disabled=${this.disabled}
></ha-automation-sidebar>
<div class="header">
<h2 id="sequence-heading" class="name">
${this.hass.localize("ui.panel.config.script.editor.sequence")}
</h2>
<a
href=${documentationUrl(this.hass, "/docs/scripts/")}
target="_blank"
rel="noreferrer"
>
<ha-icon-button
.path=${mdiHelpCircle}
.label=${this.hass.localize(
"ui.panel.config.script.editor.link_available_actions"
)}
></ha-icon-button>
</a>
</div>
<ha-automation-action
role="region"
aria-labelledby="sequence-heading"
.actions=${this.config.sequence || []}
.highlightedActions=${this._pastedConfig?.sequence || []}
.path=${["sequence"]}
@value-changed=${this._sequenceChanged}
.hass=${this.hass}
.narrow=${this.narrow}
.disabled=${this.disabled}
root
></ha-automation-action>
`;
}
@@ -422,116 +406,22 @@ export class HaManualScriptEditor extends LitElement {
});
}
private _openSidebar(ev: CustomEvent<SidebarConfig>) {
// deselect previous selected row
this._sidebarConfig?.close?.();
this._sidebarConfig = ev.detail;
}
private _sidebarConfigChanged(ev: CustomEvent<{ value: SidebarConfig }>) {
ev.stopPropagation();
if (!this._sidebarConfig) {
return;
}
this._sidebarConfig = {
...this._sidebarConfig,
...ev.detail.value,
};
}
private _closeSidebar() {
if (this._sidebarConfig) {
const closeRow = this._sidebarConfig?.close;
this._sidebarConfig = undefined;
closeRow?.();
}
}
private _handleCloseSidebar() {
this._sidebarConfig = undefined;
}
private _saveScript() {
this._closeSidebar();
fireEvent(this, "save-script");
}
static get styles(): CSSResultGroup {
return [
saveFabStyles,
haStyle,
css`
:host {
display: block;
}
.split-view {
display: flex;
flex-direction: row;
height: 100%;
position: relative;
gap: 16px;
}
.content-wrapper {
position: relative;
flex: 6;
}
.content {
padding: 32px 16px 64px 0;
height: calc(100vh - 153px);
height: calc(100dvh - 153px);
overflow-y: auto;
overflow-x: hidden;
}
.sidebar {
padding: 12px 0;
flex: 4;
height: calc(100vh - 81px);
height: calc(100dvh - 81px);
width: 40%;
}
.sidebar.hidden {
border-color: transparent;
border-width: 0;
ha-card {
overflow: hidden;
flex: 0;
visibility: hidden;
}
.sidebar.overlay {
position: fixed;
bottom: 0;
right: 0;
height: calc(100% - 64px);
padding: 0;
z-index: 5;
}
@media all and (max-width: 870px) {
.sidebar.overlay {
max-height: 70vh;
max-height: 70dvh;
height: auto;
width: 100%;
box-shadow: 0px -8px 16px rgba(0, 0, 0, 0.2);
}
}
@media all and (max-width: 870px) {
.sidebar.overlay.hidden {
height: 0;
}
}
.sidebar.overlay.hidden {
width: 0;
}
.description {
margin: 0;
}
p {
margin-bottom: 0;
}
.header {
display: flex;
align-items: center;

View File

@@ -3,6 +3,7 @@ import { LitElement, css, html, nothing } from "lit";
import { mdiPencil, mdiDownload } from "@mdi/js";
import { customElement, property, state } from "lit/decorators";
import "../../components/ha-menu-button";
import "../../components/ha-icon-button-arrow-prev";
import "../../components/ha-list-item";
import "../../components/ha-top-app-bar-fixed";
import type { LovelaceConfig } from "../../data/lovelace/config/types";
@@ -49,6 +50,8 @@ class PanelEnergy extends LitElement {
@state() private _lovelace?: Lovelace;
@state() private _searchParms = new URLSearchParams(window.location.search);
public willUpdate(changedProps: PropertyValues) {
if (!this.hasUpdated) {
this.hass.loadFragmentTranslation("lovelace");
@@ -65,15 +68,29 @@ class PanelEnergy extends LitElement {
}
}
private _back(ev) {
ev.stopPropagation();
history.back();
}
protected render(): TemplateResult {
return html`
<div class="header">
<div class="toolbar">
<ha-menu-button
slot="navigationIcon"
.hass=${this.hass}
.narrow=${this.narrow}
></ha-menu-button>
${this._searchParms.has("historyBack")
? html`
<ha-icon-button-arrow-prev
@click=${this._back}
slot="navigationIcon"
></ha-icon-button-arrow-prev>
`
: html`
<ha-menu-button
slot="navigationIcon"
.hass=${this.hass}
.narrow=${this.narrow}
></ha-menu-button>
`}
${!this.narrow
? html`<div class="main-title">
${this.hass.localize("panel.energy")}

View File

@@ -0,0 +1,85 @@
import { css, LitElement, nothing, html } from "lit";
import { customElement, property, state } from "lit/decorators";
import { computeDomain } from "../../../common/entity/compute_domain";
import type { HomeAssistant } from "../../../types";
import type { LovelaceCardFeature } from "../types";
import type {
LovelaceCardFeatureContext,
BarGaugeCardFeatureConfig,
} from "./types";
export const supportsBarGaugeCardFeature = (
hass: HomeAssistant,
context: LovelaceCardFeatureContext
) => {
const stateObj = context.entity_id
? hass.states[context.entity_id]
: undefined;
if (!stateObj) return false;
const domain = computeDomain(stateObj.entity_id);
return domain === "sensor" && stateObj.attributes.unit_of_measurement === "%";
};
@customElement("hui-bar-gauge-card-feature")
class HuiBarGaugeCardFeature extends LitElement implements LovelaceCardFeature {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public context!: LovelaceCardFeatureContext;
@state() private _config?: BarGaugeCardFeatureConfig;
static getStubConfig(): BarGaugeCardFeatureConfig {
return {
type: "bar-gauge",
};
}
public setConfig(config: BarGaugeCardFeatureConfig): void {
if (!config) {
throw new Error("Invalid configuration");
}
this._config = config;
}
render() {
if (
!this._config ||
!this.hass ||
!this.context ||
!this.context.entity_id ||
!this.hass.states[this.context.entity_id] ||
!supportsBarGaugeCardFeature(this.hass, this.context)
) {
return nothing;
}
const stateObj = this.hass.states[this.context.entity_id];
const value = stateObj.state;
return html`<div style="width: ${value}%"></div>
<div class="bar-gauge-background"></div>`;
}
static styles = css`
:host {
display: flex;
width: 100%;
height: var(--feature-height);
border-radius: var(--feature-border-radius);
overflow: hidden;
}
:host > div {
height: 100%;
background-color: var(--feature-color);
transition: width 180ms ease-in-out;
}
.bar-gauge-background {
flex: 1;
opacity: 0.2;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"hui-bar-gauge-card-feature": HuiBarGaugeCardFeature;
}
}

View File

@@ -0,0 +1,143 @@
import { html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { computeDomain } from "../../../common/entity/compute_domain";
import "../../../components/ha-control-slider";
import type { HomeAssistant } from "../../../types";
import type { LovelaceCardFeature } from "../types";
import { cardFeatureStyles } from "./common/card-feature-styles";
import type {
LovelaceCardFeatureContext,
DateSetCardFeatureConfig,
} from "./types";
import { fireEvent } from "../../../common/dom/fire_event";
import type { DatePickerDialogParams } from "../../../components/ha-date-input";
import { firstWeekdayIndex } from "../../../common/datetime/first_weekday";
import "../../../components/ha-control-button";
import "../../../components/ha-control-button-group";
const loadDatePickerDialog = () =>
import("../../../components/ha-dialog-date-picker");
export const supportsDateSetCardFeature = (
hass: HomeAssistant,
context: LovelaceCardFeatureContext
) => {
const stateObj = context.entity_id
? hass.states[context.entity_id]
: undefined;
if (!stateObj) return false;
const domain = computeDomain(stateObj.entity_id);
return (
(domain === "input_datetime" && stateObj.attributes.has_date) ||
["datetime", "date"].includes(domain)
);
};
@customElement("hui-date-set-card-feature")
class HuiDateSetCardFeature extends LitElement implements LovelaceCardFeature {
@property({ attribute: false }) public hass?: HomeAssistant;
@property({ attribute: false }) public context?: LovelaceCardFeatureContext;
@property({ attribute: false }) public color?: string;
@state() private _config?: DateSetCardFeatureConfig;
private get _stateObj() {
if (!this.hass || !this.context || !this.context.entity_id) {
return undefined;
}
return this.hass.states[this.context.entity_id!] ?? undefined;
}
private _pressButton() {
if (!this.hass || !this._stateObj) return;
const dialogParams: DatePickerDialogParams = {
min: "1970-01-01",
value: this._stateObj.state,
onChange: (value) => this._dateChanged(value),
locale: this.hass.locale.language,
firstWeekday: firstWeekdayIndex(this.hass.locale),
};
fireEvent(this, "show-dialog", {
dialogTag: "ha-dialog-date-picker",
dialogImport: loadDatePickerDialog,
dialogParams,
});
}
private _dateChanged(value: string | undefined) {
if (!this.hass || !this._stateObj || !value) return;
const domain = computeDomain(this._stateObj.entity_id);
const service = domain === "input_datetime" ? "set_datetime" : "set_value";
// datetime requires a full datetime string
if (domain === "datetime") {
const dateObj = new Date(this._stateObj.state);
const selectedDate = new Date(`${value}T00:00:00`);
dateObj.setFullYear(
selectedDate.getFullYear(),
selectedDate.getMonth(),
selectedDate.getDate()
);
this.hass.callService(domain, service, {
entity_id: this._stateObj.entity_id,
datetime: dateObj.toISOString(),
});
} else {
this.hass.callService(domain, service, {
entity_id: this._stateObj.entity_id,
date: value,
});
}
}
static getStubConfig(): DateSetCardFeatureConfig {
return {
type: "date-set",
};
}
public setConfig(config: DateSetCardFeatureConfig): void {
if (!config) {
throw new Error("Invalid configuration");
}
this._config = config;
}
protected render() {
if (
!this._config ||
!this.hass ||
!this.context ||
!this._stateObj ||
!supportsDateSetCardFeature(this.hass, this.context)
) {
return nothing;
}
return html`
<ha-control-button-group>
<ha-control-button
.disabled=${["unavailable", "unknown"].includes(this._stateObj.state)}
class="press-button"
@click=${this._pressButton}
>
${this.hass.localize("ui.card.date.set_date")}
</ha-control-button>
</ha-control-button-group>
`;
}
static styles = cardFeatureStyles;
}
declare global {
interface HTMLElementTagNameMap {
"hui-date-set-card-feature": HuiDateSetCardFeature;
}
}

View File

@@ -0,0 +1,150 @@
import { mdiArrowOscillating, mdiArrowOscillatingOff } from "@mdi/js";
import type { PropertyValues, TemplateResult } from "lit";
import { LitElement, html } from "lit";
import { customElement, property, state } from "lit/decorators";
import { styleMap } from "lit/directives/style-map";
import { computeDomain } from "../../../common/entity/compute_domain";
import { stateColorCss } from "../../../common/entity/state_color";
import "../../../components/ha-control-select";
import type { ControlSelectOption } from "../../../components/ha-control-select";
import { UNAVAILABLE } from "../../../data/entity";
import type { FanEntity } from "../../../data/fan";
import { FanEntityFeature } from "../../../data/fan";
import type { HomeAssistant } from "../../../types";
import type { LovelaceCardFeature } from "../types";
import { cardFeatureStyles } from "./common/card-feature-styles";
import type {
FanOscillateCardFeatureConfig,
LovelaceCardFeatureContext,
} from "./types";
import { supportsFeature } from "../../../common/entity/supports-feature";
export const supportsFanOscilatteCardFeature = (
hass: HomeAssistant,
context: LovelaceCardFeatureContext
) => {
const stateObj = context.entity_id
? hass.states[context.entity_id]
: undefined;
if (!stateObj) return false;
const domain = computeDomain(stateObj.entity_id);
return (
domain === "fan" && supportsFeature(stateObj, FanEntityFeature.OSCILLATE)
);
};
@customElement("hui-fan-oscillate-card-feature")
class HuiFanOscillateCardFeature
extends LitElement
implements LovelaceCardFeature
{
@property({ attribute: false }) public hass?: HomeAssistant;
@property({ attribute: false }) public context?: LovelaceCardFeatureContext;
@state() private _config?: FanOscillateCardFeatureConfig;
@state() _oscillate?: boolean;
private get _stateObj() {
if (!this.hass || !this.context || !this.context.entity_id) {
return undefined;
}
return this.hass.states[this.context.entity_id!] as FanEntity | undefined;
}
static getStubConfig(): FanOscillateCardFeatureConfig {
return {
type: "fan-oscillate",
};
}
public setConfig(config: FanOscillateCardFeatureConfig): void {
if (!config) {
throw new Error("Invalid configuration");
}
this._config = config;
}
protected willUpdate(changedProp: PropertyValues): void {
if (
(changedProp.has("hass") || changedProp.has("context")) &&
this._stateObj
) {
const oldHass = changedProp.get("hass") as HomeAssistant | undefined;
const oldStateObj = oldHass?.states[this.context!.entity_id!];
if (oldStateObj !== this._stateObj) {
this._oscillate = this._stateObj.attributes.oscillating;
}
}
}
private async _valueChanged(ev: CustomEvent) {
const shouldOscillate = (ev.detail as any).value === "yes";
if (shouldOscillate === this._stateObj!.attributes.oscillating) return;
const wasOscillating = this._stateObj!.attributes.oscillating;
this._oscillate = shouldOscillate;
try {
await this._updateOscillate(shouldOscillate);
} catch (_err) {
this._oscillate = wasOscillating;
}
}
private async _updateOscillate(oscillate: boolean) {
await this.hass!.callService("fan", "oscillate", {
entity_id: this._stateObj!.entity_id,
oscillating: oscillate,
});
}
protected render(): TemplateResult | null {
if (
!this._config ||
!this.hass ||
!this.context ||
!this._stateObj ||
!supportsFanOscilatteCardFeature(this.hass, this.context)
) {
return null;
}
const color = stateColorCss(this._stateObj);
const yesNo = ["no", "yes"] as const;
const options = yesNo.map<ControlSelectOption>((oscillating) => ({
value: oscillating,
label: this.hass!.localize(`ui.common.${oscillating}`),
path:
oscillating === "yes" ? mdiArrowOscillating : mdiArrowOscillatingOff,
}));
return html`
<ha-control-select
.options=${options}
.value=${this._oscillate ? "yes" : "no"}
@value-changed=${this._valueChanged}
hide-option-label
.label=${this.hass.localize("ui.card.fan.oscillate")}
style=${styleMap({
"--control-select-color": color,
})}
.disabled=${this._stateObj!.state === UNAVAILABLE}
>
</ha-control-select>
`;
}
static get styles() {
return cardFeatureStyles;
}
}
declare global {
interface HTMLElementTagNameMap {
"hui-fan-oscillate-card-feature": HuiFanOscillateCardFeature;
}
}

View File

@@ -0,0 +1,301 @@
import { css, html, LitElement, nothing, svg } from "lit";
import { customElement, property, state } from "lit/decorators";
import { computeDomain } from "../../../common/entity/compute_domain";
import {
computeHistory,
subscribeHistoryStatesTimeWindow,
} from "../../../data/history";
import type {
HistoryResult,
LineChartUnit,
TimelineEntity,
} from "../../../data/history";
import type { HomeAssistant } from "../../../types";
import type { LovelaceCardFeature } from "../types";
import type {
LovelaceCardFeatureContext,
HistoryChartCardFeatureConfig,
} from "./types";
import { getSensorNumericDeviceClasses } from "../../../data/sensor";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import { SubscribeMixin } from "../../../mixins/subscribe-mixin";
import { getGraphColorByIndex } from "../../../common/color/colors";
import { computeTimelineColor } from "../../../components/chart/timeline-color";
import { downSampleLineData } from "../../../components/chart/down-sample";
import { fireEvent } from "../../../common/dom/fire_event";
export const supportsHistoryChartCardFeature = (
_hass: HomeAssistant,
context: LovelaceCardFeatureContext
) =>
!!context.entity_id &&
["sensor", "binary_sensor"].includes(computeDomain(context.entity_id));
@customElement("hui-history-chart-card-feature")
class HuiHistoryChartCardFeature
extends SubscribeMixin(LitElement)
implements LovelaceCardFeature
{
@property({ attribute: false, hasChanged: () => false })
public hass?: HomeAssistant;
@property({ attribute: false }) public context?: LovelaceCardFeatureContext;
@state() private _config?: HistoryChartCardFeatureConfig;
@state() private _stateHistory?: HistoryResult;
private _interval?: number;
static getStubConfig(): HistoryChartCardFeatureConfig {
return {
type: "history-chart",
hours_to_show: 24,
};
}
public setConfig(config: HistoryChartCardFeatureConfig): void {
if (!config) {
throw new Error("Invalid configuration");
}
this._config = config;
}
public connectedCallback() {
super.connectedCallback();
// redraw the graph every minute to update the time axis
clearInterval(this._interval);
this._interval = window.setInterval(() => this.requestUpdate(), 1000 * 60);
}
public disconnectedCallback() {
super.disconnectedCallback();
clearInterval(this._interval);
}
protected hassSubscribe() {
return [this._subscribeHistory()];
}
protected render() {
if (
!this._config ||
!this.hass ||
!this.context ||
!this._stateHistory ||
!supportsHistoryChartCardFeature(this.hass, this.context)
) {
return nothing;
}
const line = this._stateHistory.line[0];
const timeline = this._stateHistory.timeline[0];
const width = this.clientWidth;
const height = this.clientHeight;
if (line) {
const points = this._generateLinePoints(line);
const { paths, filledPaths } = this._getLinePaths(points);
const color = getGraphColorByIndex(0, this.style);
return html`
<div class="line" @click=${this._handleClick}>
${svg`<svg width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
${paths.map(
(path) =>
svg`<path d="${path}" stroke="${color}" stroke-width="1" stroke-linecap="round" fill="none" />`
)}
${filledPaths.map(
(path) =>
svg`<path d="${path}" stroke="none" stroke-linecap="round" fill="${color}" fill-opacity="0.2" />`
)}
</svg>`}
</div>
`;
}
if (timeline) {
const ranges = this._generateTimelineRanges(timeline);
return html`
<div class="timeline" @click=${this._handleClick}>
${svg`<svg width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">
<g>
${ranges.map((r) => svg`<rect x="${r.startX}" y="0" width="${r.endX - r.startX}" height="${height}" fill="${r.color}" />`)}
</g>
</svg>`}
</div>
`;
}
return nothing;
}
private _handleClick() {
// open more info dialog to show more detailed history
fireEvent(this, "hass-more-info", { entityId: this.context!.entity_id! });
}
private async _subscribeHistory(): Promise<() => Promise<void>> {
if (
!isComponentLoaded(this.hass!, "history") ||
!this.context?.entity_id ||
!this._config
) {
return () => Promise.resolve();
}
const { numeric_device_classes: sensorNumericDeviceClasses } =
await getSensorNumericDeviceClasses(this.hass!);
return subscribeHistoryStatesTimeWindow(
this.hass!,
(historyStates) => {
this._stateHistory = computeHistory(
this.hass!,
historyStates,
[this.context!.entity_id!],
this.hass!.localize,
sensorNumericDeviceClasses,
false
);
},
this._config!.hours_to_show ?? 24,
[this.context!.entity_id!]
);
}
private _generateLinePoints(line: LineChartUnit): { x: number; y: number }[] {
const width = this.clientWidth;
const height = this.clientHeight;
let minY = Number(line.data[0].states[0].state);
let maxY = Number(line.data[0].states[0].state);
const minX = line.data[0].states[0].last_changed;
const maxX = Date.now();
line.data[0].states.forEach((stateData) => {
const stateValue = Number(stateData.state);
if (stateValue < minY) {
minY = stateValue;
}
if (stateValue > maxY) {
maxY = stateValue;
}
});
const rangeY = maxY - minY || minY * 0.1;
const sampledData = downSampleLineData(
line.data[0].states.map((stateData) => [
stateData.last_changed,
Number(stateData.state),
]),
width,
minX,
maxX
);
// add margin to the min and max
minY -= rangeY * 0.1;
maxY += rangeY * 0.1;
const yDenom = maxY - minY || 1;
const xDenom = maxX - minX || 1;
const points = sampledData!.map((point) => {
const x = ((point![0] - minX) / xDenom) * width;
const y = height - ((Number(point![1]) - minY) / yDenom) * height;
return { x, y };
});
points.push({ x: width, y: points[points.length - 1].y });
return points;
}
private _generateTimelineRanges(timeline: TimelineEntity) {
if (timeline.data.length === 0) {
return [];
}
const computedStyles = getComputedStyle(this);
const width = this.clientWidth;
const minX = timeline.data[0].last_changed;
const maxX = Date.now();
let prevEndX = 0;
let prevStateColor = "";
const ranges = timeline.data.map((t) => {
const x = ((t.last_changed - minX) / (maxX - minX)) * width;
const range = {
startX: prevEndX,
endX: x,
color: prevStateColor,
};
prevStateColor = computeTimelineColor(
t.state,
computedStyles,
this.hass!.states[timeline.entity_id]
);
prevEndX = x;
return range;
});
ranges.push({
startX: prevEndX,
endX: width,
color: prevStateColor,
});
return ranges;
}
private _getLinePaths(points: { x: number; y: number }[]) {
const paths: string[] = [];
const filledPaths: string[] = [];
if (!points.length) {
return { paths, filledPaths };
}
// path can interupted by missing data, so we need to split the path into segments
const pathSegments: { x: number; y: number }[][] = [[]];
points.forEach((point) => {
if (!isNaN(point.y)) {
pathSegments[pathSegments.length - 1].push(point);
} else if (pathSegments[pathSegments.length - 1].length > 0) {
pathSegments.push([]);
}
});
pathSegments.forEach((pathPoints) => {
// create a smoothed path
let next: { x: number; y: number };
let path = "";
let last = pathPoints[0];
path += `M ${last.x},${last.y}`;
pathPoints.forEach((coord) => {
next = coord;
path += ` ${(next.x + last.x) / 2},${(next.y + last.y) / 2}`;
path += ` Q${next.x},${next.y}`;
last = next;
});
path += ` ${next!.x},${next!.y}`;
paths.push(path);
filledPaths.push(
path +
` L ${next!.x},${this.clientHeight} L ${pathPoints[0].x},${this.clientHeight} Z`
);
});
return { paths, filledPaths };
}
static styles = css`
:host {
display: block;
width: 100%;
height: var(--feature-height);
}
:host > div {
width: 100%;
height: 100%;
cursor: pointer;
}
.timeline {
border-radius: 4px;
overflow: hidden;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"hui-history-chart-card-feature": HuiHistoryChartCardFeature;
}
}

View File

@@ -47,6 +47,10 @@ export interface FanDirectionCardFeatureConfig {
type: "fan-direction";
}
export interface FanOscillateCardFeatureConfig {
type: "fan-oscillate";
}
export interface FanPresetModesCardFeatureConfig {
type: "fan-preset-modes";
style?: "dropdown" | "icons";
@@ -101,6 +105,10 @@ export interface CounterActionsCardFeatureConfig {
actions?: CounterActions[];
}
export interface DateSetCardFeatureConfig {
type: "date-set";
}
export interface SelectOptionsCardFeatureConfig {
type: "select-options";
options?: string[];
@@ -175,6 +183,11 @@ export interface UpdateActionsCardFeatureConfig {
backup?: "yes" | "no" | "ask";
}
export interface HistoryChartCardFeatureConfig {
type: "history-chart";
hours_to_show: number;
}
export const AREA_CONTROLS = [
"light",
"fan",
@@ -198,6 +211,10 @@ export interface AreaControlsCardFeatureConfig {
controls?: AreaControl[];
}
export interface BarGaugeCardFeatureConfig {
type: "bar-gauge";
}
export type LovelaceCardFeaturePosition = "bottom" | "inline";
export type LovelaceCardFeatureConfig =
@@ -213,7 +230,9 @@ export type LovelaceCardFeatureConfig =
| CoverPositionCardFeatureConfig
| CoverTiltPositionCardFeatureConfig
| CoverTiltCardFeatureConfig
| DateSetCardFeatureConfig
| FanDirectionCardFeatureConfig
| FanOscillateCardFeatureConfig
| FanPresetModesCardFeatureConfig
| FanSpeedCardFeatureConfig
| HumidifierToggleCardFeatureConfig
@@ -226,6 +245,7 @@ export type LovelaceCardFeatureConfig =
| MediaPlayerVolumeSliderCardFeatureConfig
| NumericInputCardFeatureConfig
| SelectOptionsCardFeatureConfig
| HistoryChartCardFeatureConfig
| TargetHumidityCardFeatureConfig
| TargetTemperatureCardFeatureConfig
| ToggleCardFeatureConfig
@@ -234,7 +254,8 @@ export type LovelaceCardFeatureConfig =
| ValveOpenCloseCardFeatureConfig
| ValvePositionCardFeatureConfig
| WaterHeaterOperationModesCardFeatureConfig
| AreaControlsCardFeatureConfig;
| AreaControlsCardFeatureConfig
| BarGaugeCardFeatureConfig;
export interface LovelaceCardFeatureContext {
entity_id?: string;

View File

@@ -148,13 +148,18 @@ export class HuiEnergyDevicesDetailGraphCard
{ num: formatNumber(total, this.hass.locale), unit: UNIT }
);
// ha-chart-base will track hidden per ID (so it will have two entries for ID and compare-ID)
// But it will only fire the event for the primary ID, and we will convert and store a list of statistic ids only
private _datasetHidden(ev) {
this._hiddenStats = [...this._hiddenStats, ev.detail.id];
this._hiddenStats = [
...this._hiddenStats,
this._getStatIdFromId(ev.detail.id),
];
}
private _datasetUnhidden(ev) {
this._hiddenStats = this._hiddenStats.filter(
(stat) => stat !== ev.detail.id
(stat) => stat !== this._getStatIdFromId(ev.detail.id)
);
}
@@ -179,16 +184,25 @@ export class HuiEnergyDevicesDetailGraphCard
this._formatTotal
);
const selected = this._legendData
? this._legendData
.filter(
(d) =>
d.id && this._hiddenStats.includes(this._getStatIdFromId(d.id))
)
.reduce((acc, d) => {
acc[d.id!] = false;
return acc;
}, {})
: {};
return {
...commonOptions,
legend: {
show: true,
type: "custom",
data: this._legendData,
selected: this._hiddenStats.reduce((acc, stat) => {
acc[stat] = false;
return acc;
}, {}),
selected,
},
grid: {
top: 15,
@@ -315,6 +329,7 @@ export class HuiEnergyDevicesDetailGraphCard
datasets.push(...processedData);
this._legendData = processedData.map((d) => ({
id: d.id as string,
secondaryIds: [`compare-${d.id}`],
name: d.name as string,
itemStyle: {
color: d.color as string,
@@ -332,6 +347,7 @@ export class HuiEnergyDevicesDetailGraphCard
datasets.push(untrackedData);
this._legendData.push({
id: untrackedData.id as string,
secondaryIds: [`compare-${untrackedData.id}`],
name: untrackedData.name as string,
itemStyle: {
color: untrackedData.color as string,

View File

@@ -9,6 +9,7 @@ import {
type TemplateResult,
} from "lit";
import { customElement, property, 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 memoizeOne from "memoize-one";
@@ -84,8 +85,10 @@ export class HuiAreaCard extends LitElement implements LovelaceCard {
const displayType =
config.display_type || (config.show_camera ? "camera" : "picture");
const vertical = displayType === "compact" ? config.vertical : false;
this._config = {
...config,
vertical,
display_type: displayType,
};
@@ -109,7 +112,7 @@ export class HuiAreaCard extends LitElement implements LovelaceCard {
const featuresCount = this._config?.features?.length || 0;
return (
1 +
(displayType === "compact" ? 0 : 2) +
(displayType === "compact" ? (this._config?.vertical ? 1 : 0) : 2) +
(featuresPosition === "inline" ? 0 : featuresCount)
);
}
@@ -133,6 +136,11 @@ export class HuiAreaCard extends LitElement implements LovelaceCard {
const displayType = this._config?.display_type || "picture";
if (this._config?.vertical) {
rows++;
min_columns = 3;
}
if (displayType !== "compact") {
if (featurePosition === "inline" && featuresCount > 0) {
rows += 3;
@@ -397,9 +405,12 @@ export class HuiAreaCard extends LitElement implements LovelaceCard {
return sensorStates;
}
private _featurePosition = memoizeOne(
(config: AreaCardConfig) => config.features_position || "bottom"
);
private _featurePosition = memoizeOne((config: AreaCardConfig) => {
if (config.vertical) {
return "bottom";
}
return config.features_position || "bottom";
});
private _displayedFeatures = memoizeOne((config: AreaCardConfig) => {
const features = config.features || [];
@@ -439,6 +450,8 @@ export class HuiAreaCard extends LitElement implements LovelaceCard {
`;
}
const contentClasses = { vertical: Boolean(this._config.vertical) };
const icon = area.icon;
const name = this._config.name || computeAreaName(area);
@@ -518,7 +531,7 @@ export class HuiAreaCard extends LitElement implements LovelaceCard {
</div>
`}
<div class="container ${containerOrientationClass}">
<div class="content">
<div class="content ${classMap(contentClasses)}">
<ha-tile-icon>
${displayType === "compact"
? this._renderAlertSensorBadge()
@@ -656,6 +669,16 @@ export class HuiAreaCard extends LitElement implements LovelaceCard {
gap: 10px;
}
.vertical {
flex-direction: column;
text-align: center;
justify-content: center;
}
.vertical ha-tile-info {
width: 100%;
flex: none;
}
ha-tile-icon {
--tile-icon-color: var(--tile-color);
position: relative;

View File

@@ -1,6 +1,7 @@
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import "../../../components/ha-alert";
import "../../../components/ha-card";
import type { HomeAssistant } from "../../../types";
@@ -156,7 +157,11 @@ export class HuiClockCard extends LitElement implements LovelaceCard {
if (!this._config) return nothing;
return html`
<ha-card>
<ha-card
class=${classMap({
"no-background": this._config.no_background ?? false,
})}
>
<div
class="time-wrapper ${this._config.clock_size
? `size-${this._config.clock_size}`
@@ -185,6 +190,12 @@ export class HuiClockCard extends LitElement implements LovelaceCard {
height: 100%;
}
.no-background {
background: none;
box-shadow: none;
border: none;
}
.time-wrapper {
display: flex;
height: calc(100% - 12px);

View File

@@ -24,6 +24,29 @@ import type {
} from "../types";
import type { EntitiesCardConfig } from "./types";
export const computeShowHeaderToggle = <
T extends EntityConfig | LovelaceRowConfig,
>(
config: EntitiesCardConfig,
entities: T[]
): boolean => {
if (config.title !== undefined && config.show_header_toggle === undefined) {
// Default value is show toggle if we can at least toggle 2 entities.
let toggleable = 0;
for (const rowConf of entities) {
if (!("entity" in rowConf)) {
continue;
}
toggleable += Number(DOMAINS_TOGGLE.has(computeDomain(rowConf.entity)));
if (toggleable === 2) {
break;
}
}
return toggleable === 2;
}
return !!config.show_header_toggle;
};
@customElement("hui-entities-card")
class HuiEntitiesCard extends LitElement implements LovelaceCard {
public static async getConfigElement(): Promise<LovelaceCardEditor> {
@@ -110,23 +133,7 @@ class HuiEntitiesCard extends LitElement implements LovelaceCard {
this._config = config;
this._configEntities = entities;
if (config.title !== undefined && config.show_header_toggle === undefined) {
// Default value is show toggle if we can at least toggle 2 entities.
let toggleable = 0;
for (const rowConf of entities) {
if (!("entity" in rowConf)) {
continue;
}
toggleable += Number(DOMAINS_TOGGLE.has(computeDomain(rowConf.entity)));
if (toggleable === 2) {
break;
}
}
this._showHeaderToggle = toggleable === 2;
} else {
this._showHeaderToggle = config.show_header_toggle;
}
this._showHeaderToggle = computeShowHeaderToggle(config, entities);
if (this._config.header) {
this._headerElement = createHeaderFooterElement(
this._config.header

View File

@@ -113,11 +113,7 @@ class HuiPictureGlanceCard extends LitElement implements LovelaceCard {
}
});
this._config = {
tap_action: { action: "more-info" },
hold_action: { action: "more-info" },
...config,
};
this._config = config;
}
protected shouldUpdate(changedProps: PropertyValues): boolean {
@@ -212,25 +208,31 @@ class HuiPictureGlanceCard extends LitElement implements LovelaceCard {
this.layout === "grid" &&
typeof this._config.grid_options?.rows === "number";
const hasTapAction =
hasAction(this._config.tap_action) ||
Boolean(
!this._config.tap_action &&
(this._config.camera_image ||
this._config.image_entity ||
this._config.entity)
);
return html`
<ha-card>
<hui-image
class=${classMap({
clickable: Boolean(
this._config.tap_action ||
this._config.hold_action ||
this._config.camera_image ||
this._config.image_entity
),
clickable:
hasTapAction ||
hasAction(this._config.hold_action) ||
hasAction(this._config.double_tap_action),
})}
@action=${this._handleAction}
.actionHandler=${actionHandler({
hasHold: hasAction(this._config!.hold_action),
hasDoubleClick: hasAction(this._config!.double_tap_action),
hasTap: hasTapAction,
hasHold: hasAction(this._config.hold_action),
hasDoubleClick: hasAction(this._config.double_tap_action),
})}
tabindex=${ifDefined(
hasAction(this._config.tap_action) ? "0" : undefined
)}
tabindex=${ifDefined(hasTapAction ? "0" : undefined)}
.config=${this._config}
.hass=${this.hass}
.image=${image}

View File

@@ -251,8 +251,6 @@ export class HuiTileCard extends LitElement implements LovelaceCard {
const entityId = this._config.entity;
const stateObj = entityId ? this.hass.states[entityId] : undefined;
const contentClasses = { vertical: Boolean(this._config.vertical) };
if (!stateObj) {
return html`
<hui-warning .hass=${this.hass}>
@@ -261,6 +259,8 @@ export class HuiTileCard extends LitElement implements LovelaceCard {
`;
}
const contentClasses = { vertical: Boolean(this._config.vertical) };
const name = this._config.name || computeStateName(stateObj);
const active = stateActive(stateObj);
const color = this._computeStateColor(stateObj, this._config.color);

View File

@@ -54,6 +54,9 @@ import { createEntityNotFoundWarning } from "../components/hui-warning";
import type { LovelaceCard, LovelaceCardEditor } from "../types";
import type { TodoListCardConfig } from "./types";
export const ITEM_TAP_ACTION_EDIT = "edit";
export const ITEM_TAP_ACTION_TOGGLE = "toggle";
@customElement("hui-todo-list-card")
export class HuiTodoListCard extends LitElement implements LovelaceCard {
public static async getConfigElement(): Promise<LovelaceCardEditor> {
@@ -482,7 +485,7 @@ export class HuiTodoListCard extends LitElement implements LovelaceCard {
)}
.itemId=${item.uid}
@change=${this._completeItem}
@click=${this._openItem}
@click=${this._itemTap}
@request-selected=${this._requestSelected}
@keydown=${this._handleKeydown}
>
@@ -575,7 +578,18 @@ export class HuiTodoListCard extends LitElement implements LovelaceCard {
return;
}
if (ev.key === "Enter") {
this._itemTap(ev);
}
}
private _itemTap(ev): void {
if (
!this._config!.item_tap_action ||
this._config!.item_tap_action === ITEM_TAP_ACTION_EDIT
) {
this._openItem(ev);
} else if (this._config!.item_tap_action === ITEM_TAP_ACTION_TOGGLE) {
this._completeItem(ev);
}
}

View File

@@ -104,12 +104,13 @@ export interface EntitiesCardConfig extends LovelaceCardConfig {
state_color?: boolean;
}
export type AreaCardDisplayType = "compact" | "icon" | "picture" | "camera";
export interface AreaCardConfig extends LovelaceCardConfig {
area?: string;
name?: string;
color?: string;
navigation_path?: string;
display_type?: "compact" | "icon" | "picture" | "camera";
display_type?: AreaCardDisplayType;
/** @deprecated Use `display_type` instead */
show_camera?: boolean;
camera_view?: HuiImage["cameraView"];
@@ -374,6 +375,7 @@ export interface ClockCardConfig extends LovelaceCardConfig {
show_seconds?: boolean | undefined;
time_format?: TimeFormat;
time_zone?: string;
no_background?: boolean;
}
export interface MediaControlCardConfig extends LovelaceCardConfig {

View File

@@ -174,14 +174,16 @@ class ActionHandler extends HTMLElement implements ActionHandlerType {
) {
this.dblClickTimeout = window.setTimeout(() => {
this.dblClickTimeout = undefined;
fireEvent(target, "action", { action: "tap" });
if (options.hasTap !== false) {
fireEvent(target, "action", { action: "tap" });
}
}, 250);
} else {
clearTimeout(this.dblClickTimeout);
this.dblClickTimeout = undefined;
fireEvent(target, "action", { action: "double_tap" });
}
} else {
} else if (options.hasTap !== false) {
fireEvent(target, "action", { action: "tap" });
}
};

View File

@@ -10,7 +10,9 @@ import "../card-features/hui-cover-open-close-card-feature";
import "../card-features/hui-cover-position-card-feature";
import "../card-features/hui-cover-tilt-card-feature";
import "../card-features/hui-cover-tilt-position-card-feature";
import "../card-features/hui-date-set-card-feature";
import "../card-features/hui-fan-direction-card-feature";
import "../card-features/hui-fan-oscillate-card-feature";
import "../card-features/hui-fan-preset-modes-card-feature";
import "../card-features/hui-fan-speed-card-feature";
import "../card-features/hui-humidifier-modes-card-feature";
@@ -32,6 +34,8 @@ import "../card-features/hui-valve-open-close-card-feature";
import "../card-features/hui-valve-position-card-feature";
import "../card-features/hui-water-heater-operation-modes-card-feature";
import "../card-features/hui-area-controls-card-feature";
import "../card-features/hui-bar-gauge-card-feature";
import "../card-features/hui-history-chart-card-feature";
import type { LovelaceCardFeatureConfig } from "../card-features/types";
import {
@@ -41,8 +45,9 @@ import {
const TYPES = new Set<LovelaceCardFeatureConfig["type"]>([
"alarm-modes",
"button",
"area-controls",
"bar-gauge",
"button",
"climate-fan-modes",
"climate-swing-modes",
"climate-swing-horizontal-modes",
@@ -53,7 +58,9 @@ const TYPES = new Set<LovelaceCardFeatureConfig["type"]>([
"cover-position",
"cover-tilt-position",
"cover-tilt",
"date-set",
"fan-direction",
"fan-oscillate",
"fan-preset-modes",
"fan-speed",
"humidifier-modes",
@@ -66,6 +73,7 @@ const TYPES = new Set<LovelaceCardFeatureConfig["type"]>([
"media-player-volume-slider",
"numeric-input",
"select-options",
"history-chart",
"target-humidity",
"target-temperature",
"toggle",

View File

@@ -1,5 +1,14 @@
import type { ActionDetail } from "@material/mwc-list";
import { mdiDelete, mdiDotsVertical, mdiFlask, mdiPlaylistEdit } from "@mdi/js";
import {
mdiContentCopy,
mdiContentCut,
mdiContentDuplicate,
mdiDelete,
mdiDotsVertical,
mdiFlask,
mdiPlaylistEdit,
} from "@mdi/js";
import deepClone from "deep-clone-simple";
import type { PropertyValues } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
@@ -30,6 +39,7 @@ import {
validateConditionalConfig,
} from "../../common/validate-condition";
import type { LovelaceConditionEditorConstructor } from "./types";
import { storage } from "../../../../common/decorators/storage";
@customElement("ha-card-condition-editor")
export class HaCardConditionEditor extends LitElement {
@@ -37,6 +47,14 @@ export class HaCardConditionEditor extends LitElement {
@property({ attribute: false }) condition!: Condition | LegacyCondition;
@storage({
key: "dashboardConditionClipboard",
state: false,
subscribe: false,
storage: "sessionStorage",
})
protected _clipboard?: Condition | LegacyCondition;
@state() public _yamlMode = false;
@state() public _uiAvailable = false;
@@ -131,6 +149,26 @@ export class HaCardConditionEditor extends LitElement {
<ha-svg-icon slot="graphic" .path=${mdiFlask}></ha-svg-icon>
</ha-list-item>
<ha-list-item graphic="icon">
${this.hass.localize(
"ui.panel.lovelace.editor.edit_card.duplicate"
)}
<ha-svg-icon
slot="graphic"
.path=${mdiContentDuplicate}
></ha-svg-icon>
</ha-list-item>
<ha-list-item graphic="icon">
${this.hass.localize("ui.panel.lovelace.editor.edit_card.copy")}
<ha-svg-icon slot="graphic" .path=${mdiContentCopy}></ha-svg-icon>
</ha-list-item>
<ha-list-item graphic="icon">
${this.hass.localize("ui.panel.lovelace.editor.edit_card.cut")}
<ha-svg-icon slot="graphic" .path=${mdiContentCut}></ha-svg-icon>
</ha-list-item>
<ha-list-item graphic="icon" .disabled=${!this._uiAvailable}>
${this.hass.localize(
`ui.panel.lovelace.editor.edit_view.edit_${!this._yamlMode ? "yaml" : "ui"}`
@@ -220,9 +258,18 @@ export class HaCardConditionEditor extends LitElement {
await this._testCondition();
break;
case 1:
this._yamlMode = !this._yamlMode;
this._duplicateCondition();
break;
case 2:
this._copyCondition();
break;
case 3:
this._cutCondition();
break;
case 4:
this._yamlMode = !this._yamlMode;
break;
case 5:
this._delete();
break;
}
@@ -259,6 +306,21 @@ export class HaCardConditionEditor extends LitElement {
}, 2500);
}
private _duplicateCondition() {
fireEvent(this, "duplicate-condition", {
value: deepClone(this.condition),
});
}
private _copyCondition() {
this._clipboard = deepClone(this.condition);
}
private _cutCondition() {
this._copyCondition();
this._delete();
}
private _delete() {
fireEvent(this, "value-changed", { value: null });
}
@@ -350,4 +412,8 @@ declare global {
interface HTMLElementTagNameMap {
"ha-card-condition-editor": HaCardConditionEditor;
}
interface HASSDomEvents {
"duplicate-condition": { value: Condition | LegacyCondition };
}
}

View File

@@ -1,6 +1,7 @@
import { mdiPlus } from "@mdi/js";
import { mdiContentPaste, mdiPlus } from "@mdi/js";
import deepClone from "deep-clone-simple";
import type { CSSResultGroup, PropertyValues } from "lit";
import { LitElement, css, html } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { fireEvent } from "../../../../common/dom/fire_event";
import { stopPropagation } from "../../../../common/dom/stop_propagation";
@@ -25,6 +26,7 @@ import "./types/ha-card-condition-or";
import "./types/ha-card-condition-screen";
import "./types/ha-card-condition-state";
import "./types/ha-card-condition-user";
import { storage } from "../../../../common/decorators/storage";
const UI_CONDITION = [
"location",
@@ -37,10 +39,20 @@ const UI_CONDITION = [
"or",
] as const satisfies readonly Condition["condition"][];
export const PASTE_VALUE = "__paste__" as const;
@customElement("ha-card-conditions-editor")
export class HaCardConditionsEditor extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@storage({
key: "dashboardConditionClipboard",
state: false,
subscribe: false,
storage: "sessionStorage",
})
protected _clipboard?: Condition | LegacyCondition;
@property({ attribute: false }) public conditions!: (
| Condition
| LegacyCondition
@@ -85,6 +97,7 @@ export class HaCardConditionsEditor extends LitElement {
(cond, idx) => html`
<ha-card-condition-editor
.index=${idx}
@duplicate-condition=${this._duplicateCondition}
@value-changed=${this._conditionChanged}
.hass=${this.hass}
.condition=${cond}
@@ -103,6 +116,19 @@ export class HaCardConditionsEditor extends LitElement {
"ui.panel.lovelace.editor.condition-editor.add"
)}
</ha-button>
${this._clipboard
? html`
<ha-list-item .value=${PASTE_VALUE} graphic="icon">
${this.hass.localize(
"ui.panel.lovelace.editor.edit_card.paste_condition"
)}
<ha-svg-icon
slot="graphic"
.path=${mdiContentPaste}
></ha-svg-icon>
</ha-list-item>
`
: nothing}
${UI_CONDITION.map(
(condition) => html`
<ha-list-item .value=${condition} graphic="icon">
@@ -123,10 +149,19 @@ export class HaCardConditionsEditor extends LitElement {
}
private _addCondition(ev: CustomEvent): void {
const condition = (ev.currentTarget as HaSelect).items[ev.detail.index]
.value as Condition["condition"];
const conditions = [...this.conditions];
const item = (ev.currentTarget as HaSelect).items[ev.detail.index];
if (item.value === PASTE_VALUE && this._clipboard) {
const condition = deepClone(this._clipboard);
conditions.push(condition);
fireEvent(this, "value-changed", { value: conditions });
return;
}
const condition = item.value as Condition["condition"];
const elClass = customElements.get(`ha-card-condition-${condition}`) as
| LovelaceConditionEditorConstructor
| undefined;
@@ -140,6 +175,12 @@ export class HaCardConditionsEditor extends LitElement {
fireEvent(this, "value-changed", { value: conditions });
}
private _duplicateCondition(ev: CustomEvent) {
const conditions = [...this.conditions];
conditions.push(ev.detail.value);
fireEvent(this, "value-changed", { value: conditions });
}
private _conditionChanged(ev: CustomEvent) {
ev.stopPropagation();
const conditions = [...this.conditions];

View File

@@ -36,7 +36,7 @@ import {
DEVICE_CLASSES,
type AreaCardFeatureContext,
} from "../../cards/hui-area-card";
import type { AreaCardConfig } from "../../cards/types";
import type { AreaCardConfig, AreaCardDisplayType } from "../../cards/types";
import type { LovelaceCardEditor } from "../../types";
import { baseLovelaceCardConfig } from "../structs/base-card-struct";
import type { EditDetailElementEvent, EditSubElementEvent } from "../types";
@@ -52,6 +52,7 @@ const cardConfigStruct = assign(
navigation_path: optional(string()),
show_camera: optional(boolean()),
display_type: optional(enums(["compact", "icon", "picture", "camera"])),
vertical: optional(boolean()),
camera_view: optional(string()),
alert_classes: optional(array(string())),
sensor_classes: optional(array(string())),
@@ -78,7 +79,7 @@ export class HuiAreaCardEditor
private _schema = memoizeOne(
(
localize: LocalizeFunc,
showCamera: boolean,
displayType: AreaCardDisplayType,
binaryClasses: SelectOption[],
sensorClasses: SelectOption[]
) =>
@@ -113,7 +114,28 @@ export class HuiAreaCardEditor
},
},
},
...(showCamera
...(displayType === "compact"
? ([
{
name: "content_layout",
required: true,
selector: {
select: {
mode: "dropdown",
options: ["horizontal", "vertical"].map(
(value) => ({
label: localize(
`ui.panel.lovelace.editor.card.area.content_layout_options.${value}`
),
value,
})
),
},
},
},
] as const satisfies readonly HaFormSchema[])
: []),
...(displayType === "camera"
? ([
{
name: "camera_view",
@@ -282,7 +304,7 @@ export class HuiAreaCardEditor
}
private _featuresSchema = memoizeOne(
(localize: LocalizeFunc) =>
(localize: LocalizeFunc, vertical: boolean) =>
[
{
name: "features_position",
@@ -303,6 +325,7 @@ export class HuiAreaCardEditor
src_dark: `/static/images/form/tile_features_position_${value}_dark.svg`,
flip_rtl: true,
},
disabled: vertical && value === "inline",
})),
},
},
@@ -338,31 +361,35 @@ export class HuiAreaCardEditor
this._config.sensor_classes || DEVICE_CLASSES.sensor
);
const showCamera = this._config.display_type === "camera";
const displayType =
this._config.display_type || this._config.show_camera
? "camera"
: "picture";
this._config.display_type ||
(this._config.show_camera ? "camera" : "picture");
const schema = this._schema(
this.hass.localize,
showCamera,
displayType,
binarySelectOptions,
sensorSelectOptions
);
const featuresSchema = this._featuresSchema(this.hass.localize);
const vertical = this._config.vertical && displayType === "compact";
const featuresSchema = this._featuresSchema(this.hass.localize, vertical);
const data = {
camera_view: "auto",
alert_classes: DEVICE_CLASSES.binary_sensor,
sensor_classes: DEVICE_CLASSES.sensor,
features_position: "bottom",
display_type: displayType,
content_layout: vertical ? "vertical" : "horizontal",
...this._config,
};
// Default features position to bottom and force it to bottom in vertical mode
if (!data.features_position || vertical) {
data.features_position = "bottom";
}
const hasCompatibleFeatures = this._hasCompatibleFeatures(
this._featureContext
);
@@ -420,6 +447,12 @@ export class HuiAreaCardEditor
delete config.camera_view;
}
// Convert content_layout to vertical
if (config.content_layout) {
config.vertical = config.content_layout === "vertical";
delete config.content_layout;
}
fireEvent(this, "config-changed", { config });
}

View File

@@ -30,7 +30,9 @@ import { supportsCoverOpenCloseCardFeature } from "../../card-features/hui-cover
import { supportsCoverPositionCardFeature } from "../../card-features/hui-cover-position-card-feature";
import { supportsCoverTiltCardFeature } from "../../card-features/hui-cover-tilt-card-feature";
import { supportsCoverTiltPositionCardFeature } from "../../card-features/hui-cover-tilt-position-card-feature";
import { supportsDateSetCardFeature } from "../../card-features/hui-date-set-card-feature";
import { supportsFanDirectionCardFeature } from "../../card-features/hui-fan-direction-card-feature";
import { supportsFanOscilatteCardFeature } from "../../card-features/hui-fan-oscillate-card-feature";
import { supportsFanPresetModesCardFeature } from "../../card-features/hui-fan-preset-modes-card-feature";
import { supportsFanSpeedCardFeature } from "../../card-features/hui-fan-speed-card-feature";
import { supportsHumidifierModesCardFeature } from "../../card-features/hui-humidifier-modes-card-feature";
@@ -43,6 +45,7 @@ import { supportsLockOpenDoorCardFeature } from "../../card-features/hui-lock-op
import { supportsMediaPlayerVolumeSliderCardFeature } from "../../card-features/hui-media-player-volume-slider-card-feature";
import { supportsNumericInputCardFeature } from "../../card-features/hui-numeric-input-card-feature";
import { supportsSelectOptionsCardFeature } from "../../card-features/hui-select-options-card-feature";
import { supportsHistoryChartCardFeature } from "../../card-features/hui-history-chart-card-feature";
import { supportsTargetHumidityCardFeature } from "../../card-features/hui-target-humidity-card-feature";
import { supportsTargetTemperatureCardFeature } from "../../card-features/hui-target-temperature-card-feature";
import { supportsToggleCardFeature } from "../../card-features/hui-toggle-card-feature";
@@ -50,6 +53,7 @@ import { supportsUpdateActionsCardFeature } from "../../card-features/hui-update
import { supportsVacuumCommandsCardFeature } from "../../card-features/hui-vacuum-commands-card-feature";
import { supportsValveOpenCloseCardFeature } from "../../card-features/hui-valve-open-close-card-feature";
import { supportsValvePositionCardFeature } from "../../card-features/hui-valve-position-card-feature";
import { supportsBarGaugeCardFeature } from "../../card-features/hui-bar-gauge-card-feature";
import { supportsWaterHeaterOperationModesCardFeature } from "../../card-features/hui-water-heater-operation-modes-card-feature";
import type {
LovelaceCardFeatureConfig,
@@ -67,6 +71,7 @@ type SupportsFeature = (
const UI_FEATURE_TYPES = [
"alarm-modes",
"area-controls",
"bar-gauge",
"button",
"climate-fan-modes",
"climate-hvac-modes",
@@ -78,7 +83,9 @@ const UI_FEATURE_TYPES = [
"cover-position",
"cover-tilt-position",
"cover-tilt",
"date-set",
"fan-direction",
"fan-oscillate",
"fan-preset-modes",
"fan-speed",
"humidifier-modes",
@@ -91,6 +98,7 @@ const UI_FEATURE_TYPES = [
"media-player-volume-slider",
"numeric-input",
"select-options",
"history-chart",
"target-humidity",
"target-temperature",
"toggle",
@@ -129,6 +137,7 @@ const SUPPORTS_FEATURE_TYPES: Record<
> = {
"alarm-modes": supportsAlarmModesCardFeature,
"area-controls": supportsAreaControlsCardFeature,
"bar-gauge": supportsBarGaugeCardFeature,
button: supportsButtonCardFeature,
"climate-fan-modes": supportsClimateFanModesCardFeature,
"climate-swing-modes": supportsClimateSwingModesCardFeature,
@@ -141,7 +150,9 @@ const SUPPORTS_FEATURE_TYPES: Record<
"cover-position": supportsCoverPositionCardFeature,
"cover-tilt-position": supportsCoverTiltPositionCardFeature,
"cover-tilt": supportsCoverTiltCardFeature,
"date-set": supportsDateSetCardFeature,
"fan-direction": supportsFanDirectionCardFeature,
"fan-oscillate": supportsFanOscilatteCardFeature,
"fan-preset-modes": supportsFanPresetModesCardFeature,
"fan-speed": supportsFanSpeedCardFeature,
"humidifier-modes": supportsHumidifierModesCardFeature,
@@ -154,6 +165,7 @@ const SUPPORTS_FEATURE_TYPES: Record<
"media-player-volume-slider": supportsMediaPlayerVolumeSliderCardFeature,
"numeric-input": supportsNumericInputCardFeature,
"select-options": supportsSelectOptionsCardFeature,
"history-chart": supportsHistoryChartCardFeature,
"target-humidity": supportsTargetHumidityCardFeature,
"target-temperature": supportsTargetTemperatureCardFeature,
toggle: supportsToggleCardFeature,

View File

@@ -36,6 +36,7 @@ const cardConfigStruct = assign(
time_format: optional(enums(Object.values(TimeFormat))),
time_zone: optional(enums(Object.keys(timezones))),
show_seconds: optional(boolean()),
no_background: optional(boolean()),
})
);
@@ -67,6 +68,7 @@ export class HuiClockCardEditor
},
},
{ name: "show_seconds", selector: { boolean: {} } },
{ name: "no_background", selector: { boolean: {} } },
{
name: "time_format",
selector: {
@@ -168,6 +170,10 @@ export class HuiClockCardEditor
return this.hass!.localize(
`ui.panel.lovelace.editor.card.clock.show_seconds`
);
case "no_background":
return this.hass!.localize(
`ui.panel.lovelace.editor.card.clock.no_background`
);
default:
return undefined;
}

View File

@@ -261,6 +261,9 @@ export class HuiConditionalCardEditor
justify-content: flex-end;
width: 100%;
}
.card-options {
align-items: center;
}
.gui-mode-button {
margin-right: auto;
margin-inline-end: auto;

View File

@@ -17,6 +17,7 @@ import {
type,
union,
} from "superstruct";
import memoizeOne from "memoize-one";
import type { HASSDomEvent } from "../../../../common/dom/fire_event";
import { fireEvent } from "../../../../common/dom/fire_event";
import { customType } from "../../../../common/structs/is-custom-type";
@@ -47,6 +48,8 @@ import type {
SubElementEditorConfig,
} from "../types";
import { configElementStyle } from "./config-elements-style";
import { computeShowHeaderToggle } from "../../cards/hui-entities-card";
import { processConfigEntities } from "../../common/process-config-entities";
const buttonEntitiesRowConfigStruct = object({
type: literal("button"),
@@ -209,6 +212,16 @@ export class HuiEntitiesCardEditor
this._configEntities = processEditorEntities(config.entities);
}
private _showHeaderToggle = memoizeOne((config: EntitiesCardConfig) => {
if (config.show_header_toggle !== undefined) {
return config.show_header_toggle;
}
return computeShowHeaderToggle(
config,
processConfigEntities(config.entities)
);
});
get _title(): string {
return this._config!.title || "";
}
@@ -264,7 +277,7 @@ export class HuiEntitiesCardEditor
)}
>
<ha-switch
.checked=${this._config!.show_header_toggle !== false}
.checked=${this._showHeaderToggle(this._config)}
.configValue=${"show_header_toggle"}
@change=${this._valueChanged}
></ha-switch>

View File

@@ -19,7 +19,6 @@ import type {
HaFormSchema,
SchemaUnion,
} from "../../../../components/ha-form/types";
import type { ActionConfig } from "../../../../data/lovelace/config/action";
import type { HomeAssistant } from "../../../../types";
import type { PictureGlanceCardConfig } from "../../cards/types";
import "../../components/hui-entity-editor";
@@ -124,19 +123,19 @@ export class HuiPictureGlanceCardEditor
},
},
},
{
name: "hold_action",
selector: {
ui_action: {
default_action: "more-info",
},
},
},
{
name: "",
type: "optional_actions",
flatten: true,
schema: [
{
name: "hold_action",
selector: {
ui_action: {
default_action: "none",
},
},
},
{
name: "double_tap_action",
selector: {
@@ -158,14 +157,6 @@ export class HuiPictureGlanceCardEditor
this._configEntities = processEditorEntities(config.entities);
}
get _tap_action(): ActionConfig {
return this._config!.tap_action || { action: "toggle" };
}
get _hold_action(): ActionConfig {
return this._config!.hold_action || { action: "more-info" };
}
protected render() {
if (!this.hass || !this._config) {
return nothing;

View File

@@ -261,18 +261,17 @@ export class HuiTileCardEditor
this._config.hide_state ?? false
);
const featuresSchema = this._featuresSchema(
this.hass.localize,
this._config.vertical ?? false
);
const vertical = this._config.vertical ?? false;
const featuresSchema = this._featuresSchema(this.hass.localize, vertical);
const data = {
...this._config,
content_layout: this._config.vertical ? "vertical" : "horizontal",
content_layout: vertical ? "vertical" : "horizontal",
};
// Default features position to bottom and force it to bottom in vertical mode
if (!data.features_position || data.vertical) {
if (!data.features_position || vertical) {
data.features_position = "bottom";
}

View File

@@ -3,6 +3,11 @@ import { html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { assert, assign, boolean, object, optional, string } from "superstruct";
import { mdiGestureTap } from "@mdi/js";
import {
ITEM_TAP_ACTION_EDIT,
ITEM_TAP_ACTION_TOGGLE,
} from "../../cards/hui-todo-list-card";
import { isComponentLoaded } from "../../../../common/config/is_component_loaded";
import { fireEvent } from "../../../../common/dom/fire_event";
import "../../../../components/ha-alert";
@@ -17,6 +22,8 @@ import { configElementStyle } from "./config-elements-style";
import { TodoListEntityFeature, TodoSortMode } from "../../../../data/todo";
import { supportsFeature } from "../../../../common/entity/supports-feature";
const ITEM_TAP_ACTIONS = [ITEM_TAP_ACTION_EDIT, ITEM_TAP_ACTION_TOGGLE];
const cardConfigStruct = assign(
baseLovelaceCardConfig,
object({
@@ -26,6 +33,7 @@ const cardConfigStruct = assign(
hide_completed: optional(boolean()),
hide_create: optional(boolean()),
display_order: optional(string()),
item_tap_action: optional(string()),
})
);
@@ -64,11 +72,35 @@ export class HuiTodoListEditor
},
},
},
{
name: "interactions",
type: "expandable",
flatten: true,
iconPath: mdiGestureTap,
schema: [
{
name: "item_tap_action",
required: true,
selector: {
select: {
mode: "dropdown",
options: Object.values(ITEM_TAP_ACTIONS).map((action) => ({
value: action,
label: localize(
`ui.panel.lovelace.editor.card.todo-list.actions.${action}`
),
})),
},
},
},
],
},
] as const
);
private _data = memoizeOne((config) => ({
display_order: "none",
item_tap_action: "edit",
...config,
}));
@@ -106,7 +138,10 @@ export class HuiTodoListEditor
}
private _valueChanged(ev: CustomEvent): void {
const config = ev.detail.value;
const config = { ...ev.detail.value };
if (config.item_tap_action === ITEM_TAP_ACTION_EDIT) {
delete config.item_tap_action;
}
fireEvent(this, "config-changed", { config });
}
@@ -130,6 +165,7 @@ export class HuiTodoListEditor
case "hide_completed":
case "hide_create":
case "display_order":
case "item_tap_action":
return this.hass!.localize(
`ui.panel.lovelace.editor.card.todo-list.${schema.name}`
);

View File

@@ -211,6 +211,7 @@ export class HuiWeatherForecastCardEditor
? ([
{
name: "forecast",
default: "show_both",
selector: {
select: {
options: [

View File

@@ -33,6 +33,7 @@ const STRATEGIES: Record<LovelaceStrategyConfigType, Record<string, any>> = {
map: () => import("./map/map-dashboard-strategy"),
iframe: () => import("./iframe/iframe-dashboard-strategy"),
areas: () => import("./areas/areas-dashboard-strategy"),
overview: () => import("./overview/overview-dashboard-strategy"),
},
view: {
"original-states": () =>
@@ -42,6 +43,15 @@ const STRATEGIES: Record<LovelaceStrategyConfigType, Record<string, any>> = {
iframe: () => import("./iframe/iframe-view-strategy"),
area: () => import("./areas/area-view-strategy"),
"areas-overview": () => import("./areas/areas-overview-view-strategy"),
"overview-home": () => import("./overview/overview-home-view-strategy"),
"overview-lights": () => import("./overview/overview-lights-view-strategy"),
"overview-climate": () =>
import("./overview/overview-climate-view-strategy"),
"overview-security": () =>
import("./overview/overview-security-view-strategy"),
"overview-media-players": () =>
import("./overview/overview-media-players-view-strategy"),
"overview-area": () => import("./overview/overview-area-view-strategy"),
},
section: {},
};

View File

@@ -0,0 +1,67 @@
import { html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../../../../common/dom/fire_event";
import "../../../../../components/entity/ha-entities-picker";
import type { HomeAssistant } from "../../../../../types";
import type { LovelaceStrategyEditor } from "../../types";
import type { OverviewDashboardStrategyConfig } from "../overview-dashboard-strategy";
@customElement("hui-overview-dashboard-strategy-editor")
export class HuiOverviewDashboardStrategyEditor
extends LitElement
implements LovelaceStrategyEditor
{
@property({ attribute: false }) public hass?: HomeAssistant;
@state()
private _config?: OverviewDashboardStrategyConfig;
public setConfig(config: OverviewDashboardStrategyConfig): void {
this._config = config;
}
protected render() {
if (!this.hass || !this._config) {
return nothing;
}
return html`
<ha-entities-picker
.hass=${this.hass}
.value=${this._config.favorite_entities || []}
label="Favorite entities"
placeholder="Add favorite entity"
reorder
allow-custom-entity
@value-changed=${this._valueChanged}
>
</ha-entities-picker>
`;
}
private _valueChanged(ev: CustomEvent): void {
ev.stopPropagation();
if (!this._config || !this.hass) {
return;
}
const favoriteEntities = ev.detail.value as string[];
const config: OverviewDashboardStrategyConfig = {
...this._config,
favorite_entities: favoriteEntities,
};
if (config.favorite_entities?.length === 0) {
delete config.favorite_entities;
}
fireEvent(this, "config-changed", { config });
}
}
declare global {
interface HTMLElementTagNameMap {
"hui-overview-dashboard-strategy-editor": HuiOverviewDashboardStrategyEditor;
}
}

View File

@@ -0,0 +1,40 @@
import type { AreaRegistryEntry } from "../../../../../data/area_registry";
import type { FloorRegistryEntry } from "../../../../../data/floor_registry";
interface HomeStructure {
floors: {
id: string;
areas: string[];
}[];
areas: string[];
}
export const getHomeStructure = (
floors: FloorRegistryEntry[],
areas: AreaRegistryEntry[]
): HomeStructure => {
const floorAreas = new Map<string, string[]>();
const unassignedAreas: string[] = [];
for (const area of areas) {
if (area.floor_id) {
if (!floorAreas.has(area.floor_id)) {
floorAreas.set(area.floor_id, []);
}
floorAreas.get(area.floor_id)!.push(area.area_id);
} else {
unassignedAreas.push(area.area_id);
}
}
const homeStructure: HomeStructure = {
floors: floors.map((floor) => ({
id: floor.floor_id,
areas: floorAreas.get(floor.floor_id) || [],
})),
areas: unassignedAreas,
};
return homeStructure;
};

View File

@@ -0,0 +1,95 @@
import type {
EntityFilter,
EntityFilterFunc,
} from "../../../../../common/entity/entity_filter";
export const OVERVIEW_SUMMARIES = [
"lights",
"climate",
"security",
"media_players",
] as const;
export type OverviewSummaries = (typeof OVERVIEW_SUMMARIES)[number];
export const OVERVIEW_SUMMARIES_ICONS: Record<OverviewSummaries, string> = {
lights: "mdi:lamps",
climate: "mdi:home-thermometer",
security: "mdi:security",
media_players: "mdi:multimedia",
};
export const OVERVIEW_SUMMARIES_FILTERS: Record<
OverviewSummaries,
EntityFilter[]
> = {
lights: [{ domain: "light", entity_category: "none" }],
climate: [
{ domain: "climate", entity_category: "none" },
{ domain: "humidifier", entity_category: "none" },
{ domain: "fan", entity_category: "none" },
{ domain: "water_heater", entity_category: "none" },
{
domain: "cover",
device_class: [
"awning",
"blind",
"curtain",
"shade",
"shutter",
"window",
"none",
],
entity_category: "none",
},
{
domain: "binary_sensor",
device_class: ["window"],
entity_category: "none",
},
],
security: [
{
domain: "alarm_control_panel",
entity_category: "none",
},
{
domain: "lock",
entity_category: "none",
},
{
domain: "camera",
entity_category: "none",
},
{
domain: "cover",
device_class: ["door", "garage", "gate"],
entity_category: "none",
},
{
domain: "binary_sensor",
device_class: ["door", "garage_door"],
entity_category: "none",
},
],
media_players: [{ domain: "media_player", entity_category: "none" }],
};
export const findEntities = (
entities: string[],
filters: EntityFilterFunc[]
): string[] => {
const seen = new Set<string>();
const results: string[] = [];
for (const filter of filters) {
for (const entity of entities) {
if (filter(entity) && !seen.has(entity)) {
seen.add(entity);
results.push(entity);
}
}
}
return results;
};

View File

@@ -0,0 +1,315 @@
import { ReactiveElement } from "lit";
import { customElement } from "lit/decorators";
import { computeDeviceName } from "../../../../common/entity/compute_device_name";
import { computeEntityName } from "../../../../common/entity/compute_entity_name";
import { getEntityContext } from "../../../../common/entity/context/get_entity_context";
import { generateEntityFilter } from "../../../../common/entity/entity_filter";
import { clamp } from "../../../../common/number/clamp";
import type { LovelaceBadgeConfig } from "../../../../data/lovelace/config/badge";
import type { LovelaceCardConfig } from "../../../../data/lovelace/config/card";
import type { LovelaceSectionRawConfig } from "../../../../data/lovelace/config/section";
import type { LovelaceViewConfig } from "../../../../data/lovelace/config/view";
import type { HomeAssistant } from "../../../../types";
import type { HeadingCardConfig } from "../../cards/types";
import { computeAreaTileCardConfig } from "../areas/helpers/areas-strategy-helper";
import {
findEntities,
OVERVIEW_SUMMARIES,
OVERVIEW_SUMMARIES_FILTERS,
OVERVIEW_SUMMARIES_ICONS,
type OverviewSummaries,
} from "./helpers/overview-summaries";
export interface OverviewAreaViewStrategyConfig {
type: "overview-area";
area?: string;
}
const computeHeadingCard = (
heading: string,
icon: string,
navigation_path?: string
): LovelaceCardConfig =>
({
type: "heading",
heading: heading,
icon: icon,
tap_action: navigation_path
? {
action: "navigate",
navigation_path,
}
: undefined,
}) satisfies HeadingCardConfig;
@customElement("overview-area-view-strategy")
export class OverviewAreaViewStrategy extends ReactiveElement {
static async generate(
config: OverviewAreaViewStrategyConfig,
hass: HomeAssistant
): Promise<LovelaceViewConfig> {
if (!config.area) {
throw new Error("Area not provided");
}
const area = hass.areas[config.area];
if (!area) {
throw new Error("Unknown area");
}
const sections: LovelaceSectionRawConfig[] = [];
const badges: LovelaceBadgeConfig[] = [];
if (area.temperature_entity_id) {
badges.push({
entity: area.temperature_entity_id,
type: "entity",
color: "red",
});
}
if (area.humidity_entity_id) {
badges.push({
entity: area.humidity_entity_id,
type: "entity",
color: "indigo",
});
}
const computeTileCard = computeAreaTileCardConfig(hass, area.name, true);
const areaFilter = generateEntityFilter(hass, {
area: config.area,
});
const allEntities = Object.keys(hass.states);
const areaEntities = allEntities.filter(areaFilter);
const entitiesBySummary = OVERVIEW_SUMMARIES.reduce(
(acc, summary) => {
const summariesFilters = OVERVIEW_SUMMARIES_FILTERS[summary];
const filterFunctions = summariesFilters.map((filter) =>
generateEntityFilter(hass, filter)
);
acc[summary] = findEntities(areaEntities, filterFunctions);
return acc;
},
{} as Record<OverviewSummaries, string[]>
);
const {
lights,
climate,
security,
media_players: mediaPlayers,
} = entitiesBySummary;
if (lights.length > 0) {
sections.push({
type: "grid",
cards: [
computeHeadingCard(
"Lights",
OVERVIEW_SUMMARIES_ICONS.lights,
"lights"
),
...lights.map(computeTileCard),
],
});
}
if (climate.length > 0) {
sections.push({
type: "grid",
cards: [
computeHeadingCard(
"Climate",
OVERVIEW_SUMMARIES_ICONS.climate,
"climate"
),
...climate.map(computeTileCard),
],
});
}
if (security.length > 0) {
sections.push({
type: "grid",
cards: [
computeHeadingCard(
"Security",
OVERVIEW_SUMMARIES_ICONS.security,
"security"
),
...security.map(computeTileCard),
],
});
}
if (mediaPlayers.length > 0) {
sections.push({
type: "grid",
cards: [
computeHeadingCard(
"Media players",
OVERVIEW_SUMMARIES_ICONS.media_players,
"media-players"
),
...mediaPlayers.map(computeTileCard),
],
});
}
const deviceSections: LovelaceSectionRawConfig[] = [];
const summaryEntities = Object.values(entitiesBySummary).flat();
// Rest of entities grouped by device
const otherEntities = areaEntities.filter(
(entityId) => !summaryEntities.includes(entityId)
);
const entitiesByDevice: Record<string, string[]> = {};
const unassignedEntities: string[] = [];
for (const entityId of otherEntities) {
const stateObj = hass.states[entityId];
if (!stateObj) continue;
const { device } = getEntityContext(stateObj, hass);
if (!device) {
unassignedEntities.push(entityId);
continue;
}
if (!(device.id in entitiesByDevice)) {
entitiesByDevice[device.id] = [];
}
entitiesByDevice[device.id].push(entityId);
}
const otherDeviceEntities = Object.entries(entitiesByDevice).map(
([deviceId, entities]) => ({
device_id: deviceId,
entities: entities,
})
);
if (unassignedEntities.length > 0) {
otherDeviceEntities.push({
device_id: "unassigned",
entities: unassignedEntities,
});
}
const batteryFilter = generateEntityFilter(hass, {
domain: "sensor",
device_class: "battery",
});
const energyFilter = generateEntityFilter(hass, {
domain: "sensor",
device_class: ["energy", "power"],
});
const primaryFilter = generateEntityFilter(hass, {
entity_category: "none",
});
for (const deviceEntities of otherDeviceEntities) {
if (deviceEntities.entities.length === 0) continue;
const batteryEntities = deviceEntities.entities.filter((e) =>
batteryFilter(e)
);
const entities = deviceEntities.entities.filter(
(e) => !batteryFilter(e) && !energyFilter(e) && primaryFilter(e)
);
if (entities.length === 0) {
continue;
}
const deviceId = deviceEntities.device_id;
const device = hass.devices[deviceId];
let heading = "";
if (device) {
heading = computeDeviceName(device) || "Unnamed device";
} else {
heading = "Others";
}
deviceSections.push({
type: "grid",
cards: [
{
type: "heading",
heading: heading,
tap_action: device
? {
action: "navigate",
navigation_path: `/config/devices/device/${device.id}`,
}
: undefined,
badges: [
...batteryEntities.slice(0, 1).map((e) => ({
entity: e,
type: "entity",
tap_action: {
action: "more-info",
},
})),
],
} satisfies HeadingCardConfig,
...entities.map((e) => {
const stateObj = hass.states[e];
return {
...computeTileCard(e),
name:
computeEntityName(stateObj, hass) ||
(device ? computeDeviceName(device) : ""),
};
}),
],
});
}
if (deviceSections.length > 0) {
sections.push({
type: "grid",
column_span: 3,
cards: [
{
type: "heading",
heading_style: "subtitle",
heading: "",
} satisfies HeadingCardConfig,
],
} satisfies LovelaceSectionRawConfig);
sections.push(...deviceSections);
}
// Allow between 2 and 3 columns (the max should be set to define the width of the header)
const maxColumns = clamp(sections.length, 2, 3);
// Take the full width if there is only one section to avoid narrow header on desktop
if (sections.length === 1) {
sections[0].column_span = 2;
}
return {
type: "sections",
header: {
badges_position: "bottom",
},
max_columns: maxColumns,
sections: sections,
badges: badges,
};
}
}
declare global {
interface HTMLElementTagNameMap {
"overview-area-view-strategy": OverviewAreaViewStrategy;
}
}

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