((item) => ({
- ...item,
- a11y_label: item.a11y_label || item.primary,
- }))
- .sort((entityA, entityB) =>
+ private _getItems = () => {
+ let items = [
+ ...(this.getItems
+ ? this.getItems(this._search, this.selectedSection)
+ : []),
+ ];
+
+ if (!this.sections?.length) {
+ items = items.sort((entityA, entityB) =>
caseInsensitiveStringCompare(
- entityA.sorting_label!,
- entityB.sorting_label!,
+ (entityA as PickerComboBoxItem).sorting_label!,
+ (entityB as PickerComboBoxItem).sorting_label!,
this.hass?.locale.language ?? navigator.language
)
);
+ }
- if (!sortedItems.length) {
- sortedItems.push(
- this._defaultNotFoundItem(this.notFoundLabel, this.hass?.localize)
- );
+ if (!items.length) {
+ items.push(NO_ITEMS_AVAILABLE_ID);
}
const additionalItems = this._getAdditionalItems();
- sortedItems.push(...additionalItems);
- return sortedItems;
+ items.push(...additionalItems);
+
+ if (this.mode === "dialog") {
+ items.push("padding"); // padding for safe area inset
+ }
+
+ return items;
};
- private _renderItem = (item: PickerComboBoxItem, index: number) => {
+ private _renderItem = (item: PickerComboBoxItem | string, index: number) => {
+ if (item === "padding") {
+ return html``;
+ }
+ if (item === NO_ITEMS_AVAILABLE_ID) {
+ return html`
+
+
+
+ ${this._search
+ ? typeof this.notFoundLabel === "function"
+ ? this.notFoundLabel(this._search)
+ : this.notFoundLabel ||
+ this.hass?.localize("ui.components.combo-box.no_match") ||
+ "No matching items found"
+ : this.emptyLabel ||
+ this.hass?.localize("ui.components.combo-box.no_items") ||
+ "No items available"}
+
+
+ `;
+ }
+ if (typeof item === "string") {
+ return html`${item}
`;
+ }
+
const renderer = this.rowRenderer || DEFAULT_ROW_RENDERER;
return html`
- ${item.id === NO_MATCHING_ITEMS_FOUND_ID
- ? DEFAULT_ROW_RENDERER(item, index)
- : renderer(item, index)}
+ ${renderer(item, index)}
`;
};
@@ -242,10 +328,6 @@ export class HaPickerComboBox extends LitElement {
const value = (ev.currentTarget as any).value as string;
const newValue = value?.trim();
- if (newValue === NO_MATCHING_ITEMS_FOUND_ID) {
- return;
- }
-
fireEvent(this, "value-changed", { value: newValue });
};
@@ -256,51 +338,83 @@ export class HaPickerComboBox extends LitElement {
private _filterChanged = (ev: Event) => {
const textfield = ev.target as HaTextField;
const searchString = textfield.value.trim();
+ this._search = searchString;
- if (!searchString) {
- this._items = this._allItems;
- return;
- }
+ if (this.sections?.length) {
+ this._items = this._getItems();
+ } else {
+ if (!searchString) {
+ this._items = this._allItems;
+ return;
+ }
- const index = this._fuseIndex(this._allItems);
- const fuse = new HaFuse(
- this._allItems,
- {
- shouldSort: false,
- minMatchCharLength: Math.min(searchString.length, 2),
- },
- index
- );
+ const index = this._fuseIndex(this._allItems as PickerComboBoxItem[]);
+ const fuse = new HaFuse(
+ this._allItems as PickerComboBoxItem[],
+ {
+ shouldSort: false,
+ minMatchCharLength: Math.min(searchString.length, 2),
+ },
+ index
+ );
- const results = fuse.multiTermsSearch(searchString);
- let filteredItems = this._allItems as PickerComboBoxItem[];
- if (results) {
- const items = results.map((result) => result.item);
- if (items.length === 0) {
- items.push(
- this._defaultNotFoundItem(this.notFoundLabel, this.hass?.localize)
+ const results = fuse.multiTermsSearch(searchString);
+ let filteredItems = [...this._allItems];
+
+ if (results) {
+ const items: (PickerComboBoxItem | string)[] = results.map(
+ (result) => result.item
+ );
+
+ if (!items.length) {
+ filteredItems.push(NO_ITEMS_AVAILABLE_ID);
+ }
+
+ const additionalItems = this._getAdditionalItems();
+ items.push(...additionalItems);
+
+ filteredItems = items;
+ }
+
+ if (this.searchFn) {
+ filteredItems = this.searchFn(
+ searchString,
+ filteredItems as PickerComboBoxItem[],
+ this._allItems as PickerComboBoxItem[]
);
}
- const additionalItems = this._getAdditionalItems(searchString);
- items.push(...additionalItems);
- filteredItems = items;
+
+ this._items = filteredItems as PickerComboBoxItem[];
}
- if (this.searchFn) {
- filteredItems = this.searchFn(
- searchString,
- filteredItems,
- this._allItems
- );
- }
-
- this._items = filteredItems as PickerComboBoxItemWithLabel[];
this._selectedItemIndex = -1;
if (this._virtualizerElement) {
this._virtualizerElement.scrollTo(0, 0);
}
};
+ private _toggleSection(ev: Event) {
+ ev.stopPropagation();
+ this._resetSelectedItem();
+ this._sectionTitle = undefined;
+ const section = (ev.target as HTMLElement)["section-id"] as string;
+ if (!section) {
+ return;
+ }
+ if (this.selectedSection === section) {
+ this.selectedSection = undefined;
+ } else {
+ this.selectedSection = section;
+ }
+
+ this._items = this._getItems();
+
+ // Reset scroll position when filter changes
+ if (this._virtualizerElement) {
+ this._virtualizerElement.scrollToIndex(0);
+ }
+ }
+
private _registerKeyboardShortcuts() {
this._removeKeyboardShortcuts = tinykeys(this, {
ArrowUp: this._selectPreviousItem,
@@ -344,7 +458,7 @@ export class HaPickerComboBox extends LitElement {
return;
}
- if (items[nextIndex].id === NO_MATCHING_ITEMS_FOUND_ID) {
+ if (typeof items[nextIndex] === "string") {
// Skip titles, padding and empty search
if (nextIndex === maxItems) {
return;
@@ -373,7 +487,7 @@ export class HaPickerComboBox extends LitElement {
return;
}
- if (items[nextIndex]?.id === NO_MATCHING_ITEMS_FOUND_ID) {
+ if (typeof items[nextIndex] === "string") {
// Skip titles, padding and empty search
if (nextIndex === 0) {
return;
@@ -395,13 +509,6 @@ export class HaPickerComboBox extends LitElement {
const nextIndex = 0;
- if (
- (this._virtualizerElement.items[nextIndex] as PickerComboBoxItem)?.id ===
- NO_MATCHING_ITEMS_FOUND_ID
- ) {
- return;
- }
-
if (typeof this._virtualizerElement.items[nextIndex] === "string") {
this._selectedItemIndex = nextIndex + 1;
} else {
@@ -419,13 +526,6 @@ export class HaPickerComboBox extends LitElement {
const nextIndex = this._virtualizerElement.items.length - 1;
- if (
- (this._virtualizerElement.items[nextIndex] as PickerComboBoxItem)?.id ===
- NO_MATCHING_ITEMS_FOUND_ID
- ) {
- return;
- }
-
if (typeof this._virtualizerElement.items[nextIndex] === "string") {
this._selectedItemIndex = nextIndex - 1;
} else {
@@ -453,10 +553,7 @@ export class HaPickerComboBox extends LitElement {
ev.stopPropagation();
const firstItem = this._virtualizerElement?.items[0] as PickerComboBoxItem;
- if (
- this._virtualizerElement?.items.length === 1 &&
- firstItem.id !== NO_MATCHING_ITEMS_FOUND_ID
- ) {
+ if (this._virtualizerElement?.items.length === 1) {
fireEvent(this, "value-changed", {
value: firstItem.id,
});
@@ -472,7 +569,7 @@ export class HaPickerComboBox extends LitElement {
const item = this._virtualizerElement?.items[
this._selectedItemIndex
] as PickerComboBoxItem;
- if (item && item.id !== NO_MATCHING_ITEMS_FOUND_ID) {
+ if (item) {
fireEvent(this, "value-changed", { value: item.id });
}
};
@@ -484,6 +581,9 @@ export class HaPickerComboBox extends LitElement {
this._selectedItemIndex = -1;
}
+ private _keyFunction = (item: PickerComboBoxItem | string) =>
+ typeof item === "string" ? item : item.id;
+
static styles = [
haStyleScrollbar,
css`
@@ -558,6 +658,80 @@ export class HaPickerComboBox extends LitElement {
background-color: var(--ha-color-fill-neutral-normal-hover);
}
}
+
+ .sections {
+ display: flex;
+ flex-wrap: nowrap;
+ gap: var(--ha-space-2);
+ padding: var(--ha-space-3) var(--ha-space-3);
+ overflow: auto;
+ }
+
+ :host([mode="dialog"]) .sections {
+ padding: var(--ha-space-3) var(--ha-space-4);
+ }
+
+ .sections ha-filter-chip {
+ flex-shrink: 0;
+ --md-filter-chip-selected-container-color: var(
+ --ha-color-fill-primary-normal-hover
+ );
+ color: var(--primary-color);
+ }
+
+ .sections .separator {
+ height: var(--ha-space-8);
+ width: 0;
+ border: 1px solid var(--ha-color-border-neutral-quiet);
+ }
+
+ .section-title,
+ .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%;
+ }
+
+ :host([mode="dialog"]) .title {
+ padding: var(--ha-space-1) var(--ha-space-4);
+ }
+
+ :host([mode="dialog"]) ha-textfield {
+ padding: 0 var(--ha-space-4);
+ }
+
+ .section-title-wrapper {
+ height: 0;
+ position: relative;
+ }
+
+ .section-title {
+ opacity: 0;
+ position: absolute;
+ top: 1px;
+ width: calc(100% - var(--ha-space-8));
+ }
+
+ .section-title.show {
+ opacity: 1;
+ z-index: 1;
+ }
+
+ .empty-search {
+ display: flex;
+ width: 100%;
+ flex-direction: column;
+ align-items: center;
+ padding: var(--ha-space-3);
+ }
`,
];
}
diff --git a/src/components/ha-target-picker.ts b/src/components/ha-target-picker.ts
index d2eae714b4..2ecb646d29 100644
--- a/src/components/ha-target-picker.ts
+++ b/src/components/ha-target-picker.ts
@@ -1,15 +1,31 @@
import "@home-assistant/webawesome/dist/components/popover/popover";
+import { consume } from "@lit/context";
// @ts-ignore
import chipStyles from "@material/chips/dist/mdc.chips.min.css";
-import { mdiPlaylistPlus } from "@mdi/js";
+import { mdiPlus, mdiTextureBox } from "@mdi/js";
+import Fuse from "fuse.js";
import type { HassServiceTarget } from "home-assistant-js-websocket";
-import type { CSSResultGroup } from "lit";
+import type { CSSResultGroup, PropertyValues } from "lit";
import { LitElement, css, html, nothing, unsafeCSS } from "lit";
-import { customElement, property, query, state } from "lit/decorators";
+import { customElement, property, state } from "lit/decorators";
+import { styleMap } from "lit/directives/style-map";
+import memoizeOne from "memoize-one";
import { ensureArray } from "../common/array/ensure-array";
import { fireEvent } from "../common/dom/fire_event";
import { isValidEntityId } from "../common/entity/valid_entity_id";
+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 {
areaMeetsFilter,
deviceMeetsFilter,
@@ -18,18 +34,23 @@ import {
type TargetTypeFloorless,
} from "../data/target";
import { SubscribeMixin } from "../mixins/subscribe-mixin";
+import { isHelperDomain } from "../panels/config/helpers/const";
import { showHelperDetailDialog } from "../panels/config/helpers/show-dialog-helper-detail";
+import { HaFuse } from "../resources/fuse";
import type { HomeAssistant } from "../types";
+import { brandsUrl } from "../util/brands-url";
import type { HaDevicePickerDeviceFilterFunc } from "./device/ha-device-picker";
-import "./ha-bottom-sheet";
-import "./ha-button";
-import "./ha-input-helper-text";
+import "./ha-generic-picker";
+import type { PickerComboBoxItem } from "./ha-picker-combo-box";
import "./ha-svg-icon";
+import "./ha-tree-indicator";
import "./target-picker/ha-target-picker-item-group";
-import "./target-picker/ha-target-picker-selector";
-import type { HaTargetPickerSelector } from "./target-picker/ha-target-picker-selector";
import "./target-picker/ha-target-picker-value-chip";
+const EMPTY_SEARCH = "___EMPTY_SEARCH___";
+const SEPARATOR = "________";
+const CREATE_ID = "___create-new-entity___";
+
@customElement("ha-target-picker")
export class HaTargetPicker extends SubscribeMixin(LitElement) {
@property({ attribute: false }) public hass!: HomeAssistant;
@@ -68,23 +89,54 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
@property({ attribute: "add-on-top", type: Boolean }) public addOnTop = false;
- @state() private _open = false;
+ @state() private _selectedSection?: TargetTypeFloorless;
- @state() private _addTargetWidth = 0;
+ @state() private _configEntryLookup: Record = {};
- @state() private _narrow = false;
-
- @state() private _pickerFilter?: TargetTypeFloorless;
-
- @state() private _pickerWrapperOpen = false;
-
- @query(".add-target-wrapper") private _addTargetWrapper?: HTMLDivElement;
-
- @query("ha-target-picker-selector")
- private _targetPickerSelectorElement?: HaTargetPickerSelector;
+ @state()
+ @consume({ context: labelsContext, subscribe: true })
+ private _labelRegistry!: LabelRegistryEntry[];
private _newTarget?: { type: TargetType; id: string };
+ private _getDevicesMemoized = memoizeOne(getDevices);
+
+ private _getLabelsMemoized = memoizeOne(getLabels);
+
+ private _getEntitiesMemoized = memoizeOne(getEntities);
+
+ private _getAreasAndFloorsMemoized = memoizeOne(getAreasAndFloors);
+
+ private get _showEntityId() {
+ return this.hass.userData?.showEntityIdPicker;
+ }
+
+ 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)
+ ),
+ };
+
+ public willUpdate(changedProps: PropertyValues) {
+ super.willUpdate(changedProps);
+
+ if (!this.hasUpdated) {
+ this._loadConfigEntries();
+ }
+ }
+
+ private _createFuseIndex = (states) =>
+ Fuse.createIndex(["search_labels"], states);
+
protected render() {
if (this.addOnTop) {
return html` ${this._renderPicker()} ${this._renderItems()} `;
@@ -289,137 +341,63 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
}
private _renderPicker() {
+ const sections = [
+ {
+ id: "entity",
+ label: this.hass.localize("ui.components.target-picker.type.entities"),
+ },
+ {
+ id: "device",
+ label: this.hass.localize("ui.components.target-picker.type.devices"),
+ },
+ {
+ id: "area",
+ label: this.hass.localize("ui.components.target-picker.type.areas"),
+ },
+ "separator" as const,
+ {
+ id: "label",
+ label: this.hass.localize("ui.components.target-picker.type.labels"),
+ },
+ ];
+
return html`
-
-
- ${this.hass.localize("ui.components.target-picker.add_target")}
-
- ${!this._narrow && (this._pickerWrapperOpen || this._open)
- ? html`
-
- ${this._renderTargetSelector()}
-
- `
- : this._pickerWrapperOpen || this._open
- ? html`
- ${this._renderTargetSelector(true)}
- `
- : nothing}
+
- ${this.helper
- ? html`${this.helper}`
- : nothing}
`;
}
- connectedCallback() {
- super.connectedCallback();
- this._handleResize();
- window.addEventListener("resize", this._handleResize);
- }
-
- public disconnectedCallback() {
- super.disconnectedCallback();
- window.removeEventListener("resize", this._handleResize);
- }
-
- private _handleResize = () => {
- this._narrow =
- window.matchMedia("(max-width: 870px)").matches ||
- window.matchMedia("(max-height: 500px)").matches;
- };
-
- private _showPicker() {
- this._addTargetWidth = this._addTargetWrapper?.offsetWidth || 0;
- this._pickerWrapperOpen = true;
- }
-
- // wait for drawer animation to finish
- private _showSelector = () => {
- this._open = true;
- requestAnimationFrame(() => {
- this._targetPickerSelectorElement?.focus();
- });
- };
-
- private _handleUpdatePickerFilter(
- ev: CustomEvent
- ) {
- this._updatePickerFilter(
- typeof ev.detail === "string" ? ev.detail : undefined
- );
- }
-
- private _updatePickerFilter = (filter?: TargetTypeFloorless) => {
- this._pickerFilter = filter;
- };
-
- private _hidePicker(ev) {
+ private _targetPicked(ev: CustomEvent<{ value: string }>) {
ev.stopPropagation();
- this._open = false;
- this._pickerWrapperOpen = false;
-
- if (this._newTarget) {
- this._addTarget(this._newTarget.id, this._newTarget.type);
- this._newTarget = undefined;
+ const value = ev.detail.value;
+ if (value.startsWith(CREATE_ID)) {
+ this._createNewDomainElement(value.substring(CREATE_ID.length));
+ return;
}
- }
- private _renderTargetSelector(dialogMode = false) {
- if (!this._open) {
- return nothing;
- }
- return html`
-
- `;
+ const [type, id] = ev.detail.value.split(SEPARATOR);
+ this._addTarget(id, type as TargetType);
}
private _addTarget(id: string, type: TargetType) {
@@ -454,26 +432,7 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
?.removeAttribute("collapsed");
}
- private _handleTargetPicked = async (
- ev: CustomEvent<{ type: TargetType; id: string }>
- ) => {
- ev.stopPropagation();
-
- this._pickerWrapperOpen = false;
-
- if (!ev.detail.type || !ev.detail.id) {
- return;
- }
-
- // save new target temporarily to add it after dialog closes
- this._newTarget = ev.detail;
- };
-
- private _handleCreateDomain = (ev: CustomEvent) => {
- this._pickerWrapperOpen = false;
-
- const domain = ev.detail;
-
+ private _createNewDomainElement = (domain: string) => {
showHelperDetailDialog(this, {
domain,
dialogClosedCallback: (item) => {
@@ -675,6 +634,465 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
return undefined;
}
+ 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 _sectionTitleFunction = ({
+ firstIndex,
+ lastIndex,
+ firstItem,
+ secondItem,
+ itemsCount,
+ }: {
+ firstIndex: number;
+ lastIndex: number;
+ firstItem: PickerComboBoxItem | string;
+ secondItem: PickerComboBoxItem | string;
+ itemsCount: number;
+ }) => {
+ if (
+ firstItem === undefined ||
+ secondItem === undefined ||
+ typeof firstItem === "string" ||
+ (typeof secondItem === "string" && secondItem !== "padding") ||
+ (firstIndex === 0 && lastIndex === itemsCount - 1)
+ ) {
+ return undefined;
+ }
+
+ 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;
+
+ return translationType
+ ? this.hass.localize(
+ `ui.components.target-picker.type.${translationType}`
+ )
+ : undefined;
+ };
+
+ private _getItems = (searchString: string, section: string) => {
+ this._selectedSection = section as TargetTypeFloorless | undefined;
+
+ return this._getItemsMemoized(
+ this.entityFilter,
+ this.deviceFilter,
+ this.includeDomains,
+ this.includeDeviceClasses,
+ this.value,
+ searchString,
+ this._configEntryLookup,
+ this._selectedSection
+ );
+ };
+
+ private _getItemsMemoized = memoizeOne(
+ (
+ entityFilter: this["entityFilter"],
+ deviceFilter: this["deviceFilter"],
+ includeDomains: this["includeDomains"],
+ includeDeviceClasses: this["includeDeviceClasses"],
+ targetValue: this["value"],
+ searchTerm: string,
+ configEntryLookup: Record,
+ filterType?: TargetTypeFloorless
+ ) => {
+ const items: (
+ | string
+ | FloorComboBoxItem
+ | EntityComboBoxItem
+ | PickerComboBoxItem
+ )[] = [];
+
+ if (!filterType || filterType === "entity") {
+ let entities = this._getEntitiesMemoized(
+ this.hass,
+ includeDomains,
+ undefined,
+ entityFilter,
+ includeDeviceClasses,
+ undefined,
+ undefined,
+ targetValue?.entity_id
+ ? ensureArray(targetValue.entity_id)
+ : undefined,
+ undefined,
+ `entity${SEPARATOR}`
+ );
+
+ if (searchTerm) {
+ entities = this._filterGroup(
+ "entity",
+ entities,
+ searchTerm,
+ (item: EntityComboBoxItem) =>
+ item.stateObj?.entity_id === searchTerm
+ ) as EntityComboBoxItem[];
+ }
+
+ if (!filterType && entities.length) {
+ // show group title
+ items.push(
+ this.hass.localize("ui.components.target-picker.type.entities")
+ );
+ }
+
+ items.push(...entities);
+ }
+
+ if (!filterType || filterType === "device") {
+ let devices = this._getDevicesMemoized(
+ this.hass,
+ configEntryLookup,
+ includeDomains,
+ undefined,
+ includeDeviceClasses,
+ deviceFilter,
+ entityFilter,
+ targetValue?.device_id
+ ? ensureArray(targetValue.device_id)
+ : undefined,
+ undefined,
+ `device${SEPARATOR}`
+ );
+
+ if (searchTerm) {
+ devices = this._filterGroup("device", devices, searchTerm);
+ }
+
+ if (!filterType && devices.length) {
+ // show group title
+ items.push(
+ this.hass.localize("ui.components.target-picker.type.devices")
+ );
+ }
+
+ items.push(...devices);
+ }
+
+ if (!filterType || filterType === "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,
+ searchTerm
+ ) as FloorComboBoxItem[];
+ }
+
+ if (!filterType && areasAndFloors.length) {
+ // 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 (!filterType || filterType === "label") {
+ let labels = this._getLabelsMemoized(
+ this.hass,
+ this._labelRegistry,
+ includeDomains,
+ undefined,
+ includeDeviceClasses,
+ deviceFilter,
+ entityFilter,
+ targetValue?.label_id ? ensureArray(targetValue.label_id) : undefined,
+ `label${SEPARATOR}`
+ );
+
+ if (searchTerm) {
+ labels = this._filterGroup("label", labels, searchTerm);
+ }
+
+ if (!filterType && labels.length) {
+ // show group title
+ items.push(
+ this.hass.localize("ui.components.target-picker.type.labels")
+ );
+ }
+
+ items.push(...labels);
+ }
+
+ return items;
+ }
+ );
+
+ private _filterGroup(
+ type: TargetType,
+ items: (FloorComboBoxItem | PickerComboBoxItem | EntityComboBoxItem)[],
+ searchTerm: string,
+ checkExact?: (
+ item: FloorComboBoxItem | PickerComboBoxItem | EntityComboBoxItem
+ ) => boolean
+ ) {
+ const fuseIndex = this._fuseIndexes[type](items);
+ const fuse = new HaFuse(
+ items,
+ {
+ shouldSort: false,
+ minMatchCharLength: Math.min(searchTerm.length, 2),
+ },
+ fuseIndex
+ );
+
+ const results = fuse.multiTermsSearch(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 _getAdditionalItems = () => this._getCreateItems(this.createDomains);
+
+ 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}`)
+ : 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 async _loadConfigEntries() {
+ const configEntries = await getConfigEntries(this.hass);
+ this._configEntryLookup = Object.fromEntries(
+ configEntries.map((entry) => [entry.entry_id, entry])
+ );
+ }
+
+ private _renderRow = (
+ item:
+ | PickerComboBoxItem
+ | (FloorComboBoxItem & { last?: boolean | undefined })
+ | EntityComboBoxItem
+ | DevicePickerItem,
+ index: number
+ ) => {
+ if (!item) {
+ return nothing;
+ }
+
+ 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 _noTargetFoundLabel = (search: string) =>
+ this.hass.localize("ui.components.target-picker.no_target_found", {
+ term: html`‘${search}’`,
+ });
+
static get styles(): CSSResultGroup {
return css`
.add-target-wrapper {
@@ -683,31 +1101,8 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
margin-top: var(--ha-space-3);
}
- wa-popover {
- --wa-space-l: var(--ha-space-0);
- }
-
- wa-popover::part(body) {
- width: min(max(var(--body-width), 336px), 600px);
- max-width: min(max(var(--body-width), 336px), 600px);
- max-height: 500px;
- height: 70vh;
- overflow: hidden;
- }
-
- @media (max-height: 1000px) {
- wa-popover::part(body) {
- max-height: 400px;
- }
- }
-
- ha-bottom-sheet {
- --ha-bottom-sheet-height: 90vh;
- --ha-bottom-sheet-height: calc(100dvh - var(--ha-space-12));
- --ha-bottom-sheet-max-height: var(--ha-bottom-sheet-height);
- --ha-bottom-sheet-max-width: 600px;
- --ha-bottom-sheet-padding: var(--ha-space-0);
- --ha-bottom-sheet-surface-background: var(--card-background-color);
+ ha-generic-picker {
+ width: 100%;
}
${unsafeCSS(chipStyles)}
diff --git a/src/components/target-picker/ha-target-picker-selector.ts b/src/components/target-picker/ha-target-picker-selector.ts
deleted file mode 100644
index 94a3db5155..0000000000
--- a/src/components/target-picker/ha-target-picker-selector.ts
+++ /dev/null
@@ -1,1105 +0,0 @@
-import type { LitVirtualizer } from "@lit-labs/virtualizer";
-import { consume } from "@lit/context";
-import { 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 "../chips/ha-chip-set";
-import "../chips/ha-filter-chip";
-import type { HaDevicePickerDeviceFilterFunc } from "../device/ha-device-picker";
-import "../entity/state-badge";
-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 filterType?: 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()}
-
-
-
- `;
- }
-
- @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.filterType === filterType;
- return html`
-
-
- `;
- });
- }
-
- 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(
- (
- 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"],
- filterType?: TargetTypeFloorless
- ) => {
- const items: (
- | string
- | FloorComboBoxItem
- | EntityComboBoxItem
- | PickerComboBoxItem
- )[] = [];
-
- if (!filterType || filterType === "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 (!filterType && entities.length) {
- // show group title
- items.push(
- this.hass.localize("ui.components.target-picker.type.entities")
- );
- }
-
- items.push(...entities);
- }
-
- if (!filterType || filterType === "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 (!filterType && devices.length) {
- // show group title
- items.push(
- this.hass.localize("ui.components.target-picker.type.devices")
- );
- }
-
- items.push(...devices);
- }
-
- if (!filterType || filterType === "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 (!filterType && areasAndFloors.length) {
- // 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 (!filterType || filterType === "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 (!filterType && labels.length) {
- // 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) {
- ev.stopPropagation();
- this._resetSelectedItem();
- this._filterHeader = undefined;
- const type = ev.target.type as TargetTypeFloorless;
- if (!type) {
- return;
- }
- if (this.filterType === type) {
- this.filterType = undefined;
- } else {
- this.filterType = type;
- }
-
- // Reset scroll position when filter changes
- if (this._virtualizerElement) {
- this._virtualizerElement.scrollToIndex(0);
- }
-
- fireEvent(this, "filter-type-changed", this.filterType);
- }
-
- @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;
- flex-wrap: nowrap;
- gap: var(--ha-space-2);
- padding: var(--ha-space-3) var(--ha-space-3);
- overflow: auto;
- }
-
- :host([mode="dialog"]) .filter {
- padding: var(--ha-space-3) var(--ha-space-4);
- }
-
- .filter ha-filter-chip {
- flex-shrink: 0;
- --md-filter-chip-selected-container-color: var(
- --ha-color-fill-primary-normal-hover
- );
- color: var(--primary-color);
- }
-
- .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-type-changed": TargetTypeFloorless | undefined;
- "target-picked": {
- type: TargetType;
- id: string;
- };
- "create-domain-picked": string;
- }
-}
diff --git a/src/components/user/ha-user-picker.ts b/src/components/user/ha-user-picker.ts
index 2959a37d89..f990ca0c0b 100644
--- a/src/components/user/ha-user-picker.ts
+++ b/src/components/user/ha-user-picker.ts
@@ -128,9 +128,7 @@ class HaUserPicker extends LitElement {
.hass=${this.hass}
.autofocus=${this.autofocus}
.label=${this.label}
- .notFoundLabel=${this.hass.localize(
- "ui.components.user-picker.no_match"
- )}
+ .notFoundLabel=${this._notFoundLabel}
.placeholder=${placeholder}
.value=${this.value}
.getItems=${this._getItems}
@@ -149,6 +147,11 @@ class HaUserPicker extends LitElement {
fireEvent(this, "value-changed", { value });
fireEvent(this, "change");
}
+
+ private _notFoundLabel = (search: string) =>
+ this.hass.localize("ui.components.user-picker.no_match", {
+ term: html`‘${search}’`,
+ });
}
declare global {
diff --git a/src/data/device_registry.ts b/src/data/device_registry.ts
index 056bf9c3a9..0b5b3be251 100644
--- a/src/data/device_registry.ts
+++ b/src/data/device_registry.ts
@@ -186,7 +186,8 @@ export const getDevices = (
deviceFilter?: HaDevicePickerDeviceFilterFunc,
entityFilter?: HaEntityPickerEntityFilterFunc,
excludeDevices?: string[],
- value?: string
+ value?: string,
+ idPrefix = ""
): DevicePickerItem[] => {
const devices = Object.values(hass.devices);
const entities = Object.values(hass.entities);
@@ -298,7 +299,7 @@ export const getDevices = (
const domainName = domain ? domainToName(hass.localize, domain) : undefined;
return {
- id: device.id,
+ id: `${idPrefix}${device.id}`,
label: "",
primary:
deviceName ||
diff --git a/src/data/entity_registry.ts b/src/data/entity_registry.ts
index a4b3279a68..d6ef7678e1 100644
--- a/src/data/entity_registry.ts
+++ b/src/data/entity_registry.ts
@@ -344,7 +344,8 @@ export const getEntities = (
includeUnitOfMeasurement?: string[],
includeEntities?: string[],
excludeEntities?: string[],
- value?: string
+ value?: string,
+ idPrefix = ""
): EntityComboBoxItem[] => {
let items: EntityComboBoxItem[] = [];
@@ -395,10 +396,9 @@ export const getEntities = (
const secondary = [areaName, entityName ? deviceName : undefined]
.filter(Boolean)
.join(isRTL ? " ◂ " : " ▸ ");
- const a11yLabel = [deviceName, entityName].filter(Boolean).join(" - ");
return {
- id: entityId,
+ id: `${idPrefix}${entityId}`,
primary: primary,
secondary: secondary,
domain_name: domainName,
@@ -411,7 +411,6 @@ export const getEntities = (
friendlyName,
entityId,
].filter(Boolean) as string[],
- a11y_label: a11yLabel,
stateObj: stateObj,
};
});
diff --git a/src/data/label_registry.ts b/src/data/label_registry.ts
index 78157cef51..f30bbcbfef 100644
--- a/src/data/label_registry.ts
+++ b/src/data/label_registry.ts
@@ -108,7 +108,8 @@ export const getLabels = (
includeDeviceClasses?: string[],
deviceFilter?: HaDevicePickerDeviceFilterFunc,
entityFilter?: HaEntityPickerEntityFilterFunc,
- excludeLabels?: string[]
+ excludeLabels?: string[],
+ idPrefix = ""
): PickerComboBoxItem[] => {
if (!labels || labels.length === 0) {
return [];
@@ -262,7 +263,7 @@ export const getLabels = (
}
const items = outputLabels.map((label) => ({
- id: label.label_id,
+ id: `${idPrefix}${label.label_id}`,
primary: label.name,
secondary: label.description ?? "",
icon: label.icon || undefined,
diff --git a/src/panels/config/category/ha-category-picker.ts b/src/panels/config/category/ha-category-picker.ts
index b4d987b3cb..f5bb9d633b 100644
--- a/src/panels/config/category/ha-category-picker.ts
+++ b/src/panels/config/category/ha-category-picker.ts
@@ -1,4 +1,4 @@
-import { mdiTag, mdiPlus } from "@mdi/js";
+import { mdiPlus, mdiTag } from "@mdi/js";
import type { UnsubscribeFunc } from "home-assistant-js-websocket";
import type { TemplateResult } from "lit";
import { html, LitElement } from "lit";
@@ -194,8 +194,9 @@ export class HaCategoryPicker extends SubscribeMixin(LitElement) {
.hass=${this.hass}
.autofocus=${this.autofocus}
.label=${this.label}
- .notFoundLabel=${this.hass.localize(
- "ui.components.category-picker.no_match"
+ .notFoundLabel=${this._notFoundLabel}
+ .emptyLabel=${this.hass.localize(
+ "ui.components.category-picker.no_categories"
)}
.placeholder=${placeholder}
.value=${this.value}
@@ -254,6 +255,11 @@ export class HaCategoryPicker extends SubscribeMixin(LitElement) {
fireEvent(this, "change");
}, 0);
}
+
+ private _notFoundLabel = (search: string) =>
+ this.hass.localize("ui.components.category-picker.no_match", {
+ term: html`‘${search}’`,
+ });
}
declare global {
diff --git a/src/translations/en.json b/src/translations/en.json
index 90ca254795..c01b5770f2 100644
--- a/src/translations/en.json
+++ b/src/translations/en.json
@@ -652,7 +652,7 @@
"edit": "Edit",
"clear": "Clear",
"no_entities": "You don't have any entities",
- "no_match": "No matching entities found",
+ "no_match": "No entities found for {term}",
"show_entities": "Show entities",
"new_entity": "Create a new entity",
"placeholder": "Select an entity",
@@ -763,7 +763,7 @@
},
"language-picker": {
"language": "Language",
- "no_match": "No matching languages found",
+ "no_match": "No languages found for {term}",
"no_languages": "No languages available"
},
"tts-picker": {
@@ -775,7 +775,7 @@
"none": "None"
},
"user-picker": {
- "no_match": "No matching users found",
+ "no_match": "No users found for {term}",
"user": "User",
"add_user": "Add user"
},
@@ -786,8 +786,8 @@
"clear": "Clear",
"toggle": "Toggle",
"show_devices": "Show devices",
- "no_devices": "You don't have any devices",
- "no_match": "No matching devices found",
+ "no_devices": "No devices available",
+ "no_match": "No devices found for {term}",
"device": "Device",
"unnamed_device": "Unnamed device",
"no_area": "No area",
@@ -801,8 +801,8 @@
"add_category": "Add category",
"add_new_sugestion": "Add new category ''{name}''",
"add_new": "Add new category…",
- "no_categories": "You don't have any categories",
- "no_match": "No matching categories found",
+ "no_categories": "No categories available",
+ "no_match": "No categories found for {term}",
"add_dialog": {
"title": "Add new category",
"text": "Enter the name of the new category.",
@@ -817,8 +817,8 @@
"add_new_sugestion": "Add new label ''{name}''",
"add_new": "Add new label…",
"add": "Add label",
- "no_labels": "You don't have any labels",
- "no_match": "No matching labels found",
+ "no_labels": "No labels available",
+ "no_match": "No labels found for {term}",
"failed_create_label": "Failed to create label."
},
"area-picker": {
@@ -827,8 +827,8 @@
"area": "Area",
"add_new_sugestion": "Add new area ''{name}''",
"add_new": "Add new area…",
- "no_areas": "You don't have any areas",
- "no_match": "No matching areas found",
+ "no_areas": "No areas available",
+ "no_match": "No areas found for {term}",
"unassigned_areas": "Unassigned areas",
"failed_create_area": "Failed to create area."
},
@@ -838,8 +838,8 @@
"floor": "Floor",
"add_new_sugestion": "Add new floor ''{name}''",
"add_new": "Add new floor…",
- "no_floors": "You don't have any floors",
- "no_match": "No matching floors found",
+ "no_floors": "No floors available",
+ "no_match": "No floors found for {term}",
"failed_create_floor": "Failed to create floor."
},
"area-filter": {
@@ -853,8 +853,8 @@
"statistic-picker": {
"statistic": "Statistic",
"placeholder": "Select a statistic",
- "no_statistics": "You don't have any statistics",
- "no_match": "No matching statistics found",
+ "no_statistics": "No statistics available",
+ "no_match": "No statistics found for {term}",
"no_state": "Entity without state",
"missing_entity": "Why is my entity not listed?",
"learn_more": "Learn more about statistics"
@@ -1292,7 +1292,8 @@
"add": "Add interaction"
},
"combo-box": {
- "no_match": "No matching items found"
+ "no_match": "No matching items found",
+ "no_items": "No items available"
},
"suggest_with_ai": {
"label": "Suggest",