Compare commits

...

83 Commits

Author SHA1 Message Date
Wendelin d6d5290afb review 2026-06-02 15:56:45 +02:00
Wendelin 680cc8b564 fix 2026-06-02 10:22:10 +02:00
Wendelin 87395f83b4 Update docs and clean up 2026-06-02 09:58:33 +02:00
Wendelin db3732aa31 Fix ha-domain-integration 2026-06-02 09:34:34 +02:00
Wendelin 10751ea4e9 update jsdocs 2026-06-02 09:09:59 +02:00
Wendelin e8a2ddbb45 use virtualized list in zha 2026-06-02 09:09:07 +02:00
Wendelin e072a66bf0 Simplify 2026-06-01 17:18:19 +02:00
Wendelin a1305ba8fe Fix keyboard nav and focus 2026-06-01 13:34:41 +02:00
Wendelin b73732acdb Card visibility-status use ha-alert (#52271) 2026-05-28 10:57:41 +01:00
Wendelin d950514104 Fix automation note keyboard a11y (#52270) 2026-05-28 10:56:12 +01:00
Simon Lamon f37cf1e848 Don't redispatch the original event in a checklist item (#52242) 2026-05-28 08:26:00 +03:00
Paulus Schoutsen a188ef1b7a Fix resend-verification flash and concurrency on cloud signup (#52244)
Resending the confirmation email reused the registration code path, so
the flash on the login screen said "Account created!" even though no
new account was created. Pass a message key to _verificationEmailSent
so resend can show "Verification email sent." instead.

_handleResendVerifyEmail also never set _requestInProgress, so the
resend button (and the start-trial button, which share that flag) were
not disabled while a resend was in flight and could be clicked
repeatedly. Set the flag at the start and clear it on terminal errors;
_verificationEmailSent already clears it on success.

Co-authored-by: Claude <noreply@anthropic.com>
2026-05-28 08:11:54 +03:00
Wendelin 087ef159df Fix ha-radio-option checked theming (#52237)
Update ha-radio-option theming to use checked-icon-color for text and border
2026-05-27 15:58:00 +02:00
Bram Kragten e39e1b3f5b Merge branch 'rc' into dev 2026-05-27 15:29:28 +02:00
Bram Kragten ff583d2274 Bumped version to 20260527.0 2026-05-27 15:26:39 +02:00
Wendelin d4de29e073 Rename automation trigger behavior options (#52224) 2026-05-27 15:24:53 +02:00
Wendelin 97dfed0cc4 Rename automation comments to note (#52219) 2026-05-27 15:23:27 +02:00
Bram Kragten 8b3df752da Add associated zone option for device trackers (#52211) 2026-05-27 15:18:01 +02:00
Paul Bottein 8c0d547962 Render small media browser thumbnails without blur (#52230)
* Render small media browser thumbnails without blur

* Only 16 pixels and no svg

* Skip brand url

* Media selector
2026-05-27 15:17:20 +02:00
Wendelin 5e3d84f0ad Add live test state message tooltip (#52233) 2026-05-27 15:08:43 +02:00
Petar Petrov b4e30bdf63 Fix energy compare bars stacking when compare month has more days (#52221) 2026-05-27 15:01:33 +02:00
Petar Petrov 4fcae4231c Remove redundant log-axis non-positive data preprocessing (#52222) 2026-05-27 14:59:37 +02:00
Wendelin 2aecf33955 Fix app details in tablet width (#52234) 2026-05-27 14:54:02 +02:00
Paulus Schoutsen 5f26a2b3da Show verify-email flash after cloud signup (#52232)
Co-authored-by: Claude <noreply@anthropic.com>
2026-05-27 14:46:24 +02:00
Paul Bottein b08f5bcb34 Add custom card suggestions in the entity card picker (#52228)
* Add custom card suggestions in the entity card picker

* Prettier

* rename function

* Use ensure array
2026-05-27 14:38:53 +02:00
Wendelin c329e5b827 Revert "Automation triggers - auto IDs" (#52226) 2026-05-27 14:19:38 +02:00
Paulus Schoutsen 97f591337d Fix cloud TTS try dialog failing on default browser target (#52231)
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 14:17:18 +02:00
Bram Kragten e6e6e75f73 Revert "Add-on iframe: delegate microphone + camera Permissions Policy" (#52229) 2026-05-27 13:08:57 +02:00
Wendelin ff334de0ca Fix checked radio option (#52227) 2026-05-27 12:46:14 +02:00
Bram Kragten 8dbe97b480 Add device step to matter add flow (#52216)
* Add device step to matter add flow

* Update matter-add-device-device-added.ts
2026-05-27 12:48:14 +03:00
Bram Kragten 7bea54851d Remove advanced mode completely (#52212) 2026-05-27 09:20:48 +00:00
Bram Kragten 9298e00f20 Merge branch 'rc' 2026-05-14 11:32:44 +02:00
Bram Kragten 70085d4bad Bumped version to 20260429.4 2026-05-14 11:32:29 +02:00
Wendelin d83a553b62 Reactivate iOS focus element (#52020) 2026-05-14 11:31:23 +02:00
Wendelin cab5c6af30 Add macOS version mapping for Safari 26 support (#51999) 2026-05-14 11:24:44 +02:00
Petar Petrov d44d8a6dbd Fix water sankey untracked consumption with nested sub-trackers (#51998) 2026-05-14 11:24:43 +02:00
karwosts 3cf1d94b92 Fix sensor card when visibility changes (#51953)
* Fix sensor card when visibility changes

* History card

* map card

* trend graph

* minor change
2026-05-14 11:23:31 +02:00
Tom Carpenter 9f5f849e32 Fix demo instance mock recorder data generation (#51950)
Fix demo mock recorder data end times

The mock recorder was setting the start and end time for each of the samples to be the same value, causing the solar graph in the energy dashboard to render incorrectly.

Fix the recorder to set the end time of each sample to the start time of the next.
2026-05-14 11:21:23 +02:00
karwosts 27e9926363 Fix heading badge current-entity visibility (#51942) 2026-05-14 11:21:22 +02:00
karwosts efe734892a Fix create new person with login (#51939) 2026-05-14 11:21:21 +02:00
Tom Carpenter b3d79e312d Remove extra padding to right of ha-switch (#51932)
Fix empty padding to right of ha-switch

When the label slot for the ha-switch is empty, the initial margin is still present which causes an odd misalignment on the switches in e,g, the entities card.

To fix this, if the label slot is empty, hide the label to remove the unwanted margin.
2026-05-14 11:21:19 +02:00
Marcin Bauer ecfef9e112 Improve continue on error tooltip in automation editor (#51926)
Co-authored-by: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-05-14 11:21:18 +02:00
George Caliment ca960446f0 Fixed blueprint rows event result chip render when collapsed (#51910) 2026-05-14 11:21:17 +02:00
Petar Petrov a6eb722025 Clamp power sources graph usage line to non-negative (#51902) 2026-05-14 11:21:16 +02:00
Paul Bottein f3ff01ace4 Fix race condition loading home dashboard favorites (#51901) 2026-05-14 11:21:15 +02:00
karwosts d5e1a373ec Fix entity filter card (#51895) 2026-05-14 11:21:14 +02:00
Wendelin e1b9a1a185 Fix content padding picker (#51889) 2026-05-14 11:21:12 +02:00
Paul Bottein efe8eaa941 Move logs page search bar out of the toolbar (#51887) 2026-05-14 11:21:11 +02:00
Wendelin 5856196ef3 Improve automation event chips action, condition (#51886) 2026-05-14 11:21:10 +02:00
Clément Notin 2671a8c64b Fix quick bar search not focused on first open (#51822)
Co-authored-by: Wendelin <12148533+wendevlin@users.noreply.github.com>
2026-05-14 11:21:09 +02:00
Bram Kragten 8620653a54 Merge branch 'rc' 2026-05-06 11:19:42 +02:00
Bram Kragten c4f4cbd323 Bumped version to 20260429.3 2026-05-06 11:18:01 +02:00
Paul Bottein 2e0df00f0f Fix name for battery entities without device (#51879) 2026-05-06 11:17:09 +02:00
Wendelin ce02f8072d Reduce progress bar default height (#51878)
reduce progress bar default height to 12px
2026-05-06 11:17:08 +02:00
Paul Bottein c973aa7516 Fix media controls in media player more info dialog (#51877) 2026-05-06 11:17:07 +02:00
Paul Bottein 1e2328707c Fix switch clipping in view visibility editor (#51876) 2026-05-06 11:17:06 +02:00
Wendelin 56368b88cd Remove duplicate definition in semantic colors (#51875)
* Remove duplicate definition in semantic colors

* rearrange surface tokens
2026-05-06 11:17:05 +02:00
Aidan Timson fcd4f177c1 Fix Safari 14 legacy bundle require errors (#51868)
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-06 11:17:04 +02:00
Wendelin 7423ae7316 Fix integration search shrink on mobile (#51867) 2026-05-06 11:17:03 +02:00
Marcin Bauer 4427c581f1 Fix automation row right padding and soften chip highlight animation (#51865)
Co-authored-by: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-05-06 11:17:02 +02:00
Paul Bottein cf86bb9821 Use ha-switch instead of ha-control-switch in entity toggle (#51852) 2026-05-06 11:17:01 +02:00
karwosts 897802dc16 Change display for uptime sensors (#51830) 2026-05-06 11:17:00 +02:00
Paul Bottein 95edd6c2c2 20260429.2 (#51856) 2026-05-04 17:05:24 +02:00
Paul Bottein dd65173c5a Bumped version to 20260429.2 2026-05-04 17:04:06 +02:00
Paul Bottein cf26753f7d Remove daily and hourly forecast card features (#51854) 2026-05-04 17:03:54 +02:00
Paul Bottein d6ab8ffb16 Resolve service name and icon for shortcut card and badge (#51850) 2026-05-04 17:03:53 +02:00
Wendelin 2dc4b16eac Fix automation row target width (#51848) 2026-05-04 17:03:52 +02:00
Paul Bottein 1eba765bc2 Group areas floor vacuum clean (#51847) 2026-05-04 17:03:51 +02:00
Wendelin 398479ddd7 Use ha-switch in ha-automation-picker (#51846)
use ha-switch in ha-automation-picker
2026-05-04 17:03:50 +02:00
Paul Bottein c4fd7bb3e1 Fix entity toggle switch size (#51845) 2026-05-04 17:03:49 +02:00
Isaac (Kwangjin Ko) 4cfc67a95e ha-humidifier-state: fix incorrect translation key for 'Currently' (#51843) 2026-05-04 17:03:48 +02:00
Brooke Hatton e38d1964ca Remove battery chargers from maintenance dashboard (#51835) 2026-05-04 17:03:47 +02:00
Paul Bottein ec8b5c77bd Add min touch size for control switch (#51826) 2026-05-04 17:03:46 +02:00
Simon Lamon 425f2775e2 Missing toggle in switch group (#51825)
Missing toggle
2026-05-04 17:03:45 +02:00
Brooke Hatton 3a3d8191a3 Adjust Copy for maintenance summary card and include unavailable device count (#51815)
* Adjust Copy For summary card

* Further tweak copy and include unavailable devices
2026-05-04 17:03:44 +02:00
Aidan Timson 04fca68549 Add gap between hui editors and previews on mobile (#51811) 2026-05-04 17:03:43 +02:00
Paul Bottein 3046f3e47d 20260429.1 (#51817) 2026-04-30 20:33:33 +02:00
Paul Bottein 35601a0900 Bumped version to 20260429.1 2026-04-30 20:32:28 +02:00
Wendelin e7016c15af Fix ha-select undefined value (#51800)
Fix ha-select undefined

Co-authored-by: Copilot <copilot@github.com>
2026-04-30 20:32:08 +02:00
Wendelin 624521e30b Hide tooltip on mobile clients in ha-sidebar component (#51799) 2026-04-30 20:32:08 +02:00
Bram Kragten 4876bfa639 Add tooltips for Jinja editors (#51792)
* Add descriptions to Jinja2 tags, filters, expressions, tests and variables

All standard Jinja2 tags, filters, and expression completions now carry
info and detail strings so the autocomplete info popover shows meaningful
documentation when users browse them — not just HA-specific functions.

* Add keyboard shortcut tip to the template developer tool

A ha-tip below the editor card now shows users that Ctrl+Space triggers
autocomplete, Ctrl+F opens the search panel, and F11 toggles fullscreen,
making the editor's built-in features more discoverable.

* Add hover tooltips for Jinja2 functions, filters and expressions

Hovering over a function, filter, tag, test, or variable name inside a
Jinja2 template shows a tooltip with its signature and description.
Non-tag completions also get a help-circle icon linking to the
corresponding Home Assistant template-functions documentation page.

The tooltip is rendered as a custom Lit element (ha-code-editor-jinja-hover)
that takes the Completion object and an optional docUrl as properties.

The tooltip source (haJinjaHoverSource) is wired into ha-code-editor
via CodeMirror's hoverTooltip extension. The documentationUrl() helper
is used so the link points to the correct subdomain (www / rc / next)
based on the running HA version.

* Add hover tooltips for Jinja2 hover + arg value tooltips for entity/device/area

Wire haJinjaHoverSource into ha-code-editor via CodeMirror hoverTooltip.
Two types of hover are now shown in jinja2/yaml mode:

- Hovering a function/filter/tag/expression name shows its signature,
  description, and a doc-link icon (non-tags only).
- Hovering a string-literal argument of a known HA Jinja function (e.g.
  states(), device_name(), area_entities()) shows the friendly name,
  current state, device, and area for entity_id arguments; the device
  name and area for device_id arguments; and the area name for area_id
  arguments. The same applies to states["entity_id"] subscripts.

The arg-value tooltip reuses CompletionItem / ha-code-editor-completion-items
(the same component used for autocomplete info popovers) via a new
ha-code-editor-jinja-arg-hover element. HA registry data is passed from
ha-code-editor via a HassArgHoverContext interface to keep jinja_ha_completions.ts
free of HomeAssistant type imports.

* only add tip for autocomplete

* review
2026-04-30 20:32:07 +02:00
AlCalzone 5dea0764b2 Expose Z-Wave exclusion instructions when removing device (#51788)
* Expose Z-Wave exclusion instructions when removing device

* text tweaks

* Apply suggestion from @MindFreeze

* Apply suggestions from code review

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

* bring back comment

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
2026-04-30 20:32:05 +02:00
Paul Bottein 121ed7ac1f 20260429.0 (#51790) 2026-04-29 16:47:32 +02:00
117 changed files with 2747 additions and 2220 deletions
@@ -62,10 +62,11 @@ host reflects `aria-multiselectable`.
**Events**
- `ha-list-selected`selection changed. Detail
`{ index: number | Set<number>, diff: { added: Set<number>, removed: Set<number> } }`.
`index` is a `number` in single mode (`-1` when nothing selected) and a
`Set<number>` in multi mode.
- `ha-list-item-selected`an option was selected. Detail is the option's
index (`number`). In single mode this is the only selection event; in multi
mode it fires for each option added to the selection.
- `ha-list-item-deselected` — an option was deselected (multi mode only). Detail
is the option's index (`number`).
**Methods / getters**
+53 -13
View File
@@ -20,7 +20,6 @@ import "../../../../src/components/item/ha-list-item-option";
import "../../../../src/components/list/ha-list-base";
import "../../../../src/components/list/ha-list-nav";
import "../../../../src/components/list/ha-list-selectable";
import type { HaListSelectedDetail } from "../../../../src/components/list/types";
type Appearance = "line" | "checkbox";
type Position = "start" | "end";
@@ -185,7 +184,7 @@ export class DemoHaList extends LitElement {
<ha-card header="Single select, appearance=line">
<ha-list-selectable
aria-label="Single select"
@ha-list-selected=${this._onSingle}
@ha-list-item-selected=${this._onSingle}
>
${this._options.map(
(o, i) => html`
@@ -205,7 +204,8 @@ export class DemoHaList extends LitElement {
<ha-list-selectable
multi
aria-label="Multi select line"
@ha-list-selected=${this._onMultiLine}
@ha-list-item-selected=${this._onMultiLineSelected}
@ha-list-item-deselected=${this._onMultiLineDeselected}
>
${this._options.map(
(o, i) => html`
@@ -227,7 +227,8 @@ export class DemoHaList extends LitElement {
<ha-list-selectable
multi
aria-label="Multi checkbox start"
@ha-list-selected=${this._onMultiCheckStart}
@ha-list-item-selected=${this._onMultiCheckStartSelected}
@ha-list-item-deselected=${this._onMultiCheckStartDeselected}
>
${this._options.map(
(o, i) => html`
@@ -253,7 +254,8 @@ selected: ${JSON.stringify(this._toJson(this._multiCheckStart))}</pre
<ha-list-selectable
multi
aria-label="Multi checkbox end"
@ha-list-selected=${this._onMultiCheckEnd}
@ha-list-item-selected=${this._onMultiCheckEndSelected}
@ha-list-item-deselected=${this._onMultiCheckEndDeselected}
>
${this._options.map(
(o, i) => html`
@@ -347,20 +349,58 @@ selected: ${JSON.stringify(this._toJson(this._multiCheckEnd))}</pre
this._buttonClicks++;
};
private _onSingle = (ev: CustomEvent<HaListSelectedDetail>) => {
this._single = ev.detail.index;
private _withIndex(
value: number | Set<number>,
index: number,
selected: boolean
): Set<number> {
const next = new Set(value instanceof Set ? value : []);
if (selected) {
next.add(index);
} else {
next.delete(index);
}
return next;
}
private _onSingle = (ev: CustomEvent<number>) => {
this._single = ev.detail;
};
private _onMultiLine = (ev: CustomEvent<HaListSelectedDetail>) => {
this._multiLine = ev.detail.index;
private _onMultiLineSelected = (ev: CustomEvent<number>) => {
this._multiLine = this._withIndex(this._multiLine, ev.detail, true);
};
private _onMultiCheckStart = (ev: CustomEvent<HaListSelectedDetail>) => {
this._multiCheckStart = ev.detail.index;
private _onMultiLineDeselected = (ev: CustomEvent<number>) => {
this._multiLine = this._withIndex(this._multiLine, ev.detail, false);
};
private _onMultiCheckEnd = (ev: CustomEvent<HaListSelectedDetail>) => {
this._multiCheckEnd = ev.detail.index;
private _onMultiCheckStartSelected = (ev: CustomEvent<number>) => {
this._multiCheckStart = this._withIndex(
this._multiCheckStart,
ev.detail,
true
);
};
private _onMultiCheckStartDeselected = (ev: CustomEvent<number>) => {
this._multiCheckStart = this._withIndex(
this._multiCheckStart,
ev.detail,
false
);
};
private _onMultiCheckEndSelected = (ev: CustomEvent<number>) => {
this._multiCheckEnd = this._withIndex(this._multiCheckEnd, ev.detail, true);
};
private _onMultiCheckEndDeselected = (ev: CustomEvent<number>) => {
this._multiCheckEnd = this._withIndex(
this._multiCheckEnd,
ev.detail,
false
);
};
static styles = css`
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "home-assistant-frontend"
version = "20260429.0"
version = "20260527.0"
license = "Apache-2.0"
license-files = ["LICENSE*"]
description = "The Home Assistant frontend"
@@ -1,5 +1,6 @@
import { LitElement, css, html } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import "../ha-tooltip";
export type LiveTestState = "pass" | "fail" | "invalid" | "unknown";
@@ -12,6 +13,7 @@ export type LiveTestState = "pass" | "fail" | "invalid" | "unknown";
*
* @attr {"pass"|"fail"|"invalid"|"unknown"} state - The current live-test state. Defaults to `unknown`.
* @attr {string} label - Accessible label announced by assistive technology.
* @attr {string} message - Optional tooltip body shown on hover/focus.
*/
@customElement("ha-automation-row-live-test")
export class HaAutomationRowLiveTest extends LitElement {
@@ -19,6 +21,8 @@ export class HaAutomationRowLiveTest extends LitElement {
@property() public label = "";
@property() public message?: string;
protected render() {
return html`
<div
@@ -27,6 +31,9 @@ export class HaAutomationRowLiveTest extends LitElement {
tabindex="0"
aria-label=${this.label}
></div>
${this.message
? html`<ha-tooltip for="indicator">${this.message}</ha-tooltip>`
: nothing}
`;
}
-18
View File
@@ -956,29 +956,11 @@ export class HaChartBase extends LitElement {
const xAxis = (this.options?.xAxis?.[0] ?? this.options?.xAxis) as
| XAXisOption
| undefined;
const yAxis = (this.options?.yAxis?.[0] ?? this.options?.yAxis) as
| YAXisOption
| undefined;
const series = ensureArray(this.data).map((s) => {
const data = this._hiddenDatasets.has(String(s.id ?? s.name))
? undefined
: s.data;
if (data && s.type === "line") {
if (yAxis?.type === "log") {
// set <=0 values to null so they render as gaps on a log graph
return {
...s,
data: (data as LineSeriesOption["data"])!.map((v) =>
Array.isArray(v)
? [
v[0],
typeof v[1] !== "number" || v[1] > 0 ? v[1] : null,
...v.slice(2),
]
: v
),
};
}
if (s.sampling === "minmax") {
const minX = xAxis?.min
? xAxis.min instanceof Date
+6 -3
View File
@@ -6,8 +6,9 @@ import {
mdiInformationOutline,
} from "@mdi/js";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { consumeLocalize } from "../common/decorators/consume-context-entry";
import type { LocalizeFunc } from "../common/translations/localize";
import { fireEvent } from "../common/dom/fire_event";
import "./ha-icon-button";
@@ -39,7 +40,9 @@ class HaAlert extends LitElement {
@property({ type: Boolean }) public dismissable = false;
@property({ attribute: false }) public localize?: LocalizeFunc;
@state()
@consumeLocalize()
private _localize?: LocalizeFunc;
@property({ type: Boolean }) public narrow = false;
@@ -68,7 +71,7 @@ class HaAlert extends LitElement {
${this.dismissable
? html`<ha-icon-button
@click=${this._dismissClicked}
.label=${this.localize!("ui.common.dismiss_alert")}
.label=${this._localize?.("ui.common.dismiss_alert")}
.path=${mdiClose}
></ha-icon-button>`
: nothing}
+1
View File
@@ -20,6 +20,7 @@ export class HaCheckListItem extends CheckListItemBase {
separateCheckboxClick = false;
async onChange(event) {
event.stopPropagation();
super.onChange(event);
fireEvent(this, event.type);
}
+1
View File
@@ -107,6 +107,7 @@ export class HaExpansionPanel extends LitElement {
}
const newExpanded = !this.expanded;
fireEvent(this, "expanded-will-change", { expanded: newExpanded });
this._container.style.overflow = "hidden";
if (newExpanded) {
+136 -132
View File
@@ -1,5 +1,5 @@
import { mdiFilterVariantRemove } from "@mdi/js";
import type { CSSResultGroup, PropertyValues } from "lit";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one";
@@ -9,14 +9,18 @@ import { stringCompare } from "../common/string/compare";
import { deepEqual } from "../common/util/deep-equal";
import type { RelatedResult } from "../data/search";
import { findRelated } from "../data/search";
import { haStyleScrollbar } from "../resources/styles";
import { loadVirtualizer } from "../resources/virtualizer";
import type { HomeAssistant } from "../types";
import "./ha-check-list-item";
import "./ha-expansion-panel";
import "./ha-list";
import "./input/ha-input-search";
import type { HaInputSearch } from "./input/ha-input-search";
import "./item/ha-list-item-option";
import "./list/ha-list-selectable-virtualized";
import type { HaListSelectableVirtualized } from "./list/ha-list-selectable-virtualized";
import type { HaListVirtualizedItem } from "./list/ha-list-virtualized";
interface HaFilterDevicesItem extends HaListVirtualizedItem {
name: string;
}
@customElement("ha-filter-devices")
export class HaFilterDevices extends LitElement {
@@ -34,15 +38,12 @@ export class HaFilterDevices extends LitElement {
@state() private _filter?: string;
@query("ha-list") private _list?: HTMLElement;
@query("ha-list-selectable-virtualized")
private _listElement?: HaListSelectableVirtualized;
public willUpdate(properties: PropertyValues<this>) {
super.willUpdate(properties);
if (!this.hasUpdated) {
loadVirtualizer();
}
if (
properties.has("value") &&
!deepEqual(this.value, properties.get("value"))
@@ -51,6 +52,20 @@ export class HaFilterDevices extends LitElement {
}
}
protected updated(changed: PropertyValues<this>) {
if (changed.has("expanded") && this.expanded) {
setTimeout(() => {
if (!this.expanded || !this._listElement) {
return;
}
this._listElement.style.height = `${this.clientHeight - 49 - 4 - 38}px`;
// 49px - height of a header + 1px
// 4px - padding-top of the search-input
// 38px - height of the search input
}, 300);
}
}
protected render() {
return html`
<ha-expansion-panel
@@ -66,6 +81,7 @@ export class HaFilterDevices extends LitElement {
<ha-icon-button
.path=${mdiFilterVariantRemove}
@click=${this._clearFilter}
@keydown=${this._handleClearFilterKeydown}
></ha-icon-button>`
: nothing}
</div>
@@ -74,75 +90,45 @@ export class HaFilterDevices extends LitElement {
appearance="outlined"
.value=${this._filter}
@input=${this._handleSearchChange}
@keydown=${this._handleSearchKeydown}
>
</ha-input-search>
<ha-list class="ha-scrollbar" multi>
<lit-virtualizer
.items=${this._devices(
this.hass.devices,
this._filter || "",
this.value
)}
.keyFunction=${this._keyFunction}
.renderItem=${this._renderItem}
@click=${this._handleItemClick}
@keydown=${this._handleItemKeydown}
>
</lit-virtualizer>
</ha-list>`
<ha-list-selectable-virtualized
multi
.rows=${this._devices(this.hass.devices, this._filter || "")}
.rowRenderer=${this._renderItem}
@ha-list-item-selected=${this._handleAdded}
@ha-list-item-deselected=${this._handleRemoved}
></ha-list-selectable-virtualized>`
: nothing}
</ha-expansion-panel>
`;
}
private _keyFunction = (device) => device?.id;
private _renderItem = (device) =>
!device
private _renderItem = (item?: HaFilterDevicesItem) =>
!item
? nothing
: html`<ha-check-list-item
tabindex="0"
.value=${device.id}
.selected=${this.value?.includes(device.id) ?? false}
: html`<ha-list-item-option
style="width: 100%;"
appearance="checkbox"
selection-position="end"
.value=${item.id}
.selected=${this.value?.includes(item.id) ?? false}
>
${computeDeviceNameDisplay(
device,
this.hass.localize,
this.hass.states
)}
</ha-check-list-item>`;
<span slot="headline">${item.name}</span>
</ha-list-item-option>`;
private _handleItemKeydown(ev: KeyboardEvent) {
if (ev.key === "Enter" || ev.key === " ") {
ev.preventDefault();
this._handleItemClick(ev);
}
private _handleAdded(ev: CustomEvent<number>) {
this.value = [
...(this.value ?? []),
this._devices(this.hass.devices, this._filter || "")[ev.detail].id,
];
}
private _handleItemClick(ev) {
const listItem = ev.target.closest("ha-check-list-item");
const value = listItem?.value;
if (!value) {
return;
}
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);
}
protected updated(changed: PropertyValues<this>) {
if (changed.has("expanded") && this.expanded) {
setTimeout(() => {
if (!this.expanded) return;
this._list!.style.height = `${this.clientHeight - 49 - 4 - 32}px`;
// 49px - height of a header + 1px
// 4px - padding-top of the search-input
// 32px - height of the search input
}, 300);
}
private _handleRemoved(ev: CustomEvent<number>) {
const id = this._devices(this.hass.devices, this._filter || "")[ev.detail]
.id;
this.value = (this.value ?? []).filter((deviceId) => deviceId !== id);
}
private _expandedWillChange(ev) {
@@ -155,30 +141,38 @@ export class HaFilterDevices extends LitElement {
private _handleSearchChange(ev: InputEvent) {
const target = ev.target as HaInputSearch;
this._filter = (target.value ?? "").toLowerCase();
this._filter = target.value ?? "";
}
private _handleSearchKeydown(ev: KeyboardEvent) {
if (ev.key === "ArrowDown" && this._listElement) {
ev.preventDefault();
this._listElement.focus();
}
}
private _devices = memoizeOne(
(devices: HomeAssistant["devices"], filter: string, _value) => {
(
devices: HomeAssistant["devices"],
filter: string
): HaFilterDevicesItem[] => {
const values = Object.values(devices);
return values
.map((device) => ({
id: device.id,
interactive: true,
name: computeDeviceNameDisplay(
device,
this.hass.localize,
this.hass.states
),
}))
.filter(
(device) =>
!filter ||
computeDeviceNameDisplay(
device,
this.hass.localize,
this.hass.states
)
.toLowerCase()
.includes(filter)
({ name }) =>
!filter || name.toLowerCase().includes(filter.toLowerCase())
)
.sort((a, b) =>
stringCompare(
computeDeviceNameDisplay(a, this.hass.localize, this.hass.states),
computeDeviceNameDisplay(b, this.hass.localize, this.hass.states),
this.hass.locale.language
)
stringCompare(a.name, b.name, this.hass.locale.language)
);
}
);
@@ -217,6 +211,13 @@ export class HaFilterDevices extends LitElement {
});
}
private _handleClearFilterKeydown(ev: KeyboardEvent) {
if (ev.key === "Enter" || ev.key === " ") {
ev.stopPropagation();
this._clearFilter(ev);
}
}
private _clearFilter(ev) {
ev.preventDefault();
this.value = undefined;
@@ -224,58 +225,61 @@ export class HaFilterDevices extends LitElement {
value: undefined,
items: undefined,
});
this._listElement?.clearSelection();
}
static get styles(): CSSResultGroup {
return [
haStyleScrollbar,
css`
:host {
border-bottom: 1px solid var(--divider-color);
}
:host([expanded]) {
flex: 1;
height: 0;
}
static styles = css`
:host {
border-bottom: 1px solid var(--divider-color);
}
:host([expanded]) {
flex: 1;
height: 0;
display: flex;
flex-direction: column;
}
ha-expansion-panel {
--ha-card-border-radius: var(--ha-border-radius-square);
--expansion-panel-content-padding: 0;
}
.header {
display: flex;
align-items: center;
}
.header ha-icon-button {
margin-inline-start: auto;
margin-inline-end: 8px;
}
.badge {
display: inline-block;
margin-left: 8px;
margin-inline-start: 8px;
margin-inline-end: 0;
min-width: 16px;
box-sizing: border-box;
border-radius: var(--ha-border-radius-circle);
font-size: var(--ha-font-size-xs);
font-weight: var(--ha-font-weight-normal);
background-color: var(--primary-color);
line-height: var(--ha-line-height-normal);
text-align: center;
padding: 0px 2px;
color: var(--text-primary-color);
}
ha-check-list-item {
width: 100%;
}
ha-input-search {
display: block;
padding: var(--ha-space-1) var(--ha-space-2) 0;
}
`,
];
}
ha-expansion-panel {
--ha-card-border-radius: var(--ha-border-radius-square);
--expansion-panel-content-padding: 0;
}
:host([expanded]) ha-expansion-panel {
flex: 1;
min-height: 0;
}
ha-list-selectable-virtualized {
flex: 1;
min-height: 0;
}
.header {
display: flex;
align-items: center;
}
.header ha-icon-button {
margin-inline-start: auto;
margin-inline-end: 8px;
}
.badge {
display: inline-block;
margin-left: 8px;
margin-inline-start: 8px;
margin-inline-end: 0;
min-width: 16px;
box-sizing: border-box;
border-radius: var(--ha-border-radius-circle);
font-size: var(--ha-font-size-xs);
font-weight: var(--ha-font-weight-normal);
background-color: var(--primary-color);
line-height: var(--ha-line-height-normal);
text-align: center;
padding: 0px 2px;
color: var(--text-primary-color);
}
ha-input-search {
display: block;
padding: var(--ha-space-1) var(--ha-space-2) 0;
}
`;
}
declare global {
+48 -38
View File
@@ -23,7 +23,6 @@ import "./item/ha-list-item-option";
import type { HaListItemOption } from "./item/ha-list-item-option";
import "./list/ha-list-selectable";
import type { HaListSelectable } from "./list/ha-list-selectable";
import type { HaListSelectedDetail } from "./list/types";
@customElement("ha-filter-floor-areas")
export class HaFilterFloorAreas extends LitElement {
@@ -42,7 +41,7 @@ export class HaFilterFloorAreas extends LitElement {
@state() private _shouldRender = false;
@query("ha-list-selectable") private _list?: HTMLElement;
@query("ha-list-selectable") private _list?: HaListSelectable;
public willUpdate(properties: PropertyValues<this>) {
super.willUpdate(properties);
@@ -75,6 +74,7 @@ export class HaFilterFloorAreas extends LitElement {
<ha-icon-button
.path=${mdiFilterVariantRemove}
@click=${this._clearFilter}
@keydown=${this._handleClearFilterKeydown}
></ha-icon-button>`
: nothing}
</div>
@@ -83,7 +83,8 @@ export class HaFilterFloorAreas extends LitElement {
<ha-list-selectable
class="ha-scrollbar"
multi
@ha-list-selected=${this._handleListChanged}
@ha-list-item-selected=${this._handleAdded}
@ha-list-item-deselected=${this._handleRemoved}
aria-label=${this.hass.localize(
"ui.panel.config.areas.caption"
)}
@@ -163,46 +164,47 @@ export class HaFilterFloorAreas extends LitElement {
`;
}
private _handleListChanged(ev: CustomEvent<HaListSelectedDetail>) {
if (!ev.detail.diff?.added.size && !ev.detail.diff?.removed.size) {
private _handleAdded(ev: CustomEvent<number>) {
if (!this.value) {
this.value = {};
}
const addedItem = (ev.currentTarget as HaListSelectable).items[
ev.detail
] as HaListItemOption & { type: string; value: string };
if (!addedItem) {
return;
}
if (ev.detail.diff?.added.size) {
const addedIndex = ev.detail.diff.added.values().next().value;
if (addedIndex === undefined) {
return;
}
const addedItem = (ev.currentTarget as HaListSelectable).items[
addedIndex
] as HaListItemOption & { type: string; value: string };
this.value = {
...this.value,
[addedItem.type]: [
...(this.value[addedItem.type] || []),
addedItem.value,
],
};
}
if (!this.value) {
this.value = {};
}
this.value = {
...this.value,
[addedItem.type]: [
...(this.value[addedItem.type] || []),
addedItem.value,
],
};
} else {
const removedIndex = ev.detail.diff?.removed.values().next().value;
if (removedIndex === undefined) {
return;
}
const removedItem = (ev.currentTarget as HaListSelectable).items[
removedIndex
] as HaListItemOption & { type: string; value: string };
this.value = {
...this.value,
[removedItem.type]: this.value![removedItem.type].filter(
(val) => val !== removedItem.value
),
};
private _handleRemoved(ev: CustomEvent<number>) {
if (!this.value) {
return;
}
const removedItem = (ev.currentTarget as HaListSelectable).items[
ev.detail
] as HaListItemOption & { type: string; value: string };
if (!removedItem) {
return;
}
this.value = {
...this.value,
[removedItem.type]: this.value![removedItem.type].filter(
(val) => val !== removedItem.value
),
};
}
protected updated(changed: PropertyValues<this>) {
@@ -286,6 +288,13 @@ export class HaFilterFloorAreas extends LitElement {
});
}
private _handleClearFilterKeydown(ev: KeyboardEvent) {
if (ev.key === "Enter" || ev.key === " ") {
ev.stopPropagation();
this._clearFilter(ev);
}
}
private _clearFilter(ev) {
ev.preventDefault();
this.value = undefined;
@@ -293,6 +302,7 @@ export class HaFilterFloorAreas extends LitElement {
value: undefined,
items: undefined,
});
this._list?.clearSelection();
}
static get styles(): CSSResultGroup {
+15 -8
View File
@@ -1,11 +1,12 @@
import { consume, type ContextType } from "@lit/context";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { ifDefined } from "lit/directives/if-defined";
import { styleMap } from "lit/directives/style-map";
import { fireEvent } from "../common/dom/fire_event";
import { computeRTL } from "../common/util/compute_rtl";
import type { HomeAssistant } from "../types";
import { internationalizationContext, uiContext } from "../data/context";
import "./radio/ha-radio-group";
import type { HaRadioGroup } from "./radio/ha-radio-group";
import "./radio/ha-radio-option";
@@ -26,8 +27,6 @@ export interface SelectBoxOption {
@customElement("ha-select-box")
export class HaSelectBox extends LitElement {
@property({ attribute: false }) public hass?: HomeAssistant;
@property({ attribute: false }) public options: SelectBoxOption[] = [];
@property({ attribute: false }) public value?: string;
@@ -40,6 +39,14 @@ export class HaSelectBox extends LitElement {
@property({ type: Boolean, attribute: "stacked_image" })
public stackedImage = false;
@state()
@consume({ context: internationalizationContext, subscribe: true })
protected _i18n?: ContextType<typeof internationalizationContext>;
@state()
@consume({ context: uiContext, subscribe: true })
protected _ui?: ContextType<typeof uiContext>;
render() {
const maxColumns = this.maxColumns ?? 3;
const columns = Math.min(maxColumns, this.options.length);
@@ -62,11 +69,11 @@ export class HaSelectBox extends LitElement {
const disabled = option.disabled || this.disabled || false;
const selected = option.value === this.value;
const isDark = this.hass?.themes.darkMode || false;
const isRTL = this.hass
const isDark = this._ui?.themes.darkMode || false;
const isRTL = this._i18n
? computeRTL(
this.hass.language,
this.hass.translationMetadata.translations
this._i18n.language,
this._i18n.translationMetadata.translations
)
: false;
@@ -1,30 +1,31 @@
import { LitElement, css, html, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { consumeLocalize } from "../../common/decorators/consume-context-entry";
import { fireEvent } from "../../common/dom/fire_event";
import type { LocalizeKeys } from "../../common/translations/localize";
import type {
LocalizeFunc,
LocalizeKeys,
} from "../../common/translations/localize";
import type {
AutomationBehavior,
AutomationBehaviorConditionMode,
AutomationBehaviorSelector,
AutomationBehaviorTriggerMode,
} from "../../data/selector";
import type { HomeAssistant } from "../../types";
import "../ha-input-helper-text";
import type { SelectBoxOption } from "../ha-select-box";
import "../ha-select-box";
import type { SelectBoxOption } from "../ha-select-box";
const TRIGGER_BEHAVIORS: AutomationBehaviorTriggerMode[] = [
"any",
"each",
"first",
"last",
"all",
];
const CONDITION_BEHAVIORS: AutomationBehaviorConditionMode[] = ["any", "all"];
@customElement("ha-selector-automation_behavior")
export class HaSelectorAutomationBehavior extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false })
public selector!: AutomationBehaviorSelector;
@@ -39,6 +40,9 @@ export class HaSelectorAutomationBehavior extends LitElement {
@property({ type: Boolean }) public required = true;
@consumeLocalize()
protected _localize?: LocalizeFunc;
protected render() {
const { mode } = this.selector.automation_behavior ?? {};
const modeKey = mode ?? "trigger";
@@ -60,7 +64,6 @@ export class HaSelectorAutomationBehavior extends LitElement {
return html`
<ha-select-box
.hass=${this.hass}
.options=${options}
.value=${this.value ?? ""}
max_columns="1"
@@ -95,8 +98,10 @@ export class HaSelectorAutomationBehavior extends LitElement {
return translated;
}
}
return this.hass.localize(
`ui.components.selectors.automation_behavior.${mode ?? "trigger"}.options.${behavior}.${field}` as LocalizeKeys
return (
this._localize?.(
`ui.components.selectors.automation_behavior.${mode ?? "trigger"}.options.${behavior}.${field}` as LocalizeKeys
) || behavior
);
}
+10 -46
View File
@@ -1,11 +1,10 @@
import { mdiPlayBox, mdiPlus } from "@mdi/js";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { customElement, property } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { fireEvent } from "../../common/dom/fire_event";
import { supportsFeature } from "../../common/entity/supports-feature";
import { getSignedPath } from "../../data/auth";
import type { MediaPickedEvent } from "../../data/media-player";
import {
MediaClassBrowserSettings,
@@ -13,14 +12,10 @@ import {
} from "../../data/media-player";
import type { MediaSelector, MediaSelectorValue } from "../../data/selector";
import type { HomeAssistant } from "../../types";
import {
brandsUrl,
extractDomainFromBrandUrl,
isBrandUrl,
} from "../../util/brands-url";
import "../ha-alert";
import "../ha-form/ha-form";
import type { SchemaUnion } from "../ha-form/types";
import "../media-player/ha-media-browser-thumbnail";
import { showMediaBrowserDialog } from "../media-player/show-media-browser-dialog";
import { ensureArray } from "../../common/array/ensure-array";
import "../ha-picture-upload";
@@ -54,8 +49,6 @@ export class HaMediaSelector extends LitElement {
filter_entity?: string | string[];
};
@state() private _thumbnailUrl?: string | null;
private _contextEntities: string[] | undefined;
private get _hasAccept(): boolean {
@@ -68,35 +61,6 @@ export class HaMediaSelector extends LitElement {
this._contextEntities = ensureArray(this.context?.filter_entity);
}
}
if (changedProps.has("value")) {
const thumbnail = this.value?.metadata?.thumbnail;
const oldThumbnail = (changedProps.get("value") as this["value"])
?.metadata?.thumbnail;
if (thumbnail === oldThumbnail) {
return;
}
if (thumbnail && isBrandUrl(thumbnail)) {
// The backend is not aware of the theme used by the users,
// so we rewrite the URL to show a proper icon
this._thumbnailUrl = brandsUrl(
{
domain: extractDomainFromBrandUrl(thumbnail),
type: "icon",
darkOptimized: this.hass.themes?.darkMode,
},
this.hass.auth.data.hassUrl
);
} else if (thumbnail && thumbnail.startsWith("/")) {
this._thumbnailUrl = undefined;
// Thumbnails served by local API require authentication
getSignedPath(this.hass, thumbnail).then((signedPath) => {
this._thumbnailUrl = signedPath.path;
});
} else {
this._thumbnailUrl = thumbnail;
}
}
}
protected render() {
@@ -186,10 +150,12 @@ export class HaMediaSelector extends LitElement {
),
})}
image"
style=${this._thumbnailUrl
? `background-image: url(${this._thumbnailUrl});`
: ""}
></div>
>
<ha-media-browser-thumbnail
.hass=${this.hass}
.url=${this.value.metadata.thumbnail}
></ha-media-browser-thumbnail>
</div>
`
: html`
<div class="icon-holder image">
@@ -410,13 +376,11 @@ export class HaMediaSelector extends LitElement {
right: 0;
left: 0;
bottom: 0;
background-size: cover;
background-repeat: no-repeat;
background-position: center;
--ha-media-browser-thumbnail-fit: cover;
}
.centered-image {
margin: 4px;
background-size: contain;
--ha-media-browser-thumbnail-fit: contain;
}
.icon-holder {
display: flex;
@@ -96,7 +96,6 @@ export class HaSelectSelector extends LitElement {
.value=${this.value as string | undefined}
@value-changed=${this._selectChanged}
.maxColumns=${this.selector.select?.box_max_columns}
.hass=${this.hass}
></ha-select-box>
${this._renderHelper()}
`;
+3
View File
@@ -38,6 +38,9 @@ export class HaListItemBase extends HaRowItem {
public connectedCallback(): void {
super.connectedCallback();
if (!this.hasAttribute("ha-list-item")) {
this.setAttribute("ha-list-item", "");
}
if (!this.hasAttribute("role")) {
this.setAttribute("role", this.defaultRole);
}
+149 -91
View File
@@ -1,9 +1,9 @@
import type { CSSResultGroup, TemplateResult } from "lit";
import { css, html, LitElement } from "lit";
import { css, html, LitElement, type nothing, type TemplateResult } from "lit";
import { customElement, property } from "lit/decorators";
import { tinykeys } from "tinykeys";
import { compareNodeOrder } from "../../common/dom/compare-node-order";
import { fireEvent, type HASSDomEvent } from "../../common/dom/fire_event";
import { haStyleScrollbar } from "../../resources/styles";
import type { HaListItemBase } from "../item/ha-list-item-base";
import "./types";
import type { HaListItemRegistrationDetail } from "./types";
@@ -45,13 +45,13 @@ export class HaListBase extends LitElement {
/** Host `role` attribute. Empty string means no role is set. */
protected readonly hostRole: string = "list";
private _activeItemIndex = -1;
protected activeItemIndex = -1;
private _firstFocusableIndex = -1;
protected firstFocusableIndex = -1;
private _lastFocusableIndex = -1;
protected lastFocusableIndex = -1;
private _hasFocusableItem = false;
protected hasFocusableItem = false;
private _unbindKeys?: () => void;
@@ -63,22 +63,28 @@ export class HaListBase extends LitElement {
if (!this.hasAttribute("role") && this.hostRole) {
this.setAttribute("role", this.hostRole);
}
this._unbindKeys = tinykeys(this, {
ArrowDown: this._onForward,
ArrowUp: this._onBack,
Home: this._onHome,
End: this._onEnd,
Enter: this._onActivate,
Space: this._onActivate,
});
this.addEventListener("focusin", this._onFocusIn);
this._unbindKeys = tinykeys(
this,
{
ArrowDown: this._onForward,
ArrowUp: this._onBack,
Home: this._onHome,
End: this._onEnd,
PageDown: this._onPageDown,
PageUp: this._onPageUp,
Enter: this.onActivate,
Space: this.onActivate,
},
{ ignore: this._ignoreKeyEvent }
);
this.addEventListener("focusin", this.onFocusIn);
this.addEventListener(
"ha-list-item-register",
this._onItemRegister as EventListener
this.onItemRegister as EventListener
);
this.addEventListener(
"ha-list-item-unregister",
this._onItemUnregister as EventListener
this.onItemUnregister as EventListener
);
}
@@ -86,25 +92,23 @@ export class HaListBase extends LitElement {
super.disconnectedCallback();
this._unbindKeys?.();
this._unbindKeys = undefined;
this.removeEventListener("focusin", this._onFocusIn);
this.removeEventListener("focusin", this.onFocusIn);
this.removeEventListener(
"ha-list-item-register",
this._onItemRegister as EventListener
this.onItemRegister as EventListener
);
this.removeEventListener(
"ha-list-item-unregister",
this._onItemUnregister as EventListener
this.onItemUnregister as EventListener
);
}
public focus(options?: FocusOptions) {
if (!this.items.length) {
if (!this.itemCount) {
super.focus(options);
return;
}
this.focusItemAtIndex(
this._activeItemIndex >= 0 ? this._activeItemIndex : 0
);
this.focusItemAtIndex(this.activeItemIndex >= 0 ? this.activeItemIndex : 0);
}
public focusItemAtIndex(index: number) {
@@ -115,19 +119,19 @@ export class HaListBase extends LitElement {
}
public getActiveItemIndex(): number {
return this._activeItemIndex;
return this.activeItemIndex;
}
public setActiveItemIndex(index: number, focusItem = false) {
if (!this._hasFocusableItem) {
this._activeItemIndex = -1;
if (!this.hasFocusableItem) {
this.activeItemIndex = -1;
return;
}
this._activeItemIndex = Math.max(0, Math.min(this.items.length - 1, index));
if (!this._isFocusable(this._activeItemIndex)) {
this._activeItemIndex = this._firstFocusableIndex;
this.activeItemIndex = Math.max(0, Math.min(this.itemCount - 1, index));
if (!this.isFocusable(this.activeItemIndex)) {
this.activeItemIndex = this.firstFocusableIndex;
}
this._applyActive(focusItem);
this.applyActive(focusItem);
}
/**
@@ -135,18 +139,18 @@ export class HaListBase extends LitElement {
* to layer in extra bookkeeping (e.g. selection state sync).
*/
public updateListItems() {
this._recomputeFocusableIndexes();
this.recomputeFocusableIndexes();
if (
this._activeItemIndex >= this.items.length ||
!this._hasFocusableItem ||
this._activeItemIndex < 0
this.activeItemIndex >= this.itemCount ||
!this.hasFocusableItem ||
this.activeItemIndex < 0
) {
this._activeItemIndex = this._firstFocusableIndex;
this.activeItemIndex = this.firstFocusableIndex;
}
this._applyActive(false);
this.applyActive(false);
}
private _onItemRegister = (
protected onItemRegister = (
ev: HASSDomEvent<HaListItemRegistrationDetail>
) => {
ev.stopPropagation();
@@ -160,7 +164,7 @@ export class HaListBase extends LitElement {
this.updateListItems();
};
private _onItemUnregister = (
protected onItemUnregister = (
ev: HASSDomEvent<HaListItemRegistrationDetail>
) => {
ev.stopPropagation();
@@ -172,136 +176,190 @@ export class HaListBase extends LitElement {
this.updateListItems();
};
private _recomputeFocusableIndexes() {
protected recomputeFocusableIndexes() {
let first = -1;
let last = -1;
for (let i = 0; i < this.items.length; i++) {
if (this._isFocusable(i)) {
for (let i = 0; i < this.itemCount; i++) {
if (this.isFocusable(i)) {
if (first === -1) {
first = i;
}
last = i;
}
}
this._firstFocusableIndex = first;
this._lastFocusableIndex = last;
this._hasFocusableItem = first !== -1;
this.firstFocusableIndex = first;
this.lastFocusableIndex = last;
this.hasFocusableItem = first !== -1;
}
protected render(): TemplateResult {
return html`<div part="base" class="base">
protected render(): TemplateResult | typeof nothing {
return html`<div part="base" class="base ha-scrollbar">
<slot></slot>
</div>`;
}
private _isFocusable(index: number): boolean {
protected isFocusable(index: number): boolean {
const item = this.items[index];
return !!item && item.interactive && !item.disabled;
}
private _applyActive(focusItem: boolean) {
protected applyActive(focusItem: boolean) {
this.items.forEach((item, i) => {
if (!item.interactive || item.disabled) {
item.removeAttribute("tabindex");
return;
}
item.tabIndex = i === this._activeItemIndex ? 0 : -1;
item.tabIndex = i === this.activeItemIndex ? 0 : -1;
});
if (focusItem && this._activeItemIndex >= 0) {
this.items[this._activeItemIndex]?.focus();
if (focusItem && this.activeItemIndex >= 0) {
this.items[this.activeItemIndex]?.focus();
}
}
private _onFocusIn = (ev: FocusEvent) => {
protected onFocusIn = (ev: FocusEvent) => {
const path = ev.composedPath();
for (let i = 0; i < this.items.length; i++) {
if (path.includes(this.items[i])) {
if (i !== this._activeItemIndex) {
this._activeItemIndex = i;
this._applyActive(false);
if (i !== this.activeItemIndex) {
this.activeItemIndex = i;
this.applyActive(false);
}
return;
}
}
};
private _ignoreKeyEvent = (ev: KeyboardEvent): boolean => {
if (ev.repeat && (ev.key === "Enter" || ev.key === " ")) {
return true;
}
if (ev.isComposing) {
return true;
}
const target = ev.target as HTMLElement | null;
// Allow held arrow/Home/End to repeat for continuous navigation
return (
!!target &&
target !== ev.currentTarget &&
target.matches("[contenteditable],input,select,textarea")
);
};
private _onForward = (ev: KeyboardEvent) => {
this._moveFocus(ev, this._stepIndex(this._activeItemIndex, 1));
this.moveFocus(ev, this._stepIndex(this.activeItemIndex, 1));
};
private _onBack = (ev: KeyboardEvent) => {
this._moveFocus(ev, this._stepIndex(this._activeItemIndex, -1));
this.moveFocus(ev, this._stepIndex(this.activeItemIndex, -1));
};
private _onHome = (ev: KeyboardEvent) => {
this._moveFocus(ev, this._firstFocusableIndex);
this.moveFocus(ev, this.firstFocusableIndex);
};
private _onEnd = (ev: KeyboardEvent) => {
this._moveFocus(ev, this._lastFocusableIndex);
this.moveFocus(ev, this.lastFocusableIndex);
};
private _onActivate = (ev: KeyboardEvent) => {
if (!this._isFocusable(this._activeItemIndex)) {
private _onPageDown = (ev: KeyboardEvent) => {
this.moveFocus(
ev,
this._stepIndex(this.activeItemIndex, 1, this.getPageSize())
);
};
private _onPageUp = (ev: KeyboardEvent) => {
this.moveFocus(
ev,
this._stepIndex(this.activeItemIndex, -1, this.getPageSize())
);
};
/**
* Number of items to jump for PageUp/PageDown. Defaults to 10 (per WAI-ARIA
* Authoring Practices: "moves focus a manageable number of nodes,
* typically 10"). Subclasses with a known viewport (e.g. virtualized lists)
* can override to use the visible page size.
*/
protected getPageSize(): number {
return 10;
}
protected onActivate = (ev: KeyboardEvent) => {
if (!this.isFocusable(this.activeItemIndex)) {
return;
}
ev.preventDefault();
const active = this.items[this._activeItemIndex];
const active = this.items[this.activeItemIndex];
active.activate();
fireEvent(this, "ha-list-activated", {
index: this._activeItemIndex,
index: this.activeItemIndex,
item: active,
});
};
private _moveFocus(ev: KeyboardEvent, next: number) {
if (!this._hasFocusableItem || next < 0 || next === this._activeItemIndex) {
protected moveFocus(ev: KeyboardEvent, next: number) {
if (!this.hasFocusableItem) {
return;
}
ev.preventDefault();
this._activeItemIndex = next;
this._applyActive(true);
if (next < 0 || next === this.activeItemIndex) {
return;
}
this.activeItemIndex = next;
this.applyActive(true);
}
protected get itemCount(): number {
return this.items.length;
}
/**
* Step from `from` by `delta`, skipping non-interactive and disabled items.
* Returns `from` when no other focusable item can be reached (honouring
* `wrapFocus`).
* Pass `count` > 1 to advance by multiple focusable items (PageUp/Down).
* Returns the last focusable index reached, or `from` when none is.
*/
private _stepIndex(from: number, delta: 1 | -1): number {
const n = this.items.length;
if (!n || !this._hasFocusableItem) {
private _stepIndex(from: number, delta: 1 | -1, count = 1): number {
const n = this.itemCount;
if (!n || !this.hasFocusableItem) {
return from;
}
let last = from;
let i = from;
for (let step = 0; step < n; step++) {
let landed = 0;
for (let step = 0; step < n && landed < count; step++) {
i += delta;
if (i < 0 || i >= n) {
if (!this.wrapFocus) {
return from;
return last;
}
i = (i + n) % n;
}
if (this._isFocusable(i)) {
return i;
if (this.isFocusable(i)) {
last = i;
landed++;
}
}
return from;
return last;
}
static styles: CSSResultGroup = css`
:host {
display: block;
}
.base {
display: flex;
flex-direction: column;
gap: var(--ha-list-gap, 0);
padding: var(--ha-list-padding, 0);
margin: 0;
list-style: none;
}
`;
static styles = [
haStyleScrollbar,
css`
:host {
display: block;
}
.base {
display: flex;
flex-direction: column;
gap: var(--ha-list-gap, 0);
padding: var(--ha-list-padding, 0);
margin: 0;
list-style: none;
overflow-x: hidden;
}
`,
];
}
declare global {
+1 -1
View File
@@ -30,7 +30,7 @@ export class HaListNav extends HaListBase {
part="nav"
aria-label=${ifDefined(this.ariaLabel ?? undefined)}
>
<div part="base" class="base" role="list">
<div part="base" class="base ha-scrollbar" role="list">
<slot></slot>
</div>
</nav>`;
@@ -0,0 +1,85 @@
import { property } from "lit/decorators";
import { fireEvent } from "../../common/dom/fire_event";
import type { Constructor } from "../../types";
import { HaListItemOption } from "../item/ha-list-item-option";
import type { HaListBase } from "./ha-list-base";
export const SelectableMixin = <T extends Constructor<HaListBase>>(
superClass: T
) => {
class SelectableClass extends superClass {
@property({ type: Boolean, reflect: true }) public multi = false;
protected override readonly hostRole = "listbox";
public connectedCallback(): void {
super.connectedCallback();
this.addEventListener("click", this._onOptionClick);
this.setAttribute("aria-multiselectable", this.multi ? "true" : "false");
}
public disconnectedCallback(): void {
super.disconnectedCallback();
this.removeEventListener("click", this._onOptionClick);
}
public updated(changed: Map<string, unknown>) {
super.updated(changed);
if (changed.has("multi")) {
this.setAttribute(
"aria-multiselectable",
this.multi ? "true" : "false"
);
}
}
/** Hook: index of a clicked option element, or `-1` if it's not ours. */
protected optionIndexOf(opt: HaListItemOption): number {
return this.items.indexOf(opt);
}
public clearSelection() {
(this.items as HaListItemOption[]).forEach((opt) => {
if (opt.selected) {
opt.toggleAttribute("selected", false);
}
});
}
private _onOptionClick = (ev: Event) => {
const path = ev.composedPath();
for (const el of path) {
if (el === this) {
return;
}
if (el instanceof HaListItemOption) {
if (el.disabled) {
return;
}
const index = this.optionIndexOf(el);
if (index < 0) {
return;
}
if (this.multi) {
fireEvent(
this,
`ha-list-item-${el.selected ? "deselected" : "selected"}`,
index
);
el.toggleAttribute("selected");
return;
}
if (!el.selected) {
fireEvent(this, "ha-list-item-selected", index);
// deselect the other optional selected item
this.clearSelection();
el.toggleAttribute("selected", true);
}
}
}
};
}
return SelectableClass;
};
@@ -0,0 +1,66 @@
import { customElement } from "lit/decorators";
import { HaListItemOption } from "../item/ha-list-item-option";
import { SelectableMixin } from "./ha-list-selectable-mixin";
import { HaListVirtualized } from "./ha-list-virtualized";
/**
* @element ha-list-selectable-virtualized
* @extends {HaListVirtualized}
*
* @summary
* Virtualized selection list (role `listbox`). Rows must render
* `<ha-list-item-option>` as their top-level element. Selection is index-based:
* clicking a row fires `ha-list-item-selected` / `ha-list-item-deselected` with
* the row's index, and the row's `selected` attribute is toggled. Consumers own
* the source of truth — set each row's `selected` from their own state (for
* example, keyed by the option's `value`) and update it in the event handlers.
*
* Because selection is tracked per-row by the consumer, filtering the visible
* `rows` doesn't affect selections for items outside the current view.
*
* @attr {boolean} multi - Whether multiple options can be selected at once. In
* single-select mode, selecting a row clears any previous selection.
*
* @fires ha-list-item-selected - Fires when the user selects a row.
* `detail` is the row's index (number).
* @fires ha-list-item-deselected - Fires when the user deselects a row (multi-select only).
* `detail` is the row's index (number).
*/
@customElement("ha-list-selectable-virtualized")
export class HaListSelectableVirtualized extends SelectableMixin(
HaListVirtualized
) {
/**
* Hook: maps a clicked option to its absolute index by offsetting its
* position among the rendered (virtualized) children by `rangeStart`.
* Returns `-1` if it's not one of our rows or nothing is rendered yet.
*/
protected optionIndexOf(opt: HaListItemOption): number {
if (!this.virtualizerElement || this.rangeStart === -1) {
return -1;
}
const index = Array.from(this.virtualizerElement.children).indexOf(opt);
if (index === -1) {
return -1;
}
return this.rangeStart + index;
}
/** Deselects every currently rendered (visible) option. */
public clearSelection() {
if (!this.virtualizerElement || this.rangeStart === -1) {
return;
}
Array.from(this.virtualizerElement.children).forEach((opt) => {
if (opt instanceof HaListItemOption && opt.selected) {
opt.toggleAttribute("selected", false);
}
});
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-list-selectable-virtualized": HaListSelectableVirtualized;
}
}
+5 -192
View File
@@ -1,8 +1,6 @@
import { customElement, property } from "lit/decorators";
import { fireEvent } from "../../common/dom/fire_event";
import { HaListItemOption } from "../item/ha-list-item-option";
import { customElement } from "lit/decorators";
import { HaListBase } from "./ha-list-base";
import type { HaListSelectedDetail } from "./types";
import { SelectableMixin } from "./ha-list-selectable-mixin";
/**
* @element ha-list-selectable
@@ -14,196 +12,11 @@ import type { HaListSelectedDetail } from "./types";
*
* @attr {boolean} multi - Whether multiple options can be selected at once.
*
* @fires ha-list-selected - Fired when the selection changes. `detail: HaListSelectedDetail`.
* @fires ha-list-item-selected - An option was selected. `detail: number` (option index).
* @fires ha-list-item-deselected - An option was deselected (multi mode only). `detail: number` (option index).
*/
@customElement("ha-list-selectable")
export class HaListSelectable extends HaListBase {
@property({ type: Boolean, reflect: true }) public multi = false;
protected override readonly hostRole = "listbox";
private _selectedIndices?: Set<number>;
public connectedCallback(): void {
super.connectedCallback();
this.addEventListener("click", this._onOptionClick);
this.setAttribute("aria-multiselectable", this.multi ? "true" : "false");
}
public disconnectedCallback(): void {
super.disconnectedCallback();
this.removeEventListener("click", this._onOptionClick);
}
public updated(changed: Map<string, unknown>) {
super.updated(changed);
if (changed.has("multi")) {
this.setAttribute("aria-multiselectable", this.multi ? "true" : "false");
if (!this.multi && (this._selectedIndices?.size ?? 0) > 1) {
const first = Math.min(...this._selectedIndices!);
this._setSelection(new Set([first]));
}
}
}
/**
* Returns the current selection. `number` (or `-1` if nothing) when single,
* `Set<number>` when multi.
*/
public get selected(): number | Set<number> {
if (this.multi) {
return new Set(this._selectedIndices);
}
return (this._selectedIndices?.size ?? 0) === 0
? -1
: this._selectedIndices!.values().next().value!;
}
public get selectedItems(): HaListItemOption[] {
return this._sortedSelectedIndices()
.map((i) => this.items[i] as HaListItemOption | undefined)
.filter((it): it is HaListItemOption => !!it);
}
/** Replace the entire selection. */
public setSelected(indices: number | number[] | Set<number>): void {
const next =
typeof indices === "number"
? indices < 0
? new Set<number>()
: new Set([indices])
: new Set(indices);
if (!this.multi && next.size > 1) {
const first = Math.min(...next);
this._setSelection(new Set([first]));
return;
}
this._setSelection(next);
}
public select(index: number): void {
if (index < 0) {
return;
}
if (this.multi) {
const next = new Set(this._selectedIndices);
next.add(index);
this._setSelection(next);
} else {
this._setSelection(new Set([index]));
}
}
public toggle(index: number, force?: boolean): void {
if (index < 0) {
return;
}
if (this.multi) {
const next = new Set(this._selectedIndices);
const isSelected = next.has(index);
const shouldSelect = force !== undefined ? force : !isSelected;
if (shouldSelect) {
next.add(index);
} else {
next.delete(index);
}
this._setSelection(next);
} else {
const isSelected = this._selectedIndices!.has(index);
const shouldSelect = force !== undefined ? force : !isSelected;
this._setSelection(shouldSelect ? new Set([index]) : new Set());
}
}
public clearSelection(): void {
this._setSelection(new Set());
}
public updateListItems() {
super.updateListItems();
this._syncItemSelectedState(true);
}
private _sortedSelectedIndices(): number[] {
return [...this._selectedIndices!].sort((a, b) => a - b);
}
private _syncItemSelectedState(reset = false): void {
if (!this._selectedIndices || reset) {
this._selectedIndices = new Set<number>();
this.items.forEach((item, i) => {
const opt = item as HaListItemOption;
if (opt.selected) {
this._selectedIndices!.add(i);
}
});
return;
}
this.items.forEach((item, i) => {
const opt = item as HaListItemOption;
const shouldBe = this._selectedIndices!.has(i);
if (opt.selected !== shouldBe) {
opt.selected = shouldBe;
}
});
}
private _setSelection(next: Set<number>): void {
const prev = this._selectedIndices!;
const added = new Set<number>();
const removed = new Set<number>();
next.forEach((i) => {
if (!prev.has(i)) {
added.add(i);
}
});
prev.forEach((i) => {
if (!next.has(i)) {
removed.add(i);
}
});
if (!added.size && !removed.size) {
return;
}
this._selectedIndices = next;
this._syncItemSelectedState();
const detail: HaListSelectedDetail = this.multi
? { index: new Set(next), diff: { added, removed } }
: {
index: next.size === 0 ? -1 : next.values().next().value!,
diff: { added, removed },
};
fireEvent(this, "ha-list-selected", detail);
}
private _onOptionClick = (ev: Event) => {
const path = ev.composedPath();
for (const el of path) {
if (el === this) {
return;
}
if (el instanceof HaListItemOption) {
const index = this.items.indexOf(el);
if (index < 0) {
return;
}
const item = this.items[index];
if (item.disabled) {
return;
}
if (this.multi) {
this.toggle(index);
} else {
this.select(index);
}
return;
}
}
};
}
export class HaListSelectable extends SelectableMixin(HaListBase) {}
declare global {
interface HTMLElementTagNameMap {
+356
View File
@@ -0,0 +1,356 @@
import type { LitVirtualizer } from "@lit-labs/virtualizer";
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize.js";
import {
css,
html,
nothing,
type PropertyValues,
type TemplateResult,
} from "lit";
import {
customElement,
eventOptions,
property,
query,
state,
} from "lit/decorators";
import { fireEvent, type HASSDomEvent } from "../../common/dom/fire_event";
import { loadVirtualizer } from "../../resources/virtualizer";
import { HaListItemBase } from "../item/ha-list-item-base";
import { HaListBase } from "./ha-list-base";
import type { HaListItemRegistrationDetail } from "./types";
/**
* A single row in a {@link HaListVirtualized}. Identified by a stable `id`
* used as the virtualizer key. Extra fields are passed through to the
* `rowRenderer`.
*/
export interface HaListVirtualizedItem {
/** Stable key used by the virtualizer to track the row across re-renders. */
id: string;
/** Whether the row can be focused and activated. Defaults to `false`. */
interactive?: boolean;
disabled?: boolean;
[key: string]: unknown;
}
/**
* @element ha-list-virtualized
* @extends {HaListBase}
*
* @summary
* Virtualized list. Renders only the rows currently in view to keep large
* lists performant, while preserving the roving-tabindex keyboard navigation
* of {@link HaListBase}.
*
* @csspart base - The scrollable outer container (`<div>`).
*
* @attr {number} pin-index - Row index to scroll to when the list first
* renders. Cleared once the user scrolls.
* @attr {string} pin-block - Block alignment for `pin-index`: `start`,
* `center` (default), `end`, or `nearest`.
*
* @fires ha-list-activated - Fired when a row is activated via Enter/Space. `detail: { index, item }`.
*/
@customElement("ha-list-virtualized")
export class HaListVirtualized extends HaListBase {
@state() private _virtualizerReady = false;
/**
* The list data. Each item is rendered by `rowRenderer`; its `interactive`
* and `disabled` flags determine whether the row is focusable.
*/
@property({ attribute: false })
public rows!: HaListVirtualizedItem[];
/** Renders a single row from its data and index. */
@property({ attribute: false })
public rowRenderer?: RenderItemFunction<HaListVirtualizedItem>;
/** Row index to scroll to on first render (the "pinned" row). */
@property({ attribute: "pin-index", type: Number }) public pinIndex?: number;
/** Block alignment used when scrolling to `pinIndex`. */
@property({ attribute: "pin-block" }) public pinBlock:
| "start"
| "center"
| "end"
| "nearest" = "center";
@state() private _unpinned = false;
@query("lit-virtualizer")
protected virtualizerElement?: LitVirtualizer<HaListVirtualizedItem>;
protected rangeStart = -1;
protected rangeEnd = -1;
private _activeItemFocus = false;
private _scrollToActiveItem = false;
public willUpdate(changedProps: PropertyValues) {
if (!this.hasUpdated) {
this._loadVirtualizer();
}
if (changedProps.has("rows")) {
this.recomputeFocusableIndexes();
this.activeItemIndex = this.firstFocusableIndex;
}
}
private async _loadVirtualizer() {
await loadVirtualizer();
this._virtualizerReady = true;
}
protected override render(): TemplateResult | typeof nothing {
if (!this._virtualizerReady) {
return nothing;
}
return html`<div part="base" class="base ha-scrollbar">
<lit-virtualizer
.keyFunction=${this._keyFunction}
tabindex="-1"
scroller
.items=${this.rows}
.renderItem=${this.rowRenderer}
style="min-height: 36px; height: 100%;"
.layout=${!this._unpinned && this.pinIndex !== undefined
? {
pin: {
index: this.pinIndex,
block: this.pinBlock,
},
}
: undefined}
@unpinned=${this._handleUnpinned}
@rangeChanged=${this._handleRangeChanged}
>
</lit-virtualizer>
</div>`;
}
/**
* Sets the active (roving-tabindex) row. If the row is outside the rendered
* range it is scrolled into view first, then activated/focused once the
* virtualizer has laid it out.
* @param index - Row index to make active; clamped to the valid range.
* @param focusItem - Whether to move DOM focus to the row.
*/
public setActiveItemIndex(index: number, focusItem = false) {
if (!this.hasFocusableItem) {
this.activeItemIndex = -1;
return;
}
this.activeItemIndex = Math.max(0, Math.min(this.rows.length - 1, index));
if (!this.isFocusable(this.activeItemIndex)) {
this.activeItemIndex = this.firstFocusableIndex;
}
if (
this.activeItemIndex >= this.rangeStart &&
this.activeItemIndex <= this.rangeEnd
) {
this.applyActive(focusItem);
} else {
this._activeItemFocus = focusItem;
this._scrollToActiveItem = true;
this.virtualizerElement
?.element(index)
?.scrollIntoView({ block: "nearest" });
}
}
/**
* Focuses the row at `index`, scrolling it into view if needed. No-op until
* the virtualizer is ready or when `index` is negative.
*/
public override focusItemAtIndex(index: number) {
if (!this._virtualizerReady || index < 0) {
return;
}
this.setActiveItemIndex(index, true);
}
protected override applyActive(focusItem: boolean) {
if (this.virtualizerElement && this.rangeStart > -1) {
Array.from(this.virtualizerElement.children).forEach((child, index) => {
const el = child as HTMLElement;
if (index + this.rangeStart === this.activeItemIndex) {
el.tabIndex = 0;
if (focusItem) {
el.focus();
}
} else {
el.removeAttribute("tabindex");
}
});
}
}
@eventOptions({ passive: true })
private async _handleRangeChanged(ev: { first: number; last: number }) {
this.rangeStart = ev.first;
this.rangeEnd = ev.last;
await this.virtualizerElement?.layoutComplete;
this._applySetSize();
if (!this.virtualizerElement) {
return;
}
const inRange =
this.activeItemIndex >= this.rangeStart &&
this.activeItemIndex <= this.rangeEnd;
const focus = this._scrollToActiveItem && inRange && this._activeItemFocus;
this.applyActive(focus);
if (this._scrollToActiveItem && inRange) {
this._activeItemFocus = false;
this._scrollToActiveItem = false;
}
}
// Expose total count + position to assistive tech, since only a slice of
// items is in the DOM at any time.
private _applySetSize() {
if (!this.virtualizerElement || this.rangeStart < 0) {
return;
}
const total = this.rows?.length ?? 0;
Array.from(this.virtualizerElement.children).forEach((child, index) => {
const el = child as HTMLElement;
el.setAttribute("aria-setsize", String(total));
el.setAttribute("aria-posinset", String(this.rangeStart + index + 1));
});
}
protected onFocusIn = (ev: FocusEvent) => {
if (
!this.virtualizerElement ||
this.rangeStart === -1 ||
this.rangeEnd === -1
) {
return;
}
const path = ev.composedPath();
const children = Array.from(this.virtualizerElement.children);
for (let i = this.rangeStart; i <= this.rangeEnd; i++) {
if (path.includes(children[i - this.rangeStart])) {
if (i !== this.activeItemIndex) {
this.activeItemIndex = i;
if (i < this.rangeStart || i > this.rangeEnd) {
this._activeItemFocus = true;
this._scrollToActiveItem = true;
this.virtualizerElement
?.element(this.activeItemIndex)
?.scrollIntoView({ block: "nearest" });
} else {
this.applyActive(false);
}
}
return;
}
}
};
protected override onActivate = (ev: KeyboardEvent) => {
if (!this.isFocusable(this.activeItemIndex)) {
return;
}
if (
this.virtualizerElement &&
this.activeItemIndex >= this.rangeStart &&
this.activeItemIndex <= this.rangeEnd
) {
const active = this.virtualizerElement?.children[
this.activeItemIndex - this.rangeStart
] as HaListItemBase | undefined;
if (active && active instanceof HaListItemBase) {
ev.preventDefault();
active.activate();
fireEvent(this, "ha-list-activated", {
index: this.activeItemIndex,
item: active,
});
}
}
};
protected isFocusable(index: number): boolean {
const item = this.rows[index];
if (!item) {
return false;
}
const { disabled = false, interactive = false } = this.rows[index];
return interactive && !disabled;
}
protected override get itemCount(): number {
return this.rows?.length ?? 0;
}
protected override moveFocus(ev: KeyboardEvent, next: number) {
if (!this.hasFocusableItem) {
return;
}
ev.preventDefault();
if (next < 0 || next === this.activeItemIndex) {
return;
}
this.activeItemIndex = next;
if (next < this.rangeStart || next > this.rangeEnd) {
this._activeItemFocus = true;
this._scrollToActiveItem = true;
this.virtualizerElement?.element(this.activeItemIndex)?.scrollIntoView({
block: "nearest",
});
} else {
this.applyActive(true);
}
}
protected override getPageSize(): number {
if (this.rangeStart < 0 || this.rangeEnd < 0) {
return super.getPageSize();
}
return Math.max(1, this.rangeEnd - this.rangeStart + 1);
}
private _keyFunction = (item: HaListVirtualizedItem) => item.id;
@eventOptions({ passive: true })
private _handleUnpinned() {
this._unpinned = true;
}
protected override onItemRegister = (
ev: HASSDomEvent<HaListItemRegistrationDetail>
) => {
ev.stopPropagation();
};
protected override onItemUnregister = (
ev: HASSDomEvent<HaListItemRegistrationDetail>
) => {
ev.stopPropagation();
// ignore
};
static styles = [
...HaListBase.styles,
css`
.base {
height: 100%;
}
[ha-list-item] {
width: 100%;
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
"ha-list-virtualized": HaListVirtualized;
}
}
+2 -7
View File
@@ -1,11 +1,5 @@
import type { HaListItemBase } from "../item/ha-list-item-base";
export interface HaListSelectedDetail {
index: number | Set<number>;
diff?: { added: Set<number>; removed: Set<number> };
value?: string | string[];
}
export interface HaListActivatedDetail {
index: number;
item: HaListItemBase;
@@ -17,7 +11,8 @@ export interface HaListItemRegistrationDetail {
declare global {
interface HASSDomEvents {
"ha-list-selected": HaListSelectedDetail;
"ha-list-item-selected": number;
"ha-list-item-deselected": number;
"ha-list-activated": HaListActivatedDetail;
"ha-list-item-register": HaListItemRegistrationDetail;
"ha-list-item-unregister": HaListItemRegistrationDetail;
@@ -0,0 +1,147 @@
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import type { HomeAssistant } from "../../types";
import {
brandsUrl,
extractDomainFromBrandUrl,
isBrandUrl,
} from "../../util/brands-url";
const SMALL_THUMBNAIL_THRESHOLD = 16;
const isSvgUrl = (url: string): boolean =>
/\.svg(\?|#|$)/i.test(url) || url.startsWith("data:image/svg+xml");
const resolveThumbnailURL = (
hass: HomeAssistant,
thumbnailUrl: string
): Promise<string> => {
if (isBrandUrl(thumbnailUrl)) {
return Promise.resolve(
brandsUrl(
{
domain: extractDomainFromBrandUrl(thumbnailUrl),
type: "icon",
darkOptimized: hass.themes?.darkMode,
},
hass.auth.data.hassUrl
)
);
}
if (thumbnailUrl.startsWith("/")) {
// Local thumbnails require authentication; fetch and inline as base64.
return hass
.fetchWithAuth(thumbnailUrl)
.then((response) => response.blob())
.then(
(blob) =>
new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () =>
resolve(typeof reader.result === "string" ? reader.result : "");
reader.onerror = (e) => reject(e);
reader.readAsDataURL(blob);
})
);
}
return Promise.resolve(thumbnailUrl);
};
@customElement("ha-media-browser-thumbnail")
export class HaMediaBrowserThumbnail extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property() public url?: string;
@state() private _resolvedUrl?: string;
@state() private _small = false;
@state() private _brand = false;
protected willUpdate(changedProps: PropertyValues): void {
super.willUpdate(changedProps);
if (changedProps.has("url")) {
this._resolve();
}
}
private async _resolve(): Promise<void> {
this._small = false;
this._brand = !!this.url && isBrandUrl(this.url);
if (!this.url) {
this._resolvedUrl = undefined;
return;
}
const requested = this.url;
try {
const resolved = await resolveThumbnailURL(this.hass, requested);
if (requested !== this.url) return;
this._resolvedUrl = resolved;
this._probeSize(resolved);
} catch (_err) {
if (requested === this.url) this._resolvedUrl = undefined;
}
}
private _probeSize(url: string): void {
// SVGs (including brand icons) scale natively; pixelated rendering would
// break vector output.
if (this.url && isBrandUrl(this.url)) return;
if (isSvgUrl(url)) return;
const img = new Image();
img.addEventListener("load", () => {
if (this._resolvedUrl !== url) return;
if (
img.naturalWidth > 0 &&
img.naturalWidth <= SMALL_THUMBNAIL_THRESHOLD
) {
this._small = true;
}
});
img.src = url;
}
protected render(): TemplateResult | typeof nothing {
if (!this._resolvedUrl) return nothing;
return html`
<div
class=${classMap({
image: true,
small: this._small,
brand: this._brand,
})}
style="background-image: url(${this._resolvedUrl})"
></div>
`;
}
static readonly styles: CSSResultGroup = css`
:host {
display: block;
width: 100%;
height: 100%;
}
.image {
width: 100%;
height: 100%;
background-size: var(--ha-media-browser-thumbnail-fit, contain);
background-repeat: no-repeat;
background-position: center;
}
.image.brand {
background-size: 40%;
}
.image.small {
image-rendering: pixelated;
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-media-browser-thumbnail": HaMediaBrowserThumbnail;
}
}
@@ -13,7 +13,6 @@ import {
} from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { styleMap } from "lit/directives/style-map";
import { until } from "lit/directives/until";
import { fireEvent } from "../../common/dom/fire_event";
import { slugify } from "../../common/string/slugify";
import { debounce } from "../../common/util/debounce";
@@ -39,11 +38,6 @@ import { showAlertDialog } from "../../dialogs/generic/show-dialog-box";
import { haStyle, haStyleScrollbar } from "../../resources/styles";
import { loadVirtualizer } from "../../resources/virtualizer";
import type { HomeAssistant } from "../../types";
import {
brandsUrl,
extractDomainFromBrandUrl,
isBrandUrl,
} from "../../util/brands-url";
import { documentationUrl } from "../../util/documentation-url";
import "../entity/ha-entity-picker";
import "../ha-alert";
@@ -52,6 +46,7 @@ import "../ha-card";
import "../ha-icon-button";
import "../ha-list";
import "../ha-list-item";
import "./ha-media-browser-thumbnail";
import "../ha-spinner";
import "../ha-svg-icon";
import "../ha-tooltip";
@@ -411,12 +406,6 @@ export class HaMediaPlayerBrowse extends LitElement {
? MediaClassBrowserSettings[currentItem.children_media_class]
: MediaClassBrowserSettings.directory;
const backgroundImage = currentItem.thumbnail
? this._getThumbnailURLorBase64(currentItem.thumbnail).then(
(value) => `url(${value})`
)
: "none";
return html`
${
currentItem.can_play
@@ -431,13 +420,11 @@ export class HaMediaPlayerBrowse extends LitElement {
<div class="header-content">
${currentItem.thumbnail
? html`
<div
class="img"
style="background-image: ${until(
backgroundImage,
""
)}"
>
<div class="img">
<ha-media-browser-thumbnail
.hass=${this.hass}
.url=${currentItem.thumbnail}
></ha-media-browser-thumbnail>
${this.narrow &&
currentItem?.can_play &&
(!this.accept ||
@@ -638,12 +625,6 @@ export class HaMediaPlayerBrowse extends LitElement {
}
private _renderGridItem = (child: MediaPlayerItem): TemplateResult => {
const backgroundImage = child.thumbnail
? this._getThumbnailURLorBase64(child.thumbnail).then(
(value) => `url(${value})`
)
: "none";
return html`
<div class="child" .item=${child} @click=${this._childClicked}>
<ha-card outlined>
@@ -655,10 +636,13 @@ export class HaMediaPlayerBrowse extends LitElement {
"centered-image": ["app", "directory"].includes(
child.media_class
),
"brand-image": isBrandUrl(child.thumbnail),
})} image"
style="background-image: ${until(backgroundImage, "")}"
></div>
>
<ha-media-browser-thumbnail
.hass=${this.hass}
.url=${child.thumbnail}
></ha-media-browser-thumbnail>
</div>
`
: html`
<div class="icon-holder image">
@@ -703,13 +687,7 @@ export class HaMediaPlayerBrowse extends LitElement {
private _renderListItem = (child: MediaPlayerItem): TemplateResult => {
const currentItem = this._currentItem;
const mediaClass = MediaClassBrowserSettings[currentItem!.media_class];
const backgroundImage =
mediaClass.show_list_images && child.thumbnail
? this._getThumbnailURLorBase64(child.thumbnail).then(
(value) => `url(${value})`
)
: "none";
const showImage = mediaClass.show_list_images && !!child.thumbnail;
return html`
<ha-list-item
@@ -717,7 +695,7 @@ export class HaMediaPlayerBrowse extends LitElement {
.item=${child}
.graphic=${mediaClass.show_list_images ? "medium" : "avatar"}
>
${backgroundImage === "none" && !child.can_play
${!showImage && !child.can_play
? html`<ha-svg-icon
.path=${MediaClassBrowserSettings[
child.media_class === "directory"
@@ -731,9 +709,14 @@ export class HaMediaPlayerBrowse extends LitElement {
graphic: true,
thumbnail: mediaClass.show_list_images === true,
})}
style="background-image: ${until(backgroundImage, "")}"
slot="graphic"
>
${showImage
? html`<ha-media-browser-thumbnail
.hass=${this.hass}
.url=${child.thumbnail}
></ha-media-browser-thumbnail>`
: nothing}
${child.can_play
? html`<ha-icon-button
class="play ${classMap({
@@ -753,51 +736,6 @@ export class HaMediaPlayerBrowse extends LitElement {
`;
};
private async _getThumbnailURLorBase64(
thumbnailUrl: string | undefined
): Promise<string> {
if (!thumbnailUrl) {
return "";
}
if (isBrandUrl(thumbnailUrl)) {
// The backend is not aware of the theme used by the users,
// so we rewrite the URL to show a proper icon
return brandsUrl(
{
domain: extractDomainFromBrandUrl(thumbnailUrl),
type: "icon",
darkOptimized: this.hass.themes?.darkMode,
},
this.hass.auth.data.hassUrl
);
}
if (thumbnailUrl.startsWith("/")) {
// Thumbnails served by local API require authentication
return new Promise((resolve, reject) => {
this.hass
.fetchWithAuth(thumbnailUrl!)
// Since we are fetching with an authorization header, we cannot just put the
// URL directly into the document; we need to embed the image. We could do this
// using blob URLs, but then we would need to keep track of them in order to
// release them properly. Instead, we embed the thumbnail using base64.
.then((response) => response.blob())
.then((blob) => {
const reader = new FileReader();
reader.onload = () => {
const result = reader.result;
resolve(typeof result === "string" ? result : "");
};
reader.onerror = (e) => reject(e);
reader.readAsDataURL(blob);
});
});
}
return thumbnailUrl;
}
private _actionClicked = (ev: MouseEvent): void => {
ev.stopPropagation();
const item = (ev.currentTarget as any).item;
@@ -1048,14 +986,20 @@ export class HaMediaPlayerBrowse extends LitElement {
align-items: flex-start;
}
.header-content .img {
position: relative;
height: 175px;
width: 175px;
margin-right: 16px;
background-size: cover;
border-radius: 2px;
overflow: hidden;
transition:
width 0.4s,
height 0.4s;
--ha-media-browser-thumbnail-fit: cover;
}
.header-content .img ha-media-browser-thumbnail {
position: absolute;
inset: 0;
}
.header-info {
display: flex;
@@ -1191,18 +1135,12 @@ export class HaMediaPlayerBrowse extends LitElement {
right: 0;
left: 0;
bottom: 0;
background-size: cover;
background-repeat: no-repeat;
background-position: center;
--ha-media-browser-thumbnail-fit: cover;
}
.centered-image {
margin: 0 8px;
background-size: contain;
}
.brand-image {
background-size: 40%;
--ha-media-browser-thumbnail-fit: contain;
}
.children ha-card .icon-holder {
@@ -1278,17 +1216,21 @@ export class HaMediaPlayerBrowse extends LitElement {
}
ha-list-item .graphic {
background-size: contain;
background-repeat: no-repeat;
background-position: center;
position: relative;
border-radius: var(--ha-border-radius-sm);
display: flex;
align-content: center;
align-items: center;
overflow: hidden;
line-height: initial;
}
ha-list-item .graphic ha-media-browser-thumbnail {
position: absolute;
inset: 0;
}
ha-list-item .graphic .play {
position: absolute;
inset: 0;
margin: auto;
opacity: 0;
transition: all 0.5s;
background-color: rgba(var(--rgb-card-background-color), 0.5);
+2
View File
@@ -99,6 +99,8 @@ export class HaRadioOption extends Radio {
--ha-radio-option-checked-background-color,
var(--ha-color-fill-primary-normal-resting)
);
color: var(--checked-icon-color);
border-color: var(--checked-icon-color);
}
[part~="label"] {
+6 -11
View File
@@ -1,4 +1,3 @@
import { createContext } from "@lit/context";
import type {
Connection,
HassEntityAttributeBase,
@@ -96,7 +95,7 @@ export interface TriggerList {
export interface BaseTrigger {
alias?: string;
comment?: string;
note?: string;
/** @deprecated Use `trigger` instead */
platform?: string;
trigger: string;
@@ -242,7 +241,7 @@ export type Trigger = LegacyTrigger | TriggerList | PlatformTrigger;
interface BaseCondition {
condition: string;
alias?: string;
comment?: string;
note?: string;
enabled?: boolean;
options?: Record<string, unknown>;
}
@@ -491,12 +490,12 @@ export const migrateAutomationTrigger = (
export const flattenTriggers = (
triggers: undefined | Trigger | Trigger[]
): Exclude<Trigger, TriggerList>[] => {
): Trigger[] => {
if (!triggers) {
return [];
}
const flatTriggers: Exclude<Trigger, TriggerList>[] = [];
const flatTriggers: Trigger[] = [];
ensureArray(triggers).forEach((t) => {
if ("triggers" in t) {
@@ -610,7 +609,7 @@ export interface AutomationClipboard {
export interface BaseSidebarConfig {
delete: () => void;
close: (focus?: boolean) => void;
editComment: () => void;
editNote: () => void;
}
export interface TriggerSidebarConfig extends BaseSidebarConfig {
@@ -672,7 +671,7 @@ export interface OptionSidebarConfig extends BaseSidebarConfig {
rename: () => void;
duplicate: () => void;
defaultOption?: boolean;
comment?: string;
note?: string;
}
export interface ScriptFieldSidebarConfig extends BaseSidebarConfig {
@@ -698,7 +697,3 @@ export interface ShowAutomationEditorParams {
data?: Partial<AutomationConfig>;
expanded?: boolean;
}
export const automationConfigContext = createContext<
AutomationConfig | undefined
>("automationConfig");
-36
View File
@@ -27,7 +27,6 @@ import type {
LegacyTrigger,
Trigger,
} from "./automation";
import { flattenTriggers } from "./automation";
import { getConditionDomain, getConditionObjectId } from "./condition";
import type {
DeviceCondition,
@@ -108,41 +107,6 @@ const formatNumericLimitValue = (
: value;
};
export interface TriggerInfo {
id: string;
label: string;
triggerType: string;
count: number;
}
export const getTriggerInfos = (
triggers: Trigger[] | undefined,
hass: HomeAssistant,
entityRegistry: EntityRegistryEntry[]
): TriggerInfo[] => {
if (!triggers) {
return [];
}
const map = new Map<string, TriggerInfo>();
for (const t of flattenTriggers(triggers)) {
if (isTriggerList(t) || !t.id) {
continue;
}
const existing = map.get(t.id);
if (existing) {
existing.count++;
} else {
map.set(t.id, {
id: t.id,
label: describeTrigger(t, hass, entityRegistry),
triggerType: t.trigger,
count: 1,
});
}
}
return Array.from(map.values());
};
export const describeTrigger = (
trigger: Trigger,
hass: HomeAssistant,
-1
View File
@@ -40,7 +40,6 @@ export const createConfigFlow = (
"config/config_entries/flow",
{
handler,
show_advanced_options: Boolean(hass.userData?.showAdvanced),
entry_id,
},
HEADERS
+1 -1
View File
@@ -13,7 +13,7 @@ import {
export interface DeviceAutomation {
alias?: string;
comment?: string;
note?: string;
device_id: string;
domain: string;
entity_id?: string;
+7 -1
View File
@@ -161,6 +161,10 @@ export interface VacuumEntityOptions {
last_seen_segments?: Segment[];
}
export interface DeviceTrackerEntityOptions {
associated_zone?: string | null;
}
export interface EntityRegistryOptions {
number?: NumberEntityOptions;
sensor?: SensorEntityOptions;
@@ -172,6 +176,7 @@ export interface EntityRegistryOptions {
cover?: CoverEntityOptions;
valve?: ValveEntityOptions;
vacuum?: VacuumEntityOptions;
device_tracker?: DeviceTrackerEntityOptions;
switch_as_x?: SwitchAsXEntityOptions;
conversation?: Record<string, unknown>;
"cloud.alexa"?: Record<string, unknown>;
@@ -197,7 +202,8 @@ export interface EntityRegistryEntryUpdateParams {
| LightEntityOptions
| CoverEntityOptions
| ValveEntityOptions
| VacuumEntityOptions;
| VacuumEntityOptions
| DeviceTrackerEntityOptions;
aliases?: (string | null)[];
labels?: string[];
categories?: Record<string, string | null>;
-1
View File
@@ -2,7 +2,6 @@ import type { Connection } from "home-assistant-js-websocket";
import type { ShortcutItem } from "./home_shortcuts";
export interface CoreFrontendUserData {
showAdvanced?: boolean;
showEntityIdPicker?: boolean;
default_panel?: string;
apps_info_dismissed?: boolean;
+12
View File
@@ -1,6 +1,14 @@
import type { HassEntity } from "home-assistant-js-websocket";
import type { HomeAssistant } from "../types";
import type { LovelaceCardFeatureContext } from "../panels/lovelace/card-features/types";
import type { LovelaceCardConfig } from "./lovelace/config/card";
export interface CustomCardSuggestion<
T extends LovelaceCardConfig = LovelaceCardConfig,
> {
label?: string;
config: T;
}
export interface CustomCardEntry {
type: string;
@@ -8,6 +16,10 @@ export interface CustomCardEntry {
description?: string;
preview?: boolean;
documentationURL?: string;
getEntitySuggestion?: (
hass: HomeAssistant,
entityId: string
) => CustomCardSuggestion | CustomCardSuggestion[] | null;
}
export interface CustomBadgeEntry {
+16 -5
View File
@@ -2,7 +2,10 @@ import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import { isComponentLoaded } from "../common/config/is_component_loaded";
import { navigate } from "../common/navigate";
import type { HomeAssistant } from "../types";
import { subscribeDeviceRegistry } from "./device/device_registry";
import {
subscribeDeviceRegistry,
type DeviceRegistryEntry,
} from "./device/device_registry";
import { getThreadDataSetTLV, listThreadDataSets } from "./thread";
export enum NetworkType {
@@ -77,9 +80,9 @@ export const startExternalCommissioning = async (hass: HomeAssistant) => {
});
};
export const redirectOnNewMatterDevice = (
export const watchForNewMatterDevice = (
hass: HomeAssistant,
callback?: () => void
callback: (device: DeviceRegistryEntry) => void
): UnsubscribeFunc => {
let curMatterDevices: Set<string> | undefined;
const unsubDeviceReg = subscribeDeviceRegistry(hass.connection, (entries) => {
@@ -101,8 +104,7 @@ export const redirectOnNewMatterDevice = (
if (newMatterDevices.length) {
unsubDeviceReg();
curMatterDevices = undefined;
callback?.();
navigate(`/config/devices/device/${newMatterDevices[0].id}`);
callback(newMatterDevices[0]);
}
});
return () => {
@@ -111,6 +113,15 @@ export const redirectOnNewMatterDevice = (
};
};
export const redirectOnNewMatterDevice = (
hass: HomeAssistant,
callback?: () => void
): UnsubscribeFunc =>
watchForNewMatterDevice(hass, (device) => {
callback?.();
navigate(`/config/devices/device/${device.id}`);
});
export const addMatterDevice = (hass: HomeAssistant) => {
startExternalCommissioning(hass);
};
-1
View File
@@ -7,7 +7,6 @@ export const createOptionsFlow = (hass: HomeAssistant, handler: string) =>
"config/config_entries/options/flow",
{
handler,
show_advanced_options: Boolean(hass.userData?.showAdvanced),
}
);
+3 -3
View File
@@ -36,7 +36,7 @@ export const isMaxMode = arrayLiteralIncludes(MODES_MAX);
export const baseActionStruct = object({
alias: optional(string()),
comment: optional(string()),
note: optional(string()),
continue_on_error: optional(boolean()),
enabled: optional(boolean()),
});
@@ -106,7 +106,7 @@ export interface Field {
interface BaseAction {
alias?: string;
comment?: string;
note?: string;
continue_on_error?: boolean;
enabled?: boolean;
}
@@ -197,7 +197,7 @@ export interface ForEachRepeat extends BaseRepeat {
export interface Option {
alias?: string;
comment?: string;
note?: string;
conditions: string | Condition[];
sequence: Action | Action[];
}
+1 -1
View File
@@ -125,7 +125,7 @@ export interface BooleanSelector {
boolean: {} | null;
}
export type AutomationBehaviorTriggerMode = "first" | "last" | "any";
export type AutomationBehaviorTriggerMode = "first" | "all" | "each";
export type AutomationBehaviorConditionMode = "all" | "any";
-1
View File
@@ -16,7 +16,6 @@ export const createSubConfigFlow = (
"config/config_entries/subentries/flow",
{
handler: [configEntryId, subFlowType],
show_advanced_options: Boolean(hass.userData?.showAdvanced),
subentry_id,
},
HEADERS
-44
View File
@@ -8,7 +8,6 @@ import type {
Trigger,
TriggerList,
} from "./automation";
import { flattenTriggers } from "./automation";
import type { Selector, TargetSelector } from "./selector";
export const TRIGGER_COLLECTIONS: AutomationElementGroupCollection[] = [
@@ -57,49 +56,6 @@ export const TRIGGER_COLLECTIONS: AutomationElementGroupCollection[] = [
export const isTriggerList = (trigger: Trigger): trigger is TriggerList =>
"triggers" in trigger;
export const getTriggerIds = (triggers: Trigger[]): string[] =>
flattenTriggers(triggers)
.map((trigger) => trigger.id)
.filter((id): id is string => !!id);
export const getNextNumericTriggerId = (triggers: Trigger[]): string => {
let max = 0;
for (const id of getTriggerIds(triggers)) {
const num = Number(id);
if (Number.isInteger(num) && num > max) {
max = num;
}
}
return String(max + 1);
};
const computeUniqueId = (id: string, existing: Set<string>): string => {
if (!existing.has(id)) {
return id;
}
// Split into a base and a trailing integer suffix so we can bump the
// suffix on collision (e.g. "foo2" -> "foo3"); if there's no trailing
// digit we start at 2 ("foo" -> "foo2").
const match = id.match(/^(.*?)(\d+)$/);
let base: string;
let num: number;
if (match) {
base = match[1];
num = Number(match[2]) + 1;
} else {
base = id;
num = 2;
}
while (existing.has(`${base}${num}`)) {
num++;
}
return `${base}${num}`;
};
export const getUniqueTriggerId = (id: string, triggers: Trigger[]): string =>
computeUniqueId(id, new Set(getTriggerIds(triggers)));
export interface TriggerDescription {
target?: TargetSelector["target"];
fields: Record<
@@ -167,7 +167,6 @@ export interface DataEntryFlowDialogParams {
entryId?: string;
}) => void;
flowConfig: FlowConfig;
showAdvanced?: boolean;
dialogParentElement?: HTMLElement;
navigateToResult?: boolean;
carryOverDevices?: string[];
@@ -48,7 +48,6 @@ class StepFlowAbort extends LitElement {
showConfigFlowDialog(this.params.dialogParentElement!, {
dialogClosedCallback: this.params.dialogClosedCallback,
startFlowHandler: this.handler,
showAdvanced: this.hass.userData?.showAdvanced,
navigateToResult: this.params.navigateToResult,
});
},
+7 -7
View File
@@ -1,8 +1,8 @@
import type { LitElement } from "lit";
import { state } from "lit/decorators";
import { listenMediaQuery } from "../common/dom/media_query";
import type { Constructor } from "../types";
import { isMobileClient } from "../util/is_mobile";
import { listenMediaQuery } from "../common/dom/media_query";
export const MobileAwareMixin = <T extends Constructor<LitElement>>(
superClass: T
@@ -12,16 +12,16 @@ export const MobileAwareMixin = <T extends Constructor<LitElement>>(
protected _isMobileClient = isMobileClient;
protected mobileSizeQuery =
"all and (max-width: 450px), all and (max-height: 500px)";
private _unsubMql?: () => void;
public connectedCallback() {
super.connectedCallback();
this._unsubMql = listenMediaQuery(
"all and (max-width: 450px), all and (max-height: 500px)",
(matches) => {
this._isMobileSize = matches;
}
);
this._unsubMql = listenMediaQuery(this.mobileSizeQuery, (matches) => {
this._isMobileSize = matches;
});
}
public disconnectedCallback() {
-3
View File
@@ -6,7 +6,6 @@ import { classMap } from "lit/directives/class-map";
import { createRef, ref } from "lit/directives/ref";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../common/dom/fire_event";
import { IFRAME_SANDBOX } from "../../util/iframe";
import { navigate } from "../../common/navigate";
import { computeRouteTail } from "../../common/url/route";
import { nextRender } from "../../common/util/render-status";
@@ -137,8 +136,6 @@ class HaPanelApp extends LitElement {
})}
title=${this._addon.name}
src=${this._addon.ingress_url!}
.sandbox=${IFRAME_SANDBOX}
allow="microphone; camera; clipboard-write"
@load=${this._checkLoaded}
${ref(this._iframeRef)}
>
-1
View File
@@ -342,7 +342,6 @@ class PanelCalendar extends SubscribeMixin(LitElement) {
private _addCalendar = async (): Promise<void> => {
showConfigFlowDialog(this, {
startFlowHandler: "local_calendar",
showAdvanced: this.hass.userData?.showAdvanced,
manifest: await fetchIntegrationManifest(this.hass, "local_calendar"),
dialogClosedCallback: ({ flowFinished }) => {
if (flowFinished) {
@@ -31,7 +31,6 @@ class SupervisorAppInfoDashboard extends LitElement {
<supervisor-app-info
.narrow=${this.narrow}
.route=${this.route}
.hass=${this.hass}
.addon=${this.addon}
.controlEnabled=${this.controlEnabled}
></supervisor-app-info>
@@ -92,14 +92,15 @@ import {
showConfirmationDialog,
} from "../../../../../dialogs/generic/show-dialog-box";
import { showMoreInfoDialog } from "../../../../../dialogs/more-info/show-ha-more-info-dialog";
import { MobileAwareMixin } from "../../../../../mixins/mobile-aware-mixin";
import { mdiHomeAssistant } from "../../../../../resources/home-assistant-logo-svg";
import { haStyle } from "../../../../../resources/styles";
import type { Route } from "../../../../../types";
import { bytesToString } from "../../../../../util/bytes-to-string";
import { getAppDisplayName } from "../../common/app";
import "../components/supervisor-app-metric";
import "../../components/supervisor-apps-tag";
import "../../components/supervisor-apps-state";
import "../../components/supervisor-apps-tag";
import "../components/supervisor-app-metric";
import { extractChangelog } from "../util/supervisor-app";
import "./supervisor-app-system-managed";
@@ -123,7 +124,7 @@ const RATING_ICON = {
const POLL_INTERVAL_SECONDS = 5;
@customElement("supervisor-app-info")
class SupervisorAppInfo extends LitElement {
class SupervisorAppInfo extends MobileAwareMixin(LitElement) {
@property({ type: Boolean }) public narrow = false;
@property({ attribute: false }) public route!: Route;
@@ -163,6 +164,9 @@ class SupervisorAppInfo extends LitElement {
private _pollInterval?: number;
protected mobileSizeQuery =
"all and (max-width: 1120px), all and (max-height: 500px)";
private get _currentAddon(): HassioAddonDetails | StoreAddonDetails {
return this._addon || this.addon;
}
@@ -863,11 +867,11 @@ class SupervisorAppInfo extends LitElement {
`
: nothing}
<div
class="app ${this.narrow || !this._currentAddon.version
class="app ${this._isMobileSize || !this._currentAddon.version
? "column"
: ""}"
>
${this.narrow || !this._currentAddon.version
${this._isMobileSize || !this._currentAddon.version
? html`
${this._renderInfoCard()}
${this._currentAddon.version
@@ -145,8 +145,6 @@ class HaConfigAreaPage extends SubscribeMixin(LitElement) {
@property({ attribute: "is-wide", type: Boolean }) public isWide = false;
@property({ attribute: false }) public showAdvanced = false;
@state()
@consume({ context: fullEntitiesContext, subscribe: true })
_entityReg: EntityRegistryEntry[] = [];
@@ -13,8 +13,6 @@ class HaConfigAreas extends HassRouterPage {
@property({ attribute: "is-wide", type: Boolean }) public isWide = false;
@property({ attribute: false }) public showAdvanced = false;
protected routerOptions: RouterOptions = {
defaultPage: "dashboard",
routes: {
@@ -37,7 +35,6 @@ class HaConfigAreas extends HassRouterPage {
pageEl.narrow = this.narrow;
pageEl.isWide = this.isWide;
pageEl.showAdvanced = this.showAdvanced;
pageEl.route = this.routeTail;
}
}
@@ -107,7 +107,7 @@ export default class HaAutomationActionEditor extends LitElement {
ev.stopPropagation();
const value = {
...(this.action.alias ? { alias: this.action.alias } : {}),
...(this.action.comment ? { comment: this.action.comment } : {}),
...(this.action.note ? { note: this.action.note } : {}),
...ev.detail.value,
};
fireEvent(this, "value-changed", { value });
@@ -297,8 +297,8 @@ export default class HaAutomationActionRow extends LitElement {
?.target
: undefined;
const commentTooltipText = truncateWithEllipsis(
this.action.comment?.trim() || "",
const noteTooltipText = truncateWithEllipsis(
this.action.note?.trim() || "",
250
);
@@ -337,18 +337,19 @@ export default class HaAutomationActionRow extends LitElement {
serviceTargetSpec
)
: nothing}
${commentTooltipText
${noteTooltipText
? html`
<ha-svg-icon
id="comment-icon"
id="note-icon"
tabindex="0"
.path=${mdiCommentTextOutline}
.label=${this.hass.localize(
"ui.panel.config.automation.editor.comment.label"
"ui.panel.config.automation.editor.note.label"
)}
class="comment-indicator"
class="note-indicator"
></ha-svg-icon
><ha-tooltip for="comment-icon"
><p>${commentTooltipText}</p></ha-tooltip
><ha-tooltip for="note-icon"
><p>${noteTooltipText}</p></ha-tooltip
>
`
: nothing}
@@ -407,11 +408,11 @@ export default class HaAutomationActionRow extends LitElement {
)
)}
</ha-dropdown-item>
<ha-dropdown-item value="edit_comment">
<ha-dropdown-item value="edit_note">
<ha-svg-icon slot="icon" .path=${mdiCommentEditOutline}></ha-svg-icon>
${this._renderOverflowLabel(
this.hass.localize(
`ui.panel.config.automation.editor.comment.${this.action.comment ? "edit" : "add"}`
`ui.panel.config.automation.editor.note.${this.action.note ? "edit" : "add"}`
)
)}
</ha-dropdown-item>
@@ -941,25 +942,25 @@ export default class HaAutomationActionRow extends LitElement {
}
};
private _editCommentAction = async (): Promise<void> => {
const comment = await showPromptDialog(this, {
private _editNoteAction = async (): Promise<void> => {
const note = await showPromptDialog(this, {
title: this.hass.localize(
`ui.panel.config.automation.editor.comment.${this.action.comment ? "edit" : "add"}`
`ui.panel.config.automation.editor.note.${this.action.note ? "edit" : "add"}`
),
inputLabel: this.hass.localize(
"ui.panel.config.automation.editor.comment.label"
"ui.panel.config.automation.editor.note.label"
),
inputType: "string",
defaultValue: this.action.comment,
defaultValue: this.action.note,
confirmText: this.hass.localize("ui.common.submit"),
multiline: true,
});
if (comment !== null) {
if (note !== null) {
const value = { ...this.action };
if (comment === "") {
delete value.comment;
if (note === "") {
delete value.note;
} else {
value.comment = comment;
value.note = note;
}
fireEvent(this, "value-changed", {
value,
@@ -1089,7 +1090,7 @@ export default class HaAutomationActionRow extends LitElement {
rename: () => {
this._renameAction();
},
editComment: this._editCommentAction,
editNote: this._editNoteAction,
toggleYamlMode: () => {
this._toggleYamlMode();
this.openSidebar();
@@ -1185,8 +1186,8 @@ export default class HaAutomationActionRow extends LitElement {
case "rename":
this._renameAction();
break;
case "edit_comment":
this._editCommentAction();
case "edit_note":
this._editNoteAction();
break;
case "duplicate":
this._duplicateAction();
@@ -95,7 +95,6 @@ class DialogAutomationMode extends LitElement implements HassDialog {
.value=${this._newMode}
@value-changed=${this._modeChanged}
.maxColumns=${1}
.hass=${this.hass}
></ha-select-box>
${isMaxMode(this._newMode)
@@ -123,7 +123,7 @@ export default class HaAutomationConditionEditor extends LitElement {
ev.stopPropagation();
const value = {
...(this.condition.alias ? { alias: this.condition.alias } : {}),
...(this.condition.comment ? { comment: this.condition.comment } : {}),
...(this.condition.note ? { note: this.condition.note } : {}),
...ev.detail.value,
};
fireEvent(this, "value-changed", { value });
@@ -1,7 +1,6 @@
import "@home-assistant/webawesome/dist/components/divider/divider";
import { consume } from "@lit/context";
import {
mdiAlert,
mdiAppleKeyboardCommand,
mdiArrowDown,
mdiArrowUp,
@@ -26,7 +25,7 @@ import type {
} from "home-assistant-js-websocket";
import { dump } from "js-yaml";
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { LitElement, html, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import memoizeOne from "memoize-one";
@@ -53,26 +52,18 @@ import type { HaDropdownSelectEvent } from "../../../../components/ha-dropdown";
import "../../../../components/ha-dropdown-item";
import "../../../../components/ha-expansion-panel";
import "../../../../components/ha-icon-button";
import "../../../../components/ha-tooltip";
import "../../../../components/ha-trigger-icon";
import type {
AutomationClipboard,
AutomationConfig,
Condition,
ConditionSidebarConfig,
PlatformCondition,
TriggerCondition,
} from "../../../../data/automation";
import {
automationConfigContext,
isCondition,
subscribeCondition,
testCondition,
} from "../../../../data/automation";
import {
describeCondition,
getTriggerInfos,
} from "../../../../data/automation_i18n";
import { describeCondition } from "../../../../data/automation_i18n";
import type { ConditionDescriptions } from "../../../../data/condition";
import { CONDITION_BUILDING_BLOCKS } from "../../../../data/condition";
import {
@@ -92,7 +83,6 @@ import type { HomeAssistant } from "../../../../types";
import { isMac } from "../../../../util/is_mac";
import { showEditorToast } from "../editor-toast";
import "../ha-automation-editor-warning";
import "../ha-trigger-id-chip";
import { overflowStyles, rowStyles } from "../styles";
import "../target/ha-automation-row-targets";
import "./ha-automation-condition-editor";
@@ -164,11 +154,10 @@ export default class HaAutomationConditionRow extends LitElement {
@state() private _selected = false;
@state() private _liveTestResult: LiveTestState = "unknown";
@state()
@consume({ context: automationConfigContext, subscribe: true })
private _automationConfig?: AutomationConfig;
@state() private _liveTestResult: {
state: LiveTestState;
message?: string;
} = { state: "unknown" };
@state()
@consume({ context: fullEntitiesContext, subscribe: true })
@@ -216,8 +205,8 @@ export default class HaAutomationConditionRow extends LitElement {
const conditionTargetSpec =
this.conditionDescriptions[this.condition.condition]?.target;
const commentTooltipText = truncateWithEllipsis(
this.condition.comment?.trim() || "",
const noteTooltipText = truncateWithEllipsis(
this.condition.note?.trim() || "",
250
);
@@ -228,13 +217,9 @@ export default class HaAutomationConditionRow extends LitElement {
.condition=${this.condition.condition}
></ha-condition-icon>
<h3 slot="header">
${this.condition.condition === "trigger"
? this._renderTriggerConditionDescription(
this.condition as TriggerCondition
)
: capitalizeFirstLetter(
describeCondition(this.condition, this.hass, this._entityReg)
)}
${capitalizeFirstLetter(
describeCondition(this.condition, this.hass, this._entityReg)
)}
${target !== undefined || (descriptionHasTarget && !this._isNew)
? this._renderTargets(
target,
@@ -242,19 +227,18 @@ export default class HaAutomationConditionRow extends LitElement {
conditionTargetSpec
)
: nothing}
${this.condition.comment?.trim()
${this.condition.note?.trim()
? html`
<ha-svg-icon
id="comment-icon"
id="note-icon"
tabindex="0"
.path=${mdiCommentTextOutline}
.label=${this.hass.localize(
"ui.panel.config.automation.editor.comment.label"
"ui.panel.config.automation.editor.note.label"
)}
class="comment-indicator"
class="note-indicator"
></ha-svg-icon>
<ha-tooltip for="comment-icon"
><p>${commentTooltipText}</p></ha-tooltip
>
<ha-tooltip for="note-icon"><p>${noteTooltipText}</p></ha-tooltip>
`
: nothing}
</h3>
@@ -304,11 +288,11 @@ export default class HaAutomationConditionRow extends LitElement {
)
)}
</ha-dropdown-item>
<ha-dropdown-item value="edit_comment">
<ha-dropdown-item value="edit_note">
<ha-svg-icon slot="icon" .path=${mdiCommentEditOutline}></ha-svg-icon>
${this._renderOverflowLabel(
this.hass.localize(
`ui.panel.config.automation.editor.comment.${this.condition.comment ? "edit" : "add"}`
`ui.panel.config.automation.editor.note.${this.condition.note ? "edit" : "add"}`
)
)}
</ha-dropdown-item>
@@ -549,11 +533,12 @@ export default class HaAutomationConditionRow extends LitElement {
<ha-automation-row-live-test
slot="icons"
.state=${this.condition.condition !== "trigger"
? this._liveTestResult
? this._liveTestResult.state
: "unknown"}
.label=${this.hass.localize(
`ui.panel.config.automation.editor.conditions.live_test_state.${this.condition.condition !== "trigger" ? this._liveTestResult : "unknown"}`
`ui.panel.config.automation.editor.conditions.live_test_state.${this.condition.condition !== "trigger" ? this._liveTestResult.state : "unknown"}`
)}
.message=${this._liveTestResult.message}
></ha-automation-row-live-test
></ha-automation-row>`
: html`
@@ -585,102 +570,6 @@ export default class HaAutomationConditionRow extends LitElement {
`;
}
private _getTriggerInfos = memoizeOne(getTriggerInfos);
private _renderTriggerConditionDescription(condition: TriggerCondition) {
const ids = ensureArray(condition.id ?? [])
.map((id) => (typeof id === "string" ? id : String(id)))
.filter((id) => id !== "");
const prefix = capitalizeFirstLetter(
this.hass
.localize(
"ui.panel.config.automation.editor.conditions.type.trigger.description.full",
{ id: "" }
)
.trim()
);
if (!ids.length) {
return html`${prefix}
<div class="trigger warning">
${this.hass.localize(
"ui.panel.config.automation.editor.conditions.type.trigger.description.no_trigger"
)}
</div>`;
}
const triggerInfos = this._getTriggerInfos(
ensureArray(this._automationConfig?.triggers || []),
this.hass,
this._entityReg
);
const infoById = new Map(triggerInfos.map((info) => [info.id, info]));
return html`${prefix}
${ids.map((id) => {
const info = infoById.get(id);
if (!info) {
return html`<div class="trigger">
<ha-trigger-id-chip id=${`trigger-${id}`} warning .triggerId=${id}>
<ha-svg-icon slot="start" .path=${mdiAlert}></ha-svg-icon>
</ha-trigger-id-chip>
${ids.length < 4
? html`<span
>${this.hass.localize("state.default.unavailable")}</span
>`
: nothing}
<ha-tooltip .for=${`trigger-${id}`}>
${ids.length >= 4
? html`<div>
${this.hass.localize("state.default.unavailable")}
</div>`
: nothing}
${this.hass.localize(
"ui.panel.config.automation.editor.conditions.type.trigger.unavailable_info",
{ id: html`<b>${id}</b>` }
)}
</ha-tooltip>
</div>`;
}
const triggerIcon = html`<ha-trigger-icon
.slot=${ids.length < 4 ? "start" : ""}
.hass=${this.hass}
.trigger=${info.triggerType}
></ha-trigger-icon>`;
const isDuplicateId = info.count > 1;
return html`
<div class="trigger">
${ids.length < 4 ? triggerIcon : nothing}
<ha-trigger-id-chip
id=${`trigger-${id}`}
.triggerId=${id}
.warning=${isDuplicateId}
>
${isDuplicateId
? html`<ha-svg-icon slot="start" .path=${mdiAlert}></ha-svg-icon>`
: nothing}
</ha-trigger-id-chip>
${ids.length < 4
? html`<span>${info.label}</span>`
: html`<ha-tooltip .for=${`trigger-${id}`}></ha-tooltip>`}
${isDuplicateId || ids.length >= 4
? html`<ha-tooltip .for=${`trigger-${id}`}>
${ids.length >= 4
? html`<div>${triggerIcon}${info.label}</div>`
: nothing}
${isDuplicateId
? this.hass.localize(
"ui.panel.config.automation.editor.triggers.duplicate_id_warning"
)
: nothing}
</ha-tooltip>`
: nothing}
</div>
`;
})}`;
}
private _renderTargets = memoizeOne(
(
target?: HassServiceTarget,
@@ -736,7 +625,12 @@ export default class HaAutomationConditionRow extends LitElement {
}
private _resetSubscription() {
this._liveTestResult = "unknown";
this._liveTestResult = {
state: "unknown",
message: this.hass.localize(
"ui.panel.config.automation.editor.conditions.live_test_state.unknown"
),
};
if (this._conditionUnsub) {
this._conditionUnsub.then((unsub) => unsub());
this._conditionUnsub = undefined;
@@ -761,7 +655,12 @@ export default class HaAutomationConditionRow extends LitElement {
if (result.error) {
this._handleLiveTestError(result.error);
} else {
this._liveTestResult = result.result ? "pass" : "fail";
this._liveTestResult = {
state: result.result ? "pass" : "fail",
message: this.hass.localize(
`ui.panel.config.automation.editor.conditions.testing_${result.result ? "pass" : "error"}`
),
};
}
},
this.condition
@@ -778,7 +677,12 @@ export default class HaAutomationConditionRow extends LitElement {
private _handleLiveTestError(error: any) {
const invalid =
typeof error !== "string" && error.code === "invalid_format";
this._liveTestResult = invalid ? "invalid" : "unknown";
this._liveTestResult = {
state: invalid ? "invalid" : "unknown",
message: this.hass.localize(
`ui.panel.config.automation.editor.conditions.${invalid ? "invalid_condition" : "live_test_state.unknown"}`
),
};
}
private _onValueChange(event: CustomEvent) {
@@ -945,25 +849,25 @@ export default class HaAutomationConditionRow extends LitElement {
}
};
private _editCommentCondition = async (): Promise<void> => {
const comment = await showPromptDialog(this, {
private _editNoteCondition = async (): Promise<void> => {
const note = await showPromptDialog(this, {
title: this.hass.localize(
`ui.panel.config.automation.editor.comment.${this.condition.comment ? "edit" : "add"}`
`ui.panel.config.automation.editor.note.${this.condition.note ? "edit" : "add"}`
),
inputLabel: this.hass.localize(
"ui.panel.config.automation.editor.comment.label"
"ui.panel.config.automation.editor.note.label"
),
inputType: "string",
defaultValue: this.condition.comment,
defaultValue: this.condition.note,
confirmText: this.hass.localize("ui.common.submit"),
multiline: true,
});
if (comment !== null) {
if (note !== null) {
const value = { ...this.condition };
if (comment === "") {
delete value.comment;
if (note === "") {
delete value.note;
} else {
value.comment = comment;
value.note = note;
}
fireEvent(this, "value-changed", {
value,
@@ -1118,7 +1022,7 @@ export default class HaAutomationConditionRow extends LitElement {
rename: () => {
this._renameCondition();
},
editComment: this._editCommentCondition,
editNote: this._editNoteCondition,
toggleYamlMode: () => {
this._toggleYamlMode();
this.openSidebar();
@@ -1190,8 +1094,8 @@ export default class HaAutomationConditionRow extends LitElement {
case "rename":
this._renameCondition();
break;
case "edit_comment":
this._editCommentCondition();
case "edit_note":
this._editNoteCondition();
break;
case "duplicate":
this._duplicateCondition();
@@ -1224,26 +1128,7 @@ export default class HaAutomationConditionRow extends LitElement {
}
static get styles(): CSSResultGroup {
return [
rowStyles,
overflowStyles,
css`
.trigger {
display: flex;
align-items: center;
gap: var(--ha-space-2);
background-color: var(--ha-color-fill-neutral-normal-resting);
border-radius: var(--ha-border-radius-md);
padding-inline: var(--ha-space-2);
color: var(--ha-color-on-neutral-normal);
height: 32px;
}
.trigger.warning {
background-color: var(--ha-color-fill-warning-normal-resting);
color: var(--ha-color-on-warning-normal);
}
`,
];
return [rowStyles, overflowStyles];
}
}
@@ -22,7 +22,7 @@ import type { HomeAssistant } from "../../../../../types";
const numericStateConditionStruct = object({
alias: optional(string()),
comment: optional(string()),
note: optional(string()),
condition: literal("numeric_state"),
entity_id: optional(string()),
attribute: optional(string()),
@@ -25,7 +25,7 @@ import type { ConditionElement } from "../ha-automation-condition-row";
const stateConditionStruct = object({
alias: optional(string()),
comment: optional(string()),
note: optional(string()),
condition: literal("state"),
entity_id: optional(string()),
attribute: optional(string()),
@@ -1,31 +1,26 @@
import { consume } from "@lit/context";
import { mdiAlert } from "@mdi/js";
import { css, html, LitElement, nothing } from "lit";
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import { html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { ensureArray } from "../../../../../common/array/ensure-array";
import { fireEvent } from "../../../../../common/dom/fire_event";
import "../../../../../components/ha-alert";
import "../../../../../components/ha-form/ha-form";
import type { SchemaUnion } from "../../../../../components/ha-form/types";
import "../../../../../components/ha-select";
import "../../../../../components/item/ha-list-item-option";
import type { HaListItemOption } from "../../../../../components/item/ha-list-item-option";
import "../../../../../components/list/ha-list-selectable";
import type { HaListSelectable } from "../../../../../components/list/ha-list-selectable";
import type { HaListSelectedDetail } from "../../../../../components/list/types";
import {
automationConfigContext,
flattenTriggers,
type AutomationConfig,
type Trigger,
type TriggerCondition,
} from "../../../../../data/automation";
import {
getTriggerInfos,
type TriggerInfo,
} from "../../../../../data/automation_i18n";
import { fullEntitiesContext } from "../../../../../data/context";
import type { EntityRegistryEntry } from "../../../../../data/entity/entity_registry";
import type { HomeAssistant } from "../../../../../types";
import "../../ha-trigger-id-chip";
const getTriggersIds = (triggers: Trigger[]): string[] => {
const triggerIds = flattenTriggers(triggers)
.map((t) => ("id" in t ? t.id : undefined))
.filter(Boolean) as string[];
return Array.from(new Set(triggerIds));
};
@customElement("ha-automation-condition-trigger")
export class HaTriggerCondition extends LitElement {
@@ -35,25 +30,9 @@ export class HaTriggerCondition extends LitElement {
@property({ type: Boolean }) public disabled = false;
@state()
@consume({ context: automationConfigContext, subscribe: true })
private _automationConfig?: AutomationConfig;
@state() private _triggerIds: string[] = [];
@state()
@consume({ context: fullEntitiesContext, subscribe: true })
private _entityReg: EntityRegistryEntry[] = [];
private _triggerInfos = memoizeOne(
(
triggers: AutomationConfig["triggers"] | undefined,
entityReg: EntityRegistryEntry[]
): TriggerInfo[] =>
getTriggerInfos(
triggers ? ensureArray(triggers) : undefined,
this.hass,
entityReg
)
);
private _unsub?: UnsubscribeFunc;
public static get defaultConfig(): TriggerCondition {
return {
@@ -62,146 +41,89 @@ export class HaTriggerCondition extends LitElement {
};
}
private _schema = memoizeOne(
(triggerIds: string[]) =>
[
{
name: "id",
selector: {
select: {
multiple: true,
options: triggerIds,
},
},
required: true,
},
] as const
);
connectedCallback() {
super.connectedCallback();
const details = { callback: (config) => this._automationUpdated(config) };
fireEvent(this, "subscribe-automation-config", details);
this._unsub = (details as any).unsub;
}
disconnectedCallback() {
super.disconnectedCallback();
if (this._unsub) {
this._unsub();
}
}
protected render() {
const selectedIds = ensureArray(this.condition.id || []).filter(
(id): id is string => typeof id === "string" && id !== ""
);
const triggerInfos = this._triggerInfos(
this._automationConfig?.triggers,
this._entityReg
);
if (!triggerInfos.length && !selectedIds.length) {
return html`
<ha-alert alert-type="info">
${this.hass.localize(
"ui.panel.config.automation.editor.conditions.type.trigger.no_triggers"
)}
</ha-alert>
`;
if (!this._triggerIds.length) {
return this.hass.localize(
"ui.panel.config.automation.editor.conditions.type.trigger.no_triggers"
);
}
const schema = this._schema(this._triggerIds);
return html`
<ha-list-selectable @ha-list-selected=${this._valueChanged} multi>
${this._renderOptions(selectedIds, triggerInfos)}
</ha-list-selectable>
<ha-form
.schema=${schema}
.data=${this.condition}
.hass=${this.hass}
.disabled=${this.disabled}
.computeLabel=${this._computeLabelCallback}
@value-changed=${this._valueChanged}
></ha-form>
`;
}
private _renderOptions(selectedIds: string[], triggerInfos: TriggerInfo[]) {
const unknownTriggerIds = selectedIds.filter(
(id) => !triggerInfos.some((info) => info.id === id)
private _computeLabelCallback = (
schema: SchemaUnion<ReturnType<typeof this._schema>>
): string =>
this.hass.localize(
`ui.panel.config.automation.editor.conditions.type.trigger.${schema.name}`
);
const alertIcon = html`<ha-svg-icon
slot="start"
.path=${mdiAlert}
></ha-svg-icon>`;
return html`
${unknownTriggerIds.map(
(id) => html`
<ha-list-item-option
.value=${id}
.selected=${true}
appearance="checkbox"
>
<div class="option" slot="headline">
<ha-trigger-id-chip
id=${`trigger-${id}`}
warning
.triggerId=${id}
>
${alertIcon}
</ha-trigger-id-chip>
${this.hass.localize("state.default.unavailable")}
<ha-tooltip .for=${`trigger-${id}`}>
${this.hass.localize(
"ui.panel.config.automation.editor.conditions.type.trigger.unavailable_info",
{ id: html`<b>${id}</b>` }
)}
</ha-tooltip>
</div>
</ha-list-item-option>
`
)}
${triggerInfos.map(
(info) => html`
<ha-list-item-option
.value=${info.id}
.selected=${selectedIds.includes(info.id)}
appearance="checkbox"
>
<div class="option" slot="headline">
<ha-trigger-id-chip
id=${`trigger-${info.id}`}
.warning=${info.count > 1}
.triggerId=${info.id}
>
${info.count > 1 ? alertIcon : nothing}
</ha-trigger-id-chip>
${info.label}${info.count > 1
? html`<ha-tooltip .for=${`trigger-${info.id}`}
>${this.hass.localize(
"ui.panel.config.automation.editor.conditions.type.trigger.duplicated_info"
)}</ha-tooltip
>`
: nothing}
</div>
</ha-list-item-option>
`
)}
`;
private _automationUpdated(config?: AutomationConfig) {
this._triggerIds = config?.triggers
? getTriggersIds(ensureArray(config.triggers))
: [];
}
private _valueChanged(ev: CustomEvent<HaListSelectedDetail>): void {
private _valueChanged(ev: CustomEvent): void {
ev.stopPropagation();
if (
!ev.detail.diff ||
(!ev.detail.diff?.added.size && !ev.detail.diff?.removed.size)
) {
return;
}
const newValue = ev.detail.value;
const ids = ensureArray(this.condition.id || []);
const valueSet = ev.detail.diff.added.size
? ev.detail.diff.added
: ev.detail.diff.removed;
const index = valueSet.values().next().value;
if (index === undefined) {
return;
}
const triggerId = (
(ev.currentTarget as HaListSelectable).items[index] as HaListItemOption
).value;
if (triggerId === undefined || triggerId === "") {
return;
}
if (ev.detail.diff.added.size) {
ids.push(triggerId);
} else {
const removeIndex = ids.indexOf(triggerId);
if (removeIndex > -1) {
ids.splice(removeIndex, 1);
if (typeof newValue.id === "string") {
if (!this._triggerIds.some((id) => id === newValue.id)) {
newValue.id = "";
}
} else if (Array.isArray(newValue.id)) {
newValue.id = newValue.id.filter((_id) =>
this._triggerIds.some((id) => id === _id)
);
if (!newValue.id.length) {
newValue.id = "";
}
}
fireEvent(this, "value-changed", { value: { ...this.condition, id: ids } });
fireEvent(this, "value-changed", { value: newValue });
}
static styles = css`
.option {
display: flex;
align-items: center;
gap: var(--ha-space-1);
color: var(--ha-color-on-neutral-normal);
}
`;
}
declare global {
@@ -1,5 +1,4 @@
import "@home-assistant/webawesome/dist/components/divider/divider";
import { provide } from "@lit/context";
import {
mdiAppleKeyboardCommand,
mdiCog,
@@ -21,9 +20,10 @@ import {
mdiTransitConnection,
mdiUndo,
} from "@mdi/js";
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import { customElement, property, query } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { UndoRedoController } from "../../../common/controllers/undo-redo-controller";
import { fireEvent } from "../../../common/dom/fire_event";
@@ -31,7 +31,6 @@ import { goBack, navigate } from "../../../common/navigate";
import { promiseTimeout } from "../../../common/util/promise-timeout";
import "../../../components/ha-button";
import "../../../components/ha-dropdown";
import type { HaDropdownSelectEvent } from "../../../components/ha-dropdown";
import "../../../components/ha-dropdown-item";
import "../../../components/ha-icon";
import "../../../components/ha-icon-button";
@@ -46,7 +45,6 @@ import type {
Trigger,
} from "../../../data/automation";
import {
automationConfigContext,
deleteAutomation,
fetchAutomationFileConfig,
getAutomationEditorInitData,
@@ -74,12 +72,13 @@ 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 { 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";
import { showAutomationSaveTimeoutDialog } from "./automation-save-timeout-dialog/show-dialog-automation-save-timeout";
import { ADD_AUTOMATION_ELEMENT_QUERY_PARAM } from "./show-add-automation-element-dialog";
import "./blueprint-automation-editor";
import { showEditorToast } from "./editor-toast";
import type { EditorDomainHooks } from "./ha-automation-script-editor-mixin";
import {
AutomationScriptEditorMixin,
@@ -87,7 +86,7 @@ import {
} from "./ha-automation-script-editor-mixin";
import "./manual-automation-editor";
import type { HaManualAutomationEditor } from "./manual-automation-editor";
import { ADD_AUTOMATION_ELEMENT_QUERY_PARAM } from "./show-add-automation-element-dialog";
import type { HaDropdownSelectEvent } from "../../../components/ha-dropdown";
declare global {
interface HTMLElementTagNameMap {
@@ -95,6 +94,10 @@ declare global {
}
// for fire event
interface HASSDomEvents {
"subscribe-automation-config": {
callback: (config: AutomationConfig) => void;
unsub?: UnsubscribeFunc;
};
"ui-mode-not-available": Error;
"move-down": undefined;
"move-up": undefined;
@@ -122,9 +125,12 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
@query("ha-yaml-editor") private _yamlEditor?: HaYamlEditor;
@provide({ context: automationConfigContext })
@state()
protected config?: AutomationConfig;
private _configSubscriptions: Record<
string,
(config?: AutomationConfig) => void
> = {};
private _configSubscriptionsId = 1;
private _newAutomationId?: string;
@@ -398,7 +404,10 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
</ha-svg-icon>
</ha-dropdown-item>
</ha-dropdown>
<div class=${this.mode === "yaml" ? "yaml-mode" : ""}>
<div
class=${this.mode === "yaml" ? "yaml-mode" : ""}
@subscribe-automation-config=${this._subscribeAutomationConfig}
>
${this.mode === "gui"
? html`
<div>
@@ -629,6 +638,12 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
) {
this._setEntityId();
}
if (changedProps.has("config")) {
Object.values(this._configSubscriptions).forEach((sub) =>
sub(this.config)
);
}
}
private _setEntityId() {
@@ -1006,6 +1021,15 @@ export class HaAutomationEditor extends AutomationScriptEditorMixin<AutomationCo
}
}
private _subscribeAutomationConfig(ev) {
const id = this._configSubscriptionsId++;
this._configSubscriptions[id] = ev.detail.callback;
ev.detail.unsub = () => {
delete this._configSubscriptions[id];
};
ev.detail.callback(this.config);
}
protected supportedShortcuts(): SupportedShortcuts {
return {
s: () => this._handleSaveAutomation(),
@@ -6,9 +6,9 @@ import "../../../components/ha-button";
import "../../../components/ha-settings-row";
import { internationalizationContext } from "../../../data/context";
@customElement("ha-automation-comment")
export class HaAutomationComment extends LitElement {
@property() public comment!: string;
@customElement("ha-automation-note")
export class HaAutomationNote extends LitElement {
@property() public note!: string;
@state()
@consume({ context: internationalizationContext, subscribe: true })
@@ -18,9 +18,9 @@ export class HaAutomationComment extends LitElement {
return html`
<ha-settings-row narrow>
<div class="heading" slot="heading">
<span class="title" id="comment-label">
<span class="title" id="note-label">
${this._i18n.localize(
"ui.panel.config.automation.editor.comment.label"
"ui.panel.config.automation.editor.note.label"
)}
</span>
<ha-button
@@ -31,13 +31,13 @@ export class HaAutomationComment extends LitElement {
${this._i18n.localize("ui.common.edit")}
</ha-button>
</div>
<p aria-labelledby="comment-label">${this.comment}</p>
<p aria-labelledby="note-label">${this.note}</p>
</ha-settings-row>
`;
}
private _handleClick() {
fireEvent(this, "edit-comment");
fireEvent(this, "edit-note");
}
static styles = css`
@@ -70,10 +70,10 @@ export class HaAutomationComment extends LitElement {
declare global {
interface HTMLElementTagNameMap {
"ha-automation-comment": HaAutomationComment;
"ha-automation-note": HaAutomationNote;
}
interface HASSDomEvents {
"edit-comment": undefined;
"edit-note": undefined;
}
}
@@ -27,8 +27,6 @@ class HaConfigAutomation extends HassRouterPage {
@property({ attribute: "is-wide", type: Boolean }) public isWide = false;
@property({ attribute: false }) public showAdvanced = false;
@property({ attribute: false }) public cloudStatus?: CloudStatus;
@property({ attribute: false }) public automations: AutomationEntity[] = [];
@@ -79,7 +77,6 @@ class HaConfigAutomation extends HassRouterPage {
pageEl.narrow = this.narrow;
pageEl.isWide = this.isWide;
pageEl.route = this.routeTail;
pageEl.showAdvanced = this.showAdvanced;
pageEl.cloudStatus = this.cloudStatus;
if (this.hass) {
@@ -1,62 +0,0 @@
import { mdiPound } from "@mdi/js";
import { css, html, LitElement } from "lit";
import { customElement, property } from "lit/decorators";
import "../../../components/ha-svg-icon";
/**
* Home Assistant trigger ID chip component
*
* @element ha-trigger-id-chip
* @extends {LitElement}
*
* @summary
* A small chip that displays an automation trigger ID prefixed with a hash icon.
*
* @slot start - Optional content rendered before the hash icon (usually an icon).
*
* @attr {string} trigger-id - The trigger ID to display.
* @attr {boolean} warning - Renders the chip with warning colors.
*/
@customElement("ha-trigger-id-chip")
export class HaTriggerIdChip extends LitElement {
@property({ attribute: "trigger-id" }) public triggerId!: string;
@property({ type: Boolean, reflect: true }) public warning = false;
protected render() {
return html`
<slot name="start"></slot>
<ha-svg-icon .path=${mdiPound}></ha-svg-icon>
<span>${this.triggerId}</span>
`;
}
static styles = css`
:host {
background-color: var(--card-background-color);
border-radius: var(--ha-border-radius-sm);
border: var(--ha-border-width-sm) solid
var(--ha-color-border-neutral-normal);
--mdc-icon-size: 16px;
display: inline-flex;
gap: var(--ha-space-1);
align-items: center;
color: var(--ha-color-on-neutral-normal);
padding: 0 var(--ha-space-1);
font-weight: var(--ha-font-weight-medium);
line-height: 20px;
height: 20px;
}
:host([warning]) {
border-color: var(--ha-color-border-warning-normal);
color: var(--ha-color-on-warning-normal);
background-color: var(--ha-color-fill-warning-quiet-resting);
}
`;
}
declare global {
interface HTMLElementTagNameMap {
"ha-trigger-id-chip": HaTriggerIdChip;
}
}
@@ -32,11 +32,11 @@ import {
normalizeAutomationConfig,
} from "../../../data/automation";
import { getActionType, type Action } from "../../../data/script";
import { showEditorToast } from "./editor-toast";
import "./action/ha-automation-action";
import type HaAutomationAction from "./action/ha-automation-action";
import "./condition/ha-automation-condition";
import type HaAutomationCondition from "./condition/ha-automation-condition";
import { showEditorToast } from "./editor-toast";
import { ManualEditorMixin } from "./ha-manual-editor-mixin";
import { showPasteReplaceDialog } from "./paste-replace-dialog/show-dialog-paste-replace";
import { manualEditorStyles, saveFabStyles } from "./styles";
@@ -142,8 +142,8 @@ export default class HaAutomationOptionRow extends LitElement {
`;
}
private _renderRow() {
const commentTooltipText = truncateWithEllipsis(
this.option?.comment?.trim() || "",
const noteTooltipText = truncateWithEllipsis(
this.option?.note?.trim() || "",
250
);
@@ -157,19 +157,18 @@ export default class HaAutomationOptionRow extends LitElement {
: this.hass.localize(
"ui.panel.config.automation.editor.actions.type.choose.default"
)}
${this.option?.comment?.trim()
${this.option?.note?.trim()
? html`
<ha-svg-icon
id="comment-icon"
id="note-icon"
tabindex="0"
.path=${mdiCommentTextOutline}
.label=${this.hass.localize(
"ui.panel.config.automation.editor.comment.label"
"ui.panel.config.automation.editor.note.label"
)}
class="comment-indicator"
class="note-indicator"
></ha-svg-icon>
<ha-tooltip for="comment-icon"
><p>${commentTooltipText}</p></ha-tooltip
>
<ha-tooltip for="note-icon"><p>${noteTooltipText}</p></ha-tooltip>
`
: nothing}
</h3>
@@ -199,14 +198,14 @@ export default class HaAutomationOptionRow extends LitElement {
)
)}
</ha-dropdown-item>
<ha-dropdown-item value="edit_comment">
<ha-dropdown-item value="edit_note">
<ha-svg-icon
slot="icon"
.path=${mdiCommentEditOutline}
></ha-svg-icon>
${this._renderOverflowLabel(
this.hass.localize(
`ui.panel.config.automation.editor.comment.${this.option?.comment ? "edit" : "add"}`
`ui.panel.config.automation.editor.note.${this.option?.note ? "edit" : "add"}`
)
)}
</ha-dropdown-item>
@@ -394,8 +393,8 @@ export default class HaAutomationOptionRow extends LitElement {
case "rename":
this._renameOption();
break;
case "edit_comment":
this._editCommentOption();
case "edit_note":
this._editNoteOption();
break;
case "delete":
this._removeOption();
@@ -460,28 +459,28 @@ export default class HaAutomationOptionRow extends LitElement {
}
};
private _editCommentOption = async (): Promise<void> => {
private _editNoteOption = async (): Promise<void> => {
if (!this.option) {
return;
}
const comment = await showPromptDialog(this, {
const note = await showPromptDialog(this, {
title: this.hass.localize(
`ui.panel.config.automation.editor.comment.${this.option.comment ? "edit" : "add"}`
`ui.panel.config.automation.editor.note.${this.option.note ? "edit" : "add"}`
),
inputLabel: this.hass.localize(
"ui.panel.config.automation.editor.comment.label"
"ui.panel.config.automation.editor.note.label"
),
inputType: "string",
defaultValue: this.option.comment,
defaultValue: this.option.note,
confirmText: this.hass.localize("ui.common.submit"),
multiline: true,
});
if (comment !== null) {
if (note !== null) {
const value: Option = { ...this.option };
if (comment === "") {
delete value.comment;
if (note === "") {
delete value.note;
} else {
value.comment = comment;
value.note = note;
}
fireEvent(this, "value-changed", {
value,
@@ -537,11 +536,11 @@ export default class HaAutomationOptionRow extends LitElement {
rename: () => {
this._renameOption();
},
editComment: this._editCommentOption,
editNote: this._editNoteOption,
delete: this._removeOption,
duplicate: this._duplicateOption,
defaultOption: !!this.defaultActions,
comment: sidebarOption?.comment,
note: sidebarOption?.note,
} satisfies OptionSidebarConfig);
this._selected = true;
this._collapsed = false;
@@ -39,7 +39,7 @@ import { isMac } from "../../../../util/is_mac";
import type HaAutomationConditionEditor from "../action/ha-automation-action-editor";
import { getAutomationActionType } from "../action/ha-automation-action-row";
import { getRepeatType } from "../action/types/ha-automation-action-repeat";
import "../ha-automation-comment";
import "../ha-automation-note";
import { overflowStyles, sidebarEditorStyles } from "../styles";
import "./ha-automation-sidebar-card";
@@ -177,11 +177,11 @@ export default class HaAutomationSidebarAction extends LitElement {
<span class="shortcut-placeholder ${isMac ? "mac" : ""}"></span>
</div>
</ha-dropdown-item>
<ha-dropdown-item slot="menu-items" value="edit_comment">
<ha-dropdown-item slot="menu-items" value="edit_note">
<ha-svg-icon slot="icon" .path=${mdiCommentEditOutline}></ha-svg-icon>
<div class="overflow-label">
${this.hass.localize(
`ui.panel.config.automation.editor.comment.${this.config.config.action.comment ? "edit" : "add"}`
`ui.panel.config.automation.editor.note.${this.config.config.action.note ? "edit" : "add"}`
)}
<span class="shortcut-placeholder ${isMac ? "mac" : ""}"></span>
</div>
@@ -388,11 +388,11 @@ export default class HaAutomationSidebarAction extends LitElement {
@ui-mode-not-available=${this._handleUiModeNotAvailable}
></ha-automation-action-editor>`
)}
${this.config.config.action.comment?.trim() && !this.yamlMode
? html`<ha-automation-comment
@edit-comment=${this.config.editComment}
.comment=${this.config.config.action.comment}
></ha-automation-comment>`
${this.config.config.action.note?.trim() && !this.yamlMode
? html`<ha-automation-note
@edit-note=${this.config.editNote}
.note=${this.config.config.action.note}
></ha-automation-note>`
: nothing}
</ha-automation-sidebar-card>`;
}
@@ -442,8 +442,8 @@ export default class HaAutomationSidebarAction extends LitElement {
case "rename":
this.config.rename();
break;
case "edit_comment":
this.config.editComment();
case "edit_note":
this.config.editNote();
break;
case "run":
this.config.run();
@@ -35,7 +35,7 @@ import type { HomeAssistant } from "../../../../types";
import { isMac } from "../../../../util/is_mac";
import "../condition/ha-automation-condition-editor";
import type HaAutomationConditionEditor from "../condition/ha-automation-condition-editor";
import "../ha-automation-comment";
import "../ha-automation-note";
import { overflowStyles, sidebarEditorStyles } from "../styles";
import "./ha-automation-sidebar-card";
@@ -153,13 +153,13 @@ export default class HaAutomationSidebarCondition extends LitElement {
</ha-dropdown-item>
<ha-dropdown-item
slot="menu-items"
value="edit_comment"
value="edit_note"
.disabled=${this.disabled}
>
<ha-svg-icon slot="icon" .path=${mdiCommentEditOutline}></ha-svg-icon>
<div class="overflow-label">
${this.hass.localize(
`ui.panel.config.automation.editor.comment.${this.config.config.comment ? "edit" : "add"}`
`ui.panel.config.automation.editor.note.${this.config.config.note ? "edit" : "add"}`
)}
<span class="shortcut-placeholder ${isMac ? "mac" : ""}"></span>
</div>
@@ -347,11 +347,11 @@ export default class HaAutomationSidebarCondition extends LitElement {
sidebar
></ha-automation-condition-editor>`
)}
${this.config.config.comment?.trim() && !this.yamlMode
? html`<ha-automation-comment
@edit-comment=${this.config.editComment}
.comment=${this.config.config.comment}
></ha-automation-comment>`
${this.config.config.note?.trim() && !this.yamlMode
? html`<ha-automation-note
@edit-note=${this.config.editNote}
.note=${this.config.config.note}
></ha-automation-note>`
: nothing}
<div class="testing-wrapper">
<div
@@ -417,8 +417,8 @@ export default class HaAutomationSidebarCondition extends LitElement {
case "rename":
this.config.rename();
break;
case "edit_comment":
this.config.editComment();
case "edit_note":
this.config.editNote();
break;
case "test":
this.config.test();
@@ -9,16 +9,16 @@ import {
import { html, LitElement, nothing } from "lit";
import { customElement, property, query } from "lit/decorators";
import type { HaDropdownSelectEvent } from "../../../../components/ha-dropdown";
import "../../../../components/ha-dropdown-item";
import "../../../../components/ha-svg-icon";
import type { OptionSidebarConfig } from "../../../../data/automation";
import type { HomeAssistant } from "../../../../types";
import { isMac } from "../../../../util/is_mac";
import type HaAutomationConditionEditor from "../action/ha-automation-action-editor";
import "../ha-automation-comment";
import "../ha-automation-note";
import { overflowStyles, sidebarEditorStyles } from "../styles";
import "./ha-automation-sidebar-card";
import type { HaDropdownSelectEvent } from "../../../../components/ha-dropdown";
@customElement("ha-automation-sidebar-option")
export default class HaAutomationSidebarOption extends LitElement {
@@ -76,7 +76,7 @@ export default class HaAutomationSidebarOption extends LitElement {
</ha-dropdown-item>
<ha-dropdown-item
slot="menu-items"
value="edit_comment"
value="edit_note"
.disabled=${!!disabled}
>
<ha-svg-icon
@@ -85,7 +85,7 @@ export default class HaAutomationSidebarOption extends LitElement {
></ha-svg-icon>
<div class="overflow-label">
${this.hass.localize(
`ui.panel.config.automation.editor.comment.${this.config.comment ? "edit" : "add"}`
`ui.panel.config.automation.editor.note.${this.config.note ? "edit" : "add"}`
)}
<span class="shortcut-placeholder ${isMac ? "mac" : ""}"></span>
</div>
@@ -144,11 +144,11 @@ export default class HaAutomationSidebarOption extends LitElement {
`}
<div class="description">${description}</div>
${!this.config.defaultOption && this.config.comment?.trim()
? html`<ha-automation-comment
@edit-comment=${this.config.editComment}
.comment=${this.config.comment}
></ha-automation-comment>`
${!this.config.defaultOption && this.config.note?.trim()
? html`<ha-automation-note
@edit-note=${this.config.editNote}
.note=${this.config.note}
></ha-automation-note>`
: nothing}
</ha-automation-sidebar-card>`;
}
@@ -164,8 +164,8 @@ export default class HaAutomationSidebarOption extends LitElement {
case "rename":
this.config.rename();
break;
case "edit_comment":
this.config.editComment();
case "edit_note":
this.config.editNote();
break;
case "duplicate":
this.config.duplicate();
@@ -16,7 +16,7 @@ import type { HomeAssistant } from "../../../../types";
import { isMac } from "../../../../util/is_mac";
import "../../script/ha-script-field-editor";
import type HaAutomationConditionEditor from "../action/ha-automation-action-editor";
import "../ha-automation-comment";
import "../ha-automation-note";
import { overflowStyles, sidebarEditorStyles } from "../styles";
import "./ha-automation-sidebar-card";
@@ -68,11 +68,11 @@ export default class HaAutomationSidebarScriptField extends LitElement {
@wa-select=${this._handleDropdownSelect}
>
<span slot="title">${title}</span>
<ha-dropdown-item slot="menu-items" value="edit_comment">
<ha-dropdown-item slot="menu-items" value="edit_note">
<ha-svg-icon slot="icon" .path=${mdiCommentEditOutline}></ha-svg-icon>
<div class="overflow-label">
${this.hass.localize(
`ui.panel.config.automation.editor.comment.${this.config.config.field.description ? "edit" : "add"}`
`ui.panel.config.automation.editor.note.${this.config.config.field.description ? "edit" : "add"}`
)}
<span class="shortcut-placeholder ${isMac ? "mac" : ""}"></span>
</div>
@@ -137,10 +137,10 @@ export default class HaAutomationSidebarScriptField extends LitElement {
></ha-script-field-editor>`
)}
${this.config.config.field.description?.trim() && !this.yamlMode
? html`<ha-automation-comment
@edit-comment=${this.config.editComment}
.comment=${this.config.config.field.description}
></ha-automation-comment>`
? html`<ha-automation-note
@edit-note=${this.config.editNote}
.note=${this.config.config.field.description}
></ha-automation-note>`
: nothing}
</ha-automation-sidebar-card>`;
}
@@ -189,8 +189,8 @@ export default class HaAutomationSidebarScriptField extends LitElement {
case "toggle_yaml_mode":
this._toggleYamlMode();
break;
case "edit_comment":
this.config.editComment();
case "edit_note":
this.config.editNote();
break;
case "delete":
this.config.delete();
@@ -34,7 +34,7 @@ import {
} from "../../../../data/trigger";
import type { HomeAssistant } from "../../../../types";
import { isMac } from "../../../../util/is_mac";
import "../ha-automation-comment";
import "../ha-automation-note";
import { overflowStyles, sidebarEditorStyles } from "../styles";
import "../trigger/ha-automation-trigger-editor";
import type HaAutomationTriggerEditor from "../trigger/ha-automation-trigger-editor";
@@ -132,7 +132,7 @@ export default class HaAutomationSidebarTrigger extends LitElement {
${type !== "list"
? html`<ha-dropdown-item
slot="menu-items"
value="edit_comment"
value="edit_note"
.disabled=${this.disabled}
>
<ha-svg-icon
@@ -141,7 +141,7 @@ export default class HaAutomationSidebarTrigger extends LitElement {
></ha-svg-icon>
<div class="overflow-label">
${this.hass.localize(
`ui.panel.config.automation.editor.comment.${(this.config.config as Exclude<Trigger, TriggerList>).comment ? "edit" : "add"}`
`ui.panel.config.automation.editor.note.${(this.config.config as Exclude<Trigger, TriggerList>).note ? "edit" : "add"}`
)}
<span class="shortcut-placeholder ${isMac ? "mac" : ""}"></span>
</div>
@@ -343,12 +343,12 @@ export default class HaAutomationSidebarTrigger extends LitElement {
></ha-automation-trigger-editor>`
)}
${!isTriggerList(this.config.config) &&
this.config.config.comment?.trim() &&
this.config.config.note?.trim() &&
!this.yamlMode
? html`<ha-automation-comment
@edit-comment=${this.config.editComment}
.comment=${this.config.config.comment}
></ha-automation-comment>`
? html`<ha-automation-note
@edit-note=${this.config.editNote}
.note=${this.config.config.note}
></ha-automation-note>`
: nothing}
</ha-automation-sidebar-card>
`;
@@ -401,8 +401,8 @@ export default class HaAutomationSidebarTrigger extends LitElement {
case "rename":
this.config.rename();
break;
case "edit_comment":
this.config.editComment();
case "edit_note":
this.config.editNote();
break;
case "show_id":
this._showTriggerId();
+1 -1
View File
@@ -4,7 +4,7 @@ export const baseTriggerStruct = object({
trigger: string(),
id: optional(string()),
enabled: optional(boolean()),
comment: optional(string()),
note: optional(string()),
});
export const forDictStruct = object({
+3 -3
View File
@@ -53,14 +53,14 @@ export const rowStyles = css`
position: absolute;
}
.comment-indicator {
.note-indicator {
color: var(--ha-color-on-neutral-normal);
}
.comment-indicator + ha-tooltip::part(body) {
.note-indicator + ha-tooltip::part(body) {
cursor: default;
max-width: 300px;
}
.comment-indicator + ha-tooltip p {
.note-indicator + ha-tooltip p {
white-space: pre-wrap;
margin: 0;
}
@@ -141,7 +141,7 @@ export default class HaAutomationTriggerEditor extends LitElement {
ev.stopPropagation();
const value = {
...(this.trigger.alias ? { alias: this.trigger.alias } : {}),
...(this.trigger.comment ? { comment: this.trigger.comment } : {}),
...(this.trigger.note ? { note: this.trigger.note } : {}),
...ev.detail.value,
};
fireEvent(this, "value-changed", { value });
@@ -1,7 +1,6 @@
import "@home-assistant/webawesome/dist/components/divider/divider";
import { consume } from "@lit/context";
import {
mdiAlert,
mdiAppleKeyboardCommand,
mdiArrowDown,
mdiArrowUp,
@@ -29,7 +28,6 @@ import { customElement, property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { ensureArray } from "../../../../common/array/ensure-array";
import { storage } from "../../../../common/decorators/storage";
import { transform } from "../../../../common/decorators/transform";
import { fireEvent } from "../../../../common/dom/fire_event";
import { preventDefaultStopPropagation } from "../../../../common/dom/prevent_default_stop_propagation";
import { stopPropagation } from "../../../../common/dom/stop_propagation";
@@ -50,21 +48,15 @@ import "../../../../components/ha-dropdown-item";
import "../../../../components/ha-expansion-panel";
import "../../../../components/ha-icon-button";
import "../../../../components/ha-svg-icon";
import "../../../../components/ha-tooltip";
import { TRIGGER_ICONS } from "../../../../components/ha-trigger-icon";
import type {
AutomationClipboard,
AutomationConfig,
PlatformTrigger,
Trigger,
TriggerList,
TriggerSidebarConfig,
} from "../../../../data/automation";
import {
automationConfigContext,
isTrigger,
subscribeTrigger,
} from "../../../../data/automation";
import { isTrigger, subscribeTrigger } from "../../../../data/automation";
import { describeTrigger } from "../../../../data/automation_i18n";
import { validateConfig } from "../../../../data/config";
import { fullEntitiesContext } from "../../../../data/context";
@@ -81,7 +73,6 @@ import type { HomeAssistant } from "../../../../types";
import { isMac } from "../../../../util/is_mac";
import { showEditorToast } from "../editor-toast";
import "../ha-automation-editor-warning";
import "../ha-trigger-id-chip";
import { overflowStyles, rowStyles } from "../styles";
import "../target/ha-automation-row-targets";
import "./ha-automation-trigger-editor";
@@ -187,30 +178,6 @@ export default class HaAutomationTriggerRow extends LitElement {
@consume({ context: fullEntitiesContext, subscribe: true })
_entityReg: EntityRegistryEntry[] = [];
@state()
@consume({ context: automationConfigContext, subscribe: true })
@transform<AutomationConfig, boolean>({
transformer: function (this: HaAutomationTriggerRow, value) {
if (
!this.trigger ||
isTriggerList(this.trigger) ||
!(this.trigger as Exclude<Trigger, TriggerList>).id
) {
return false;
}
const triggerId = (this.trigger as Exclude<Trigger, TriggerList>).id;
// count how often this trigger id is used in the automation, if more than once, show warning
return (
ensureArray(value?.triggers || []).filter(
(trigger) =>
(trigger as Exclude<Trigger, TriggerList>).id === triggerId
).length > 1
);
},
watch: ["trigger"],
})
private _duplicateTriggerId = false;
get selected() {
return this._selected;
}
@@ -257,9 +224,9 @@ export default class HaAutomationTriggerRow extends LitElement {
?.target
: undefined;
const commentTooltipText = truncateWithEllipsis(
const noteTooltipText = truncateWithEllipsis(
(type !== "list" &&
(this.trigger as Exclude<Trigger, TriggerList>).comment?.trim()) ||
(this.trigger as Exclude<Trigger, TriggerList>).note?.trim()) ||
"",
250
);
@@ -277,28 +244,6 @@ export default class HaAutomationTriggerRow extends LitElement {
.trigger=${(this.trigger as Exclude<Trigger, TriggerList>).trigger}
></ha-trigger-icon>`}
<h3 slot="header">
${type !== "list" && (this.trigger as Exclude<Trigger, TriggerList>).id
? html`<ha-trigger-id-chip
id="trigger-id-chip"
.warning=${this._duplicateTriggerId}
slot="leading-icon"
.triggerId=${(this.trigger as Exclude<Trigger, TriggerList>).id}
>
${this._duplicateTriggerId
? html`<ha-svg-icon
slot="start"
.path=${mdiAlert}
></ha-svg-icon>`
: nothing}
</ha-trigger-id-chip>
${this._duplicateTriggerId
? html`<ha-tooltip for="trigger-id-chip">
${this.hass.localize(
"ui.panel.config.automation.editor.triggers.duplicate_id_warning"
)}
</ha-tooltip>`
: nothing} `
: nothing}
${describeTrigger(this.trigger, this.hass, this._entityReg)}
${target !== undefined || (descriptionHasTarget && !this._isNew)
? this._renderTargets(
@@ -308,19 +253,18 @@ export default class HaAutomationTriggerRow extends LitElement {
)
: nothing}
${type !== "list" &&
(this.trigger as Exclude<Trigger, TriggerList>).comment?.trim()
(this.trigger as Exclude<Trigger, TriggerList>).note?.trim()
? html`
<ha-svg-icon
id="comment-icon"
tabindex="0"
id="note-icon"
.path=${mdiCommentTextOutline}
.label=${this.hass.localize(
"ui.panel.config.automation.editor.comment.label"
"ui.panel.config.automation.editor.note.label"
)}
class="comment-indicator"
class="note-indicator"
></ha-svg-icon>
<ha-tooltip for="comment-icon"
><p>${commentTooltipText}</p></ha-tooltip
>
<ha-tooltip for="note-icon"><p>${noteTooltipText}</p></ha-tooltip>
`
: nothing}
</h3>
@@ -364,14 +308,14 @@ export default class HaAutomationTriggerRow extends LitElement {
)}
</ha-dropdown-item>
${type !== "list"
? html`<ha-dropdown-item value="edit_comment">
? html`<ha-dropdown-item value="edit_note">
<ha-svg-icon
slot="icon"
.path=${mdiCommentEditOutline}
></ha-svg-icon>
${this._renderOverflowLabel(
this.hass.localize(
`ui.panel.config.automation.editor.comment.${(this.trigger as Exclude<Trigger, TriggerList>).comment ? "edit" : "add"}`
`ui.panel.config.automation.editor.note.${(this.trigger as Exclude<Trigger, TriggerList>).note ? "edit" : "add"}`
)
)}
</ha-dropdown-item>`
@@ -752,7 +696,7 @@ export default class HaAutomationTriggerRow extends LitElement {
rename: () => {
this._renameTrigger();
},
editComment: this._editCommentTrigger,
editNote: this._editNoteTrigger,
toggleYamlMode: () => {
this._toggleYamlMode();
this.openSidebar();
@@ -896,27 +840,27 @@ export default class HaAutomationTriggerRow extends LitElement {
}
};
private _editCommentTrigger = async (): Promise<void> => {
private _editNoteTrigger = async (): Promise<void> => {
if (isTriggerList(this.trigger)) return;
const trigger = this.trigger;
const comment = await showPromptDialog(this, {
const note = await showPromptDialog(this, {
title: this.hass.localize(
`ui.panel.config.automation.editor.comment.${trigger.comment ? "edit" : "add"}`
`ui.panel.config.automation.editor.note.${trigger.note ? "edit" : "add"}`
),
inputLabel: this.hass.localize(
"ui.panel.config.automation.editor.comment.label"
"ui.panel.config.automation.editor.note.label"
),
inputType: "string",
defaultValue: trigger.comment,
defaultValue: trigger.note,
confirmText: this.hass.localize("ui.common.submit"),
multiline: true,
});
if (comment !== null) {
if (note !== null) {
const value = { ...trigger };
if (comment === "") {
delete value.comment;
if (note === "") {
delete value.note;
} else {
value.comment = comment;
value.note = note;
}
fireEvent(this, "value-changed", {
value,
@@ -1041,8 +985,8 @@ export default class HaAutomationTriggerRow extends LitElement {
case "rename":
this._renameTrigger();
break;
case "edit_comment":
this._editCommentTrigger();
case "edit_note":
this._editNoteTrigger();
break;
case "duplicate":
this._duplicateTrigger();
@@ -8,7 +8,6 @@ import type { PropertyValues } from "lit";
import { html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import { ensureArray } from "../../../../common/array/ensure-array";
import { fireEvent } from "../../../../common/dom/fire_event";
import { stopPropagation } from "../../../../common/dom/stop_propagation";
import "../../../../components/ha-button";
@@ -22,20 +21,15 @@ import {
} from "../../../../data/automation";
import { subscribeLabFeature } from "../../../../data/labs";
import type { TriggerDescriptions } from "../../../../data/trigger";
import {
getNextNumericTriggerId,
getUniqueTriggerId,
isTriggerList,
subscribeTriggers,
} from "../../../../data/trigger";
import { isTriggerList, subscribeTriggers } from "../../../../data/trigger";
import { SubscribeMixin } from "../../../../mixins/subscribe-mixin";
import { EDITOR_SAVE_FAB_TOAST_BOTTOM_OFFSET } from "../editor-toast";
import { AutomationSortableListMixin } from "../ha-automation-sortable-list-mixin";
import {
getAddAutomationElementTargetFromQuery,
PASTE_VALUE,
showAddAutomationElementDialog,
} from "../show-add-automation-element-dialog";
import { AutomationSortableListMixin } from "../ha-automation-sortable-list-mixin";
import { automationRowsStyles } from "../styles";
import "./ha-automation-trigger-row";
import type HaAutomationTriggerRow from "./ha-automation-trigger-row";
@@ -73,53 +67,6 @@ export default class HaAutomationTrigger extends AutomationSortableListMixin<Tri
this.highlightedTriggers = items;
}
protected override pasteItem(ev: CustomEvent) {
if (this.root && ev.detail.item) {
const pasted = deepClone(ev.detail.item) as Trigger;
if (!isTriggerList(pasted)) {
pasted.id = pasted.id
? getUniqueTriggerId(pasted.id, this.triggers)
: getNextNumericTriggerId(this.triggers);
}
ev.detail.item = pasted;
}
super.pasteItem(ev);
}
protected override insertAfter(ev: CustomEvent) {
// Only dedupe when a single trigger is being inserted.
const incoming = ensureArray(ev.detail.value) as Trigger[];
if (this.root && incoming.length === 1) {
const trigger = deepClone(incoming[0]);
if (!isTriggerList(trigger)) {
trigger.id = trigger.id
? getUniqueTriggerId(trigger.id, this.triggers)
: getNextNumericTriggerId(this.triggers);
}
ev.detail.value = trigger;
}
super.insertAfter(ev);
}
protected override duplicateItem(ev: CustomEvent) {
if (this.root) {
const index = (ev.target as any).index;
const duplicated = deepClone(this.triggers[index]);
if (!isTriggerList(duplicated)) {
duplicated.id = duplicated.id
? getUniqueTriggerId(duplicated.id, this.triggers)
: getNextNumericTriggerId(this.triggers);
}
fireEvent(this, "value-changed", {
// @ts-expect-error Requires library bump to ES2023
value: this.triggers.toSpliced(index + 1, 0, duplicated),
});
ev.stopPropagation();
return;
}
super.duplicateItem(ev);
}
public disconnectedCallback() {
super.disconnectedCallback();
this._unsubscribe();
@@ -266,36 +213,23 @@ export default class HaAutomationTrigger extends AutomationSortableListMixin<Tri
private _addTrigger = (value: string, target?: HassServiceTarget) => {
let triggers: Trigger[];
if (value === PASTE_VALUE) {
const pasted = deepClone(this._clipboard!.trigger!);
if (this.root && !isTriggerList(pasted)) {
pasted.id = pasted.id
? getUniqueTriggerId(pasted.id, this.triggers)
: getNextNumericTriggerId(this.triggers);
}
triggers = this.triggers.concat(pasted);
triggers = this.triggers.concat(deepClone(this._clipboard!.trigger!));
} else if (isDynamic(value)) {
triggers = this.triggers.concat({
trigger: getValueFromDynamic(value),
target,
});
} else {
let newTrigger: Trigger;
if (isDynamic(value)) {
newTrigger = {
trigger: getValueFromDynamic(value),
target,
};
} else {
const trigger = value as Exclude<Trigger, TriggerList>["trigger"];
const elClass = customElements.get(
`ha-automation-trigger-${trigger}`
) as CustomElementConstructor & {
defaultConfig: Trigger;
};
newTrigger = {
...elClass.defaultConfig,
...(target?.entity_id ? { entity_id: target.entity_id } : {}),
};
}
if (this.root && !isTriggerList(newTrigger)) {
newTrigger.id = getNextNumericTriggerId(this.triggers);
}
triggers = this.triggers.concat(newTrigger);
const trigger = value as Exclude<Trigger, TriggerList>["trigger"];
const elClass = customElements.get(
`ha-automation-trigger-${trigger}`
) as CustomElementConstructor & {
defaultConfig: Trigger;
};
triggers = this.triggers.concat({
...elClass.defaultConfig,
...(target?.entity_id ? { entity_id: target.entity_id } : {}),
});
}
this.focusLastItemOnChange = true;
fireEvent(this, "value-changed", { value: triggers });
@@ -29,7 +29,7 @@ const DEFAULT_KEYS: (keyof PlatformTrigger)[] = [
"trigger",
"target",
"alias",
"comment",
"note",
"id",
"variables",
"enabled",
@@ -1,6 +1,6 @@
import "@home-assistant/webawesome/dist/components/divider/divider";
import { consume } from "@lit/context";
import { mdiCog, mdiContentCopy } from "@mdi/js";
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import type { PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
@@ -13,10 +13,9 @@ import "../../../../../components/ha-dropdown-item";
import "../../../../../components/ha-icon-button";
import "../../../../../components/input/ha-input";
import type { HaInput } from "../../../../../components/input/ha-input";
import {
automationConfigContext,
type AutomationConfig,
type WebhookTrigger,
import type {
AutomationConfig,
WebhookTrigger,
} from "../../../../../data/automation";
import type { HomeAssistant } from "../../../../../types";
import { showEditorToast } from "../../editor-toast";
@@ -34,9 +33,9 @@ export class HaWebhookTrigger extends LitElement {
@property({ type: Boolean }) public disabled = false;
@consume({ context: automationConfigContext, subscribe: true })
@state()
private _config?: AutomationConfig;
@state() private _config?: AutomationConfig;
private _unsub?: UnsubscribeFunc;
public static get defaultConfig(): WebhookTrigger {
return {
@@ -47,6 +46,24 @@ export class HaWebhookTrigger extends LitElement {
};
}
connectedCallback() {
super.connectedCallback();
const details = {
callback: (config) => {
this._config = config;
},
};
fireEvent(this, "subscribe-automation-config", details);
this._unsub = (details as any).unsub;
}
disconnectedCallback() {
super.disconnectedCallback();
if (this._unsub) {
this._unsub();
}
}
private _generateWebhookId(): string {
// The webhook_id should be treated like a password. Generate a default
// value that would be hard for someone to guess. This generates a
@@ -22,8 +22,6 @@ class HaConfigBlueprint extends HassRouterPage {
@property({ attribute: "is-wide", type: Boolean }) public isWide = false;
@property({ attribute: false }) public showAdvanced = false;
@property({ attribute: false })
public blueprints: Record<string, Blueprints> = {};
@@ -61,7 +59,6 @@ class HaConfigBlueprint extends HassRouterPage {
pageEl.narrow = this.narrow;
pageEl.isWide = this.isWide;
pageEl.route = this.routeTail;
pageEl.showAdvanced = this.showAdvanced;
pageEl.blueprints = this.blueprints;
if (
@@ -167,14 +167,15 @@ export class DialogTryTts extends LitElement {
}
this._message = message;
if (this._target === "browser") {
const target = this._target || "browser";
if (target === "browser") {
// We create the audio element here + do a play, because iOS requires it to be done by user action
const audio = new Audio();
audio.play();
this._playBrowser(message, audio);
} else {
this.hass.callService("tts", "cloud_say", {
entity_id: this._target,
entity_id: target,
message,
});
}
@@ -217,7 +217,7 @@ export class CloudRegister extends LitElement {
try {
await cloudRegister(this.hass, email, password);
this._verificationEmailSent(email);
this._verificationEmailSent(email, "account_created");
} catch (err: any) {
this._password = "";
this._requestInProgress = false;
@@ -238,15 +238,18 @@ export class CloudRegister extends LitElement {
const email = emailField.value || "";
this._requestInProgress = true;
const doResend = async (username: string) => {
try {
await cloudResendVerification(this.hass, username);
this._verificationEmailSent(username);
this._verificationEmailSent(username, "verification_email_sent");
} catch (err: any) {
const errCode = err && err.body && err.body.code;
if (errCode === "usernotfound" && username !== username.toLowerCase()) {
await doResend(username.toLowerCase());
} else {
this._requestInProgress = false;
this._error =
err && err.body && err.body.message
? err.body.message
@@ -258,13 +261,16 @@ export class CloudRegister extends LitElement {
await doResend(email);
}
private _verificationEmailSent(email: string) {
private _verificationEmailSent(
email: string,
messageKey: "account_created" | "verification_email_sent"
) {
this._requestInProgress = false;
this._password = "";
fireEvent(this, "cloud-email-changed", { value: email });
fireEvent(this, "cloud-done", {
flashMessage: this.hass.localize(
"ui.panel.config.cloud.register.account_created"
`ui.panel.config.cloud.register.${messageKey}`
),
});
}
@@ -211,7 +211,6 @@ class HaConfigSectionGeneral extends LitElement {
<div class="unit-system-options">
<ha-select-box
name="unit_system"
.hass=${this.hass}
.value=${this._unitSystem}
.disabled=${disabled}
@value-changed=${this._unitSystemChanged}
@@ -42,8 +42,6 @@ class HaConfigSystemNavigation extends LitElement {
@property({ attribute: false }) public cloudStatus?: CloudStatus;
@property({ attribute: false }) public showAdvanced = false;
@state() private _latestBackupDate?: Date;
@state() private _boardName?: string;
@@ -35,8 +35,6 @@ export class DeveloperYamlConfig extends LitElement {
@property({ attribute: false }) public route!: Route;
@property({ attribute: false }) public showAdvanced = false;
@state() private _validating = false;
@state() private _reloadableDomains: TranslatedReloadableDomain[] = [];
@@ -188,8 +188,6 @@ export class HaConfigDevicePage extends LitElement {
@property({ attribute: "is-wide", type: Boolean }) public isWide = false;
@property({ attribute: false }) public showAdvanced = false;
@state() private _related?: RelatedResult;
@state() private _diagnosticDownloadLinks: DeviceAction[] = [];
@@ -18,8 +18,6 @@ class HaConfigDevices extends HassRouterPage {
@property({ attribute: "is-wide", type: Boolean }) public isWide = false;
@property({ attribute: false }) public showAdvanced = false;
protected routerOptions: RouterOptions = {
defaultPage: "dashboard",
routes: {
@@ -53,7 +51,6 @@ class HaConfigDevices extends HassRouterPage {
pageEl.manifests = this._manifests;
pageEl.narrow = this.narrow;
pageEl.isWide = this.isWide;
pageEl.showAdvanced = this.showAdvanced;
pageEl.route = this.routeTail;
}
@@ -68,8 +68,6 @@ class HaConfigEnergy extends LitElement {
@property({ attribute: "is-wide", type: Boolean }) public isWide = false;
@property({ attribute: false }) public showAdvanced = false;
@property({ attribute: false }) public route!: Route;
@state() private _searchParms = new URLSearchParams(window.location.search);
@@ -23,6 +23,7 @@ import "../../../components/ha-alert";
import "../../../components/ha-area-picker";
import "../../../components/ha-color-picker";
import "../../../components/ha-dropdown-item";
import "../../../components/entity/ha-entity-picker";
import "../../../components/ha-icon";
import "../../../components/ha-icon-button-next";
import "../../../components/ha-icon-picker";
@@ -56,6 +57,7 @@ import { updateDeviceRegistryEntry } from "../../../data/device/device_registry"
import type {
AlarmControlPanelEntityOptions,
CalendarEntityOptions,
DeviceTrackerEntityOptions,
EntityRegistryEntry,
EntityRegistryEntryUpdateParams,
ExtEntityRegistryEntry,
@@ -97,7 +99,7 @@ import type { HomeAssistant } from "../../../types";
import { showToast } from "../../../util/toast";
import { showDeviceRegistryDetailDialog } from "../devices/device-registry-detail/show-dialog-device-registry-detail";
const OVERRIDE_DEVICE_CLASSES = {
export const OVERRIDE_DEVICE_CLASSES = {
cover: [
[
"awning",
@@ -138,6 +140,10 @@ const SWITCH_AS_DOMAINS_INVERT = ["cover", "lock", "valve"];
const PRECISIONS = [0, 1, 2, 3, 4, 5, 6];
const SCANNER_SOURCE_TYPES = ["router", "bluetooth", "bluetooth_le"];
const ZONE_DOMAINS = ["zone"];
@customElement("entity-registry-settings-editor")
export class EntityRegistrySettingsEditor extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -202,6 +208,8 @@ export class EntityRegistrySettingsEditor extends LitElement {
@state() private _calendarColor?: string | null;
@state() private _associatedZone?: string;
@state() private _noDeviceArea?: boolean;
private _origEntityId!: string;
@@ -292,6 +300,11 @@ export class EntityRegistrySettingsEditor extends LitElement {
this._calendarColor = this.entry.options?.calendar?.color;
}
if (domain === "device_tracker") {
this._associatedZone =
this.entry.options?.device_tracker?.associated_zone ?? "zone.home";
}
if (domain === "weather") {
const stateObj: HassEntity | undefined =
this.hass.states[this.entry.entity_id];
@@ -713,6 +726,21 @@ export class EntityRegistrySettingsEditor extends LitElement {
></ha-color-picker>
`
: nothing}
${domain === "device_tracker" &&
SCANNER_SOURCE_TYPES.includes(stateObj?.attributes?.source_type)
? html`
<ha-entity-picker
.hass=${this.hass}
.value=${this._associatedZone}
.label=${this.hass.localize(
"ui.dialogs.entity_registry.editor.associated_zone"
)}
.includeDomains=${ZONE_DOMAINS}
.disabled=${this.disabled}
@value-changed=${this._associatedZoneChanged}
></ha-entity-picker>
`
: nothing}
${domain === "sensor" &&
this._deviceClass &&
stateObj?.attributes.unit_of_measurement &&
@@ -1208,6 +1236,17 @@ export class EntityRegistrySettingsEditor extends LitElement {
(params.options as CalendarEntityOptions).color = this._calendarColor;
}
}
if (
domain === "device_tracker" &&
this._associatedZone !== undefined &&
(this.entry.options?.device_tracker?.associated_zone ?? "zone.home") !==
this._associatedZone
) {
params.options_domain = "device_tracker";
params.options = {
associated_zone: this._associatedZone,
} as DeviceTrackerEntityOptions;
}
if (
domain === "weather" &&
(stateObj?.attributes?.precipitation_unit !== this._precipitation_unit ||
@@ -1447,6 +1486,11 @@ export class EntityRegistrySettingsEditor extends LitElement {
this._calendarColor = ev.detail.value || null;
}
private _associatedZoneChanged(ev: CustomEvent): void {
fireEvent(this, "change");
this._associatedZone = ev.detail.value || "zone.home";
}
private _precipitationUnitChanged(ev: HaSelectSelectEvent): void {
fireEvent(this, "change");
this._precipitation_unit = ev.detail.value;
-1
View File
@@ -819,7 +819,6 @@ class HaPanelConfig extends HassRouterPage {
el.route = this.routeTail;
el.hass = this.hass;
el.showAdvanced = Boolean(this.hass.userData?.showAdvanced);
el.isWide = isWide;
el.narrow = this.narrow;
el.cloudStatus = this._cloudStatus;
@@ -1213,7 +1213,6 @@ ${rejected
showConfigFlowDialog(this, {
startFlowHandler: domain,
manifest: await fetchIntegrationManifest(this.hass, domain),
showAdvanced: this.hass.userData?.showAdvanced,
});
}
-2
View File
@@ -92,8 +92,6 @@ class HaConfigInfo extends LitElement {
@property({ attribute: "is-wide", type: Boolean }) public isWide = false;
@property({ attribute: false }) public showAdvanced = false;
@property({ attribute: false }) public route!: Route;
@state() private _osInfo?: HassioHassOSInfo;
@@ -3,7 +3,7 @@ import Fuse from "fuse.js";
import type { HassConfig } from "home-assistant-js-websocket";
import type { PropertyValues, TemplateResult } from "lit";
import { LitElement, css, html, nothing } from "lit";
import { customElement, state } from "lit/decorators";
import { customElement, query, state } from "lit/decorators";
import { styleMap } from "lit/directives/style-map";
import memoizeOne from "memoize-one";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
@@ -16,11 +16,18 @@ import { navigate } from "../../../common/navigate";
import { caseInsensitiveStringCompare } from "../../../common/string/compare";
import type { LocalizeFunc } from "../../../common/translations/localize";
import "../../../components/ha-dialog";
import "../../../components/ha-domain-icon";
import "../../../components/ha-icon-button-prev";
import "../../../components/ha-list";
import "../../../components/ha-spinner";
import "../../../components/ha-icon-next";
import "../../../components/ha-svg-icon";
import "../../../components/input/ha-input-search";
import type { HaInputSearch } from "../../../components/input/ha-input-search";
import "../../../components/item/ha-list-item-button";
import "../../../components/list/ha-list-virtualized";
import type {
HaListVirtualized,
HaListVirtualizedItem,
} from "../../../components/list/ha-list-virtualized";
import { getConfigEntries } from "../../../data/config_entries";
import {
DISCOVERY_SOURCES,
@@ -46,16 +53,17 @@ import {
showAlertDialog,
showConfirmationDialog,
} from "../../../dialogs/generic/show-dialog-box";
import { haStyleDialog, haStyleScrollbar } from "../../../resources/styles";
import { haStyleDialog } from "../../../resources/styles";
import { loadVirtualizer } from "../../../resources/virtualizer";
import type { HomeAssistant } from "../../../types";
import "./ha-domain-integrations";
import "./ha-integration-list-item";
import type { HaIntegrationListItem } from "./ha-integration-list-item";
import type { AddIntegrationDialogParams } from "./show-add-integration-dialog";
import { showYamlIntegrationDialog } from "./show-add-integration-dialog";
import { showSingleConfigEntryWarning } from "./show-single-config-entry-warning";
export interface IntegrationListItem {
export interface IntegrationListItem extends HaListVirtualizedItem {
name: string;
domain: string;
config_flow?: boolean;
@@ -100,6 +108,8 @@ class AddIntegrationDialog extends LitElement {
@state() private _narrow = false;
@query("ha-list-virtualized") private _listElement?: HaListVirtualized;
private _width?: number;
private _height?: number;
@@ -185,8 +195,9 @@ class AddIntegrationDialog extends LitElement {
(!this._width || !this._height)
) {
// Store the width and height so that when we search, box doesn't jump
const boundingRect =
this.shadowRoot!.querySelector("ha-list")?.getBoundingClientRect();
const boundingRect = this.shadowRoot!.querySelector(
"ha-list-virtualized"
)?.getBoundingClientRect();
this._width = boundingRect?.width;
this._height = boundingRect?.height;
}
@@ -206,6 +217,8 @@ class AddIntegrationDialog extends LitElement {
discoveredFlowsCount > 0
? [
{
id: "_discovered",
interactive: true,
name: localize(
"ui.panel.config.integrations.discovered_devices",
{ count: discoveredFlowsCount }
@@ -222,6 +235,8 @@ class AddIntegrationDialog extends LitElement {
(domain) => components.includes(domain)
)
.map((domain) => ({
id: `device_${domain}`,
interactive: true,
name: localize(`ui.panel.config.integrations.add_${domain}_device`),
domain,
config_flow: true,
@@ -262,6 +277,8 @@ class AddIntegrationDialog extends LitElement {
return;
}
integrations.push({
id: domain,
interactive: true,
domain,
name: integration.name || domainToName(localize, domain),
config_flow: supportedIntegration.config_flow,
@@ -278,6 +295,8 @@ class AddIntegrationDialog extends LitElement {
) {
// Brand
integrations.push({
id: domain,
interactive: true,
domain,
name: integration.name || domainToName(localize, domain),
iot_standards: integration.iot_standards,
@@ -295,6 +314,8 @@ class AddIntegrationDialog extends LitElement {
} else if (filter && "integration_type" in integration) {
// Integration without a config flow
yamlIntegrations.push({
id: domain,
interactive: true,
domain,
name: integration.name || domainToName(localize, domain),
config_flow: integration.config_flow,
@@ -320,6 +341,8 @@ class AddIntegrationDialog extends LitElement {
ignoreDiacritics: true,
};
const helpers = Object.entries(h).map(([domain, integration]) => ({
id: domain,
interactive: true,
domain,
name: integration.name || domainToName(localize, domain),
config_flow: integration.config_flow,
@@ -517,6 +540,7 @@ class AddIntegrationDialog extends LitElement {
}
if (supportIntegration) {
this._handleIntegrationPicked({
id: integration.supported_by,
domain: integration.supported_by,
name:
supportIntegration.name ||
@@ -543,45 +567,33 @@ class AddIntegrationDialog extends LitElement {
.placeholder=${this.hass.localize(
"ui.panel.config.integrations.search_brand"
)}
@keypress=${this._maybeSubmit}
@keydown=${this._maybeSubmit}
></ha-input-search>
${integrations
? html`<ha-list ?autofocus=${this._narrow}>
<lit-virtualizer
scroller
tabindex="-1"
class="ha-scrollbar"
style=${styleMap({
width: `${this._width}px`,
height: this._narrow
? "calc(100vh - 184px - var(--safe-area-inset-top, 0px) - var(--safe-area-inset-bottom, 0px))"
: "500px",
})}
@click=${this._integrationPicked}
@keypress=${this._handleKeyPress}
.items=${integrations}
.keyFunction=${this._keyFunction}
.renderItem=${this._renderRow}
>
</lit-virtualizer>
</ha-list>`
? html`<ha-list-virtualized
.rows=${integrations}
.rowRenderer=${this._renderRow}
style=${styleMap({
width: `${this._width}px`,
height: this._narrow
? "calc(100vh - 184px - var(--safe-area-inset-top, 0px) - var(--safe-area-inset-bottom, 0px))"
: "500px",
})}
>
</ha-list-virtualized>`
: html`<div class="flex center">
<ha-spinner></ha-spinner>
</div>`} `;
</div>`}`;
}
private _keyFunction = (integration: IntegrationListItem) =>
integration.domain;
private _renderRow = (integration: IntegrationListItem) => {
if (!integration) {
return nothing;
}
return html`
<ha-integration-list-item
.hass=${this.hass}
@click=${this._integrationPicked}
.integration=${integration}
tabindex="0"
>
</ha-integration-list-item>
`;
@@ -647,19 +659,13 @@ class AddIntegrationDialog extends LitElement {
this._filter = (ev.target as HaInputSearch).value ?? "";
}
private _integrationPicked(ev) {
const listItem = ev.target.closest("ha-integration-list-item");
if (!listItem) {
private _integrationPicked = (ev: Event) => {
const listItem = ev.currentTarget as HaIntegrationListItem;
if (!listItem?.integration) {
return;
}
this._handleIntegrationPicked(listItem.integration);
}
private _handleKeyPress(ev) {
if (ev.key === "Enter") {
this._integrationPicked(ev);
}
}
};
private async _handleIntegrationPicked(integration: IntegrationListItem) {
if (integration.supported_by) {
@@ -761,7 +767,6 @@ class AddIntegrationDialog extends LitElement {
showConfigFlowDialog(this, {
startFlowHandler: domain,
showAdvanced: this.hass.userData?.showAdvanced,
manifest,
navigateToResult: this._navigateToResult,
});
@@ -782,6 +787,11 @@ class AddIntegrationDialog extends LitElement {
}
private _maybeSubmit(ev: KeyboardEvent) {
if (ev.key === "ArrowDown" && this._listElement) {
ev.preventDefault();
this._listElement.focus();
return;
}
if (ev.key !== "Enter") {
return;
}
@@ -803,7 +813,6 @@ class AddIntegrationDialog extends LitElement {
}
static styles = [
haStyleScrollbar,
haStyleDialog,
css`
ha-dialog {
@@ -831,15 +840,9 @@ class AddIntegrationDialog extends LitElement {
ha-spinner {
margin: 24px 0;
}
ha-list {
ha-list-virtualized {
position: relative;
}
lit-virtualizer {
contain: size layout !important;
}
ha-integration-list-item {
width: 100%;
}
`,
];
}
@@ -626,7 +626,6 @@ export class HaConfigEntryRow extends LitElement {
private _handleReconfigure = async () => {
showConfigFlowDialog(this, {
startFlowHandler: this.data.entry.domain,
showAdvanced: this.hass.userData?.showAdvanced,
manifest: await fetchIntegrationManifest(
this.hass,
this.data.entry.domain
@@ -138,8 +138,6 @@ class HaConfigIntegrationPage extends SubscribeMixin(LitElement) {
@property({ attribute: "is-wide", type: Boolean }) public isWide = false;
@property({ attribute: false }) public showAdvanced = false;
@property({ attribute: false }) public configEntries?: ConfigEntry[];
@property({ attribute: false })
@@ -129,8 +129,6 @@ class HaConfigIntegrationsDashboard extends KeyboardShortcutMixin(
@property({ attribute: "is-wide", type: Boolean }) public isWide = false;
@property({ attribute: false }) public showAdvanced = false;
@property({ attribute: false }) public route!: Route;
@property({ attribute: false }) public configEntries?: ConfigEntryExtended[];
@@ -989,7 +987,6 @@ class HaConfigIntegrationsDashboard extends KeyboardShortcutMixin(
this.hass,
integration.supported_by!
),
showAdvanced: this.hass.userData?.showAdvanced,
});
},
});
@@ -51,8 +51,6 @@ class HaConfigIntegrations extends SubscribeMixin(HassRouterPage) {
@property({ attribute: "is-wide", type: Boolean }) public isWide = false;
@property({ attribute: false }) public showAdvanced = false;
protected routerOptions: RouterOptions = {
defaultPage: "dashboard",
routes: {
@@ -207,7 +205,6 @@ class HaConfigIntegrations extends SubscribeMixin(HassRouterPage) {
pageEl.configEntriesInProgress = this._configEntriesInProgress;
pageEl.narrow = this.narrow;
pageEl.isWide = this.isWide;
pageEl.showAdvanced = this.showAdvanced;
}
}
@@ -1,3 +1,4 @@
import "@home-assistant/webawesome/dist/components/divider/divider";
import type { RequestSelectedDetail } from "@material/mwc-list/mwc-list-item-base";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
@@ -7,11 +8,10 @@ import {
PROTOCOL_INTEGRATIONS,
protocolIntegrationPicked,
} from "../../../common/integrations/protocolIntegrationPicked";
import { shouldHandleRequestSelectedEvent } from "../../../common/mwc/handle-request-selected-event";
import { navigate } from "../../../common/navigate";
import { caseInsensitiveStringCompare } from "../../../common/string/compare";
import "../../../components/ha-list";
import "../../../components/ha-list-item";
import "../../../components/item/ha-list-item-button";
import "../../../components/list/ha-list-base";
import { localizeConfigFlowTitle } from "../../../data/config_flow";
import type { DataEntryFlowProgress } from "../../../data/data_entry_flow";
import {
@@ -46,23 +46,20 @@ class HaDomainIntegrations extends LitElement {
public showManageLink = false;
protected render() {
return html`<ha-list>
return html`<ha-list-base>
${this.flowsInProgress?.length
? html`<h3>
${this.hass.localize("ui.panel.config.integrations.discovered")}
</h3>
${this.flowsInProgress.map(
(flow) =>
html`<ha-list-item
graphic="medium"
twoLine
html`<ha-list-item-button
.flow=${flow}
@request-selected=${this._flowInProgressPicked}
hasMeta
@click=${this._flowInProgressPicked}
>
<img
alt=""
slot="graphic"
slot="start"
loading="lazy"
src=${brandsUrl(
{
@@ -75,16 +72,16 @@ class HaDomainIntegrations extends LitElement {
crossorigin="anonymous"
referrerpolicy="no-referrer"
/>
<span
<span slot="headline"
>${localizeConfigFlowTitle(this.hass.localize, flow)}</span
>
<span slot="secondary"
<span slot="supporting-text"
>${domainToName(this.hass.localize, flow.handler)}</span
>
<ha-icon-next slot="meta"></ha-icon-next>
</ha-list-item>`
<ha-icon-next slot="end"></ha-icon-next>
</ha-list-item-button>`
)}
<li divider role="separator"></li>
<wa-divider></wa-divider>
${this.integration &&
"integrations" in this.integration &&
this.integration.integrations
@@ -105,14 +102,12 @@ class HaDomainIntegrations extends LitElement {
.map((standard) => {
const domain: (typeof PROTOCOL_INTEGRATIONS)[number] =
standardToDomain[standard] || standard;
return html`<ha-list-item
graphic="medium"
return html`<ha-list-item-button
.domain=${domain}
@request-selected=${this._standardPicked}
hasMeta
@click=${this._standardPicked}
>
<img
slot="graphic"
slot="start"
loading="lazy"
alt=""
src=${brandsUrl(
@@ -126,13 +121,13 @@ class HaDomainIntegrations extends LitElement {
crossorigin="anonymous"
referrerpolicy="no-referrer"
/>
<span
<span slot="headline"
>${this.hass.localize(
`ui.panel.config.integrations.add_${domain}_device`
)}</span
>
<ha-icon-next slot="meta"></ha-icon-next>
</ha-list-item>`;
<ha-icon-next slot="end"></ha-icon-next>
</ha-list-item-button>`;
})
: ""}
${this.integration &&
@@ -156,8 +151,6 @@ class HaDomainIntegrations extends LitElement {
.map(
([dom, val]) =>
html`<ha-integration-list-item
.hass=${this.hass}
.domain=${dom}
.integration=${{
...val,
domain: dom,
@@ -165,20 +158,18 @@ class HaDomainIntegrations extends LitElement {
is_built_in: val.is_built_in !== false,
cloud: val.iot_class?.startsWith("cloud_"),
}}
@request-selected=${this._integrationPicked}
@click=${this._integrationPicked}
>
</ha-integration-list-item>`
)
: ""}
${(PROTOCOL_INTEGRATIONS as readonly string[]).includes(this.domain)
? html`<ha-list-item
graphic="medium"
? html`<ha-list-item-button
.domain=${this.domain}
@request-selected=${this._standardPicked}
hasMeta
@click=${this._standardPicked}
>
<img
slot="graphic"
slot="start"
loading="lazy"
alt=""
src=${brandsUrl(
@@ -192,23 +183,23 @@ class HaDomainIntegrations extends LitElement {
crossorigin="anonymous"
referrerpolicy="no-referrer"
/>
<span
<span slot="headline"
>${this.hass.localize(
`ui.panel.config.integrations.add_${
this.domain as (typeof PROTOCOL_INTEGRATIONS)[number]
}_device`
)}</span
>
<ha-icon-next slot="meta"></ha-icon-next>
</ha-list-item>`
<ha-icon-next slot="end"></ha-icon-next>
</ha-list-item-button>`
: ""}
${this.integration &&
"config_flow" in this.integration &&
this.integration.config_flow
? html`${this.flowsInProgress?.length
? html`<ha-list-item
? html`<ha-list-item-button
.domain=${this.domain}
@request-selected=${this._integrationPicked}
@click=${this._integrationPicked}
.integration=${{
...this.integration,
domain: this.domain,
@@ -218,17 +209,20 @@ class HaDomainIntegrations extends LitElement {
is_built_in: this.integration.is_built_in !== false,
cloud: this.integration.iot_class?.startsWith("cloud_"),
}}
hasMeta
>
${this.hass.localize("ui.panel.config.integrations.new_flow", {
integration:
this.integration.name ||
domainToName(this.hass.localize, this.domain),
})}
<ha-icon-next slot="meta"></ha-icon-next>
</ha-list-item>`
<span slot="headline">
${this.hass.localize(
"ui.panel.config.integrations.new_flow",
{
integration:
this.integration.name ||
domainToName(this.hass.localize, this.domain),
}
)}
</span>
<ha-icon-next slot="end"></ha-icon-next>
</ha-list-item-button>`
: html`<ha-integration-list-item
.hass=${this.hass}
.domain=${this.domain}
.integration=${{
...this.integration,
@@ -239,38 +233,31 @@ class HaDomainIntegrations extends LitElement {
is_built_in: this.integration.is_built_in !== false,
cloud: this.integration.iot_class?.startsWith("cloud_"),
}}
@request-selected=${this._integrationPicked}
@click=${this._integrationPicked}
>
</ha-integration-list-item>`}`
: ""}
${this.showManageLink &&
// Only show manage link if not already on the integrations dashboard
!location.pathname.startsWith("/config/integrations")
? html`<ha-list-item
twoLine
@request-selected=${this._manageDiscovered}
hasMeta
>
<span
? html`<ha-list-item-button @click=${this._manageDiscovered}>
<span slot="headline"
>${this.hass.localize(
"ui.panel.config.integrations.manage_discovered"
)}</span
>
<span slot="secondary"
<span slot="supporting-text"
>${this.hass.localize(
"ui.panel.config.integrations.manage_discovered_description"
)}</span
>
<ha-icon-next slot="meta"></ha-icon-next>
</ha-list-item>`
<ha-icon-next slot="end"></ha-icon-next>
</ha-list-item-button>`
: nothing}
</ha-list> `;
</ha-list-base> `;
}
private async _integrationPicked(ev: CustomEvent<RequestSelectedDetail>) {
if (!shouldHandleRequestSelectedEvent(ev)) {
return;
}
const domain = (ev.currentTarget as any).domain;
if (
@@ -318,7 +305,6 @@ class HaDomainIntegrations extends LitElement {
root instanceof ShadowRoot ? (root.host as HTMLElement) : this,
{
startFlowHandler: domain,
showAdvanced: this.hass.userData?.showAdvanced,
navigateToResult: this.navigateToResult,
manifest: await fetchIntegrationManifest(this.hass, domain),
}
@@ -326,10 +312,7 @@ class HaDomainIntegrations extends LitElement {
fireEvent(this, "close-dialog");
}
private async _flowInProgressPicked(ev: CustomEvent<RequestSelectedDetail>) {
if (!shouldHandleRequestSelectedEvent(ev)) {
return;
}
private async _flowInProgressPicked(ev: Event) {
const flow: DataEntryFlowProgress = (ev.currentTarget as any).flow;
const root = this.getRootNode();
showConfigFlowDialog(
@@ -337,25 +320,18 @@ class HaDomainIntegrations extends LitElement {
{
continueFlowId: flow.flow_id,
navigateToResult: this.navigateToResult,
showAdvanced: this.hass.userData?.showAdvanced,
manifest: await fetchIntegrationManifest(this.hass, flow.handler),
}
);
fireEvent(this, "close-dialog");
}
private _manageDiscovered(ev: CustomEvent<RequestSelectedDetail>) {
if (!shouldHandleRequestSelectedEvent(ev)) {
return;
}
private _manageDiscovered() {
fireEvent(this, "close-dialog");
navigate("/config/integrations/dashboard?historyBack=1");
}
private _standardPicked(ev: CustomEvent<RequestSelectedDetail>) {
if (!shouldHandleRequestSelectedEvent(ev)) {
return;
}
const domain = (ev.currentTarget as any).domain;
const root = this.getRootNode();
fireEvent(this, "close-dialog");
@@ -372,11 +348,10 @@ class HaDomainIntegrations extends LitElement {
css`
:host {
display: block;
--mdc-list-item-graphic-size: 40px;
--mdc-list-side-padding: 24px;
--ha-row-item-padding-inline: var(--ha-space-6);
}
h3 {
margin: 8px 24px 0;
margin: var(--ha-space-2) var(--ha-space-6) 0;
color: var(--secondary-text-color);
font-size: var(--ha-font-size-m);
font-weight: var(--ha-font-weight-medium);
@@ -385,11 +360,14 @@ class HaDomainIntegrations extends LitElement {
margin-top: 0;
}
img {
width: 40px;
height: 40px;
width: 32px;
height: 32px;
}
li[divider] {
margin-top: 8px;
wa-divider {
margin-top: var(--ha-space-2);
}
ha-icon-next {
color: var(--ha-color-text-secondary);
}
`,
];
@@ -1,196 +1,127 @@
import type { GraphicType } from "@material/mwc-list/mwc-list-item-base";
import { ListItemBase } from "@material/mwc-list/mwc-list-item-base";
import { styles } from "@material/mwc-list/mwc-list-item.css";
import {
mdiDevices,
mdiFileCodeOutline,
mdiPackageVariant,
mdiWeb,
} from "@mdi/js";
import type { CSSResultGroup } from "lit";
import type { CSSResultGroup, TemplateResult } from "lit";
import { css, html, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import { classMap } from "lit/directives/class-map";
import { domainToName } from "../../../data/integration";
import type { HomeAssistant } from "../../../types";
import { brandsUrl } from "../../../util/brands-url";
import type { IntegrationListItem } from "./dialog-add-integration";
import "../../../components/ha-svg-icon";
import { customElement, property, state } from "lit/decorators";
import { consumeLocalize } from "../../../common/decorators/consume-context-entry";
import type { LocalizeFunc } from "../../../common/translations/localize";
import "../../../components/ha-domain-icon";
import "../../../components/ha-icon-next";
import "../../../components/ha-svg-icon";
import "../../../components/ha-tooltip";
import { HaListItemButton } from "../../../components/item/ha-list-item-button";
import { domainToName } from "../../../data/integration";
import type { IntegrationListItem } from "./dialog-add-integration";
@customElement("ha-integration-list-item")
export class HaIntegrationListItem extends ListItemBase {
public hass!: HomeAssistant;
export class HaIntegrationListItem extends HaListItemButton {
@property({ attribute: false }) public integration!: IntegrationListItem;
@property({ attribute: false }) public integration?: IntegrationListItem;
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
@property({ type: String, reflect: true }) graphic: GraphicType = "medium";
// eslint-disable-next-line lit/attribute-names
@property({ type: Boolean }) hasMeta = true;
// @ts-expect-error
protected override renderSingleLine() {
if (!this.integration) {
return nothing;
}
return html`${this.integration.name ||
domainToName(this.hass.localize, this.integration.domain)}
${this.integration.is_helper ? " (helper)" : ""}`;
protected override _renderInner(): TemplateResult {
const integration = this.integration;
const yamlOnly =
!integration.config_flow &&
!integration.integrations &&
!integration.iot_standards;
return html`
<div part="start" class="start">
${integration.is_discovered
? html`<ha-svg-icon
class="discovered-icon"
.path=${mdiDevices}
></ha-svg-icon>`
: html`<ha-domain-icon
brand-fallback
.domain=${integration.domain}
></ha-domain-icon>`}
</div>
<div part="content" class="content">
<div part="headline" class="headline">
${integration.name ||
domainToName(this._localize, integration.domain)}
${integration.is_helper
? // @ts-expect-error translation key not yet defined
` (${this._localize("ui.panel.config.integrations.config_entry.helper")})`
: nothing}
</div>
</div>
<div part="end" class="end">
${integration.cloud
? html`<ha-svg-icon id="icon-cloud" .path=${mdiWeb}></ha-svg-icon>
<ha-tooltip for="icon-cloud" placement="left">
${this._localize(
"ui.panel.config.integrations.config_entry.depends_on_cloud"
)}
</ha-tooltip>`
: nothing}
${!integration.is_built_in
? html`<ha-svg-icon
id="icon-custom"
class=${integration.overwrites_built_in
? "overwrites"
: "custom"}
.path=${mdiPackageVariant}
></ha-svg-icon>
<ha-tooltip for="icon-custom" placement="left">
${this._localize(
integration.overwrites_built_in
? "ui.panel.config.integrations.config_entry.custom_overwrites_core"
: "ui.panel.config.integrations.config_entry.custom_integration"
)}
</ha-tooltip>`
: nothing}
${yamlOnly
? html`<ha-svg-icon
id="icon-yaml"
.path=${mdiFileCodeOutline}
class="open-in-new"
></ha-svg-icon>
<ha-tooltip for="icon-yaml" placement="left">
${this._localize(
"ui.panel.config.integrations.config_entry.yaml_only"
)}
</ha-tooltip>`
: html`<ha-icon-next></ha-icon-next>`}
</div>
`;
}
// @ts-expect-error
protected override renderGraphic() {
if (!this.integration) {
return nothing;
}
const graphicClasses = {
multi: this.multipleGraphics,
};
return html` <span
class="mdc-deprecated-list-item__graphic material-icons ${classMap(
graphicClasses
)}"
>
${this.integration.is_discovered
? html`<ha-svg-icon
class="discovered-icon"
.path=${mdiDevices}
></ha-svg-icon>`
: html`<img
alt=""
loading="lazy"
src=${brandsUrl(
{
domain: this.integration.domain,
type: "icon",
darkOptimized: this.hass.themes?.darkMode,
},
this.hass.auth.data.hassUrl
)}
crossorigin="anonymous"
referrerpolicy="no-referrer"
/>`}
</span>`;
}
// @ts-expect-error
protected override renderMeta() {
if (!this.integration) {
return nothing;
}
return html`<span class="mdc-deprecated-list-item__meta material-icons">
${this.integration.cloud
? html` <ha-svg-icon id="icon-cloud" .path=${mdiWeb}></ha-svg-icon>
<ha-tooltip for="icon-cloud" placement="left"
>${this.hass.localize(
"ui.panel.config.integrations.config_entry.depends_on_cloud"
)}
</ha-tooltip>`
: nothing}
${!this.integration.is_built_in
? html`<span
class=${this.integration.overwrites_built_in
? "overwrites"
: "custom"}
>
<ha-svg-icon
id="icon-custom"
.path=${mdiPackageVariant}
></ha-svg-icon>
<ha-tooltip for="icon-custom" placement="left"
>${this.hass.localize(
this.integration.overwrites_built_in
? "ui.panel.config.integrations.config_entry.custom_overwrites_core"
: "ui.panel.config.integrations.config_entry.custom_integration"
)}</ha-tooltip
></span
>`
: nothing}
${!this.integration.config_flow &&
!this.integration.integrations &&
!this.integration.iot_standards
? html` <ha-svg-icon
id="icon-yaml"
.path=${mdiFileCodeOutline}
class="open-in-new"
></ha-svg-icon>
<ha-tooltip for="icon-yaml" placement="left">
${this.hass.localize(
"ui.panel.config.integrations.config_entry.yaml_only"
)}
</ha-tooltip>`
: html`<ha-icon-next></ha-icon-next>`}
</span>`;
}
static get styles(): CSSResultGroup {
return [
styles,
css`
:host {
--mdc-list-side-padding: 24px;
--mdc-list-item-graphic-size: 40px;
}
:host([graphic="avatar"]:not([twoLine])),
:host([graphic="icon"]:not([twoLine])) {
height: 48px;
}
span.material-icons:first-of-type {
margin-inline-start: 0px !important;
margin-inline-end: var(
--mdc-list-item-graphic-margin,
16px
) !important;
direction: var(--direction);
}
span.material-icons:last-of-type {
margin-inline-start: auto !important;
margin-inline-end: 0px !important;
direction: var(--direction);
}
img {
width: 40px;
height: 40px;
}
.discovered-icon {
--mdc-icon-size: 40px;
color: var(--primary-color);
}
.mdc-deprecated-list-item__meta {
width: auto;
white-space: nowrap;
}
.mdc-deprecated-list-item__meta > * {
margin-right: 8px;
margin-inline-end: 8px;
margin-inline-start: initial;
}
.mdc-deprecated-list-item__meta > *:last-child {
margin-right: 0px;
margin-inline-end: 0px;
margin-inline-start: initial;
}
ha-icon-next {
margin-right: 8px;
margin-inline-end: 8px;
margin-inline-start: initial;
}
.open-in-new {
--mdc-icon-size: 22px;
padding: 1px;
}
.custom {
color: var(--warning-color);
}
.overwrites {
color: var(--error-color);
}
`,
];
}
static styles: CSSResultGroup = [
HaListItemButton.styles,
css`
.start {
--mdc-icon-size: 32px;
height: 32px;
}
.end {
color: var(--ha-color-text-secondary);
display: flex;
align-items: center;
gap: var(--ha-space-2);
}
.discovered-icon {
color: var(--primary-color);
}
.open-in-new {
--mdc-icon-size: 22px;
padding: 1px;
}
.end .custom {
color: var(--warning-color);
}
.end .overwrites {
color: var(--error-color);
}
`,
];
}
declare global {
@@ -3,16 +3,28 @@ import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import { dynamicElement } from "../../../../../common/dom/dynamic-element-directive";
import { fireEvent } from "../../../../../common/dom/fire_event";
import { computeDomain } from "../../../../../common/entity/compute_domain";
import { computeDeviceName } from "../../../../../common/entity/compute_device_name";
import { navigate } from "../../../../../common/navigate";
import "../../../../../components/ha-dialog-footer";
import "../../../../../components/ha-icon-button-arrow-prev";
import "../../../../../components/ha-button";
import "../../../../../components/ha-dialog";
import {
commissionMatterDevice,
redirectOnNewMatterDevice,
watchForNewMatterDevice,
} from "../../../../../data/matter";
import { haStyleDialog } from "../../../../../resources/styles";
import type { HomeAssistant } from "../../../../../types";
import type { DeviceRegistryEntry } from "../../../../../data/device/device_registry";
import { updateDeviceRegistryEntry } from "../../../../../data/device/device_registry";
import {
getAutomaticEntityIds,
getExtendedEntityRegistryEntries,
updateEntityRegistryEntry,
type ExtEntityRegistryEntry,
} from "../../../../../data/entity/entity_registry";
import { showAlertDialog } from "../../../../../dialogs/generic/show-dialog-box";
import "./matter-add-device/matter-add-device-apple-home";
import "./matter-add-device/matter-add-device-existing";
import "./matter-add-device/matter-add-device-generic";
@@ -21,6 +33,7 @@ import "./matter-add-device/matter-add-device-google-home-fallback";
import "./matter-add-device/matter-add-device-main";
import "./matter-add-device/matter-add-device-new";
import "./matter-add-device/matter-add-device-commissioning";
import "./matter-add-device/matter-add-device-device-added";
import { showToast } from "../../../../../util/toast";
export type MatterAddDeviceStep =
@@ -31,7 +44,8 @@ export type MatterAddDeviceStep =
| "google_home_fallback"
| "apple_home"
| "generic"
| "commissioning";
| "commissioning"
| "device_added";
declare global {
interface HASSDomEvents {
@@ -50,6 +64,7 @@ const BACK_STEP: Record<MatterAddDeviceStep, MatterAddDeviceStep | undefined> =
apple_home: "existing",
generic: "existing",
commissioning: undefined,
device_added: undefined,
};
@customElement("dialog-matter-add-device")
@@ -62,23 +77,90 @@ class DialogMatterAddDevice extends LitElement {
@state() _step: MatterAddDeviceStep = "main";
@state() private _newDevice?: DeviceRegistryEntry;
@state() private _mainEntity?: ExtEntityRegistryEntry;
@state() private _deviceAddedState: {
name: string;
area: string | undefined;
deviceClass: string | undefined;
hasPendingUpdates: boolean;
} = {
name: "",
area: undefined,
deviceClass: undefined,
hasPendingUpdates: false,
};
private _mainEntityFetched = false;
private _unsub?: UnsubscribeFunc;
public showDialog(): void {
this._open = true;
this._unsub = redirectOnNewMatterDevice(this.hass, () =>
this.closeDialog()
);
this._unsub = watchForNewMatterDevice(this.hass, (device) => {
this._newDevice = device;
this._step = "device_added";
this._fetchMainEntity();
});
}
public closeDialog(): void {
this._open = false;
}
protected updated(changedProps: Map<string, unknown>): void {
// Retry fetching main entity when hass updates (entities may not be available immediately)
if (
changedProps.has("hass") &&
this._newDevice &&
!this._mainEntityFetched
) {
this._fetchMainEntity();
}
}
private async _fetchMainEntity(): Promise<void> {
if (!this._newDevice || this._mainEntityFetched) return;
const entityIds = Object.values(this.hass.entities)
.filter((e) => e.device_id === this._newDevice!.id)
.map((e) => e.entity_id);
if (!entityIds.length) return;
this._mainEntityFetched = true;
const entries = await getExtendedEntityRegistryEntries(
this.hass,
entityIds
);
const mainEntry = Object.values(entries).find(
(e) => e.original_name === null
);
if (!mainEntry) return;
const domain = computeDomain(mainEntry.entity_id);
if (domain === "cover" || domain === "binary_sensor") {
this._mainEntity = mainEntry;
}
}
private _dialogClosed(): void {
this._open = false;
this._step = "main";
this._pairingCode = "";
this._newDevice = undefined;
this._mainEntity = undefined;
this._mainEntityFetched = false;
this._deviceAddedState = {
name: "",
area: undefined,
deviceClass: undefined,
hasPendingUpdates: false,
};
this._unsub?.();
this._unsub = undefined;
fireEvent(this, "dialog-closed", { dialog: this.localName });
@@ -93,6 +175,17 @@ class DialogMatterAddDevice extends LitElement {
this._pairingCode = ev.detail.code;
}
private _handleDeviceAddedChanged(
ev: CustomEvent<{
name: string;
area: string | undefined;
deviceClass: string | undefined;
hasPendingUpdates: boolean;
}>
) {
this._deviceAddedState = ev.detail;
}
private _back() {
const backStep = BACK_STEP[this._step];
if (!backStep) return;
@@ -104,12 +197,15 @@ class DialogMatterAddDevice extends LitElement {
<div
@pairing-code-changed=${this._handlePairingCodeChanged}
@step-selected=${this._handleStepSelected}
@device-added-changed=${this._handleDeviceAddedChanged}
.hass=${this.hass}
>
${dynamicElement(
`matter-add-device-${this._step.replaceAll("_", "-")}`,
{
hass: this.hass,
device: this._newDevice,
mainEntity: this._mainEntity,
}
)}
</div>
@@ -129,8 +225,86 @@ class DialogMatterAddDevice extends LitElement {
),
duration: 2000,
});
this._step = savedStep;
}
this._step = savedStep;
// On success, keep showing commissioning spinner until watchForNewMatterDevice fires
}
private async _finishDeviceAdded(): Promise<void> {
const device = this._newDevice!;
const { name, area, deviceClass, hasPendingUpdates } =
this._deviceAddedState;
if (hasPendingUpdates) {
const origName = computeDeviceName(device) ?? "";
const nameChanged = name !== origName;
const origArea = device.area_id ?? undefined;
const areaChanged = area !== origArea;
if (nameChanged || areaChanged) {
await updateDeviceRegistryEntry(this.hass, device.id, {
...(nameChanged && { name_by_user: name || null }),
...(areaChanged && { area_id: area || null }),
}).catch((err: Error) =>
showAlertDialog(this, {
text: this.hass.localize(
"ui.panel.config.integrations.config_flow.error_saving_device",
{ error: err.message }
),
})
);
}
if (nameChanged && name) {
const entityIds = Object.values(this.hass.entities)
.filter((e) => e.device_id === device.id)
.map((e) => e.entity_id);
if (entityIds.length) {
const mapping = await getAutomaticEntityIds(this.hass, entityIds);
await Promise.allSettled(
Object.entries(mapping)
.filter((entry): entry is [string, string] => !!entry[1])
.map(([oldId, newId]) =>
updateEntityRegistryEntry(this.hass, oldId, {
new_entity_id: newId,
}).catch((err: Error) =>
showAlertDialog(this, {
text: this.hass.localize(
"ui.panel.config.integrations.config_flow.error_saving_entity",
{ error: err.message }
),
})
)
)
);
}
}
if (this._mainEntity) {
const origClass =
this._mainEntity.device_class ??
this._mainEntity.original_device_class ??
undefined;
if (deviceClass !== origClass) {
await updateEntityRegistryEntry(
this.hass,
this._mainEntity.entity_id,
{ device_class: deviceClass || null }
).catch((err: Error) =>
showAlertDialog(this, {
text: this.hass.localize(
"ui.panel.config.integrations.config_flow.error_saving_entity",
{ error: err.message }
),
})
);
}
}
}
this.closeDialog();
navigate(`/config/devices/device/${device.id}`);
}
private _renderActions() {
@@ -156,6 +330,19 @@ class DialogMatterAddDevice extends LitElement {
</ha-button>
`;
}
if (this._step === "device_added") {
return html`
<ha-button slot="primaryAction" @click=${this._finishDeviceAdded}>
${this._deviceAddedState.hasPendingUpdates
? this.hass.localize(
"ui.dialogs.matter-add-device.device_added.finish"
)
: this.hass.localize(
"ui.dialogs.matter-add-device.device_added.skip"
)}
</ha-button>
`;
}
return nothing;
}
@@ -0,0 +1,283 @@
import { css, html, LitElement, nothing, type PropertyValues } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { computeDomain } from "../../../../../../common/entity/compute_domain";
import { computeDeviceName } from "../../../../../../common/entity/compute_device_name";
import { caseInsensitiveStringCompare } from "../../../../../../common/string/compare";
import { fireEvent } from "../../../../../../common/dom/fire_event";
import "../../../../../../components/ha-area-picker";
import "../../../../../../components/input/ha-input";
import "../../../../../../components/ha-select";
import "../../../../../../components/ha-dropdown-item";
import type { HaSelectSelectEvent } from "../../../../../../components/ha-select";
import type { ExtEntityRegistryEntry } from "../../../../../../data/entity/entity_registry";
import type { DeviceRegistryEntry } from "../../../../../../data/device/device_registry";
import type { HomeAssistant } from "../../../../../../types";
import { brandsUrl } from "../../../../../../util/brands-url";
import { sharedStyles } from "./matter-add-device-shared-styles";
import { OVERRIDE_DEVICE_CLASSES } from "../../../../entities/entity-registry-settings-editor";
declare global {
interface HASSDomEvents {
"device-added-changed": {
name: string;
area: string | undefined;
deviceClass: string | undefined;
hasPendingUpdates: boolean;
};
}
}
@customElement("matter-add-device-device-added")
class MatterAddDeviceDeviceAdded extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public device!: DeviceRegistryEntry;
@property({ attribute: false }) public mainEntity?: ExtEntityRegistryEntry;
@state() private _deviceName = "";
@state() private _area: string | undefined;
@state() private _deviceClass: string | undefined;
private _initialized = false;
private _deviceClassInitialized = false;
protected willUpdate(changedProps: PropertyValues) {
if (!this._initialized && this.device) {
this._initialized = true;
this._deviceName = computeDeviceName(this.device) ?? "";
this._area = this.device.area_id ?? undefined;
}
if (
!this._deviceClassInitialized &&
(changedProps.has("mainEntity") || this._initialized) &&
this.mainEntity
) {
this._deviceClassInitialized = true;
this._deviceClass =
this.mainEntity.device_class ??
this.mainEntity.original_device_class ??
undefined;
}
}
private get _deviceClassOptions(): string[][] | undefined {
if (!this.mainEntity) return undefined;
const domain = computeDomain(this.mainEntity.entity_id);
const deviceClasses = OVERRIDE_DEVICE_CLASSES[domain];
if (!deviceClasses) return undefined;
const options: string[][] = [[], []];
for (const deviceClass of deviceClasses) {
if (
this.mainEntity.original_device_class &&
deviceClass.includes(this.mainEntity.original_device_class)
) {
options[0] = deviceClass;
} else {
options[1].push(...deviceClass);
}
}
return options;
}
private get _hasPendingUpdates(): boolean {
const origName = computeDeviceName(this.device) ?? "";
const origArea = this.device.area_id ?? undefined;
const origDeviceClass =
this.mainEntity?.device_class ??
this.mainEntity?.original_device_class ??
undefined;
return (
this._deviceName !== origName ||
this._area !== origArea ||
(this.mainEntity !== undefined && this._deviceClass !== origDeviceClass)
);
}
protected updated(changedProps: Map<string, unknown>) {
if (
changedProps.has("_deviceName") ||
changedProps.has("_area") ||
changedProps.has("_deviceClass")
) {
fireEvent(this, "device-added-changed", {
name: this._deviceName,
area: this._area,
deviceClass: this._deviceClass,
hasPendingUpdates: this._hasPendingUpdates,
});
}
}
private _deviceClassesSorted = memoizeOne(
(domain: string, deviceClasses: string[]) =>
deviceClasses
.map((deviceClass) => ({
deviceClass,
label: this.hass.localize(
`ui.dialogs.entity_registry.editor.device_classes.${domain}.${deviceClass}`
),
}))
.sort((a, b) =>
caseInsensitiveStringCompare(
a.label,
b.label,
this.hass.locale.language
)
)
);
protected render() {
if (!this.device) return nothing;
const domain = this.mainEntity
? computeDomain(this.mainEntity.entity_id)
: undefined;
const deviceClassOptions = this._deviceClassOptions;
return html`
<div class="content">
<div class="device">
<div class="device-info">
<img
alt="Matter"
src=${brandsUrl(
{
domain: "matter",
type: "icon",
darkOptimized: this.hass.themes?.darkMode,
},
this.hass.auth.data.hassUrl
)}
crossorigin="anonymous"
referrerpolicy="no-referrer"
/>
<div class="device-name">
<span>${computeDeviceName(this.device)}</span>
<span class="secondary">Matter</span>
</div>
</div>
<ha-input
.label=${this.hass.localize(
"ui.panel.config.integrations.config_flow.device_name"
)}
.value=${this._deviceName}
@change=${this._deviceNameChanged}
></ha-input>
<ha-area-picker
.hass=${this.hass}
.value=${this._area}
@value-changed=${this._areaPicked}
></ha-area-picker>
${deviceClassOptions && domain
? html`
<ha-select
.label=${this.hass.localize(
"ui.dialogs.entity_registry.editor.device_class"
)}
.value=${this._deviceClass
? this.hass.localize(
`ui.dialogs.entity_registry.editor.device_classes.${domain}.${this._deviceClass}`
)
: undefined}
clearable
@selected=${this._deviceClassChanged}
>
${this._deviceClassesSorted(
domain,
deviceClassOptions[0]
).map(
(entry) => html`
<ha-dropdown-item
.value=${entry.deviceClass}
.selected=${entry.deviceClass === this._deviceClass}
>
${entry.label}
</ha-dropdown-item>
`
)}
${deviceClassOptions[0].length && deviceClassOptions[1].length
? html`<wa-divider></wa-divider>`
: nothing}
${this._deviceClassesSorted(
domain,
deviceClassOptions[1]
).map(
(entry) => html`
<ha-dropdown-item
.value=${entry.deviceClass}
.selected=${entry.deviceClass === this._deviceClass}
>
${entry.label}
</ha-dropdown-item>
`
)}
</ha-select>
`
: nothing}
</div>
</div>
`;
}
private _deviceNameChanged(ev: InputEvent) {
this._deviceName = (ev.currentTarget as HTMLInputElement).value;
}
private _areaPicked(ev: CustomEvent<{ value: string }>) {
this._area = ev.detail.value || undefined;
}
private _deviceClassChanged(ev: HaSelectSelectEvent<string, true>) {
this._deviceClass = ev.detail.value;
}
static styles = [
sharedStyles,
css`
.device {
border: 1px solid var(--divider-color);
padding: var(--ha-space-2);
border-radius: var(--ha-border-radius-sm);
}
.device-info {
display: flex;
align-items: center;
gap: var(--ha-space-2);
margin-bottom: var(--ha-space-1);
}
.device-info img {
width: 40px;
height: 40px;
}
.device-name {
display: flex;
flex-direction: column;
justify-content: center;
}
.secondary {
color: var(--secondary-text-color);
font-size: var(--ha-font-size-s);
}
ha-input {
margin: var(--ha-space-2) 0;
}
ha-area-picker,
ha-select {
display: block;
margin-top: var(--ha-space-2);
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
"matter-add-device-device-added": MatterAddDeviceDeviceAdded;
}
}
@@ -85,6 +85,12 @@ class MatterAddDeviceNew extends LitElement {
static styles = [
sharedStyles,
css`
.content {
display: flex;
align-items: center;
flex-direction: column;
text-align: center;
}
.app-qr {
margin: 24px auto 0 auto;
display: flex;
@@ -506,7 +506,6 @@ export class ThreadConfigPanel extends SubscribeMixin(LitElement) {
this._refresh();
},
startFlowHandler: "otbr",
showAdvanced: this.hass.userData?.showAdvanced,
});
}
@@ -3,18 +3,16 @@ import { mdiClose } from "@mdi/js";
import type { CSSResultGroup, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../../../../common/dom/fire_event";
import "../../../../../components/ha-button";
import "../../../../../components/ha-dialog";
import "../../../../../components/ha-dialog-footer";
import "../../../../../components/ha-icon-button";
import "../../../../../components/ha-spinner";
import "../../../../../components/input/ha-input-search";
import "../../../../../components/item/ha-list-item-option";
import type { HaListItemOption } from "../../../../../components/item/ha-list-item-option";
import "../../../../../components/list/ha-list-selectable";
import type { HaListSelectable } from "../../../../../components/list/ha-list-selectable";
import type { HaListSelectedDetail } from "../../../../../components/list/types";
import "../../../../../components/ha-spinner";
import "../../../../../components/list/ha-list-selectable-virtualized";
import type { ZHADeviceEndpoint, ZHAGroup } from "../../../../../data/zha";
import {
addMembersToGroup,
@@ -23,7 +21,6 @@ import {
} from "../../../../../data/zha";
import type { HassDialog } from "../../../../../dialogs/make-dialog-manager";
import { haStyleScrollbar } from "../../../../../resources/styles";
import { loadVirtualizer } from "../../../../../resources/virtualizer";
import type { HomeAssistant } from "../../../../../types";
import type { ZHAAddGroupMembersDialogParams } from "./show-dialog-zha-add-group-members";
@@ -50,8 +47,6 @@ class DialogZHAAddGroupMembers
@state() private _selectedDevicesToAdd: string[] = [];
@state() private _virtualizerReady = false;
private _fetchDataToken = 0;
public showDialog(params: ZHAAddGroupMembersDialogParams): void {
@@ -80,7 +75,6 @@ class DialogZHAAddGroupMembers
this._loading = false;
this._processingAdd = false;
this._selectedDevicesToAdd = [];
this._virtualizerReady = false;
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
@@ -89,7 +83,10 @@ class DialogZHAAddGroupMembers
return nothing;
}
const deviceEndpoints = this._filteredDeviceEndpoints;
const deviceEndpoints = this._filteredDeviceEndpoints(
this._filter,
this._availableDeviceEndpoints
);
const showSearch =
this._availableDeviceEndpoints.length > 5 || this._filter;
@@ -100,7 +97,6 @@ class DialogZHAAddGroupMembers
"ui.panel.config.zha.groups.add_members"
)}
?prevent-scrim-close=${this._selectedDevicesToAdd.length > 0}
@after-show=${this._loadVirtualizer}
@closed=${this._dialogClosed}
>
<ha-icon-button
@@ -126,22 +122,14 @@ class DialogZHAAddGroupMembers
<div class="list-container">
${deviceEndpoints.length
? html`
${this._virtualizerReady
? html`
<ha-list-selectable
multi
@ha-list-selected=${this._handleSelected}
>
<lit-virtualizer
scroller
class="ha-scrollbar"
.items=${deviceEndpoints}
.renderItem=${this._renderDeviceEndpoint}
.keyFunction=${this._keyFunction}
></lit-virtualizer>
</ha-list-selectable>
`
: this._renderLoadingSpinner()}
<ha-list-selectable-virtualized
multi
.rows=${deviceEndpoints}
.rowRenderer=${this._renderDeviceEndpoint}
@ha-list-item-selected=${this._handleSelected}
@ha-list-item-deselected=${this._handleDeselected}
>
</ha-list-selectable-virtualized>
`
: html`
<div class="empty-list">
@@ -205,34 +193,32 @@ class DialogZHAAddGroupMembers
);
}
private get _filteredDeviceEndpoints(): ZHADeviceEndpoint[] {
const normalizedFilter = this._filter.trim().toLowerCase();
const deviceEndpoints = this._availableDeviceEndpoints;
private _filteredDeviceEndpoints = memoizeOne(
(filter: string, availableDeviceEndpoints: ZHADeviceEndpoint[]) => {
const normalizedFilter = filter.trim().toLowerCase();
let deviceEndpoints = availableDeviceEndpoints;
if (!normalizedFilter) {
return deviceEndpoints;
if (normalizedFilter) {
deviceEndpoints = deviceEndpoints.filter((deviceEndpoint) =>
[
this._deviceEndpointName(deviceEndpoint),
this._deviceEndpointDetails(deviceEndpoint),
deviceEndpoint.device.ieee,
deviceEndpoint.device.manufacturer,
deviceEndpoint.device.model,
]
.filter(Boolean)
.some((value) => value!.toLowerCase().includes(normalizedFilter))
);
}
return deviceEndpoints.map((deviceEndpoint) => ({
id: this._deviceEndpointId(deviceEndpoint),
interactive: true,
...deviceEndpoint,
}));
}
return deviceEndpoints.filter((deviceEndpoint) =>
[
this._deviceEndpointName(deviceEndpoint),
this._deviceEndpointDetails(deviceEndpoint),
deviceEndpoint.device.ieee,
deviceEndpoint.device.manufacturer,
deviceEndpoint.device.model,
]
.filter(Boolean)
.some((value) => value!.toLowerCase().includes(normalizedFilter))
);
}
private async _loadVirtualizer(): Promise<void> {
await loadVirtualizer();
this._virtualizerReady = true;
}
private _keyFunction = (deviceEndpoint: unknown): string =>
this._deviceEndpointId(deviceEndpoint as ZHADeviceEndpoint);
);
private _renderDeviceEndpoint: RenderItemFunction<ZHADeviceEndpoint> = (
deviceEndpoint
@@ -305,26 +291,30 @@ class DialogZHAAddGroupMembers
this._filter = (ev.currentTarget as HTMLInputElement).value;
}
private _handleSelected(ev: CustomEvent<HaListSelectedDetail>): void {
const list = ev.currentTarget as HaListSelectable;
private _handleSelected(ev: CustomEvent<number>): void {
let selectedDevicesToAdd = this._selectedDevicesToAdd;
const item = this._filteredDeviceEndpoints(
this._filter,
this._availableDeviceEndpoints
)[ev.detail];
if (item && !selectedDevicesToAdd.includes(item.id)) {
selectedDevicesToAdd = [...selectedDevicesToAdd, item.id];
}
ev.detail.diff?.added.forEach((index) => {
const item = list.items[index] as HaListItemOption | undefined;
if (item?.value && !selectedDevicesToAdd.includes(item.value)) {
selectedDevicesToAdd = [...selectedDevicesToAdd, item.value];
}
});
ev.detail.diff?.removed.forEach((index) => {
const item = list.items[index] as HaListItemOption | undefined;
if (item?.value) {
selectedDevicesToAdd = selectedDevicesToAdd.filter(
(selectedDeviceId) => selectedDeviceId !== item.value
);
}
});
this._selectedDevicesToAdd = selectedDevicesToAdd;
}
private _handleDeselected(ev: CustomEvent<number>): void {
let selectedDevicesToAdd = this._selectedDevicesToAdd;
const item = this._filteredDeviceEndpoints(
this._filter,
this._availableDeviceEndpoints
)[ev.detail];
if (item && selectedDevicesToAdd.includes(item.id)) {
selectedDevicesToAdd = selectedDevicesToAdd.filter(
(value) => value !== item.id
);
}
this._selectedDevicesToAdd = selectedDevicesToAdd;
}
@@ -385,7 +375,7 @@ class DialogZHAAddGroupMembers
overflow: hidden;
}
lit-virtualizer {
ha-list-selectable-virtualized {
display: block;
width: 100%;
height: 100%;
@@ -13,7 +13,6 @@ import "../../../../../components/item/ha-list-item-option";
import type { HaListItemOption } from "../../../../../components/item/ha-list-item-option";
import "../../../../../components/list/ha-list-selectable";
import type { HaListSelectable } from "../../../../../components/list/ha-list-selectable";
import type { HaListSelectedDetail } from "../../../../../components/list/types";
import {
areasContext,
internationalizationContext,
@@ -103,7 +102,8 @@ export class ZHADeviceEndpointList extends LitElement {
? html`
<ha-list-selectable
multi
@ha-list-selected=${this._handleListSelectionChanged}
@ha-list-item-selected=${this._handleItemSelected}
@ha-list-item-deselected=${this._handleItemDeselected}
>
${repeat(
deviceEndpoints,
@@ -261,36 +261,38 @@ export class ZHADeviceEndpointList extends LitElement {
this._filter = (ev.currentTarget as HTMLInputElement).value;
}
private _handleListSelectionChanged(
ev: CustomEvent<HaListSelectedDetail>
): void {
private _handleItemSelected(ev: CustomEvent<number>): void {
const list = ev.currentTarget as HaListSelectable;
let selectedDeviceIds = this._selectedDeviceIds;
ev.detail.diff?.added.forEach((index) => {
const item = list.items[index] as HaListItemOption | undefined;
if (item?.value) {
selectedDeviceIds = this._setSelectedDeviceId(
selectedDeviceIds,
item.value,
true
);
}
});
const item = list.items[ev.detail] as HaListItemOption | undefined;
if (item?.value) {
selectedDeviceIds = this._setSelectedDeviceId(
selectedDeviceIds,
item.value,
true
);
ev.detail.diff?.removed.forEach((index) => {
const item = list.items[index] as HaListItemOption | undefined;
if (item?.value) {
selectedDeviceIds = this._setSelectedDeviceId(
selectedDeviceIds,
item.value,
false
);
}
});
this._selectedDeviceIds = selectedDeviceIds;
this._fireSelectionChanged();
}
}
this._selectedDeviceIds = selectedDeviceIds;
this._fireSelectionChanged();
private _handleItemDeselected(ev: CustomEvent<number>): void {
const list = ev.currentTarget as HaListSelectable;
let selectedDeviceIds = this._selectedDeviceIds;
const item = list.items[ev.detail] as HaListItemOption | undefined;
if (item?.value) {
selectedDeviceIds = this._setSelectedDeviceId(
selectedDeviceIds,
item.value,
false
);
this._selectedDeviceIds = selectedDeviceIds;
this._fireSelectionChanged();
}
}
private _setSelectedDeviceId(
@@ -27,8 +27,6 @@ class HaConfigScript extends HassRouterPage {
@property({ attribute: "is-wide", type: Boolean }) public isWide = false;
@property({ attribute: false }) public showAdvanced = false;
@property({ attribute: false }) public cloudStatus?: CloudStatus;
@property({ attribute: false }) public scripts: ScriptEntity[] = [];
@@ -78,7 +76,6 @@ class HaConfigScript extends HassRouterPage {
pageEl.narrow = this.narrow;
pageEl.isWide = this.isWide;
pageEl.route = this.routeTail;
pageEl.showAdvanced = this.showAdvanced;
pageEl.cloudStatus = this.cloudStatus;
if (this.hass) {

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