mirror of
https://github.com/home-assistant/frontend.git
synced 2026-06-01 22:12:00 +00:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e072a66bf0 | |||
| a1305ba8fe | |||
| b73732acdb | |||
| d950514104 | |||
| f37cf1e848 | |||
| a188ef1b7a |
@@ -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**
|
||||
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -20,6 +20,7 @@ export class HaCheckListItem extends CheckListItemBase {
|
||||
separateCheckboxClick = false;
|
||||
|
||||
async onChange(event) {
|
||||
event.stopPropagation();
|
||||
super.onChange(event);
|
||||
fireEvent(this, event.type);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
+20
-19
@@ -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);
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user