mirror of
https://github.com/home-assistant/frontend.git
synced 2025-10-20 17:20:07 +00:00
1109 lines
30 KiB
TypeScript
1109 lines
30 KiB
TypeScript
import type { LitVirtualizer } from "@lit-labs/virtualizer";
|
||
import { consume } from "@lit/context";
|
||
import { mdiCheck, mdiPlus, mdiTextureBox } from "@mdi/js";
|
||
import Fuse from "fuse.js";
|
||
import type { HassServiceTarget } from "home-assistant-js-websocket";
|
||
import { css, html, LitElement, nothing, type PropertyValues } from "lit";
|
||
import {
|
||
customElement,
|
||
eventOptions,
|
||
property,
|
||
query,
|
||
state,
|
||
} from "lit/decorators";
|
||
import { styleMap } from "lit/directives/style-map";
|
||
import memoizeOne from "memoize-one";
|
||
import { tinykeys } from "tinykeys";
|
||
import { ensureArray } from "../../common/array/ensure-array";
|
||
import { fireEvent } from "../../common/dom/fire_event";
|
||
import type { LocalizeKeys } from "../../common/translations/localize";
|
||
import { computeRTL } from "../../common/util/compute_rtl";
|
||
import {
|
||
getAreasAndFloors,
|
||
type AreaFloorValue,
|
||
type FloorComboBoxItem,
|
||
} from "../../data/area_floor";
|
||
import { getConfigEntries, type ConfigEntry } from "../../data/config_entries";
|
||
import { labelsContext } from "../../data/context";
|
||
import { getDevices, type DevicePickerItem } from "../../data/device_registry";
|
||
import type { HaEntityPickerEntityFilterFunc } from "../../data/entity";
|
||
import {
|
||
getEntities,
|
||
type EntityComboBoxItem,
|
||
} from "../../data/entity_registry";
|
||
import { domainToName } from "../../data/integration";
|
||
import { getLabels, type LabelRegistryEntry } from "../../data/label_registry";
|
||
import type { TargetType, TargetTypeFloorless } from "../../data/target";
|
||
import {
|
||
isHelperDomain,
|
||
type HelperDomain,
|
||
} from "../../panels/config/helpers/const";
|
||
import { HaFuse } from "../../resources/fuse";
|
||
import { haStyleScrollbar } from "../../resources/styles";
|
||
import { loadVirtualizer } from "../../resources/virtualizer";
|
||
import type { HomeAssistant } from "../../types";
|
||
import { brandsUrl } from "../../util/brands-url";
|
||
import type { HaDevicePickerDeviceFilterFunc } from "../device/ha-device-picker";
|
||
import "../entity/state-badge";
|
||
import "../ha-button";
|
||
import "../ha-combo-box-item";
|
||
import "../ha-floor-icon";
|
||
import "../ha-md-list";
|
||
import type { PickerComboBoxItem } from "../ha-picker-combo-box";
|
||
import "../ha-svg-icon";
|
||
import "../ha-textfield";
|
||
import type { HaTextField } from "../ha-textfield";
|
||
import "../ha-tree-indicator";
|
||
|
||
const SEPARATOR = "________";
|
||
const EMPTY_SEARCH = "___EMPTY_SEARCH___";
|
||
const CREATE_ID = "___create-new-entity___";
|
||
|
||
@customElement("ha-target-picker-selector")
|
||
export class HaTargetPickerSelector extends LitElement {
|
||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||
|
||
@property({ attribute: false }) public filterTypes: TargetTypeFloorless[] =
|
||
[];
|
||
|
||
@property({ reflect: true }) public mode: "popover" | "dialog" = "popover";
|
||
|
||
/**
|
||
* Show only targets with entities from specific domains.
|
||
* @type {Array}
|
||
* @attr include-domains
|
||
*/
|
||
@property({ type: Array, attribute: "include-domains" })
|
||
public includeDomains?: string[];
|
||
|
||
/**
|
||
* Show only targets with entities of these device classes.
|
||
* @type {Array}
|
||
* @attr include-device-classes
|
||
*/
|
||
@property({ type: Array, attribute: "include-device-classes" })
|
||
public includeDeviceClasses?: string[];
|
||
|
||
@property({ attribute: false })
|
||
public deviceFilter?: HaDevicePickerDeviceFilterFunc;
|
||
|
||
@property({ attribute: false })
|
||
public entityFilter?: HaEntityPickerEntityFilterFunc;
|
||
|
||
@property({ attribute: false }) public targetValue?: HassServiceTarget;
|
||
|
||
@property({ attribute: false, type: Array }) public createDomains?: string[];
|
||
|
||
@query("lit-virtualizer") private _virtualizerElement?: LitVirtualizer;
|
||
|
||
@query("ha-textfield") private _searchFieldElement?: HaTextField;
|
||
|
||
@state() private _searchTerm = "";
|
||
|
||
@state() private _listScrolled = false;
|
||
|
||
@state() private _configEntryLookup: Record<string, ConfigEntry> = {};
|
||
|
||
private _selectedItemIndex = -1;
|
||
|
||
@state() private _filterHeader?: string;
|
||
|
||
@state()
|
||
@consume({ context: labelsContext, subscribe: true })
|
||
private _labelRegistry!: LabelRegistryEntry[];
|
||
|
||
private _getDevicesMemoized = memoizeOne(getDevices);
|
||
|
||
private _getLabelsMemoized = memoizeOne(getLabels);
|
||
|
||
private _getEntitiesMemoized = memoizeOne(getEntities);
|
||
|
||
private _getAreasAndFloorsMemoized = memoizeOne(getAreasAndFloors);
|
||
|
||
static shadowRootOptions = {
|
||
...LitElement.shadowRootOptions,
|
||
delegatesFocus: true,
|
||
};
|
||
|
||
private _removeKeyboardShortcuts?: () => void;
|
||
|
||
public willUpdate(changedProps: PropertyValues) {
|
||
super.willUpdate(changedProps);
|
||
|
||
if (!this.hasUpdated) {
|
||
this._loadConfigEntries();
|
||
loadVirtualizer();
|
||
}
|
||
}
|
||
|
||
protected firstUpdated() {
|
||
this._registerKeyboardShortcuts();
|
||
}
|
||
|
||
disconnectedCallback() {
|
||
super.disconnectedCallback();
|
||
this._removeKeyboardShortcuts?.();
|
||
}
|
||
|
||
private async _loadConfigEntries() {
|
||
const configEntries = await getConfigEntries(this.hass);
|
||
this._configEntryLookup = Object.fromEntries(
|
||
configEntries.map((entry) => [entry.entry_id, entry])
|
||
);
|
||
}
|
||
|
||
protected render() {
|
||
return html`
|
||
<ha-textfield
|
||
.label=${this.hass.localize("ui.common.search")}
|
||
@input=${this._searchChanged}
|
||
.value=${this._searchTerm}
|
||
></ha-textfield>
|
||
<div class="filter">${this._renderFilterButtons()}</div>
|
||
<div class="filter-header-wrapper">
|
||
<div
|
||
class="filter-header ${this.filterTypes.length !== 1 &&
|
||
this._filterHeader
|
||
? "show"
|
||
: ""}"
|
||
>
|
||
${this._filterHeader}
|
||
</div>
|
||
</div>
|
||
<lit-virtualizer
|
||
tabindex="0"
|
||
scroller
|
||
.keyFunction=${this._keyFunction}
|
||
.items=${this._getItems(
|
||
this.filterTypes,
|
||
this.entityFilter,
|
||
this.deviceFilter,
|
||
this.includeDomains,
|
||
this.includeDeviceClasses,
|
||
this.targetValue,
|
||
this._searchTerm,
|
||
this.createDomains,
|
||
this._configEntryLookup,
|
||
this.mode
|
||
)}
|
||
.renderItem=${this._renderRow}
|
||
@scroll=${this._onScrollList}
|
||
class="list ${this._listScrolled ? "scrolled" : ""}"
|
||
style="min-height: 56px;"
|
||
@visibilityChanged=${this._visibilityChanged}
|
||
@focus=${this._focusList}
|
||
>
|
||
</lit-virtualizer>
|
||
`;
|
||
}
|
||
|
||
@eventOptions({ passive: true })
|
||
private _visibilityChanged(ev) {
|
||
if (this._virtualizerElement) {
|
||
const firstItem = this._virtualizerElement.items[ev.first];
|
||
const secondItem = this._virtualizerElement.items[ev.first + 1];
|
||
|
||
if (
|
||
firstItem === undefined ||
|
||
secondItem === undefined ||
|
||
typeof firstItem === "string" ||
|
||
(typeof secondItem === "string" && secondItem !== "padding") ||
|
||
(ev.first === 0 &&
|
||
ev.last === this._virtualizerElement.items.length - 1)
|
||
) {
|
||
this._filterHeader = undefined;
|
||
return;
|
||
}
|
||
|
||
const type = this._getRowType(firstItem as PickerComboBoxItem);
|
||
const translationType:
|
||
| "areas"
|
||
| "entities"
|
||
| "devices"
|
||
| "labels"
|
||
| undefined =
|
||
type === "area" || type === "floor"
|
||
? "areas"
|
||
: type === "entity"
|
||
? "entities"
|
||
: type && type !== "empty"
|
||
? `${type}s`
|
||
: undefined;
|
||
|
||
this._filterHeader = translationType
|
||
? (this._filterHeader = this.hass.localize(
|
||
`ui.components.target-picker.type.${translationType}`
|
||
))
|
||
: undefined;
|
||
}
|
||
}
|
||
|
||
private _registerKeyboardShortcuts() {
|
||
this._removeKeyboardShortcuts = tinykeys(this, {
|
||
ArrowUp: this._selectPreviousItem,
|
||
ArrowDown: this._selectNextItem,
|
||
Home: this._selectFirstItem,
|
||
End: this._selectLastItem,
|
||
Enter: this._pickSelectedItem,
|
||
});
|
||
}
|
||
|
||
private _focusList() {
|
||
if (this._selectedItemIndex === -1) {
|
||
this._selectNextItem();
|
||
}
|
||
}
|
||
|
||
private _selectNextItem = (ev?: KeyboardEvent) => {
|
||
ev?.stopPropagation();
|
||
ev?.preventDefault();
|
||
if (!this._virtualizerElement) {
|
||
return;
|
||
}
|
||
|
||
this._searchFieldElement?.focus();
|
||
|
||
const items = this._virtualizerElement.items;
|
||
|
||
const maxItems = items.length - 1;
|
||
|
||
if (maxItems === -1) {
|
||
this._resetSelectedItem();
|
||
return;
|
||
}
|
||
|
||
const nextIndex =
|
||
maxItems === this._selectedItemIndex
|
||
? this._selectedItemIndex
|
||
: this._selectedItemIndex + 1;
|
||
|
||
if (!items[nextIndex]) {
|
||
return;
|
||
}
|
||
|
||
if (
|
||
typeof items[nextIndex] === "string" ||
|
||
(items[nextIndex] as PickerComboBoxItem)?.id === EMPTY_SEARCH
|
||
) {
|
||
// Skip titles, padding and empty search
|
||
if (nextIndex === maxItems) {
|
||
return;
|
||
}
|
||
this._selectedItemIndex = nextIndex + 1;
|
||
} else {
|
||
this._selectedItemIndex = nextIndex;
|
||
}
|
||
|
||
this._scrollToSelectedItem();
|
||
};
|
||
|
||
private _selectPreviousItem = (ev: KeyboardEvent) => {
|
||
ev.stopPropagation();
|
||
ev.preventDefault();
|
||
if (!this._virtualizerElement) {
|
||
return;
|
||
}
|
||
|
||
if (this._selectedItemIndex > 0) {
|
||
const nextIndex = this._selectedItemIndex - 1;
|
||
|
||
const items = this._virtualizerElement.items;
|
||
|
||
if (!items[nextIndex]) {
|
||
return;
|
||
}
|
||
|
||
if (
|
||
typeof items[nextIndex] === "string" ||
|
||
(items[nextIndex] as PickerComboBoxItem)?.id === EMPTY_SEARCH
|
||
) {
|
||
// Skip titles, padding and empty search
|
||
if (nextIndex === 0) {
|
||
return;
|
||
}
|
||
this._selectedItemIndex = nextIndex - 1;
|
||
} else {
|
||
this._selectedItemIndex = nextIndex;
|
||
}
|
||
|
||
this._scrollToSelectedItem();
|
||
}
|
||
};
|
||
|
||
private _selectFirstItem = (ev: KeyboardEvent) => {
|
||
ev.stopPropagation();
|
||
if (!this._virtualizerElement || !this._virtualizerElement.items.length) {
|
||
return;
|
||
}
|
||
|
||
const nextIndex = 0;
|
||
|
||
if (
|
||
(this._virtualizerElement.items[nextIndex] as PickerComboBoxItem)?.id ===
|
||
EMPTY_SEARCH
|
||
) {
|
||
return;
|
||
}
|
||
|
||
if (typeof this._virtualizerElement.items[nextIndex] === "string") {
|
||
this._selectedItemIndex = nextIndex + 1;
|
||
} else {
|
||
this._selectedItemIndex = nextIndex;
|
||
}
|
||
|
||
this._scrollToSelectedItem();
|
||
};
|
||
|
||
private _selectLastItem = (ev: KeyboardEvent) => {
|
||
ev.stopPropagation();
|
||
if (!this._virtualizerElement || !this._virtualizerElement.items.length) {
|
||
return;
|
||
}
|
||
|
||
const nextIndex = this._virtualizerElement.items.length - 1;
|
||
|
||
if (
|
||
(this._virtualizerElement.items[nextIndex] as PickerComboBoxItem)?.id ===
|
||
EMPTY_SEARCH
|
||
) {
|
||
return;
|
||
}
|
||
|
||
if (typeof this._virtualizerElement.items[nextIndex] === "string") {
|
||
this._selectedItemIndex = nextIndex - 1;
|
||
} else {
|
||
this._selectedItemIndex = nextIndex;
|
||
}
|
||
|
||
this._scrollToSelectedItem();
|
||
};
|
||
|
||
private _scrollToSelectedItem = () => {
|
||
this._virtualizerElement
|
||
?.querySelector(".selected")
|
||
?.classList.remove("selected");
|
||
|
||
this._virtualizerElement?.scrollToIndex(this._selectedItemIndex, "end");
|
||
|
||
requestAnimationFrame(() => {
|
||
this._virtualizerElement
|
||
?.querySelector(`#list-item-${this._selectedItemIndex}`)
|
||
?.classList.add("selected");
|
||
});
|
||
};
|
||
|
||
private _pickSelectedItem = (ev: KeyboardEvent) => {
|
||
if (this._selectedItemIndex === -1) {
|
||
return;
|
||
}
|
||
|
||
// if filter button is focused
|
||
ev.preventDefault();
|
||
|
||
const item: any = this._virtualizerElement?.items[this._selectedItemIndex];
|
||
if (item && typeof item !== "string") {
|
||
this._pickTarget(
|
||
item.id,
|
||
"domain" in item
|
||
? "device"
|
||
: "stateObj" in item
|
||
? "entity"
|
||
: item.type
|
||
? "area"
|
||
: "label"
|
||
);
|
||
}
|
||
};
|
||
|
||
private _renderFilterButtons() {
|
||
const filter: (TargetTypeFloorless | "separator")[] = [
|
||
"entity",
|
||
"device",
|
||
"area",
|
||
"separator",
|
||
"label",
|
||
];
|
||
return filter.map((filterType) => {
|
||
if (filterType === "separator") {
|
||
return html`<div class="separator"></div>`;
|
||
}
|
||
|
||
const selected = this.filterTypes.includes(filterType);
|
||
return html`
|
||
<ha-button
|
||
@click=${this._toggleFilter}
|
||
.type=${filterType}
|
||
size="small"
|
||
.variant=${selected ? "brand" : "neutral"}
|
||
appearance="filled"
|
||
no-shrink
|
||
>
|
||
${selected
|
||
? html`<ha-svg-icon slot="start" .path=${mdiCheck}></ha-svg-icon>`
|
||
: nothing}
|
||
${this.hass.localize(
|
||
`ui.components.target-picker.type.${filterType === "entity" ? "entities" : `${filterType}s`}` as LocalizeKeys
|
||
)}
|
||
</ha-button>
|
||
`;
|
||
});
|
||
}
|
||
|
||
private _getRowType = (
|
||
item:
|
||
| PickerComboBoxItem
|
||
| (FloorComboBoxItem & { last?: boolean | undefined })
|
||
| EntityComboBoxItem
|
||
| DevicePickerItem
|
||
) => {
|
||
if (
|
||
(item as FloorComboBoxItem).type === "area" ||
|
||
(item as FloorComboBoxItem).type === "floor"
|
||
) {
|
||
return (item as FloorComboBoxItem).type;
|
||
}
|
||
|
||
if ("domain" in item) {
|
||
return "device";
|
||
}
|
||
|
||
if ("stateObj" in item) {
|
||
return "entity";
|
||
}
|
||
|
||
if (item.id === EMPTY_SEARCH) {
|
||
return "empty";
|
||
}
|
||
|
||
return "label";
|
||
};
|
||
|
||
private _renderRow = (
|
||
item:
|
||
| PickerComboBoxItem
|
||
| (FloorComboBoxItem & { last?: boolean | undefined })
|
||
| EntityComboBoxItem
|
||
| DevicePickerItem
|
||
| string,
|
||
index: number
|
||
) => {
|
||
if (!item) {
|
||
return nothing;
|
||
}
|
||
|
||
if (typeof item === "string") {
|
||
if (item === "padding") {
|
||
return html`<div class="bottom-padding"></div>`;
|
||
}
|
||
return html`<div class="title">${item}</div>`;
|
||
}
|
||
|
||
const type = this._getRowType(item);
|
||
let hasFloor = false;
|
||
let rtl = false;
|
||
let showEntityId = false;
|
||
|
||
if (type === "area" || type === "floor") {
|
||
item.id = item[type]?.[`${type}_id`];
|
||
|
||
rtl = computeRTL(this.hass);
|
||
hasFloor =
|
||
type === "area" && !!(item as FloorComboBoxItem).area?.floor_id;
|
||
}
|
||
|
||
if (type === "entity") {
|
||
showEntityId = !!this._showEntityId;
|
||
}
|
||
|
||
return html`
|
||
<ha-combo-box-item
|
||
id=${`list-item-${index}`}
|
||
tabindex="-1"
|
||
.type=${type === "empty" ? "text" : "button"}
|
||
class=${type === "empty" ? "empty" : ""}
|
||
@click=${this._handlePickTarget}
|
||
.targetType=${type}
|
||
.targetId=${type !== "empty" ? item.id : undefined}
|
||
style=${(item as FloorComboBoxItem).type === "area" && hasFloor
|
||
? "--md-list-item-leading-space: var(--ha-space-12);"
|
||
: ""}
|
||
>
|
||
${(item as FloorComboBoxItem).type === "area" && hasFloor
|
||
? html`
|
||
<ha-tree-indicator
|
||
style=${styleMap({
|
||
width: "var(--ha-space-12)",
|
||
position: "absolute",
|
||
top: "var(--ha-space-0)",
|
||
left: rtl ? undefined : "var(--ha-space-1)",
|
||
right: rtl ? "var(--ha-space-1)" : undefined,
|
||
transform: rtl ? "scaleX(-1)" : "",
|
||
})}
|
||
.end=${(
|
||
item as FloorComboBoxItem & { last?: boolean | undefined }
|
||
).last}
|
||
slot="start"
|
||
></ha-tree-indicator>
|
||
`
|
||
: nothing}
|
||
${item.icon
|
||
? html`<ha-icon slot="start" .icon=${item.icon}></ha-icon>`
|
||
: item.icon_path
|
||
? html`<ha-svg-icon
|
||
slot="start"
|
||
.path=${item.icon_path}
|
||
></ha-svg-icon>`
|
||
: type === "entity" && (item as EntityComboBoxItem).stateObj
|
||
? html`
|
||
<state-badge
|
||
slot="start"
|
||
.stateObj=${(item as EntityComboBoxItem).stateObj}
|
||
.hass=${this.hass}
|
||
></state-badge>
|
||
`
|
||
: type === "device" && (item as DevicePickerItem).domain
|
||
? html`
|
||
<img
|
||
slot="start"
|
||
alt=""
|
||
crossorigin="anonymous"
|
||
referrerpolicy="no-referrer"
|
||
src=${brandsUrl({
|
||
domain: (item as DevicePickerItem).domain!,
|
||
type: "icon",
|
||
darkOptimized: this.hass.themes.darkMode,
|
||
})}
|
||
/>
|
||
`
|
||
: type === "floor"
|
||
? html`<ha-floor-icon
|
||
slot="start"
|
||
.floor=${(item as FloorComboBoxItem).floor!}
|
||
></ha-floor-icon>`
|
||
: type === "area"
|
||
? html`<ha-svg-icon
|
||
slot="start"
|
||
.path=${item.icon_path || mdiTextureBox}
|
||
></ha-svg-icon>`
|
||
: nothing}
|
||
<span slot="headline">${item.primary}</span>
|
||
${item.secondary
|
||
? html`<span slot="supporting-text">${item.secondary}</span>`
|
||
: nothing}
|
||
${(item as EntityComboBoxItem).stateObj && showEntityId
|
||
? html`
|
||
<span slot="supporting-text" class="code">
|
||
${(item as EntityComboBoxItem).stateObj?.entity_id}
|
||
</span>
|
||
`
|
||
: nothing}
|
||
${(item as EntityComboBoxItem).domain_name &&
|
||
(type !== "entity" || !showEntityId)
|
||
? html`
|
||
<div slot="trailing-supporting-text" class="domain">
|
||
${(item as EntityComboBoxItem).domain_name}
|
||
</div>
|
||
`
|
||
: nothing}
|
||
</ha-combo-box-item>
|
||
`;
|
||
};
|
||
|
||
private _filterGroup(
|
||
type: TargetType,
|
||
items: (FloorComboBoxItem | PickerComboBoxItem | EntityComboBoxItem)[],
|
||
checkExact?: (
|
||
item: FloorComboBoxItem | PickerComboBoxItem | EntityComboBoxItem
|
||
) => boolean
|
||
) {
|
||
const fuseIndex = this._fuseIndexes[type](items);
|
||
const fuse = new HaFuse(
|
||
items,
|
||
{
|
||
shouldSort: false,
|
||
minMatchCharLength: Math.min(this._searchTerm.length, 2),
|
||
},
|
||
fuseIndex
|
||
);
|
||
|
||
const results = fuse.multiTermsSearch(this._searchTerm);
|
||
let filteredItems = items;
|
||
if (results) {
|
||
filteredItems = results.map((result) => result.item);
|
||
}
|
||
|
||
if (!checkExact) {
|
||
return filteredItems;
|
||
}
|
||
|
||
// If there is exact match for entity id, put it first
|
||
const index = filteredItems.findIndex((item) => checkExact(item));
|
||
if (index === -1) {
|
||
return filteredItems;
|
||
}
|
||
|
||
const [exactMatch] = filteredItems.splice(index, 1);
|
||
filteredItems.unshift(exactMatch);
|
||
|
||
return filteredItems;
|
||
}
|
||
|
||
private _keyFunction = (
|
||
item:
|
||
| PickerComboBoxItem
|
||
| (FloorComboBoxItem & { last?: boolean | undefined })
|
||
| EntityComboBoxItem
|
||
| DevicePickerItem
|
||
| string
|
||
) => {
|
||
if (typeof item === "string") {
|
||
return item === "padding" ? "padding" : `title-${item}`;
|
||
}
|
||
const type = this._getRowType(item);
|
||
if (type === "empty") {
|
||
return `empty-search`;
|
||
}
|
||
if (type === "area" || type === "floor") {
|
||
return `${type}-${item[type]?.[`${type}_id`]}`;
|
||
}
|
||
return `${type}-${item.id}`;
|
||
};
|
||
|
||
private _getItems = memoizeOne(
|
||
(
|
||
filterTypes: TargetTypeFloorless[],
|
||
entityFilter: this["entityFilter"],
|
||
deviceFilter: this["deviceFilter"],
|
||
includeDomains: this["includeDomains"],
|
||
includeDeviceClasses: this["includeDeviceClasses"],
|
||
targetValue: this["targetValue"],
|
||
searchTerm: string,
|
||
createDomains: this["createDomains"],
|
||
configEntryLookup: Record<string, ConfigEntry>,
|
||
mode: this["mode"]
|
||
) => {
|
||
const items: (
|
||
| string
|
||
| FloorComboBoxItem
|
||
| EntityComboBoxItem
|
||
| PickerComboBoxItem
|
||
)[] = [];
|
||
|
||
if (filterTypes.length === 0 || filterTypes.includes("entity")) {
|
||
let entities = this._getEntitiesMemoized(
|
||
this.hass,
|
||
includeDomains,
|
||
undefined,
|
||
entityFilter,
|
||
includeDeviceClasses,
|
||
undefined,
|
||
undefined,
|
||
targetValue?.entity_id
|
||
? ensureArray(targetValue.entity_id)
|
||
: undefined
|
||
);
|
||
|
||
if (searchTerm) {
|
||
entities = this._filterGroup(
|
||
"entity",
|
||
entities,
|
||
(item: EntityComboBoxItem) =>
|
||
item.stateObj?.entity_id === searchTerm
|
||
) as EntityComboBoxItem[];
|
||
}
|
||
|
||
if (entities.length > 0 && filterTypes.length !== 1) {
|
||
// show group title
|
||
items.push(
|
||
this.hass.localize("ui.components.target-picker.type.entities")
|
||
);
|
||
}
|
||
|
||
items.push(...entities);
|
||
}
|
||
|
||
if (filterTypes.length === 0 || filterTypes.includes("device")) {
|
||
let devices = this._getDevicesMemoized(
|
||
this.hass,
|
||
configEntryLookup,
|
||
includeDomains,
|
||
undefined,
|
||
includeDeviceClasses,
|
||
deviceFilter,
|
||
entityFilter,
|
||
targetValue?.device_id
|
||
? ensureArray(targetValue.device_id)
|
||
: undefined
|
||
);
|
||
|
||
if (searchTerm) {
|
||
devices = this._filterGroup("device", devices);
|
||
}
|
||
|
||
if (devices.length > 0 && filterTypes.length !== 1) {
|
||
// show group title
|
||
items.push(
|
||
this.hass.localize("ui.components.target-picker.type.devices")
|
||
);
|
||
}
|
||
|
||
items.push(...devices);
|
||
}
|
||
|
||
if (filterTypes.length === 0 || filterTypes.includes("area")) {
|
||
let areasAndFloors = this._getAreasAndFloorsMemoized(
|
||
this.hass.states,
|
||
this.hass.floors,
|
||
this.hass.areas,
|
||
this.hass.devices,
|
||
this.hass.entities,
|
||
memoizeOne((value: AreaFloorValue): string =>
|
||
[value.type, value.id].join(SEPARATOR)
|
||
),
|
||
includeDomains,
|
||
undefined,
|
||
includeDeviceClasses,
|
||
deviceFilter,
|
||
entityFilter,
|
||
targetValue?.area_id ? ensureArray(targetValue.area_id) : undefined,
|
||
targetValue?.floor_id ? ensureArray(targetValue.floor_id) : undefined
|
||
);
|
||
|
||
if (searchTerm) {
|
||
areasAndFloors = this._filterGroup(
|
||
"area",
|
||
areasAndFloors
|
||
) as FloorComboBoxItem[];
|
||
}
|
||
|
||
if (areasAndFloors.length > 0 && filterTypes.length !== 1) {
|
||
// show group title
|
||
items.push(
|
||
this.hass.localize("ui.components.target-picker.type.areas")
|
||
);
|
||
}
|
||
|
||
items.push(
|
||
...areasAndFloors.map((item, index) => {
|
||
const nextItem = areasAndFloors[index + 1];
|
||
|
||
if (
|
||
!nextItem ||
|
||
(item.type === "area" && nextItem.type === "floor")
|
||
) {
|
||
return {
|
||
...item,
|
||
last: true,
|
||
};
|
||
}
|
||
|
||
return item;
|
||
})
|
||
);
|
||
}
|
||
|
||
if (filterTypes.length === 0 || filterTypes.includes("label")) {
|
||
let labels = this._getLabelsMemoized(
|
||
this.hass,
|
||
this._labelRegistry,
|
||
includeDomains,
|
||
undefined,
|
||
includeDeviceClasses,
|
||
deviceFilter,
|
||
entityFilter,
|
||
targetValue?.label_id ? ensureArray(targetValue.label_id) : undefined
|
||
);
|
||
|
||
if (searchTerm) {
|
||
labels = this._filterGroup("label", labels);
|
||
}
|
||
|
||
if (labels.length > 0 && filterTypes.length !== 1) {
|
||
// show group title
|
||
items.push(
|
||
this.hass.localize("ui.components.target-picker.type.labels")
|
||
);
|
||
}
|
||
|
||
items.push(...labels);
|
||
}
|
||
|
||
items.push(...this._getCreateItems(createDomains));
|
||
|
||
if (searchTerm && items.length === 0) {
|
||
items.push({
|
||
id: EMPTY_SEARCH,
|
||
primary: this.hass.localize(
|
||
"ui.components.target-picker.no_target_found",
|
||
{ term: html`<div><b>‘${searchTerm}’</b></div>` }
|
||
),
|
||
});
|
||
} else if (items.length === 0) {
|
||
items.push({
|
||
id: EMPTY_SEARCH,
|
||
primary: this.hass.localize("ui.components.target-picker.no_targets"),
|
||
});
|
||
}
|
||
|
||
if (mode === "dialog") {
|
||
items.push("padding"); // padding for safe area inset
|
||
}
|
||
|
||
return items;
|
||
}
|
||
);
|
||
|
||
private _getCreateItems = memoizeOne(
|
||
(createDomains: this["createDomains"]) => {
|
||
if (!createDomains?.length) {
|
||
return [];
|
||
}
|
||
|
||
return createDomains.map((domain) => {
|
||
const primary = this.hass.localize(
|
||
"ui.components.entity.entity-picker.create_helper",
|
||
{
|
||
domain: isHelperDomain(domain)
|
||
? this.hass.localize(
|
||
`ui.panel.config.helpers.types.${domain as HelperDomain}`
|
||
)
|
||
: domainToName(this.hass.localize, domain),
|
||
}
|
||
);
|
||
|
||
return {
|
||
id: CREATE_ID + domain,
|
||
primary: primary,
|
||
secondary: this.hass.localize(
|
||
"ui.components.entity.entity-picker.new_entity"
|
||
),
|
||
icon_path: mdiPlus,
|
||
} satisfies EntityComboBoxItem;
|
||
});
|
||
}
|
||
);
|
||
|
||
private _fuseIndexes = {
|
||
area: memoizeOne((states: FloorComboBoxItem[]) =>
|
||
this._createFuseIndex(states)
|
||
),
|
||
entity: memoizeOne((states: EntityComboBoxItem[]) =>
|
||
this._createFuseIndex(states)
|
||
),
|
||
device: memoizeOne((states: DevicePickerItem[]) =>
|
||
this._createFuseIndex(states)
|
||
),
|
||
label: memoizeOne((states: PickerComboBoxItem[]) =>
|
||
this._createFuseIndex(states)
|
||
),
|
||
};
|
||
|
||
private _createFuseIndex = (states) =>
|
||
Fuse.createIndex(["search_labels"], states);
|
||
|
||
private _searchChanged(ev: Event) {
|
||
const textfield = ev.target as HaTextField;
|
||
const value = textfield.value.trim();
|
||
this._searchTerm = value;
|
||
|
||
this._resetSelectedItem();
|
||
}
|
||
|
||
private _handlePickTarget = (ev) => {
|
||
const id = ev.currentTarget?.targetId as string;
|
||
const type = ev.currentTarget?.targetType as TargetType;
|
||
|
||
if (!id || !type) {
|
||
return;
|
||
}
|
||
|
||
this._pickTarget(id, type);
|
||
};
|
||
|
||
private _pickTarget = (id: string, type: TargetType) => {
|
||
if (type === "label" && id === EMPTY_SEARCH) {
|
||
return;
|
||
}
|
||
|
||
if (id.startsWith(CREATE_ID)) {
|
||
const domain = id.substring(CREATE_ID.length);
|
||
|
||
fireEvent(this, "create-domain-picked", domain);
|
||
return;
|
||
}
|
||
|
||
fireEvent(this, "target-picked", {
|
||
id,
|
||
type,
|
||
});
|
||
};
|
||
|
||
private get _showEntityId() {
|
||
return this.hass.userData?.showEntityIdPicker;
|
||
}
|
||
|
||
private _toggleFilter(ev: any) {
|
||
this._resetSelectedItem();
|
||
this._filterHeader = undefined;
|
||
const type = ev.target.type as TargetTypeFloorless;
|
||
if (!type) {
|
||
return;
|
||
}
|
||
const index = this.filterTypes.indexOf(type);
|
||
if (index === -1) {
|
||
this.filterTypes = [...this.filterTypes, type];
|
||
} else {
|
||
this.filterTypes = this.filterTypes.filter((t) => t !== type);
|
||
}
|
||
|
||
// Reset scroll position when filter changes
|
||
if (this._virtualizerElement) {
|
||
this._virtualizerElement.scrollToIndex(0);
|
||
}
|
||
|
||
fireEvent(this, "filter-types-changed", this.filterTypes);
|
||
}
|
||
|
||
@eventOptions({ passive: true })
|
||
private _onScrollList(ev) {
|
||
const top = ev.target.scrollTop ?? 0;
|
||
this._listScrolled = top > 0;
|
||
}
|
||
|
||
private _resetSelectedItem() {
|
||
this._virtualizerElement
|
||
?.querySelector(".selected")
|
||
?.classList.remove("selected");
|
||
this._selectedItemIndex = -1;
|
||
}
|
||
|
||
static styles = [
|
||
haStyleScrollbar,
|
||
css`
|
||
:host {
|
||
display: flex;
|
||
flex-direction: column;
|
||
padding-top: var(--ha-space-3);
|
||
flex: 1;
|
||
}
|
||
|
||
ha-textfield {
|
||
padding: 0 var(--ha-space-3);
|
||
}
|
||
|
||
.filter {
|
||
display: flex;
|
||
gap: var(--ha-space-2);
|
||
padding: var(--ha-space-3) var(--ha-space-3);
|
||
overflow: auto;
|
||
--ha-button-border-radius: var(--ha-border-radius-md);
|
||
}
|
||
|
||
:host([mode="dialog"]) .filter {
|
||
padding: var(--ha-space-3) var(--ha-space-4);
|
||
}
|
||
|
||
.filter ha-button {
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.filter .separator {
|
||
height: var(--ha-space-8);
|
||
width: 0;
|
||
border: 1px solid var(--ha-color-border-neutral-quiet);
|
||
}
|
||
|
||
.filter-header,
|
||
.title {
|
||
background-color: var(--ha-color-fill-neutral-quiet-resting);
|
||
padding: var(--ha-space-1) var(--ha-space-2);
|
||
font-weight: var(--ha-font-weight-bold);
|
||
color: var(--secondary-text-color);
|
||
min-height: var(--ha-space-6);
|
||
display: flex;
|
||
align-items: center;
|
||
}
|
||
|
||
.title {
|
||
width: 100%;
|
||
min-height: var(--ha-space-8);
|
||
}
|
||
|
||
:host([mode="dialog"]) .title {
|
||
padding: var(--ha-space-1) var(--ha-space-4);
|
||
}
|
||
|
||
:host([mode="dialog"]) ha-textfield {
|
||
padding: 0 var(--ha-space-4);
|
||
}
|
||
|
||
ha-combo-box-item {
|
||
width: 100%;
|
||
}
|
||
|
||
ha-combo-box-item.selected {
|
||
background-color: var(--ha-color-fill-neutral-quiet-hover);
|
||
}
|
||
|
||
@media (prefers-color-scheme: dark) {
|
||
ha-combo-box-item.selected {
|
||
background-color: var(--ha-color-fill-neutral-normal-hover);
|
||
}
|
||
}
|
||
|
||
.filter-header-wrapper {
|
||
height: 0;
|
||
position: relative;
|
||
}
|
||
|
||
.filter-header {
|
||
opacity: 0;
|
||
position: absolute;
|
||
top: 1px;
|
||
width: calc(100% - var(--ha-space-8));
|
||
}
|
||
|
||
.filter-header.show {
|
||
opacity: 1;
|
||
z-index: 1;
|
||
}
|
||
|
||
lit-virtualizer {
|
||
flex: 1;
|
||
}
|
||
|
||
lit-virtualizer:focus-visible {
|
||
outline: none;
|
||
}
|
||
|
||
lit-virtualizer.scrolled {
|
||
border-top: 1px solid var(--ha-color-border-neutral-quiet);
|
||
}
|
||
|
||
.bottom-padding {
|
||
height: max(var(--safe-area-inset-bottom, 0px), var(--ha-space-8));
|
||
width: 100%;
|
||
}
|
||
|
||
.empty {
|
||
text-align: center;
|
||
}
|
||
`,
|
||
];
|
||
}
|
||
|
||
declare global {
|
||
interface HTMLElementTagNameMap {
|
||
"ha-target-picker-selector": HaTargetPickerSelector;
|
||
}
|
||
|
||
interface HASSDomEvents {
|
||
"filter-types-changed": TargetTypeFloorless[];
|
||
"target-picked": {
|
||
type: TargetType;
|
||
id: string;
|
||
};
|
||
"create-domain-picked": string;
|
||
}
|
||
}
|