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 = {}; 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`
${this._renderFilterButtons()}
${this._filterHeader}
`; } @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`
`; } const selected = this.filterTypes.includes(filterType); return html` ${selected ? html`` : nothing} ${this.hass.localize( `ui.components.target-picker.type.${filterType === "entity" ? "entities" : `${filterType}s`}` as LocalizeKeys )} `; }); } 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`
`; } return html`
${item}
`; } 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` ${(item as FloorComboBoxItem).type === "area" && hasFloor ? html` ` : nothing} ${item.icon ? html`` : item.icon_path ? html`` : type === "entity" && (item as EntityComboBoxItem).stateObj ? html` ` : type === "device" && (item as DevicePickerItem).domain ? html` ` : type === "floor" ? html`` : type === "area" ? html`` : nothing} ${item.primary} ${item.secondary ? html`${item.secondary}` : nothing} ${(item as EntityComboBoxItem).stateObj && showEntityId ? html` ${(item as EntityComboBoxItem).stateObj?.entity_id} ` : nothing} ${(item as EntityComboBoxItem).domain_name && (type !== "entity" || !showEntityId) ? html`
${(item as EntityComboBoxItem).domain_name}
` : nothing}
`; }; 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, 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`
‘${searchTerm}’
` } ), }); } 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; } }