Files
frontend/src/components/target-picker/ha-target-picker-selector.ts
2025-10-16 15:37:04 +02:00

1109 lines
30 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;
}
}