Compare commits

..

1 Commits

Author SHA1 Message Date
Paul Bottein f249e2d64d Add created/modified columns to automation, scene, and script tables 2026-05-28 10:00:04 +02:00
30 changed files with 956 additions and 1263 deletions
@@ -62,11 +62,10 @@ host reflects `aria-multiselectable`.
**Events**
- `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`).
- `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.
**Methods / getters**
+13 -53
View File
@@ -20,6 +20,7 @@ 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";
@@ -184,7 +185,7 @@ export class DemoHaList extends LitElement {
<ha-card header="Single select, appearance=line">
<ha-list-selectable
aria-label="Single select"
@ha-list-item-selected=${this._onSingle}
@ha-list-selected=${this._onSingle}
>
${this._options.map(
(o, i) => html`
@@ -204,8 +205,7 @@ export class DemoHaList extends LitElement {
<ha-list-selectable
multi
aria-label="Multi select line"
@ha-list-item-selected=${this._onMultiLineSelected}
@ha-list-item-deselected=${this._onMultiLineDeselected}
@ha-list-selected=${this._onMultiLine}
>
${this._options.map(
(o, i) => html`
@@ -227,8 +227,7 @@ export class DemoHaList extends LitElement {
<ha-list-selectable
multi
aria-label="Multi checkbox start"
@ha-list-item-selected=${this._onMultiCheckStartSelected}
@ha-list-item-deselected=${this._onMultiCheckStartDeselected}
@ha-list-selected=${this._onMultiCheckStart}
>
${this._options.map(
(o, i) => html`
@@ -254,8 +253,7 @@ selected: ${JSON.stringify(this._toJson(this._multiCheckStart))}</pre
<ha-list-selectable
multi
aria-label="Multi checkbox end"
@ha-list-item-selected=${this._onMultiCheckEndSelected}
@ha-list-item-deselected=${this._onMultiCheckEndDeselected}
@ha-list-selected=${this._onMultiCheckEnd}
>
${this._options.map(
(o, i) => html`
@@ -349,58 +347,20 @@ selected: ${JSON.stringify(this._toJson(this._multiCheckEnd))}</pre
this._buttonClicks++;
};
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 _onSingle = (ev: CustomEvent<HaListSelectedDetail>) => {
this._single = ev.detail.index;
};
private _onMultiLineSelected = (ev: CustomEvent<number>) => {
this._multiLine = this._withIndex(this._multiLine, ev.detail, true);
private _onMultiLine = (ev: CustomEvent<HaListSelectedDetail>) => {
this._multiLine = ev.detail.index;
};
private _onMultiLineDeselected = (ev: CustomEvent<number>) => {
this._multiLine = this._withIndex(this._multiLine, ev.detail, false);
private _onMultiCheckStart = (ev: CustomEvent<HaListSelectedDetail>) => {
this._multiCheckStart = 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
);
private _onMultiCheckEnd = (ev: CustomEvent<HaListSelectedDetail>) => {
this._multiCheckEnd = ev.detail.index;
};
static styles = css`
-1
View File
@@ -20,7 +20,6 @@ export class HaCheckListItem extends CheckListItemBase {
separateCheckboxClick = false;
async onChange(event) {
event.stopPropagation();
super.onChange(event);
fireEvent(this, event.type);
}
-1
View File
@@ -107,7 +107,6 @@ export class HaExpansionPanel extends LitElement {
}
const newExpanded = !this.expanded;
fireEvent(this, "expanded-will-change", { expanded: newExpanded });
this._container.style.overflow = "hidden";
if (newExpanded) {
+134 -138
View File
@@ -1,5 +1,5 @@
import { mdiFilterVariantRemove } from "@mdi/js";
import type { PropertyValues } from "lit";
import type { CSSResultGroup, PropertyValues } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, query, state } from "lit/decorators";
import memoizeOne from "memoize-one";
@@ -9,18 +9,14 @@ 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 {
@@ -38,12 +34,15 @@ export class HaFilterDevices extends LitElement {
@state() private _filter?: string;
@query("ha-list-selectable-virtualized")
private _listElement?: HaListSelectableVirtualized;
@query("ha-list") private _list?: HTMLElement;
public willUpdate(properties: PropertyValues<this>) {
super.willUpdate(properties);
if (!this.hasUpdated) {
loadVirtualizer();
}
if (
properties.has("value") &&
!deepEqual(this.value, properties.get("value"))
@@ -52,20 +51,6 @@ 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
@@ -81,7 +66,6 @@ export class HaFilterDevices extends LitElement {
<ha-icon-button
.path=${mdiFilterVariantRemove}
@click=${this._clearFilter}
@keydown=${this._handleClearFilterKeydown}
></ha-icon-button>`
: nothing}
</div>
@@ -90,45 +74,75 @@ export class HaFilterDevices extends LitElement {
appearance="outlined"
.value=${this._filter}
@input=${this._handleSearchChange}
@keydown=${this._handleSearchKeydown}
>
</ha-input-search>
<ha-list-selectable-virtualized
multi
.rows=${this._devices(this.hass.devices, this._filter || "")}
.rowRenderer=${this._renderItem}
@ha-list-item-selected=${this._handleAdded}
@ha-list-item-deselected=${this._handleRemoved}
></ha-list-selectable-virtualized>`
<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>`
: nothing}
</ha-expansion-panel>
`;
}
private _renderItem = (item?: HaFilterDevicesItem) =>
!item
? nothing
: html`<ha-list-item-option
style="width: 100%;"
appearance="checkbox"
selection-position="end"
.value=${item.id}
.selected=${this.value?.includes(item.id) ?? false}
>
<span slot="headline">${item.name}</span>
</ha-list-item-option>`;
private _keyFunction = (device) => device?.id;
private _handleAdded(ev: CustomEvent<number>) {
this.value = [
...(this.value ?? []),
this._devices(this.hass.devices, this._filter || "")[ev.detail].id,
];
private _renderItem = (device) =>
!device
? nothing
: html`<ha-check-list-item
tabindex="0"
.value=${device.id}
.selected=${this.value?.includes(device.id) ?? false}
>
${computeDeviceNameDisplay(
device,
this.hass.localize,
this.hass.states
)}
</ha-check-list-item>`;
private _handleItemKeydown(ev: KeyboardEvent) {
if (ev.key === "Enter" || ev.key === " ") {
ev.preventDefault();
this._handleItemClick(ev);
}
}
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 _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 _expandedWillChange(ev) {
@@ -141,38 +155,30 @@ export class HaFilterDevices extends LitElement {
private _handleSearchChange(ev: InputEvent) {
const target = ev.target as HaInputSearch;
this._filter = target.value ?? "";
}
private _handleSearchKeydown(ev: KeyboardEvent) {
if (ev.key === "ArrowDown" && this._listElement) {
ev.preventDefault();
this._listElement.focus();
}
this._filter = (target.value ?? "").toLowerCase();
}
private _devices = memoizeOne(
(
devices: HomeAssistant["devices"],
filter: string
): HaFilterDevicesItem[] => {
(devices: HomeAssistant["devices"], filter: string, _value) => {
const values = Object.values(devices);
return values
.map((device) => ({
id: device.id,
interactive: true,
name: computeDeviceNameDisplay(
device,
this.hass.localize,
this.hass.states
),
}))
.filter(
({ name }) =>
!filter || name.toLowerCase().includes(filter.toLowerCase())
(device) =>
!filter ||
computeDeviceNameDisplay(
device,
this.hass.localize,
this.hass.states
)
.toLowerCase()
.includes(filter)
)
.sort((a, b) =>
stringCompare(a.name, b.name, this.hass.locale.language)
stringCompare(
computeDeviceNameDisplay(a, this.hass.localize, this.hass.states),
computeDeviceNameDisplay(b, this.hass.localize, this.hass.states),
this.hass.locale.language
)
);
}
);
@@ -211,13 +217,6 @@ 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;
@@ -225,61 +224,58 @@ export class HaFilterDevices extends LitElement {
value: undefined,
items: undefined,
});
this._listElement?.clearSelection();
}
static styles = css`
:host {
border-bottom: 1px solid var(--divider-color);
}
:host([expanded]) {
flex: 1;
height: 0;
display: flex;
flex-direction: column;
}
static get styles(): CSSResultGroup {
return [
haStyleScrollbar,
css`
:host {
border-bottom: 1px solid var(--divider-color);
}
:host([expanded]) {
flex: 1;
height: 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;
}
`;
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;
}
`,
];
}
}
declare global {
+38 -48
View File
@@ -23,6 +23,7 @@ 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 {
@@ -41,7 +42,7 @@ export class HaFilterFloorAreas extends LitElement {
@state() private _shouldRender = false;
@query("ha-list-selectable") private _list?: HaListSelectable;
@query("ha-list-selectable") private _list?: HTMLElement;
public willUpdate(properties: PropertyValues<this>) {
super.willUpdate(properties);
@@ -74,7 +75,6 @@ export class HaFilterFloorAreas extends LitElement {
<ha-icon-button
.path=${mdiFilterVariantRemove}
@click=${this._clearFilter}
@keydown=${this._handleClearFilterKeydown}
></ha-icon-button>`
: nothing}
</div>
@@ -83,8 +83,7 @@ export class HaFilterFloorAreas extends LitElement {
<ha-list-selectable
class="ha-scrollbar"
multi
@ha-list-item-selected=${this._handleAdded}
@ha-list-item-deselected=${this._handleRemoved}
@ha-list-selected=${this._handleListChanged}
aria-label=${this.hass.localize(
"ui.panel.config.areas.caption"
)}
@@ -164,47 +163,46 @@ export class HaFilterFloorAreas extends LitElement {
`;
}
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) {
private _handleListChanged(ev: CustomEvent<HaListSelectedDetail>) {
if (!ev.detail.diff?.added.size && !ev.detail.diff?.removed.size) {
return;
}
this.value = {
...this.value,
[addedItem.type]: [
...(this.value[addedItem.type] || []),
addedItem.value,
],
};
}
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 };
private _handleRemoved(ev: CustomEvent<number>) {
if (!this.value) {
return;
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
),
};
}
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>) {
@@ -288,13 +286,6 @@ 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;
@@ -302,7 +293,6 @@ export class HaFilterFloorAreas extends LitElement {
value: undefined,
items: undefined,
});
this._list?.clearSelection();
}
static get styles(): CSSResultGroup {
-3
View File
@@ -38,9 +38,6 @@ 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);
}
+91 -149
View File
@@ -1,9 +1,9 @@
import { css, html, LitElement, type nothing, type TemplateResult } from "lit";
import type { CSSResultGroup, TemplateResult } from "lit";
import { css, html, LitElement } 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";
protected activeItemIndex = -1;
private _activeItemIndex = -1;
protected firstFocusableIndex = -1;
private _firstFocusableIndex = -1;
protected lastFocusableIndex = -1;
private _lastFocusableIndex = -1;
protected hasFocusableItem = false;
private _hasFocusableItem = false;
private _unbindKeys?: () => void;
@@ -63,28 +63,22 @@ 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,
PageDown: this._onPageDown,
PageUp: this._onPageUp,
Enter: this.onActivate,
Space: this.onActivate,
},
{ ignore: this._ignoreKeyEvent }
);
this.addEventListener("focusin", this.onFocusIn);
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.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
);
}
@@ -92,23 +86,25 @@ 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.itemCount) {
if (!this.items.length) {
super.focus(options);
return;
}
this.focusItemAtIndex(this.activeItemIndex >= 0 ? this.activeItemIndex : 0);
this.focusItemAtIndex(
this._activeItemIndex >= 0 ? this._activeItemIndex : 0
);
}
public focusItemAtIndex(index: number) {
@@ -119,19 +115,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.itemCount - 1, index));
if (!this.isFocusable(this.activeItemIndex)) {
this.activeItemIndex = this.firstFocusableIndex;
this._activeItemIndex = Math.max(0, Math.min(this.items.length - 1, index));
if (!this._isFocusable(this._activeItemIndex)) {
this._activeItemIndex = this._firstFocusableIndex;
}
this.applyActive(focusItem);
this._applyActive(focusItem);
}
/**
@@ -139,18 +135,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.itemCount ||
!this.hasFocusableItem ||
this.activeItemIndex < 0
this._activeItemIndex >= this.items.length ||
!this._hasFocusableItem ||
this._activeItemIndex < 0
) {
this.activeItemIndex = this.firstFocusableIndex;
this._activeItemIndex = this._firstFocusableIndex;
}
this.applyActive(false);
this._applyActive(false);
}
protected onItemRegister = (
private _onItemRegister = (
ev: HASSDomEvent<HaListItemRegistrationDetail>
) => {
ev.stopPropagation();
@@ -164,7 +160,7 @@ export class HaListBase extends LitElement {
this.updateListItems();
};
protected onItemUnregister = (
private _onItemUnregister = (
ev: HASSDomEvent<HaListItemRegistrationDetail>
) => {
ev.stopPropagation();
@@ -176,190 +172,136 @@ export class HaListBase extends LitElement {
this.updateListItems();
};
protected recomputeFocusableIndexes() {
private _recomputeFocusableIndexes() {
let first = -1;
let last = -1;
for (let i = 0; i < this.itemCount; i++) {
if (this.isFocusable(i)) {
for (let i = 0; i < this.items.length; 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 | typeof nothing {
return html`<div part="base" class="base ha-scrollbar">
protected render(): TemplateResult {
return html`<div part="base" class="base">
<slot></slot>
</div>`;
}
protected isFocusable(index: number): boolean {
private _isFocusable(index: number): boolean {
const item = this.items[index];
return !!item && item.interactive && !item.disabled;
}
protected applyActive(focusItem: boolean) {
private _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();
}
}
protected onFocusIn = (ev: FocusEvent) => {
private _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 _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)) {
private _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,
});
};
protected moveFocus(ev: KeyboardEvent, next: number) {
if (!this.hasFocusableItem) {
private _moveFocus(ev: KeyboardEvent, next: number) {
if (!this._hasFocusableItem || next < 0 || next === this._activeItemIndex) {
return;
}
ev.preventDefault();
if (next < 0 || next === this.activeItemIndex) {
return;
}
this.activeItemIndex = next;
this.applyActive(true);
}
protected get itemCount(): number {
return this.items.length;
this._activeItemIndex = next;
this._applyActive(true);
}
/**
* Step from `from` by `delta`, skipping non-interactive and disabled items.
* Pass `count` > 1 to advance by multiple focusable items (PageUp/Down).
* Returns the last focusable index reached, or `from` when none is.
* Returns `from` when no other focusable item can be reached (honouring
* `wrapFocus`).
*/
private _stepIndex(from: number, delta: 1 | -1, count = 1): number {
const n = this.itemCount;
if (!n || !this.hasFocusableItem) {
private _stepIndex(from: number, delta: 1 | -1): number {
const n = this.items.length;
if (!n || !this._hasFocusableItem) {
return from;
}
let last = from;
let i = from;
let landed = 0;
for (let step = 0; step < n && landed < count; step++) {
for (let step = 0; step < n; step++) {
i += delta;
if (i < 0 || i >= n) {
if (!this.wrapFocus) {
return last;
return from;
}
i = (i + n) % n;
}
if (this.isFocusable(i)) {
last = i;
landed++;
if (this._isFocusable(i)) {
return i;
}
}
return last;
return from;
}
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;
}
`,
];
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;
}
`;
}
declare global {
+1 -1
View File
@@ -30,7 +30,7 @@ export class HaListNav extends HaListBase {
part="nav"
aria-label=${ifDefined(this.ariaLabel ?? undefined)}
>
<div part="base" class="base ha-scrollbar" role="list">
<div part="base" class="base" role="list">
<slot></slot>
</div>
</nav>`;
@@ -1,85 +0,0 @@
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;
};
@@ -1,66 +0,0 @@
import { customElement } from "lit/decorators";
import { HaListItemOption } from "../item/ha-list-item-option";
import { SelectableMixin } from "./ha-list-selectable-mixin";
import { HaListVirtualized } from "./ha-list-virtualized";
/**
* @element ha-list-selectable-virtualized
* @extends {HaListVirtualized}
*
* @summary
* Virtualized selection list (role `listbox`). Rows must render
* `<ha-list-item-option>` as their top-level element. Selection is index-based:
* clicking a row fires `ha-list-item-selected` / `ha-list-item-deselected` with
* the row's index, and the row's `selected` attribute is toggled. Consumers own
* the source of truth — set each row's `selected` from their own state (for
* example, keyed by the option's `value`) and update it in the event handlers.
*
* Because selection is tracked per-row by the consumer, filtering the visible
* `rows` doesn't affect selections for items outside the current view.
*
* @attr {boolean} multi - Whether multiple options can be selected at once. In
* single-select mode, selecting a row clears any previous selection.
*
* @fires ha-list-item-selected - Fires when the user selects a row.
* `detail` is the row's index (number).
* @fires ha-list-item-deselected - Fires when the user deselects a row (multi-select only).
* `detail` is the row's index (number).
*/
@customElement("ha-list-selectable-virtualized")
export class HaListSelectableVirtualized extends SelectableMixin(
HaListVirtualized
) {
/**
* Hook: maps a clicked option to its absolute index by offsetting its
* position among the rendered (virtualized) children by `rangeStart`.
* Returns `-1` if it's not one of our rows or nothing is rendered yet.
*/
protected optionIndexOf(opt: HaListItemOption): number {
if (!this.virtualizerElement || this.rangeStart === -1) {
return -1;
}
const index = Array.from(this.virtualizerElement.children).indexOf(opt);
if (index === -1) {
return -1;
}
return this.rangeStart + index;
}
/** Deselects every currently rendered (visible) option. */
public clearSelection() {
if (!this.virtualizerElement || this.rangeStart === -1) {
return;
}
Array.from(this.virtualizerElement.children).forEach((opt) => {
if (opt instanceof HaListItemOption && opt.selected) {
opt.toggleAttribute("selected", false);
}
});
}
}
declare global {
interface HTMLElementTagNameMap {
"ha-list-selectable-virtualized": HaListSelectableVirtualized;
}
}
+192 -5
View File
@@ -1,6 +1,8 @@
import { customElement } from "lit/decorators";
import { customElement, property } from "lit/decorators";
import { fireEvent } from "../../common/dom/fire_event";
import { HaListItemOption } from "../item/ha-list-item-option";
import { HaListBase } from "./ha-list-base";
import { SelectableMixin } from "./ha-list-selectable-mixin";
import type { HaListSelectedDetail } from "./types";
/**
* @element ha-list-selectable
@@ -12,11 +14,196 @@ import { SelectableMixin } from "./ha-list-selectable-mixin";
*
* @attr {boolean} multi - Whether multiple options can be selected at once.
*
* @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).
* @fires ha-list-selected - Fired when the selection changes. `detail: HaListSelectedDetail`.
*/
@customElement("ha-list-selectable")
export class HaListSelectable extends SelectableMixin(HaListBase) {}
export class HaListSelectable extends HaListBase {
@property({ type: Boolean, reflect: true }) public multi = false;
protected override readonly hostRole = "listbox";
private _selectedIndices?: Set<number>;
public connectedCallback(): void {
super.connectedCallback();
this.addEventListener("click", this._onOptionClick);
this.setAttribute("aria-multiselectable", this.multi ? "true" : "false");
}
public disconnectedCallback(): void {
super.disconnectedCallback();
this.removeEventListener("click", this._onOptionClick);
}
public updated(changed: Map<string, unknown>) {
super.updated(changed);
if (changed.has("multi")) {
this.setAttribute("aria-multiselectable", this.multi ? "true" : "false");
if (!this.multi && (this._selectedIndices?.size ?? 0) > 1) {
const first = Math.min(...this._selectedIndices!);
this._setSelection(new Set([first]));
}
}
}
/**
* Returns the current selection. `number` (or `-1` if nothing) when single,
* `Set<number>` when multi.
*/
public get selected(): number | Set<number> {
if (this.multi) {
return new Set(this._selectedIndices);
}
return (this._selectedIndices?.size ?? 0) === 0
? -1
: this._selectedIndices!.values().next().value!;
}
public get selectedItems(): HaListItemOption[] {
return this._sortedSelectedIndices()
.map((i) => this.items[i] as HaListItemOption | undefined)
.filter((it): it is HaListItemOption => !!it);
}
/** Replace the entire selection. */
public setSelected(indices: number | number[] | Set<number>): void {
const next =
typeof indices === "number"
? indices < 0
? new Set<number>()
: new Set([indices])
: new Set(indices);
if (!this.multi && next.size > 1) {
const first = Math.min(...next);
this._setSelection(new Set([first]));
return;
}
this._setSelection(next);
}
public select(index: number): void {
if (index < 0) {
return;
}
if (this.multi) {
const next = new Set(this._selectedIndices);
next.add(index);
this._setSelection(next);
} else {
this._setSelection(new Set([index]));
}
}
public toggle(index: number, force?: boolean): void {
if (index < 0) {
return;
}
if (this.multi) {
const next = new Set(this._selectedIndices);
const isSelected = next.has(index);
const shouldSelect = force !== undefined ? force : !isSelected;
if (shouldSelect) {
next.add(index);
} else {
next.delete(index);
}
this._setSelection(next);
} else {
const isSelected = this._selectedIndices!.has(index);
const shouldSelect = force !== undefined ? force : !isSelected;
this._setSelection(shouldSelect ? new Set([index]) : new Set());
}
}
public clearSelection(): void {
this._setSelection(new Set());
}
public updateListItems() {
super.updateListItems();
this._syncItemSelectedState(true);
}
private _sortedSelectedIndices(): number[] {
return [...this._selectedIndices!].sort((a, b) => a - b);
}
private _syncItemSelectedState(reset = false): void {
if (!this._selectedIndices || reset) {
this._selectedIndices = new Set<number>();
this.items.forEach((item, i) => {
const opt = item as HaListItemOption;
if (opt.selected) {
this._selectedIndices!.add(i);
}
});
return;
}
this.items.forEach((item, i) => {
const opt = item as HaListItemOption;
const shouldBe = this._selectedIndices!.has(i);
if (opt.selected !== shouldBe) {
opt.selected = shouldBe;
}
});
}
private _setSelection(next: Set<number>): void {
const prev = this._selectedIndices!;
const added = new Set<number>();
const removed = new Set<number>();
next.forEach((i) => {
if (!prev.has(i)) {
added.add(i);
}
});
prev.forEach((i) => {
if (!next.has(i)) {
removed.add(i);
}
});
if (!added.size && !removed.size) {
return;
}
this._selectedIndices = next;
this._syncItemSelectedState();
const detail: HaListSelectedDetail = this.multi
? { index: new Set(next), diff: { added, removed } }
: {
index: next.size === 0 ? -1 : next.values().next().value!,
diff: { added, removed },
};
fireEvent(this, "ha-list-selected", detail);
}
private _onOptionClick = (ev: Event) => {
const path = ev.composedPath();
for (const el of path) {
if (el === this) {
return;
}
if (el instanceof HaListItemOption) {
const index = this.items.indexOf(el);
if (index < 0) {
return;
}
const item = this.items[index];
if (item.disabled) {
return;
}
if (this.multi) {
this.toggle(index);
} else {
this.select(index);
}
return;
}
}
};
}
declare global {
interface HTMLElementTagNameMap {
-356
View File
@@ -1,356 +0,0 @@
import type { LitVirtualizer } from "@lit-labs/virtualizer";
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize.js";
import {
css,
html,
nothing,
type PropertyValues,
type TemplateResult,
} from "lit";
import {
customElement,
eventOptions,
property,
query,
state,
} from "lit/decorators";
import { fireEvent, type HASSDomEvent } from "../../common/dom/fire_event";
import { loadVirtualizer } from "../../resources/virtualizer";
import { HaListItemBase } from "../item/ha-list-item-base";
import { HaListBase } from "./ha-list-base";
import type { HaListItemRegistrationDetail } from "./types";
/**
* A single row in a {@link HaListVirtualized}. Identified by a stable `id`
* used as the virtualizer key. Extra fields are passed through to the
* `rowRenderer`.
*/
export interface HaListVirtualizedItem {
/** Stable key used by the virtualizer to track the row across re-renders. */
id: string;
/** Whether the row can be focused and activated. Defaults to `false`. */
interactive?: boolean;
disabled?: boolean;
[key: string]: unknown;
}
/**
* @element ha-list-virtualized
* @extends {HaListBase}
*
* @summary
* Virtualized list. Renders only the rows currently in view to keep large
* lists performant, while preserving the roving-tabindex keyboard navigation
* of {@link HaListBase}.
*
* @csspart base - The scrollable outer container (`<div>`).
*
* @attr {number} pin-index - Row index to scroll to when the list first
* renders. Cleared once the user scrolls.
* @attr {string} pin-block - Block alignment for `pin-index`: `start`,
* `center` (default), `end`, or `nearest`.
*
* @fires ha-list-activated - Fired when a row is activated via Enter/Space. `detail: { index, item }`.
*/
@customElement("ha-list-virtualized")
export class HaListVirtualized extends HaListBase {
@state() private _virtualizerReady = false;
/**
* The list data. Each item is rendered by `rowRenderer`; its `interactive`
* and `disabled` flags determine whether the row is focusable.
*/
@property({ attribute: false })
public rows!: HaListVirtualizedItem[];
/** Renders a single row from its data and index. */
@property({ attribute: false })
public rowRenderer?: RenderItemFunction<HaListVirtualizedItem>;
/** Row index to scroll to on first render (the "pinned" row). */
@property({ attribute: "pin-index", type: Number }) public pinIndex?: number;
/** Block alignment used when scrolling to `pinIndex`. */
@property({ attribute: "pin-block" }) public pinBlock:
| "start"
| "center"
| "end"
| "nearest" = "center";
@state() private _unpinned = false;
@query("lit-virtualizer")
protected virtualizerElement?: LitVirtualizer<HaListVirtualizedItem>;
protected rangeStart = -1;
protected rangeEnd = -1;
private _activeItemFocus = false;
private _scrollToActiveItem = false;
public willUpdate(changedProps: PropertyValues) {
if (!this.hasUpdated) {
this._loadVirtualizer();
}
if (changedProps.has("rows")) {
this.recomputeFocusableIndexes();
this.activeItemIndex = this.firstFocusableIndex;
}
}
private async _loadVirtualizer() {
await loadVirtualizer();
this._virtualizerReady = true;
}
protected override render(): TemplateResult | typeof nothing {
if (!this._virtualizerReady) {
return nothing;
}
return html`<div part="base" class="base ha-scrollbar">
<lit-virtualizer
.keyFunction=${this._keyFunction}
tabindex="-1"
scroller
.items=${this.rows}
.renderItem=${this.rowRenderer}
style="min-height: 36px; height: 100%;"
.layout=${!this._unpinned && this.pinIndex !== undefined
? {
pin: {
index: this.pinIndex,
block: this.pinBlock,
},
}
: undefined}
@unpinned=${this._handleUnpinned}
@rangeChanged=${this._handleRangeChanged}
>
</lit-virtualizer>
</div>`;
}
/**
* Sets the active (roving-tabindex) row. If the row is outside the rendered
* range it is scrolled into view first, then activated/focused once the
* virtualizer has laid it out.
* @param index - Row index to make active; clamped to the valid range.
* @param focusItem - Whether to move DOM focus to the row.
*/
public setActiveItemIndex(index: number, focusItem = false) {
if (!this.hasFocusableItem) {
this.activeItemIndex = -1;
return;
}
this.activeItemIndex = Math.max(0, Math.min(this.rows.length - 1, index));
if (!this.isFocusable(this.activeItemIndex)) {
this.activeItemIndex = this.firstFocusableIndex;
}
if (
this.activeItemIndex >= this.rangeStart &&
this.activeItemIndex <= this.rangeEnd
) {
this.applyActive(focusItem);
} else {
this._activeItemFocus = focusItem;
this._scrollToActiveItem = true;
this.virtualizerElement
?.element(index)
?.scrollIntoView({ block: "nearest" });
}
}
/**
* Focuses the row at `index`, scrolling it into view if needed. No-op until
* the virtualizer is ready or when `index` is negative.
*/
public override focusItemAtIndex(index: number) {
if (!this._virtualizerReady || index < 0) {
return;
}
this.setActiveItemIndex(index, true);
}
protected override applyActive(focusItem: boolean) {
if (this.virtualizerElement && this.rangeStart > -1) {
Array.from(this.virtualizerElement.children).forEach((child, index) => {
const el = child as HTMLElement;
if (index + this.rangeStart === this.activeItemIndex) {
el.tabIndex = 0;
if (focusItem) {
el.focus();
}
} else {
el.removeAttribute("tabindex");
}
});
}
}
@eventOptions({ passive: true })
private async _handleRangeChanged(ev: { first: number; last: number }) {
this.rangeStart = ev.first;
this.rangeEnd = ev.last;
await this.virtualizerElement?.layoutComplete;
this._applySetSize();
if (!this.virtualizerElement) {
return;
}
const inRange =
this.activeItemIndex >= this.rangeStart &&
this.activeItemIndex <= this.rangeEnd;
const focus = this._scrollToActiveItem && inRange && this._activeItemFocus;
this.applyActive(focus);
if (this._scrollToActiveItem && inRange) {
this._activeItemFocus = false;
this._scrollToActiveItem = false;
}
}
// Expose total count + position to assistive tech, since only a slice of
// items is in the DOM at any time.
private _applySetSize() {
if (!this.virtualizerElement || this.rangeStart < 0) {
return;
}
const total = this.rows?.length ?? 0;
Array.from(this.virtualizerElement.children).forEach((child, index) => {
const el = child as HTMLElement;
el.setAttribute("aria-setsize", String(total));
el.setAttribute("aria-posinset", String(this.rangeStart + index + 1));
});
}
protected onFocusIn = (ev: FocusEvent) => {
if (
!this.virtualizerElement ||
this.rangeStart === -1 ||
this.rangeEnd === -1
) {
return;
}
const path = ev.composedPath();
const children = Array.from(this.virtualizerElement.children);
for (let i = this.rangeStart; i <= this.rangeEnd; i++) {
if (path.includes(children[i - this.rangeStart])) {
if (i !== this.activeItemIndex) {
this.activeItemIndex = i;
if (i < this.rangeStart || i > this.rangeEnd) {
this._activeItemFocus = true;
this._scrollToActiveItem = true;
this.virtualizerElement
?.element(this.activeItemIndex)
?.scrollIntoView({ block: "nearest" });
} else {
this.applyActive(false);
}
}
return;
}
}
};
protected override onActivate = (ev: KeyboardEvent) => {
if (!this.isFocusable(this.activeItemIndex)) {
return;
}
if (
this.virtualizerElement &&
this.activeItemIndex >= this.rangeStart &&
this.activeItemIndex <= this.rangeEnd
) {
const active = this.virtualizerElement?.children[
this.activeItemIndex - this.rangeStart
] as HaListItemBase | undefined;
if (active && active instanceof HaListItemBase) {
ev.preventDefault();
active.activate();
fireEvent(this, "ha-list-activated", {
index: this.activeItemIndex,
item: active,
});
}
}
};
protected isFocusable(index: number): boolean {
const item = this.rows[index];
if (!item) {
return false;
}
const { disabled = false, interactive = false } = this.rows[index];
return interactive && !disabled;
}
protected override get itemCount(): number {
return this.rows?.length ?? 0;
}
protected override moveFocus(ev: KeyboardEvent, next: number) {
if (!this.hasFocusableItem) {
return;
}
ev.preventDefault();
if (next < 0 || next === this.activeItemIndex) {
return;
}
this.activeItemIndex = next;
if (next < this.rangeStart || next > this.rangeEnd) {
this._activeItemFocus = true;
this._scrollToActiveItem = true;
this.virtualizerElement?.element(this.activeItemIndex)?.scrollIntoView({
block: "nearest",
});
} else {
this.applyActive(true);
}
}
protected override getPageSize(): number {
if (this.rangeStart < 0 || this.rangeEnd < 0) {
return super.getPageSize();
}
return Math.max(1, this.rangeEnd - this.rangeStart + 1);
}
private _keyFunction = (item: HaListVirtualizedItem) => item.id;
@eventOptions({ passive: true })
private _handleUnpinned() {
this._unpinned = true;
}
protected override onItemRegister = (
ev: HASSDomEvent<HaListItemRegistrationDetail>
) => {
ev.stopPropagation();
};
protected override onItemUnregister = (
ev: HASSDomEvent<HaListItemRegistrationDetail>
) => {
ev.stopPropagation();
// ignore
};
static styles = [
...HaListBase.styles,
css`
.base {
height: 100%;
}
[ha-list-item] {
width: 100%;
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
"ha-list-virtualized": HaListVirtualized;
}
}
+7 -2
View File
@@ -1,5 +1,11 @@
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;
@@ -11,8 +17,7 @@ export interface HaListItemRegistrationDetail {
declare global {
interface HASSDomEvents {
"ha-list-item-selected": number;
"ha-list-item-deselected": number;
"ha-list-selected": HaListSelectedDetail;
"ha-list-activated": HaListActivatedDetail;
"ha-list-item-register": HaListItemRegistrationDetail;
"ha-list-item-unregister": HaListItemRegistrationDetail;
@@ -341,7 +341,6 @@ 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,7 +231,6 @@ 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"
@@ -116,8 +116,10 @@ import { showCategoryRegistryDetailDialog } from "../category/show-dialog-catego
import {
getAreaTableColumn,
getCategoryTableColumn,
getCreatedAtTableColumn,
getEntityIdHiddenTableColumn,
getLabelsTableColumn,
getModifiedAtTableColumn,
getTriggeredAtTableColumn,
} from "../common/data-table-columns";
import { configSections } from "../ha-panel-config";
@@ -139,6 +141,8 @@ type AutomationItem = AutomationEntity & {
labels: string[]; // search only
assistants: string[];
assistants_sortable_key: string | undefined;
created_at: number | undefined;
modified_at: number | undefined;
};
@customElement("ha-automation-picker")
@@ -285,6 +289,8 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
labels: label_entries.map((lbl) => lbl.name),
assistants,
assistants_sortable_key: getAssistantsSortableKey(assistants),
created_at: entityRegEntry?.created_at,
modified_at: entityRegEntry?.modified_at,
selectable: entityRegEntry !== undefined,
};
});
@@ -335,6 +341,8 @@ class HaAutomationPicker extends SubscribeMixin(LitElement) {
category: getCategoryTableColumn(localize),
labels: getLabelsTableColumn(),
last_triggered: getTriggeredAtTableColumn(localize, this.hass),
created_at: getCreatedAtTableColumn(localize, this.hass),
modified_at: getModifiedAtTableColumn(localize, this.hass),
formatted_state: {
minWidth: "82px",
maxWidth: "82px",
@@ -161,7 +161,6 @@ 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,7 +256,6 @@ 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, "account_created");
this._verificationEmailSent(email);
} catch (err: any) {
this._password = "";
this._requestInProgress = false;
@@ -238,18 +238,15 @@ 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, "verification_email_sent");
this._verificationEmailSent(username);
} 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
@@ -261,16 +258,13 @@ export class CloudRegister extends LitElement {
await doResend(email);
}
private _verificationEmailSent(
email: string,
messageKey: "account_created" | "verification_email_sent"
) {
private _verificationEmailSent(email: string) {
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.${messageKey}`
"ui.panel.config.cloud.register.account_created"
),
});
}
@@ -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, query, state } from "lit/decorators";
import { customElement, 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,18 +16,11 @@ 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-icon-next";
import "../../../components/ha-svg-icon";
import "../../../components/ha-list";
import "../../../components/ha-spinner";
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,
@@ -53,17 +46,16 @@ import {
showAlertDialog,
showConfirmationDialog,
} from "../../../dialogs/generic/show-dialog-box";
import { haStyleDialog } from "../../../resources/styles";
import { haStyleDialog, haStyleScrollbar } 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 extends HaListVirtualizedItem {
export interface IntegrationListItem {
name: string;
domain: string;
config_flow?: boolean;
@@ -108,8 +100,6 @@ class AddIntegrationDialog extends LitElement {
@state() private _narrow = false;
@query("ha-list-virtualized") private _listElement?: HaListVirtualized;
private _width?: number;
private _height?: number;
@@ -195,9 +185,8 @@ 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-virtualized"
)?.getBoundingClientRect();
const boundingRect =
this.shadowRoot!.querySelector("ha-list")?.getBoundingClientRect();
this._width = boundingRect?.width;
this._height = boundingRect?.height;
}
@@ -217,8 +206,6 @@ class AddIntegrationDialog extends LitElement {
discoveredFlowsCount > 0
? [
{
id: "_discovered",
interactive: true,
name: localize(
"ui.panel.config.integrations.discovered_devices",
{ count: discoveredFlowsCount }
@@ -235,8 +222,6 @@ 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,
@@ -277,8 +262,6 @@ class AddIntegrationDialog extends LitElement {
return;
}
integrations.push({
id: domain,
interactive: true,
domain,
name: integration.name || domainToName(localize, domain),
config_flow: supportedIntegration.config_flow,
@@ -295,8 +278,6 @@ class AddIntegrationDialog extends LitElement {
) {
// Brand
integrations.push({
id: domain,
interactive: true,
domain,
name: integration.name || domainToName(localize, domain),
iot_standards: integration.iot_standards,
@@ -314,8 +295,6 @@ 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,
@@ -341,8 +320,6 @@ 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,
@@ -540,7 +517,6 @@ class AddIntegrationDialog extends LitElement {
}
if (supportIntegration) {
this._handleIntegrationPicked({
id: integration.supported_by,
domain: integration.supported_by,
name:
supportIntegration.name ||
@@ -567,33 +543,45 @@ class AddIntegrationDialog extends LitElement {
.placeholder=${this.hass.localize(
"ui.panel.config.integrations.search_brand"
)}
@keydown=${this._maybeSubmit}
@keypress=${this._maybeSubmit}
></ha-input-search>
${integrations
? 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`<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`<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
@click=${this._integrationPicked}
.hass=${this.hass}
.integration=${integration}
tabindex="0"
>
</ha-integration-list-item>
`;
@@ -659,13 +647,19 @@ class AddIntegrationDialog extends LitElement {
this._filter = (ev.target as HaInputSearch).value ?? "";
}
private _integrationPicked = (ev: Event) => {
const listItem = ev.currentTarget as HaIntegrationListItem;
if (!listItem?.integration) {
private _integrationPicked(ev) {
const listItem = ev.target.closest("ha-integration-list-item");
if (!listItem) {
return;
}
this._handleIntegrationPicked(listItem.integration);
};
}
private _handleKeyPress(ev) {
if (ev.key === "Enter") {
this._integrationPicked(ev);
}
}
private async _handleIntegrationPicked(integration: IntegrationListItem) {
if (integration.supported_by) {
@@ -787,11 +781,6 @@ 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;
}
@@ -813,6 +802,7 @@ class AddIntegrationDialog extends LitElement {
}
static styles = [
haStyleScrollbar,
haStyleDialog,
css`
ha-dialog {
@@ -840,9 +830,15 @@ class AddIntegrationDialog extends LitElement {
ha-spinner {
margin: 24px 0;
}
ha-list-virtualized {
ha-list {
position: relative;
}
lit-virtualizer {
contain: size layout !important;
}
ha-integration-list-item {
width: 100%;
}
`,
];
}
@@ -1,4 +1,3 @@
import "@home-assistant/webawesome/dist/components/divider/divider";
import type { RequestSelectedDetail } from "@material/mwc-list/mwc-list-item-base";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
@@ -8,10 +7,11 @@ import {
PROTOCOL_INTEGRATIONS,
protocolIntegrationPicked,
} from "../../../common/integrations/protocolIntegrationPicked";
import { shouldHandleRequestSelectedEvent } from "../../../common/mwc/handle-request-selected-event";
import { navigate } from "../../../common/navigate";
import { caseInsensitiveStringCompare } from "../../../common/string/compare";
import "../../../components/item/ha-list-item-button";
import "../../../components/list/ha-list-base";
import "../../../components/ha-list";
import "../../../components/ha-list-item";
import { localizeConfigFlowTitle } from "../../../data/config_flow";
import type { DataEntryFlowProgress } from "../../../data/data_entry_flow";
import {
@@ -46,20 +46,23 @@ class HaDomainIntegrations extends LitElement {
public showManageLink = false;
protected render() {
return html`<ha-list-base>
return html`<ha-list>
${this.flowsInProgress?.length
? html`<h3>
${this.hass.localize("ui.panel.config.integrations.discovered")}
</h3>
${this.flowsInProgress.map(
(flow) =>
html`<ha-list-item-button
html`<ha-list-item
graphic="medium"
twoLine
.flow=${flow}
@click=${this._flowInProgressPicked}
@request-selected=${this._flowInProgressPicked}
hasMeta
>
<img
alt=""
slot="start"
slot="graphic"
loading="lazy"
src=${brandsUrl(
{
@@ -72,16 +75,16 @@ class HaDomainIntegrations extends LitElement {
crossorigin="anonymous"
referrerpolicy="no-referrer"
/>
<span slot="headline"
<span
>${localizeConfigFlowTitle(this.hass.localize, flow)}</span
>
<span slot="supporting-text"
<span slot="secondary"
>${domainToName(this.hass.localize, flow.handler)}</span
>
<ha-icon-next slot="end"></ha-icon-next>
</ha-list-item-button>`
<ha-icon-next slot="meta"></ha-icon-next>
</ha-list-item>`
)}
<wa-divider></wa-divider>
<li divider role="separator"></li>
${this.integration &&
"integrations" in this.integration &&
this.integration.integrations
@@ -102,12 +105,14 @@ class HaDomainIntegrations extends LitElement {
.map((standard) => {
const domain: (typeof PROTOCOL_INTEGRATIONS)[number] =
standardToDomain[standard] || standard;
return html`<ha-list-item-button
return html`<ha-list-item
graphic="medium"
.domain=${domain}
@click=${this._standardPicked}
@request-selected=${this._standardPicked}
hasMeta
>
<img
slot="start"
slot="graphic"
loading="lazy"
alt=""
src=${brandsUrl(
@@ -121,13 +126,13 @@ class HaDomainIntegrations extends LitElement {
crossorigin="anonymous"
referrerpolicy="no-referrer"
/>
<span slot="headline"
<span
>${this.hass.localize(
`ui.panel.config.integrations.add_${domain}_device`
)}</span
>
<ha-icon-next slot="end"></ha-icon-next>
</ha-list-item-button>`;
<ha-icon-next slot="meta"></ha-icon-next>
</ha-list-item>`;
})
: ""}
${this.integration &&
@@ -151,6 +156,8 @@ class HaDomainIntegrations extends LitElement {
.map(
([dom, val]) =>
html`<ha-integration-list-item
.hass=${this.hass}
.domain=${dom}
.integration=${{
...val,
domain: dom,
@@ -158,18 +165,20 @@ class HaDomainIntegrations extends LitElement {
is_built_in: val.is_built_in !== false,
cloud: val.iot_class?.startsWith("cloud_"),
}}
@click=${this._integrationPicked}
@request-selected=${this._integrationPicked}
>
</ha-integration-list-item>`
)
: ""}
${(PROTOCOL_INTEGRATIONS as readonly string[]).includes(this.domain)
? html`<ha-list-item-button
? html`<ha-list-item
graphic="medium"
.domain=${this.domain}
@click=${this._standardPicked}
@request-selected=${this._standardPicked}
hasMeta
>
<img
slot="start"
slot="graphic"
loading="lazy"
alt=""
src=${brandsUrl(
@@ -183,23 +192,23 @@ class HaDomainIntegrations extends LitElement {
crossorigin="anonymous"
referrerpolicy="no-referrer"
/>
<span slot="headline"
<span
>${this.hass.localize(
`ui.panel.config.integrations.add_${
this.domain as (typeof PROTOCOL_INTEGRATIONS)[number]
}_device`
)}</span
>
<ha-icon-next slot="end"></ha-icon-next>
</ha-list-item-button>`
<ha-icon-next slot="meta"></ha-icon-next>
</ha-list-item>`
: ""}
${this.integration &&
"config_flow" in this.integration &&
this.integration.config_flow
? html`${this.flowsInProgress?.length
? html`<ha-list-item-button
? html`<ha-list-item
.domain=${this.domain}
@click=${this._integrationPicked}
@request-selected=${this._integrationPicked}
.integration=${{
...this.integration,
domain: this.domain,
@@ -209,20 +218,17 @@ class HaDomainIntegrations extends LitElement {
is_built_in: this.integration.is_built_in !== false,
cloud: this.integration.iot_class?.startsWith("cloud_"),
}}
hasMeta
>
<span slot="headline">
${this.hass.localize(
"ui.panel.config.integrations.new_flow",
{
integration:
this.integration.name ||
domainToName(this.hass.localize, this.domain),
}
)}
</span>
<ha-icon-next slot="end"></ha-icon-next>
</ha-list-item-button>`
${this.hass.localize("ui.panel.config.integrations.new_flow", {
integration:
this.integration.name ||
domainToName(this.hass.localize, this.domain),
})}
<ha-icon-next slot="meta"></ha-icon-next>
</ha-list-item>`
: html`<ha-integration-list-item
.hass=${this.hass}
.domain=${this.domain}
.integration=${{
...this.integration,
@@ -233,31 +239,38 @@ class HaDomainIntegrations extends LitElement {
is_built_in: this.integration.is_built_in !== false,
cloud: this.integration.iot_class?.startsWith("cloud_"),
}}
@click=${this._integrationPicked}
@request-selected=${this._integrationPicked}
>
</ha-integration-list-item>`}`
: ""}
${this.showManageLink &&
// Only show manage link if not already on the integrations dashboard
!location.pathname.startsWith("/config/integrations")
? html`<ha-list-item-button @click=${this._manageDiscovered}>
<span slot="headline"
? html`<ha-list-item
twoLine
@request-selected=${this._manageDiscovered}
hasMeta
>
<span
>${this.hass.localize(
"ui.panel.config.integrations.manage_discovered"
)}</span
>
<span slot="supporting-text"
<span slot="secondary"
>${this.hass.localize(
"ui.panel.config.integrations.manage_discovered_description"
)}</span
>
<ha-icon-next slot="end"></ha-icon-next>
</ha-list-item-button>`
<ha-icon-next slot="meta"></ha-icon-next>
</ha-list-item>`
: nothing}
</ha-list-base> `;
</ha-list> `;
}
private async _integrationPicked(ev: CustomEvent<RequestSelectedDetail>) {
if (!shouldHandleRequestSelectedEvent(ev)) {
return;
}
const domain = (ev.currentTarget as any).domain;
if (
@@ -312,7 +325,10 @@ class HaDomainIntegrations extends LitElement {
fireEvent(this, "close-dialog");
}
private async _flowInProgressPicked(ev: Event) {
private async _flowInProgressPicked(ev: CustomEvent<RequestSelectedDetail>) {
if (!shouldHandleRequestSelectedEvent(ev)) {
return;
}
const flow: DataEntryFlowProgress = (ev.currentTarget as any).flow;
const root = this.getRootNode();
showConfigFlowDialog(
@@ -326,12 +342,18 @@ class HaDomainIntegrations extends LitElement {
fireEvent(this, "close-dialog");
}
private _manageDiscovered() {
private _manageDiscovered(ev: CustomEvent<RequestSelectedDetail>) {
if (!shouldHandleRequestSelectedEvent(ev)) {
return;
}
fireEvent(this, "close-dialog");
navigate("/config/integrations/dashboard?historyBack=1");
}
private _standardPicked(ev: CustomEvent<RequestSelectedDetail>) {
if (!shouldHandleRequestSelectedEvent(ev)) {
return;
}
const domain = (ev.currentTarget as any).domain;
const root = this.getRootNode();
fireEvent(this, "close-dialog");
@@ -348,10 +370,11 @@ class HaDomainIntegrations extends LitElement {
css`
:host {
display: block;
--ha-row-item-padding-inline: var(--ha-space-6);
--mdc-list-item-graphic-size: 40px;
--mdc-list-side-padding: 24px;
}
h3 {
margin: var(--ha-space-2) var(--ha-space-6) 0;
margin: 8px 24px 0;
color: var(--secondary-text-color);
font-size: var(--ha-font-size-m);
font-weight: var(--ha-font-weight-medium);
@@ -360,14 +383,11 @@ class HaDomainIntegrations extends LitElement {
margin-top: 0;
}
img {
width: 32px;
height: 32px;
width: 40px;
height: 40px;
}
wa-divider {
margin-top: var(--ha-space-2);
}
ha-icon-next {
color: var(--ha-color-text-secondary);
li[divider] {
margin-top: 8px;
}
`,
];
@@ -1,127 +1,196 @@
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, TemplateResult } from "lit";
import type { CSSResultGroup } from "lit";
import { css, html, nothing } from "lit";
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 { 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 "../../../components/ha-icon-next";
import "../../../components/ha-tooltip";
@customElement("ha-integration-list-item")
export class HaIntegrationListItem extends HaListItemButton {
@property({ attribute: false }) public integration!: IntegrationListItem;
export class HaIntegrationListItem extends ListItemBase {
public hass!: HomeAssistant;
@state()
@consumeLocalize()
private _localize!: LocalizeFunc;
@property({ attribute: false }) public integration?: IntegrationListItem;
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>
`;
@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)" : ""}`;
}
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);
}
`,
];
// @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);
}
`,
];
}
}
declare global {
@@ -3,16 +3,18 @@ import { mdiClose } from "@mdi/js";
import type { CSSResultGroup, TemplateResult } from "lit";
import { css, html, LitElement, nothing } from "lit";
import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../../../../common/dom/fire_event";
import "../../../../../components/ha-button";
import "../../../../../components/ha-dialog";
import "../../../../../components/ha-dialog-footer";
import "../../../../../components/ha-icon-button";
import "../../../../../components/ha-spinner";
import "../../../../../components/input/ha-input-search";
import "../../../../../components/item/ha-list-item-option";
import "../../../../../components/list/ha-list-selectable-virtualized";
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,
@@ -21,6 +23,7 @@ import {
} from "../../../../../data/zha";
import type { HassDialog } from "../../../../../dialogs/make-dialog-manager";
import { haStyleScrollbar } from "../../../../../resources/styles";
import { loadVirtualizer } from "../../../../../resources/virtualizer";
import type { HomeAssistant } from "../../../../../types";
import type { ZHAAddGroupMembersDialogParams } from "./show-dialog-zha-add-group-members";
@@ -47,6 +50,8 @@ class DialogZHAAddGroupMembers
@state() private _selectedDevicesToAdd: string[] = [];
@state() private _virtualizerReady = false;
private _fetchDataToken = 0;
public showDialog(params: ZHAAddGroupMembersDialogParams): void {
@@ -75,6 +80,7 @@ class DialogZHAAddGroupMembers
this._loading = false;
this._processingAdd = false;
this._selectedDevicesToAdd = [];
this._virtualizerReady = false;
fireEvent(this, "dialog-closed", { dialog: this.localName });
}
@@ -83,10 +89,7 @@ class DialogZHAAddGroupMembers
return nothing;
}
const deviceEndpoints = this._filteredDeviceEndpoints(
this._filter,
this._availableDeviceEndpoints
);
const deviceEndpoints = this._filteredDeviceEndpoints;
const showSearch =
this._availableDeviceEndpoints.length > 5 || this._filter;
@@ -97,6 +100,7 @@ class DialogZHAAddGroupMembers
"ui.panel.config.zha.groups.add_members"
)}
?prevent-scrim-close=${this._selectedDevicesToAdd.length > 0}
@after-show=${this._loadVirtualizer}
@closed=${this._dialogClosed}
>
<ha-icon-button
@@ -122,14 +126,22 @@ class DialogZHAAddGroupMembers
<div class="list-container">
${deviceEndpoints.length
? html`
<ha-list-selectable-virtualized
multi
.rows=${deviceEndpoints}
.rowRenderer=${this._renderDeviceEndpoint}
@ha-list-item-selected=${this._handleSelected}
@ha-list-item-deselected=${this._handleDeselected}
>
</ha-list-selectable-virtualized>
${this._virtualizerReady
? html`
<ha-list-selectable
multi
@ha-list-selected=${this._handleSelected}
>
<lit-virtualizer
scroller
class="ha-scrollbar"
.items=${deviceEndpoints}
.renderItem=${this._renderDeviceEndpoint}
.keyFunction=${this._keyFunction}
></lit-virtualizer>
</ha-list-selectable>
`
: this._renderLoadingSpinner()}
`
: html`
<div class="empty-list">
@@ -193,32 +205,34 @@ class DialogZHAAddGroupMembers
);
}
private _filteredDeviceEndpoints = memoizeOne(
(filter: string, availableDeviceEndpoints: ZHADeviceEndpoint[]) => {
const normalizedFilter = filter.trim().toLowerCase();
let deviceEndpoints = availableDeviceEndpoints;
private get _filteredDeviceEndpoints(): ZHADeviceEndpoint[] {
const normalizedFilter = this._filter.trim().toLowerCase();
const deviceEndpoints = this._availableDeviceEndpoints;
if (normalizedFilter) {
deviceEndpoints = deviceEndpoints.filter((deviceEndpoint) =>
[
this._deviceEndpointName(deviceEndpoint),
this._deviceEndpointDetails(deviceEndpoint),
deviceEndpoint.device.ieee,
deviceEndpoint.device.manufacturer,
deviceEndpoint.device.model,
]
.filter(Boolean)
.some((value) => value!.toLowerCase().includes(normalizedFilter))
);
}
return deviceEndpoints.map((deviceEndpoint) => ({
id: this._deviceEndpointId(deviceEndpoint),
interactive: true,
...deviceEndpoint,
}));
if (!normalizedFilter) {
return deviceEndpoints;
}
);
return deviceEndpoints.filter((deviceEndpoint) =>
[
this._deviceEndpointName(deviceEndpoint),
this._deviceEndpointDetails(deviceEndpoint),
deviceEndpoint.device.ieee,
deviceEndpoint.device.manufacturer,
deviceEndpoint.device.model,
]
.filter(Boolean)
.some((value) => value!.toLowerCase().includes(normalizedFilter))
);
}
private async _loadVirtualizer(): Promise<void> {
await loadVirtualizer();
this._virtualizerReady = true;
}
private _keyFunction = (deviceEndpoint: unknown): string =>
this._deviceEndpointId(deviceEndpoint as ZHADeviceEndpoint);
private _renderDeviceEndpoint: RenderItemFunction<ZHADeviceEndpoint> = (
deviceEndpoint
@@ -291,30 +305,26 @@ class DialogZHAAddGroupMembers
this._filter = (ev.currentTarget as HTMLInputElement).value;
}
private _handleSelected(ev: CustomEvent<number>): void {
private _handleSelected(ev: CustomEvent<HaListSelectedDetail>): void {
const list = ev.currentTarget as HaListSelectable;
let selectedDevicesToAdd = this._selectedDevicesToAdd;
const item = this._filteredDeviceEndpoints(
this._filter,
this._availableDeviceEndpoints
)[ev.detail];
if (item && !selectedDevicesToAdd.includes(item.id)) {
selectedDevicesToAdd = [...selectedDevicesToAdd, item.id];
}
this._selectedDevicesToAdd = selectedDevicesToAdd;
}
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
);
}
});
private _handleDeselected(ev: CustomEvent<number>): void {
let selectedDevicesToAdd = this._selectedDevicesToAdd;
const item = this._filteredDeviceEndpoints(
this._filter,
this._availableDeviceEndpoints
)[ev.detail];
if (item && selectedDevicesToAdd.includes(item.id)) {
selectedDevicesToAdd = selectedDevicesToAdd.filter(
(value) => value !== item.id
);
}
this._selectedDevicesToAdd = selectedDevicesToAdd;
}
@@ -375,7 +385,7 @@ class DialogZHAAddGroupMembers
overflow: hidden;
}
ha-list-selectable-virtualized {
lit-virtualizer {
display: block;
width: 100%;
height: 100%;
@@ -13,6 +13,7 @@ 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,
@@ -102,8 +103,7 @@ export class ZHADeviceEndpointList extends LitElement {
? html`
<ha-list-selectable
multi
@ha-list-item-selected=${this._handleItemSelected}
@ha-list-item-deselected=${this._handleItemDeselected}
@ha-list-selected=${this._handleListSelectionChanged}
>
${repeat(
deviceEndpoints,
@@ -261,38 +261,36 @@ export class ZHADeviceEndpointList extends LitElement {
this._filter = (ev.currentTarget as HTMLInputElement).value;
}
private _handleItemSelected(ev: CustomEvent<number>): void {
private _handleListSelectionChanged(
ev: CustomEvent<HaListSelectedDetail>
): 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,
true
);
ev.detail.diff?.added.forEach((index) => {
const item = list.items[index] as HaListItemOption | undefined;
if (item?.value) {
selectedDeviceIds = this._setSelectedDeviceId(
selectedDeviceIds,
item.value,
true
);
}
});
this._selectedDeviceIds = selectedDeviceIds;
this._fireSelectionChanged();
}
}
ev.detail.diff?.removed.forEach((index) => {
const item = list.items[index] as HaListItemOption | undefined;
if (item?.value) {
selectedDeviceIds = this._setSelectedDeviceId(
selectedDeviceIds,
item.value,
false
);
}
});
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();
}
this._selectedDeviceIds = selectedDeviceIds;
this._fireSelectionChanged();
}
private _setSelectedDeviceId(
@@ -104,8 +104,10 @@ import { showCategoryRegistryDetailDialog } from "../category/show-dialog-catego
import {
getAreaTableColumn,
getCategoryTableColumn,
getCreatedAtTableColumn,
getEditableTableColumn,
getLabelsTableColumn,
getModifiedAtTableColumn,
renderRelativeTimeColumn,
} from "../common/data-table-columns";
import { configSections } from "../ha-panel-config";
@@ -125,6 +127,8 @@ type SceneItem = SceneEntity & {
labels: string[]; // search only
assistants: string[];
assistants_sortable_key: string | undefined;
created_at: number | undefined;
modified_at: number | undefined;
editable: boolean;
};
@@ -264,6 +268,8 @@ class HaSceneDashboard extends SubscribeMixin(LitElement) {
labels: label_entries.map((lbl) => lbl.name),
assistants,
assistants_sortable_key: getAssistantsSortableKey(assistants),
created_at: entityRegEntry?.created_at,
modified_at: entityRegEntry?.modified_at,
selectable: entityRegEntry !== undefined,
editable: Boolean(scene.attributes.id),
};
@@ -323,6 +329,8 @@ class HaSceneDashboard extends SubscribeMixin(LitElement) {
localize,
localize("ui.panel.config.scene.picker.only_editable")
),
created_at: getCreatedAtTableColumn(localize, this.hass),
modified_at: getModifiedAtTableColumn(localize, this.hass),
actions: {
lastFixed: true,
title: "",
@@ -158,7 +158,6 @@ 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"
@@ -109,8 +109,10 @@ import { showCategoryRegistryDetailDialog } from "../category/show-dialog-catego
import {
getAreaTableColumn,
getCategoryTableColumn,
getCreatedAtTableColumn,
getEntityIdHiddenTableColumn,
getLabelsTableColumn,
getModifiedAtTableColumn,
getTriggeredAtTableColumn,
} from "../common/data-table-columns";
import { configSections } from "../ha-panel-config";
@@ -130,6 +132,8 @@ type ScriptItem = ScriptEntity & {
labels: string[]; // search only
assistants: string[];
assistants_sortable_key: string | undefined;
created_at: number | undefined;
modified_at: number | undefined;
};
@customElement("ha-script-picker")
@@ -271,6 +275,8 @@ class HaScriptPicker extends SubscribeMixin(LitElement) {
labels: label_entries.map((lbl) => lbl.name),
assistants,
assistants_sortable_key: getAssistantsSortableKey(assistants),
created_at: entityRegEntry?.created_at,
modified_at: entityRegEntry?.modified_at,
selectable: entityRegEntry !== undefined,
};
});
@@ -318,6 +324,8 @@ class HaScriptPicker extends SubscribeMixin(LitElement) {
category: getCategoryTableColumn(localize),
labels: getLabelsTableColumn(),
last_triggered: getTriggeredAtTableColumn(localize, this.hass),
created_at: getCreatedAtTableColumn(localize, this.hass),
modified_at: getModifiedAtTableColumn(localize, this.hass),
actions: {
lastFixed: true,
title: "",
@@ -1,10 +1,9 @@
import { consume } from "@lit/context";
import { mdiAlertCircle, mdiEye, mdiEyeOff } from "@mdi/js";
import type { CSSResultGroup, PropertyValues } from "lit";
import { css, html, LitElement } from "lit";
import type { CSSResultGroup, PropertyValues, TemplateResult } from "lit";
import { css, html } 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";
@@ -29,15 +28,17 @@ const STATE_ICONS: Record<VisibilityState, string> = {
/**
* @element ha-visibility-status
* @extends {HaRowItem}
*
* @summary
* Alert banner that surfaces the live visibility result for a set of
* lovelace conditions.
* 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.
*
* @attr {"visible"|"hidden"|"invalid"} state - Computed visibility state
* @attr {"visible"|"hidden"|"invalid"} state - Computed visibility state (reflected for styling).
*/
@customElement("ha-visibility-status")
export class HaVisibilityStatus extends LitElement {
export class HaVisibilityStatus extends HaRowItem {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false })
@@ -47,7 +48,7 @@ export class HaVisibilityStatus extends LitElement {
@consume({ context: conditionsEntityContext, subscribe: true })
private _entityContext?: ConditionsEntityContext;
@property()
@property({ reflect: true })
public state: VisibilityState = "visible";
private _listeners = new ConditionListenersController(this);
@@ -70,27 +71,23 @@ export class HaVisibilityStatus extends LitElement {
}
}
public render() {
protected override _renderInner(): TemplateResult {
return html`
<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">
<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">
${this.hass?.localize(
`ui.panel.lovelace.editor.condition-editor.visibility_status.${this.state}.headline`
)}
</div>
<div class="supporting">
<div part="supporting-text" class="supporting">
${this.hass?.localize(
`ui.panel.lovelace.editor.condition-editor.visibility_status.${this.state}.supporting${(this.conditions?.length ?? 0) === 0 ? "_empty" : ""}`
)}
</div>
</ha-alert>
</div>
`;
}
@@ -120,13 +117,37 @@ export class HaVisibilityStatus extends LitElement {
static styles: CSSResultGroup = [
HaRowItem.styles,
css`
ha-alert {
:host {
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);
margin-bottom: var(--ha-space-1);
white-space: normal;
}
`,
];
+1 -2
View File
@@ -6319,8 +6319,7 @@
"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.",
"verification_email_sent": "Verification email sent. Check your inbox for instructions on how to activate your account."
"account_created": "Account created! Check your email for instructions on how to activate your account."
},
"account": {
"download_support_package": "Download support package",