Compare commits

...

6 Commits

Author SHA1 Message Date
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
26 changed files with 1135 additions and 796 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
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) {
+137 -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,46 @@ 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 || "")}
.value=${this.value}
.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 +142,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 +212,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 +226,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 {
+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,58 @@
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 driven by
* the id-based `value` property; the component handles index/id translation
* and fires `ha-list-value-changed` when the user changes the selection.
*
* Pass an externally-filtered subset of rows and the full `value`: ids that
* aren't in `rows` are preserved untouched, so filtering the visible list
* doesn't deselect items outside the current view.
*
* @attr {boolean} multi - Whether multiple options can be selected at once.
*
* @fires ha-list-value-changed - Fires on user-driven selection changes.
* `detail: { value, added, removed }` (all id-arrays).
* @fires ha-list-item-selected - Lower-level index-based event from the base mixin.
* @fires ha-list-item-deselected - Lower-level index-based event from the base mixin.
*/
@customElement("ha-list-selectable-virtualized")
export class HaListSelectableVirtualized extends SelectableMixin(
HaListVirtualized
) {
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;
}
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;
}
}
+7 -187
View File
@@ -1,8 +1,7 @@
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 type { HaListItemOption } from "../item/ha-list-item-option";
import { HaListBase } from "./ha-list-base";
import type { HaListSelectedDetail } from "./types";
import { SelectableMixin } from "./ha-list-selectable-mixin";
/**
* @element ha-list-selectable
@@ -14,195 +13,16 @@ 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!;
}
export class HaListSelectable extends SelectableMixin(HaListBase) {
public get selectedItems(): HaListItemOption[] {
return this._sortedSelectedIndices()
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;
}
}
};
}
declare global {
+333
View File
@@ -0,0 +1,333 @@
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";
export interface HaListVirtualizedItem {
id: string;
interactive?: boolean;
disabled?: boolean;
[key: string]: unknown;
}
/**
* @element ha-list-virtualized
* @extends {HaListBase}
*
* @summary
* Virtualized list. Renders only the items currently in view to keep large
* lists performant.
*/
@customElement("ha-list-virtualized")
export class HaListVirtualized extends HaListBase {
@state() private _virtualizerReady = false;
@property({ attribute: false })
public rows!: HaListVirtualizedItem[];
@property({ attribute: false })
public rowRenderer?: RenderItemFunction<HaListVirtualizedItem>;
@property({ attribute: "pin-index", type: Number }) public pinIndex?: number;
@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>`;
}
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" });
}
}
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;
this.onRangeChanged(ev.first, ev.last);
// rangeChanged fires before the virtualizer renders the new children,
// so wait for layout to settle before reading/focusing them.
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;
// Always keep roving tabindex in sync with the rendered range so the
// active item is the tab target — otherwise nothing in the list is
// tabbable and focus falls through to the scroller container.
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));
});
}
/** Hook fired whenever the visible row range changes. */
// eslint-disable-next-line @typescript-eslint/no-empty-function
protected onRangeChanged(_first: number, _last: number) {}
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 {
// Number of rendered (visible) rows in the current range. Fall back to
// the base default when the range isn't known yet.
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;
@@ -341,6 +341,7 @@ export default class HaAutomationActionRow extends LitElement {
? html`
<ha-svg-icon
id="note-icon"
tabindex="0"
.path=${mdiCommentTextOutline}
.label=${this.hass.localize(
"ui.panel.config.automation.editor.note.label"
@@ -231,6 +231,7 @@ export default class HaAutomationConditionRow extends LitElement {
? html`
<ha-svg-icon
id="note-icon"
tabindex="0"
.path=${mdiCommentTextOutline}
.label=${this.hass.localize(
"ui.panel.config.automation.editor.note.label"
@@ -161,6 +161,7 @@ export default class HaAutomationOptionRow extends LitElement {
? html`
<ha-svg-icon
id="note-icon"
tabindex="0"
.path=${mdiCommentTextOutline}
.label=${this.hass.localize(
"ui.panel.config.automation.editor.note.label"
@@ -256,6 +256,7 @@ export default class HaAutomationTriggerRow extends LitElement {
(this.trigger as Exclude<Trigger, TriggerList>).note?.trim()
? html`
<ha-svg-icon
tabindex="0"
id="note-icon"
.path=${mdiCommentTextOutline}
.label=${this.hass.localize(
@@ -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}`
),
});
}
@@ -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) {
@@ -781,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;
}
@@ -802,7 +813,6 @@ class AddIntegrationDialog extends LitElement {
}
static styles = [
haStyleScrollbar,
haStyleDialog,
css`
ha-dialog {
@@ -830,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%;
}
`,
];
}
@@ -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 {
@@ -8,13 +8,12 @@ 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 type { ZHADeviceEndpoint, ZHAGroup } from "../../../../../data/zha";
import {
addMembersToGroup,
@@ -130,7 +129,9 @@ class DialogZHAAddGroupMembers
? html`
<ha-list-selectable
multi
@ha-list-selected=${this._handleSelected}
@ha-list-item-selected=${this._handleSelected}
@ha-list-item-deselected=${this
._handleDeselected}
>
<lit-virtualizer
scroller
@@ -305,26 +306,26 @@ class DialogZHAAddGroupMembers
this._filter = (ev.currentTarget as HTMLInputElement).value;
}
private _handleSelected(ev: CustomEvent<HaListSelectedDetail>): void {
private _handleSelected(ev: CustomEvent<number>): void {
const list = ev.currentTarget as HaListSelectable;
let selectedDevicesToAdd = this._selectedDevicesToAdd;
const item = list.items[ev.detail] as HaListItemOption | undefined;
if (item?.value && !selectedDevicesToAdd.includes(item.value)) {
selectedDevicesToAdd = [...selectedDevicesToAdd, item.value];
}
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 {
const list = ev.currentTarget as HaListSelectable;
let selectedDevicesToAdd = this._selectedDevicesToAdd;
const item = list.items[ev.detail] as HaListItemOption | undefined;
if (item?.value && selectedDevicesToAdd.includes(item.value)) {
selectedDevicesToAdd = selectedDevicesToAdd.filter(
(value) => value !== item.value
);
}
this._selectedDevicesToAdd = selectedDevicesToAdd;
}
@@ -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(
@@ -158,6 +158,7 @@ export default class HaScriptFieldRow extends LitElement {
? html`
<ha-svg-icon
id="note-icon"
tabindex="0"
.path=${mdiCommentTextOutline}
.label=${this.hass.localize(
"ui.panel.config.automation.editor.note.label"
@@ -1,9 +1,10 @@
import { consume } from "@lit/context";
import { mdiAlertCircle, mdiEye, mdiEyeOff } from "@mdi/js";
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { css, html } from "lit";
import type { CSSResultGroup, PropertyValues } from "lit";
import { css, html, LitElement } from "lit";
import { customElement, property, state } from "lit/decorators";
import { ConditionListenersController } from "../../../../common/controllers/condition-listeners-controller";
import "../../../../components/ha-alert";
import "../../../../components/ha-svg-icon";
import { HaRowItem } from "../../../../components/item/ha-row-item";
import type { HomeAssistant } from "../../../../types";
@@ -28,17 +29,15 @@ const STATE_ICONS: Record<VisibilityState, string> = {
/**
* @element ha-visibility-status
* @extends {HaRowItem}
*
* @summary
* Row-style banner that surfaces the live visibility result for a set of
* lovelace conditions. Replaces the static explanation alert at the top of
* card / section / badge / conditional-card visibility editors.
* Alert banner that surfaces the live visibility result for a set of
* lovelace conditions.
*
* @attr {"visible"|"hidden"|"invalid"} state - Computed visibility state (reflected for styling).
* @attr {"visible"|"hidden"|"invalid"} state - Computed visibility state
*/
@customElement("ha-visibility-status")
export class HaVisibilityStatus extends HaRowItem {
export class HaVisibilityStatus extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false })
@@ -48,7 +47,7 @@ export class HaVisibilityStatus extends HaRowItem {
@consume({ context: conditionsEntityContext, subscribe: true })
private _entityContext?: ConditionsEntityContext;
@property({ reflect: true })
@property()
public state: VisibilityState = "visible";
private _listeners = new ConditionListenersController(this);
@@ -71,23 +70,27 @@ export class HaVisibilityStatus extends HaRowItem {
}
}
protected override _renderInner(): TemplateResult {
public render() {
return html`
<div part="start" class="start">
<ha-svg-icon .path=${STATE_ICONS[this.state]}></ha-svg-icon>
</div>
<div part="content" class="content">
<div part="headline" class="headline">
<ha-alert
.alertType=${this.state === "visible"
? "success"
: this.state === "hidden"
? "warning"
: "error"}
>
<ha-svg-icon slot="icon" .path=${STATE_ICONS[this.state]}></ha-svg-icon>
<div class="headline">
${this.hass?.localize(
`ui.panel.lovelace.editor.condition-editor.visibility_status.${this.state}.headline`
)}
</div>
<div part="supporting-text" class="supporting">
<div class="supporting">
${this.hass?.localize(
`ui.panel.lovelace.editor.condition-editor.visibility_status.${this.state}.supporting${(this.conditions?.length ?? 0) === 0 ? "_empty" : ""}`
)}
</div>
</div>
</ha-alert>
`;
}
@@ -117,37 +120,13 @@ export class HaVisibilityStatus extends HaRowItem {
static styles: CSSResultGroup = [
HaRowItem.styles,
css`
:host {
ha-alert {
display: block;
border-radius: var(--ha-border-radius-xl);
transition: background-color var(--ha-animation-duration-normal)
ease-in-out;
}
.base {
padding: var(--ha-space-4);
}
:host([state="visible"]) {
background-color: var(--ha-color-fill-success-quiet-resting);
--visibility-status-color: var(--ha-color-on-success-normal);
}
:host([state="hidden"]) {
background-color: var(--ha-color-fill-warning-quiet-resting);
--visibility-status-color: var(--ha-color-on-warning-normal);
}
:host([state="invalid"]) {
background-color: var(--ha-color-fill-danger-quiet-resting);
--visibility-status-color: var(--ha-color-on-danger-normal);
}
.start {
align-self: start;
}
.start ha-svg-icon {
color: var(--visibility-status-color);
--mdc-icon-size: 24px;
}
.headline {
font-weight: var(--ha-font-weight-medium);
white-space: normal;
margin-bottom: var(--ha-space-1);
}
`,
];
+2 -1
View File
@@ -6319,7 +6319,8 @@
"resend_confirm_email": "Resend confirmation email",
"clicked_confirm": "I opened the confirmation link",
"confirm_email": "Check the email we just sent to {email} and open the confirmation link to continue.",
"account_created": "Account created! Check your email for instructions on how to activate your account."
"account_created": "Account created! Check your email for instructions on how to activate your account.",
"verification_email_sent": "Verification email sent. Check your inbox for instructions on how to activate your account."
},
"account": {
"download_support_package": "Download support package",