mirror of
https://github.com/home-assistant/frontend.git
synced 2026-04-23 02:52:47 +00:00
Compare commits
69 Commits
badges-flo
...
fix-legacy
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
89fff3bc69 | ||
|
|
93110b1d70 | ||
|
|
541c112159 | ||
|
|
84382fdf0d | ||
|
|
591057b80d | ||
|
|
d220725e5b | ||
|
|
fdb4de9aa8 | ||
|
|
c3b768c111 | ||
|
|
7d9874adfa | ||
|
|
64ad41a533 | ||
|
|
520739dd0e | ||
|
|
30f70e179a | ||
|
|
e66564ff65 | ||
|
|
70ac14ed52 | ||
|
|
e0d881ff53 | ||
|
|
61c0b7394e | ||
|
|
34b2509a76 | ||
|
|
7d03ef6dfc | ||
|
|
96b59c6171 | ||
|
|
7691d2ca4a | ||
|
|
da1c2bdee4 | ||
|
|
509443fbb2 | ||
|
|
07992286b5 | ||
|
|
cf7274b0ba | ||
|
|
501c72d203 | ||
|
|
a0ad488579 | ||
|
|
ead2d1296f | ||
|
|
5ba5408e78 | ||
|
|
eecca1fa55 | ||
|
|
f2ba0fca73 | ||
|
|
fc448ab3a7 | ||
|
|
9269c1ff0a | ||
|
|
b7dcbd559e | ||
|
|
80e0c098f8 | ||
|
|
364c793ee6 | ||
|
|
99f36e1aad | ||
|
|
25dcaa4eb8 | ||
|
|
d92f7e14b4 | ||
|
|
2c1bf3369d | ||
|
|
81d57cf43c | ||
|
|
09053533ff | ||
|
|
7df61f239f | ||
|
|
f89eace462 | ||
|
|
52956eefc6 | ||
|
|
1fbbeba083 | ||
|
|
4e0d2e290a | ||
|
|
641773d5c4 | ||
|
|
3b53867216 | ||
|
|
7ea936088c | ||
|
|
4281240383 | ||
|
|
6b6203986d | ||
|
|
6997ffa580 | ||
|
|
2d2558db40 | ||
|
|
039fc45532 | ||
|
|
209e6f8def | ||
|
|
f6a19eb6c4 | ||
|
|
ceb9967deb | ||
|
|
b2015465fb | ||
|
|
8e4c99049f | ||
|
|
5a5b8c0bbd | ||
|
|
b60d189a69 | ||
|
|
19ed00c677 | ||
|
|
b92775ea2d | ||
|
|
b5bacf85dd | ||
|
|
8f4fe9ba4e | ||
|
|
9179218336 | ||
|
|
274ec50dbd | ||
|
|
2629881a18 | ||
|
|
d7f143a65a |
2
.github/PULL_REQUEST_TEMPLATE.md
vendored
2
.github/PULL_REQUEST_TEMPLATE.md
vendored
@@ -69,7 +69,6 @@
|
||||
- [ ] I understand the code I am submitting and can explain how it works.
|
||||
- [ ] The code change is tested and works locally.
|
||||
- [ ] There is no commented out code in this PR.
|
||||
- [ ] I have followed the [development checklist][dev-checklist]
|
||||
- [ ] I have followed the [perfect PR recommendations][perfect-pr]
|
||||
- [ ] Any generated code has been carefully reviewed for correctness and compliance with project standards.
|
||||
|
||||
@@ -105,6 +104,5 @@ To help with the load of incoming pull requests:
|
||||
|
||||
Below, some useful links you could explore:
|
||||
-->
|
||||
[dev-checklist]: https://developers.home-assistant.io/docs/development_checklist/
|
||||
[docs-repository]: https://github.com/home-assistant/home-assistant.io
|
||||
[perfect-pr]: https://developers.home-assistant.io/docs/review-process/#creating-the-perfect-pr
|
||||
|
||||
1
.github/dependabot.yml
vendored
1
.github/dependabot.yml
vendored
@@ -6,7 +6,6 @@ updates:
|
||||
interval: weekly
|
||||
time: "06:00"
|
||||
cooldown:
|
||||
default-days-before-reopen: 30
|
||||
default-days: 7
|
||||
open-pull-requests-limit: 10
|
||||
labels:
|
||||
|
||||
4
.github/workflows/restrict-task-creation.yml
vendored
4
.github/workflows/restrict-task-creation.yml
vendored
@@ -22,7 +22,7 @@ jobs:
|
||||
|| github.event.issue.type.name == 'Opportunity'
|
||||
steps:
|
||||
- name: Add no-stale label
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
await github.rest.issues.addLabels({
|
||||
@@ -41,7 +41,7 @@ jobs:
|
||||
if: github.event.issue.type.name == 'Task'
|
||||
steps:
|
||||
- name: Check if user is authorized
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
with:
|
||||
script: |
|
||||
const issueAuthor = context.payload.issue.user.login;
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -8,4 +8,4 @@ enableGlobalCache: false
|
||||
|
||||
nodeLinker: node-modules
|
||||
|
||||
yarnPath: .yarn/releases/yarn-4.13.0.cjs
|
||||
yarnPath: .yarn/releases/yarn-4.14.1.cjs
|
||||
|
||||
@@ -3,37 +3,73 @@ title: Switch / Toggle
|
||||
---
|
||||
|
||||
<style>
|
||||
ha-switch {
|
||||
display: block;
|
||||
.wrapper {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
||||
|
||||
# Switch `<ha-switch>`
|
||||
|
||||
A toggle switch can represent two states: on and off.
|
||||
A toggle switch representing two states: on and off.
|
||||
|
||||
## Examples
|
||||
## Implementation
|
||||
|
||||
Switch in on state
|
||||
### Example usage
|
||||
|
||||
<div class="wrapper">
|
||||
<ha-switch checked></ha-switch>
|
||||
<ha-switch></ha-switch>
|
||||
<ha-switch disabled></ha-switch>
|
||||
<ha-switch disabled checked></ha-switch>
|
||||
</div>
|
||||
|
||||
```html
|
||||
<ha-switch checked></ha-switch>
|
||||
|
||||
Switch in off state
|
||||
<ha-switch></ha-switch>
|
||||
|
||||
Disabled switch
|
||||
<ha-switch disabled></ha-switch>
|
||||
|
||||
## CSS variables
|
||||
<ha-switch disabled checked></ha-switch>
|
||||
```
|
||||
|
||||
For the switch / toggle there are always two variables, one for the on / checked state and one for the off / unchecked state.
|
||||
### API
|
||||
|
||||
The track element (background rounded rectangle that the round circular handle travels on) is set to being half transparent, so the final color will also be impacted by the color behind the track.
|
||||
This component is based on the webawesome switch component.
|
||||
Check the [webawesome documentation](https://webawesome.com/docs/components/switch/) for more details.
|
||||
|
||||
`switch-checked-color` / `switch-unchecked-color`
|
||||
Set both the color of the round handle and the track behind it. If you want to control them separately, use the variables below instead.
|
||||
**Properties/Attributes**
|
||||
|
||||
`switch-checked-button-color` / `switch-unchecked-button-color`
|
||||
Color of the round handle
|
||||
| Name | Type | Default | Description |
|
||||
| -------- | ------- | ------- | ------------------------------------------------------------------------------------------------------------------- |
|
||||
| checked | Boolean | false | The checked state of the switch. |
|
||||
| disabled | Boolean | false | Disables the switch and prevents user interaction. |
|
||||
| required | Boolean | false | Makes the switch a required field. |
|
||||
| haptic | Boolean | false | Enables haptic vibration on toggle. Only use when the new state is applied immediately (not when save is required). |
|
||||
|
||||
`switch-checked-track-color` / `switch-unchecked-track-color`
|
||||
Color of the track behind the round handle
|
||||
**CSS Custom Properties**
|
||||
|
||||
- `--ha-switch-size` - The size of the switch track height. Defaults to `24px`.
|
||||
- `--ha-switch-thumb-size` - The size of the thumb. Defaults to `18px`.
|
||||
- `--ha-switch-width` - The width of the switch track. Defaults to `48px`.
|
||||
- `--ha-switch-thumb-box-shadow` - The box shadow of the thumb. Defaults to `var(--ha-box-shadow-s)`.
|
||||
- `--ha-switch-background-color` - Background color of the unchecked track.
|
||||
- `--ha-switch-thumb-background-color` - Background color of the unchecked thumb.
|
||||
- `--ha-switch-background-color-hover` - Background color of the unchecked track on hover.
|
||||
- `--ha-switch-thumb-background-color-hover` - Background color of the unchecked thumb on hover.
|
||||
- `--ha-switch-border-color` - Border color of the unchecked track.
|
||||
- `--ha-switch-thumb-border-color` - Border color of the unchecked thumb.
|
||||
- `--ha-switch-thumb-border-color-hover` - Border color of the unchecked thumb on hover.
|
||||
- `--ha-switch-checked-background-color` - Background color of the checked track.
|
||||
- `--ha-switch-checked-thumb-background-color` - Background color of the checked thumb.
|
||||
- `--ha-switch-checked-background-color-hover` - Background color of the checked track on hover.
|
||||
- `--ha-switch-checked-thumb-background-color-hover` - Background color of the checked thumb on hover.
|
||||
- `--ha-switch-checked-border-color` - Border color of the checked track.
|
||||
- `--ha-switch-checked-thumb-border-color` - Border color of the checked thumb.
|
||||
- `--ha-switch-checked-border-color-hover` - Border color of the checked track on hover.
|
||||
- `--ha-switch-checked-thumb-border-color-hover` - Border color of the checked thumb on hover.
|
||||
- `--ha-switch-disabled-opacity` - Opacity of the switch when disabled. Defaults to `0.2`.
|
||||
- `--ha-switch-required-marker` - The marker shown after the label for required fields. Defaults to `"*"`.
|
||||
- `--ha-switch-required-marker-offset` - Offset of the required marker. Defaults to `0.1rem`.
|
||||
|
||||
@@ -1 +1,95 @@
|
||||
import type { TemplateResult } from "lit";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { applyThemesOnElement } from "../../../../src/common/dom/apply_themes_on_element";
|
||||
import "../../../../src/components/ha-card";
|
||||
import "../../../../src/components/ha-switch";
|
||||
import type { HomeAssistant } from "../../../../src/types";
|
||||
|
||||
@customElement("demo-components-ha-switch")
|
||||
export class DemoHaSwitch extends LitElement {
|
||||
@property({ attribute: false }) hass!: HomeAssistant;
|
||||
|
||||
protected render(): TemplateResult {
|
||||
return html`
|
||||
${["light", "dark"].map(
|
||||
(mode) => html`
|
||||
<div class=${mode}>
|
||||
<ha-card header="ha-switch ${mode}">
|
||||
<div class="card-content">
|
||||
<div class="row">
|
||||
<span>Unchecked</span>
|
||||
<ha-switch></ha-switch>
|
||||
</div>
|
||||
<div class="row">
|
||||
<span>Checked</span>
|
||||
<ha-switch checked></ha-switch>
|
||||
</div>
|
||||
<div class="row">
|
||||
<span>Disabled</span>
|
||||
<ha-switch disabled></ha-switch>
|
||||
</div>
|
||||
<div class="row">
|
||||
<span>Disabled checked</span>
|
||||
<ha-switch disabled checked></ha-switch>
|
||||
</div>
|
||||
</div>
|
||||
</ha-card>
|
||||
</div>
|
||||
`
|
||||
)}
|
||||
`;
|
||||
}
|
||||
|
||||
firstUpdated(changedProps) {
|
||||
super.firstUpdated(changedProps);
|
||||
applyThemesOnElement(
|
||||
this.shadowRoot!.querySelector(".dark"),
|
||||
{
|
||||
default_theme: "default",
|
||||
default_dark_theme: "default",
|
||||
themes: {},
|
||||
darkMode: true,
|
||||
theme: "default",
|
||||
},
|
||||
undefined,
|
||||
undefined,
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
.dark,
|
||||
.light {
|
||||
display: block;
|
||||
background-color: var(--primary-background-color);
|
||||
padding: 0 50px;
|
||||
margin: 16px;
|
||||
border-radius: var(--ha-border-radius-md);
|
||||
}
|
||||
ha-card {
|
||||
margin: 24px auto;
|
||||
}
|
||||
.card-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--ha-space-4);
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: var(--ha-space-4);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"demo-components-ha-switch": DemoHaSwitch;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,6 +149,38 @@ const CONFIGS = [
|
||||
max: 1.9
|
||||
unit: GBP/h`,
|
||||
},
|
||||
{
|
||||
heading: "A lot of segments",
|
||||
config: `
|
||||
- type: gauge
|
||||
needle: true
|
||||
name: Percent gauge
|
||||
entity: sensor.brightness_high
|
||||
unit: "%"
|
||||
min: 0
|
||||
max: 100
|
||||
segments:
|
||||
- from: 0
|
||||
color: "#db4437"
|
||||
- from: 10
|
||||
color: "#cc4d39"
|
||||
- from: 20
|
||||
color: "#bd563a"
|
||||
- from: 30
|
||||
color: "#ad603c"
|
||||
- from: 40
|
||||
color: "#9e693d"
|
||||
- from: 50
|
||||
color: "#8f723f"
|
||||
- from: 60
|
||||
color: "#807b41"
|
||||
- from: 70
|
||||
color: "#718442"
|
||||
- from: 80
|
||||
color: "#618e44"
|
||||
- from: 90
|
||||
color: "#43a047"`,
|
||||
},
|
||||
];
|
||||
|
||||
@customElement("demo-lovelace-gauge-card")
|
||||
|
||||
3
gallery/src/pages/more-info/lawn-mower.markdown
Normal file
3
gallery/src/pages/more-info/lawn-mower.markdown
Normal file
@@ -0,0 +1,3 @@
|
||||
---
|
||||
title: Lawn mower
|
||||
---
|
||||
98
gallery/src/pages/more-info/lawn-mower.ts
Normal file
98
gallery/src/pages/more-info/lawn-mower.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
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 type { MockHomeAssistant } from "../../../../src/fake_data/provide_hass";
|
||||
import { provideHass } from "../../../../src/fake_data/provide_hass";
|
||||
import "../../components/demo-more-infos";
|
||||
import { LawnMowerEntityFeature } from "../../../../src/data/lawn_mower";
|
||||
|
||||
const ALL_FEATURES =
|
||||
LawnMowerEntityFeature.START_MOWING +
|
||||
LawnMowerEntityFeature.PAUSE +
|
||||
LawnMowerEntityFeature.DOCK;
|
||||
|
||||
const ENTITIES = [
|
||||
{
|
||||
entity_id: "lawn_mower.full_featured",
|
||||
state: "docked",
|
||||
attributes: {
|
||||
friendly_name: "Full featured mower",
|
||||
supported_features: ALL_FEATURES,
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "lawn_mower.mowing",
|
||||
state: "mowing",
|
||||
attributes: {
|
||||
friendly_name: "Mowing",
|
||||
supported_features: ALL_FEATURES,
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "lawn_mower.returning",
|
||||
state: "returning",
|
||||
attributes: {
|
||||
friendly_name: "Returning",
|
||||
supported_features:
|
||||
LawnMowerEntityFeature.START_MOWING +
|
||||
LawnMowerEntityFeature.PAUSE +
|
||||
LawnMowerEntityFeature.DOCK,
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "lawn_mower.paused",
|
||||
state: "paused",
|
||||
attributes: {
|
||||
friendly_name: "Paused",
|
||||
supported_features: ALL_FEATURES,
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "lawn_mower.error",
|
||||
state: "error",
|
||||
attributes: {
|
||||
friendly_name: "Error",
|
||||
supported_features:
|
||||
LawnMowerEntityFeature.START_MOWING + LawnMowerEntityFeature.DOCK,
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "lawn_mower.basic",
|
||||
state: "docked",
|
||||
attributes: {
|
||||
friendly_name: "Basic mower",
|
||||
supported_features: LawnMowerEntityFeature.START_MOWING,
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@customElement("demo-more-info-lawn-mower")
|
||||
class DemoMoreInfoLawnMower 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.entity_id)}
|
||||
></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-lawn-mower": DemoMoreInfoLawnMower;
|
||||
}
|
||||
}
|
||||
@@ -8,18 +8,101 @@ import { provideHass } from "../../../../src/fake_data/provide_hass";
|
||||
import "../../components/demo-more-infos";
|
||||
import { VacuumEntityFeature } from "../../../../src/data/vacuum";
|
||||
|
||||
const ALL_FEATURES =
|
||||
VacuumEntityFeature.STATE +
|
||||
VacuumEntityFeature.START +
|
||||
VacuumEntityFeature.PAUSE +
|
||||
VacuumEntityFeature.STOP +
|
||||
VacuumEntityFeature.RETURN_HOME +
|
||||
VacuumEntityFeature.FAN_SPEED +
|
||||
VacuumEntityFeature.BATTERY +
|
||||
VacuumEntityFeature.STATUS +
|
||||
VacuumEntityFeature.LOCATE +
|
||||
VacuumEntityFeature.CLEAN_SPOT +
|
||||
VacuumEntityFeature.CLEAN_AREA;
|
||||
|
||||
const ENTITIES = [
|
||||
{
|
||||
entity_id: "vacuum.first_floor_vacuum",
|
||||
entity_id: "vacuum.full_featured",
|
||||
state: "docked",
|
||||
attributes: {
|
||||
friendly_name: "First floor vacuum",
|
||||
friendly_name: "Full featured vacuum",
|
||||
supported_features: ALL_FEATURES,
|
||||
battery_level: 85,
|
||||
battery_icon: "mdi:battery-80",
|
||||
fan_speed: "balanced",
|
||||
fan_speed_list: ["silent", "standard", "balanced", "turbo", "max"],
|
||||
status: "Charged",
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "vacuum.cleaning_vacuum",
|
||||
state: "cleaning",
|
||||
attributes: {
|
||||
friendly_name: "Cleaning vacuum",
|
||||
supported_features: ALL_FEATURES,
|
||||
battery_level: 62,
|
||||
battery_icon: "mdi:battery-60",
|
||||
fan_speed: "turbo",
|
||||
fan_speed_list: ["silent", "standard", "balanced", "turbo", "max"],
|
||||
status: "Cleaning bedroom",
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "vacuum.returning_vacuum",
|
||||
state: "returning",
|
||||
attributes: {
|
||||
friendly_name: "Returning vacuum",
|
||||
supported_features:
|
||||
VacuumEntityFeature.STATE +
|
||||
VacuumEntityFeature.START +
|
||||
VacuumEntityFeature.PAUSE +
|
||||
VacuumEntityFeature.STOP +
|
||||
VacuumEntityFeature.RETURN_HOME +
|
||||
VacuumEntityFeature.BATTERY,
|
||||
battery_level: 23,
|
||||
battery_icon: "mdi:battery-20",
|
||||
status: "Returning to dock",
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "vacuum.error_vacuum",
|
||||
state: "error",
|
||||
attributes: {
|
||||
friendly_name: "Error vacuum",
|
||||
supported_features:
|
||||
VacuumEntityFeature.STATE +
|
||||
VacuumEntityFeature.START +
|
||||
VacuumEntityFeature.STOP +
|
||||
VacuumEntityFeature.RETURN_HOME +
|
||||
VacuumEntityFeature.LOCATE,
|
||||
status: "Stuck on obstacle",
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "vacuum.basic_vacuum",
|
||||
state: "docked",
|
||||
attributes: {
|
||||
friendly_name: "Basic vacuum",
|
||||
supported_features:
|
||||
VacuumEntityFeature.START +
|
||||
VacuumEntityFeature.STOP +
|
||||
VacuumEntityFeature.RETURN_HOME,
|
||||
},
|
||||
},
|
||||
{
|
||||
entity_id: "vacuum.paused_vacuum",
|
||||
state: "paused",
|
||||
attributes: {
|
||||
friendly_name: "Paused vacuum",
|
||||
supported_features: ALL_FEATURES,
|
||||
battery_level: 45,
|
||||
battery_icon: "mdi:battery-40",
|
||||
fan_speed: "standard",
|
||||
fan_speed_list: ["silent", "standard", "balanced", "turbo", "max"],
|
||||
status: "Paused",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
@customElement("demo-more-info-vacuum")
|
||||
|
||||
49
package.json
49
package.json
@@ -30,22 +30,23 @@
|
||||
"@braintree/sanitize-url": "7.1.2",
|
||||
"@codemirror/autocomplete": "6.20.1",
|
||||
"@codemirror/commands": "6.10.3",
|
||||
"@codemirror/lang-jinja": "6.0.1",
|
||||
"@codemirror/lang-yaml": "6.1.3",
|
||||
"@codemirror/language": "6.12.3",
|
||||
"@codemirror/legacy-modes": "6.5.2",
|
||||
"@codemirror/search": "6.6.0",
|
||||
"@codemirror/state": "6.6.0",
|
||||
"@codemirror/view": "6.41.0",
|
||||
"@date-fns/tz": "1.4.1",
|
||||
"@egjs/hammerjs": "2.0.17",
|
||||
"@formatjs/intl-datetimeformat": "7.3.1",
|
||||
"@formatjs/intl-displaynames": "7.3.1",
|
||||
"@formatjs/intl-durationformat": "0.10.3",
|
||||
"@formatjs/intl-getcanonicallocales": "3.2.2",
|
||||
"@formatjs/intl-listformat": "8.3.1",
|
||||
"@formatjs/intl-locale": "5.3.1",
|
||||
"@formatjs/intl-numberformat": "9.3.1",
|
||||
"@formatjs/intl-pluralrules": "6.3.1",
|
||||
"@formatjs/intl-relativetimeformat": "12.3.1",
|
||||
"@formatjs/intl-datetimeformat": "7.3.2",
|
||||
"@formatjs/intl-displaynames": "7.3.2",
|
||||
"@formatjs/intl-durationformat": "0.10.4",
|
||||
"@formatjs/intl-getcanonicallocales": "3.2.3",
|
||||
"@formatjs/intl-listformat": "8.3.2",
|
||||
"@formatjs/intl-locale": "5.3.2",
|
||||
"@formatjs/intl-numberformat": "9.3.2",
|
||||
"@formatjs/intl-pluralrules": "6.3.2",
|
||||
"@formatjs/intl-relativetimeformat": "12.3.2",
|
||||
"@fullcalendar/core": "6.1.20",
|
||||
"@fullcalendar/daygrid": "6.1.20",
|
||||
"@fullcalendar/interaction": "6.1.20",
|
||||
@@ -61,12 +62,10 @@
|
||||
"@lit/reactive-element": "2.1.2",
|
||||
"@material/data-table": "=14.0.0-canary.53b3cad2f.0",
|
||||
"@material/mwc-base": "0.27.0",
|
||||
"@material/mwc-checkbox": "0.27.0",
|
||||
"@material/mwc-drawer": "0.27.0",
|
||||
"@material/mwc-formfield": "patch:@material/mwc-formfield@npm%3A0.27.0#~/.yarn/patches/@material-mwc-formfield-npm-0.27.0-9528cb60f6.patch",
|
||||
"@material/mwc-list": "patch:@material/mwc-list@npm%3A0.27.0#~/.yarn/patches/@material-mwc-list-npm-0.27.0-5344fc9de4.patch",
|
||||
"@material/mwc-radio": "0.27.0",
|
||||
"@material/mwc-switch": "0.27.0",
|
||||
"@material/mwc-top-app-bar": "0.27.0",
|
||||
"@material/mwc-top-app-bar-fixed": "0.27.0",
|
||||
"@material/top-app-bar": "=14.0.0-canary.53b3cad2f.0",
|
||||
@@ -97,10 +96,10 @@
|
||||
"fuse.js": "7.3.0",
|
||||
"google-timezones-json": "1.2.0",
|
||||
"gulp-zopfli-green": "7.0.0",
|
||||
"hls.js": "1.6.15",
|
||||
"hls.js": "1.6.16",
|
||||
"home-assistant-js-websocket": "9.6.0",
|
||||
"idb-keyval": "6.2.2",
|
||||
"intl-messageformat": "11.2.0",
|
||||
"intl-messageformat": "11.2.1",
|
||||
"js-yaml": "4.1.1",
|
||||
"leaflet": "1.9.4",
|
||||
"leaflet-draw": "patch:leaflet-draw@npm%3A1.0.4#./.yarn/patches/leaflet-draw-npm-1.0.4-0ca0ebcf65.patch",
|
||||
@@ -108,7 +107,7 @@
|
||||
"lit": "3.3.2",
|
||||
"lit-html": "3.3.2",
|
||||
"luxon": "3.7.2",
|
||||
"marked": "18.0.0",
|
||||
"marked": "18.0.1",
|
||||
"memoize-one": "6.0.0",
|
||||
"node-vibrant": "4.0.4",
|
||||
"object-hash": "3.0.0",
|
||||
@@ -135,7 +134,7 @@
|
||||
"@babel/helper-define-polyfill-provider": "0.6.8",
|
||||
"@babel/plugin-transform-runtime": "7.29.0",
|
||||
"@babel/preset-env": "7.29.2",
|
||||
"@bundle-stats/plugin-webpack-filter": "4.22.0",
|
||||
"@bundle-stats/plugin-webpack-filter": "4.22.1",
|
||||
"@eslint/eslintrc": "3.3.5",
|
||||
"@eslint/js": "10.0.1",
|
||||
"@html-eslint/eslint-plugin": "0.59.0",
|
||||
@@ -143,7 +142,7 @@
|
||||
"@octokit/auth-oauth-device": "8.0.3",
|
||||
"@octokit/plugin-retry": "8.1.0",
|
||||
"@octokit/rest": "22.0.1",
|
||||
"@rsdoctor/rspack-plugin": "1.5.7",
|
||||
"@rsdoctor/rspack-plugin": "1.5.9",
|
||||
"@rspack/core": "1.7.11",
|
||||
"@rspack/dev-server": "1.2.1",
|
||||
"@types/babel__plugin-transform-runtime": "7.9.5",
|
||||
@@ -181,7 +180,7 @@
|
||||
"fancy-log": "2.0.0",
|
||||
"fs-extra": "11.3.4",
|
||||
"glob": "13.0.6",
|
||||
"globals": "17.4.0",
|
||||
"globals": "17.5.0",
|
||||
"gulp": "5.0.1",
|
||||
"gulp-brotli": "3.0.0",
|
||||
"gulp-json-transform": "0.5.0",
|
||||
@@ -196,15 +195,15 @@
|
||||
"lodash.template": "4.5.0",
|
||||
"map-stream": "0.0.7",
|
||||
"pinst": "3.0.0",
|
||||
"prettier": "3.8.2",
|
||||
"prettier": "3.8.3",
|
||||
"rspack-manifest-plugin": "5.2.1",
|
||||
"serve": "14.2.6",
|
||||
"sinon": "21.1.0",
|
||||
"sinon": "21.1.2",
|
||||
"tar": "7.5.13",
|
||||
"terser-webpack-plugin": "5.4.0",
|
||||
"ts-lit-plugin": "2.0.2",
|
||||
"typescript": "6.0.2",
|
||||
"typescript-eslint": "8.58.1",
|
||||
"typescript": "6.0.3",
|
||||
"typescript-eslint": "8.58.2",
|
||||
"vite-tsconfig-paths": "6.1.1",
|
||||
"vitest": "4.1.4",
|
||||
"webpack-stats-plugin": "1.1.3",
|
||||
@@ -217,13 +216,13 @@
|
||||
"clean-css": "5.3.3",
|
||||
"@lit/reactive-element": "2.1.2",
|
||||
"@fullcalendar/daygrid": "6.1.20",
|
||||
"globals": "17.4.0",
|
||||
"globals": "17.5.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",
|
||||
"glob@^10.2.2": "^10.5.0"
|
||||
},
|
||||
"packageManager": "yarn@4.13.0",
|
||||
"packageManager": "yarn@4.14.1",
|
||||
"volta": {
|
||||
"node": "24.14.1"
|
||||
"node": "24.15.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import "../components/ha-alert";
|
||||
import "../components/ha-button";
|
||||
import "../components/ha-checkbox";
|
||||
import { computeInitialHaFormData } from "../components/ha-form/compute-initial-ha-form-data";
|
||||
import "../components/ha-formfield";
|
||||
import type { AuthProvider } from "../data/auth";
|
||||
import {
|
||||
autocompleteLoginFields,
|
||||
@@ -97,11 +96,6 @@ export class HaAuthFlow extends LitElement {
|
||||
protected render() {
|
||||
return html`
|
||||
<style>
|
||||
ha-auth-flow .store-token {
|
||||
margin-left: -16px;
|
||||
margin-inline-start: -16px;
|
||||
margin-inline-end: initial;
|
||||
}
|
||||
a.forgot-password {
|
||||
color: var(--primary-color);
|
||||
text-decoration: none;
|
||||
@@ -121,6 +115,9 @@ export class HaAuthFlow extends LitElement {
|
||||
display: block;
|
||||
margin-top: 16px;
|
||||
}
|
||||
.action {
|
||||
margin-top: var(--ha-space-5);
|
||||
}
|
||||
.action ha-button {
|
||||
width: 100%;
|
||||
}
|
||||
@@ -249,17 +246,12 @@ export class HaAuthFlow extends LitElement {
|
||||
${this.clientId === genClientId() &&
|
||||
!["select_mfa_module", "mfa"].includes(step.step_id)
|
||||
? html`
|
||||
<ha-formfield
|
||||
class="store-token"
|
||||
.label=${this.localize(
|
||||
"ui.panel.page-authorize.store_token"
|
||||
)}
|
||||
<ha-checkbox
|
||||
.checked=${this._storeToken}
|
||||
@change=${this._storeTokenChanged}
|
||||
>
|
||||
<ha-checkbox
|
||||
.checked=${this._storeToken}
|
||||
@change=${this._storeTokenChanged}
|
||||
></ha-checkbox>
|
||||
</ha-formfield>
|
||||
${this.localize("ui.panel.page-authorize.store_token")}
|
||||
</ha-checkbox>
|
||||
`
|
||||
: ""}
|
||||
<a
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { DurationInput } from "@formatjs/intl-durationformat/src/types";
|
||||
import memoizeOne from "memoize-one";
|
||||
import type { HaDurationData } from "../../components/ha-duration-input";
|
||||
import type { FrontendLocaleData } from "../../data/translation";
|
||||
@@ -114,7 +113,7 @@ export const formatDuration = (
|
||||
case "d": {
|
||||
const days = Math.floor(value);
|
||||
const hours = Math.floor((value - days) * 24);
|
||||
const input: DurationInput = {
|
||||
const input = {
|
||||
days,
|
||||
hours,
|
||||
};
|
||||
@@ -123,7 +122,7 @@ export const formatDuration = (
|
||||
case "h": {
|
||||
const hours = Math.floor(value);
|
||||
const minutes = Math.floor((value - hours) * 60);
|
||||
const input: DurationInput = {
|
||||
const input = {
|
||||
hours,
|
||||
minutes,
|
||||
};
|
||||
@@ -132,7 +131,7 @@ export const formatDuration = (
|
||||
case "min": {
|
||||
const minutes = Math.floor(value);
|
||||
const seconds = Math.floor((value - minutes) * 60);
|
||||
const input: DurationInput = {
|
||||
const input = {
|
||||
minutes,
|
||||
seconds,
|
||||
};
|
||||
|
||||
@@ -37,7 +37,7 @@ export function stateActive(stateObj: HassEntity, state?: string): boolean {
|
||||
case "person":
|
||||
return compareState !== "not_home";
|
||||
case "lawn_mower":
|
||||
return ["mowing", "error"].includes(compareState);
|
||||
return !["docked", "paused"].includes(compareState);
|
||||
case "lock":
|
||||
return compareState !== "locked";
|
||||
case "media_player":
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import type { Connection, UnsubscribeFunc } from "home-assistant-js-websocket";
|
||||
import type {
|
||||
Collection,
|
||||
Connection,
|
||||
UnsubscribeFunc,
|
||||
} from "home-assistant-js-websocket";
|
||||
|
||||
export const subscribeOne = async <T>(
|
||||
conn: Connection,
|
||||
@@ -13,3 +17,11 @@ export const subscribeOne = async <T>(
|
||||
resolve(items);
|
||||
});
|
||||
});
|
||||
|
||||
export const subscribeOneCollection = async <T>(collection: Collection<T>) =>
|
||||
new Promise<T>((resolve) => {
|
||||
const unsub = collection.subscribe((data) => {
|
||||
unsub();
|
||||
resolve(data);
|
||||
});
|
||||
});
|
||||
|
||||
103
src/components/chart/round-caps.ts
Normal file
103
src/components/chart/round-caps.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import type { BarSeriesOption } from "echarts/types/dist/shared";
|
||||
|
||||
export function fillDataGapsAndRoundCaps(
|
||||
datasets: BarSeriesOption[],
|
||||
stacked = true
|
||||
) {
|
||||
if (!stacked) {
|
||||
// For non-stacked charts, we can simply apply an overall border to each stack
|
||||
// to curve the top of the bar, and then override on any negative bars.
|
||||
datasets.forEach((dataset) => {
|
||||
// Add upper border radius to stack
|
||||
dataset.itemStyle = {
|
||||
...dataset.itemStyle,
|
||||
borderRadius: [4, 4, 0, 0],
|
||||
};
|
||||
// And override any negative points to have bottom border curved
|
||||
for (let pointIdx = 0; pointIdx < dataset.data!.length; pointIdx++) {
|
||||
const dataPoint = dataset.data![pointIdx];
|
||||
const item: any =
|
||||
dataPoint && typeof dataPoint === "object" && "value" in dataPoint
|
||||
? dataPoint
|
||||
: { value: dataPoint };
|
||||
if (item.value?.[1] < 0) {
|
||||
dataset.data![pointIdx] = {
|
||||
...item,
|
||||
itemStyle: {
|
||||
...item.itemStyle,
|
||||
borderRadius: [0, 0, 4, 4],
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// For stacked charts, we need to carefully work through the data points in each
|
||||
// stack to ensure only the lowermost negative and uppermost positive values have
|
||||
// a curved border.
|
||||
const buckets = Array.from(
|
||||
new Set(
|
||||
datasets
|
||||
.map((dataset) =>
|
||||
dataset.data!.map((datapoint) => Number(datapoint![0]))
|
||||
)
|
||||
.flat()
|
||||
)
|
||||
).sort((a, b) => a - b);
|
||||
|
||||
// make sure all datasets have the same buckets
|
||||
// otherwise the chart will render incorrectly in some cases
|
||||
buckets.forEach((bucket, index) => {
|
||||
const capRounded = {};
|
||||
const capRoundedNegative = {};
|
||||
for (let i = datasets.length - 1; i >= 0; i--) {
|
||||
const dataPoint = datasets[i].data![index];
|
||||
const item: any =
|
||||
dataPoint && typeof dataPoint === "object" && "value" in dataPoint
|
||||
? dataPoint
|
||||
: { value: dataPoint };
|
||||
const x = item.value?.[0];
|
||||
const stack = datasets[i].stack ?? "";
|
||||
if (x === undefined) {
|
||||
continue;
|
||||
}
|
||||
if (Number(x) !== bucket) {
|
||||
datasets[i].data?.splice(index, 0, {
|
||||
value: [bucket, 0],
|
||||
itemStyle: {
|
||||
borderWidth: 0,
|
||||
},
|
||||
});
|
||||
} else if (item.value?.[1] === 0) {
|
||||
// remove the border for zero values or it will be rendered
|
||||
datasets[i].data![index] = {
|
||||
...item,
|
||||
itemStyle: {
|
||||
...item.itemStyle,
|
||||
borderWidth: 0,
|
||||
},
|
||||
};
|
||||
} else if (!capRounded[stack] && item.value?.[1] > 0) {
|
||||
datasets[i].data![index] = {
|
||||
...item,
|
||||
itemStyle: {
|
||||
...item.itemStyle,
|
||||
borderRadius: [4, 4, 0, 0],
|
||||
},
|
||||
};
|
||||
capRounded[stack] = true;
|
||||
} else if (!capRoundedNegative[stack] && item.value?.[1] < 0) {
|
||||
datasets[i].data![index] = {
|
||||
...item,
|
||||
itemStyle: {
|
||||
...item.itemStyle,
|
||||
borderRadius: [0, 0, 4, 4],
|
||||
},
|
||||
};
|
||||
capRoundedNegative[stack] = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -35,6 +35,7 @@ import type { HomeAssistant } from "../../types";
|
||||
import { getPeriodicAxisLabelConfig } from "./axis-label";
|
||||
import type { CustomLegendOption } from "./ha-chart-base";
|
||||
import "./ha-chart-base";
|
||||
import { fillDataGapsAndRoundCaps } from "./round-caps";
|
||||
|
||||
export const supportedStatTypeMap: Record<StatisticType, StatisticType> = {
|
||||
mean: "mean",
|
||||
@@ -67,7 +68,11 @@ export class StatisticsChart extends LitElement {
|
||||
@property({ attribute: false })
|
||||
public statTypes: StatisticType[] = ["sum", "min", "mean", "max"];
|
||||
|
||||
@property({ attribute: false }) public chartType: "line" | "bar" = "line";
|
||||
@property({ attribute: false }) public chartType:
|
||||
| "line"
|
||||
| "line-stack"
|
||||
| "bar"
|
||||
| "bar-stack" = "line";
|
||||
|
||||
@property({ attribute: false }) public minYAxis?: number;
|
||||
|
||||
@@ -326,7 +331,7 @@ export class StatisticsChart extends LitElement {
|
||||
},
|
||||
position: computeRTL(this.hass) ? "right" : "left",
|
||||
scale:
|
||||
this.chartType !== "bar" ||
|
||||
this.chartType.startsWith("line") ||
|
||||
this.logarithmicScale ||
|
||||
minYAxis !== undefined ||
|
||||
maxYAxis !== undefined,
|
||||
@@ -386,6 +391,8 @@ export class StatisticsChart extends LitElement {
|
||||
(await this._getStatisticsMetaData(Object.keys(this.statisticsData)));
|
||||
|
||||
let colorIndex = 0;
|
||||
const chartType = this.chartType.startsWith("line") ? "line" : "bar";
|
||||
const chartStacked = this.chartType.endsWith("stack");
|
||||
const statisticsData = Object.entries(this.statisticsData);
|
||||
const totalDataSets: typeof this._chartData = [];
|
||||
const legendData: {
|
||||
@@ -471,19 +478,17 @@ export class StatisticsChart extends LitElement {
|
||||
}
|
||||
statDataSets.forEach((d, i) => {
|
||||
if (
|
||||
this.chartType === "line" &&
|
||||
chartType === "line" &&
|
||||
prevEndTime &&
|
||||
prevValues &&
|
||||
prevEndTime.getTime() !== start.getTime()
|
||||
) {
|
||||
// if the end of the previous data doesn't match the start of the current data,
|
||||
// we have to draw a gap so add a value at the end time, and then an empty value.
|
||||
d.data!.push(
|
||||
this._transformDataValue([prevEndTime, ...prevValues[i]!])
|
||||
);
|
||||
d.data!.push([prevEndTime, ...prevValues[i]!]);
|
||||
d.data!.push([prevEndTime, null]);
|
||||
}
|
||||
d.data!.push(this._transformDataValue([start, ...dataValues[i]!]));
|
||||
d.data!.push([start, ...dataValues[i]!]);
|
||||
});
|
||||
prevValues = dataValues;
|
||||
prevEndTime = end;
|
||||
@@ -503,7 +508,8 @@ export class StatisticsChart extends LitElement {
|
||||
this.statTypes.includes("max") && statisticsHaveType(stats, "max");
|
||||
const hasMin =
|
||||
this.statTypes.includes("min") && statisticsHaveType(stats, "min");
|
||||
const drawBands = [hasMean, hasMax, hasMin].filter(Boolean).length > 1;
|
||||
const drawBands =
|
||||
!chartStacked && [hasMean, hasMax, hasMin].filter(Boolean).length > 1;
|
||||
|
||||
const hasState = this.statTypes.includes("state");
|
||||
|
||||
@@ -535,8 +541,8 @@ export class StatisticsChart extends LitElement {
|
||||
const backgroundColor = band ? color + "3F" : color + "7F";
|
||||
const series: LineSeriesOption | BarSeriesOption = {
|
||||
id: `${statistic_id}-${type}`,
|
||||
type: this.chartType,
|
||||
smooth: this.chartType === "line" ? 0.4 : false,
|
||||
type: chartType,
|
||||
smooth: chartType === "line" ? 0.4 : false,
|
||||
cursor: "default",
|
||||
data: [],
|
||||
name: name
|
||||
@@ -555,16 +561,23 @@ export class StatisticsChart extends LitElement {
|
||||
width: 1.5,
|
||||
},
|
||||
itemStyle:
|
||||
this.chartType === "bar"
|
||||
chartType === "bar"
|
||||
? {
|
||||
borderRadius: [4, 4, 0, 0],
|
||||
borderColor,
|
||||
borderWidth: 1.5,
|
||||
}
|
||||
: undefined,
|
||||
color: this.chartType === "bar" ? backgroundColor : borderColor,
|
||||
color: chartType === "bar" ? backgroundColor : borderColor,
|
||||
};
|
||||
if (band && this.chartType === "line") {
|
||||
if (chartStacked) {
|
||||
series.stack = `band-stacked`;
|
||||
series.stackStrategy = "samesign";
|
||||
if (chartType === "line") {
|
||||
(series as LineSeriesOption).areaStyle = {
|
||||
color: color + "3F",
|
||||
};
|
||||
}
|
||||
} else if (band && chartType === "line") {
|
||||
series.stack = `band-${statistic_id}`;
|
||||
series.stackStrategy = "all";
|
||||
if (this._hiddenStats.has(`${statistic_id}-${bandBottom}`)) {
|
||||
@@ -621,7 +634,7 @@ export class StatisticsChart extends LitElement {
|
||||
}
|
||||
} else if (
|
||||
type === bandTop &&
|
||||
this.chartType === "line" &&
|
||||
chartType === "line" &&
|
||||
drawBands &&
|
||||
!this._hiddenStats.has(`${statistic_id}-${bandBottom}`)
|
||||
) {
|
||||
@@ -645,11 +658,9 @@ export class StatisticsChart extends LitElement {
|
||||
// For line charts, close out the last stat segment at prevEndTime
|
||||
const lastEndTime = prevEndTime;
|
||||
const lastValues = prevValues;
|
||||
if (this.chartType === "line" && lastEndTime && lastValues) {
|
||||
if (chartType === "line" && lastEndTime && lastValues) {
|
||||
statDataSets.forEach((d, i) => {
|
||||
d.data!.push(
|
||||
this._transformDataValue([lastEndTime, ...lastValues[i]!])
|
||||
);
|
||||
d.data!.push([lastEndTime, ...lastValues[i]!]);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -657,6 +668,7 @@ export class StatisticsChart extends LitElement {
|
||||
const statisticUnit = getDisplayUnit(this.hass, statistic_id, meta);
|
||||
if (
|
||||
displayCurrentState &&
|
||||
!chartStacked &&
|
||||
(!this.unit || !statisticUnit || this.unit === statisticUnit)
|
||||
) {
|
||||
// Skip external statistics
|
||||
@@ -677,7 +689,7 @@ export class StatisticsChart extends LitElement {
|
||||
const val: (number | null)[] = [];
|
||||
if (
|
||||
type === bandTop &&
|
||||
this.chartType === "line" &&
|
||||
chartType === "line" &&
|
||||
drawBands &&
|
||||
!this._hiddenStats.has(`${statistic_id}-${bandBottom}`)
|
||||
) {
|
||||
@@ -687,9 +699,7 @@ export class StatisticsChart extends LitElement {
|
||||
} else {
|
||||
val.push(currentValue);
|
||||
}
|
||||
statDataSets[i].data!.push(
|
||||
this._transformDataValue([now, ...val])
|
||||
);
|
||||
statDataSets[i].data!.push([now, ...val]);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -701,6 +711,13 @@ export class StatisticsChart extends LitElement {
|
||||
Array.prototype.push.apply(legendData, statLegendData);
|
||||
});
|
||||
|
||||
if (chartType === "bar") {
|
||||
fillDataGapsAndRoundCaps(
|
||||
totalDataSets as BarSeriesOption[],
|
||||
chartStacked
|
||||
);
|
||||
}
|
||||
|
||||
legendData.forEach(({ id, name, color, borderColor }) => {
|
||||
// Add an empty series for the legend
|
||||
totalDataSets.push({
|
||||
@@ -710,7 +727,7 @@ export class StatisticsChart extends LitElement {
|
||||
itemStyle: {
|
||||
borderColor,
|
||||
},
|
||||
type: this.chartType,
|
||||
type: chartType,
|
||||
data: [],
|
||||
xAxisIndex: 1,
|
||||
});
|
||||
@@ -728,13 +745,6 @@ export class StatisticsChart extends LitElement {
|
||||
this._statisticIds = statisticIds;
|
||||
}
|
||||
|
||||
private _transformDataValue(val: [Date, ...(number | null)[]]) {
|
||||
if (this.chartType === "bar" && val[1] && val[1] < 0) {
|
||||
return { value: val, itemStyle: { borderRadius: [0, 0, 4, 4] } };
|
||||
}
|
||||
return val;
|
||||
}
|
||||
|
||||
private _clampYAxis(value?: number | ((values: any) => number)) {
|
||||
if (this.logarithmicScale) {
|
||||
// log(0) is -Infinity, so we need to set a minimum value
|
||||
|
||||
@@ -887,10 +887,20 @@ export class HaDataTable extends LitElement {
|
||||
this._lastSelectedRowId = null;
|
||||
}
|
||||
|
||||
private _handleRowCheckboxClicked = (
|
||||
ev: HASSDomCurrentTargetEvent<HaCheckbox & { rowId: string }>
|
||||
) => {
|
||||
const rowId = ev.currentTarget.rowId;
|
||||
private _handleRowCheckboxClicked = (ev: MouseEvent) => {
|
||||
// ha-checkbox label dispatches synthetic click on input, so handle the input click only
|
||||
if (!(ev.composedPath()[0] instanceof HTMLInputElement) && !ev.shiftKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
// In range select mode, use label click for Firefox since it doesn't fire input click events
|
||||
if (ev.composedPath()[0] instanceof HTMLInputElement && ev.shiftKey) {
|
||||
ev.preventDefault();
|
||||
}
|
||||
|
||||
const checkboxElement = ev.currentTarget as HaCheckbox & { rowId: string };
|
||||
|
||||
const rowId = checkboxElement.rowId;
|
||||
|
||||
const groupedData = this._groupData(
|
||||
this._filteredData,
|
||||
@@ -927,7 +937,7 @@ export class HaDataTable extends LitElement {
|
||||
...this._selectRange(groupedData, lastSelectedRowIndex, rowIndex),
|
||||
];
|
||||
}
|
||||
} else if (!ev.currentTarget.checked) {
|
||||
} else if (checkboxElement.checked) {
|
||||
if (!this._checkedRows.includes(rowId)) {
|
||||
this._checkedRows = [...this._checkedRows, rowId];
|
||||
}
|
||||
@@ -1474,6 +1484,10 @@ export class HaDataTable extends LitElement {
|
||||
lit-virtualizer:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
ha-checkbox {
|
||||
padding: var(--ha-space-1);
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -79,6 +79,8 @@ export const datePickerStyles = css`
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
margin-left: 48px;
|
||||
margin-inline-start: 48px;
|
||||
margin-inline-end: initial;
|
||||
}
|
||||
`;
|
||||
|
||||
|
||||
@@ -3,8 +3,9 @@ import { styles as controlStyles } from "@material/mwc-list/mwc-control-list-ite
|
||||
import { styles } from "@material/mwc-list/mwc-list-item.css";
|
||||
import { css, html, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import { preventDefault } from "../common/dom/prevent_default";
|
||||
import { stopPropagation } from "../common/dom/stop_propagation";
|
||||
import "./ha-checkbox";
|
||||
|
||||
@customElement("ha-check-list-item")
|
||||
@@ -15,17 +16,15 @@ export class HaCheckListItem extends CheckListItemBase {
|
||||
@property({ type: Boolean })
|
||||
indeterminate = false;
|
||||
|
||||
@property({ type: Boolean, attribute: "separate-checkbox-click" })
|
||||
separateCheckboxClick = false;
|
||||
|
||||
async onChange(event) {
|
||||
super.onChange(event);
|
||||
fireEvent(this, event.type);
|
||||
}
|
||||
|
||||
override render() {
|
||||
const checkboxClasses = {
|
||||
"mdc-deprecated-list-item__graphic": this.left,
|
||||
"mdc-deprecated-list-item__meta": !this.left,
|
||||
};
|
||||
|
||||
const text = this.renderText();
|
||||
const graphic =
|
||||
this.graphic && this.graphic !== "control" && !this.left
|
||||
@@ -35,17 +34,16 @@ export class HaCheckListItem extends CheckListItemBase {
|
||||
const ripple = this.renderRipple();
|
||||
|
||||
return html` ${ripple} ${graphic} ${this.left ? "" : text}
|
||||
<span class=${classMap(checkboxClasses)}>
|
||||
<ha-checkbox
|
||||
reducedTouchTarget
|
||||
tabindex=${this.tabindex}
|
||||
.checked=${this.selected}
|
||||
.indeterminate=${this.indeterminate}
|
||||
?disabled=${this.disabled || this.checkboxDisabled}
|
||||
@change=${this.onChange}
|
||||
>
|
||||
</ha-checkbox>
|
||||
</span>
|
||||
<ha-checkbox
|
||||
tabindex=${this.separateCheckboxClick ? this.tabindex : -1}
|
||||
.checked=${this.selected}
|
||||
.indeterminate=${this.indeterminate}
|
||||
?disabled=${this.disabled || this.checkboxDisabled}
|
||||
@change=${this.onChange}
|
||||
@click=${this.separateCheckboxClick ? stopPropagation : preventDefault}
|
||||
class=${this.left ? "left" : ""}
|
||||
>
|
||||
</ha-checkbox>
|
||||
${this.left ? text : ""} ${meta}`;
|
||||
}
|
||||
|
||||
@@ -65,11 +63,16 @@ export class HaCheckListItem extends CheckListItemBase {
|
||||
margin-inline-start: 0px;
|
||||
direction: var(--direction);
|
||||
}
|
||||
.mdc-deprecated-list-item__meta {
|
||||
ha-checkbox {
|
||||
flex-shrink: 0;
|
||||
direction: var(--direction);
|
||||
margin-inline-start: auto;
|
||||
margin-inline-end: 0;
|
||||
height: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
ha-checkbox.left {
|
||||
margin-inline-start: 0;
|
||||
}
|
||||
.mdc-deprecated-list-item__graphic {
|
||||
margin-top: var(--check-list-item-graphic-margin-top);
|
||||
|
||||
@@ -1,18 +1,156 @@
|
||||
import { CheckboxBase } from "@material/mwc-checkbox/mwc-checkbox-base";
|
||||
import { styles } from "@material/mwc-checkbox/mwc-checkbox.css";
|
||||
import { css } from "lit";
|
||||
import WaCheckbox from "@home-assistant/webawesome/dist/components/checkbox/checkbox";
|
||||
import { css, type CSSResultGroup } from "lit";
|
||||
import { customElement } from "lit/decorators";
|
||||
|
||||
/**
|
||||
* Home Assistant checkbox component
|
||||
*
|
||||
* @element ha-checkbox
|
||||
* @extends {WaCheckbox}
|
||||
*
|
||||
* @summary
|
||||
* A Home Assistant themed wrapper around the Web Awesome checkbox.
|
||||
*
|
||||
* @slot - The checkbox's label.
|
||||
* @slot hint - Text that describes how to use the checkbox.
|
||||
*
|
||||
* @csspart base - The component's label wrapper.
|
||||
* @csspart control - The square container that wraps the checkbox's checked state.
|
||||
* @csspart checked-icon - The checked icon, a `<wa-icon>` element.
|
||||
* @csspart indeterminate-icon - The indeterminate icon, a `<wa-icon>` element.
|
||||
* @csspart label - The container that wraps the checkbox's label.
|
||||
* @csspart hint - The hint's wrapper.
|
||||
*
|
||||
* @cssprop --ha-checkbox-size - The checkbox size. Defaults to `20px`.
|
||||
* @cssprop --ha-checkbox-border-color - The border color of the checkbox control. Defaults to `--ha-color-border-neutral-normal`.
|
||||
* @cssprop --ha-checkbox-border-color-hover - The border color of the checkbox control on hover. Defaults to `--ha-checkbox-border-color`, then `--ha-color-border-neutral-loud`.
|
||||
* @cssprop --ha-checkbox-background-color - The background color of the checkbox control. Defaults to `--wa-form-control-background-color`.
|
||||
* @cssprop --ha-checkbox-background-color-hover - The background color of the checkbox control on hover. Defaults to `--ha-color-form-background-hover`.
|
||||
* @cssprop --ha-checkbox-checked-background-color - The background color when checked or indeterminate. Defaults to `--ha-color-fill-primary-loud-resting`.
|
||||
* @cssprop --ha-checkbox-checked-background-color-hover - The background color when checked or indeterminate on hover. Defaults to `--ha-color-fill-primary-loud-hover`.
|
||||
* @cssprop --ha-checkbox-checked-icon-color - The color of the checked and indeterminate icons. Defaults to `--wa-color-brand-on-loud`.
|
||||
* @cssprop --ha-checkbox-checked-icon-scale - The size of the checked and indeterminate icons relative to the checkbox. Defaults to `0.9`.
|
||||
* @cssprop --ha-checkbox-border-radius - The border radius of the checkbox control. Defaults to `--ha-border-radius-sm`.
|
||||
* @cssprop --ha-checkbox-border-width - The border width of the checkbox control. Defaults to `--ha-border-width-md`.
|
||||
* @cssprop --ha-checkbox-required-marker - The marker shown after the label for required fields. Defaults to `"*"`.
|
||||
* @cssprop --ha-checkbox-required-marker-offset - Offset of the required marker. Defaults to `0.1rem`.
|
||||
*
|
||||
* @attr {boolean} checked - Draws the checkbox in a checked state.
|
||||
* @attr {boolean} disabled - Disables the checkbox.
|
||||
* @attr {boolean} indeterminate - Draws the checkbox in an indeterminate state.
|
||||
* @attr {boolean} required - Makes the checkbox a required field.
|
||||
*/
|
||||
@customElement("ha-checkbox")
|
||||
export class HaCheckbox extends CheckboxBase {
|
||||
static override styles = [
|
||||
styles,
|
||||
css`
|
||||
:host {
|
||||
--mdc-theme-secondary: var(--primary-color);
|
||||
}
|
||||
`,
|
||||
];
|
||||
export class HaCheckbox extends WaCheckbox {
|
||||
/**
|
||||
* Returns the configured checkbox value, independent of checked state.
|
||||
*
|
||||
* The base Web Awesome checkbox returns `null` when unchecked to align with
|
||||
* form submission rules. Home Assistant components expect the configured value
|
||||
* to remain readable, so this wrapper always exposes the internal value.
|
||||
*/
|
||||
// @ts-ignore - accessing WA internal _value property
|
||||
override get value(): string | null {
|
||||
// @ts-ignore
|
||||
return this._value ?? null;
|
||||
}
|
||||
|
||||
/** Sets the configured checkbox value. */
|
||||
override set value(val: string | null) {
|
||||
// @ts-ignore
|
||||
this._value = val;
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
WaCheckbox.styles,
|
||||
css`
|
||||
:host {
|
||||
--wa-form-control-toggle-size: var(--ha-checkbox-size, 20px);
|
||||
--wa-form-control-border-color: var(
|
||||
--ha-checkbox-border-color,
|
||||
var(--ha-color-border-neutral-normal)
|
||||
);
|
||||
--wa-form-control-background-color: var(
|
||||
--ha-checkbox-background-color,
|
||||
var(--wa-form-control-background-color)
|
||||
);
|
||||
--checked-icon-color: var(
|
||||
--ha-checkbox-checked-icon-color,
|
||||
var(--wa-color-brand-on-loud)
|
||||
);
|
||||
|
||||
--wa-form-control-activated-color: var(
|
||||
--ha-checkbox-checked-background-color,
|
||||
var(--ha-color-fill-primary-loud-resting)
|
||||
);
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
|
||||
--checked-icon-scale: var(--ha-checkbox-checked-icon-scale, 0.9);
|
||||
--wa-form-control-required-content: var(
|
||||
--ha-checkbox-required-marker,
|
||||
var(--ha-input-required-marker, "*")
|
||||
);
|
||||
--wa-form-control-required-content-offset: var(
|
||||
--ha-checkbox-required-marker-offset,
|
||||
0.1rem
|
||||
);
|
||||
}
|
||||
|
||||
[part~="base"] {
|
||||
align-items: center;
|
||||
gap: var(--ha-space-2);
|
||||
}
|
||||
|
||||
[part~="control"] {
|
||||
border-radius: var(
|
||||
--ha-checkbox-border-radius,
|
||||
var(--ha-border-radius-sm)
|
||||
);
|
||||
border-width: var(
|
||||
--ha-checkbox-border-width,
|
||||
var(--ha-border-width-md)
|
||||
);
|
||||
margin-inline-end: 0;
|
||||
}
|
||||
|
||||
[part~="label"] {
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
#hint {
|
||||
font-size: var(--ha-font-size-xs);
|
||||
color: var(--ha-color-text-secondary);
|
||||
}
|
||||
|
||||
label:has(input:not(:disabled)):hover {
|
||||
--wa-form-control-border-color: var(
|
||||
--ha-checkbox-border-color-hover,
|
||||
var(--ha-checkbox-border-color, var(--ha-color-border-neutral-loud))
|
||||
);
|
||||
}
|
||||
|
||||
label:has(input:not(:disabled)):hover [part~="control"] {
|
||||
background-color: var(
|
||||
--ha-checkbox-background-color-hover,
|
||||
var(--ha-color-form-background-hover)
|
||||
);
|
||||
}
|
||||
|
||||
label:has(input:checked:not(:disabled)):hover [part~="control"],
|
||||
label:has(input:indeterminate:not(:disabled)):hover [part~="control"] {
|
||||
background-color: var(
|
||||
--ha-checkbox-checked-background-color-hover,
|
||||
var(--ha-color-fill-primary-loud-hover)
|
||||
);
|
||||
border-color: var(
|
||||
--ha-checkbox-checked-background-color-hover,
|
||||
var(--ha-color-fill-primary-loud-hover)
|
||||
);
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
@@ -10,6 +10,7 @@ import type {
|
||||
import { redo, redoDepth, undo, undoDepth } from "@codemirror/commands";
|
||||
import type { Extension, TransactionSpec } from "@codemirror/state";
|
||||
import type { EditorView, KeyBinding, ViewUpdate } from "@codemirror/view";
|
||||
import type { SyntaxNode } from "@lezer/common";
|
||||
import { placeholder } from "@codemirror/view";
|
||||
import {
|
||||
mdiArrowCollapse,
|
||||
@@ -26,13 +27,20 @@ import type { CSSResultGroup, PropertyValues } from "lit";
|
||||
import { css, html, ReactiveElement, render } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { consume } from "@lit/context";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import { stopPropagation } from "../common/dom/stop_propagation";
|
||||
import { getEntityContext } from "../common/entity/context/get_entity_context";
|
||||
import { computeDeviceName } from "../common/entity/compute_device_name";
|
||||
import { computeAreaName } from "../common/entity/compute_area_name";
|
||||
import { computeFloorName } from "../common/entity/compute_floor_name";
|
||||
import { copyToClipboard } from "../common/util/copy-clipboard";
|
||||
import { haStyleScrollbar } from "../resources/styles";
|
||||
import type { JinjaArgType } from "../resources/jinja_ha_completions";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import { showToast } from "../util/toast";
|
||||
import { labelsContext } from "../data/context";
|
||||
import type { LabelRegistryEntry } from "../data/label/label_registry";
|
||||
import "./ha-code-editor-completion-items";
|
||||
import type { CompletionItem } from "./ha-code-editor-completion-items";
|
||||
import "./ha-icon";
|
||||
@@ -109,6 +117,10 @@ export class HaCodeEditor extends ReactiveElement {
|
||||
|
||||
@state() private _canCopy = false;
|
||||
|
||||
@consume({ context: labelsContext, subscribe: true })
|
||||
@state()
|
||||
private _labels?: LabelRegistryEntry[];
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-imports
|
||||
private _loadedCodeMirror?: typeof import("../resources/codemirror");
|
||||
|
||||
@@ -204,9 +216,6 @@ export class HaCodeEditor extends ReactiveElement {
|
||||
transactions.push({
|
||||
effects: [
|
||||
this._loadedCodeMirror!.langCompartment!.reconfigure(this._mode),
|
||||
this._loadedCodeMirror!.foldingCompartment.reconfigure(
|
||||
this._getFoldingExtensions()
|
||||
),
|
||||
],
|
||||
});
|
||||
}
|
||||
@@ -273,6 +282,8 @@ export class HaCodeEditor extends ReactiveElement {
|
||||
}
|
||||
const extensions: Extension[] = [
|
||||
this._loadedCodeMirror.lineNumbers(),
|
||||
this._loadedCodeMirror.foldGutter(),
|
||||
this._loadedCodeMirror.bracketMatching(),
|
||||
this._loadedCodeMirror.history(),
|
||||
this._loadedCodeMirror.drawSelection(),
|
||||
this._loadedCodeMirror.EditorState.allowMultipleSelections.of(true),
|
||||
@@ -290,6 +301,9 @@ export class HaCodeEditor extends ReactiveElement {
|
||||
},
|
||||
}),
|
||||
this._loadedCodeMirror.keymap.of([
|
||||
// closeBracketsKeymap must come before defaultKeymap so its Backspace
|
||||
// handler runs before the default delete-character binding.
|
||||
...(!this.readOnly ? this._loadedCodeMirror.closeBracketsKeymap : []),
|
||||
...this._loadedCodeMirror.defaultKeymap,
|
||||
...this._loadedCodeMirror.searchKeymap,
|
||||
...this._loadedCodeMirror.historyKeymap,
|
||||
@@ -300,6 +314,8 @@ export class HaCodeEditor extends ReactiveElement {
|
||||
this._loadedCodeMirror.langCompartment.of(this._mode),
|
||||
this._loadedCodeMirror.haTheme,
|
||||
this._loadedCodeMirror.haSyntaxHighlighting,
|
||||
this._loadedCodeMirror.yamlScalarHighlighter,
|
||||
this._loadedCodeMirror.yamlScalarHighlightStyle,
|
||||
this._loadedCodeMirror.readonlyCompartment.of(
|
||||
this._loadedCodeMirror.EditorView.editable.of(!this.readOnly)
|
||||
),
|
||||
@@ -307,9 +323,6 @@ export class HaCodeEditor extends ReactiveElement {
|
||||
this.linewrap ? this._loadedCodeMirror.EditorView.lineWrapping : []
|
||||
),
|
||||
this._loadedCodeMirror.EditorView.updateListener.of(this._onUpdate),
|
||||
this._loadedCodeMirror.foldingCompartment.of(
|
||||
this._getFoldingExtensions()
|
||||
),
|
||||
this._loadedCodeMirror.tooltips({
|
||||
position: "absolute",
|
||||
}),
|
||||
@@ -317,21 +330,24 @@ export class HaCodeEditor extends ReactiveElement {
|
||||
];
|
||||
|
||||
if (!this.readOnly) {
|
||||
const completionSources: CompletionSource[] = [];
|
||||
const completionSources: CompletionSource[] = [
|
||||
this._loadedCodeMirror.haJinjaCompletionSource,
|
||||
];
|
||||
if (this.autocompleteEntities && this.hass) {
|
||||
completionSources.push(this._entityCompletions.bind(this));
|
||||
}
|
||||
if (this.autocompleteIcons) {
|
||||
completionSources.push(this._mdiCompletions.bind(this));
|
||||
}
|
||||
if (completionSources.length > 0) {
|
||||
extensions.push(
|
||||
this._loadedCodeMirror.autocompletion({
|
||||
override: completionSources,
|
||||
maxRenderedOptions: 10,
|
||||
})
|
||||
);
|
||||
}
|
||||
extensions.push(
|
||||
this._loadedCodeMirror.autocompletion({
|
||||
override: completionSources,
|
||||
maxRenderedOptions: 10,
|
||||
}),
|
||||
this._loadedCodeMirror.closeBrackets(),
|
||||
this._loadedCodeMirror.closeBracketsOverride,
|
||||
this._loadedCodeMirror.closePercentBrace
|
||||
);
|
||||
}
|
||||
|
||||
// Create the code editor
|
||||
@@ -559,7 +575,10 @@ export class HaCodeEditor extends ReactiveElement {
|
||||
};
|
||||
|
||||
private _renderInfo = (completion: Completion): CompletionInfo => {
|
||||
const key = completion.label;
|
||||
const key =
|
||||
typeof completion.apply === "string"
|
||||
? completion.apply
|
||||
: completion.label;
|
||||
const context = getEntityContext(
|
||||
this.hass!.states[key],
|
||||
this.hass!.entities,
|
||||
@@ -620,10 +639,62 @@ export class HaCodeEditor extends ReactiveElement {
|
||||
return completionInfo;
|
||||
};
|
||||
|
||||
private _renderAttributeInfo = (
|
||||
entityId: string,
|
||||
attribute: string
|
||||
): CompletionInfo | null => {
|
||||
if (!this.hass) return null;
|
||||
const stateObj = this.hass.states[entityId];
|
||||
if (!stateObj) return null;
|
||||
|
||||
const translatedName = this.hass.formatEntityAttributeName(
|
||||
stateObj,
|
||||
attribute
|
||||
);
|
||||
const formattedValue = this.hass.formatEntityAttributeValue(
|
||||
stateObj,
|
||||
attribute
|
||||
);
|
||||
const rawValue = stateObj.attributes[attribute];
|
||||
const rawValueStr =
|
||||
rawValue !== null && rawValue !== undefined
|
||||
? String(rawValue)
|
||||
: undefined;
|
||||
|
||||
const completionItems: CompletionItem[] = [
|
||||
{
|
||||
label: translatedName,
|
||||
value: formattedValue,
|
||||
// Show raw value as sub-value only when it differs from the formatted one
|
||||
subValue:
|
||||
rawValueStr !== undefined && rawValueStr !== formattedValue
|
||||
? rawValueStr
|
||||
: undefined,
|
||||
},
|
||||
];
|
||||
|
||||
const completionInfo = document.createElement("div");
|
||||
completionInfo.classList.add("completion-info");
|
||||
render(
|
||||
html`
|
||||
<ha-code-editor-completion-items
|
||||
.items=${completionItems}
|
||||
></ha-code-editor-completion-items>
|
||||
`,
|
||||
completionInfo
|
||||
);
|
||||
|
||||
return completionInfo;
|
||||
};
|
||||
|
||||
private _getCompletionInfo = (
|
||||
completion: Completion
|
||||
): CompletionInfo | Promise<CompletionInfo> | null => {
|
||||
if (this.hass && completion.label in this.hass.states) {
|
||||
if (
|
||||
this.hass &&
|
||||
typeof completion.apply === "string" &&
|
||||
completion.apply in this.hass.states
|
||||
) {
|
||||
return this._renderInfo(completion);
|
||||
}
|
||||
|
||||
@@ -631,6 +702,11 @@ export class HaCodeEditor extends ReactiveElement {
|
||||
return renderIcon(completion);
|
||||
}
|
||||
|
||||
// Attribute completions attach an info function directly on the object.
|
||||
if (typeof completion.info === "function") {
|
||||
return completion.info(completion);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
@@ -778,16 +854,546 @@ export class HaCodeEditor extends ReactiveElement {
|
||||
|
||||
const options = Object.keys(states).map((key) => ({
|
||||
type: "variable",
|
||||
label: key,
|
||||
label: states[key].attributes.friendly_name
|
||||
? `${states[key].attributes.friendly_name} ${key}` // label is used for searching, so include both name and entity_id here
|
||||
: key,
|
||||
displayLabel: key,
|
||||
detail: states[key].attributes.friendly_name,
|
||||
apply: key,
|
||||
}));
|
||||
|
||||
return options;
|
||||
});
|
||||
|
||||
// Map of HA Jinja function name → (arg index → JinjaArgType).
|
||||
// Derived from the snippet definitions in jinja_ha_completions.ts.
|
||||
private get _jinjaFunctionArgTypes() {
|
||||
return this._loadedCodeMirror!.JINJA_FUNCTION_ARG_TYPES;
|
||||
}
|
||||
|
||||
// The accessible properties on TemplateStateBase (from HA core source).
|
||||
// These are valid completions at `states.<domain>.<entity>.___`.
|
||||
private static readonly _STATE_FIELDS: string[] = [
|
||||
"state",
|
||||
"attributes",
|
||||
"last_changed",
|
||||
"last_updated",
|
||||
"context",
|
||||
"domain",
|
||||
"object_id",
|
||||
"name",
|
||||
"entity_id",
|
||||
"state_with_unit",
|
||||
];
|
||||
|
||||
/**
|
||||
* Handles `states.<domain>.<entity>.<field>.<attr>` dot-notation completions.
|
||||
*
|
||||
* Walks the MemberExpression chain in the Jinja syntax tree rooted at the
|
||||
* `states` VariableName and offers completions depending on depth:
|
||||
* - `states.` → all domains
|
||||
* - `states.<d>.` → all entity object_ids for that domain
|
||||
* - `states.<d>.<e>.` → fixed state fields
|
||||
* - `states.<d>.<e>.attributes.` → attribute names from hass.states
|
||||
*
|
||||
* Returns undefined to fall through when the cursor is not inside a
|
||||
* `states.` chain; returns null/CompletionResult to short-circuit.
|
||||
*/
|
||||
private _statesDotNotationCompletions(
|
||||
context: CompletionContext
|
||||
): CompletionResult | null | undefined {
|
||||
if (!this.hass) return undefined;
|
||||
|
||||
const { state: editorState, pos } = context;
|
||||
const tree = this._loadedCodeMirror!.syntaxTree(editorState);
|
||||
const node = tree.resolveInner(pos, -1);
|
||||
|
||||
// We act on two cursor positions:
|
||||
// (a) cursor is ON a PropertyName node → partially typed identifier
|
||||
// (b) cursor is on/just-after a "." node → right after the dot
|
||||
// In both cases the parent is a MemberExpression.
|
||||
const memberNode = node.parent;
|
||||
// "from" for the completion result (start of what the user is currently typing)
|
||||
let completionFrom = pos;
|
||||
|
||||
if (
|
||||
node.name === "PropertyName" &&
|
||||
memberNode?.name === "MemberExpression"
|
||||
) {
|
||||
// Cursor is on a PropertyName — replace from start of that name.
|
||||
completionFrom = node.from;
|
||||
} else if (node.name === "." && memberNode?.name === "MemberExpression") {
|
||||
// Cursor just after "." — insert from current position.
|
||||
completionFrom = pos;
|
||||
} else {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Walk up the MemberExpression chain to collect property segments and
|
||||
// find the root VariableName.
|
||||
//
|
||||
// Each MemberExpression has the shape: <object> "." <PropertyName>
|
||||
// so the last PropertyName in the chain is the one directly under the
|
||||
// outermost member expression. We walk *up* to find the root, collecting
|
||||
// each intermediate PropertyName text along the way.
|
||||
//
|
||||
// Example for states.light.living_room.attributes at cursor after the
|
||||
// last dot:
|
||||
// MemberExpression <- memberNode (cursor's parent)
|
||||
// MemberExpression <- depth 3 (states.light.living_room)
|
||||
// MemberExpression <- depth 2 (states.light)
|
||||
// VariableName "states"
|
||||
// "."
|
||||
// PropertyName "light"
|
||||
// "."
|
||||
// PropertyName "living_room"
|
||||
// "."
|
||||
// (no PropertyName yet — cursor is right here)
|
||||
|
||||
// Collect the segments bottom-up (innermost first).
|
||||
const segments: string[] = [];
|
||||
let cur = memberNode; // the MemberExpression directly containing the cursor
|
||||
|
||||
// If cursor is on a PropertyName, that is part of *this* MemberExpression;
|
||||
// skip it — we don't want to include what the user is currently typing.
|
||||
// We want the segments that lead *up to* the current position.
|
||||
|
||||
// Walk up through parent MemberExpressions collecting PropertyName texts.
|
||||
// Each MemberExpression's last PropertyName child is the segment for that
|
||||
// level — but we skip the innermost one if the cursor is on a PropertyName
|
||||
// (that's the partial input, not a committed segment).
|
||||
let skipFirst = node.name === "PropertyName";
|
||||
|
||||
while (cur?.name === "MemberExpression") {
|
||||
// The PropertyName child of this MemberExpression is its rightmost segment.
|
||||
let propChild = cur.lastChild;
|
||||
while (propChild && propChild.name !== "PropertyName") {
|
||||
propChild = propChild.prevSibling;
|
||||
}
|
||||
if (propChild) {
|
||||
if (skipFirst) {
|
||||
skipFirst = false;
|
||||
} else {
|
||||
segments.unshift(
|
||||
editorState.doc.sliceString(propChild.from, propChild.to)
|
||||
);
|
||||
}
|
||||
}
|
||||
// The object side is the first child of the MemberExpression
|
||||
const objectChild = cur.firstChild;
|
||||
if (!objectChild) break;
|
||||
if (objectChild.name === "VariableName") {
|
||||
// Check if this is the root "states" variable
|
||||
const varName = editorState.doc.sliceString(
|
||||
objectChild.from,
|
||||
objectChild.to
|
||||
);
|
||||
if (varName !== "states") return undefined; // not a states chain
|
||||
break; // found root
|
||||
}
|
||||
if (objectChild.name !== "MemberExpression") return undefined;
|
||||
cur = objectChild;
|
||||
}
|
||||
|
||||
// Verify we actually found a root VariableName "states" (cur must be a
|
||||
// MemberExpression whose first child is VariableName "states").
|
||||
const rootObject = cur?.firstChild;
|
||||
if (!rootObject || rootObject.name !== "VariableName") return undefined;
|
||||
if (
|
||||
editorState.doc.sliceString(rootObject.from, rootObject.to) !== "states"
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const depth = segments.length; // number of segments already committed
|
||||
|
||||
switch (depth) {
|
||||
case 0: {
|
||||
// states. → offer all unique domains
|
||||
const domains = [
|
||||
...new Set(
|
||||
Object.keys(this.hass.states).map((id) => id.split(".")[0])
|
||||
),
|
||||
].sort();
|
||||
return {
|
||||
from: completionFrom,
|
||||
options: domains.map((d) => ({ label: d, type: "variable" })),
|
||||
validFor: /^\w*$/,
|
||||
};
|
||||
}
|
||||
case 1: {
|
||||
// states.<domain>. → offer entity object_ids for that domain
|
||||
const [domain] = segments;
|
||||
const entities = Object.keys(this.hass.states)
|
||||
.filter((id) => id.startsWith(`${domain}.`))
|
||||
.map((id) => id.split(".").slice(1).join("."));
|
||||
if (!entities.length) return { from: completionFrom, options: [] };
|
||||
return {
|
||||
from: completionFrom,
|
||||
options: entities.map((e) => ({ label: e, type: "variable" })),
|
||||
validFor: /^\w*$/,
|
||||
};
|
||||
}
|
||||
case 2: {
|
||||
// states.<domain>.<entity>. → fixed state fields
|
||||
return {
|
||||
from: completionFrom,
|
||||
options: HaCodeEditor._STATE_FIELDS.map((f) => ({
|
||||
label: f,
|
||||
type: "property",
|
||||
})),
|
||||
validFor: /^\w*$/,
|
||||
};
|
||||
}
|
||||
case 3: {
|
||||
// states.<domain>.<entity>.<field>.
|
||||
const [domain, entity, field] = segments;
|
||||
if (field !== "attributes") {
|
||||
// No further completions for non-attribute fields
|
||||
return { from: completionFrom, options: [] };
|
||||
}
|
||||
// Offer attribute names from the entity's state object
|
||||
const entityId = `${domain}.${entity}`;
|
||||
const entityState = this.hass.states[entityId];
|
||||
if (!entityState) return { from: completionFrom, options: [] };
|
||||
const attrNames = Object.keys(entityState.attributes).sort();
|
||||
return {
|
||||
from: completionFrom,
|
||||
options: attrNames.map((a) => ({ label: a, type: "property" })),
|
||||
validFor: /^\w*$/,
|
||||
};
|
||||
}
|
||||
default:
|
||||
// Depth ≥ 4 — no further completions
|
||||
return { from: completionFrom, options: [] };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns completions when inside a quoted Jinja string argument of a known
|
||||
* HA function, or inside a states['...'] subscript.
|
||||
* Returns undefined to signal the caller should fall through to other logic.
|
||||
*/
|
||||
private _jinjaStringArgCompletions(
|
||||
context: CompletionContext
|
||||
): CompletionResult | null | undefined {
|
||||
const { state: editorState, pos } = context;
|
||||
const node = this._loadedCodeMirror!.syntaxTree(editorState).resolveInner(
|
||||
pos,
|
||||
-1
|
||||
);
|
||||
|
||||
// Must be inside a StringLiteral
|
||||
if (node.name !== "StringLiteral") return undefined;
|
||||
|
||||
// Case 1: states['entity_id'] — StringLiteral inside SubscriptExpression
|
||||
// whose object is the `states` variable.
|
||||
const subscript = node.parent;
|
||||
if (subscript?.name === "SubscriptExpression") {
|
||||
const obj = subscript.firstChild;
|
||||
if (obj && editorState.doc.sliceString(obj.from, obj.to) === "states") {
|
||||
return this._completionResultForArgType("entity_id", node);
|
||||
}
|
||||
}
|
||||
|
||||
// Case 2: string argument of a known HA function call.
|
||||
const argList = node.parent;
|
||||
if (argList?.name !== "ArgumentList") return undefined;
|
||||
|
||||
const callExpr = argList.parent;
|
||||
if (callExpr?.name !== "CallExpression") return undefined;
|
||||
|
||||
const fnNode = callExpr.firstChild;
|
||||
if (!fnNode) return undefined;
|
||||
|
||||
const fnName = editorState.doc.sliceString(fnNode.from, fnNode.to);
|
||||
const argTypeMap = this._jinjaFunctionArgTypes.get(fnName);
|
||||
if (!argTypeMap) return undefined;
|
||||
|
||||
// Walk ArgumentList children to find the zero-based index of this node.
|
||||
// Children are: "(" arg0 "," arg1 "," arg2 ... ")" — skip punctuation.
|
||||
let argIndex = 0;
|
||||
let child = argList.firstChild?.nextSibling; // skip opening "("
|
||||
while (child) {
|
||||
if (child.name === ")") break;
|
||||
if (child.name !== ",") {
|
||||
if (child.from === node.from) break;
|
||||
argIndex++;
|
||||
}
|
||||
child = child.nextSibling;
|
||||
}
|
||||
|
||||
const argType = argTypeMap.get(argIndex);
|
||||
if (!argType) return undefined;
|
||||
|
||||
// For attribute completions, try to resolve the entity_id from the
|
||||
// sibling argument whose type is entity_id in the same call.
|
||||
if (argType === "attribute") {
|
||||
const entityId = this._entityIdFromSiblingArg(
|
||||
argList,
|
||||
argTypeMap,
|
||||
editorState
|
||||
);
|
||||
return this._attributeCompletionResult(node, entityId);
|
||||
}
|
||||
|
||||
return this._completionResultForArgType(argType, node);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scans the ArgumentList for the first argument whose type is `entity_id`
|
||||
* and returns the literal string value it contains, or null if not found /
|
||||
* not a plain string literal.
|
||||
*/
|
||||
private _entityIdFromSiblingArg(
|
||||
argList: SyntaxNode,
|
||||
argTypeMap: Map<number, JinjaArgType>,
|
||||
editorState: CompletionContext["state"]
|
||||
): string | null {
|
||||
// Find the index of the entity_id argument in the type map.
|
||||
let entityArgIndex: number | undefined;
|
||||
for (const [idx, type] of argTypeMap) {
|
||||
if (type === "entity_id") {
|
||||
entityArgIndex = idx;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (entityArgIndex === undefined) return null;
|
||||
|
||||
// Walk the ArgumentList to find that argument node.
|
||||
let idx = 0;
|
||||
let child = argList.firstChild?.nextSibling; // skip "("
|
||||
while (child) {
|
||||
if (child.name === ")") break;
|
||||
if (child.name !== ",") {
|
||||
if (idx === entityArgIndex) {
|
||||
// child should be a StringLiteral — extract its content without quotes.
|
||||
if (child.name !== "StringLiteral") return null;
|
||||
const raw = editorState.doc.sliceString(child.from, child.to);
|
||||
// Strip surrounding quote character (single or double).
|
||||
return raw.slice(1, -1);
|
||||
}
|
||||
idx++;
|
||||
}
|
||||
child = child.nextSibling;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatches to the appropriate completion result builder for the given
|
||||
* argument type. Add new cases here as completion sources are implemented.
|
||||
*
|
||||
* Always returns a CompletionResult (never null) so that other completion
|
||||
* sources are suppressed when the cursor is inside a known typed string arg.
|
||||
* An empty options list is returned when no completions are available.
|
||||
*/
|
||||
private _completionResultForArgType(
|
||||
argType: JinjaArgType,
|
||||
stringNode: { from: number; to: number }
|
||||
): CompletionResult {
|
||||
const from = stringNode.from + 1;
|
||||
const empty: CompletionResult = { from, options: [] };
|
||||
switch (argType) {
|
||||
case "entity_id":
|
||||
return this._entityCompletionResult(stringNode) ?? empty;
|
||||
case "device_id":
|
||||
return this._deviceCompletionResult(stringNode) ?? empty;
|
||||
case "area_id":
|
||||
return this._areaCompletionResult(stringNode) ?? empty;
|
||||
case "floor_id":
|
||||
return this._floorCompletionResult(stringNode) ?? empty;
|
||||
case "label_id":
|
||||
return this._labelCompletionResult(stringNode) ?? empty;
|
||||
case "attribute":
|
||||
// No entity context available — return empty to suppress other sources.
|
||||
return empty;
|
||||
default:
|
||||
return empty;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a CompletionResult for attribute names of a specific entity.
|
||||
* `entityId` may be null when the sibling entity arg is not yet filled in,
|
||||
* in which case an empty result is returned (other sources stay suppressed).
|
||||
*/
|
||||
private _attributeCompletionResult(
|
||||
stringNode: { from: number; to: number },
|
||||
entityId: string | null
|
||||
): CompletionResult {
|
||||
const from = stringNode.from + 1;
|
||||
const empty: CompletionResult = { from, options: [] };
|
||||
if (!entityId || !this.hass) return empty;
|
||||
const entityState = this.hass.states[entityId];
|
||||
if (!entityState) return empty;
|
||||
const attrs = Object.keys(entityState.attributes).sort();
|
||||
if (!attrs.length) return empty;
|
||||
return {
|
||||
from,
|
||||
options: attrs.map((a) => ({
|
||||
label: a,
|
||||
type: "property",
|
||||
info: () => this._renderAttributeInfo(entityId, a),
|
||||
})),
|
||||
validFor: /^[\w.]*$/,
|
||||
};
|
||||
}
|
||||
|
||||
/** Build a CompletionResult for entity IDs, with `from` set inside the quotes. */
|
||||
private _entityCompletionResult(stringNode: {
|
||||
from: number;
|
||||
to: number;
|
||||
}): CompletionResult | null {
|
||||
const states = this._getStates(this.hass!.states);
|
||||
if (!states?.length) return null;
|
||||
// from is stringNode.from + 1 to skip the opening quote character.
|
||||
const from = stringNode.from + 1;
|
||||
// Always offer completions inside a known entity-string context, including
|
||||
// immediately after the opening quote (e.g. after snippet insertion).
|
||||
return {
|
||||
from,
|
||||
options: states,
|
||||
validFor: /^[\w.]*$/,
|
||||
};
|
||||
}
|
||||
|
||||
private _getDevices = memoizeOne(
|
||||
(devices: HomeAssistant["devices"]): Completion[] =>
|
||||
Object.values(devices)
|
||||
.filter((device) => !device.disabled_by)
|
||||
.map((device) => {
|
||||
const name = computeDeviceName(device);
|
||||
return {
|
||||
type: "variable",
|
||||
label: `${name} ${device.id}`,
|
||||
displayLabel: name ?? device.id,
|
||||
detail: device.id,
|
||||
apply: device.id,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
/** Build a CompletionResult for device IDs, with `from` set inside the quotes. */
|
||||
private _deviceCompletionResult(stringNode: {
|
||||
from: number;
|
||||
to: number;
|
||||
}): CompletionResult | null {
|
||||
if (!this.hass?.devices) return null;
|
||||
const devices = this._getDevices(this.hass.devices);
|
||||
if (!devices.length) return null;
|
||||
return {
|
||||
from: stringNode.from + 1,
|
||||
options: devices,
|
||||
validFor: /^[^"]*$/,
|
||||
};
|
||||
}
|
||||
|
||||
private _getAreas = memoizeOne(
|
||||
(areas: HomeAssistant["areas"]): Completion[] =>
|
||||
Object.values(areas).map((area) => {
|
||||
const name = computeAreaName(area) ?? area.area_id;
|
||||
return {
|
||||
type: "variable",
|
||||
label: `${name} ${area.area_id}`, // label is used for searching, so include both name and ID here
|
||||
displayLabel: name,
|
||||
detail: area.area_id,
|
||||
apply: area.area_id,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
/** Build a CompletionResult for area IDs, with `from` set inside the quotes. */
|
||||
private _areaCompletionResult(stringNode: {
|
||||
from: number;
|
||||
to: number;
|
||||
}): CompletionResult | null {
|
||||
if (!this.hass?.areas) return null;
|
||||
const areas = this._getAreas(this.hass.areas);
|
||||
if (!areas.length) return null;
|
||||
return {
|
||||
from: stringNode.from + 1,
|
||||
options: areas,
|
||||
validFor: /^[^"]*$/,
|
||||
};
|
||||
}
|
||||
|
||||
private _getFloors = memoizeOne(
|
||||
(floors: HomeAssistant["floors"]): Completion[] =>
|
||||
Object.values(floors).map((floor) => {
|
||||
const name = computeFloorName(floor) ?? floor.floor_id;
|
||||
return {
|
||||
type: "variable",
|
||||
label: `${name} ${floor.floor_id}`, // label is used for searching, so include both name and ID here
|
||||
displayLabel: name,
|
||||
detail: floor.floor_id,
|
||||
apply: floor.floor_id,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
/** Build a CompletionResult for floor IDs, with `from` set inside the quotes. */
|
||||
private _floorCompletionResult(stringNode: {
|
||||
from: number;
|
||||
to: number;
|
||||
}): CompletionResult | null {
|
||||
if (!this.hass?.floors) return null;
|
||||
const floors = this._getFloors(this.hass.floors);
|
||||
if (!floors.length) return null;
|
||||
return {
|
||||
from: stringNode.from + 1,
|
||||
options: floors,
|
||||
validFor: /^[^"]*$/,
|
||||
};
|
||||
}
|
||||
|
||||
private _getLabels = memoizeOne(
|
||||
(labels: LabelRegistryEntry[]): Completion[] =>
|
||||
labels.map((label) => {
|
||||
const name = label.name.trim() || label.label_id;
|
||||
return {
|
||||
type: "variable",
|
||||
label: `${name} ${label.label_id}`, // label is used for searching, so include both name and ID here
|
||||
displayLabel: name,
|
||||
detail: label.label_id,
|
||||
apply: label.label_id,
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
/** Build a CompletionResult for label IDs, with `from` set inside the quotes. */
|
||||
private _labelCompletionResult(stringNode: {
|
||||
from: number;
|
||||
to: number;
|
||||
}): CompletionResult | null {
|
||||
if (!this._labels?.length) return null;
|
||||
const labels = this._getLabels(this._labels);
|
||||
if (!labels.length) return null;
|
||||
return {
|
||||
from: stringNode.from + 1,
|
||||
options: labels,
|
||||
validFor: /^[^"]*$/,
|
||||
};
|
||||
}
|
||||
|
||||
private _entityCompletions(
|
||||
context: CompletionContext
|
||||
): CompletionResult | null | Promise<CompletionResult | null> {
|
||||
// Jinja context: offer entity completions inside string arguments of
|
||||
// entity-accepting functions, and inside states['...'] subscripts.
|
||||
if (this.mode === "yaml" || this.mode === "jinja2") {
|
||||
// First try states.<domain>.<entity>.<field> dot-notation completions.
|
||||
const statesDotResult = this._statesDotNotationCompletions(context);
|
||||
if (statesDotResult !== undefined) {
|
||||
return statesDotResult;
|
||||
}
|
||||
|
||||
const jinjaEntityResult = this._jinjaStringArgCompletions(context);
|
||||
if (jinjaEntityResult !== undefined) {
|
||||
return jinjaEntityResult;
|
||||
}
|
||||
}
|
||||
|
||||
// Check for YAML mode and entity-related fields
|
||||
if (this.mode === "yaml") {
|
||||
const currentLine = context.state.doc.lineAt(context.pos);
|
||||
@@ -819,8 +1425,16 @@ export class HaCodeEditor extends ReactiveElement {
|
||||
const listItemMatch = lineText.match(/^\s*-\s+/);
|
||||
|
||||
if (entityFieldMatch) {
|
||||
// Calculate the position after the entity field
|
||||
// Calculate the position after the entity field key+colon.
|
||||
// The regex consumes trailing spaces too, so afterField lands right
|
||||
// where the entity ID should start. If the cursor is sitting directly
|
||||
// after the colon with no space (e.g. "entity:|"), we need to insert
|
||||
// a space before the entity ID, so we shift `from` back to the colon
|
||||
// and use an `apply` that prepends the space.
|
||||
const afterField = currentLine.from + entityFieldMatch[0].length;
|
||||
const needsSpace =
|
||||
afterField > 0 &&
|
||||
context.state.doc.sliceString(afterField - 1, afterField) === ":";
|
||||
|
||||
// If cursor is after the entity field, show all entities
|
||||
if (context.pos >= afterField) {
|
||||
@@ -842,9 +1456,13 @@ export class HaCodeEditor extends ReactiveElement {
|
||||
)
|
||||
: states;
|
||||
|
||||
const options = needsSpace
|
||||
? filteredStates.map((s) => ({ ...s, apply: ` ${s.label}` }))
|
||||
: filteredStates;
|
||||
|
||||
return {
|
||||
from: afterField,
|
||||
options: filteredStates,
|
||||
options,
|
||||
validFor: /^[a-z_]*\.?\w*$/,
|
||||
};
|
||||
}
|
||||
@@ -919,7 +1537,13 @@ export class HaCodeEditor extends ReactiveElement {
|
||||
}
|
||||
}
|
||||
|
||||
// Original entity completion logic for non-YAML or when not in entity_id field
|
||||
// Original entity completion logic for non-YAML or when not in entity_id field.
|
||||
// Not used in jinja2 mode — Jinja string-arg completions are handled above via
|
||||
// _jinjaStringArgCompletions() which is context-aware.
|
||||
if (this.mode === "jinja2") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const entityWord = context.matchBefore(/[a-z_]{3,}\.\w*/);
|
||||
|
||||
if (
|
||||
@@ -989,17 +1613,6 @@ export class HaCodeEditor extends ReactiveElement {
|
||||
fireEvent(this, "value-changed", { value: this._value });
|
||||
};
|
||||
|
||||
private _getFoldingExtensions = (): Extension => {
|
||||
if (this.mode === "yaml") {
|
||||
return [
|
||||
this._loadedCodeMirror!.foldGutter(),
|
||||
this._loadedCodeMirror!.foldingOnIndent,
|
||||
];
|
||||
}
|
||||
|
||||
return [];
|
||||
};
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
haStyleScrollbar,
|
||||
|
||||
@@ -84,6 +84,7 @@ export class HaFilterDevices extends LitElement {
|
||||
.keyFunction=${this._keyFunction}
|
||||
.renderItem=${this._renderItem}
|
||||
@click=${this._handleItemClick}
|
||||
@keydown=${this._handleItemKeydown}
|
||||
>
|
||||
</lit-virtualizer>
|
||||
</ha-list>`
|
||||
@@ -98,6 +99,7 @@ export class HaFilterDevices extends LitElement {
|
||||
!device
|
||||
? nothing
|
||||
: html`<ha-check-list-item
|
||||
tabindex="0"
|
||||
.value=${device.id}
|
||||
.selected=${this.value?.includes(device.id) ?? false}
|
||||
>
|
||||
@@ -108,6 +110,13 @@ export class HaFilterDevices extends LitElement {
|
||||
)}
|
||||
</ha-check-list-item>`;
|
||||
|
||||
private _handleItemKeydown(ev: KeyboardEvent) {
|
||||
if (ev.key === "Enter" || ev.key === " ") {
|
||||
ev.preventDefault();
|
||||
this._handleItemClick(ev);
|
||||
}
|
||||
}
|
||||
|
||||
private _handleItemClick(ev) {
|
||||
const listItem = ev.target.closest("ha-check-list-item");
|
||||
const value = listItem?.value;
|
||||
|
||||
@@ -58,7 +58,7 @@ export class HaFilterDomains extends LitElement {
|
||||
</ha-input-search>
|
||||
<ha-list
|
||||
class="ha-scrollbar"
|
||||
@click=${this._handleItemClick}
|
||||
@selected=${this._handleItemSelected}
|
||||
multi
|
||||
>
|
||||
${repeat(
|
||||
@@ -126,19 +126,16 @@ export class HaFilterDomains extends LitElement {
|
||||
this.expanded = ev.detail.expanded;
|
||||
}
|
||||
|
||||
private _handleItemClick(ev) {
|
||||
const listItem = ev.target.closest("ha-check-list-item");
|
||||
const value = listItem?.value;
|
||||
if (!value) {
|
||||
return;
|
||||
private _handleItemSelected(
|
||||
ev: CustomEvent<{ diff: { added: number[]; removed: number[] } }>
|
||||
) {
|
||||
const domains = this._domains(this.hass.states, this._filter);
|
||||
if (ev.detail.diff.added.length) {
|
||||
this.value = [...(this.value || []), domains[ev.detail.diff.added[0]]];
|
||||
} else if (ev.detail.diff.removed.length) {
|
||||
const removedDomain = domains[ev.detail.diff.removed[0]];
|
||||
this.value = this.value?.filter((value) => value !== removedDomain);
|
||||
}
|
||||
if (this.value?.includes(value)) {
|
||||
this.value = this.value?.filter((val) => val !== value);
|
||||
} else {
|
||||
this.value = [...(this.value || []), value];
|
||||
}
|
||||
|
||||
listItem.selected = this.value.includes(value);
|
||||
|
||||
fireEvent(this, "data-table-filter-changed", {
|
||||
value: this.value,
|
||||
|
||||
@@ -88,6 +88,7 @@ export class HaFilterEntities extends LitElement {
|
||||
.keyFunction=${this._keyFunction}
|
||||
.renderItem=${this._renderItem}
|
||||
@click=${this._handleItemClick}
|
||||
@keydown=${this._handleItemKeydown}
|
||||
>
|
||||
</lit-virtualizer>
|
||||
</ha-list>
|
||||
@@ -116,6 +117,7 @@ export class HaFilterEntities extends LitElement {
|
||||
!entity
|
||||
? nothing
|
||||
: html`<ha-check-list-item
|
||||
tabindex="0"
|
||||
.value=${entity.entity_id}
|
||||
.selected=${this.value?.includes(entity.entity_id) ?? false}
|
||||
graphic="icon"
|
||||
@@ -128,6 +130,13 @@ export class HaFilterEntities extends LitElement {
|
||||
${computeStateName(entity)}
|
||||
</ha-check-list-item>`;
|
||||
|
||||
private _handleItemKeydown(ev: KeyboardEvent) {
|
||||
if (ev.key === "Enter" || ev.key === " ") {
|
||||
ev.preventDefault();
|
||||
this._handleItemClick(ev);
|
||||
}
|
||||
}
|
||||
|
||||
private _handleItemClick(ev) {
|
||||
const listItem = ev.target.closest("ha-check-list-item");
|
||||
const value = listItem?.value;
|
||||
|
||||
@@ -88,6 +88,7 @@ export class HaFilterFloorAreas extends LitElement {
|
||||
) || false}
|
||||
graphic="icon"
|
||||
@request-selected=${this._handleItemClick}
|
||||
@keydown=${this._handleItemKeydown}
|
||||
>
|
||||
<ha-floor-icon
|
||||
slot="graphic"
|
||||
@@ -125,6 +126,7 @@ export class HaFilterFloorAreas extends LitElement {
|
||||
.type=${"areas"}
|
||||
graphic="icon"
|
||||
@request-selected=${this._handleItemClick}
|
||||
@keydown=${this._handleItemKeydown}
|
||||
class=${classMap({
|
||||
rtl: computeRTL(this.hass),
|
||||
floor: hasFloor,
|
||||
@@ -149,6 +151,13 @@ export class HaFilterFloorAreas extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
private _handleItemKeydown(ev) {
|
||||
if (ev.key === " " || ev.key === "Enter") {
|
||||
ev.preventDefault();
|
||||
this._handleItemClick(ev);
|
||||
}
|
||||
}
|
||||
|
||||
private _handleItemClick(ev) {
|
||||
ev.stopPropagation();
|
||||
|
||||
|
||||
@@ -61,7 +61,7 @@ export class HaFilterIntegrations extends LitElement {
|
||||
</ha-input-search>
|
||||
<ha-list
|
||||
class="ha-scrollbar"
|
||||
@click=${this._handleItemClick}
|
||||
@selected=${this._itemSelected}
|
||||
multi
|
||||
>
|
||||
${repeat(
|
||||
@@ -147,18 +147,25 @@ export class HaFilterIntegrations extends LitElement {
|
||||
)
|
||||
);
|
||||
|
||||
private _handleItemClick(ev) {
|
||||
const listItem = ev.target.closest("ha-check-list-item");
|
||||
const value = listItem?.value;
|
||||
if (!value) {
|
||||
return;
|
||||
private _itemSelected(
|
||||
ev: CustomEvent<{ diff: { added: number[]; removed: number[] } }>
|
||||
) {
|
||||
const integrations = this._integrations(
|
||||
this.hass.localize,
|
||||
this._manifests!,
|
||||
this._filter,
|
||||
this.value
|
||||
);
|
||||
|
||||
if (ev.detail.diff.added.length) {
|
||||
this.value = [
|
||||
...(this.value || []),
|
||||
integrations[ev.detail.diff.added[0]].domain,
|
||||
];
|
||||
} else if (ev.detail.diff.removed.length) {
|
||||
const removedDomain = integrations[ev.detail.diff.removed[0]].domain;
|
||||
this.value = this.value?.filter((val) => val !== removedDomain);
|
||||
}
|
||||
if (this.value?.includes(value)) {
|
||||
this.value = this.value?.filter((val) => val !== value);
|
||||
} else {
|
||||
this.value = [...(this.value || []), value];
|
||||
}
|
||||
listItem.selected = this.value?.includes(value);
|
||||
|
||||
fireEvent(this, "data-table-filter-changed", {
|
||||
value: this.value,
|
||||
|
||||
@@ -79,6 +79,7 @@ export const computeInitialHaFormData = (
|
||||
"attribute" in selector ||
|
||||
"file" in selector ||
|
||||
"icon" in selector ||
|
||||
"serial" in selector ||
|
||||
"template" in selector ||
|
||||
"text" in selector ||
|
||||
"theme" in selector ||
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import type { TemplateResult } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { css, html, LitElement } from "lit";
|
||||
import { customElement, property, query } from "lit/decorators";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import "../ha-checkbox";
|
||||
import type { HaCheckbox } from "../ha-checkbox";
|
||||
import type {
|
||||
HaFormBooleanData,
|
||||
HaFormBooleanSchema,
|
||||
HaFormElement,
|
||||
} from "./types";
|
||||
import type { HaCheckbox } from "../ha-checkbox";
|
||||
import "../ha-checkbox";
|
||||
import "../ha-formfield";
|
||||
|
||||
@customElement("ha-form-boolean")
|
||||
export class HaFormBoolean extends LitElement implements HaFormElement {
|
||||
@@ -33,19 +32,14 @@ export class HaFormBoolean extends LitElement implements HaFormElement {
|
||||
|
||||
protected render(): TemplateResult {
|
||||
return html`
|
||||
<ha-formfield .label=${this.label}>
|
||||
<ha-checkbox
|
||||
.checked=${this.data}
|
||||
.disabled=${this.disabled}
|
||||
@change=${this._valueChanged}
|
||||
></ha-checkbox>
|
||||
<span slot="label">
|
||||
<p class="primary">${this.label}</p>
|
||||
${this.helper
|
||||
? html`<p class="secondary">${this.helper}</p>`
|
||||
: nothing}
|
||||
</span>
|
||||
</ha-formfield>
|
||||
<ha-checkbox
|
||||
.checked=${this.data}
|
||||
.disabled=${this.disabled}
|
||||
@change=${this._valueChanged}
|
||||
.hint=${this.helper}
|
||||
>
|
||||
${this.label}
|
||||
</ha-checkbox>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -56,25 +50,12 @@ export class HaFormBoolean extends LitElement implements HaFormElement {
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
ha-formfield {
|
||||
display: flex;
|
||||
ha-checkbox {
|
||||
min-height: 56px;
|
||||
justify-content: center;
|
||||
}
|
||||
ha-checkbox::part(base) {
|
||||
align-items: center;
|
||||
--mdc-typography-body2-font-size: 1em;
|
||||
}
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
.secondary {
|
||||
direction: var(--direction);
|
||||
padding-top: 4px;
|
||||
box-sizing: border-box;
|
||||
color: var(--secondary-text-color);
|
||||
font-size: 0.875rem;
|
||||
font-weight: var(
|
||||
--mdc-typography-body2-font-weight,
|
||||
var(--ha-font-weight-normal)
|
||||
);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -199,10 +199,15 @@ export class HaFormInteger extends LitElement implements HaFormElement {
|
||||
}
|
||||
.flex {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--ha-space-3);
|
||||
}
|
||||
ha-slider {
|
||||
flex: 1;
|
||||
}
|
||||
ha-input-helper-text {
|
||||
margin-top: var(--ha-space-1);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ import "../ha-checkbox";
|
||||
import type { HaCheckbox } from "../ha-checkbox";
|
||||
import "../ha-dropdown";
|
||||
import "../ha-dropdown-item";
|
||||
import "../ha-formfield";
|
||||
import "../ha-icon-button";
|
||||
import "../ha-picker-field";
|
||||
|
||||
@@ -63,14 +62,14 @@ export class HaFormMultiSelect extends LitElement implements HaFormElement {
|
||||
${this.label}${options.map((item: string | [string, string]) => {
|
||||
const value = optionValue(item);
|
||||
return html`
|
||||
<ha-formfield .label=${optionLabel(item)}>
|
||||
<ha-checkbox
|
||||
.checked=${data.includes(value)}
|
||||
.value=${value}
|
||||
.disabled=${this.disabled}
|
||||
@change=${this._valueChanged}
|
||||
></ha-checkbox>
|
||||
</ha-formfield>
|
||||
<ha-checkbox
|
||||
.checked=${data.includes(value)}
|
||||
.value=${value}
|
||||
.disabled=${this.disabled}
|
||||
@change=${this._valueChanged}
|
||||
>
|
||||
${optionLabel(item)}
|
||||
</ha-checkbox>
|
||||
`;
|
||||
})}
|
||||
</div> `;
|
||||
@@ -192,11 +191,12 @@ export class HaFormMultiSelect extends LitElement implements HaFormElement {
|
||||
ha-dropdown {
|
||||
display: block;
|
||||
}
|
||||
ha-formfield {
|
||||
display: block;
|
||||
padding-right: 16px;
|
||||
padding-inline-end: 16px;
|
||||
ha-checkbox {
|
||||
display: flex;
|
||||
padding-inline-end: var(--ha-space-4);
|
||||
padding-inline-start: initial;
|
||||
min-height: 40px;
|
||||
justify-content: center;
|
||||
direction: var(--direction);
|
||||
}
|
||||
ha-icon-button {
|
||||
|
||||
@@ -46,6 +46,18 @@ export class HaGauge extends LitElement {
|
||||
|
||||
@state() private _segment_label?: string = "";
|
||||
|
||||
private _sortedLevels?: LevelDefinition[];
|
||||
|
||||
private _rescaleOnConnect = false;
|
||||
|
||||
public connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
if (this._rescaleOnConnect) {
|
||||
this._rescaleSvg();
|
||||
this._rescaleOnConnect = false;
|
||||
}
|
||||
}
|
||||
|
||||
protected firstUpdated(changedProperties: PropertyValues) {
|
||||
super.firstUpdated(changedProperties);
|
||||
afterNextRender(() => {
|
||||
@@ -58,6 +70,26 @@ export class HaGauge extends LitElement {
|
||||
});
|
||||
}
|
||||
|
||||
protected willUpdate(changedProperties: PropertyValues) {
|
||||
if (changedProperties.has("levels") || changedProperties.has("min")) {
|
||||
if (this.levels) {
|
||||
this._sortedLevels = [...this.levels].sort((a, b) => a.level - b.level);
|
||||
|
||||
if (
|
||||
this._sortedLevels.length > 0 &&
|
||||
this._sortedLevels[0].level !== this.min
|
||||
) {
|
||||
this._sortedLevels.unshift({
|
||||
level: this.min,
|
||||
stroke: "var(--info-color)",
|
||||
});
|
||||
}
|
||||
} else {
|
||||
this._sortedLevels = undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected updated(changedProperties: PropertyValues) {
|
||||
super.updated(changedProperties);
|
||||
if (
|
||||
@@ -90,88 +122,61 @@ export class HaGauge extends LitElement {
|
||||
/>
|
||||
|
||||
|
||||
${
|
||||
this.levels
|
||||
? (() => {
|
||||
const sortedLevels = [...this.levels].sort(
|
||||
(a, b) => a.level - b.level
|
||||
);
|
||||
${this._sortedLevels?.map((level, i, arr) => {
|
||||
const startLevel = level.level;
|
||||
const endLevel = i + 1 < arr.length ? arr[i + 1].level : this.max;
|
||||
|
||||
if (
|
||||
sortedLevels.length > 0 &&
|
||||
sortedLevels[0].level !== this.min
|
||||
) {
|
||||
sortedLevels.unshift({
|
||||
level: this.min,
|
||||
stroke: "var(--info-color)",
|
||||
});
|
||||
}
|
||||
const startAngle = getAngle(startLevel, this.min, this.max);
|
||||
const endAngle = getAngle(endLevel, this.min, this.max);
|
||||
const largeArc = endAngle - startAngle > 180 ? 1 : 0;
|
||||
|
||||
return sortedLevels.map((level, i, arr) => {
|
||||
const startLevel = level.level;
|
||||
const endLevel =
|
||||
i + 1 < arr.length ? arr[i + 1].level : this.max;
|
||||
const x1 = -arcRadius * Math.cos((startAngle * Math.PI) / 180);
|
||||
const y1 = -arcRadius * Math.sin((startAngle * Math.PI) / 180);
|
||||
|
||||
const startAngle = getAngle(startLevel, this.min, this.max);
|
||||
const endAngle = getAngle(endLevel, this.min, this.max);
|
||||
const largeArc = endAngle - startAngle > 180 ? 1 : 0;
|
||||
const isFirst = i === 0;
|
||||
const isLast = i === arr.length - 1;
|
||||
|
||||
const x1 =
|
||||
-arcRadius * Math.cos((startAngle * Math.PI) / 180);
|
||||
const y1 =
|
||||
-arcRadius * Math.sin((startAngle * Math.PI) / 180);
|
||||
const x2 = -arcRadius * Math.cos((endAngle * Math.PI) / 180);
|
||||
const y2 = -arcRadius * Math.sin((endAngle * Math.PI) / 180);
|
||||
if (isFirst) {
|
||||
return svg`
|
||||
<path
|
||||
class="level"
|
||||
stroke="${level.stroke}"
|
||||
style="stroke-linecap: butt"
|
||||
d="M ${x1} ${y1} A ${arcRadius} ${arcRadius} 0 ${largeArc} 1 40 0"
|
||||
/>
|
||||
`;
|
||||
}
|
||||
|
||||
const isFirst = i === 0;
|
||||
const isLast = i === arr.length - 1;
|
||||
if (isLast) {
|
||||
const offsetAngle = 0.5;
|
||||
const midAngle = endAngle - offsetAngle;
|
||||
const xm = -arcRadius * Math.cos((midAngle * Math.PI) / 180);
|
||||
const ym = -arcRadius * Math.sin((midAngle * Math.PI) / 180);
|
||||
|
||||
if (isFirst) {
|
||||
return svg`
|
||||
<path
|
||||
class="level"
|
||||
stroke="${level.stroke}"
|
||||
style="stroke-linecap: butt"
|
||||
d="M ${x1} ${y1} A ${arcRadius} ${arcRadius} 0 ${largeArc} 1 ${x2} ${y2}"
|
||||
/>
|
||||
`;
|
||||
}
|
||||
return svg`
|
||||
<path class="level" stroke="${level.stroke}" style="stroke-linecap: butt"
|
||||
d="M ${x1} ${y1} A ${arcRadius} ${arcRadius} 0 ${largeArc} 1 40 0" />
|
||||
<path class="level" stroke="${level.stroke}" style="stroke-linecap: butt"
|
||||
d="M ${xm} ${ym} A ${arcRadius} ${arcRadius} 0 0 1 40 0" />
|
||||
`;
|
||||
}
|
||||
|
||||
if (isLast) {
|
||||
const offsetAngle = 0.5;
|
||||
const midAngle = endAngle - offsetAngle;
|
||||
const xm =
|
||||
-arcRadius * Math.cos((midAngle * Math.PI) / 180);
|
||||
const ym =
|
||||
-arcRadius * Math.sin((midAngle * Math.PI) / 180);
|
||||
|
||||
return svg`
|
||||
<path class="level" stroke="${level.stroke}" style="stroke-linecap: butt"
|
||||
d="M ${x1} ${y1} A ${arcRadius} ${arcRadius} 0 ${largeArc} 1 ${xm} ${ym}" />
|
||||
<path class="level" stroke="${level.stroke}" style="stroke-linecap: butt"
|
||||
d="M ${xm} ${ym} A ${arcRadius} ${arcRadius} 0 0 1 ${x2} ${y2}" />
|
||||
`;
|
||||
}
|
||||
|
||||
return svg`
|
||||
<path
|
||||
class="level"
|
||||
stroke="${level.stroke}"
|
||||
style="stroke-linecap: butt"
|
||||
d="M ${x1} ${y1} A ${arcRadius} ${arcRadius} 0 ${largeArc} 1 ${x2} ${y2}"
|
||||
></path>
|
||||
`;
|
||||
});
|
||||
})()
|
||||
: ""
|
||||
}
|
||||
return svg`
|
||||
<path
|
||||
class="level"
|
||||
stroke="${level.stroke}"
|
||||
style="stroke-linecap: butt"
|
||||
d="M ${x1} ${y1} A ${arcRadius} ${arcRadius} 0 ${largeArc} 1 40 0"
|
||||
></path>
|
||||
`;
|
||||
})}
|
||||
|
||||
${
|
||||
this.needle
|
||||
? svg`
|
||||
<path
|
||||
class="needle"
|
||||
d="M -34,-3 L -48,-1 A 1,1,0,0,0,-48,1 L -34,3 A 2,2,0,0,0,-34,-3 Z"
|
||||
d="M -34,-3 L -40,-1 A 1,1,0,0,0,-40,1 L -34,3 A 2,2,0,0,0,-34,-3 Z"
|
||||
|
||||
style=${styleMap({ transform: `rotate(${this._angle}deg)` })}
|
||||
/>
|
||||
@@ -215,6 +220,13 @@ export class HaGauge extends LitElement {
|
||||
// Set the viewbox of the SVG containing the value to perfectly
|
||||
// fit the text
|
||||
// That way it will auto-scale correctly
|
||||
|
||||
if (!this.isConnected) {
|
||||
// Retry this later if we're disconnected, otherwise we get a 0 bbox and missing label
|
||||
this._rescaleOnConnect = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const svgRoot = this.shadowRoot!.querySelector(".text")!;
|
||||
const box = svgRoot.querySelector("text")!.getBBox()!;
|
||||
svgRoot.setAttribute(
|
||||
@@ -224,11 +236,10 @@ export class HaGauge extends LitElement {
|
||||
}
|
||||
|
||||
private _getSegmentLabel() {
|
||||
if (this.levels) {
|
||||
[...this.levels].sort((a, b) => a.level - b.level);
|
||||
for (let i = this.levels.length - 1; i >= 0; i--) {
|
||||
if (this.value >= this.levels[i].level) {
|
||||
return this.levels[i].label;
|
||||
if (this._sortedLevels) {
|
||||
for (let i = this._sortedLevels.length - 1; i >= 0; i--) {
|
||||
if (this.value >= this._sortedLevels[i].level) {
|
||||
return this._sortedLevels[i].label;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,6 +30,19 @@ const debouncedWriteCache = debounce(() => writeCache(chunks), 2000);
|
||||
|
||||
const cachedIcons: Record<string, string> = {};
|
||||
|
||||
const CUSTOM_ICONS: Record<string, () => Promise<string>> = {
|
||||
"home-assistant": () =>
|
||||
import("../resources/home-assistant-logo-svg").then(
|
||||
(mod) => mod.mdiHomeAssistant
|
||||
),
|
||||
"music-assistant": () =>
|
||||
import("../resources/music-assistant-logo-svg").then(
|
||||
(mod) => mod.mdiMusicAssistant
|
||||
),
|
||||
esphome: () =>
|
||||
import("../resources/esphome-logo-svg").then((mod) => mod.mdiEsphomeLogo),
|
||||
};
|
||||
|
||||
@customElement("ha-icon")
|
||||
export class HaIcon extends LitElement {
|
||||
@property() public icon?: string;
|
||||
@@ -117,10 +130,8 @@ export class HaIcon extends LitElement {
|
||||
return;
|
||||
}
|
||||
|
||||
if (iconName === "home-assistant") {
|
||||
const icon = (await import("../resources/home-assistant-logo-svg"))
|
||||
.mdiHomeAssistant;
|
||||
|
||||
if (iconName in CUSTOM_ICONS) {
|
||||
const icon = await CUSTOM_ICONS[iconName]();
|
||||
if (this.icon === requestedIcon) {
|
||||
this._path = icon;
|
||||
}
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
import Fuse from "fuse.js";
|
||||
import { mdiDevices, mdiTextureBox } from "@mdi/js";
|
||||
import { html, LitElement, nothing, type PropertyValues } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { isComponentLoaded } from "../common/config/is_component_loaded";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import { subscribeOneCollection } from "../common/util/subscribe-one";
|
||||
import { caseInsensitiveStringCompare } from "../common/string/compare";
|
||||
import { titleCase } from "../common/string/title-case";
|
||||
import { getConfigEntries, type ConfigEntry } from "../data/config_entries";
|
||||
import { getIngressPanelInfoCollection } from "../data/hassio/ingress";
|
||||
import { fetchConfig } from "../data/lovelace/config/types";
|
||||
import { getPanelIcon, getPanelTitle, SYSTEM_PANELS } from "../data/panel";
|
||||
import { SYSTEM_PANELS } from "../data/panel";
|
||||
import {
|
||||
CONFIG_SUB_ROUTES,
|
||||
computeNavigationPathInfo,
|
||||
} from "../data/compute-navigation-path-info";
|
||||
import { findRelated, type RelatedResult } from "../data/search";
|
||||
import { PANEL_DASHBOARDS } from "../panels/config/lovelace/dashboards/ha-config-lovelace-dashboards";
|
||||
import { computeAreaPath } from "../panels/lovelace/strategies/areas/helpers/areas-strategy-helper";
|
||||
@@ -23,7 +28,12 @@ import {
|
||||
type PickerComboBoxItem,
|
||||
} from "./ha-picker-combo-box";
|
||||
|
||||
type NavigationGroup = "related" | "dashboards" | "views" | "other_routes";
|
||||
type NavigationGroup =
|
||||
| "related"
|
||||
| "dashboards"
|
||||
| "views"
|
||||
| "apps"
|
||||
| "other_routes";
|
||||
|
||||
const RELATED_SORT_PREFIX = {
|
||||
area_view: "0_area_view",
|
||||
@@ -31,6 +41,12 @@ const RELATED_SORT_PREFIX = {
|
||||
device: "2_device",
|
||||
} as const;
|
||||
|
||||
const createSortingLabel = (...parts: (string | undefined)[]) =>
|
||||
parts
|
||||
.filter(Boolean)
|
||||
.map((part) => (part!.startsWith("/") ? `zzz${part}` : part))
|
||||
.join("_");
|
||||
|
||||
interface NavigationItem extends PickerComboBoxItem {
|
||||
group: NavigationGroup;
|
||||
domain?: string;
|
||||
@@ -50,6 +66,10 @@ export class HaNavigationPicker extends LitElement {
|
||||
|
||||
@property({ type: Boolean }) public required = false;
|
||||
|
||||
@property({ attribute: false }) public excludePaths?: string[];
|
||||
|
||||
@property({ attribute: "add-button-label" }) public addButtonLabel?: string;
|
||||
|
||||
@state() private _loading = true;
|
||||
|
||||
@property({ attribute: false }) public context?: ActionRelatedContext;
|
||||
@@ -66,6 +86,7 @@ export class HaNavigationPicker extends LitElement {
|
||||
related: [],
|
||||
dashboards: [],
|
||||
views: [],
|
||||
apps: [],
|
||||
other_routes: [],
|
||||
};
|
||||
|
||||
@@ -95,6 +116,14 @@ export class HaNavigationPicker extends LitElement {
|
||||
id: "views",
|
||||
label: this.hass.localize("ui.components.navigation-picker.views"),
|
||||
},
|
||||
...(this._navigationGroups.apps.length
|
||||
? [
|
||||
{
|
||||
id: "apps",
|
||||
label: this.hass.localize("ui.components.navigation-picker.apps"),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
id: "other_routes",
|
||||
label: this.hass.localize(
|
||||
@@ -119,6 +148,7 @@ export class HaNavigationPicker extends LitElement {
|
||||
.customValueLabel=${this.hass.localize(
|
||||
"ui.components.navigation-picker.add_custom_path"
|
||||
)}
|
||||
.addButtonLabel=${this.addButtonLabel}
|
||||
@value-changed=${this._valueChanged}
|
||||
>
|
||||
</ha-generic-picker>
|
||||
@@ -186,17 +216,28 @@ export class HaNavigationPicker extends LitElement {
|
||||
views: memoizeOne((items: NavigationItem[]) =>
|
||||
Fuse.createIndex(DEFAULT_SEARCH_KEYS, items)
|
||||
),
|
||||
apps: memoizeOne((items: NavigationItem[]) =>
|
||||
Fuse.createIndex(DEFAULT_SEARCH_KEYS, items)
|
||||
),
|
||||
other_routes: memoizeOne((items: NavigationItem[]) =>
|
||||
Fuse.createIndex(DEFAULT_SEARCH_KEYS, items)
|
||||
),
|
||||
};
|
||||
|
||||
private _getItems = (searchString?: string, section?: string) => {
|
||||
const excludeSet = this.excludePaths
|
||||
? new Set(this.excludePaths)
|
||||
: undefined;
|
||||
|
||||
const getGroupItems = (group: NavigationGroup) => {
|
||||
let items = [...this._navigationGroups[group]].sort(
|
||||
this._sortBySortingLabel
|
||||
);
|
||||
|
||||
if (excludeSet) {
|
||||
items = items.filter((item) => !excludeSet.has(item.id));
|
||||
}
|
||||
|
||||
if (searchString) {
|
||||
const fuseIndex = this._fuseIndexes[group](items);
|
||||
items = multiTermSortedSearch(
|
||||
@@ -216,6 +257,7 @@ export class HaNavigationPicker extends LitElement {
|
||||
const related = getGroupItems("related");
|
||||
const dashboards = getGroupItems("dashboards");
|
||||
const views = getGroupItems("views");
|
||||
const apps = getGroupItems("apps");
|
||||
const otherRoutes = getGroupItems("other_routes");
|
||||
|
||||
const addGroup = (group: NavigationGroup, groupItems: NavigationItem[]) => {
|
||||
@@ -233,6 +275,7 @@ export class HaNavigationPicker extends LitElement {
|
||||
addGroup("related", related);
|
||||
addGroup("dashboards", dashboards);
|
||||
addGroup("views", views);
|
||||
addGroup("apps", apps);
|
||||
addGroup("other_routes", otherRoutes);
|
||||
|
||||
return items;
|
||||
@@ -271,27 +314,24 @@ export class HaNavigationPicker extends LitElement {
|
||||
const related = this._navigationGroups.related;
|
||||
const dashboards: NavigationItem[] = [];
|
||||
const views: NavigationItem[] = [];
|
||||
const apps: NavigationItem[] = [];
|
||||
const otherRoutes: NavigationItem[] = [];
|
||||
|
||||
for (const panel of panels) {
|
||||
if (SYSTEM_PANELS.includes(panel.id)) continue;
|
||||
// Skip app panels — they are handled by the ingress panels fetch below
|
||||
if (panel.component_name === "app") continue;
|
||||
const path = `/${panel.url_path}`;
|
||||
const panelTitle = getPanelTitle(this.hass, panel);
|
||||
const primary = panelTitle || path;
|
||||
const resolved = computeNavigationPathInfo(this.hass!, path);
|
||||
const isDashboardPanel =
|
||||
panel.component_name === "lovelace" ||
|
||||
PANEL_DASHBOARDS.includes(panel.id);
|
||||
const panelItem: NavigationItem = {
|
||||
id: path,
|
||||
primary,
|
||||
secondary: panelTitle ? path : undefined,
|
||||
icon: getPanelIcon(panel) || "mdi:view-dashboard",
|
||||
sorting_label: [
|
||||
primary.startsWith("/") ? `zzz${primary}` : primary,
|
||||
path,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join("_"),
|
||||
primary: resolved.label,
|
||||
secondary: resolved.label !== path ? path : undefined,
|
||||
icon: resolved.icon || "mdi:view-dashboard",
|
||||
sorting_label: createSortingLabel(resolved.label, path),
|
||||
group: isDashboardPanel ? "dashboards" : "other_routes",
|
||||
};
|
||||
|
||||
@@ -307,26 +347,69 @@ export class HaNavigationPicker extends LitElement {
|
||||
|
||||
config.views.forEach((view, index) => {
|
||||
const viewPath = `/${panel.url_path}/${view.path ?? index}`;
|
||||
const viewPrimary =
|
||||
view.title ?? (view.path ? titleCase(view.path) : `${index}`);
|
||||
const viewInfo = computeNavigationPathInfo(
|
||||
this.hass!,
|
||||
viewPath,
|
||||
config
|
||||
);
|
||||
views.push({
|
||||
id: viewPath,
|
||||
secondary: viewPath,
|
||||
icon: view.icon ?? "mdi:view-compact",
|
||||
primary: viewPrimary,
|
||||
sorting_label: [
|
||||
viewPrimary.startsWith("/") ? `zzz${viewPrimary}` : viewPrimary,
|
||||
viewPath,
|
||||
].join("_"),
|
||||
icon: viewInfo.icon || "mdi:view-compact",
|
||||
primary: viewInfo.label,
|
||||
sorting_label: createSortingLabel(viewInfo.label, viewPath),
|
||||
group: "views",
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Fetch all ingress add-on panels
|
||||
if (isComponentLoaded(this.hass!.config, "hassio")) {
|
||||
try {
|
||||
const ingressPanels = await subscribeOneCollection(
|
||||
getIngressPanelInfoCollection(this.hass!.connection)
|
||||
);
|
||||
for (const slug of Object.keys(ingressPanels)) {
|
||||
const path = `/app/${slug}`;
|
||||
const resolved = computeNavigationPathInfo(
|
||||
this.hass!,
|
||||
path,
|
||||
undefined,
|
||||
ingressPanels
|
||||
);
|
||||
apps.push({
|
||||
id: path,
|
||||
primary: resolved.label,
|
||||
secondary: path,
|
||||
icon: resolved.icon,
|
||||
icon_path: resolved.iconPath,
|
||||
sorting_label: createSortingLabel(resolved.label, path),
|
||||
group: "apps",
|
||||
});
|
||||
}
|
||||
} catch (_err) {
|
||||
// Supervisor may not be available, silently ignore
|
||||
}
|
||||
}
|
||||
|
||||
for (const [subPath, route] of Object.entries(CONFIG_SUB_ROUTES)) {
|
||||
const path = `/config/${subPath}`;
|
||||
const label = this.hass!.localize(route.translationKey) || subPath;
|
||||
otherRoutes.push({
|
||||
id: path,
|
||||
primary: label,
|
||||
secondary: path,
|
||||
icon_path: route.iconPath,
|
||||
sorting_label: createSortingLabel(label, path),
|
||||
group: "other_routes",
|
||||
});
|
||||
}
|
||||
|
||||
this._navigationGroups = {
|
||||
related,
|
||||
dashboards,
|
||||
views,
|
||||
apps,
|
||||
other_routes: otherRoutes,
|
||||
};
|
||||
|
||||
@@ -334,6 +417,7 @@ export class HaNavigationPicker extends LitElement {
|
||||
...related,
|
||||
...dashboards,
|
||||
...views,
|
||||
...apps,
|
||||
...otherRoutes,
|
||||
];
|
||||
|
||||
@@ -356,6 +440,7 @@ export class HaNavigationPicker extends LitElement {
|
||||
...relatedItems,
|
||||
...this._navigationGroups.dashboards,
|
||||
...this._navigationGroups.views,
|
||||
...this._navigationGroups.apps,
|
||||
...this._navigationGroups.other_routes,
|
||||
];
|
||||
};
|
||||
@@ -399,28 +484,20 @@ export class HaNavigationPicker extends LitElement {
|
||||
relatedAreaIds.add(context.area_id);
|
||||
}
|
||||
|
||||
const createSortingLabel = (
|
||||
prefix: string,
|
||||
primary: string,
|
||||
path: string
|
||||
) =>
|
||||
[prefix, primary.startsWith("/") ? `zzz${primary}` : primary, path]
|
||||
.filter(Boolean)
|
||||
.join("_");
|
||||
|
||||
const relatedItems: NavigationItem[] = [];
|
||||
for (const deviceId of relatedDeviceIds) {
|
||||
const device = this.hass.devices[deviceId];
|
||||
const primary = device?.name_by_user ?? device?.name ?? deviceId;
|
||||
const path = `/config/devices/device/${deviceId}`;
|
||||
const resolved = computeNavigationPathInfo(this.hass, path);
|
||||
relatedItems.push({
|
||||
id: path,
|
||||
primary,
|
||||
primary: resolved.label,
|
||||
secondary: path,
|
||||
icon_path: mdiDevices,
|
||||
icon: resolved.icon,
|
||||
icon_path: resolved.iconPath,
|
||||
sorting_label: createSortingLabel(
|
||||
RELATED_SORT_PREFIX.device,
|
||||
primary,
|
||||
resolved.label,
|
||||
path
|
||||
),
|
||||
group: "related",
|
||||
@@ -431,20 +508,18 @@ export class HaNavigationPicker extends LitElement {
|
||||
}
|
||||
|
||||
for (const areaId of relatedAreaIds) {
|
||||
const area = this.hass.areas[areaId];
|
||||
const primary = area?.name ?? areaId;
|
||||
|
||||
// Area dashboard view
|
||||
const viewPath = `/home/${computeAreaPath(areaId)}`;
|
||||
const resolvedArea = computeNavigationPathInfo(this.hass, viewPath);
|
||||
relatedItems.push({
|
||||
id: viewPath,
|
||||
primary,
|
||||
primary: resolvedArea.label,
|
||||
secondary: viewPath,
|
||||
icon: area?.icon ?? undefined,
|
||||
icon_path: area?.icon ? undefined : mdiTextureBox,
|
||||
icon: resolvedArea.icon,
|
||||
icon_path: resolvedArea.icon ? undefined : resolvedArea.iconPath,
|
||||
sorting_label: createSortingLabel(
|
||||
RELATED_SORT_PREFIX.area_view,
|
||||
primary,
|
||||
resolvedArea.label,
|
||||
viewPath
|
||||
),
|
||||
group: "related",
|
||||
@@ -456,14 +531,14 @@ export class HaNavigationPicker extends LitElement {
|
||||
id: path,
|
||||
primary: this.hass.localize(
|
||||
"ui.components.navigation-picker.area_settings",
|
||||
{ area: primary }
|
||||
{ area: resolvedArea.label }
|
||||
),
|
||||
secondary: path,
|
||||
icon: area?.icon ?? undefined,
|
||||
icon_path: area?.icon ? undefined : mdiTextureBox,
|
||||
icon: resolvedArea.icon,
|
||||
icon_path: resolvedArea.icon ? undefined : resolvedArea.iconPath,
|
||||
sorting_label: createSortingLabel(
|
||||
RELATED_SORT_PREFIX.area,
|
||||
primary,
|
||||
resolvedArea.label,
|
||||
path
|
||||
),
|
||||
group: "related",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mdiInformationOutline, mdiStar } from "@mdi/js";
|
||||
import { mdiStar } from "@mdi/js";
|
||||
import type { CSSResultGroup, TemplateResult } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
@@ -53,65 +53,42 @@ export class HaNetwork extends LitElement {
|
||||
}
|
||||
const configured_adapters = this.networkConfig.configured_adapters || [];
|
||||
return html`
|
||||
<ha-settings-row>
|
||||
<span slot="prefix">
|
||||
<ha-checkbox
|
||||
id="auto_configure"
|
||||
@change=${this._handleAutoConfigureCheckboxClick}
|
||||
.checked=${!configured_adapters.length}
|
||||
name="auto_configure"
|
||||
>
|
||||
</ha-checkbox>
|
||||
</span>
|
||||
<span slot="heading" data-for="auto_configure">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.network.adapter.auto_configure"
|
||||
)}
|
||||
</span>
|
||||
<span slot="description" data-for="auto_configure">
|
||||
<ha-checkbox
|
||||
@change=${this._handleAutoConfigureCheckboxClick}
|
||||
.checked=${!configured_adapters.length}
|
||||
.hint=${!configured_adapters.length
|
||||
? this.hass.localize(
|
||||
"ui.panel.config.network.adapter.auto_configure_manual_hint"
|
||||
)
|
||||
: ""}
|
||||
>
|
||||
${this.hass.localize("ui.panel.config.network.adapter.auto_configure")}
|
||||
<div class="description">
|
||||
${this.hass.localize("ui.panel.config.network.adapter.detected")}:
|
||||
${format_auto_detected_interfaces(this.networkConfig.adapters)}
|
||||
${!configured_adapters.length
|
||||
? html`<div class="info-text">
|
||||
<ha-svg-icon
|
||||
.path=${mdiInformationOutline}
|
||||
class="info-icon"
|
||||
></ha-svg-icon>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.network.adapter.auto_configure_manual_hint"
|
||||
)}
|
||||
</div>`
|
||||
: nothing}
|
||||
</span>
|
||||
</ha-settings-row>
|
||||
</div>
|
||||
</ha-checkbox>
|
||||
${configured_adapters.length || this._expanded
|
||||
? this.networkConfig.adapters.map(
|
||||
(adapter) =>
|
||||
html`<ha-settings-row>
|
||||
<span slot="prefix">
|
||||
<ha-checkbox
|
||||
id=${adapter.name}
|
||||
@change=${this._handleAdapterCheckboxClick}
|
||||
.checked=${configured_adapters.includes(adapter.name)}
|
||||
.adapter=${adapter.name}
|
||||
name=${adapter.name}
|
||||
>
|
||||
</ha-checkbox>
|
||||
</span>
|
||||
<span slot="heading">
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.network.adapter.adapter"
|
||||
)}:
|
||||
${adapter.name}
|
||||
${adapter.default
|
||||
? html`<ha-svg-icon .path=${mdiStar}></ha-svg-icon>
|
||||
(${this.hass.localize("ui.common.default")})`
|
||||
: nothing}
|
||||
</span>
|
||||
<span slot="description">
|
||||
html`<ha-checkbox
|
||||
id=${adapter.name}
|
||||
@change=${this._handleAdapterCheckboxClick}
|
||||
.checked=${configured_adapters.includes(adapter.name)}
|
||||
.adapter=${adapter.name}
|
||||
>
|
||||
${this.hass.localize(
|
||||
"ui.panel.config.network.adapter.adapter"
|
||||
)}:
|
||||
${adapter.name}
|
||||
${adapter.default
|
||||
? html`<ha-svg-icon .path=${mdiStar}></ha-svg-icon>
|
||||
(${this.hass.localize("ui.common.default")})`
|
||||
: nothing}
|
||||
<div class="description">
|
||||
${format_addresses([...adapter.ipv4, ...adapter.ipv6])}
|
||||
</span>
|
||||
</ha-settings-row>`
|
||||
</div>
|
||||
</ha-checkbox>`
|
||||
)
|
||||
: nothing}
|
||||
`;
|
||||
@@ -145,7 +122,7 @@ export class HaNetwork extends LitElement {
|
||||
|
||||
private _handleAdapterCheckboxClick(ev: Event) {
|
||||
const checkbox = ev.currentTarget as HaCheckbox;
|
||||
const adapter_name = (checkbox as any).name;
|
||||
const adapter_name = checkbox.id;
|
||||
if (this.networkConfig === undefined) {
|
||||
return;
|
||||
}
|
||||
@@ -172,31 +149,20 @@ export class HaNetwork extends LitElement {
|
||||
color: var(--error-color);
|
||||
}
|
||||
|
||||
ha-settings-row {
|
||||
padding: 0;
|
||||
--settings-row-content-display: contents;
|
||||
--settings-row-prefix-display: contents;
|
||||
ha-checkbox:not(:last-child) {
|
||||
margin-bottom: var(--ha-space-3);
|
||||
}
|
||||
|
||||
span[slot="heading"],
|
||||
span[slot="description"] {
|
||||
cursor: pointer;
|
||||
ha-svg-icon {
|
||||
--mdc-icon-size: 12px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.info-text {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-top: 8px;
|
||||
.description {
|
||||
font-size: var(--ha-font-size-s);
|
||||
margin-top: var(--ha-space-1);
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
|
||||
.info-icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: var(--info-color, var(--primary-color));
|
||||
margin-right: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import type { HaPickerField } from "./ha-picker-field";
|
||||
import "./ha-svg-icon";
|
||||
|
||||
export interface HaSelectOption {
|
||||
value: string;
|
||||
value: string | number;
|
||||
label?: string;
|
||||
secondary?: string;
|
||||
iconPath?: string;
|
||||
@@ -34,13 +34,16 @@ export type HaSelectSelectEvent<
|
||||
export class HaSelect extends LitElement {
|
||||
@property({ type: Boolean }) public clearable = false;
|
||||
|
||||
@property({ attribute: false }) public options?: HaSelectOption[] | string[];
|
||||
@property({ attribute: false }) public options?:
|
||||
| HaSelectOption[]
|
||||
| string[]
|
||||
| number[];
|
||||
|
||||
@property() public label?: string;
|
||||
|
||||
@property() public helper?: string;
|
||||
|
||||
@property() public value?: string;
|
||||
@property() public value?: string | number;
|
||||
|
||||
@property({ type: Boolean }) public required = false;
|
||||
|
||||
@@ -52,25 +55,30 @@ export class HaSelect extends LitElement {
|
||||
|
||||
private _getValueLabel = memoizeOne(
|
||||
(
|
||||
options: HaSelectOption[] | string[] | undefined,
|
||||
value: string | undefined
|
||||
options: HaSelectOption[] | string[] | number[] | undefined,
|
||||
value: string | number | undefined
|
||||
) => {
|
||||
if (!options || !value) {
|
||||
return value;
|
||||
// just in case value is a number, convert it to string to avoid falsy value
|
||||
const valueStr = String(value);
|
||||
if (!options || !valueStr) {
|
||||
return valueStr;
|
||||
}
|
||||
|
||||
for (const option of options) {
|
||||
const simpleOption = ["string", "number"].includes(typeof option);
|
||||
if (
|
||||
(typeof option === "string" && option === value) ||
|
||||
(typeof option !== "string" && option.value === value)
|
||||
(simpleOption && option === valueStr) ||
|
||||
(!simpleOption &&
|
||||
String((option as HaSelectOption).value) === valueStr)
|
||||
) {
|
||||
return typeof option === "string"
|
||||
return simpleOption
|
||||
? option
|
||||
: option.label || option.value;
|
||||
: (option as HaSelectOption).label ||
|
||||
(option as HaSelectOption).value;
|
||||
}
|
||||
}
|
||||
|
||||
return value;
|
||||
return valueStr;
|
||||
}
|
||||
);
|
||||
|
||||
@@ -88,15 +96,14 @@ export class HaSelect extends LitElement {
|
||||
>
|
||||
${this._renderField()}
|
||||
${this.options
|
||||
? this.options.map(
|
||||
(option) => html`
|
||||
? this.options.map((option) => {
|
||||
const simpleOption = ["string", "number"].includes(typeof option);
|
||||
return html`
|
||||
<ha-dropdown-item
|
||||
.value=${typeof option === "string" ? option : option.value}
|
||||
.disabled=${typeof option === "string"
|
||||
? false
|
||||
: (option.disabled ?? false)}
|
||||
.value=${simpleOption ? option : option.value}
|
||||
.disabled=${simpleOption ? false : (option.disabled ?? false)}
|
||||
.selected=${this.value ===
|
||||
(typeof option === "string" ? option : option.value)}
|
||||
(simpleOption ? option : option.value)}
|
||||
>
|
||||
${option.iconPath
|
||||
? html`<ha-svg-icon
|
||||
@@ -105,16 +112,14 @@ export class HaSelect extends LitElement {
|
||||
></ha-svg-icon>`
|
||||
: nothing}
|
||||
<div class="content">
|
||||
${typeof option === "string"
|
||||
? option
|
||||
: option.label || option.value}
|
||||
${simpleOption ? option : option.label || option.value}
|
||||
${option.secondary
|
||||
? html`<div class="secondary">${option.secondary}</div>`
|
||||
: nothing}
|
||||
</div>
|
||||
</ha-dropdown-item>
|
||||
`
|
||||
)
|
||||
`;
|
||||
})
|
||||
: html`<slot></slot>`}
|
||||
</ha-dropdown>
|
||||
${this._renderHelper()}
|
||||
@@ -139,7 +144,7 @@ export class HaSelect extends LitElement {
|
||||
.hideClearIcon=${!this.clearable ||
|
||||
this.required ||
|
||||
this.disabled ||
|
||||
!this.value}
|
||||
!String(this.value)}
|
||||
>
|
||||
</ha-picker-field>
|
||||
`;
|
||||
@@ -153,7 +158,7 @@ export class HaSelect extends LitElement {
|
||||
: nothing;
|
||||
}
|
||||
|
||||
private _handleSelect(ev: CustomEvent<{ item: { value: string } }>) {
|
||||
private _handleSelect(ev: CustomEvent<{ item: { value: string | number } }>) {
|
||||
ev.stopPropagation();
|
||||
const value = ev.detail.item.value;
|
||||
if (value === this.value) {
|
||||
@@ -216,6 +221,6 @@ declare global {
|
||||
}
|
||||
|
||||
interface HASSDomEvents {
|
||||
selected: { value: string | undefined };
|
||||
selected: { value: string | number | undefined };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,14 +136,14 @@ export class HaSelectSelector extends LitElement {
|
||||
${this.label}
|
||||
${options.map(
|
||||
(item: SelectOption) => html`
|
||||
<ha-formfield .label=${item.label}>
|
||||
<ha-checkbox
|
||||
.checked=${value.includes(item.value)}
|
||||
.value=${item.value}
|
||||
.disabled=${item.disabled || this.disabled}
|
||||
@change=${this._checkboxChanged}
|
||||
></ha-checkbox>
|
||||
</ha-formfield>
|
||||
<ha-checkbox
|
||||
.checked=${value.includes(item.value)}
|
||||
.value=${item.value}
|
||||
.disabled=${item.disabled || this.disabled}
|
||||
@change=${this._checkboxChanged}
|
||||
>
|
||||
${item.label}
|
||||
</ha-checkbox>
|
||||
`
|
||||
)}
|
||||
</div>
|
||||
@@ -231,7 +231,9 @@ export class HaSelectSelector extends LitElement {
|
||||
return html`
|
||||
<ha-select
|
||||
.label=${this.label ?? ""}
|
||||
.value=${typeof this.value === "string" ? this.value : ""}
|
||||
.value=${["string", "number"].includes(typeof this.value)
|
||||
? (this.value as string | number)
|
||||
: ""}
|
||||
.helper=${this.helper ?? ""}
|
||||
.disabled=${this.disabled}
|
||||
.required=${this.required}
|
||||
@@ -256,7 +258,7 @@ export class HaSelectSelector extends LitElement {
|
||||
selector.select?.options?.map((option) =>
|
||||
typeof option === "object"
|
||||
? (option as SelectOption)
|
||||
: ({ value: option, label: option } as SelectOption)
|
||||
: ({ value: String(option), label: option } as SelectOption)
|
||||
) || []
|
||||
);
|
||||
|
||||
@@ -300,7 +302,7 @@ export class HaSelectSelector extends LitElement {
|
||||
}
|
||||
|
||||
private _valueChanged(ev) {
|
||||
const value = ev.detail?.value || ev.target.value;
|
||||
const value = ev.detail?.value ?? ev.target.value;
|
||||
if (this.disabled || value === undefined || value === (this.value ?? "")) {
|
||||
return;
|
||||
}
|
||||
@@ -383,6 +385,12 @@ export class HaSelectSelector extends LitElement {
|
||||
ha-formfield {
|
||||
display: block;
|
||||
}
|
||||
|
||||
ha-checkbox {
|
||||
display: flex;
|
||||
min-height: 40px;
|
||||
justify-content: center;
|
||||
}
|
||||
ha-dropdown-item[disabled] {
|
||||
--mdc-theme-text-primary-on-background: var(--disabled-text-color);
|
||||
}
|
||||
|
||||
197
src/components/ha-selector/ha-selector-serial.ts
Normal file
197
src/components/ha-selector/ha-selector-serial.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
import { mdiClose } from "@mdi/js";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { isComponentLoaded } from "../../common/config/is_component_loaded";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import type { SerialSelector } from "../../data/selector";
|
||||
import { listSerialPorts, type SerialPort } from "../../data/usb";
|
||||
import type { HomeAssistant, ValueChangedEvent } from "../../types";
|
||||
import "../ha-generic-picker";
|
||||
import type { PickerComboBoxItem } from "../ha-picker-combo-box";
|
||||
import "../ha-icon-button";
|
||||
import "../input/ha-input";
|
||||
|
||||
const MANUAL_ENTRY_ID = "__manual_entry__";
|
||||
|
||||
@customElement("ha-selector-serial")
|
||||
export class HaSerialSelector extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ attribute: false }) public selector!: SerialSelector;
|
||||
|
||||
@property() public value?: string;
|
||||
|
||||
@property() public label?: string;
|
||||
|
||||
@property() public helper?: string;
|
||||
|
||||
@property() public placeholder?: string;
|
||||
|
||||
@property({ type: Boolean }) public disabled = false;
|
||||
|
||||
@property({ type: Boolean }) public required = true;
|
||||
|
||||
@state() private _serialPorts?: SerialPort[];
|
||||
|
||||
@state() private _manualEntry = false;
|
||||
|
||||
@query("ha-input") private _input?: HTMLElement;
|
||||
|
||||
protected firstUpdated(): void {
|
||||
if (
|
||||
this.hass &&
|
||||
this.hass.user?.is_admin &&
|
||||
isComponentLoaded(this.hass.config, "usb")
|
||||
) {
|
||||
this._loadSerialPorts();
|
||||
}
|
||||
}
|
||||
|
||||
private async _loadSerialPorts(): Promise<void> {
|
||||
try {
|
||||
this._serialPorts = await listSerialPorts(this.hass);
|
||||
} catch (err: unknown) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(err);
|
||||
this._serialPorts = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private _humanReadablePort(port: SerialPort): string {
|
||||
const parts: string[] = [port.device];
|
||||
if (port.manufacturer) {
|
||||
parts.push(port.manufacturer);
|
||||
}
|
||||
if (port.description) {
|
||||
parts.push(port.description);
|
||||
}
|
||||
return parts.join(" - ");
|
||||
}
|
||||
|
||||
private _getPickerItems = (): (PickerComboBoxItem | string)[] | undefined =>
|
||||
this._serialPorts
|
||||
? this._getItems(this._serialPorts, this.hass.localize)
|
||||
: undefined;
|
||||
|
||||
private _getItems = memoizeOne(
|
||||
(
|
||||
ports: SerialPort[],
|
||||
localize: HomeAssistant["localize"]
|
||||
): (PickerComboBoxItem | string)[] => {
|
||||
const items: (PickerComboBoxItem | string)[] = ports.map((port) => ({
|
||||
id: port.device,
|
||||
primary: this._humanReadablePort(port),
|
||||
secondary: port.vid
|
||||
? `${port.vid}:${port.pid}${port.serial_number ? ` - S/N: ${port.serial_number}` : ""}`
|
||||
: undefined,
|
||||
search_labels: {
|
||||
device: port.device,
|
||||
manufacturer: port.manufacturer,
|
||||
description: port.description,
|
||||
serial_number: port.serial_number,
|
||||
},
|
||||
sorting_label: port.device,
|
||||
}));
|
||||
items.push({
|
||||
id: MANUAL_ENTRY_ID,
|
||||
primary: localize("ui.components.selectors.serial.enter_manually"),
|
||||
secondary: undefined,
|
||||
});
|
||||
return items;
|
||||
}
|
||||
);
|
||||
|
||||
protected render() {
|
||||
const usbLoaded = this.hass && isComponentLoaded(this.hass.config, "usb");
|
||||
|
||||
if (!usbLoaded || !this._serialPorts || this._manualEntry) {
|
||||
return html`
|
||||
<ha-input
|
||||
.value=${this.value || ""}
|
||||
.placeholder=${this.placeholder || ""}
|
||||
.hint=${this.helper}
|
||||
.disabled=${this.disabled}
|
||||
.label=${this.label || ""}
|
||||
.required=${this.required}
|
||||
@input=${this._handleInputChange}
|
||||
@change=${this._handleInputChange}
|
||||
>
|
||||
${this._manualEntry
|
||||
? html`
|
||||
<ha-icon-button
|
||||
slot="end"
|
||||
@click=${this._revertToDropdown}
|
||||
.path=${mdiClose}
|
||||
></ha-icon-button>
|
||||
`
|
||||
: nothing}
|
||||
</ha-input>
|
||||
`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<ha-generic-picker
|
||||
.hass=${this.hass}
|
||||
.value=${this.value}
|
||||
.label=${this.label}
|
||||
.helper=${this.helper}
|
||||
.disabled=${this.disabled}
|
||||
.required=${this.required}
|
||||
.getItems=${this._getPickerItems}
|
||||
@value-changed=${this._handlePickerChange}
|
||||
></ha-generic-picker>
|
||||
`;
|
||||
}
|
||||
|
||||
private async _handlePickerChange(ev: ValueChangedEvent<string>) {
|
||||
ev.stopPropagation();
|
||||
const value = ev.detail.value;
|
||||
if (value === MANUAL_ENTRY_ID) {
|
||||
this._manualEntry = true;
|
||||
fireEvent(this, "value-changed", { value: undefined });
|
||||
await this.updateComplete;
|
||||
// Wait for the picker popover to fully close and restore focus
|
||||
// before moving focus to our input
|
||||
requestAnimationFrame(() => {
|
||||
this._input?.focus();
|
||||
});
|
||||
return;
|
||||
}
|
||||
fireEvent(this, "value-changed", { value: value || undefined });
|
||||
}
|
||||
|
||||
private _handleInputChange(ev: InputEvent) {
|
||||
ev.stopPropagation();
|
||||
const value = (ev.target as HTMLInputElement).value;
|
||||
fireEvent(this, "value-changed", {
|
||||
value: value || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
private _revertToDropdown() {
|
||||
this._manualEntry = false;
|
||||
const ports = this._serialPorts;
|
||||
const firstPort = ports?.[0]?.device;
|
||||
fireEvent(this, "value-changed", {
|
||||
value: firstPort || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
position: relative;
|
||||
}
|
||||
ha-generic-picker,
|
||||
ha-input {
|
||||
width: 100%;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-selector-serial": HaSerialSelector;
|
||||
}
|
||||
}
|
||||
@@ -78,7 +78,7 @@ export class HaTextSelector extends LitElement {
|
||||
return html`<ha-input
|
||||
.name=${this.name}
|
||||
.value=${this.value || ""}
|
||||
.placeholder=${this.placeholder || ""}
|
||||
.placeholder=${this.placeholder || this.selector.text?.placeholder || ""}
|
||||
.hint=${this.helper}
|
||||
.disabled=${this.disabled}
|
||||
.type=${this.selector.text?.type}
|
||||
|
||||
@@ -14,6 +14,8 @@ export class HaThemeSelector extends LitElement {
|
||||
|
||||
@property() public label?: string;
|
||||
|
||||
@property() public helper?: string;
|
||||
|
||||
@property({ type: Boolean, reflect: true }) public disabled = false;
|
||||
|
||||
@property({ type: Boolean }) public required = true;
|
||||
@@ -24,6 +26,7 @@ export class HaThemeSelector extends LitElement {
|
||||
.hass=${this.hass}
|
||||
.value=${this.value}
|
||||
.label=${this.label}
|
||||
.helper=${this.helper}
|
||||
.includeDefault=${this.selector.theme?.include_default}
|
||||
.disabled=${this.disabled}
|
||||
.required=${this.required}
|
||||
|
||||
@@ -4,8 +4,8 @@ import { fireEvent } from "../../common/dom/fire_event";
|
||||
import type { ActionConfig } from "../../data/lovelace/config/action";
|
||||
import type { UiActionSelector } from "../../data/selector";
|
||||
import "../../panels/lovelace/components/hui-action-editor";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import type { ActionRelatedContext } from "../../panels/lovelace/components/hui-action-editor";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
|
||||
@customElement("ha-selector-ui_action")
|
||||
export class HaSelectorUiAction extends LitElement {
|
||||
@@ -21,10 +21,13 @@ export class HaSelectorUiAction extends LitElement {
|
||||
|
||||
@property() public helper?: string;
|
||||
|
||||
@property({ type: Boolean }) public required?: boolean;
|
||||
|
||||
protected render() {
|
||||
return html`
|
||||
<hui-action-editor
|
||||
.label=${this.label}
|
||||
.required=${this.required}
|
||||
.hass=${this.hass}
|
||||
.config=${this.value}
|
||||
.context=${this.context}
|
||||
|
||||
@@ -45,6 +45,7 @@ const LOAD_ELEMENTS = {
|
||||
qr_code: () => import("./ha-selector-qr-code"),
|
||||
select: () => import("./ha-selector-select"),
|
||||
selector: () => import("./ha-selector-selector"),
|
||||
serial: () => import("./ha-selector-serial"),
|
||||
state: () => import("./ha-selector-state"),
|
||||
backup_location: () => import("./ha-selector-backup-location"),
|
||||
stt: () => import("./ha-selector-stt"),
|
||||
|
||||
@@ -679,13 +679,16 @@ export class HaServiceControl extends LitElement {
|
||||
: html`<ha-checkbox
|
||||
.key=${dataField.key}
|
||||
.checked=${this._checkedKeys.has(dataField.key) ||
|
||||
(this._value?.data &&
|
||||
(!!this._value?.data &&
|
||||
this._value.data[dataField.key] !== undefined)}
|
||||
.disabled=${this.disabled}
|
||||
@change=${this._checkboxChanged}
|
||||
slot="prefix"
|
||||
></ha-checkbox>`}
|
||||
<span slot="heading"
|
||||
<span
|
||||
slot="heading"
|
||||
class=${showOptional ? "clickable" : ""}
|
||||
@click=${showOptional ? this._toggleCheckbox : undefined}
|
||||
>${this.hass.localize(
|
||||
`component.${domain}.services.${serviceName}.fields.${dataField.key}.name`,
|
||||
descriptionPlaceholders
|
||||
@@ -693,7 +696,10 @@ export class HaServiceControl extends LitElement {
|
||||
dataField.name ||
|
||||
dataField.key}</span
|
||||
>
|
||||
<span slot="description"
|
||||
<span
|
||||
slot="description"
|
||||
class=${showOptional ? "clickable" : ""}
|
||||
@click=${showOptional ? this._toggleCheckbox : undefined}
|
||||
><ha-markdown
|
||||
breaks
|
||||
allow-svg
|
||||
@@ -738,6 +744,13 @@ export class HaServiceControl extends LitElement {
|
||||
);
|
||||
};
|
||||
|
||||
private _toggleCheckbox(ev: Event) {
|
||||
const checkbox = (
|
||||
ev.currentTarget as HTMLElement
|
||||
)?.parentElement?.querySelector("ha-checkbox");
|
||||
checkbox?.click();
|
||||
}
|
||||
|
||||
private _checkboxChanged(ev) {
|
||||
const checked = ev.currentTarget.checked;
|
||||
const key = ev.currentTarget.key;
|
||||
@@ -995,10 +1008,8 @@ export class HaServiceControl extends LitElement {
|
||||
.checkbox-spacer {
|
||||
width: 32px;
|
||||
}
|
||||
ha-checkbox {
|
||||
margin-left: -16px;
|
||||
margin-inline-start: -16px;
|
||||
margin-inline-end: initial;
|
||||
.clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
.help-icon {
|
||||
color: var(--secondary-text-color);
|
||||
|
||||
@@ -8,13 +8,7 @@ import {
|
||||
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
|
||||
import type { PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import {
|
||||
customElement,
|
||||
eventOptions,
|
||||
property,
|
||||
query,
|
||||
state,
|
||||
} from "lit/decorators";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
@@ -47,9 +41,9 @@ import "./ha-icon";
|
||||
import "./ha-icon-button";
|
||||
import "./ha-md-list";
|
||||
import "./ha-md-list-item";
|
||||
import type { HaMdListItem } from "./ha-md-list-item";
|
||||
import "./ha-spinner";
|
||||
import "./ha-svg-icon";
|
||||
import "./ha-tooltip";
|
||||
import "./user/ha-user-badge";
|
||||
|
||||
const SORT_VALUE_URL_PATHS = {
|
||||
@@ -185,18 +179,8 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
|
||||
|
||||
@state() private _hiddenPanels?: string[];
|
||||
|
||||
private _mouseLeaveTimeout?: number;
|
||||
|
||||
private _touchendTimeout?: number;
|
||||
|
||||
private _tooltipHideTimeout?: number;
|
||||
|
||||
private _recentKeydownActiveUntil = 0;
|
||||
|
||||
private _unsubPersistentNotifications: UnsubscribeFunc | undefined;
|
||||
|
||||
@query(".tooltip") private _tooltip!: HTMLDivElement;
|
||||
|
||||
@query(".before-spacer") private _scrollableList?: HTMLDivElement;
|
||||
|
||||
protected get scrollableElement(): HTMLElement | null {
|
||||
@@ -237,14 +221,6 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
|
||||
|
||||
public disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
// clear timeouts
|
||||
clearTimeout(this._mouseLeaveTimeout);
|
||||
clearTimeout(this._tooltipHideTimeout);
|
||||
clearTimeout(this._touchendTimeout);
|
||||
// set undefined values
|
||||
this._mouseLeaveTimeout = undefined;
|
||||
this._tooltipHideTimeout = undefined;
|
||||
this._touchendTimeout = undefined;
|
||||
}
|
||||
|
||||
protected render() {
|
||||
@@ -257,8 +233,7 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
|
||||
// prettier-ignore
|
||||
return html`
|
||||
${this._renderHeader()}
|
||||
${this._renderAllPanels(selectedPanel)}
|
||||
<div class="tooltip"></div>`;
|
||||
${this._renderAllPanels(selectedPanel)}`;
|
||||
}
|
||||
|
||||
protected shouldUpdate(changedProps: PropertyValues): boolean {
|
||||
@@ -382,11 +357,6 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
|
||||
"ha-scrollbar": scrollable,
|
||||
[cls]: true,
|
||||
})}
|
||||
@focusin=${this._listboxFocusIn}
|
||||
@focusout=${this._listboxFocusOut}
|
||||
@touchend=${this._listboxTouchend}
|
||||
@scroll=${this._listboxScroll}
|
||||
@keydown=${this._listboxKeydown}
|
||||
>${content}</ha-md-list
|
||||
>`;
|
||||
|
||||
@@ -462,15 +432,17 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
|
||||
<ha-md-list-item
|
||||
.href=${`/${urlPath}`}
|
||||
type="link"
|
||||
id="sidebar-panel-${urlPath}"
|
||||
class=${classMap({ selected: isSelected })}
|
||||
@mouseenter=${this._itemMouseEnter}
|
||||
@mouseleave=${this._itemMouseLeave}
|
||||
>
|
||||
${iconPath
|
||||
? html`<ha-svg-icon slot="start" .path=${iconPath}></ha-svg-icon>`
|
||||
: html`<ha-icon slot="start" .icon=${icon}></ha-icon>`}
|
||||
<span class="item-text" slot="headline">${title}</span>
|
||||
</ha-md-list-item>
|
||||
${!this.alwaysExpand && title
|
||||
? this._renderToolTip(`sidebar-panel-${urlPath}`, title)
|
||||
: nothing}
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -489,8 +461,7 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
|
||||
class="configuration ${classMap({ selected: isSelected })}"
|
||||
type="button"
|
||||
href="/config"
|
||||
@mouseenter=${this._itemMouseEnter}
|
||||
@mouseleave=${this._itemMouseLeave}
|
||||
id="sidebar-config"
|
||||
>
|
||||
<ha-svg-icon slot="start" .path=${mdiCog}></ha-svg-icon>
|
||||
${this._updatesCount > 0 || this._issuesCount > 0
|
||||
@@ -511,6 +482,12 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
|
||||
`
|
||||
: nothing}
|
||||
</ha-md-list-item>
|
||||
${!this.alwaysExpand
|
||||
? this._renderToolTip(
|
||||
"sidebar-config",
|
||||
this.hass.localize("panel.config")
|
||||
)
|
||||
: nothing}
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -523,9 +500,8 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
|
||||
<ha-md-list-item
|
||||
class="notifications"
|
||||
@click=${this._handleShowNotificationDrawer}
|
||||
@mouseenter=${this._itemMouseEnter}
|
||||
@mouseleave=${this._itemMouseLeave}
|
||||
type="button"
|
||||
id="sidebar-notifications"
|
||||
>
|
||||
<ha-svg-icon slot="start" .path=${mdiBell}></ha-svg-icon>
|
||||
${notificationCount > 0
|
||||
@@ -540,6 +516,12 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
|
||||
? html`<span class="badge" slot="end">${notificationCount}</span>`
|
||||
: nothing}
|
||||
</ha-md-list-item>
|
||||
${!this.alwaysExpand
|
||||
? this._renderToolTip(
|
||||
"sidebar-notifications",
|
||||
this.hass.localize("ui.notification_drawer.title")
|
||||
)
|
||||
: nothing}
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -551,13 +533,12 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
|
||||
<ha-md-list-item
|
||||
href="/profile"
|
||||
type="link"
|
||||
id="sidebar-profile"
|
||||
class=${classMap({
|
||||
user: true,
|
||||
selected: isSelected,
|
||||
rtl: isRTL,
|
||||
})}
|
||||
@mouseenter=${this._itemMouseEnter}
|
||||
@mouseleave=${this._itemMouseLeave}
|
||||
>
|
||||
<ha-user-badge
|
||||
slot="start"
|
||||
@@ -568,6 +549,9 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
|
||||
>${this.hass.user ? this.hass.user.name : ""}</span
|
||||
>
|
||||
</ha-md-list-item>
|
||||
${!this.alwaysExpand && this.hass.user
|
||||
? this._renderToolTip("sidebar-profile", this.hass.user.name)
|
||||
: nothing}
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -579,17 +563,33 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
|
||||
<ha-md-list-item
|
||||
@click=${this._handleExternalAppConfiguration}
|
||||
type="button"
|
||||
@mouseenter=${this._itemMouseEnter}
|
||||
@mouseleave=${this._itemMouseLeave}
|
||||
id="sidebar-external-config"
|
||||
>
|
||||
<ha-svg-icon slot="start" .path=${mdiCellphoneCog}></ha-svg-icon>
|
||||
<span class="item-text" slot="headline"
|
||||
>${this.hass.localize("ui.sidebar.external_app_configuration")}</span
|
||||
>
|
||||
</ha-md-list-item>
|
||||
${!this.alwaysExpand
|
||||
? this._renderToolTip(
|
||||
"sidebar-external-config",
|
||||
this.hass.localize("ui.sidebar.external_app_configuration")
|
||||
)
|
||||
: nothing}
|
||||
`;
|
||||
}
|
||||
|
||||
private _renderToolTip(id: string, text: string) {
|
||||
return html`<ha-tooltip
|
||||
for=${id}
|
||||
show-delay="0"
|
||||
hide-delay="0"
|
||||
placement="right"
|
||||
>
|
||||
${text}
|
||||
</ha-tooltip>`;
|
||||
}
|
||||
|
||||
private _handleExternalAppConfiguration(ev: Event) {
|
||||
ev.preventDefault();
|
||||
this.hass.auth.external!.fireMessage({
|
||||
@@ -605,98 +605,6 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
|
||||
showEditSidebarDialog(this);
|
||||
}
|
||||
|
||||
private _itemMouseEnter(ev: MouseEvent) {
|
||||
// On keypresses on the listbox, we're going to ignore mouse enter events
|
||||
// for 100ms so that we ignore it when pressing down arrow scrolls the
|
||||
// sidebar causing the mouse to hover a new icon
|
||||
if (new Date().getTime() < this._recentKeydownActiveUntil) {
|
||||
return;
|
||||
}
|
||||
if (this._mouseLeaveTimeout) {
|
||||
clearTimeout(this._mouseLeaveTimeout);
|
||||
this._mouseLeaveTimeout = undefined;
|
||||
}
|
||||
this._showTooltip(ev.currentTarget as HaMdListItem);
|
||||
}
|
||||
|
||||
private _itemMouseLeave() {
|
||||
if (this._mouseLeaveTimeout) {
|
||||
clearTimeout(this._mouseLeaveTimeout);
|
||||
}
|
||||
this._mouseLeaveTimeout = window.setTimeout(() => {
|
||||
this._hideTooltip();
|
||||
}, 500);
|
||||
}
|
||||
|
||||
private _listboxFocusIn(ev) {
|
||||
if (ev.target.localName !== "ha-md-list-item") {
|
||||
return;
|
||||
}
|
||||
this._showTooltip(ev.target);
|
||||
}
|
||||
|
||||
private _listboxFocusOut() {
|
||||
this._hideTooltip();
|
||||
}
|
||||
|
||||
private _listboxTouchend() {
|
||||
clearTimeout(this._touchendTimeout);
|
||||
this._touchendTimeout = window.setTimeout(() => {
|
||||
// Allow 1 second for users to read the tooltip on touch devices
|
||||
this._hideTooltip();
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
@eventOptions({
|
||||
passive: true,
|
||||
})
|
||||
private _listboxScroll() {
|
||||
// On keypresses on the listbox, we're going to ignore scroll events
|
||||
// for 100ms so that if pressing down arrow scrolls the sidebar, the tooltip
|
||||
// will not be hidden.
|
||||
if (new Date().getTime() < this._recentKeydownActiveUntil) {
|
||||
return;
|
||||
}
|
||||
this._hideTooltip();
|
||||
}
|
||||
|
||||
private _listboxKeydown() {
|
||||
this._recentKeydownActiveUntil = new Date().getTime() + 100;
|
||||
}
|
||||
|
||||
private _showTooltip(item: HaMdListItem) {
|
||||
if (this._tooltipHideTimeout) {
|
||||
clearTimeout(this._tooltipHideTimeout);
|
||||
this._tooltipHideTimeout = undefined;
|
||||
}
|
||||
const itemText = item.querySelector(".item-text") as HTMLElement | null;
|
||||
if (this.hasAttribute("expanded") && itemText) {
|
||||
const isTruncated = itemText.scrollWidth > itemText.clientWidth;
|
||||
if (!isTruncated) {
|
||||
this._hideTooltip();
|
||||
return;
|
||||
}
|
||||
}
|
||||
const tooltip = this._tooltip;
|
||||
const itemRect = item.getBoundingClientRect();
|
||||
|
||||
tooltip.innerText = itemText?.innerText ?? "";
|
||||
tooltip.style.display = "block";
|
||||
tooltip.style.position = "fixed";
|
||||
tooltip.style.top = `${itemRect.top + itemRect.height / 2 - tooltip.offsetHeight / 2}px`;
|
||||
tooltip.style.left = `calc(${itemRect.right + 8}px)`;
|
||||
}
|
||||
|
||||
private _hideTooltip() {
|
||||
// Delay it a little in case other events are pending processing.
|
||||
if (!this._tooltipHideTimeout) {
|
||||
this._tooltipHideTimeout = window.setTimeout(() => {
|
||||
this._tooltipHideTimeout = undefined;
|
||||
this._tooltip.style.display = "none";
|
||||
}, 10);
|
||||
}
|
||||
}
|
||||
|
||||
private _handleShowNotificationDrawer() {
|
||||
fireEvent(this, "hass-show-notifications");
|
||||
}
|
||||
@@ -957,20 +865,6 @@ class HaSidebar extends SubscribeMixin(ScrollableFadeMixin(LitElement)) {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.tooltip {
|
||||
display: none;
|
||||
position: absolute;
|
||||
opacity: 0.9;
|
||||
border-radius: var(--ha-border-radius-sm);
|
||||
max-width: calc(var(--ha-space-20) * 3);
|
||||
white-space: normal;
|
||||
overflow-wrap: break-word;
|
||||
color: var(--sidebar-background-color);
|
||||
background-color: var(--sidebar-text-color);
|
||||
padding: var(--ha-space-1);
|
||||
font-weight: var(--ha-font-weight-medium);
|
||||
}
|
||||
|
||||
.menu ha-icon-button {
|
||||
-webkit-transform: scaleX(var(--scale-direction));
|
||||
transform: scaleX(var(--scale-direction));
|
||||
|
||||
@@ -56,7 +56,7 @@ export class HaSlider extends Slider {
|
||||
--ha-tooltip-border-radius,
|
||||
var(--ha-border-radius-sm)
|
||||
);
|
||||
--wa-tooltip-arrow-size: var(--ha-tooltip-arrow-size, 8px);
|
||||
--wa-tooltip-arrow-size: var(--ha-tooltip-arrow-size, 0px);
|
||||
--wa-tooltip-border-width: 0px;
|
||||
--wa-z-index-tooltip: 1000;
|
||||
min-width: 100px;
|
||||
|
||||
@@ -1,49 +1,220 @@
|
||||
import { SwitchBase } from "@material/mwc-switch/deprecated/mwc-switch-base";
|
||||
import { styles } from "@material/mwc-switch/deprecated/mwc-switch.css";
|
||||
import { css } from "lit";
|
||||
import Switch from "@home-assistant/webawesome/dist/components/switch/switch";
|
||||
import { css, type CSSResultGroup, type PropertyValues } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { forwardHaptic } from "../data/haptics";
|
||||
|
||||
/**
|
||||
* Home Assistant switch component
|
||||
*
|
||||
* @element ha-switch
|
||||
* @extends {Switch}
|
||||
*
|
||||
* @summary
|
||||
* A toggle switch component supporting Home Assistant theming, based on the webawesome switch.
|
||||
* Represents two states: on and off.
|
||||
*
|
||||
* @cssprop --ha-switch-size - The size of the switch track height. Defaults to `24px`.
|
||||
* @cssprop --ha-switch-thumb-size - The size of the thumb. Defaults to `18px`.
|
||||
* @cssprop --ha-switch-width - The width of the switch track. Defaults to `48px`.
|
||||
* @cssprop --ha-switch-background-color - Background color of the unchecked track.
|
||||
* @cssprop --ha-switch-thumb-background-color - Background color of the unchecked thumb.
|
||||
* @cssprop --ha-switch-background-color-hover - Background color of the unchecked track on hover.
|
||||
* @cssprop --ha-switch-thumb-background-color-hover - Background color of the unchecked thumb on hover.
|
||||
* @cssprop --ha-switch-checked-background-color - Background color of the checked track.
|
||||
* @cssprop --ha-switch-checked-thumb-background-color - Background color of the checked thumb.
|
||||
* @cssprop --ha-switch-checked-background-color-hover - Background color of the checked track on hover.
|
||||
* @cssprop --ha-switch-checked-thumb-background-color-hover - Background color of the checked thumb on hover.
|
||||
* @cssprop --ha-switch-border-color - Border color of the unchecked track.
|
||||
* @cssprop --ha-switch-thumb-border-color - Border color of the unchecked thumb.
|
||||
* @cssprop --ha-switch-thumb-border-color-hover - Border color of the unchecked thumb on hover.
|
||||
* @cssprop --ha-switch-checked-border-color - Border color of the checked track.
|
||||
* @cssprop --ha-switch-checked-thumb-border-color - Border color of the checked thumb.
|
||||
* @cssprop --ha-switch-checked-border-color-hover - Border color of the checked track on hover.
|
||||
* @cssprop --ha-switch-checked-thumb-border-color-hover - Border color of the checked thumb on hover.
|
||||
* @cssprop --ha-switch-thumb-box-shadow - The box shadow of the thumb. Defaults to `var(--ha-box-shadow-s)`.
|
||||
* @cssprop --ha-switch-disabled-opacity - Opacity of the switch when disabled. Defaults to `0.2`.
|
||||
* @cssprop --ha-switch-required-marker - The marker shown after the label for required fields. Defaults to `"*"`.
|
||||
* @cssprop --ha-switch-required-marker-offset - Offset of the required marker. Defaults to `0.1rem`.
|
||||
*
|
||||
* @attr {boolean} checked - The checked state of the switch.
|
||||
* @attr {boolean} disabled - Disables the switch and prevents user interaction.
|
||||
* @attr {boolean} required - Makes the switch a required field.
|
||||
* @attr {boolean} haptic - Enables haptic vibration on toggle. Only use when the new state is applied immediately (not when a save action is required).
|
||||
*/
|
||||
@customElement("ha-switch")
|
||||
export class HaSwitch extends SwitchBase {
|
||||
// Generate a haptic vibration.
|
||||
// Only set to true if the new value of the switch is applied right away when toggling.
|
||||
// Do not add haptic when a user is required to press save.
|
||||
export class HaSwitch extends Switch {
|
||||
/**
|
||||
* Enables haptic vibration on toggle.
|
||||
* Only set to true if the new value of the switch is applied right away when toggling.
|
||||
* Do not add haptic when a user is required to press save.
|
||||
*/
|
||||
@property({ type: Boolean }) public haptic = false;
|
||||
|
||||
protected firstUpdated() {
|
||||
super.firstUpdated();
|
||||
this.addEventListener("change", () => {
|
||||
public updated(changedProperties: PropertyValues<typeof this>) {
|
||||
super.updated(changedProperties);
|
||||
if (changedProperties.has("haptic")) {
|
||||
if (this.haptic) {
|
||||
forwardHaptic(this, "light");
|
||||
this.addEventListener("change", this._forwardHaptic);
|
||||
} else {
|
||||
this.removeEventListener("change", this._forwardHaptic);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
static override styles = [
|
||||
styles,
|
||||
css`
|
||||
:host {
|
||||
--mdc-theme-secondary: var(--switch-checked-color);
|
||||
}
|
||||
.mdc-switch.mdc-switch--checked .mdc-switch__thumb {
|
||||
background-color: var(--switch-checked-button-color);
|
||||
border-color: var(--switch-checked-button-color);
|
||||
}
|
||||
.mdc-switch.mdc-switch--checked .mdc-switch__track {
|
||||
background-color: var(--switch-checked-track-color);
|
||||
border-color: var(--switch-checked-track-color);
|
||||
}
|
||||
.mdc-switch:not(.mdc-switch--checked) .mdc-switch__thumb {
|
||||
background-color: var(--switch-unchecked-button-color);
|
||||
border-color: var(--switch-unchecked-button-color);
|
||||
}
|
||||
.mdc-switch:not(.mdc-switch--checked) .mdc-switch__track {
|
||||
background-color: var(--switch-unchecked-track-color);
|
||||
border-color: var(--switch-unchecked-track-color);
|
||||
}
|
||||
`,
|
||||
];
|
||||
public disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this.removeEventListener("change", this._forwardHaptic);
|
||||
}
|
||||
|
||||
private _forwardHaptic = () => {
|
||||
forwardHaptic(this, "light");
|
||||
};
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
Switch.styles,
|
||||
css`
|
||||
:host {
|
||||
--wa-form-control-toggle-size: var(--ha-switch-size, 24px);
|
||||
--wa-form-control-required-content: var(
|
||||
--ha-switch-required-marker,
|
||||
var(--ha-input-required-marker, "*")
|
||||
);
|
||||
--wa-form-control-required-content-offset: var(
|
||||
--ha-switch-required-marker-offset,
|
||||
0.1rem
|
||||
);
|
||||
--thumb-size: var(--ha-switch-thumb-size, 18px);
|
||||
--width: var(--ha-switch-width, 48px);
|
||||
}
|
||||
|
||||
label {
|
||||
height: max(var(--thumb-size), var(--wa-form-control-toggle-size));
|
||||
}
|
||||
|
||||
.switch {
|
||||
background-color: var(
|
||||
--ha-switch-background-color,
|
||||
var(--ha-color-fill-disabled-quiet-resting)
|
||||
);
|
||||
border-color: var(
|
||||
--ha-switch-border-color,
|
||||
var(--ha-color-border-neutral-normal)
|
||||
);
|
||||
}
|
||||
label:not(.disabled):hover .switch,
|
||||
label:not(.disabled) .input:focus-visible ~ .switch {
|
||||
background-color: var(
|
||||
--ha-switch-background-color-hover,
|
||||
var(
|
||||
--ha-switch-background-color,
|
||||
var(--ha-color-fill-disabled-quiet-hover)
|
||||
)
|
||||
);
|
||||
}
|
||||
.checked .switch {
|
||||
background-color: var(
|
||||
--ha-switch-checked-background-color,
|
||||
var(--ha-color-fill-primary-normal-resting)
|
||||
);
|
||||
border-color: var(
|
||||
--ha-switch-checked-border-color,
|
||||
var(--ha-color-border-primary-loud)
|
||||
);
|
||||
}
|
||||
label:not(.disabled).checked:hover .switch,
|
||||
label:not(.disabled).checked .input:focus-visible ~ .switch {
|
||||
background-color: var(
|
||||
--ha-switch-checked-background-color-hover,
|
||||
var(
|
||||
--ha-switch-checked-background-color,
|
||||
var(--ha-color-fill-primary-normal-hover)
|
||||
)
|
||||
);
|
||||
border-color: var(
|
||||
--ha-switch-checked-border-color-hover,
|
||||
var(
|
||||
--ha-switch-checked-border-color,
|
||||
var(--ha-color-border-primary-loud)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
.switch .thumb {
|
||||
background-color: var(
|
||||
--ha-switch-thumb-background-color,
|
||||
var(--ha-color-on-neutral-normal)
|
||||
);
|
||||
border-color: var(
|
||||
--ha-switch-thumb-border-color,
|
||||
var(--ha-color-on-neutral-normal)
|
||||
);
|
||||
border-style: var(--wa-form-control-border-style);
|
||||
border-width: var(--wa-form-control-border-width);
|
||||
box-shadow: var(--ha-switch-thumb-box-shadow, var(--ha-box-shadow-s));
|
||||
}
|
||||
label:not(.disabled):hover .switch .thumb,
|
||||
label:not(.disabled) .input:focus-visible ~ .switch .thumb {
|
||||
background-color: var(
|
||||
--ha-switch-thumb-background-color-hover,
|
||||
var(
|
||||
--ha-switch-thumb-background-color,
|
||||
var(--ha-color-on-neutral-normal)
|
||||
)
|
||||
);
|
||||
border-color: var(
|
||||
--ha-switch-thumb-border-color-hover,
|
||||
var(
|
||||
--ha-switch-thumb-border-color,
|
||||
var(--ha-color-on-neutral-normal)
|
||||
)
|
||||
);
|
||||
}
|
||||
.checked .switch .thumb {
|
||||
background-color: var(
|
||||
--ha-switch-checked-thumb-background-color,
|
||||
var(--ha-color-on-primary-normal)
|
||||
);
|
||||
border-color: var(
|
||||
--ha-switch-checked-thumb-border-color,
|
||||
var(--ha-color-on-primary-normal)
|
||||
);
|
||||
}
|
||||
label:not(.disabled).checked:hover .switch .thumb,
|
||||
label:not(.disabled).checked .input:focus-visible ~ .switch .thumb {
|
||||
background-color: var(
|
||||
--ha-switch-checked-thumb-background-color-hover,
|
||||
var(
|
||||
--ha-switch-checked-thumb-background-color,
|
||||
var(--ha-color-on-primary-normal)
|
||||
)
|
||||
);
|
||||
border-color: var(
|
||||
--ha-switch-checked-thumb-border-color-hover,
|
||||
var(
|
||||
--ha-switch-checked-thumb-border-color,
|
||||
var(--ha-color-on-primary-normal)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
label.disabled {
|
||||
opacity: var(--ha-switch-disabled-opacity, 0.3);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Focus */
|
||||
label:not(.disabled) .input:focus-visible ~ .switch .thumb {
|
||||
outline: none;
|
||||
outline-offset: none;
|
||||
}
|
||||
label:not(.disabled) .input:focus-visible ~ .switch {
|
||||
outline: var(--wa-focus-ring);
|
||||
outline-offset: var(--wa-focus-ring-offset);
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
@@ -14,6 +14,8 @@ export class HaThemePicker extends LitElement {
|
||||
|
||||
@property() public label?: string;
|
||||
|
||||
@property() public helper?: string;
|
||||
|
||||
@property({ attribute: "include-default", type: Boolean })
|
||||
public includeDefault = false;
|
||||
|
||||
@@ -49,6 +51,7 @@ export class HaThemePicker extends LitElement {
|
||||
.label=${this.label ||
|
||||
this.hass!.localize("ui.components.theme-picker.theme")}
|
||||
.value=${this.value}
|
||||
.helper=${this.helper}
|
||||
.required=${this.required}
|
||||
.disabled=${this.disabled}
|
||||
@selected=${this._changed}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
state,
|
||||
} from "lit/decorators";
|
||||
import { ifDefined } from "lit/directives/if-defined";
|
||||
import { styleMap } from "lit/directives/style-map";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import { popoverSupported } from "../common/feature-detect/support-popover";
|
||||
import { nextRender } from "../common/util/render-status";
|
||||
@@ -28,6 +29,9 @@ export class HaToast extends LitElement {
|
||||
|
||||
@property({ type: Number, attribute: "timeout-ms" }) public timeoutMs = 4000;
|
||||
|
||||
@property({ type: Number, attribute: "bottom-offset" }) public bottomOffset =
|
||||
0;
|
||||
|
||||
@query(".toast")
|
||||
private _toast?: HTMLDivElement;
|
||||
|
||||
@@ -186,6 +190,9 @@ export class HaToast extends LitElement {
|
||||
active: this._active,
|
||||
visible: this._visible,
|
||||
})}
|
||||
style=${styleMap({
|
||||
"--ha-toast-bottom-offset": `${this.bottomOffset}px`,
|
||||
})}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
popover=${ifDefined(popoverSupported ? "manual" : undefined)}
|
||||
@@ -205,7 +212,8 @@ export class HaToast extends LitElement {
|
||||
inset-block-start: auto;
|
||||
inset-inline-end: auto;
|
||||
inset-block-end: calc(
|
||||
var(--safe-area-inset-bottom, 0px) + var(--ha-space-4)
|
||||
var(--safe-area-inset-bottom, 0px) + var(--ha-space-4) +
|
||||
var(--ha-toast-bottom-offset, 0px)
|
||||
);
|
||||
inset-inline-start: 50%;
|
||||
margin: 0;
|
||||
@@ -232,15 +240,15 @@ export class HaToast extends LitElement {
|
||||
transform var(--ha-animation-duration-fast, 150ms) ease;
|
||||
}
|
||||
|
||||
.toast:not(.active) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.toast.visible {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, 0);
|
||||
}
|
||||
|
||||
.toast:not(.active) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.message {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import Tooltip from "@home-assistant/webawesome/dist/components/tooltip/tooltip";
|
||||
import { css } from "lit";
|
||||
import type { CSSResultGroup } from "lit";
|
||||
import { css } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
|
||||
@customElement("ha-tooltip")
|
||||
export class HaTooltip extends Tooltip {
|
||||
/** The amount of time to wait before showing the tooltip when the user mouses in. */
|
||||
@property({ attribute: "show-delay", type: Number }) showDelay = 150;
|
||||
@property({ attribute: "show-delay", type: Number }) showDelay = 350;
|
||||
|
||||
/** The amount of time to wait before hiding the tooltip when the user mouses out.. */
|
||||
@property({ attribute: "hide-delay", type: Number }) hideDelay = 150;
|
||||
@@ -18,7 +18,7 @@ export class HaTooltip extends Tooltip {
|
||||
:host {
|
||||
--wa-tooltip-background-color: var(
|
||||
--ha-tooltip-background-color,
|
||||
var(--secondary-background-color)
|
||||
var(--ha-color-surface-default)
|
||||
);
|
||||
--wa-tooltip-content-color: var(
|
||||
--ha-tooltip-text-color,
|
||||
@@ -30,11 +30,11 @@ export class HaTooltip extends Tooltip {
|
||||
);
|
||||
--wa-tooltip-font-size: var(
|
||||
--ha-tooltip-font-size,
|
||||
var(--ha-font-size-s)
|
||||
var(--ha-font-size-m)
|
||||
);
|
||||
--wa-tooltip-font-weight: var(
|
||||
--ha-tooltip-font-weight,
|
||||
var(--ha-font-weight-normal)
|
||||
var(--ha-font-weight-medium)
|
||||
);
|
||||
--wa-tooltip-line-height: var(
|
||||
--ha-tooltip-line-height,
|
||||
@@ -43,12 +43,20 @@ export class HaTooltip extends Tooltip {
|
||||
--wa-tooltip-padding: var(--ha-tooltip-padding, var(--ha-space-2));
|
||||
--wa-tooltip-border-radius: var(
|
||||
--ha-tooltip-border-radius,
|
||||
var(--ha-border-radius-sm)
|
||||
var(--ha-border-radius-md)
|
||||
);
|
||||
--wa-tooltip-arrow-size: var(--ha-tooltip-arrow-size, 8px);
|
||||
--wa-tooltip-arrow-size: var(--ha-tooltip-arrow-size, 0px);
|
||||
--wa-tooltip-border-width: 0px;
|
||||
--wa-z-index-tooltip: 1000;
|
||||
}
|
||||
|
||||
.tooltip::part(popup) {
|
||||
animation-duration: var(--ha-tooltip-animation-duration, 0);
|
||||
}
|
||||
|
||||
.body {
|
||||
box-shadow: var(--ha-tooltip-box-shadow, var(--ha-box-shadow-m));
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { consume, type ContextType } from "@lit/context";
|
||||
import { mdiMagnify } from "@mdi/js";
|
||||
import { html, type PropertyValues } from "lit";
|
||||
import { css, html, type PropertyValues } from "lit";
|
||||
import { customElement, state } from "lit/decorators";
|
||||
import { internationalizationContext } from "../../data/context";
|
||||
import { HaInput } from "./ha-input";
|
||||
@@ -42,6 +42,15 @@ export class HaInputSearch extends HaInput {
|
||||
protected renderStartDefault() {
|
||||
return html`<ha-svg-icon slot="start" .path=${mdiMagnify}></ha-svg-icon>`;
|
||||
}
|
||||
|
||||
static styles = [
|
||||
...HaInput.styles,
|
||||
css`
|
||||
:host([appearance="outlined"]) wa-input.no-label::part(base) {
|
||||
height: 40px;
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
262
src/data/compute-navigation-path-info.ts
Normal file
262
src/data/compute-navigation-path-info.ts
Normal file
@@ -0,0 +1,262 @@
|
||||
import {
|
||||
mdiDevices,
|
||||
mdiHammer,
|
||||
mdiLink,
|
||||
mdiPalette,
|
||||
mdiPuzzle,
|
||||
mdiRobot,
|
||||
mdiScriptText,
|
||||
mdiShape,
|
||||
mdiTextureBox,
|
||||
} from "@mdi/js";
|
||||
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
|
||||
import { isComponentLoaded } from "../common/config/is_component_loaded";
|
||||
import { computeDeviceName } from "../common/entity/compute_device_name";
|
||||
import {
|
||||
getIngressPanelInfoCollection,
|
||||
type IngressPanelInfoMap,
|
||||
} from "./hassio/ingress";
|
||||
import { getLovelaceCollection } from "./lovelace";
|
||||
import type { LovelaceRawConfig } from "./lovelace/config/types";
|
||||
import { computeViewIcon, computeViewTitle } from "./lovelace/config/view";
|
||||
import {
|
||||
APP_PANEL,
|
||||
getPanelIcon,
|
||||
getPanelIconPath,
|
||||
getPanelTitleFromUrlPath,
|
||||
} from "./panel";
|
||||
import type { LocalizeKeys } from "../common/translations/localize";
|
||||
import type { HomeAssistant } from "../types";
|
||||
|
||||
export interface NavigationPathInfo {
|
||||
label: string;
|
||||
icon?: string;
|
||||
iconPath: string;
|
||||
}
|
||||
|
||||
export const DEFAULT_NAVIGATION_PATH_INFO: NavigationPathInfo = {
|
||||
label: "",
|
||||
iconPath: mdiLink,
|
||||
};
|
||||
|
||||
const AREA_VIEW_PREFIX = "areas-";
|
||||
|
||||
export const CONFIG_SUB_ROUTES: Record<
|
||||
string,
|
||||
{ translationKey: LocalizeKeys; iconPath: string }
|
||||
> = {
|
||||
automation: {
|
||||
translationKey: "ui.components.navigation-picker.route.automations",
|
||||
iconPath: mdiRobot,
|
||||
},
|
||||
scene: {
|
||||
translationKey: "ui.components.navigation-picker.route.scenes",
|
||||
iconPath: mdiPalette,
|
||||
},
|
||||
script: {
|
||||
translationKey: "ui.components.navigation-picker.route.scripts",
|
||||
iconPath: mdiScriptText,
|
||||
},
|
||||
"developer-tools": {
|
||||
translationKey: "ui.components.navigation-picker.route.developer_tools",
|
||||
iconPath: mdiHammer,
|
||||
},
|
||||
integrations: {
|
||||
translationKey: "ui.components.navigation-picker.route.integrations",
|
||||
iconPath: mdiPuzzle,
|
||||
},
|
||||
devices: {
|
||||
translationKey: "ui.components.navigation-picker.route.devices",
|
||||
iconPath: mdiDevices,
|
||||
},
|
||||
entities: {
|
||||
translationKey: "ui.components.navigation-picker.route.entities",
|
||||
iconPath: mdiShape,
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve a navigation path to a display label and icon.
|
||||
* Works synchronously for panels, areas, and devices.
|
||||
* For lovelace views, pass the dashboard config to resolve view title/icon.
|
||||
*/
|
||||
export const computeNavigationPathInfo = (
|
||||
hass: HomeAssistant,
|
||||
path: string,
|
||||
lovelaceConfig?: LovelaceRawConfig,
|
||||
ingressPanels?: IngressPanelInfoMap
|
||||
): NavigationPathInfo => {
|
||||
const segments = path.replace(/^\//, "").split(/[/?]/);
|
||||
const panelUrlPath = segments[0];
|
||||
const subPath = segments[1];
|
||||
|
||||
// /config/areas/area/{areaId}
|
||||
if (
|
||||
panelUrlPath === "config" &&
|
||||
segments[1] === "areas" &&
|
||||
segments[2] === "area" &&
|
||||
segments[3]
|
||||
) {
|
||||
return computeAreaNavigationPathInfo(hass, segments[3]);
|
||||
}
|
||||
|
||||
// /config/devices/device/{deviceId}
|
||||
if (
|
||||
panelUrlPath === "config" &&
|
||||
segments[1] === "devices" &&
|
||||
segments[2] === "device" &&
|
||||
segments[3]
|
||||
) {
|
||||
return computeDeviceNavigationPathInfo(hass, segments[3]);
|
||||
}
|
||||
|
||||
// /app/<slug> (ingress addon panel)
|
||||
if (panelUrlPath === APP_PANEL && subPath) {
|
||||
return computeIngressNavigationPathInfo(subPath, ingressPanels);
|
||||
}
|
||||
|
||||
// /config/{subRoute} (e.g. /config/automation, /config/integrations)
|
||||
if (panelUrlPath === "config" && subPath && subPath in CONFIG_SUB_ROUTES) {
|
||||
const route = CONFIG_SUB_ROUTES[subPath];
|
||||
return {
|
||||
label: hass.localize(route.translationKey) || subPath,
|
||||
iconPath: route.iconPath,
|
||||
};
|
||||
}
|
||||
|
||||
const panel = panelUrlPath ? hass.panels[panelUrlPath] : undefined;
|
||||
const panelIcon = panel ? getPanelIcon(panel) : undefined;
|
||||
const panelIconPath = panel ? getPanelIconPath(panel) : undefined;
|
||||
|
||||
// /home/areas-{areaId} (area dashboard view)
|
||||
if (subPath?.startsWith(AREA_VIEW_PREFIX)) {
|
||||
const areaId = subPath.slice(AREA_VIEW_PREFIX.length);
|
||||
return computeAreaNavigationPathInfo(hass, areaId);
|
||||
}
|
||||
|
||||
const isDashboard = panel?.component_name === "lovelace";
|
||||
|
||||
const panelInfo: NavigationPathInfo = {
|
||||
label: getPanelTitleFromUrlPath(hass, panelUrlPath) || panelUrlPath,
|
||||
icon: panelIcon || (isDashboard ? "mdi:view-dashboard" : undefined),
|
||||
iconPath: panelIconPath || mdiLink,
|
||||
};
|
||||
|
||||
// Lovelace view path
|
||||
if (subPath && lovelaceConfig && "views" in lovelaceConfig) {
|
||||
const viewIndex = lovelaceConfig.views.findIndex(
|
||||
(v, index) => (v.path ?? String(index)) === subPath
|
||||
);
|
||||
if (viewIndex !== -1) {
|
||||
const view = lovelaceConfig.views[viewIndex];
|
||||
return {
|
||||
...panelInfo,
|
||||
label: computeViewTitle(view, viewIndex),
|
||||
icon: computeViewIcon(view),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return panelInfo;
|
||||
};
|
||||
|
||||
const computeAreaNavigationPathInfo = (
|
||||
hass: HomeAssistant,
|
||||
areaId: string
|
||||
): NavigationPathInfo => {
|
||||
const area = hass.areas[areaId];
|
||||
return {
|
||||
label: area?.name || areaId,
|
||||
icon: area?.icon || undefined,
|
||||
iconPath: mdiTextureBox,
|
||||
};
|
||||
};
|
||||
|
||||
const computeDeviceNavigationPathInfo = (
|
||||
hass: HomeAssistant,
|
||||
deviceId: string
|
||||
): NavigationPathInfo => {
|
||||
const device = hass.devices[deviceId];
|
||||
return {
|
||||
label: (device ? computeDeviceName(device) : undefined) || deviceId,
|
||||
iconPath: mdiDevices,
|
||||
};
|
||||
};
|
||||
|
||||
const computeIngressNavigationPathInfo = (
|
||||
slug: string,
|
||||
ingressPanels?: IngressPanelInfoMap
|
||||
): NavigationPathInfo => {
|
||||
const panel = ingressPanels?.[slug];
|
||||
return {
|
||||
label: panel?.title || slug,
|
||||
icon: panel?.icon || undefined,
|
||||
iconPath: mdiPuzzle,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Subscribe to navigation path info updates.
|
||||
* Resolves synchronously first, then subscribes to lovelace config
|
||||
* updates for view paths and ingress panel info for app paths.
|
||||
*/
|
||||
export const subscribeNavigationPathInfo = (
|
||||
hass: HomeAssistant,
|
||||
path: string,
|
||||
onChange: (info: NavigationPathInfo) => void
|
||||
): UnsubscribeFunc | undefined => {
|
||||
const segments = path.replace(/^\//, "").split(/[/?]/);
|
||||
const panelUrlPath = segments[0];
|
||||
|
||||
// Subscribe to ingress panels for /app/<slug> paths
|
||||
if (
|
||||
panelUrlPath === APP_PANEL &&
|
||||
segments[1] &&
|
||||
isComponentLoaded(hass.config, "hassio")
|
||||
) {
|
||||
try {
|
||||
const collection = getIngressPanelInfoCollection(hass.connection);
|
||||
// Use cached state for immediate resolution if available
|
||||
const info = computeNavigationPathInfo(
|
||||
hass,
|
||||
path,
|
||||
undefined,
|
||||
collection.state
|
||||
);
|
||||
onChange(info);
|
||||
let current = info;
|
||||
return collection.subscribe((panels) => {
|
||||
const newInfo = computeNavigationPathInfo(
|
||||
hass,
|
||||
path,
|
||||
undefined,
|
||||
panels
|
||||
);
|
||||
if (newInfo.label !== current.label || newInfo.icon !== current.icon) {
|
||||
current = newInfo;
|
||||
onChange(newInfo);
|
||||
}
|
||||
});
|
||||
} catch (_err) {
|
||||
// Supervisor may not be available
|
||||
}
|
||||
}
|
||||
|
||||
const info = computeNavigationPathInfo(hass, path);
|
||||
onChange(info);
|
||||
|
||||
const panel = panelUrlPath ? hass.panels[panelUrlPath] : undefined;
|
||||
if (segments[1] && panel?.component_name === "lovelace") {
|
||||
let current = info;
|
||||
const collection = getLovelaceCollection(hass.connection, panelUrlPath);
|
||||
return collection.subscribe((config) => {
|
||||
const newInfo = computeNavigationPathInfo(hass, path, config);
|
||||
if (newInfo.label !== current.label || newInfo.icon !== current.icon) {
|
||||
current = newInfo;
|
||||
onChange(newInfo);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
@@ -29,36 +29,6 @@ export const coverSupportsTiltPosition = (stateObj: CoverEntity) =>
|
||||
export const coverSupportsAnyPosition = (stateObj: CoverEntity) =>
|
||||
coverSupportsPosition(stateObj) || coverSupportsTiltPosition(stateObj);
|
||||
|
||||
export const normalizeCoverFavoritePositions = (
|
||||
positions?: number[]
|
||||
): number[] => {
|
||||
if (!positions) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const unique = new Set<number>();
|
||||
const normalized: number[] = [];
|
||||
|
||||
for (const position of positions) {
|
||||
const value = Number(position);
|
||||
|
||||
if (isNaN(value)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const clamped = Math.max(0, Math.min(100, Math.round(value)));
|
||||
|
||||
if (unique.has(clamped)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
unique.add(clamped);
|
||||
normalized.push(clamped);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
};
|
||||
|
||||
export function isFullyOpen(stateObj: CoverEntity) {
|
||||
if (stateObj.attributes.current_position !== undefined) {
|
||||
return stateObj.attributes.current_position === 100;
|
||||
|
||||
27
src/data/favorite_positions.ts
Normal file
27
src/data/favorite_positions.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
export const normalizeFavoritePositions = (positions?: number[]): number[] => {
|
||||
if (!positions) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const unique = new Set<number>();
|
||||
const normalized: number[] = [];
|
||||
|
||||
for (const position of positions) {
|
||||
const value = Number(position);
|
||||
|
||||
if (isNaN(value)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const clamped = Math.max(0, Math.min(100, value));
|
||||
|
||||
if (unique.has(clamped)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
unique.add(clamped);
|
||||
normalized.push(clamped);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
};
|
||||
@@ -18,11 +18,19 @@ export interface CoreFrontendSystemData {
|
||||
onboarded_date?: string;
|
||||
}
|
||||
|
||||
export interface CustomShortcutItem {
|
||||
path: string;
|
||||
label?: string;
|
||||
icon?: string;
|
||||
color?: string;
|
||||
}
|
||||
|
||||
export interface HomeFrontendSystemData {
|
||||
favorite_entities?: string[];
|
||||
welcome_banner_dismissed?: boolean;
|
||||
hidden_summaries?: string[];
|
||||
hide_welcome_message?: boolean;
|
||||
custom_shortcuts?: CustomShortcutItem[];
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { getCollection, type Connection } from "home-assistant-js-websocket";
|
||||
import { atLeastVersion } from "../../common/config/version";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import { supervisorApiWsRequest } from "../supervisor/supervisor";
|
||||
import type { HassioResponse } from "./common";
|
||||
import type { CreateSessionResponse } from "./supervisor";
|
||||
|
||||
@@ -28,6 +30,25 @@ export const createHassioSession = async (
|
||||
return setIngressCookie(restResponse.data.session);
|
||||
};
|
||||
|
||||
export interface IngressPanelInfo {
|
||||
title: string;
|
||||
icon: string;
|
||||
}
|
||||
|
||||
export type IngressPanelInfoMap = Record<string, IngressPanelInfo>;
|
||||
|
||||
export const getIngressPanelInfoCollection = (conn: Connection) =>
|
||||
getCollection<IngressPanelInfoMap>(
|
||||
conn,
|
||||
"_ingressPanelInfo",
|
||||
async (conn2) => {
|
||||
const result = await supervisorApiWsRequest<{
|
||||
panels: IngressPanelInfoMap;
|
||||
}>(conn2, { endpoint: "/ingress/panels" });
|
||||
return result.panels;
|
||||
}
|
||||
);
|
||||
|
||||
export const validateHassioSession = async (
|
||||
hass: HomeAssistant,
|
||||
session: string
|
||||
|
||||
@@ -3,6 +3,7 @@ import type {
|
||||
HassEntities,
|
||||
HassEntity,
|
||||
HassEntityAttributeBase,
|
||||
MessageBase,
|
||||
} from "home-assistant-js-websocket";
|
||||
import { computeDomain } from "../common/entity/compute_domain";
|
||||
import { computeStateDisplayFromEntityAttributes } from "../common/entity/compute_state_display";
|
||||
@@ -124,8 +125,8 @@ export const subscribeHistory = (
|
||||
startTime: Date,
|
||||
endTime: Date,
|
||||
entityIds: string[]
|
||||
): Promise<() => Promise<void>> => {
|
||||
const params = {
|
||||
): Promise<() => Promise<void>> =>
|
||||
subscribeHistoryStream(hass, callbackFunction, () => ({
|
||||
type: "history/stream",
|
||||
entity_ids: entityIds,
|
||||
start_time: startTime.toISOString(),
|
||||
@@ -134,13 +135,7 @@ export const subscribeHistory = (
|
||||
no_attributes: !entityIds.some((entityId) =>
|
||||
entityIdHistoryNeedsAttributes(hass, entityId)
|
||||
),
|
||||
};
|
||||
const stream = new HistoryStream(hass);
|
||||
return hass.connection.subscribeMessage<HistoryStreamMessage>(
|
||||
(message) => callbackFunction(stream.processMessage(message)),
|
||||
params
|
||||
);
|
||||
};
|
||||
}));
|
||||
|
||||
export class HistoryStream {
|
||||
hass: HomeAssistant;
|
||||
@@ -238,26 +233,81 @@ export const subscribeHistoryStatesTimeWindow = (
|
||||
noAttributes?: boolean,
|
||||
minimalResponse = true,
|
||||
significantChangesOnly = true
|
||||
): Promise<() => Promise<void>> => {
|
||||
const params = {
|
||||
type: "history/stream",
|
||||
entity_ids: entityIds,
|
||||
start_time: new Date(
|
||||
new Date().getTime() - 60 * 60 * hoursToShow * 1000
|
||||
).toISOString(),
|
||||
minimal_response: minimalResponse,
|
||||
significant_changes_only: significantChangesOnly,
|
||||
no_attributes:
|
||||
noAttributes ??
|
||||
!entityIds.some((entityId) =>
|
||||
entityIdHistoryNeedsAttributes(hass, entityId)
|
||||
),
|
||||
};
|
||||
const stream = new HistoryStream(hass, hoursToShow);
|
||||
return hass.connection.subscribeMessage<HistoryStreamMessage>(
|
||||
(message) => callbackFunction(stream.processMessage(message)),
|
||||
params
|
||||
): Promise<() => Promise<void>> =>
|
||||
subscribeHistoryStream(
|
||||
hass,
|
||||
callbackFunction,
|
||||
() => ({
|
||||
type: "history/stream",
|
||||
entity_ids: entityIds,
|
||||
// Recomputed on every (re)subscribe so the replay window stays anchored
|
||||
// to "now" after a reconnect instead of replaying a stale window.
|
||||
start_time: new Date(
|
||||
new Date().getTime() - 60 * 60 * hoursToShow * 1000
|
||||
).toISOString(),
|
||||
minimal_response: minimalResponse,
|
||||
significant_changes_only: significantChangesOnly,
|
||||
no_attributes:
|
||||
noAttributes ??
|
||||
!entityIds.some((entityId) =>
|
||||
entityIdHistoryNeedsAttributes(hass, entityId)
|
||||
),
|
||||
}),
|
||||
hoursToShow
|
||||
);
|
||||
|
||||
/**
|
||||
* Subscribe to a history stream with transparent reconnect handling.
|
||||
*
|
||||
* Auto-resubscribe in home-assistant-js-websocket replays the original
|
||||
* `subscribeMessage` call, which reuses the `HistoryStream` (its
|
||||
* `combinedHistory` would merge stale state with the replayed stream) and the
|
||||
* original `start_time` (which is stale after a disconnect for time-window
|
||||
* subscriptions). Instead, we disable the library's auto-resubscribe and
|
||||
* reimplement it here: on every `ready` event we build fresh params and start
|
||||
* a fresh `HistoryStream`, so consumers don't need to listen for reconnects.
|
||||
*/
|
||||
const subscribeHistoryStream = async (
|
||||
hass: HomeAssistant,
|
||||
callbackFunction: (data: HistoryStates) => void,
|
||||
buildParams: () => MessageBase,
|
||||
hoursToShow?: number
|
||||
): Promise<() => Promise<void>> => {
|
||||
let currentUnsub: (() => Promise<void>) | undefined;
|
||||
let disposed = false;
|
||||
|
||||
const doSubscribe = async () => {
|
||||
const stream = new HistoryStream(hass, hoursToShow);
|
||||
const unsub = await hass.connection.subscribeMessage<HistoryStreamMessage>(
|
||||
(message) => callbackFunction(stream.processMessage(message)),
|
||||
buildParams(),
|
||||
{ resubscribe: false }
|
||||
);
|
||||
if (disposed) {
|
||||
unsub().catch(() => undefined);
|
||||
return;
|
||||
}
|
||||
currentUnsub = unsub;
|
||||
};
|
||||
|
||||
const onReady = () => {
|
||||
if (disposed) return;
|
||||
currentUnsub = undefined;
|
||||
// Reconnect failures (e.g. history component not yet loaded) are swallowed;
|
||||
// consumers retry via their own component-availability logic.
|
||||
doSubscribe().catch(() => undefined);
|
||||
};
|
||||
|
||||
await doSubscribe();
|
||||
hass.connection.addEventListener("ready", onReady);
|
||||
|
||||
return async () => {
|
||||
disposed = true;
|
||||
hass.connection.removeEventListener("ready", onReady);
|
||||
if (currentUnsub) {
|
||||
await currentUnsub();
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const equalState = (obj1: LineChartState, obj2: LineChartState) =>
|
||||
|
||||
@@ -24,6 +24,10 @@ export interface LawnMowerEntity extends HassEntityBase {
|
||||
attributes: LawnMowerEntityAttributes;
|
||||
}
|
||||
|
||||
export function isMowing(stateObj: LawnMowerEntity): boolean {
|
||||
return stateObj.state === "mowing";
|
||||
}
|
||||
|
||||
export function canStartMowing(stateObj: LawnMowerEntity): boolean {
|
||||
if (stateObj.state === UNAVAILABLE) {
|
||||
return false;
|
||||
|
||||
@@ -19,6 +19,7 @@ export interface LovelaceBaseSectionConfig {
|
||||
* @deprecated Use heading card instead.
|
||||
*/
|
||||
title?: string;
|
||||
theme?: string;
|
||||
}
|
||||
|
||||
export interface LovelaceSectionConfig extends LovelaceBaseSectionConfig {
|
||||
|
||||
@@ -2,3 +2,21 @@ export interface LovelaceStrategyConfig {
|
||||
type: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
/** Must stay aligned with `STRATEGIES.dashboard` in `panels/lovelace/strategies/get-strategy.ts`. */
|
||||
export const LOVELACE_BUILTIN_DASHBOARD_STRATEGY_TYPES = [
|
||||
"original-states",
|
||||
"map",
|
||||
"iframe",
|
||||
"areas",
|
||||
"home",
|
||||
"energy",
|
||||
] as const;
|
||||
|
||||
export type LovelaceBuiltinDashboardStrategyType =
|
||||
(typeof LOVELACE_BUILTIN_DASHBOARD_STRATEGY_TYPES)[number];
|
||||
|
||||
/** Dashboard strategy id from the new-dashboard picker: built-in key or `custom:…`. */
|
||||
export type LovelaceDashboardStrategyTypeId =
|
||||
| LovelaceBuiltinDashboardStrategyType
|
||||
| `custom:${string}`;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { titleCase } from "../../../common/string/title-case";
|
||||
import type { Condition } from "../../../panels/lovelace/common/validate-condition";
|
||||
import type { MediaSelectorValue } from "../../selector";
|
||||
import type { LovelaceBadgeConfig } from "./badge";
|
||||
@@ -93,3 +94,11 @@ export function isStrategyView(
|
||||
): view is LovelaceStrategyViewConfig {
|
||||
return "strategy" in view;
|
||||
}
|
||||
|
||||
export const computeViewTitle = (
|
||||
view: LovelaceBaseViewConfig,
|
||||
index: number
|
||||
): string => view.title ?? (view.path ? titleCase(view.path) : String(index));
|
||||
|
||||
export const computeViewIcon = (view: LovelaceBaseViewConfig): string =>
|
||||
view.icon ?? "mdi:view-compact";
|
||||
|
||||
@@ -34,6 +34,12 @@ export interface LovelaceDashboardCreateParams extends LovelaceDashboardMutableP
|
||||
mode: "storage";
|
||||
}
|
||||
|
||||
/** Optional suggested values for dashboard creation (for example from a strategy). */
|
||||
export interface LovelaceDashboardSuggestions {
|
||||
title?: string;
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
export const fetchDashboards = (
|
||||
hass: HomeAssistant
|
||||
): Promise<LovelaceDashboard[]> =>
|
||||
|
||||
67
src/data/navigation-path-controller.ts
Normal file
67
src/data/navigation-path-controller.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
|
||||
import type { ReactiveController, ReactiveControllerHost } from "lit";
|
||||
import {
|
||||
DEFAULT_NAVIGATION_PATH_INFO,
|
||||
subscribeNavigationPathInfo,
|
||||
type NavigationPathInfo,
|
||||
} from "./compute-navigation-path-info";
|
||||
import type { HomeAssistant } from "../types";
|
||||
|
||||
/**
|
||||
* Reactive controller that keeps `NavigationPathInfo` in sync with a
|
||||
* navigation path. Resolves synchronously first, then subscribes to
|
||||
* lovelace config updates for view paths.
|
||||
*/
|
||||
export class NavigationPathInfoController implements ReactiveController {
|
||||
private _host: ReactiveControllerHost;
|
||||
|
||||
private _hass?: HomeAssistant;
|
||||
|
||||
private _info: NavigationPathInfo = DEFAULT_NAVIGATION_PATH_INFO;
|
||||
|
||||
private _unsub?: UnsubscribeFunc;
|
||||
|
||||
private _subscribedPath?: string;
|
||||
|
||||
constructor(host: ReactiveControllerHost) {
|
||||
this._host = host;
|
||||
host.addController(this);
|
||||
}
|
||||
|
||||
get info(): NavigationPathInfo {
|
||||
return this._info;
|
||||
}
|
||||
|
||||
update(hass: HomeAssistant, path: string | undefined): void {
|
||||
this._hass = hass;
|
||||
|
||||
if (path === this._subscribedPath) return;
|
||||
|
||||
this._unsub?.();
|
||||
this._unsub = undefined;
|
||||
this._subscribedPath = path;
|
||||
|
||||
if (!path) {
|
||||
this._info = DEFAULT_NAVIGATION_PATH_INFO;
|
||||
return;
|
||||
}
|
||||
|
||||
this._unsub = subscribeNavigationPathInfo(hass, path, (info) => {
|
||||
this._info = info;
|
||||
this._host.requestUpdate();
|
||||
});
|
||||
}
|
||||
|
||||
hostConnected(): void {
|
||||
if (this._hass && this._subscribedPath && !this._unsub) {
|
||||
const path = this._subscribedPath;
|
||||
this._subscribedPath = undefined;
|
||||
this.update(this._hass, path);
|
||||
}
|
||||
}
|
||||
|
||||
hostDisconnected(): void {
|
||||
this._unsub?.();
|
||||
this._unsub = undefined;
|
||||
}
|
||||
}
|
||||
@@ -77,6 +77,7 @@ export type Selector =
|
||||
| TriggerSelector
|
||||
| TTSSelector
|
||||
| TTSVoiceSelector
|
||||
| SerialSelector
|
||||
| UiActionSelector
|
||||
| UiColorSelector
|
||||
| UiStateContentSelector
|
||||
@@ -451,6 +452,10 @@ export interface SelectorSelector {
|
||||
selector: {} | null;
|
||||
}
|
||||
|
||||
export interface SerialSelector {
|
||||
serial: {} | null;
|
||||
}
|
||||
|
||||
export interface StateSelector {
|
||||
state: {
|
||||
extra_options?: { label: string; value: any }[];
|
||||
@@ -494,6 +499,7 @@ export interface StringSelector {
|
||||
| "color";
|
||||
prefix?: string;
|
||||
suffix?: string;
|
||||
placeholder?: string;
|
||||
autocomplete?: string;
|
||||
multiple?: true;
|
||||
} | null;
|
||||
|
||||
@@ -32,8 +32,13 @@ export const getSensorNumericDeviceClasses = async (
|
||||
if (sensorNumericDeviceClassesCache) {
|
||||
return sensorNumericDeviceClassesCache;
|
||||
}
|
||||
sensorNumericDeviceClassesCache = hass.callWS({
|
||||
type: "sensor/numeric_device_classes",
|
||||
});
|
||||
sensorNumericDeviceClassesCache = hass
|
||||
.callWS<SensorNumericDeviceClasses>({
|
||||
type: "sensor/numeric_device_classes",
|
||||
})
|
||||
.catch((err: Error) => {
|
||||
sensorNumericDeviceClassesCache = undefined;
|
||||
throw err;
|
||||
});
|
||||
return sensorNumericDeviceClassesCache!;
|
||||
};
|
||||
|
||||
@@ -1,4 +1,16 @@
|
||||
import type { HomeAssistant } from "../types";
|
||||
|
||||
export interface SerialPort {
|
||||
device: string;
|
||||
serial_number: string | null;
|
||||
manufacturer: string | null;
|
||||
description: string | null;
|
||||
vid?: string;
|
||||
pid?: string;
|
||||
}
|
||||
|
||||
export const scanUSBDevices = (hass: HomeAssistant) =>
|
||||
hass.callWS({ type: "usb/scan" });
|
||||
|
||||
export const listSerialPorts = (hass: HomeAssistant) =>
|
||||
hass.callWS<SerialPort[]>({ type: "usb/list_serial_ports" });
|
||||
|
||||
@@ -19,36 +19,6 @@ export const DEFAULT_VALVE_FAVORITE_POSITIONS = [0, 25, 75, 100];
|
||||
export const valveSupportsPosition = (stateObj: ValveEntity) =>
|
||||
supportsFeature(stateObj, ValveEntityFeature.SET_POSITION);
|
||||
|
||||
export const normalizeValveFavoritePositions = (
|
||||
positions?: number[]
|
||||
): number[] => {
|
||||
if (!positions) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const unique = new Set<number>();
|
||||
const normalized: number[] = [];
|
||||
|
||||
for (const position of positions) {
|
||||
const value = Number(position);
|
||||
|
||||
if (isNaN(value)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const clamped = Math.max(0, Math.min(100, Math.round(value)));
|
||||
|
||||
if (unique.has(clamped)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
unique.add(clamped);
|
||||
normalized.push(clamped);
|
||||
}
|
||||
|
||||
return normalized;
|
||||
};
|
||||
|
||||
export function isFullyOpen(stateObj: ValveEntity) {
|
||||
if (
|
||||
stateObj.attributes.current_position !== undefined &&
|
||||
|
||||
@@ -90,7 +90,7 @@ class DialogBox extends LitElement {
|
||||
></ha-icon-button
|
||||
></slot>`
|
||||
: nothing}
|
||||
<span
|
||||
<h1
|
||||
class=${classMap({ title: true, alert: confirmPrompt })}
|
||||
slot="title"
|
||||
id="dialog-box-title"
|
||||
@@ -102,7 +102,7 @@ class DialogBox extends LitElement {
|
||||
></ha-svg-icon> `
|
||||
: nothing}
|
||||
${dialogTitle}
|
||||
</span>
|
||||
</h1>
|
||||
${this._params.subtitle
|
||||
? html`<span slot="subtitle">${this._params.subtitle}</span>`
|
||||
: nothing}
|
||||
@@ -248,6 +248,11 @@ class DialogBox extends LitElement {
|
||||
ha-input {
|
||||
width: 100%;
|
||||
}
|
||||
.title {
|
||||
font-weight: inherit;
|
||||
font-size: inherit;
|
||||
margin: inherit;
|
||||
}
|
||||
.title.alert {
|
||||
padding: 0 var(--ha-space-2);
|
||||
}
|
||||
|
||||
@@ -11,8 +11,8 @@ import {
|
||||
DEFAULT_COVER_FAVORITE_POSITIONS,
|
||||
coverSupportsPosition,
|
||||
coverSupportsTiltPosition,
|
||||
normalizeCoverFavoritePositions,
|
||||
} from "../../../../data/cover";
|
||||
import { normalizeFavoritePositions } from "../../../../data/favorite_positions";
|
||||
import { UNAVAILABLE } from "../../../../data/entity/entity";
|
||||
import { DOMAIN_ATTRIBUTES_UNITS } from "../../../../data/entity/entity_attributes";
|
||||
import type {
|
||||
@@ -67,13 +67,13 @@ export class HaMoreInfoCoverFavoritePositions extends LitElement {
|
||||
const options = this.entry.options?.cover;
|
||||
|
||||
this._favoritePositions = coverSupportsPosition(this.stateObj)
|
||||
? normalizeCoverFavoritePositions(
|
||||
? normalizeFavoritePositions(
|
||||
options?.favorite_positions ?? DEFAULT_COVER_FAVORITE_POSITIONS
|
||||
)
|
||||
: [];
|
||||
|
||||
this._favoriteTiltPositions = coverSupportsTiltPosition(this.stateObj)
|
||||
? normalizeCoverFavoritePositions(
|
||||
? normalizeFavoritePositions(
|
||||
options?.favorite_tilt_positions ?? DEFAULT_COVER_FAVORITE_POSITIONS
|
||||
)
|
||||
: [];
|
||||
@@ -103,7 +103,7 @@ export class HaMoreInfoCoverFavoritePositions extends LitElement {
|
||||
? this.stateObj.attributes.current_position
|
||||
: this.stateObj.attributes.current_tilt_position;
|
||||
|
||||
return current == null ? undefined : Math.round(current);
|
||||
return current == null ? undefined : current;
|
||||
}
|
||||
|
||||
private async _save(options: CoverEntityOptions): Promise<void> {
|
||||
@@ -142,7 +142,7 @@ export class HaMoreInfoCoverFavoritePositions extends LitElement {
|
||||
kind: FavoriteKind,
|
||||
favorites: number[]
|
||||
): Promise<void> {
|
||||
const normalized = normalizeCoverFavoritePositions(favorites);
|
||||
const normalized = normalizeFavoritePositions(favorites);
|
||||
|
||||
if (kind === "position") {
|
||||
this._favoritePositions = normalized;
|
||||
@@ -213,7 +213,7 @@ export class HaMoreInfoCoverFavoritePositions extends LitElement {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return Math.max(0, Math.min(100, Math.round(number)));
|
||||
return Math.max(0, Math.min(100, number));
|
||||
}
|
||||
|
||||
private async _addFavorite(kind: FavoriteKind): Promise<void> {
|
||||
|
||||
@@ -48,22 +48,27 @@ export class HaMoreInfoStateHeader extends LitElement {
|
||||
|
||||
return html`
|
||||
<p class="state">${stateDisplay}</p>
|
||||
<p class="last-changed" @click=${this._toggleAbsolute}>
|
||||
${this._absoluteTime
|
||||
? html`
|
||||
<ha-absolute-time
|
||||
.hass=${this.hass}
|
||||
.datetime=${this.changedOverride ?? this.stateObj.last_changed}
|
||||
></ha-absolute-time>
|
||||
`
|
||||
: html`
|
||||
<ha-relative-time
|
||||
.hass=${this.hass}
|
||||
.datetime=${this.changedOverride ?? this.stateObj.last_changed}
|
||||
capitalize
|
||||
></ha-relative-time>
|
||||
`}
|
||||
</p>
|
||||
<div class="time-row">
|
||||
<p class="last-changed" @click=${this._toggleAbsolute}>
|
||||
${this._absoluteTime
|
||||
? html`
|
||||
<ha-absolute-time
|
||||
.hass=${this.hass}
|
||||
.datetime=${this.changedOverride ??
|
||||
this.stateObj.last_changed}
|
||||
></ha-absolute-time>
|
||||
`
|
||||
: html`
|
||||
<ha-relative-time
|
||||
.hass=${this.hass}
|
||||
.datetime=${this.changedOverride ??
|
||||
this.stateObj.last_changed}
|
||||
capitalize
|
||||
></ha-relative-time>
|
||||
`}
|
||||
</p>
|
||||
<slot name="after-time"></slot>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -78,6 +83,19 @@ export class HaMoreInfoStateHeader extends LitElement {
|
||||
font-size: 36px;
|
||||
line-height: var(--ha-line-height-condensed);
|
||||
}
|
||||
.time-row {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: var(--ha-space-5);
|
||||
}
|
||||
::slotted([slot="after-time"]) {
|
||||
position: absolute;
|
||||
inset-inline-end: 0;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
.last-changed {
|
||||
font-style: normal;
|
||||
font-size: var(--ha-font-size-l);
|
||||
@@ -85,7 +103,6 @@ export class HaMoreInfoStateHeader extends LitElement {
|
||||
line-height: var(--ha-line-height-normal);
|
||||
letter-spacing: 0.1px;
|
||||
padding: var(--ha-space-1) 0;
|
||||
margin-bottom: var(--ha-space-5);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { mdiCogOutline } from "@mdi/js";
|
||||
import { html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import "../../../../components/ha-icon-button";
|
||||
import { getExtendedEntityRegistryEntry } from "../../../../data/entity/entity_registry";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
import { showVacuumSegmentMappingView } from "./show-view-vacuum-segment-mapping";
|
||||
|
||||
@customElement("ha-more-info-view-vacuum-clean-areas-header-action")
|
||||
export class HaMoreInfoViewVacuumCleanAreasHeaderAction extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ attribute: false }) public params!: { entityId: string };
|
||||
|
||||
@state() private _hasMapping = false;
|
||||
|
||||
protected firstUpdated() {
|
||||
this._loadMapping();
|
||||
}
|
||||
|
||||
private async _loadMapping() {
|
||||
if (!this.params.entityId || !this.hass.user?.is_admin) return;
|
||||
try {
|
||||
const entry = await getExtendedEntityRegistryEntry(
|
||||
this.hass,
|
||||
this.params.entityId
|
||||
);
|
||||
const areaMapping = entry?.options?.vacuum?.area_mapping;
|
||||
this._hasMapping =
|
||||
!!areaMapping &&
|
||||
Object.keys(areaMapping).some((areaId) => this.hass.areas[areaId]);
|
||||
} catch (err) {
|
||||
this._hasMapping = false;
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("Failed to load area mapping", err);
|
||||
}
|
||||
}
|
||||
|
||||
protected render() {
|
||||
if (!this.hass.user?.is_admin || !this._hasMapping) {
|
||||
return nothing;
|
||||
}
|
||||
return html`
|
||||
<ha-icon-button
|
||||
.label=${this.hass.localize(
|
||||
"ui.dialogs.more_info_control.vacuum.configure_area_mapping"
|
||||
)}
|
||||
.path=${mdiCogOutline}
|
||||
@click=${this._handleClick}
|
||||
></ha-icon-button>
|
||||
`;
|
||||
}
|
||||
|
||||
private _handleClick() {
|
||||
showVacuumSegmentMappingView(
|
||||
this,
|
||||
this.hass.localize,
|
||||
this.params.entityId
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-more-info-view-vacuum-clean-areas-header-action": HaMoreInfoViewVacuumCleanAreasHeaderAction;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
import { mdiCogOutline, mdiTextureBox } from "@mdi/js";
|
||||
import type { CSSResultGroup } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
|
||||
import "../../../../components/ha-alert";
|
||||
import "../../../../components/ha-button";
|
||||
import "../../../../components/ha-icon";
|
||||
import "../../../../components/ha-spinner";
|
||||
import "../../../../components/ha-svg-icon";
|
||||
import type { AreaRegistryEntry } from "../../../../data/area/area_registry";
|
||||
import {
|
||||
getExtendedEntityRegistryEntry,
|
||||
type ExtEntityRegistryEntry,
|
||||
} from "../../../../data/entity/entity_registry";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
import { showVacuumSegmentMappingView } from "./show-view-vacuum-segment-mapping";
|
||||
|
||||
@customElement("ha-more-info-view-vacuum-clean-areas")
|
||||
export class HaMoreInfoViewVacuumCleanAreas extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
|
||||
@property({ attribute: false }) public params!: { entityId: string };
|
||||
|
||||
@state() private _mappedAreaIds?: string[];
|
||||
|
||||
@state() private _selectedAreaIds: string[] = [];
|
||||
|
||||
@state() private _loading = true;
|
||||
|
||||
@state() private _error?: string;
|
||||
|
||||
@state() private _submitting = false;
|
||||
|
||||
protected firstUpdated() {
|
||||
this._loadAreas();
|
||||
}
|
||||
|
||||
private async _loadAreas() {
|
||||
if (!this.params.entityId) return;
|
||||
this._loading = true;
|
||||
this._error = undefined;
|
||||
|
||||
try {
|
||||
const entry: ExtEntityRegistryEntry =
|
||||
await getExtendedEntityRegistryEntry(this.hass, this.params.entityId);
|
||||
|
||||
const areaMapping = entry?.options?.vacuum?.area_mapping || {};
|
||||
this._mappedAreaIds = Object.keys(areaMapping).filter(
|
||||
(areaId) => this.hass.areas[areaId]
|
||||
);
|
||||
} catch (err: any) {
|
||||
this._error = err.message || "Failed to load areas";
|
||||
} finally {
|
||||
this._loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
private _toggleArea(ev: Event) {
|
||||
const areaId = (ev.currentTarget as HTMLElement).dataset.areaId!;
|
||||
const index = this._selectedAreaIds.indexOf(areaId);
|
||||
if (index >= 0) {
|
||||
this._selectedAreaIds = this._selectedAreaIds.filter(
|
||||
(id) => id !== areaId
|
||||
);
|
||||
} else {
|
||||
this._selectedAreaIds = [...this._selectedAreaIds, areaId];
|
||||
}
|
||||
}
|
||||
|
||||
private async _startCleaning() {
|
||||
if (!this.params.entityId || this._selectedAreaIds.length === 0) return;
|
||||
this._submitting = true;
|
||||
|
||||
try {
|
||||
await this.hass.callService("vacuum", "clean_area", {
|
||||
entity_id: this.params.entityId,
|
||||
cleaning_area_id: this._selectedAreaIds,
|
||||
});
|
||||
this._selectedAreaIds = [];
|
||||
fireEvent(this, "close-child-view");
|
||||
} catch (err: any) {
|
||||
this._error = err.message || "Failed to start cleaning";
|
||||
} finally {
|
||||
this._submitting = false;
|
||||
}
|
||||
}
|
||||
|
||||
private _openSegmentMapping() {
|
||||
showVacuumSegmentMappingView(
|
||||
this,
|
||||
this.hass.localize,
|
||||
this.params.entityId
|
||||
);
|
||||
}
|
||||
|
||||
private _renderAreaCard(areaId: string) {
|
||||
const area: AreaRegistryEntry | undefined = this.hass.areas[areaId];
|
||||
if (!area) return nothing;
|
||||
|
||||
const selectionIndex = this._selectedAreaIds.indexOf(areaId);
|
||||
const isSelected = selectionIndex >= 0;
|
||||
|
||||
return html`
|
||||
<div
|
||||
class="area-card ${isSelected ? "selected" : ""}"
|
||||
data-area-id=${areaId}
|
||||
@click=${this._toggleArea}
|
||||
>
|
||||
${isSelected
|
||||
? html`<span class="badge">${selectionIndex + 1}</span>`
|
||||
: nothing}
|
||||
<div class="area-icon">
|
||||
${area.icon
|
||||
? html`<ha-icon .icon=${area.icon}></ha-icon>`
|
||||
: html`<ha-svg-icon .path=${mdiTextureBox}></ha-svg-icon>`}
|
||||
</div>
|
||||
<div class="area-name">${area.name}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
protected render() {
|
||||
if (this._loading) {
|
||||
return html`
|
||||
<div class="center">
|
||||
<ha-spinner></ha-spinner>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
if (this._error) {
|
||||
return html`
|
||||
<div class="content">
|
||||
<ha-alert alert-type="error">${this._error}</ha-alert>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
if (!this._mappedAreaIds || this._mappedAreaIds.length === 0) {
|
||||
return html`
|
||||
<div class="content empty-content">
|
||||
<div class="empty">
|
||||
<ha-svg-icon .path=${mdiTextureBox}></ha-svg-icon>
|
||||
<p class="empty-title">
|
||||
${this.hass.localize(
|
||||
"ui.dialogs.more_info_control.vacuum.no_areas_header"
|
||||
)}
|
||||
</p>
|
||||
<p>
|
||||
${this.hass.localize(
|
||||
this.hass.user?.is_admin
|
||||
? "ui.dialogs.more_info_control.vacuum.no_areas_text"
|
||||
: "ui.dialogs.more_info_control.vacuum.no_areas_text_non_admin"
|
||||
)}
|
||||
</p>
|
||||
${this.hass.user?.is_admin
|
||||
? html`
|
||||
<ha-button
|
||||
appearance="plain"
|
||||
size="small"
|
||||
@click=${this._openSegmentMapping}
|
||||
>
|
||||
<ha-svg-icon
|
||||
slot="start"
|
||||
.path=${mdiCogOutline}
|
||||
></ha-svg-icon>
|
||||
${this.hass.localize(
|
||||
"ui.dialogs.more_info_control.vacuum.configure"
|
||||
)}
|
||||
</ha-button>
|
||||
`
|
||||
: nothing}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<div class="content">
|
||||
<div class="area-grid">
|
||||
${this._mappedAreaIds.map((areaId) => this._renderAreaCard(areaId))}
|
||||
</div>
|
||||
<p class="hint">
|
||||
${this.hass.localize(
|
||||
"ui.dialogs.more_info_control.vacuum.clean_areas_order_hint"
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<ha-button
|
||||
@click=${this._startCleaning}
|
||||
.disabled=${this._selectedAreaIds.length === 0 || this._submitting}
|
||||
>
|
||||
${this.hass.localize(
|
||||
"ui.dialogs.more_info_control.vacuum.start_cleaning_areas"
|
||||
)}
|
||||
${this._selectedAreaIds.length > 0
|
||||
? ` (${this._selectedAreaIds.length})`
|
||||
: nothing}
|
||||
</ha-button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
static styles: CSSResultGroup = css`
|
||||
:host {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.center {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--ha-space-8);
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: var(--ha-space-4);
|
||||
}
|
||||
|
||||
.empty-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.empty {
|
||||
--mdc-icon-size: 48px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
color: var(--secondary-text-color);
|
||||
padding: var(--ha-space-8) var(--ha-space-4);
|
||||
max-width: 420px;
|
||||
}
|
||||
|
||||
.empty ha-button {
|
||||
--mdc-icon-size: 18px;
|
||||
}
|
||||
|
||||
.empty .empty-title {
|
||||
font-size: var(--ha-font-size-l);
|
||||
font-weight: var(--ha-font-weight-medium);
|
||||
color: var(--primary-text-color);
|
||||
margin: var(--ha-space-3) 0 var(--ha-space-2);
|
||||
}
|
||||
|
||||
.empty p {
|
||||
margin: 0 0 var(--ha-space-4);
|
||||
}
|
||||
|
||||
.area-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
||||
gap: var(--ha-space-3);
|
||||
}
|
||||
|
||||
.area-card {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--ha-space-2);
|
||||
padding: 12px;
|
||||
border-radius: var(--ha-card-border-radius, var(--ha-border-radius-lg));
|
||||
background: var(--card-background-color, #fff);
|
||||
border: 1px solid var(--divider-color);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
overflow: hidden;
|
||||
min-height: 80px;
|
||||
color: var(--primary-text-color);
|
||||
}
|
||||
|
||||
.area-card::before {
|
||||
content: "";
|
||||
display: block;
|
||||
inset: 0;
|
||||
position: absolute;
|
||||
background-color: transparent;
|
||||
pointer-events: none;
|
||||
opacity: 0.2;
|
||||
transition:
|
||||
background-color 180ms ease-in-out,
|
||||
opacity 180ms ease-in-out;
|
||||
}
|
||||
|
||||
.area-card:hover::before {
|
||||
background-color: var(--divider-color);
|
||||
}
|
||||
|
||||
.area-card.selected::before {
|
||||
background-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.area-card .badge {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
inset-inline-end: 6px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background-color: var(--primary-color);
|
||||
color: var(--text-primary-color);
|
||||
font-size: var(--ha-font-size-xs);
|
||||
font-weight: var(--ha-font-weight-medium);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.area-icon {
|
||||
--mdc-icon-size: 28px;
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
|
||||
.area-card.selected .area-icon {
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.area-name {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: var(--ha-space-8);
|
||||
font-size: var(--ha-font-size-m);
|
||||
font-weight: var(--ha-font-weight-normal);
|
||||
text-align: center;
|
||||
line-height: var(--ha-line-height-condensed);
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin: var(--ha-space-3) 0 0;
|
||||
text-align: center;
|
||||
font-size: var(--ha-font-size-s);
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
|
||||
.footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding: var(--ha-space-4);
|
||||
border-top: 1px solid var(--divider-color);
|
||||
background: var(
|
||||
--ha-dialog-surface-background,
|
||||
var(--mdc-theme-surface, #fff)
|
||||
);
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
z-index: 10;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-more-info-view-vacuum-clean-areas": HaMoreInfoViewVacuumCleanAreas;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { CSSResultGroup } from "lit";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import "../../../../components/ha-button";
|
||||
import "../../../../components/ha-spinner";
|
||||
import "../../../../components/ha-vacuum-segment-area-mapper";
|
||||
@@ -77,6 +78,7 @@ export class HaMoreInfoViewVacuumSegmentMapping extends LitElement {
|
||||
options: options,
|
||||
});
|
||||
this._dirty = false;
|
||||
fireEvent(this, "close-child-view");
|
||||
} catch (err: any) {
|
||||
this._error = err.message;
|
||||
} finally {
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { fireEvent } from "../../../../common/dom/fire_event";
|
||||
import type { LocalizeFunc } from "../../../../common/translations/localize";
|
||||
|
||||
export const loadVacuumCleanAreasView = () =>
|
||||
import("./ha-more-info-view-vacuum-clean-areas");
|
||||
|
||||
export const loadVacuumCleanAreasHeaderAction = () =>
|
||||
import("./ha-more-info-view-vacuum-clean-areas-header-action");
|
||||
|
||||
export const showVacuumCleanAreasView = (
|
||||
element: HTMLElement,
|
||||
localize: LocalizeFunc,
|
||||
entityId: string
|
||||
): void => {
|
||||
fireEvent(element, "show-child-view", {
|
||||
viewTag: "ha-more-info-view-vacuum-clean-areas",
|
||||
viewImport: loadVacuumCleanAreasView,
|
||||
viewTitle: localize("ui.dialogs.more_info_control.vacuum.clean_areas"),
|
||||
viewParams: { entityId },
|
||||
viewHeaderTag: "ha-more-info-view-vacuum-clean-areas-header-action",
|
||||
viewHeaderImport: loadVacuumCleanAreasHeaderAction,
|
||||
});
|
||||
};
|
||||
@@ -15,10 +15,8 @@ import type {
|
||||
import { updateEntityRegistryEntry } from "../../../../data/entity/entity_registry";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
import type { ValveEntity } from "../../../../data/valve";
|
||||
import {
|
||||
DEFAULT_VALVE_FAVORITE_POSITIONS,
|
||||
normalizeValveFavoritePositions,
|
||||
} from "../../../../data/valve";
|
||||
import { DEFAULT_VALVE_FAVORITE_POSITIONS } from "../../../../data/valve";
|
||||
import { normalizeFavoritePositions } from "../../../../data/favorite_positions";
|
||||
import {
|
||||
showConfirmationDialog,
|
||||
showPromptDialog,
|
||||
@@ -55,7 +53,7 @@ export class HaMoreInfoValveFavoritePositions extends LitElement {
|
||||
this.entry &&
|
||||
this.stateObj
|
||||
) {
|
||||
this._favoritePositions = normalizeValveFavoritePositions(
|
||||
this._favoritePositions = normalizeFavoritePositions(
|
||||
this.entry.options?.valve?.favorite_positions ??
|
||||
DEFAULT_VALVE_FAVORITE_POSITIONS
|
||||
);
|
||||
@@ -75,7 +73,7 @@ export class HaMoreInfoValveFavoritePositions extends LitElement {
|
||||
private _currentValue(): number | undefined {
|
||||
const current = this.stateObj.attributes.current_position;
|
||||
|
||||
return current == null ? undefined : Math.round(current);
|
||||
return current == null ? undefined : current;
|
||||
}
|
||||
|
||||
private async _save(favorite_positions: number[]): Promise<void> {
|
||||
@@ -105,7 +103,7 @@ export class HaMoreInfoValveFavoritePositions extends LitElement {
|
||||
}
|
||||
|
||||
private async _setFavorites(favorites: number[]): Promise<void> {
|
||||
const normalized = normalizeValveFavoritePositions(favorites);
|
||||
const normalized = normalizeFavoritePositions(favorites);
|
||||
this._favoritePositions = normalized;
|
||||
await this._save(normalized);
|
||||
}
|
||||
@@ -155,7 +153,7 @@ export class HaMoreInfoValveFavoritePositions extends LitElement {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return Math.max(0, Math.min(100, Math.round(number)));
|
||||
return Math.max(0, Math.min(100, number));
|
||||
}
|
||||
|
||||
private async _addFavorite(): Promise<void> {
|
||||
|
||||
@@ -42,11 +42,13 @@ export const DOMAINS_WITH_NEW_MORE_INFO = [
|
||||
"fan",
|
||||
"humidifier",
|
||||
"input_boolean",
|
||||
"lawn_mower",
|
||||
"light",
|
||||
"lock",
|
||||
"siren",
|
||||
"script",
|
||||
"switch",
|
||||
"vacuum",
|
||||
"valve",
|
||||
"water_heater",
|
||||
"weather",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { mdiHomeImportOutline, mdiPause, mdiPlay } from "@mdi/js";
|
||||
import type { CSSResultGroup } from "lit";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
@@ -6,47 +7,27 @@ import { computeStateDomain } from "../../../common/entity/compute_state_domain"
|
||||
import { supportsFeature } from "../../../common/entity/supports-feature";
|
||||
import { blankBeforePercent } from "../../../common/translations/blank_before_percent";
|
||||
import "../../../components/entity/ha-battery-icon";
|
||||
import "../../../components/ha-icon-button";
|
||||
import "../../../components/ha-control-button";
|
||||
import "../../../components/ha-control-button-group";
|
||||
import "../../../components/ha-svg-icon";
|
||||
import { UNAVAILABLE } from "../../../data/entity/entity";
|
||||
import type { EntityRegistryDisplayEntry } from "../../../data/entity/entity_registry";
|
||||
import {
|
||||
findBatteryChargingEntity,
|
||||
findBatteryEntity,
|
||||
} from "../../../data/entity/entity_registry";
|
||||
import { forwardHaptic } from "../../../data/haptics";
|
||||
import type { LawnMowerEntity } from "../../../data/lawn_mower";
|
||||
import { LawnMowerEntityFeature } from "../../../data/lawn_mower";
|
||||
import {
|
||||
LawnMowerEntityFeature,
|
||||
canDock,
|
||||
canStartMowing,
|
||||
isMowing,
|
||||
} from "../../../data/lawn_mower";
|
||||
import "../../../state-control/lawn_mower/ha-state-control-lawn_mower-status";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
|
||||
interface LawnMowerCommand {
|
||||
translationKey: string;
|
||||
icon: string;
|
||||
serviceName: string;
|
||||
isVisible: (stateObj: LawnMowerEntity) => boolean;
|
||||
}
|
||||
|
||||
const LAWN_MOWER_COMMANDS: LawnMowerCommand[] = [
|
||||
{
|
||||
translationKey: "start_mowing",
|
||||
icon: mdiPlay,
|
||||
serviceName: "start_mowing",
|
||||
isVisible: (stateObj) =>
|
||||
supportsFeature(stateObj, LawnMowerEntityFeature.START_MOWING),
|
||||
},
|
||||
{
|
||||
translationKey: "pause",
|
||||
icon: mdiPause,
|
||||
serviceName: "pause",
|
||||
isVisible: (stateObj) =>
|
||||
supportsFeature(stateObj, LawnMowerEntityFeature.PAUSE),
|
||||
},
|
||||
{
|
||||
translationKey: "dock",
|
||||
icon: mdiHomeImportOutline,
|
||||
serviceName: "dock",
|
||||
isVisible: (stateObj) =>
|
||||
supportsFeature(stateObj, LawnMowerEntityFeature.DOCK),
|
||||
},
|
||||
];
|
||||
import "../components/ha-more-info-state-header";
|
||||
import { moreInfoControlStyle } from "../components/more-info-control-style";
|
||||
|
||||
@customElement("more-info-lawn_mower")
|
||||
class MoreInfoLawnMower extends LitElement {
|
||||
@@ -54,63 +35,6 @@ class MoreInfoLawnMower extends LitElement {
|
||||
|
||||
@property({ attribute: false }) public stateObj?: LawnMowerEntity;
|
||||
|
||||
protected render() {
|
||||
if (!this.hass || !this.stateObj) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
const stateObj = this.stateObj;
|
||||
|
||||
return html`
|
||||
${stateObj.state !== UNAVAILABLE
|
||||
? html` <div class="flex-horizontal">
|
||||
<div>
|
||||
<span class="status-subtitle"
|
||||
>${this.hass!.localize(
|
||||
"ui.dialogs.more_info_control.lawn_mower.activity"
|
||||
)}:
|
||||
</span>
|
||||
<span>
|
||||
<strong>${this.hass.formatEntityState(stateObj)}</strong>
|
||||
</span>
|
||||
</div>
|
||||
${this._renderBattery()}
|
||||
</div>`
|
||||
: nothing}
|
||||
${LAWN_MOWER_COMMANDS.some((item) => item.isVisible(stateObj))
|
||||
? html`
|
||||
<div>
|
||||
<p></p>
|
||||
<div class="status-subtitle">
|
||||
${this.hass!.localize(
|
||||
"ui.dialogs.more_info_control.lawn_mower.commands"
|
||||
)}
|
||||
</div>
|
||||
<div class="flex-horizontal space-around">
|
||||
${LAWN_MOWER_COMMANDS.filter((item) =>
|
||||
item.isVisible(stateObj)
|
||||
).map(
|
||||
(item) => html`
|
||||
<div>
|
||||
<ha-icon-button
|
||||
.path=${item.icon}
|
||||
.entry=${item}
|
||||
@click=${this._callService}
|
||||
.label=${this.hass!.localize(
|
||||
`ui.dialogs.more_info_control.lawn_mower.${item.translationKey}`
|
||||
)}
|
||||
.disabled=${stateObj.state === UNAVAILABLE}
|
||||
></ha-icon-button>
|
||||
</div>
|
||||
`
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
: ""}
|
||||
`;
|
||||
}
|
||||
|
||||
private _deviceEntities = memoizeOne(
|
||||
(
|
||||
deviceId: string,
|
||||
@@ -121,11 +45,45 @@ class MoreInfoLawnMower extends LitElement {
|
||||
}
|
||||
);
|
||||
|
||||
private get _supportsStartPause(): boolean {
|
||||
if (!this.stateObj) return false;
|
||||
return (
|
||||
supportsFeature(this.stateObj, LawnMowerEntityFeature.START_MOWING) ||
|
||||
supportsFeature(this.stateObj, LawnMowerEntityFeature.PAUSE)
|
||||
);
|
||||
}
|
||||
|
||||
private get _startPauseIcon(): string {
|
||||
if (!this.stateObj) return mdiPlay;
|
||||
return isMowing(this.stateObj) &&
|
||||
supportsFeature(this.stateObj, LawnMowerEntityFeature.PAUSE)
|
||||
? mdiPause
|
||||
: mdiPlay;
|
||||
}
|
||||
|
||||
private get _startPauseLabel(): string {
|
||||
if (!this.stateObj || !this.hass) return "";
|
||||
return isMowing(this.stateObj) &&
|
||||
supportsFeature(this.stateObj, LawnMowerEntityFeature.PAUSE)
|
||||
? this.hass.localize("ui.dialogs.more_info_control.lawn_mower.pause")
|
||||
: this.hass.localize(
|
||||
"ui.dialogs.more_info_control.lawn_mower.start_mowing"
|
||||
);
|
||||
}
|
||||
|
||||
private get _startPauseDisabled(): boolean {
|
||||
if (!this.stateObj) return true;
|
||||
if (this.stateObj.state === UNAVAILABLE) return true;
|
||||
if (isMowing(this.stateObj)) return false;
|
||||
return !canStartMowing(this.stateObj);
|
||||
}
|
||||
|
||||
private _renderBattery() {
|
||||
const stateObj = this.stateObj!;
|
||||
|
||||
const deviceId = this.hass.entities[stateObj.entity_id]?.device_id;
|
||||
if (!this.stateObj || !this.hass) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
const deviceId = this.hass.entities[this.stateObj.entity_id]?.device_id;
|
||||
const entities = deviceId
|
||||
? this._deviceEntities(deviceId, this.hass.entities)
|
||||
: [];
|
||||
@@ -134,12 +92,12 @@ class MoreInfoLawnMower extends LitElement {
|
||||
const battery = batteryEntity
|
||||
? this.hass.states[batteryEntity.entity_id]
|
||||
: undefined;
|
||||
const batteryDomain = battery ? computeStateDomain(battery) : undefined;
|
||||
|
||||
const batteryIsBinary =
|
||||
battery && computeStateDomain(battery) === "binary_sensor";
|
||||
|
||||
// Use device battery entity
|
||||
if (battery && (batteryIsBinary || !isNaN(battery.state as any))) {
|
||||
if (
|
||||
battery &&
|
||||
(batteryDomain === "binary_sensor" || !isNaN(battery.state as any))
|
||||
) {
|
||||
const batteryChargingEntity = findBatteryChargingEntity(
|
||||
this.hass,
|
||||
entities
|
||||
@@ -149,49 +107,145 @@ class MoreInfoLawnMower extends LitElement {
|
||||
: undefined;
|
||||
|
||||
return html`
|
||||
<div>
|
||||
<span>
|
||||
${batteryIsBinary
|
||||
? ""
|
||||
: `${Number(battery.state).toFixed()}${blankBeforePercent(
|
||||
<span class="battery" slot="after-time">
|
||||
${batteryDomain === "binary_sensor"
|
||||
? nothing
|
||||
: html`<span
|
||||
>${Number(battery.state).toFixed()}${blankBeforePercent(
|
||||
this.hass.locale
|
||||
)}%`}
|
||||
<ha-battery-icon
|
||||
.hass=${this.hass}
|
||||
.batteryStateObj=${battery}
|
||||
.batteryChargingStateObj=${batteryCharging}
|
||||
></ha-battery-icon>
|
||||
</span>
|
||||
</div>
|
||||
)}%</span
|
||||
>`}
|
||||
<ha-battery-icon
|
||||
.hass=${this.hass}
|
||||
.batteryStateObj=${battery}
|
||||
.batteryChargingStateObj=${batteryCharging}
|
||||
></ha-battery-icon>
|
||||
</span>
|
||||
`;
|
||||
}
|
||||
|
||||
return nothing;
|
||||
}
|
||||
|
||||
private _callService(ev: CustomEvent) {
|
||||
const entry = (ev.target! as any).entry as LawnMowerCommand;
|
||||
this.hass.callService("lawn_mower", entry.serviceName, {
|
||||
entity_id: this.stateObj!.entity_id,
|
||||
private _handleStartPause() {
|
||||
if (!this.stateObj) return;
|
||||
forwardHaptic(this, "light");
|
||||
if (isMowing(this.stateObj)) {
|
||||
this.hass.callService("lawn_mower", "pause", {
|
||||
entity_id: this.stateObj.entity_id,
|
||||
});
|
||||
} else {
|
||||
this.hass.callService("lawn_mower", "start_mowing", {
|
||||
entity_id: this.stateObj.entity_id,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private _handleDock() {
|
||||
if (!this.stateObj) return;
|
||||
forwardHaptic(this, "light");
|
||||
this.hass.callService("lawn_mower", "dock", {
|
||||
entity_id: this.stateObj.entity_id,
|
||||
});
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
line-height: var(--ha-line-height-normal);
|
||||
protected render() {
|
||||
if (!this.stateObj) {
|
||||
return nothing;
|
||||
}
|
||||
.status-subtitle {
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
.flex-horizontal {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.space-around {
|
||||
justify-content: space-around;
|
||||
}
|
||||
`;
|
||||
|
||||
const stateObj = this.stateObj;
|
||||
const isUnavailable = stateObj.state === UNAVAILABLE;
|
||||
const supportsDock = supportsFeature(stateObj, LawnMowerEntityFeature.DOCK);
|
||||
|
||||
const hasAnyCommand = this._supportsStartPause || supportsDock;
|
||||
|
||||
return html`
|
||||
<ha-more-info-state-header .hass=${this.hass} .stateObj=${this.stateObj}>
|
||||
${this._renderBattery()}
|
||||
</ha-more-info-state-header>
|
||||
|
||||
<div class="controls">
|
||||
<ha-state-control-lawn_mower-status
|
||||
.stateObj=${this.stateObj}
|
||||
.hass=${this.hass}
|
||||
></ha-state-control-lawn_mower-status>
|
||||
|
||||
${hasAnyCommand
|
||||
? html`
|
||||
<div class="buttons">
|
||||
<ha-control-button-group>
|
||||
${this._supportsStartPause
|
||||
? html`
|
||||
<ha-control-button
|
||||
.label=${this._startPauseLabel}
|
||||
@click=${this._handleStartPause}
|
||||
.disabled=${this._startPauseDisabled}
|
||||
>
|
||||
<ha-svg-icon
|
||||
.path=${this._startPauseIcon}
|
||||
></ha-svg-icon>
|
||||
</ha-control-button>
|
||||
`
|
||||
: nothing}
|
||||
${supportsDock
|
||||
? html`
|
||||
<ha-control-button
|
||||
.label=${this.hass.localize(
|
||||
"ui.dialogs.more_info_control.lawn_mower.dock"
|
||||
)}
|
||||
@click=${this._handleDock}
|
||||
.disabled=${isUnavailable || !canDock(stateObj)}
|
||||
>
|
||||
<ha-svg-icon
|
||||
.path=${mdiHomeImportOutline}
|
||||
></ha-svg-icon>
|
||||
</ha-control-button>
|
||||
`
|
||||
: nothing}
|
||||
</ha-control-button-group>
|
||||
</div>
|
||||
`
|
||||
: nothing}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
moreInfoControlStyle,
|
||||
css`
|
||||
.battery {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--ha-space-1);
|
||||
font-size: var(--ha-font-size-m);
|
||||
color: var(--secondary-text-color);
|
||||
--mdc-icon-size: 18px;
|
||||
}
|
||||
|
||||
.battery span {
|
||||
height: var(--mdc-icon-size);
|
||||
line-height: var(--mdc-icon-size);
|
||||
}
|
||||
|
||||
ha-state-control-lawn_mower-status {
|
||||
margin-bottom: var(--ha-space-4);
|
||||
}
|
||||
|
||||
ha-control-button-group {
|
||||
--control-button-group-thickness: 48px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
ha-control-button-group ha-control-button {
|
||||
flex: 0 0 auto;
|
||||
width: var(--control-button-group-thickness);
|
||||
--control-button-border-radius: var(--ha-border-radius-lg);
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
@@ -7,7 +7,6 @@ import { supportsFeature } from "../../../common/entity/supports-feature";
|
||||
import "../../../components/buttons/ha-progress-button";
|
||||
import "../../../components/ha-alert";
|
||||
import "../../../components/ha-button";
|
||||
import "../../../components/ha-checkbox";
|
||||
import "../../../components/ha-faded";
|
||||
import "../../../components/ha-markdown";
|
||||
import "../../../components/ha-md-list";
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
mdiChevronRight,
|
||||
mdiFan,
|
||||
mdiHomeImportOutline,
|
||||
mdiMapMarker,
|
||||
@@ -7,93 +8,43 @@ import {
|
||||
mdiPlayPause,
|
||||
mdiStop,
|
||||
mdiTargetVariant,
|
||||
mdiTextureBox,
|
||||
} from "@mdi/js";
|
||||
import type { CSSResultGroup } from "lit";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { computeStateDomain } from "../../../common/entity/compute_state_domain";
|
||||
import { supportsFeature } from "../../../common/entity/supports-feature";
|
||||
import { blankBeforePercent } from "../../../common/translations/blank_before_percent";
|
||||
import "../../../components/entity/ha-battery-icon";
|
||||
import type { HaSelectSelectEvent } from "../../../components/ha-select";
|
||||
import "../../../components/ha-control-button";
|
||||
import "../../../components/ha-control-button-group";
|
||||
import "../../../components/ha-control-select-menu";
|
||||
import type { HaDropdownSelectEvent } from "../../../components/ha-dropdown";
|
||||
import "../../../components/ha-icon";
|
||||
import "../../../components/ha-icon-button";
|
||||
import "../../../components/ha-select";
|
||||
import "../../../components/ha-svg-icon";
|
||||
import { UNAVAILABLE } from "../../../data/entity/entity";
|
||||
import type { EntityRegistryDisplayEntry } from "../../../data/entity/entity_registry";
|
||||
import {
|
||||
findBatteryChargingEntity,
|
||||
findBatteryEntity,
|
||||
} from "../../../data/entity/entity_registry";
|
||||
import { forwardHaptic } from "../../../data/haptics";
|
||||
import type { VacuumEntity } from "../../../data/vacuum";
|
||||
import { VacuumEntityFeature } from "../../../data/vacuum";
|
||||
import {
|
||||
VacuumEntityFeature,
|
||||
canReturnHome,
|
||||
canStart,
|
||||
canStop,
|
||||
isCleaning,
|
||||
} from "../../../data/vacuum";
|
||||
import "../../../state-control/vacuum/ha-state-control-vacuum-status";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
|
||||
interface VacuumCommand {
|
||||
translationKey: string;
|
||||
icon: string;
|
||||
serviceName: string;
|
||||
isVisible: (stateObj: VacuumEntity) => boolean;
|
||||
}
|
||||
|
||||
const VACUUM_COMMANDS: VacuumCommand[] = [
|
||||
{
|
||||
translationKey: "start",
|
||||
icon: mdiPlay,
|
||||
serviceName: "start",
|
||||
isVisible: (stateObj) =>
|
||||
supportsFeature(stateObj, VacuumEntityFeature.START),
|
||||
},
|
||||
{
|
||||
translationKey: "pause",
|
||||
icon: mdiPause,
|
||||
serviceName: "pause",
|
||||
isVisible: (stateObj) =>
|
||||
// We need also to check if Start is supported because if not we show start-pause
|
||||
// Start-pause service is only available for old vacuum entities, new entities have the `STATE` feature
|
||||
supportsFeature(stateObj, VacuumEntityFeature.PAUSE) &&
|
||||
(supportsFeature(stateObj, VacuumEntityFeature.STATE) ||
|
||||
supportsFeature(stateObj, VacuumEntityFeature.START)),
|
||||
},
|
||||
{
|
||||
translationKey: "start_pause",
|
||||
icon: mdiPlayPause,
|
||||
serviceName: "start_pause",
|
||||
isVisible: (stateObj) =>
|
||||
// If start is supported, we don't show this button
|
||||
// This service is only available for old vacuum entities, new entities have the `STATE` feature
|
||||
!supportsFeature(stateObj, VacuumEntityFeature.STATE) &&
|
||||
!supportsFeature(stateObj, VacuumEntityFeature.START) &&
|
||||
supportsFeature(stateObj, VacuumEntityFeature.PAUSE),
|
||||
},
|
||||
{
|
||||
translationKey: "stop",
|
||||
icon: mdiStop,
|
||||
serviceName: "stop",
|
||||
isVisible: (stateObj) =>
|
||||
supportsFeature(stateObj, VacuumEntityFeature.STOP),
|
||||
},
|
||||
{
|
||||
translationKey: "clean_spot",
|
||||
icon: mdiTargetVariant,
|
||||
serviceName: "clean_spot",
|
||||
isVisible: (stateObj) =>
|
||||
supportsFeature(stateObj, VacuumEntityFeature.CLEAN_SPOT),
|
||||
},
|
||||
{
|
||||
translationKey: "locate",
|
||||
icon: mdiMapMarker,
|
||||
serviceName: "locate",
|
||||
isVisible: (stateObj) =>
|
||||
supportsFeature(stateObj, VacuumEntityFeature.LOCATE),
|
||||
},
|
||||
{
|
||||
translationKey: "return_home",
|
||||
icon: mdiHomeImportOutline,
|
||||
serviceName: "return_to_base",
|
||||
isVisible: (stateObj) =>
|
||||
supportsFeature(stateObj, VacuumEntityFeature.RETURN_HOME),
|
||||
},
|
||||
];
|
||||
import "../components/ha-more-info-control-select-container";
|
||||
import "../components/ha-more-info-state-header";
|
||||
import { moreInfoControlStyle } from "../components/more-info-control-style";
|
||||
import { showVacuumCleanAreasView } from "../components/vacuum/show-view-vacuum-clean-areas";
|
||||
|
||||
@customElement("more-info-vacuum")
|
||||
class MoreInfoVacuum extends LitElement {
|
||||
@@ -101,107 +52,6 @@ class MoreInfoVacuum extends LitElement {
|
||||
|
||||
@property({ attribute: false }) public stateObj?: VacuumEntity;
|
||||
|
||||
protected render() {
|
||||
if (!this.hass || !this.stateObj) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
const stateObj = this.stateObj;
|
||||
|
||||
return html`
|
||||
${stateObj.state !== UNAVAILABLE
|
||||
? html` <div class="flex-horizontal">
|
||||
<div>
|
||||
<span class="status-subtitle"
|
||||
>${this.hass!.localize(
|
||||
"ui.dialogs.more_info_control.vacuum.status"
|
||||
)}:
|
||||
</span>
|
||||
<span>
|
||||
<strong>
|
||||
${supportsFeature(stateObj, VacuumEntityFeature.STATUS) &&
|
||||
stateObj.attributes.status
|
||||
? this.hass.formatEntityAttributeValue(stateObj, "status")
|
||||
: this.hass.formatEntityState(stateObj)}
|
||||
</strong>
|
||||
</span>
|
||||
</div>
|
||||
${this._renderBattery()}
|
||||
</div>`
|
||||
: ""}
|
||||
${VACUUM_COMMANDS.some((item) => item.isVisible(stateObj))
|
||||
? html`
|
||||
<div>
|
||||
<p></p>
|
||||
<div class="status-subtitle">
|
||||
${this.hass!.localize(
|
||||
"ui.dialogs.more_info_control.vacuum.commands"
|
||||
)}
|
||||
</div>
|
||||
<div class="flex-horizontal space-around">
|
||||
${VACUUM_COMMANDS.filter((item) =>
|
||||
item.isVisible(stateObj)
|
||||
).map(
|
||||
(item) => html`
|
||||
<div>
|
||||
<ha-icon-button
|
||||
.path=${item.icon}
|
||||
.entry=${item}
|
||||
@click=${this._callService}
|
||||
.label=${this.hass!.localize(
|
||||
`ui.dialogs.more_info_control.vacuum.${item.translationKey}`
|
||||
)}
|
||||
.disabled=${stateObj.state === UNAVAILABLE}
|
||||
></ha-icon-button>
|
||||
</div>
|
||||
`
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
: ""}
|
||||
${supportsFeature(stateObj, VacuumEntityFeature.FAN_SPEED)
|
||||
? html`
|
||||
<div>
|
||||
<div class="flex-horizontal">
|
||||
<ha-select
|
||||
.label=${this.hass!.localize(
|
||||
"ui.dialogs.more_info_control.vacuum.fan_speed"
|
||||
)}
|
||||
.disabled=${stateObj.state === UNAVAILABLE}
|
||||
.value=${stateObj.attributes.fan_speed}
|
||||
@selected=${this._handleFanSpeedChanged}
|
||||
.options=${stateObj.attributes.fan_speed_list!.map(
|
||||
(mode) => ({
|
||||
value: mode,
|
||||
label: this.hass!.formatEntityAttributeValue(
|
||||
stateObj,
|
||||
"fan_speed",
|
||||
mode
|
||||
),
|
||||
})
|
||||
)}
|
||||
>
|
||||
</ha-select>
|
||||
<div
|
||||
style="justify-content: center; align-self: center; padding-top: 1.3em"
|
||||
>
|
||||
<span>
|
||||
<ha-svg-icon .path=${mdiFan}></ha-svg-icon>
|
||||
${this.hass.formatEntityAttributeValue(
|
||||
stateObj,
|
||||
"fan_speed"
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<p></p>
|
||||
</div>
|
||||
`
|
||||
: ""}
|
||||
`;
|
||||
}
|
||||
|
||||
private _deviceEntities = memoizeOne(
|
||||
(
|
||||
deviceId: string,
|
||||
@@ -212,11 +62,27 @@ class MoreInfoVacuum extends LitElement {
|
||||
}
|
||||
);
|
||||
|
||||
private get _stateOverride(): string | undefined {
|
||||
if (!this.stateObj || !this.hass) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (
|
||||
supportsFeature(this.stateObj, VacuumEntityFeature.STATUS) &&
|
||||
this.stateObj.attributes.status
|
||||
) {
|
||||
return this.hass.formatEntityAttributeValue(this.stateObj, "status");
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private _renderBattery() {
|
||||
const stateObj = this.stateObj!;
|
||||
|
||||
const deviceId = this.hass.entities[stateObj.entity_id]?.device_id;
|
||||
if (!this.stateObj || !this.hass) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
const deviceId = this.hass.entities[this.stateObj.entity_id]?.device_id;
|
||||
const entities = deviceId
|
||||
? this._deviceEntities(deviceId, this.hass.entities)
|
||||
: [];
|
||||
@@ -241,81 +107,474 @@ class MoreInfoVacuum extends LitElement {
|
||||
: undefined;
|
||||
|
||||
return html`
|
||||
<div>
|
||||
<span>
|
||||
${batteryDomain === "sensor"
|
||||
? this.hass.formatEntityState(battery)
|
||||
: nothing}
|
||||
<ha-battery-icon
|
||||
.hass=${this.hass}
|
||||
.batteryStateObj=${battery}
|
||||
.batteryChargingStateObj=${batteryCharging}
|
||||
></ha-battery-icon>
|
||||
</span>
|
||||
</div>
|
||||
<span class="battery" slot="after-time">
|
||||
${batteryDomain === "binary_sensor"
|
||||
? nothing
|
||||
: html`<span
|
||||
>${Number(battery.state).toFixed()}${blankBeforePercent(
|
||||
this.hass.locale
|
||||
)}%</span
|
||||
>`}
|
||||
<ha-battery-icon
|
||||
.hass=${this.hass}
|
||||
.batteryStateObj=${battery}
|
||||
.batteryChargingStateObj=${batteryCharging}
|
||||
></ha-battery-icon>
|
||||
</span>
|
||||
`;
|
||||
}
|
||||
|
||||
// Use battery_level and battery_icon deprecated attributes
|
||||
// Use deprecated battery_level and battery_icon attributes
|
||||
if (
|
||||
supportsFeature(stateObj, VacuumEntityFeature.BATTERY) &&
|
||||
stateObj.attributes.battery_level
|
||||
supportsFeature(this.stateObj, VacuumEntityFeature.BATTERY) &&
|
||||
this.stateObj.attributes.battery_level
|
||||
) {
|
||||
return html`
|
||||
<div>
|
||||
<span>
|
||||
${this.hass.formatEntityAttributeValue(
|
||||
stateObj,
|
||||
"battery_level",
|
||||
Math.round(stateObj.attributes.battery_level)
|
||||
)}
|
||||
|
||||
<ha-icon .icon=${stateObj.attributes.battery_icon}></ha-icon>
|
||||
</span>
|
||||
</div>
|
||||
<span class="battery" slot="after-time">
|
||||
<span
|
||||
>${Math.round(
|
||||
this.stateObj.attributes.battery_level
|
||||
)}${blankBeforePercent(this.hass.locale)}%</span
|
||||
>
|
||||
<ha-icon .icon=${this.stateObj.attributes.battery_icon}></ha-icon>
|
||||
</span>
|
||||
`;
|
||||
}
|
||||
|
||||
return nothing;
|
||||
}
|
||||
|
||||
private _callService(ev: CustomEvent) {
|
||||
const entry = (ev.target! as any).entry as VacuumCommand;
|
||||
this.hass.callService("vacuum", entry.serviceName, {
|
||||
private _callVacuumService(service: string) {
|
||||
forwardHaptic(this, "light");
|
||||
this.hass.callService("vacuum", service, {
|
||||
entity_id: this.stateObj!.entity_id,
|
||||
});
|
||||
}
|
||||
|
||||
private _handleFanSpeedChanged(ev: HaSelectSelectEvent) {
|
||||
const oldVal = this.stateObj!.attributes.fan_speed;
|
||||
const newVal = ev.detail.value;
|
||||
private _handleStartPause() {
|
||||
const stateObj = this.stateObj!;
|
||||
|
||||
if (!newVal || oldVal === newVal) {
|
||||
// Legacy start_pause for old vacuum entities without STATE feature
|
||||
if (
|
||||
!supportsFeature(stateObj, VacuumEntityFeature.STATE) &&
|
||||
!supportsFeature(stateObj, VacuumEntityFeature.START) &&
|
||||
supportsFeature(stateObj, VacuumEntityFeature.PAUSE)
|
||||
) {
|
||||
this._callVacuumService("start_pause");
|
||||
return;
|
||||
}
|
||||
|
||||
if (isCleaning(stateObj)) {
|
||||
this._callVacuumService("pause");
|
||||
} else {
|
||||
this._callVacuumService("start");
|
||||
}
|
||||
}
|
||||
|
||||
private _handleStop() {
|
||||
this._callVacuumService("stop");
|
||||
}
|
||||
|
||||
private _handleReturnHome() {
|
||||
this._callVacuumService("return_to_base");
|
||||
}
|
||||
|
||||
private _handleLocate() {
|
||||
this._callVacuumService("locate");
|
||||
}
|
||||
|
||||
private _handleCleanSpot() {
|
||||
this._callVacuumService("clean_spot");
|
||||
}
|
||||
|
||||
private _handleCleanAreas() {
|
||||
showVacuumCleanAreasView(
|
||||
this,
|
||||
this.hass.localize,
|
||||
this.stateObj!.entity_id
|
||||
);
|
||||
}
|
||||
|
||||
private _handleFanSpeedChanged(ev: HaDropdownSelectEvent) {
|
||||
const newVal = ev.detail.item.value;
|
||||
const oldVal = this.stateObj!.attributes.fan_speed;
|
||||
|
||||
if (!newVal || oldVal === newVal) return;
|
||||
|
||||
this.hass.callService("vacuum", "set_fan_speed", {
|
||||
entity_id: this.stateObj!.entity_id,
|
||||
fan_speed: newVal,
|
||||
});
|
||||
}
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
line-height: var(--ha-line-height-normal);
|
||||
private get _supportsStartPause(): boolean {
|
||||
if (!this.stateObj) return false;
|
||||
return (
|
||||
supportsFeature(this.stateObj, VacuumEntityFeature.START) ||
|
||||
supportsFeature(this.stateObj, VacuumEntityFeature.PAUSE)
|
||||
);
|
||||
}
|
||||
|
||||
private get _startPauseIcon(): string {
|
||||
if (!this.stateObj) return mdiPlay;
|
||||
|
||||
// Legacy mode
|
||||
if (
|
||||
!supportsFeature(this.stateObj, VacuumEntityFeature.STATE) &&
|
||||
!supportsFeature(this.stateObj, VacuumEntityFeature.START)
|
||||
) {
|
||||
return mdiPlayPause;
|
||||
}
|
||||
.status-subtitle {
|
||||
color: var(--secondary-text-color);
|
||||
|
||||
return isCleaning(this.stateObj) &&
|
||||
supportsFeature(this.stateObj, VacuumEntityFeature.PAUSE)
|
||||
? mdiPause
|
||||
: mdiPlay;
|
||||
}
|
||||
|
||||
private get _startPauseLabel(): string {
|
||||
if (!this.stateObj || !this.hass) return "";
|
||||
|
||||
// Legacy mode
|
||||
if (
|
||||
!supportsFeature(this.stateObj, VacuumEntityFeature.STATE) &&
|
||||
!supportsFeature(this.stateObj, VacuumEntityFeature.START)
|
||||
) {
|
||||
return this.hass.localize(
|
||||
"ui.dialogs.more_info_control.vacuum.start_pause"
|
||||
);
|
||||
}
|
||||
.flex-horizontal {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-between;
|
||||
|
||||
return isCleaning(this.stateObj) &&
|
||||
supportsFeature(this.stateObj, VacuumEntityFeature.PAUSE)
|
||||
? this.hass.localize("ui.dialogs.more_info_control.vacuum.pause")
|
||||
: this.hass.localize("ui.dialogs.more_info_control.vacuum.start");
|
||||
}
|
||||
|
||||
private get _startPauseDisabled(): boolean {
|
||||
if (!this.stateObj) return true;
|
||||
if (this.stateObj.state === UNAVAILABLE) return true;
|
||||
|
||||
// Legacy mode - never disabled
|
||||
if (
|
||||
!supportsFeature(this.stateObj, VacuumEntityFeature.STATE) &&
|
||||
!supportsFeature(this.stateObj, VacuumEntityFeature.START)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
.space-around {
|
||||
justify-content: space-around;
|
||||
|
||||
// If cleaning, pause is always available
|
||||
if (isCleaning(this.stateObj)) return false;
|
||||
|
||||
return !canStart(this.stateObj);
|
||||
}
|
||||
|
||||
protected render() {
|
||||
if (!this.stateObj) {
|
||||
return nothing;
|
||||
}
|
||||
`;
|
||||
|
||||
const stateObj = this.stateObj;
|
||||
const isUnavailable = stateObj.state === UNAVAILABLE;
|
||||
|
||||
const supportsStop = supportsFeature(stateObj, VacuumEntityFeature.STOP);
|
||||
const supportsReturnHome = supportsFeature(
|
||||
stateObj,
|
||||
VacuumEntityFeature.RETURN_HOME
|
||||
);
|
||||
const supportsLocate = supportsFeature(
|
||||
stateObj,
|
||||
VacuumEntityFeature.LOCATE
|
||||
);
|
||||
const supportsCleanSpot = supportsFeature(
|
||||
stateObj,
|
||||
VacuumEntityFeature.CLEAN_SPOT
|
||||
);
|
||||
const supportsCleanArea = supportsFeature(
|
||||
stateObj,
|
||||
VacuumEntityFeature.CLEAN_AREA
|
||||
);
|
||||
const supportsFanSpeed = supportsFeature(
|
||||
stateObj,
|
||||
VacuumEntityFeature.FAN_SPEED
|
||||
);
|
||||
|
||||
const hasAnyCommand =
|
||||
this._supportsStartPause ||
|
||||
supportsStop ||
|
||||
supportsReturnHome ||
|
||||
supportsLocate ||
|
||||
supportsCleanSpot ||
|
||||
supportsCleanArea;
|
||||
|
||||
return html`
|
||||
<ha-more-info-state-header
|
||||
.hass=${this.hass}
|
||||
.stateObj=${this.stateObj}
|
||||
.stateOverride=${this._stateOverride}
|
||||
>
|
||||
${this._renderBattery()}
|
||||
</ha-more-info-state-header>
|
||||
|
||||
<div class="controls">
|
||||
<ha-state-control-vacuum-status
|
||||
.stateObj=${this.stateObj}
|
||||
.hass=${this.hass}
|
||||
></ha-state-control-vacuum-status>
|
||||
|
||||
${hasAnyCommand
|
||||
? html`
|
||||
<div class="buttons">
|
||||
<ha-control-button-group>
|
||||
${this._supportsStartPause
|
||||
? html`
|
||||
<ha-control-button
|
||||
.label=${this._startPauseLabel}
|
||||
@click=${this._handleStartPause}
|
||||
.disabled=${this._startPauseDisabled}
|
||||
>
|
||||
<ha-svg-icon
|
||||
.path=${this._startPauseIcon}
|
||||
></ha-svg-icon>
|
||||
</ha-control-button>
|
||||
`
|
||||
: nothing}
|
||||
${supportsStop
|
||||
? html`
|
||||
<ha-control-button
|
||||
.label=${this.hass.localize(
|
||||
"ui.dialogs.more_info_control.vacuum.stop"
|
||||
)}
|
||||
@click=${this._handleStop}
|
||||
.disabled=${isUnavailable || !canStop(stateObj)}
|
||||
>
|
||||
<ha-svg-icon .path=${mdiStop}></ha-svg-icon>
|
||||
</ha-control-button>
|
||||
`
|
||||
: nothing}
|
||||
${supportsReturnHome
|
||||
? html`
|
||||
<ha-control-button
|
||||
.label=${this.hass.localize(
|
||||
"ui.dialogs.more_info_control.vacuum.return_home"
|
||||
)}
|
||||
@click=${this._handleReturnHome}
|
||||
.disabled=${isUnavailable || !canReturnHome(stateObj)}
|
||||
>
|
||||
<ha-svg-icon
|
||||
.path=${mdiHomeImportOutline}
|
||||
></ha-svg-icon>
|
||||
</ha-control-button>
|
||||
`
|
||||
: nothing}
|
||||
${supportsLocate
|
||||
? html`
|
||||
<ha-control-button
|
||||
.label=${this.hass.localize(
|
||||
"ui.dialogs.more_info_control.vacuum.locate"
|
||||
)}
|
||||
@click=${this._handleLocate}
|
||||
.disabled=${isUnavailable}
|
||||
>
|
||||
<ha-svg-icon .path=${mdiMapMarker}></ha-svg-icon>
|
||||
</ha-control-button>
|
||||
`
|
||||
: nothing}
|
||||
${supportsCleanSpot
|
||||
? html`
|
||||
<ha-control-button
|
||||
.label=${this.hass.localize(
|
||||
"ui.dialogs.more_info_control.vacuum.clean_spot"
|
||||
)}
|
||||
@click=${this._handleCleanSpot}
|
||||
.disabled=${isUnavailable}
|
||||
>
|
||||
<ha-svg-icon .path=${mdiTargetVariant}></ha-svg-icon>
|
||||
</ha-control-button>
|
||||
`
|
||||
: nothing}
|
||||
</ha-control-button-group>
|
||||
</div>
|
||||
`
|
||||
: nothing}
|
||||
</div>
|
||||
|
||||
${(supportsFanSpeed && stateObj.attributes.fan_speed_list) ||
|
||||
supportsCleanArea
|
||||
? html`
|
||||
<ha-more-info-control-select-container>
|
||||
${supportsFanSpeed && stateObj.attributes.fan_speed_list
|
||||
? html`
|
||||
<ha-control-select-menu
|
||||
.hass=${this.hass}
|
||||
.label=${this.hass.formatEntityAttributeName(
|
||||
stateObj,
|
||||
"fan_speed"
|
||||
)}
|
||||
.value=${stateObj.attributes.fan_speed}
|
||||
.disabled=${isUnavailable}
|
||||
@wa-select=${this._handleFanSpeedChanged}
|
||||
.options=${stateObj.attributes.fan_speed_list.map(
|
||||
(mode) => ({
|
||||
value: mode,
|
||||
label: this.hass.formatEntityAttributeValue(
|
||||
stateObj,
|
||||
"fan_speed",
|
||||
mode
|
||||
),
|
||||
})
|
||||
)}
|
||||
>
|
||||
<ha-svg-icon slot="icon" .path=${mdiFan}></ha-svg-icon>
|
||||
</ha-control-select-menu>
|
||||
`
|
||||
: nothing}
|
||||
${supportsCleanArea
|
||||
? html`
|
||||
<button
|
||||
class="clean-areas-button"
|
||||
?disabled=${isUnavailable}
|
||||
@click=${this._handleCleanAreas}
|
||||
>
|
||||
<div class="icon">
|
||||
<ha-svg-icon .path=${mdiTextureBox}></ha-svg-icon>
|
||||
</div>
|
||||
<div class="content">
|
||||
<p class="label">
|
||||
${this.hass.localize(
|
||||
"ui.dialogs.more_info_control.vacuum.cleaning"
|
||||
)}
|
||||
</p>
|
||||
<p class="value">
|
||||
${this.hass.localize(
|
||||
"ui.dialogs.more_info_control.vacuum.by_area"
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div class="icon">
|
||||
<ha-svg-icon .path=${mdiChevronRight}></ha-svg-icon>
|
||||
</div>
|
||||
</button>
|
||||
`
|
||||
: nothing}
|
||||
</ha-more-info-control-select-container>
|
||||
`
|
||||
: nothing}
|
||||
`;
|
||||
}
|
||||
|
||||
static get styles(): CSSResultGroup {
|
||||
return [
|
||||
moreInfoControlStyle,
|
||||
css`
|
||||
.battery {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--ha-space-1);
|
||||
font-size: var(--ha-font-size-m);
|
||||
color: var(--secondary-text-color);
|
||||
--mdc-icon-size: 18px;
|
||||
}
|
||||
|
||||
.battery span {
|
||||
height: var(--mdc-icon-size);
|
||||
line-height: var(--mdc-icon-size);
|
||||
}
|
||||
|
||||
ha-state-control-vacuum-status {
|
||||
margin-bottom: var(--ha-space-4);
|
||||
}
|
||||
|
||||
ha-control-button-group {
|
||||
--control-button-group-thickness: 48px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
ha-control-button-group ha-control-button {
|
||||
flex: 0 0 auto;
|
||||
width: var(--control-button-group-thickness);
|
||||
--control-button-border-radius: var(--ha-border-radius-lg);
|
||||
}
|
||||
|
||||
.clean-areas-button {
|
||||
--mdc-icon-size: 20px;
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
width: auto;
|
||||
max-width: 100%;
|
||||
height: 48px;
|
||||
padding: 6px 10px;
|
||||
border: none;
|
||||
border-radius: var(--ha-border-radius-lg);
|
||||
background: none;
|
||||
color: var(--primary-text-color);
|
||||
font-family: inherit;
|
||||
font-size: var(--ha-font-size-m);
|
||||
font-weight: var(--ha-font-weight-normal);
|
||||
line-height: 1.4;
|
||||
letter-spacing: 0.25px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
box-sizing: border-box;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
z-index: 0;
|
||||
transition: box-shadow 180ms ease-in-out;
|
||||
}
|
||||
.clean-areas-button::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-color: var(--disabled-color);
|
||||
opacity: 0.2;
|
||||
transition:
|
||||
background-color 180ms ease-in-out,
|
||||
opacity 180ms ease-in-out;
|
||||
}
|
||||
.clean-areas-button:hover::before {
|
||||
background-color: var(--ha-color-on-neutral-quiet);
|
||||
}
|
||||
.clean-areas-button:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px var(--secondary-text-color);
|
||||
}
|
||||
.clean-areas-button[disabled] {
|
||||
cursor: not-allowed;
|
||||
color: var(--disabled-color);
|
||||
}
|
||||
.clean-areas-button .icon {
|
||||
display: flex;
|
||||
--mdc-icon-size: 20px;
|
||||
}
|
||||
.clean-areas-button .content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
.clean-areas-button .content p {
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.clean-areas-button .label {
|
||||
font-size: var(--ha-font-size-s);
|
||||
letter-spacing: 0.4px;
|
||||
}
|
||||
.clean-areas-button .value {
|
||||
font-size: var(--ha-font-size-m);
|
||||
}
|
||||
`,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { mdiEye, mdiGauge, mdiWaterPercent, mdiWeatherWindy } from "@mdi/js";
|
||||
import type { CSSResultGroup, PropertyValues } 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 { DragScrollController } from "../../../common/controllers/drag-scroll-controller";
|
||||
import { formatDateWeekdayShort } from "../../../common/datetime/format_date";
|
||||
@@ -46,9 +47,9 @@ class MoreInfoWeather extends LitElement {
|
||||
|
||||
@state() private _subscribed?: Promise<() => void>;
|
||||
|
||||
// @ts-ignore
|
||||
private _dragScrollController = new DragScrollController(this, {
|
||||
selector: ".forecast",
|
||||
enabled: false,
|
||||
});
|
||||
|
||||
private _unsubscribeForecastEvents() {
|
||||
@@ -128,6 +129,20 @@ class MoreInfoWeather extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
protected updated(_changedProps: PropertyValues): void {
|
||||
super.updated(_changedProps);
|
||||
|
||||
if (!this.stateObj) {
|
||||
this._dragScrollController.enabled = false;
|
||||
return;
|
||||
}
|
||||
|
||||
this._dragScrollController.enabled = Boolean(
|
||||
getForecast(this.stateObj.attributes, this._forecastEvent)?.forecast
|
||||
?.length
|
||||
);
|
||||
}
|
||||
|
||||
private _supportedForecasts = memoizeOne((stateObj: WeatherEntity) =>
|
||||
getSupportedForecastTypes(stateObj)
|
||||
);
|
||||
@@ -336,7 +351,12 @@ class MoreInfoWeather extends LitElement {
|
||||
)}
|
||||
</ha-tab-group>`
|
||||
: nothing}
|
||||
<div class="forecast">
|
||||
<div
|
||||
class=${classMap({
|
||||
forecast: true,
|
||||
dragging: this._dragScrollController.scrolling,
|
||||
})}
|
||||
>
|
||||
${forecast?.length
|
||||
? this._groupForecastByDay(forecast).map((dayForecast) => {
|
||||
const showDayHeader = hourly || dayNight;
|
||||
@@ -591,6 +611,15 @@ class MoreInfoWeather extends LitElement {
|
||||
transparent 100%
|
||||
);
|
||||
user-select: none;
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.forecast.dragging {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.forecast.dragging * {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.forecast-day {
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
coverSupportsAnyPosition,
|
||||
coverSupportsPosition,
|
||||
coverSupportsTiltPosition,
|
||||
normalizeCoverFavoritePositions,
|
||||
} from "../../data/cover";
|
||||
import type {
|
||||
ExtEntityRegistryEntry,
|
||||
@@ -34,9 +33,9 @@ import {
|
||||
import type { ValveEntity } from "../../data/valve";
|
||||
import {
|
||||
DEFAULT_VALVE_FAVORITE_POSITIONS,
|
||||
normalizeValveFavoritePositions,
|
||||
valveSupportsPosition,
|
||||
} from "../../data/valve";
|
||||
import { normalizeFavoritePositions } from "../../data/favorite_positions";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import { showAlertDialog } from "../generic/show-dialog-box";
|
||||
import { showFormDialog } from "../form/show-form-dialog";
|
||||
@@ -226,7 +225,7 @@ const coverFavoritesHandler = createNumericFavoritesDialogHandler<CoverEntity>({
|
||||
supports: coverSupportsPosition,
|
||||
getStoredFavorites: (entry) => entry.options?.cover?.favorite_positions,
|
||||
getFavorites: (entry) =>
|
||||
normalizeCoverFavoritePositions(
|
||||
normalizeFavoritePositions(
|
||||
entry.options?.cover?.favorite_positions ??
|
||||
DEFAULT_COVER_FAVORITE_POSITIONS
|
||||
),
|
||||
@@ -237,7 +236,7 @@ const coverFavoritesHandler = createNumericFavoritesDialogHandler<CoverEntity>({
|
||||
getStoredFavorites: (entry) =>
|
||||
entry.options?.cover?.favorite_tilt_positions,
|
||||
getFavorites: (entry) =>
|
||||
normalizeCoverFavoritePositions(
|
||||
normalizeFavoritePositions(
|
||||
entry.options?.cover?.favorite_tilt_positions ??
|
||||
DEFAULT_COVER_FAVORITE_POSITIONS
|
||||
),
|
||||
@@ -311,7 +310,7 @@ const valveFavoritesHandler = createNumericFavoritesDialogHandler<ValveEntity>({
|
||||
supports: valveSupportsPosition,
|
||||
getStoredFavorites: (entry) => entry.options?.valve?.favorite_positions,
|
||||
getFavorites: (entry) =>
|
||||
normalizeValveFavoritePositions(
|
||||
normalizeFavoritePositions(
|
||||
entry.options?.valve?.favorite_positions ??
|
||||
DEFAULT_VALVE_FAVORITE_POSITIONS
|
||||
),
|
||||
|
||||
@@ -23,6 +23,7 @@ import { cache } from "lit/directives/cache";
|
||||
import { classMap } from "lit/directives/class-map";
|
||||
import { keyed } from "lit/directives/keyed";
|
||||
import type { HASSDomEvent } from "../../common/dom/fire_event";
|
||||
import { dynamicElement } from "../../common/dom/dynamic-element-directive";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import { stopPropagation } from "../../common/dom/stop_propagation";
|
||||
import { computeAreaName } from "../../common/entity/compute_area_name";
|
||||
@@ -101,14 +102,15 @@ interface ChildView {
|
||||
viewTitle?: string;
|
||||
viewImport?: () => Promise<unknown>;
|
||||
viewParams?: any;
|
||||
viewHeaderTag?: string;
|
||||
viewHeaderImport?: () => Promise<unknown>;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HASSDomEvents {
|
||||
"show-child-view": ChildView;
|
||||
}
|
||||
interface HASSDomEvents {
|
||||
"toggle-edit-mode": boolean;
|
||||
"close-child-view": undefined;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,7 +140,11 @@ export class MoreInfoDialog extends ScrollableFadeMixin(LitElement) {
|
||||
|
||||
@state() private _initialView: MoreInfoView = DEFAULT_VIEW;
|
||||
|
||||
@state() private _childView?: ChildView;
|
||||
@state() private _childViewStack: ChildView[] = [];
|
||||
|
||||
private get _childView(): ChildView | undefined {
|
||||
return this._childViewStack[this._childViewStack.length - 1];
|
||||
}
|
||||
|
||||
@state() private _entry?: ExtEntityRegistryEntry | null;
|
||||
|
||||
@@ -168,7 +174,7 @@ export class MoreInfoDialog extends ScrollableFadeMixin(LitElement) {
|
||||
this._data = params.data;
|
||||
this._currView = view;
|
||||
this._initialView = view;
|
||||
this._childView = undefined;
|
||||
this._childViewStack = [];
|
||||
this._infoEditMode = false;
|
||||
this._detailsYamlMode = false;
|
||||
|
||||
@@ -208,7 +214,7 @@ export class MoreInfoDialog extends ScrollableFadeMixin(LitElement) {
|
||||
this._detailsYamlMode = false;
|
||||
this._initialView = DEFAULT_VIEW;
|
||||
this._currView = DEFAULT_VIEW;
|
||||
this._childView = undefined;
|
||||
this._childViewStack = [];
|
||||
this._isEscapeEnabled = true;
|
||||
window.removeEventListener("dialog-closed", this._enableEscapeKeyClose);
|
||||
window.removeEventListener("show-dialog", this._disableEscapeKeyClose);
|
||||
@@ -279,7 +285,7 @@ export class MoreInfoDialog extends ScrollableFadeMixin(LitElement) {
|
||||
if (dialog) {
|
||||
fireEvent(dialog as HTMLElement, "dialog-set-fullscreen", false);
|
||||
}
|
||||
this._childView = undefined;
|
||||
this._childViewStack = this._childViewStack.slice(0, -1);
|
||||
this._detailsYamlMode = false;
|
||||
return;
|
||||
}
|
||||
@@ -315,11 +321,17 @@ export class MoreInfoDialog extends ScrollableFadeMixin(LitElement) {
|
||||
}
|
||||
|
||||
private _showChildView(ev: CustomEvent): void {
|
||||
const view = ev.detail as ChildView;
|
||||
this._pushChildView(ev.detail as ChildView);
|
||||
}
|
||||
|
||||
private _pushChildView(view: ChildView): void {
|
||||
if (view.viewImport) {
|
||||
view.viewImport();
|
||||
}
|
||||
this._childView = view;
|
||||
if (view.viewHeaderImport) {
|
||||
view.viewHeaderImport();
|
||||
}
|
||||
this._childViewStack = [...this._childViewStack, view];
|
||||
}
|
||||
|
||||
private _goToDevice(): void {
|
||||
@@ -587,6 +599,7 @@ export class MoreInfoDialog extends ScrollableFadeMixin(LitElement) {
|
||||
.width=${this._fill ? "full" : this.large ? "large" : "medium"}
|
||||
@closed=${this._dialogClosed}
|
||||
@opened=${this._handleOpened}
|
||||
@show-child-view=${this._showChildView}
|
||||
.preventScrimClose=${this._currView === "settings" ||
|
||||
!this._isEscapeEnabled}
|
||||
flexcontent
|
||||
@@ -789,17 +802,12 @@ export class MoreInfoDialog extends ScrollableFadeMixin(LitElement) {
|
||||
@click=${this._toggleDetailsYamlMode}
|
||||
></ha-icon-button>
|
||||
`
|
||||
: this._childView?.viewTag === "ha-more-info-details"
|
||||
? html`
|
||||
<ha-icon-button
|
||||
slot="headerActionItems"
|
||||
.label=${this.hass.localize(
|
||||
"ui.dialogs.more_info_control.toggle_yaml_mode"
|
||||
)}
|
||||
.path=${mdiCodeBraces}
|
||||
@click=${this._toggleDetailsYamlMode}
|
||||
></ha-icon-button>
|
||||
`
|
||||
: this._childView?.viewHeaderTag
|
||||
? dynamicElement(this._childView.viewHeaderTag, {
|
||||
slot: "headerActionItems",
|
||||
hass: this.hass,
|
||||
params: this._childView.viewParams,
|
||||
})
|
||||
: nothing}
|
||||
<div
|
||||
class=${classMap({
|
||||
@@ -813,7 +821,6 @@ export class MoreInfoDialog extends ScrollableFadeMixin(LitElement) {
|
||||
<div
|
||||
class="content ha-scrollbar"
|
||||
tabindex="-1"
|
||||
@show-child-view=${this._showChildView}
|
||||
@entity-entry-updated=${this._entryUpdated}
|
||||
@toggle-edit-mode=${this._handleToggleInfoEditModeEvent}
|
||||
@hass-more-info=${this._handleMoreInfoEvent}
|
||||
@@ -822,24 +829,11 @@ export class MoreInfoDialog extends ScrollableFadeMixin(LitElement) {
|
||||
this._childView
|
||||
? html`
|
||||
<div class="child-view">
|
||||
${this._childView.viewTag ===
|
||||
"ha-more-info-view-voice-assistants"
|
||||
? html`
|
||||
<ha-more-info-view-voice-assistants
|
||||
.hass=${this.hass}
|
||||
.entry=${this._entry!}
|
||||
.params=${this._childView.viewParams}
|
||||
></ha-more-info-view-voice-assistants>
|
||||
`
|
||||
: this._childView.viewTag ===
|
||||
"ha-more-info-view-vacuum-segment-mapping"
|
||||
? html`
|
||||
<ha-more-info-view-vacuum-segment-mapping
|
||||
.hass=${this.hass}
|
||||
.params=${this._childView.viewParams}
|
||||
></ha-more-info-view-vacuum-segment-mapping>
|
||||
`
|
||||
: nothing}
|
||||
${dynamicElement(this._childView.viewTag, {
|
||||
hass: this.hass,
|
||||
entry: this._entry,
|
||||
params: this._childView.viewParams,
|
||||
})}
|
||||
</div>
|
||||
`
|
||||
: this._currView === "info"
|
||||
@@ -908,6 +902,7 @@ export class MoreInfoDialog extends ScrollableFadeMixin(LitElement) {
|
||||
protected firstUpdated(changedProps: PropertyValues) {
|
||||
super.firstUpdated(changedProps);
|
||||
this.addEventListener("close-dialog", () => this.closeDialog());
|
||||
this.addEventListener("close-child-view", () => this._goBack());
|
||||
this._loadNumericDeviceClasses();
|
||||
}
|
||||
|
||||
@@ -965,7 +960,7 @@ export class MoreInfoDialog extends ScrollableFadeMixin(LitElement) {
|
||||
this._initialView = view;
|
||||
this._infoEditMode = false;
|
||||
this._detailsYamlMode = false;
|
||||
this._childView = undefined;
|
||||
this._childViewStack = [];
|
||||
this._loadEntityRegistryEntry();
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { computeDomain } from "../../common/entity/compute_domain";
|
||||
import { createSearchParam } from "../../common/url/search-params";
|
||||
import "../../components/chart/state-history-charts";
|
||||
import "../../components/chart/statistics-chart";
|
||||
import "../../components/ha-alert";
|
||||
import type { HistoryResult } from "../../data/history";
|
||||
import {
|
||||
computeHistory,
|
||||
@@ -48,7 +49,7 @@ export class MoreInfoHistory extends LitElement {
|
||||
|
||||
private _subscribed?: Promise<(() => Promise<void>) | undefined>;
|
||||
|
||||
private _error?: string;
|
||||
@state() private _error?: { code: string; message: string };
|
||||
|
||||
private _metadata?: Record<string, StatisticsMetaData>;
|
||||
|
||||
@@ -80,7 +81,10 @@ export class MoreInfoHistory extends LitElement {
|
||||
>`}
|
||||
</div>
|
||||
${this._error
|
||||
? html`<div class="errors">${this._error}</div>`
|
||||
? html`<ha-alert alert-type="error">
|
||||
${this.hass.localize("ui.components.history_charts.error")}:
|
||||
${this._error.message || this._error.code}
|
||||
</ha-alert>`
|
||||
: this._statistics
|
||||
? html`<statistics-chart
|
||||
.hass=${this.hass}
|
||||
@@ -123,6 +127,20 @@ export class MoreInfoHistory extends LitElement {
|
||||
this._showMoreHref = `/history?${createSearchParam(params)}`;
|
||||
|
||||
this._getStateHistory();
|
||||
} else if (
|
||||
changedProps.has("hass") &&
|
||||
this.entityId &&
|
||||
!this._subscribed &&
|
||||
!this._error
|
||||
) {
|
||||
// Retry when components become available after backend restart
|
||||
const oldHass = changedProps.get("hass") as HomeAssistant | undefined;
|
||||
if (
|
||||
oldHass &&
|
||||
oldHass.config.components !== this.hass.config.components
|
||||
) {
|
||||
this._getStateHistory();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -141,7 +159,7 @@ export class MoreInfoHistory extends LitElement {
|
||||
private _unsubscribeHistory() {
|
||||
clearInterval(this._interval);
|
||||
if (this._subscribed) {
|
||||
this._subscribed.then((unsub) => unsub?.());
|
||||
this._subscribed.then((unsub) => unsub?.()).catch(() => undefined);
|
||||
this._subscribed = undefined;
|
||||
}
|
||||
}
|
||||
@@ -228,8 +246,27 @@ export class MoreInfoHistory extends LitElement {
|
||||
this._unsubscribeHistory();
|
||||
}
|
||||
|
||||
const { numeric_device_classes: sensorNumericDeviceClasses } =
|
||||
await getSensorNumericDeviceClasses(this.hass);
|
||||
// Mark as subscribing before the await to prevent re-entrant calls
|
||||
const sentinel = Promise.resolve(undefined) as NonNullable<
|
||||
typeof this._subscribed
|
||||
>;
|
||||
this._subscribed = sentinel;
|
||||
|
||||
let sensorNumericDeviceClasses: string[];
|
||||
try {
|
||||
({ numeric_device_classes: sensorNumericDeviceClasses } =
|
||||
await getSensorNumericDeviceClasses(this.hass));
|
||||
} catch (_err) {
|
||||
if (this._subscribed === sentinel) {
|
||||
this._subscribed = undefined;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Bail out if a newer call replaced our sentinel while we were awaiting
|
||||
if (this._subscribed !== sentinel) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._subscribed = subscribeHistoryStatesTimeWindow(
|
||||
this.hass!,
|
||||
|
||||
@@ -233,7 +233,6 @@ export class QuickBar extends LitElement {
|
||||
return html`
|
||||
<ha-adaptive-dialog
|
||||
without-header
|
||||
allow-mode-change
|
||||
flexcontent
|
||||
.hass=${this.hass}
|
||||
aria-label=${this.hass.localize("ui.dialogs.quick-bar.title")}
|
||||
|
||||
@@ -18,6 +18,7 @@ export interface ShowToastParams {
|
||||
action?: ToastActionParams;
|
||||
duration?: number;
|
||||
dismissable?: boolean;
|
||||
bottomOffset?: number;
|
||||
}
|
||||
|
||||
export interface ToastActionParams {
|
||||
@@ -89,6 +90,7 @@ class NotificationManager extends LitElement {
|
||||
)
|
||||
: this._parameters.message}
|
||||
.timeoutMs=${this._parameters.duration!}
|
||||
.bottomOffset=${this._parameters.bottomOffset ?? 0}
|
||||
@toast-closed=${this._toastClosed}
|
||||
>
|
||||
${this._parameters?.action
|
||||
|
||||
@@ -178,7 +178,7 @@ class SupervisorAppConfig extends LitElement {
|
||||
path: string[]
|
||||
): Selector | null {
|
||||
if (entry.type === "select") {
|
||||
return { select: { options: entry.options } };
|
||||
return { select: { options: entry.options, multiple: entry.multiple } };
|
||||
}
|
||||
if (entry.type === "string") {
|
||||
return entry.multiple
|
||||
|
||||
@@ -77,7 +77,7 @@ import {
|
||||
} from "../../../../dialogs/generic/show-dialog-box";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
import { isMac } from "../../../../util/is_mac";
|
||||
import { showToast } from "../../../../util/toast";
|
||||
import { showEditorToast } from "../editor-toast";
|
||||
import "../ha-automation-editor-warning";
|
||||
import { overflowStyles, rowStyles } from "../styles";
|
||||
import "../target/ha-automation-row-targets";
|
||||
@@ -248,10 +248,14 @@ export default class HaAutomationActionRow extends LitElement {
|
||||
"target" in
|
||||
(this.hass.services?.[computeDomain(action)]?.[
|
||||
computeObjectId(action)
|
||||
] || {});
|
||||
] || {}) &&
|
||||
// special case for reload config entry as it has an optional target but mainly uses entry_id
|
||||
((this.action as ServiceAction).action !==
|
||||
"homeassistant.reload_config_entry" ||
|
||||
!(this.action as ServiceAction).data?.entry_id);
|
||||
|
||||
const target = actionHasTarget
|
||||
? (this.action as ServiceAction).target
|
||||
? this._extractTargets(this.action as ServiceAction)
|
||||
: type === "device_id" && (this.action as DeviceAction).device_id
|
||||
? { device_id: (this.action as DeviceAction).device_id }
|
||||
: undefined;
|
||||
@@ -591,6 +595,18 @@ export default class HaAutomationActionRow extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
private _extractTargets(action: ServiceAction): HassServiceTarget {
|
||||
if (action.target) {
|
||||
return action.target;
|
||||
}
|
||||
|
||||
// legacy support for entity_id
|
||||
if (action.entity_id) {
|
||||
return { entity_id: action.entity_id };
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
private _renderTargets = memoizeOne(
|
||||
(target?: HassServiceTarget, targetRequired = false) =>
|
||||
html`<ha-automation-row-targets
|
||||
@@ -688,7 +704,7 @@ export default class HaAutomationActionRow extends LitElement {
|
||||
return;
|
||||
}
|
||||
|
||||
showToast(this, {
|
||||
showEditorToast(this, {
|
||||
message: this.hass.localize(
|
||||
"ui.panel.config.automation.editor.actions.run_action_success"
|
||||
),
|
||||
@@ -701,7 +717,7 @@ export default class HaAutomationActionRow extends LitElement {
|
||||
fireEvent(this, "close-sidebar");
|
||||
}
|
||||
|
||||
showToast(this, {
|
||||
showEditorToast(this, {
|
||||
message: this.hass.localize("ui.common.successfully_deleted"),
|
||||
duration: 4000,
|
||||
action: {
|
||||
@@ -769,7 +785,7 @@ export default class HaAutomationActionRow extends LitElement {
|
||||
|
||||
private _copyAction = () => {
|
||||
this._setClipboard();
|
||||
showToast(this, {
|
||||
showEditorToast(this, {
|
||||
message: this.hass.localize(
|
||||
"ui.panel.config.automation.editor.actions.copied_to_clipboard"
|
||||
),
|
||||
@@ -783,7 +799,7 @@ export default class HaAutomationActionRow extends LitElement {
|
||||
if (this._selected) {
|
||||
fireEvent(this, "close-sidebar");
|
||||
}
|
||||
showToast(this, {
|
||||
showEditorToast(this, {
|
||||
message: this.hass.localize(
|
||||
"ui.panel.config.automation.editor.actions.cut_to_clipboard"
|
||||
),
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
} from "../../../../data/action";
|
||||
import { getValueFromDynamic, isDynamic } from "../../../../data/automation";
|
||||
import type { Action } from "../../../../data/script";
|
||||
import { EDITOR_SAVE_FAB_TOAST_BOTTOM_OFFSET } from "../editor-toast";
|
||||
import {
|
||||
PASTE_VALUE,
|
||||
showAddAutomationElementDialog,
|
||||
@@ -31,6 +32,8 @@ export default class HaAutomationAction extends AutomationSortableListMixin<Acti
|
||||
) {
|
||||
@property({ type: Boolean }) public root = false;
|
||||
|
||||
@property({ type: Boolean, attribute: false }) public editorDirty = false;
|
||||
|
||||
@property({ attribute: false }) public actions!: Action[];
|
||||
|
||||
@property({ attribute: false }) public highlightedActions?: Action[];
|
||||
@@ -192,6 +195,9 @@ export default class HaAutomationAction extends AutomationSortableListMixin<Acti
|
||||
type: "action",
|
||||
add: this._addAction,
|
||||
clipboardItem: getAutomationActionType(this._clipboard?.action),
|
||||
clipboardPasteToastBottomOffset: this.editorDirty
|
||||
? EDITOR_SAVE_FAB_TOAST_BOTTOM_OFFSET
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { customElement, property, state } from "lit/decorators";
|
||||
import { assert } from "superstruct";
|
||||
import { fireEvent } from "../../../../../common/dom/fire_event";
|
||||
import { hasTemplate } from "../../../../../common/string/has-template";
|
||||
import "../../../../../components/ha-checkbox";
|
||||
import "../../../../../components/ha-service-control";
|
||||
import "../../../../../components/input/ha-input";
|
||||
import type { HaInput } from "../../../../../components/input/ha-input";
|
||||
@@ -72,6 +73,12 @@ export class HaServiceAction extends LitElement implements ActionElement {
|
||||
const [domain, service] = this._action.action
|
||||
? this._action.action.split(".", 2)
|
||||
: [undefined, undefined];
|
||||
|
||||
const optionalResponse =
|
||||
domain && service
|
||||
? !!this.hass.services[domain]?.[service]?.response?.optional
|
||||
: false;
|
||||
|
||||
return html`
|
||||
<ha-service-control
|
||||
.narrow=${this.narrow}
|
||||
@@ -84,22 +91,29 @@ export class HaServiceAction extends LitElement implements ActionElement {
|
||||
></ha-service-control>
|
||||
${domain && service && this.hass.services[domain]?.[service]?.response
|
||||
? html`<ha-settings-row .narrow=${this.narrow}>
|
||||
${this.hass.services[domain][service].response!.optional
|
||||
${optionalResponse
|
||||
? html`<ha-checkbox
|
||||
.checked=${this._action.response_variable ||
|
||||
.checked=${!!this._action.response_variable ||
|
||||
this._responseChecked}
|
||||
.disabled=${this.disabled}
|
||||
@change=${this._responseCheckboxChanged}
|
||||
slot="prefix"
|
||||
></ha-checkbox>`
|
||||
: html`<div slot="prefix" class="checkbox-spacer"></div>`}
|
||||
<span slot="heading"
|
||||
<span
|
||||
slot="heading"
|
||||
class=${optionalResponse ? "clickable" : ""}
|
||||
@click=${optionalResponse ? this._toggleCheckbox : undefined}
|
||||
>${this.hass.localize(
|
||||
"ui.panel.config.automation.editor.actions.type.service.response_variable"
|
||||
)}</span
|
||||
>
|
||||
<span slot="description">
|
||||
${this.hass.services[domain][service].response!.optional
|
||||
<span
|
||||
slot="description"
|
||||
class=${optionalResponse ? "clickable" : ""}
|
||||
@click=${optionalResponse ? this._toggleCheckbox : undefined}
|
||||
>
|
||||
${optionalResponse
|
||||
? this.hass.localize(
|
||||
"ui.panel.config.automation.editor.actions.type.service.has_optional_response"
|
||||
)
|
||||
@@ -109,10 +123,9 @@ export class HaServiceAction extends LitElement implements ActionElement {
|
||||
</span>
|
||||
<ha-input
|
||||
.value=${this._action.response_variable || ""}
|
||||
.required=${!this.hass.services[domain][service].response!
|
||||
.optional}
|
||||
.required=${!optionalResponse}
|
||||
.disabled=${this.disabled ||
|
||||
(this.hass.services[domain][service].response!.optional &&
|
||||
(optionalResponse &&
|
||||
!this._action.response_variable &&
|
||||
!this._responseChecked)}
|
||||
@change=${this._responseVariableChanged}
|
||||
@@ -156,6 +169,13 @@ export class HaServiceAction extends LitElement implements ActionElement {
|
||||
fireEvent(this, "value-changed", { value });
|
||||
}
|
||||
|
||||
private _toggleCheckbox(ev: Event) {
|
||||
const checkbox = (
|
||||
ev.currentTarget as HTMLElement
|
||||
)?.parentElement?.querySelector("ha-checkbox");
|
||||
checkbox?.click();
|
||||
}
|
||||
|
||||
private _responseCheckboxChanged(ev) {
|
||||
this._responseChecked = ev.target.checked;
|
||||
if (!this._responseChecked) {
|
||||
@@ -182,14 +202,12 @@ export class HaServiceAction extends LitElement implements ActionElement {
|
||||
1px solid var(--divider-color)
|
||||
);
|
||||
}
|
||||
ha-checkbox {
|
||||
margin-left: -16px;
|
||||
margin-inline-start: -16px;
|
||||
margin-inline-end: initial;
|
||||
}
|
||||
.checkbox-spacer {
|
||||
width: 32px;
|
||||
}
|
||||
.clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -2122,6 +2122,12 @@ class DialogAddAutomationElement
|
||||
),
|
||||
}
|
||||
),
|
||||
dismissable: true,
|
||||
...(this._params.clipboardPasteToastBottomOffset != null
|
||||
? {
|
||||
bottomOffset: this._params.clipboardPasteToastBottomOffset,
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
this.closeDialog();
|
||||
}
|
||||
|
||||
@@ -321,6 +321,7 @@ export default class HaAutomationAddFromTarget extends LitElement {
|
||||
: undefined
|
||||
)
|
||||
);
|
||||
|
||||
return html`<ha-section-title
|
||||
>${this._i18n.localize(
|
||||
"ui.panel.config.automation.editor.home"
|
||||
@@ -868,7 +869,8 @@ export default class HaAutomationAddFromTarget extends LitElement {
|
||||
|
||||
this._floorAreas.forEach((floor) => {
|
||||
this._entries[floor.id || `floor${TARGET_SEPARATOR}`] = {
|
||||
open: false,
|
||||
// auto expand if only one floor is present
|
||||
open: this._floorAreas.length === 1,
|
||||
areas: {},
|
||||
};
|
||||
|
||||
|
||||
@@ -60,7 +60,7 @@ import {
|
||||
} from "../../../../dialogs/generic/show-dialog-box";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
import { isMac } from "../../../../util/is_mac";
|
||||
import { showToast } from "../../../../util/toast";
|
||||
import { showEditorToast } from "../editor-toast";
|
||||
import "../ha-automation-editor-warning";
|
||||
import { overflowStyles, rowStyles } from "../styles";
|
||||
import "../target/ha-automation-row-targets";
|
||||
@@ -531,7 +531,7 @@ export default class HaAutomationConditionRow extends LitElement {
|
||||
fireEvent(this, "close-sidebar");
|
||||
}
|
||||
|
||||
showToast(this, {
|
||||
showEditorToast(this, {
|
||||
message: this.hass.localize("ui.common.successfully_deleted"),
|
||||
duration: 4000,
|
||||
action: {
|
||||
@@ -665,7 +665,7 @@ export default class HaAutomationConditionRow extends LitElement {
|
||||
|
||||
private _copyCondition = () => {
|
||||
this._setClipboard();
|
||||
showToast(this, {
|
||||
showEditorToast(this, {
|
||||
message: this.hass.localize(
|
||||
"ui.panel.config.automation.editor.conditions.copied_to_clipboard"
|
||||
),
|
||||
@@ -679,7 +679,7 @@ export default class HaAutomationConditionRow extends LitElement {
|
||||
if (this._selected) {
|
||||
fireEvent(this, "close-sidebar");
|
||||
}
|
||||
showToast(this, {
|
||||
showEditorToast(this, {
|
||||
message: this.hass.localize(
|
||||
"ui.panel.config.automation.editor.conditions.cut_to_clipboard"
|
||||
),
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
} from "../../../../data/condition";
|
||||
import { subscribeLabFeature } from "../../../../data/labs";
|
||||
import { SubscribeMixin } from "../../../../mixins/subscribe-mixin";
|
||||
import { EDITOR_SAVE_FAB_TOAST_BOTTOM_OFFSET } from "../editor-toast";
|
||||
import {
|
||||
PASTE_VALUE,
|
||||
showAddAutomationElementDialog,
|
||||
@@ -44,6 +45,8 @@ export default class HaAutomationCondition extends AutomationSortableListMixin<C
|
||||
|
||||
@property({ type: Boolean }) public root = false;
|
||||
|
||||
@property({ type: Boolean, attribute: false }) public editorDirty = false;
|
||||
|
||||
@state() private _conditionDescriptions: ConditionDescriptions = {};
|
||||
|
||||
@queryAll("ha-automation-condition-row")
|
||||
@@ -280,6 +283,9 @@ export default class HaAutomationCondition extends AutomationSortableListMixin<C
|
||||
type: "condition",
|
||||
add: this._addCondition,
|
||||
clipboardItem: this._clipboard?.condition?.condition,
|
||||
clipboardPasteToastBottomOffset: this.editorDirty
|
||||
? EDITOR_SAVE_FAB_TOAST_BOTTOM_OFFSET
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -248,19 +248,27 @@ export class HaPlatformCondition extends LitElement {
|
||||
: html`<ha-checkbox
|
||||
.key=${fieldName}
|
||||
.checked=${this._checkedKeys.has(fieldName) ||
|
||||
(this.condition?.options &&
|
||||
(!!this.condition?.options &&
|
||||
this.condition.options[fieldName] !== undefined)}
|
||||
.disabled=${this.disabled}
|
||||
@change=${this._checkboxChanged}
|
||||
slot="prefix"
|
||||
></ha-checkbox>`}
|
||||
<span slot="heading"
|
||||
<span
|
||||
slot="heading"
|
||||
class=${showOptional ? "clickable" : ""}
|
||||
@click=${showOptional ? this._toggleCheckbox : undefined}
|
||||
>${this.hass.localize(
|
||||
`component.${domain}.conditions.${conditionName}.fields.${fieldName}.name`
|
||||
) || fieldName}</span
|
||||
>
|
||||
${description
|
||||
? html`<span slot="description">${description}</span>`
|
||||
? html`<span
|
||||
class=${showOptional ? "clickable" : ""}
|
||||
@click=${showOptional ? this._toggleCheckbox : undefined}
|
||||
slot="description"
|
||||
>${description}</span
|
||||
>`
|
||||
: nothing}
|
||||
<ha-selector
|
||||
.disabled=${this.disabled ||
|
||||
@@ -347,6 +355,13 @@ export class HaPlatformCondition extends LitElement {
|
||||
});
|
||||
}
|
||||
|
||||
private _toggleCheckbox(ev: Event) {
|
||||
const checkbox = (
|
||||
ev.currentTarget as HTMLElement
|
||||
)?.parentElement?.querySelector("ha-checkbox");
|
||||
checkbox?.click();
|
||||
}
|
||||
|
||||
private _checkboxChanged(ev) {
|
||||
const checked = ev.currentTarget.checked;
|
||||
const key = ev.currentTarget.key;
|
||||
@@ -499,11 +514,6 @@ export class HaPlatformCondition extends LitElement {
|
||||
.checkbox-spacer {
|
||||
width: 32px;
|
||||
}
|
||||
ha-checkbox {
|
||||
margin-left: calc(var(--ha-space-4) * -1);
|
||||
margin-inline-start: calc(var(--ha-space-4) * -1);
|
||||
margin-inline-end: initial;
|
||||
}
|
||||
.help-icon {
|
||||
color: var(--secondary-text-color);
|
||||
}
|
||||
@@ -518,6 +528,9 @@ export class HaPlatformCondition extends LitElement {
|
||||
.description p {
|
||||
direction: ltr;
|
||||
}
|
||||
.clickable {
|
||||
cursor: pointer;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
32
src/panels/config/automation/editor-toast.ts
Normal file
32
src/panels/config/automation/editor-toast.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { closestWithProperty } from "../../../common/dom/ancestors-with-property";
|
||||
import type { ShowToastParams } from "../../../managers/notification-manager";
|
||||
import { showToast } from "../../../util/toast";
|
||||
|
||||
export const EDITOR_SAVE_FAB_TOAST_BOTTOM_OFFSET = 60;
|
||||
|
||||
function editorSaveFabVisibleFrom(el: HTMLElement): boolean {
|
||||
if (
|
||||
el.localName === "ha-automation-editor" ||
|
||||
el.localName === "ha-script-editor"
|
||||
) {
|
||||
return Boolean((el as { dirty?: boolean }).dirty);
|
||||
}
|
||||
const holder = closestWithProperty(el, "dirty", false) as
|
||||
| (HTMLElement & { dirty?: boolean })
|
||||
| null;
|
||||
return Boolean(holder?.dirty);
|
||||
}
|
||||
|
||||
export function showEditorToast(
|
||||
el: HTMLElement,
|
||||
params: ShowToastParams
|
||||
): void {
|
||||
const offset = editorSaveFabVisibleFrom(el)
|
||||
? EDITOR_SAVE_FAB_TOAST_BOTTOM_OFFSET
|
||||
: undefined;
|
||||
showToast(el, {
|
||||
...params,
|
||||
...(offset !== undefined ? { bottomOffset: offset } : {}),
|
||||
dismissable: true,
|
||||
});
|
||||
}
|
||||
@@ -71,7 +71,7 @@ import { PreventUnsavedMixin } from "../../../mixins/prevent-unsaved-mixin";
|
||||
import { haStyle } from "../../../resources/styles";
|
||||
import type { Entries, ValueChangedEvent } from "../../../types";
|
||||
import { isMac } from "../../../util/is_mac";
|
||||
import { showToast } from "../../../util/toast";
|
||||
import { showEditorToast } from "./editor-toast";
|
||||
import { showAssignCategoryDialog } from "../category/show-dialog-assign-category";
|
||||
import { showAutomationModeDialog } from "./automation-mode-dialog/show-dialog-automation-mode";
|
||||
import { showAutomationSaveDialog } from "./automation-save-dialog/show-dialog-automation-save";
|
||||
@@ -914,7 +914,7 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
|
||||
|
||||
private async _handleSaveAutomation(): Promise<void> {
|
||||
if (this.yamlErrors) {
|
||||
showToast(this, {
|
||||
showEditorToast(this, {
|
||||
message: this.yamlErrors,
|
||||
});
|
||||
return;
|
||||
@@ -997,7 +997,7 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
|
||||
this.dirty = false;
|
||||
} catch (errors: any) {
|
||||
this.errors = errors.body?.message || errors.error || errors.body;
|
||||
showToast(this, {
|
||||
showEditorToast(this, {
|
||||
message: errors.body?.message || errors.error || errors.body,
|
||||
});
|
||||
throw errors;
|
||||
|
||||
@@ -46,6 +46,7 @@ import type {
|
||||
import "../../../components/data-table/ha-data-table-labels";
|
||||
import "../../../components/entity/ha-entity-toggle";
|
||||
import "../../../components/ha-button";
|
||||
import "../../../components/ha-checkbox";
|
||||
import "../../../components/ha-dropdown";
|
||||
import type {
|
||||
HaDropdown,
|
||||
@@ -1334,7 +1335,6 @@ ${rejected
|
||||
slot="icon"
|
||||
.checked=${selected}
|
||||
.indeterminate=${partial}
|
||||
reducedTouchTarget
|
||||
></ha-checkbox>
|
||||
<ha-label .color=${label.color} .description=${label.description}>
|
||||
${label.icon
|
||||
|
||||
@@ -32,7 +32,7 @@ import {
|
||||
normalizeAutomationConfig,
|
||||
} from "../../../data/automation";
|
||||
import { getActionType, type Action } from "../../../data/script";
|
||||
import { showToast } from "../../../util/toast";
|
||||
import { showEditorToast } from "./editor-toast";
|
||||
import "./action/ha-automation-action";
|
||||
import type HaAutomationAction from "./action/ha-automation-action";
|
||||
import "./condition/ha-automation-condition";
|
||||
@@ -101,6 +101,7 @@ export class HaManualAutomationEditor extends ManualEditorMixin<ManualAutomation
|
||||
.highlightedTriggers=${this.pastedConfig?.triggers}
|
||||
@value-changed=${this._triggerChanged}
|
||||
.hass=${this.hass}
|
||||
.editorDirty=${this.dirty}
|
||||
.disabled=${this.disabled || this.saving}
|
||||
.narrow=${this.narrow}
|
||||
@open-sidebar=${this.openSidebar}
|
||||
@@ -136,6 +137,7 @@ export class HaManualAutomationEditor extends ManualEditorMixin<ManualAutomation
|
||||
.highlightedConditions=${this.pastedConfig?.conditions}
|
||||
@value-changed=${this._conditionChanged}
|
||||
.hass=${this.hass}
|
||||
.editorDirty=${this.dirty}
|
||||
.disabled=${this.disabled || this.saving}
|
||||
.narrow=${this.narrow}
|
||||
@open-sidebar=${this.openSidebar}
|
||||
@@ -170,6 +172,7 @@ export class HaManualAutomationEditor extends ManualEditorMixin<ManualAutomation
|
||||
@request-close-sidebar=${this.triggerCloseSidebar}
|
||||
@close-sidebar=${this.handleCloseSidebar}
|
||||
.hass=${this.hass}
|
||||
.editorDirty=${this.dirty}
|
||||
.narrow=${this.narrow}
|
||||
.disabled=${this.disabled || this.saving}
|
||||
root
|
||||
@@ -223,7 +226,7 @@ export class HaManualAutomationEditor extends ManualEditorMixin<ManualAutomation
|
||||
try {
|
||||
loaded = load(paste);
|
||||
} catch (_err: any) {
|
||||
showToast(this, {
|
||||
showEditorToast(this, {
|
||||
message: this.hass.localize(
|
||||
"ui.panel.config.automation.editor.paste_invalid_yaml"
|
||||
),
|
||||
@@ -297,7 +300,7 @@ export class HaManualAutomationEditor extends ManualEditorMixin<ManualAutomation
|
||||
try {
|
||||
assert(normalized, automationConfigStruct);
|
||||
} catch (_err: any) {
|
||||
showToast(this, {
|
||||
showEditorToast(this, {
|
||||
message: this.hass.localize(
|
||||
"ui.panel.config.automation.editor.paste_invalid_config"
|
||||
),
|
||||
@@ -391,7 +394,7 @@ export class HaManualAutomationEditor extends ManualEditorMixin<ManualAutomation
|
||||
}
|
||||
|
||||
protected showPastedToastWithUndo() {
|
||||
showToast(this, {
|
||||
showEditorToast(this, {
|
||||
message: this.hass.localize(
|
||||
"ui.panel.config.automation.editor.paste_toast_message"
|
||||
),
|
||||
|
||||
@@ -37,7 +37,7 @@ import type { Action, Option } from "../../../../data/script";
|
||||
import { showPromptDialog } from "../../../../dialogs/generic/show-dialog-box";
|
||||
import type { HomeAssistant } from "../../../../types";
|
||||
import { isMac } from "../../../../util/is_mac";
|
||||
import { showToast } from "../../../../util/toast";
|
||||
import { showEditorToast } from "../editor-toast";
|
||||
import "../action/ha-automation-action";
|
||||
import type HaAutomationAction from "../action/ha-automation-action";
|
||||
import "../condition/ha-automation-condition";
|
||||
@@ -385,7 +385,7 @@ export default class HaAutomationOptionRow extends LitElement {
|
||||
fireEvent(this, "close-sidebar");
|
||||
}
|
||||
|
||||
showToast(this, {
|
||||
showEditorToast(this, {
|
||||
message: this.hass.localize("ui.common.successfully_deleted"),
|
||||
duration: 4000,
|
||||
action: {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user