mirror of
https://github.com/home-assistant/frontend.git
synced 2026-05-22 09:07:17 +00:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8580c99f0a | |||
| 3cf2d9d6dd | |||
| 081212eab1 |
@@ -1,9 +1,9 @@
|
||||
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
|
||||
import { mdiPlus, mdiShape } from "@mdi/js";
|
||||
import { html, LitElement, nothing, type PropertyValues } from "lit";
|
||||
import { css, html, LitElement, nothing, type PropertyValues } from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import { fireEvent } from "../../common/dom/fire_event";
|
||||
import { fireEvent, type HASSDomEvent } from "../../common/dom/fire_event";
|
||||
import { computeEntityPickerDisplay } from "../../common/entity/compute_entity_name_display";
|
||||
import { isValidEntityId } from "../../common/entity/valid_entity_id";
|
||||
import type { HaEntityPickerEntityFilterFunc } from "../../data/entity/entity";
|
||||
@@ -20,17 +20,20 @@ import {
|
||||
} from "../../panels/config/helpers/const";
|
||||
import { showHelperDetailDialog } from "../../panels/config/helpers/show-dialog-helper-detail";
|
||||
import type { HomeAssistant } from "../../types";
|
||||
import "../ha-button";
|
||||
import "../ha-combo-box-item";
|
||||
import "../ha-generic-picker";
|
||||
import "../ha-icon";
|
||||
import type { HaGenericPicker } from "../ha-generic-picker";
|
||||
import type { PickerComboBoxSearchFn } from "../ha-picker-combo-box";
|
||||
import "../ha-picker-field";
|
||||
import type { PickerValueRenderer } from "../ha-picker-field";
|
||||
import "../ha-picker-popover";
|
||||
import "../ha-picker-search-list";
|
||||
import type {
|
||||
HaPickerSearchList,
|
||||
PickerSearchFn,
|
||||
} from "../ha-picker-search-list";
|
||||
import "../ha-svg-icon";
|
||||
import "./state-badge";
|
||||
|
||||
const CREATE_ID = "___create-new-entity___";
|
||||
|
||||
@customElement("ha-entity-picker")
|
||||
export class HaEntityPicker extends LitElement {
|
||||
@property({ attribute: false }) public hass!: HomeAssistant;
|
||||
@@ -122,15 +125,17 @@ export class HaEntityPicker extends LitElement {
|
||||
@property({ attribute: "hide-clear-icon", type: Boolean })
|
||||
public hideClearIcon = false;
|
||||
|
||||
@property({ attribute: "add-button", type: Boolean })
|
||||
public addButton = false;
|
||||
@query(".trigger") private _trigger?: HTMLElement;
|
||||
|
||||
@property({ attribute: "add-button-label" }) public addButtonLabel?: string;
|
||||
@query("ha-picker-search-list") private _searchList?: HaPickerSearchList;
|
||||
|
||||
@query("ha-generic-picker") private _picker?: HaGenericPicker;
|
||||
@state() private _pickerOpen = false;
|
||||
|
||||
@state() private _pendingEntityId?: string;
|
||||
|
||||
// Commit fires on @closed (after the hide animation) to avoid flicker.
|
||||
private _pendingValue?: string;
|
||||
|
||||
protected willUpdate(changedProperties: PropertyValues<this>) {
|
||||
if (
|
||||
this._pendingEntityId &&
|
||||
@@ -145,7 +150,7 @@ export class HaEntityPicker extends LitElement {
|
||||
|
||||
protected firstUpdated(changedProperties: PropertyValues<this>): void {
|
||||
super.firstUpdated(changedProperties);
|
||||
// Load title translations so it is available when the combo-box opens
|
||||
// Preload title translations so they're ready when the dropdown opens.
|
||||
this.hass.loadBackendTranslation("title");
|
||||
}
|
||||
|
||||
@@ -275,40 +280,48 @@ export class HaEntityPicker extends LitElement {
|
||||
`;
|
||||
};
|
||||
|
||||
private _getAdditionalItems = () =>
|
||||
this._getCreateItems(this.hass.localize, this.createDomains);
|
||||
|
||||
private _getCreateItems = memoizeOne(
|
||||
private _getCreateActions = memoizeOne(
|
||||
(
|
||||
localize: this["hass"]["localize"],
|
||||
createDomains: this["createDomains"]
|
||||
) => {
|
||||
): EntityComboBoxItem[] => {
|
||||
if (!createDomains?.length) {
|
||||
return [];
|
||||
}
|
||||
this.hass.loadFragmentTranslation("config");
|
||||
return createDomains.map((domain) => {
|
||||
const primary = localize(
|
||||
"ui.components.entity.entity-picker.create_helper",
|
||||
{
|
||||
domain: isHelperDomain(domain)
|
||||
? localize(
|
||||
`ui.panel.config.helpers.types.${domain as HelperDomain}`
|
||||
) || domain
|
||||
: domainToName(localize, domain),
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
id: CREATE_ID + domain,
|
||||
primary: primary,
|
||||
secondary: localize("ui.components.entity.entity-picker.new_entity"),
|
||||
icon_path: mdiPlus,
|
||||
} satisfies EntityComboBoxItem;
|
||||
});
|
||||
return createDomains.map((domain) => ({
|
||||
id: `__create-helper__${domain}`,
|
||||
primary: localize("ui.components.entity.entity-picker.create_helper", {
|
||||
domain: isHelperDomain(domain)
|
||||
? localize(
|
||||
`ui.panel.config.helpers.types.${domain as HelperDomain}`
|
||||
) || domain
|
||||
: domainToName(localize, domain),
|
||||
}),
|
||||
secondary: localize("ui.components.entity.entity-picker.new_entity"),
|
||||
icon_path: mdiPlus,
|
||||
onSelect: ({ close }) => {
|
||||
close();
|
||||
this._openCreateHelper(domain);
|
||||
},
|
||||
}));
|
||||
}
|
||||
);
|
||||
|
||||
private _openCreateHelper(domain: string) {
|
||||
showHelperDetailDialog(this, {
|
||||
domain,
|
||||
dialogClosedCallback: (item) => {
|
||||
if (!item.entityId) return;
|
||||
if (this.hass.states[item.entityId]) {
|
||||
this._setValue(item.entityId);
|
||||
} else {
|
||||
this._pendingEntityId = item.entityId;
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private _getEntitiesMemoized = memoizeOne(getEntities);
|
||||
|
||||
private _getItems = () => {
|
||||
@@ -341,53 +354,67 @@ export class HaEntityPicker extends LitElement {
|
||||
const placeholder =
|
||||
this.placeholder ??
|
||||
this.hass.localize("ui.components.entity.entity-picker.placeholder");
|
||||
const items = this._getItems();
|
||||
const actions = this._getCreateActions(
|
||||
this.hass.localize,
|
||||
this.createDomains
|
||||
);
|
||||
const hideClearIcon = this.hideClearIcon || this._shouldHideClearIcon();
|
||||
|
||||
return html`
|
||||
<ha-generic-picker
|
||||
.hass=${this.hass}
|
||||
.disabled=${this.disabled}
|
||||
.autofocus=${this.autofocus}
|
||||
.allowCustomValue=${this.allowCustomEntity}
|
||||
.required=${this.required}
|
||||
.label=${this.label}
|
||||
.placeholder=${placeholder}
|
||||
.helper=${this.helper}
|
||||
.value=${this.addButton ? undefined : this.value}
|
||||
.searchLabel=${this.searchLabel}
|
||||
.notFoundLabel=${this._notFoundLabel}
|
||||
.rowRenderer=${this._rowRenderer}
|
||||
.getItems=${this._getItems}
|
||||
.getAdditionalItems=${this._getAdditionalItems}
|
||||
.hideClearIcon=${this.hideClearIcon || this._shouldHideClearIcon()}
|
||||
.searchFn=${this._searchFn}
|
||||
.valueRenderer=${this._valueRenderer}
|
||||
.searchKeys=${entityComboBoxKeys}
|
||||
use-top-label
|
||||
.addButtonLabel=${this.addButton
|
||||
? (this.addButtonLabel ??
|
||||
this.hass.localize("ui.components.entity.entity-picker.add"))
|
||||
: undefined}
|
||||
.unknownItemText=${this.hass.localize(
|
||||
"ui.components.entity.entity-picker.unknown"
|
||||
)}
|
||||
@value-changed=${this._valueChanged}
|
||||
>
|
||||
</ha-generic-picker>
|
||||
<div class="picker">
|
||||
<div class="trigger" @click=${this._openPicker}>
|
||||
<slot name="trigger">
|
||||
<ha-picker-field
|
||||
type="button"
|
||||
compact
|
||||
.label=${this.label}
|
||||
.placeholder=${placeholder}
|
||||
.value=${this.value}
|
||||
.valueRenderer=${this._valueRenderer}
|
||||
.required=${this.required}
|
||||
.disabled=${this.disabled}
|
||||
.helper=${this.helper}
|
||||
.hideClearIcon=${hideClearIcon}
|
||||
?autofocus=${this.autofocus}
|
||||
@clear=${this._clear}
|
||||
></ha-picker-field>
|
||||
</slot>
|
||||
</div>
|
||||
<ha-picker-popover
|
||||
.open=${this._pickerOpen}
|
||||
.anchor=${this._trigger ?? null}
|
||||
.label=${this.label ?? ""}
|
||||
@closed=${this._handlePickerClosed}
|
||||
>
|
||||
<ha-picker-search-list
|
||||
autofocus
|
||||
.items=${items}
|
||||
.value=${this.value}
|
||||
.searchKeys=${entityComboBoxKeys}
|
||||
.searchFn=${this._searchFn}
|
||||
.rowRenderer=${this._rowRenderer}
|
||||
.actions=${actions}
|
||||
.searchPlaceholder=${this.searchLabel ??
|
||||
this.hass.localize("ui.common.search")}
|
||||
.notFoundLabel=${this._notFoundLabel}
|
||||
@item-selected=${this._handleItemSelected}
|
||||
></ha-picker-search-list>
|
||||
</ha-picker-popover>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _searchFn: PickerComboBoxSearchFn<EntityComboBoxItem> = (
|
||||
private _searchFn: PickerSearchFn<EntityComboBoxItem> = (
|
||||
search,
|
||||
filteredItems
|
||||
) => {
|
||||
// If there is exact match for entity id, put it first
|
||||
const index = filteredItems.findIndex(
|
||||
(item) => item.stateObj?.entity_id === search
|
||||
);
|
||||
if (index === -1) {
|
||||
return filteredItems;
|
||||
}
|
||||
|
||||
const [exactMatch] = filteredItems.splice(index, 1);
|
||||
filteredItems.unshift(exactMatch);
|
||||
return filteredItems;
|
||||
@@ -395,46 +422,43 @@ export class HaEntityPicker extends LitElement {
|
||||
|
||||
public async open() {
|
||||
await this.updateComplete;
|
||||
await this._picker?.open();
|
||||
this._openPicker();
|
||||
}
|
||||
|
||||
private _valueChanged(ev) {
|
||||
private _openPicker = () => {
|
||||
if (this.disabled) return;
|
||||
this._pickerOpen = true;
|
||||
};
|
||||
|
||||
private _handlePickerClosed = () => {
|
||||
if (this._pendingValue !== undefined) {
|
||||
const pending = this._pendingValue;
|
||||
this._pendingValue = undefined;
|
||||
this._setValue(pending);
|
||||
}
|
||||
this._pickerOpen = false;
|
||||
this._searchList?.reset();
|
||||
};
|
||||
|
||||
private _handleItemSelected = (
|
||||
ev: HASSDomEvent<{ id: string; index: number; newTab?: boolean }>
|
||||
) => {
|
||||
ev.stopPropagation();
|
||||
const value = ev.detail.value;
|
||||
|
||||
if (!value) {
|
||||
this._setValue(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
if (value.startsWith(CREATE_ID)) {
|
||||
const domain = value.substring(CREATE_ID.length);
|
||||
|
||||
showHelperDetailDialog(this, {
|
||||
domain,
|
||||
dialogClosedCallback: (item) => {
|
||||
if (item.entityId) {
|
||||
if (this.hass.states[item.entityId]) {
|
||||
this._setValue(item.entityId);
|
||||
} else {
|
||||
this._pendingEntityId = item.entityId;
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const value = ev.detail.id;
|
||||
if (!isValidEntityId(value) && !this._findExtraOption(value)) {
|
||||
this._pickerOpen = false;
|
||||
return;
|
||||
}
|
||||
this._pendingValue = value;
|
||||
this._pickerOpen = false;
|
||||
};
|
||||
|
||||
this._setValue(value);
|
||||
private _clear() {
|
||||
this._setValue(undefined);
|
||||
}
|
||||
|
||||
private _setValue(value: string | undefined) {
|
||||
this.value = value;
|
||||
|
||||
fireEvent(this, "value-changed", { value });
|
||||
fireEvent(this, "change");
|
||||
}
|
||||
@@ -443,6 +467,18 @@ export class HaEntityPicker extends LitElement {
|
||||
this.hass.localize("ui.components.entity.entity-picker.no_match", {
|
||||
term: html`<b>‘${search}’</b>`,
|
||||
});
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
.picker {
|
||||
position: relative;
|
||||
}
|
||||
ha-picker-field {
|
||||
width: 100%;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
@@ -0,0 +1,394 @@
|
||||
import type { LitVirtualizer } from "@lit-labs/virtualizer";
|
||||
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
|
||||
import { mdiMagnify, mdiMinusBoxOutline } from "@mdi/js";
|
||||
import {
|
||||
css,
|
||||
html,
|
||||
LitElement,
|
||||
type CSSResultGroup,
|
||||
type TemplateResult,
|
||||
} from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import { tinykeys } from "tinykeys";
|
||||
import {
|
||||
fireEvent,
|
||||
type HASSDomCurrentTargetEvent,
|
||||
} from "../common/dom/fire_event";
|
||||
import { loadVirtualizer } from "../resources/virtualizer";
|
||||
import "./ha-combo-box-item";
|
||||
import {
|
||||
DEFAULT_ROW_RENDERER_CONTENT,
|
||||
type PickerComboBoxItem,
|
||||
} from "./ha-picker-combo-box";
|
||||
import "./ha-svg-icon";
|
||||
|
||||
const EMPTY_ROW_ID = "___empty___";
|
||||
|
||||
export interface PickerActionContext {
|
||||
host: HTMLElement;
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
/** Items with `onSelect` are action rows: the callback fires instead of `item-selected`. */
|
||||
export interface PickerListItem extends PickerComboBoxItem {
|
||||
onSelect?: (ctx: PickerActionContext) => void | Promise<void>;
|
||||
}
|
||||
|
||||
export type PickerListEntry = PickerListItem | string;
|
||||
|
||||
interface PickerListRowElement extends HTMLDivElement {
|
||||
index: number;
|
||||
item: PickerListItem;
|
||||
}
|
||||
|
||||
const DEFAULT_ROW: RenderItemFunction<PickerListItem> = (item) =>
|
||||
html`<ha-combo-box-item type="button" compact>
|
||||
${DEFAULT_ROW_RENDERER_CONTENT(item)}
|
||||
</ha-combo-box-item>`;
|
||||
|
||||
/**
|
||||
* Headless virtualized list for picker UIs. Receives pre-filtered `items`,
|
||||
* renders rows via `rowRenderer`. String entries are section titles.
|
||||
*/
|
||||
@customElement("ha-picker-list")
|
||||
export class HaPickerList extends LitElement {
|
||||
@property({ attribute: false }) public items: PickerListEntry[] = [];
|
||||
|
||||
@property() public value?: string;
|
||||
|
||||
@property({ attribute: false })
|
||||
public rowRenderer?: RenderItemFunction<PickerListItem>;
|
||||
|
||||
@property({ attribute: "empty-label" }) public emptyLabel?: string;
|
||||
|
||||
@property({ attribute: false })
|
||||
public notFoundLabel?:
|
||||
| string
|
||||
| TemplateResult
|
||||
| ((search: string) => string | TemplateResult);
|
||||
|
||||
/** Current search string. Picks between empty/notFound placeholders; filtering is the consumer's job. */
|
||||
@property({ attribute: "current-search" }) public currentSearch = "";
|
||||
|
||||
@state() private _highlightedIndex = -1;
|
||||
|
||||
@state() private _valuePinned = true;
|
||||
|
||||
@query("lit-virtualizer") private _virtualizer?: LitVirtualizer;
|
||||
|
||||
private _unsubscribeKeys?: () => void;
|
||||
|
||||
public willUpdate() {
|
||||
if (!this.hasUpdated) {
|
||||
loadVirtualizer();
|
||||
}
|
||||
}
|
||||
|
||||
protected firstUpdated() {
|
||||
this._registerKeys();
|
||||
}
|
||||
|
||||
public disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
this._unsubscribeKeys?.();
|
||||
}
|
||||
|
||||
public selectNext = (ev?: KeyboardEvent) => this._next(ev);
|
||||
|
||||
public selectPrev = (ev?: KeyboardEvent) => this._prev(ev);
|
||||
|
||||
public selectFirst = (ev?: KeyboardEvent) => this._first(ev);
|
||||
|
||||
public selectLast = (ev?: KeyboardEvent) => this._last(ev);
|
||||
|
||||
public commitHighlighted = (newTab = false) =>
|
||||
this._commitAt(this._highlightedIndex, newTab);
|
||||
|
||||
protected render() {
|
||||
const items = this.items.length
|
||||
? this.items
|
||||
: [{ id: EMPTY_ROW_ID, primary: "" } as PickerListItem];
|
||||
return html`
|
||||
<lit-virtualizer
|
||||
.keyFunction=${this._keyFunction}
|
||||
tabindex="0"
|
||||
scroller
|
||||
.items=${items}
|
||||
.renderItem=${this._renderEntry}
|
||||
.layout=${this.value && this._valuePinned
|
||||
? {
|
||||
pin: {
|
||||
index: this._initialPinIndex(),
|
||||
block: "center",
|
||||
},
|
||||
}
|
||||
: undefined}
|
||||
@unpinned=${this._handleUnpinned}
|
||||
@focus=${this._focusList}
|
||||
@blur=${this._resetHighlight}
|
||||
></lit-virtualizer>
|
||||
`;
|
||||
}
|
||||
|
||||
private _keyFunction = (item: PickerListEntry) =>
|
||||
typeof item === "string" ? item : item.id;
|
||||
|
||||
private _renderEntry: RenderItemFunction<PickerListEntry> = (item, index) => {
|
||||
if (typeof item === "string") {
|
||||
return html`<div class="title">${item}</div>`;
|
||||
}
|
||||
if (item.id === EMPTY_ROW_ID) {
|
||||
return this._renderEmptyRow();
|
||||
}
|
||||
const renderer = this.rowRenderer ?? DEFAULT_ROW;
|
||||
return html`<div
|
||||
id=${`list-item-${index}`}
|
||||
class="row ${this.value === item.id ? "current-value" : ""}"
|
||||
.item=${item}
|
||||
.index=${index}
|
||||
@click=${this._handleClick}
|
||||
>
|
||||
${renderer(item as PickerListItem, index)}
|
||||
</div>`;
|
||||
};
|
||||
|
||||
private _renderEmptyRow() {
|
||||
const search = this.currentSearch;
|
||||
const message = search
|
||||
? typeof this.notFoundLabel === "function"
|
||||
? this.notFoundLabel(search)
|
||||
: (this.notFoundLabel ?? "No matching items found")
|
||||
: (this.emptyLabel ?? "No items available");
|
||||
return html`
|
||||
<div class="row empty">
|
||||
<ha-combo-box-item type="text" compact>
|
||||
<ha-svg-icon
|
||||
slot="start"
|
||||
.path=${search ? mdiMagnify : mdiMinusBoxOutline}
|
||||
></ha-svg-icon>
|
||||
<span slot="headline">${message}</span>
|
||||
</ha-combo-box-item>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _handleClick = (
|
||||
ev: MouseEvent & HASSDomCurrentTargetEvent<PickerListRowElement>
|
||||
) => {
|
||||
ev.stopPropagation();
|
||||
const row = ev.currentTarget;
|
||||
if (row.item.disabled) return;
|
||||
this._dispatchSelection(row.item, row.index, ev.ctrlKey || ev.metaKey);
|
||||
};
|
||||
|
||||
private _dispatchSelection(
|
||||
item: PickerListItem,
|
||||
index: number,
|
||||
newTab: boolean
|
||||
) {
|
||||
if (item.onSelect) {
|
||||
void item.onSelect({
|
||||
host: this,
|
||||
close: () => fireEvent(this, "picker-close-request"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
fireEvent(this, "item-selected", { id: item.id, index, newTab });
|
||||
}
|
||||
|
||||
private _handleUnpinned = () => {
|
||||
this._valuePinned = false;
|
||||
};
|
||||
|
||||
private _registerKeys() {
|
||||
this._unsubscribeKeys = tinykeys(this, {
|
||||
ArrowDown: this._next,
|
||||
ArrowUp: this._prev,
|
||||
Home: this._first,
|
||||
End: this._last,
|
||||
Enter: this._commitHighlight,
|
||||
"$mod+Enter": this._commitHighlightNewTab,
|
||||
});
|
||||
}
|
||||
|
||||
private _focusList = () => {
|
||||
if (this._highlightedIndex === -1) this._initializeHighlight();
|
||||
};
|
||||
|
||||
private _resetHighlight = () => {
|
||||
this._virtualizer?.querySelector(".selected")?.classList.remove("selected");
|
||||
this._highlightedIndex = -1;
|
||||
};
|
||||
|
||||
private _initializeHighlight() {
|
||||
if (!this._virtualizer) return;
|
||||
const items = this._virtualizer.items as PickerListEntry[];
|
||||
if (this.value) {
|
||||
const i = items.findIndex(
|
||||
(item) => typeof item !== "string" && item.id === this.value
|
||||
);
|
||||
if (i !== -1) {
|
||||
this._highlightedIndex = i;
|
||||
this._scrollToHighlight();
|
||||
return;
|
||||
}
|
||||
}
|
||||
this._first();
|
||||
}
|
||||
|
||||
private _initialPinIndex(): number {
|
||||
if (!this.value) return 0;
|
||||
const i = this.items.findIndex(
|
||||
(item) => typeof item !== "string" && item.id === this.value
|
||||
);
|
||||
return i === -1 ? 0 : i;
|
||||
}
|
||||
|
||||
private _isPickable(item: PickerListEntry | undefined): boolean {
|
||||
return !!item && typeof item !== "string" && item.id !== EMPTY_ROW_ID;
|
||||
}
|
||||
|
||||
private _step(direction: 1 | -1) {
|
||||
if (!this._virtualizer) return;
|
||||
const items = this._virtualizer.items as PickerListEntry[];
|
||||
if (!items.length) return;
|
||||
let i = this._highlightedIndex + direction;
|
||||
const guard = items.length;
|
||||
let n = 0;
|
||||
while (n++ < guard && i >= 0 && i < items.length) {
|
||||
if (this._isPickable(items[i])) {
|
||||
this._highlightedIndex = i;
|
||||
this._scrollToHighlight();
|
||||
return;
|
||||
}
|
||||
i += direction;
|
||||
}
|
||||
}
|
||||
|
||||
private _next = (ev?: KeyboardEvent) => {
|
||||
ev?.preventDefault();
|
||||
if (this._highlightedIndex === -1) {
|
||||
this._initializeHighlight();
|
||||
return;
|
||||
}
|
||||
this._step(1);
|
||||
};
|
||||
|
||||
private _prev = (ev?: KeyboardEvent) => {
|
||||
ev?.preventDefault();
|
||||
if (this._highlightedIndex === -1) {
|
||||
this._initializeHighlight();
|
||||
return;
|
||||
}
|
||||
this._step(-1);
|
||||
};
|
||||
|
||||
private _first = (ev?: KeyboardEvent) => {
|
||||
ev?.preventDefault();
|
||||
this._jumpTo(0, 1);
|
||||
};
|
||||
|
||||
private _last = (ev?: KeyboardEvent) => {
|
||||
ev?.preventDefault();
|
||||
if (!this._virtualizer) return;
|
||||
this._jumpTo(this._virtualizer.items.length - 1, -1);
|
||||
};
|
||||
|
||||
private _jumpTo(start: number, direction: 1 | -1) {
|
||||
if (!this._virtualizer) return;
|
||||
const items = this._virtualizer.items as PickerListEntry[];
|
||||
for (let i = start; i >= 0 && i < items.length; i += direction) {
|
||||
if (this._isPickable(items[i])) {
|
||||
this._highlightedIndex = i;
|
||||
this._scrollToHighlight();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private _commitHighlight = (ev: KeyboardEvent) => {
|
||||
this._commitAt(this._highlightedIndex, ev.ctrlKey || ev.metaKey);
|
||||
};
|
||||
|
||||
private _commitHighlightNewTab = () => {
|
||||
this._commitAt(this._highlightedIndex, true);
|
||||
};
|
||||
|
||||
private _commitAt(index: number, newTab: boolean) {
|
||||
if (index === -1 || !this._virtualizer) return;
|
||||
const item = this._virtualizer.items[index] as PickerListEntry;
|
||||
if (typeof item === "string" || item.disabled) return;
|
||||
this._dispatchSelection(item, index, newTab);
|
||||
}
|
||||
|
||||
private _scrollToHighlight() {
|
||||
this._virtualizer?.querySelector(".selected")?.classList.remove("selected");
|
||||
this._virtualizer
|
||||
?.element(this._highlightedIndex)
|
||||
?.scrollIntoView({ block: "nearest" });
|
||||
requestAnimationFrame(() => {
|
||||
this._virtualizer
|
||||
?.querySelector(`#list-item-${this._highlightedIndex}`)
|
||||
?.classList.add("selected");
|
||||
});
|
||||
}
|
||||
|
||||
static styles: CSSResultGroup = css`
|
||||
:host {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
lit-virtualizer {
|
||||
flex: 1;
|
||||
outline: none;
|
||||
}
|
||||
.row {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
box-sizing: border-box;
|
||||
min-height: 36px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.row.empty {
|
||||
cursor: default;
|
||||
}
|
||||
.row ha-combo-box-item {
|
||||
width: 100%;
|
||||
}
|
||||
.row.current-value {
|
||||
background-color: var(--ha-color-fill-primary-quiet-resting);
|
||||
}
|
||||
.row.selected {
|
||||
background-color: var(--ha-color-fill-neutral-quiet-hover);
|
||||
}
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.row.selected {
|
||||
background-color: var(--ha-color-fill-neutral-normal-hover);
|
||||
}
|
||||
}
|
||||
.title {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
background-color: var(--ha-color-fill-neutral-quiet-resting);
|
||||
padding: var(--ha-space-1) var(--ha-space-4);
|
||||
font-weight: var(--ha-font-weight-bold);
|
||||
color: var(--secondary-text-color);
|
||||
min-height: var(--ha-space-6);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-picker-list": HaPickerList;
|
||||
}
|
||||
interface HASSDomEvents {
|
||||
"item-selected": { id: string; index: number; newTab?: boolean };
|
||||
"picker-close-request": undefined;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
import "@home-assistant/webawesome/dist/components/popover/popover";
|
||||
import {
|
||||
css,
|
||||
html,
|
||||
LitElement,
|
||||
nothing,
|
||||
type CSSResultGroup,
|
||||
type PropertyValues,
|
||||
} from "lit";
|
||||
import { customElement, property, state } from "lit/decorators";
|
||||
import { styleMap } from "lit/directives/style-map";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import "./ha-bottom-sheet";
|
||||
|
||||
/**
|
||||
* Responsive popover for picker UIs: anchored `wa-popover` on desktop,
|
||||
* `ha-bottom-sheet` on narrow viewports. Anchor drives the width.
|
||||
*/
|
||||
@customElement("ha-picker-popover")
|
||||
export class HaPickerPopover extends LitElement {
|
||||
@property({ type: Boolean, reflect: true }) public open = false;
|
||||
|
||||
@property({ attribute: false }) public anchor?: HTMLElement | null;
|
||||
|
||||
@property() public label?: string;
|
||||
|
||||
@property() public placement:
|
||||
| "bottom"
|
||||
| "top"
|
||||
| "left"
|
||||
| "right"
|
||||
| "top-start"
|
||||
| "top-end"
|
||||
| "right-start"
|
||||
| "right-end"
|
||||
| "bottom-start"
|
||||
| "bottom-end"
|
||||
| "left-start"
|
||||
| "left-end" = "bottom-start";
|
||||
|
||||
@state() private _bodyWidth = 0;
|
||||
|
||||
@state() private _narrow = false;
|
||||
|
||||
@state() private _openedNarrow = false;
|
||||
|
||||
// Kept true across the hide animation so wa-popover can finish its
|
||||
// transition; cleared on wa-after-hide.
|
||||
@state() private _mounted = false;
|
||||
|
||||
// Flipped one rAF after mount so wa-popover sees a false→true edge
|
||||
// and runs the show flow.
|
||||
@state() private _showing = false;
|
||||
|
||||
// Defers slot projection until after the show animation so a
|
||||
// virtualized list inside isn't measured at the scaled size.
|
||||
@state() private _contentReady = false;
|
||||
|
||||
private _openFrame?: number;
|
||||
|
||||
protected willUpdate(changedProperties: PropertyValues<this>) {
|
||||
if (changedProperties.has("open")) {
|
||||
if (this.open) {
|
||||
this._measureAnchor();
|
||||
this._openedNarrow = this._narrow;
|
||||
this._mounted = true;
|
||||
} else {
|
||||
this._showing = false;
|
||||
}
|
||||
}
|
||||
if (changedProperties.has("anchor") && this.open) {
|
||||
this._measureAnchor();
|
||||
}
|
||||
}
|
||||
|
||||
private _measureAnchor() {
|
||||
if (this.anchor) {
|
||||
this._bodyWidth = this.anchor.offsetWidth;
|
||||
}
|
||||
}
|
||||
|
||||
protected updated() {
|
||||
if (this.open && this._mounted && !this._showing) {
|
||||
this._scheduleShow();
|
||||
}
|
||||
}
|
||||
|
||||
private _scheduleShow() {
|
||||
if (this._openFrame !== undefined) return;
|
||||
this._openFrame = requestAnimationFrame(() => {
|
||||
this._openFrame = undefined;
|
||||
if (this.open && this._mounted) {
|
||||
this._showing = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private _cancelShow() {
|
||||
if (this._openFrame === undefined) return;
|
||||
cancelAnimationFrame(this._openFrame);
|
||||
this._openFrame = undefined;
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this._handleResize();
|
||||
window.addEventListener("resize", this._handleResize);
|
||||
this.addEventListener("picker-close-request", this._handleCloseRequest);
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
window.removeEventListener("resize", this._handleResize);
|
||||
this.removeEventListener("picker-close-request", this._handleCloseRequest);
|
||||
this._cancelShow();
|
||||
}
|
||||
|
||||
private _handleCloseRequest = (ev: Event) => {
|
||||
ev.stopPropagation();
|
||||
this._showing = false;
|
||||
};
|
||||
|
||||
private _handleResize = () => {
|
||||
this._narrow =
|
||||
window.matchMedia("(max-width: 870px)").matches ||
|
||||
window.matchMedia("(max-height: 500px)").matches;
|
||||
|
||||
if (!this._openedNarrow && this.open) {
|
||||
this._measureAnchor();
|
||||
}
|
||||
};
|
||||
|
||||
private _handleShown = () => {
|
||||
this._contentReady = true;
|
||||
fireEvent(this, "opened");
|
||||
// Native [autofocus] fires before the popover is visible; refocus
|
||||
// a slotted search component after projection.
|
||||
requestAnimationFrame(() => {
|
||||
const focusable = this.querySelector(
|
||||
"ha-picker-search-list, ha-picker-search"
|
||||
) as (HTMLElement & { focus?: () => void }) | null;
|
||||
focusable?.focus?.();
|
||||
});
|
||||
};
|
||||
|
||||
private _handleHidden = (ev: Event) => {
|
||||
ev.stopPropagation();
|
||||
this._mounted = false;
|
||||
this._showing = false;
|
||||
this._contentReady = false;
|
||||
fireEvent(this, "closed");
|
||||
};
|
||||
|
||||
protected render() {
|
||||
if (!this._mounted) return nothing;
|
||||
|
||||
if (this._openedNarrow) {
|
||||
return html`
|
||||
<ha-bottom-sheet
|
||||
flexcontent
|
||||
.open=${this._showing}
|
||||
@wa-after-show=${this._handleShown}
|
||||
@closed=${this._handleHidden}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label=${this.label ?? ""}
|
||||
>
|
||||
<div class="content">
|
||||
${this._contentReady ? html`<slot></slot>` : nothing}
|
||||
</div>
|
||||
</ha-bottom-sheet>
|
||||
`;
|
||||
}
|
||||
|
||||
return html`
|
||||
<wa-popover
|
||||
.open=${this._showing}
|
||||
style=${styleMap({ "--body-width": `${this._bodyWidth}px` })}
|
||||
without-arrow
|
||||
distance="-4"
|
||||
.placement=${this.placement}
|
||||
.anchor=${this.anchor ?? null}
|
||||
auto-size="vertical"
|
||||
auto-size-padding="16"
|
||||
@wa-after-show=${this._handleShown}
|
||||
@wa-after-hide=${this._handleHidden}
|
||||
trap-focus
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label=${this.label ?? ""}
|
||||
>
|
||||
<div class="content">
|
||||
${this._contentReady ? html`<slot></slot>` : nothing}
|
||||
</div>
|
||||
</wa-popover>
|
||||
`;
|
||||
}
|
||||
|
||||
static styles: CSSResultGroup = css`
|
||||
:host {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
wa-popover {
|
||||
--wa-space-l: 0;
|
||||
/* Disable wa-popover's built-in 25rem cap. */
|
||||
--max-width: none;
|
||||
}
|
||||
|
||||
wa-popover::part(dialog)::backdrop {
|
||||
background: none;
|
||||
}
|
||||
|
||||
wa-popover::part(body) {
|
||||
width: var(--ha-picker-popover-width, max(var(--body-width), 250px));
|
||||
max-width: var(
|
||||
--ha-picker-popover-max-width,
|
||||
var(--ha-picker-popover-width, max(var(--body-width), 250px))
|
||||
);
|
||||
max-height: 500px;
|
||||
height: 70vh;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
@media (max-height: 1000px) {
|
||||
wa-popover::part(body) {
|
||||
max-height: 400px;
|
||||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
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: 0;
|
||||
--ha-bottom-sheet-surface-background: var(--card-background-color);
|
||||
--ha-bottom-sheet-border-radius: var(--ha-border-radius-2xl);
|
||||
--ha-bottom-sheet-content-padding: 0 var(--safe-area-inset-right)
|
||||
var(--safe-area-inset-bottom) var(--safe-area-inset-left);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-picker-popover": HaPickerPopover;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize";
|
||||
import Fuse from "fuse.js";
|
||||
import {
|
||||
css,
|
||||
html,
|
||||
LitElement,
|
||||
type CSSResultGroup,
|
||||
type TemplateResult,
|
||||
} from "lit";
|
||||
import { customElement, property, query, state } from "lit/decorators";
|
||||
import memoizeOne from "memoize-one";
|
||||
import type { HASSDomEvent } from "../common/dom/fire_event";
|
||||
import {
|
||||
multiTermSortedSearch,
|
||||
type FuseWeightedKey,
|
||||
} from "../resources/fuseMultiTerm";
|
||||
import { DEFAULT_SEARCH_KEYS } from "./ha-picker-combo-box";
|
||||
import type { HaPickerList, PickerListItem } from "./ha-picker-list";
|
||||
import "./ha-picker-list";
|
||||
import "./ha-picker-search";
|
||||
import type { HaPickerSearch } from "./ha-picker-search";
|
||||
|
||||
export type PickerSearchFn<T extends PickerListItem = PickerListItem> = (
|
||||
search: string,
|
||||
filtered: T[],
|
||||
all: T[]
|
||||
) => T[];
|
||||
|
||||
/**
|
||||
* Search input + virtualized list with built-in Fuse.js filtering.
|
||||
* For custom filtering pipelines, compose `ha-picker-search` and
|
||||
* `ha-picker-list` directly instead.
|
||||
*/
|
||||
@customElement("ha-picker-search-list")
|
||||
export class HaPickerSearchList<
|
||||
T extends PickerListItem = PickerListItem,
|
||||
> extends LitElement {
|
||||
@property({ attribute: false }) public items: T[] = [];
|
||||
|
||||
@property() public value?: string;
|
||||
|
||||
@property({ attribute: false }) public searchKeys?: FuseWeightedKey[];
|
||||
|
||||
@property({ attribute: false }) public searchFn?: PickerSearchFn<T>;
|
||||
|
||||
@property({ attribute: false })
|
||||
public rowRenderer?: RenderItemFunction<T>;
|
||||
|
||||
@property({ attribute: false }) public actions?: PickerListItem[];
|
||||
|
||||
@property({ attribute: "search-placeholder" })
|
||||
public searchPlaceholder?: string;
|
||||
|
||||
@property({ attribute: "empty-label" }) public emptyLabel?: string;
|
||||
|
||||
@property({ attribute: false })
|
||||
public notFoundLabel?:
|
||||
| string
|
||||
| TemplateResult
|
||||
| ((search: string) => string | TemplateResult);
|
||||
|
||||
// eslint-disable-next-line lit/no-native-attributes
|
||||
@property({ type: Boolean }) public autofocus = false;
|
||||
|
||||
@state() private _search = "";
|
||||
|
||||
@query("ha-picker-search") private _searchEl?: HaPickerSearch;
|
||||
|
||||
@query("ha-picker-list") private _listEl?: HaPickerList;
|
||||
|
||||
public focus() {
|
||||
this._searchEl?.focus();
|
||||
}
|
||||
|
||||
public reset() {
|
||||
this._search = "";
|
||||
}
|
||||
|
||||
protected render() {
|
||||
const displayItems = this._computeDisplayItems(
|
||||
this.items,
|
||||
this._search,
|
||||
this.searchKeys,
|
||||
this.searchFn,
|
||||
this.actions
|
||||
);
|
||||
return html`
|
||||
<ha-picker-search
|
||||
?autofocus=${this.autofocus}
|
||||
.value=${this._search}
|
||||
.placeholder=${this.searchPlaceholder ?? ""}
|
||||
@search-changed=${this._handleSearchChanged}
|
||||
@keydown=${this._handleSearchKeydown}
|
||||
></ha-picker-search>
|
||||
<ha-picker-list
|
||||
.items=${displayItems}
|
||||
.value=${this.value}
|
||||
.rowRenderer=${this.rowRenderer as RenderItemFunction<PickerListItem>}
|
||||
.currentSearch=${this._search}
|
||||
.notFoundLabel=${this.notFoundLabel}
|
||||
.emptyLabel=${this.emptyLabel}
|
||||
></ha-picker-list>
|
||||
`;
|
||||
}
|
||||
|
||||
// Forward nav keys from search input to list (focus stays in search).
|
||||
private _handleSearchKeydown = (ev: KeyboardEvent) => {
|
||||
const list = this._listEl;
|
||||
if (!list) return;
|
||||
switch (ev.key) {
|
||||
case "ArrowDown":
|
||||
ev.preventDefault();
|
||||
list.selectNext(ev);
|
||||
break;
|
||||
case "ArrowUp":
|
||||
ev.preventDefault();
|
||||
list.selectPrev(ev);
|
||||
break;
|
||||
case "Home":
|
||||
ev.preventDefault();
|
||||
list.selectFirst(ev);
|
||||
break;
|
||||
case "End":
|
||||
ev.preventDefault();
|
||||
list.selectLast(ev);
|
||||
break;
|
||||
case "Enter":
|
||||
ev.preventDefault();
|
||||
list.commitHighlighted(ev.ctrlKey || ev.metaKey);
|
||||
break;
|
||||
default:
|
||||
}
|
||||
};
|
||||
|
||||
private _fuseIndex = memoizeOne(
|
||||
(items: T[], searchKeys?: FuseWeightedKey[]) =>
|
||||
Fuse.createIndex(searchKeys ?? DEFAULT_SEARCH_KEYS, items)
|
||||
);
|
||||
|
||||
private _computeDisplayItems = memoizeOne(
|
||||
(
|
||||
items: T[],
|
||||
search: string,
|
||||
searchKeys: FuseWeightedKey[] | undefined,
|
||||
searchFn: PickerSearchFn<T> | undefined,
|
||||
actions: PickerListItem[] | undefined
|
||||
): PickerListItem[] => {
|
||||
let filtered = items;
|
||||
if (search) {
|
||||
const keys = searchKeys ?? DEFAULT_SEARCH_KEYS;
|
||||
const index = this._fuseIndex(items, keys);
|
||||
filtered = multiTermSortedSearch<T>(
|
||||
items,
|
||||
search,
|
||||
keys,
|
||||
(item) => item.id,
|
||||
index
|
||||
);
|
||||
if (searchFn) {
|
||||
filtered = searchFn(search, filtered, items);
|
||||
}
|
||||
}
|
||||
if (actions?.length) {
|
||||
return [...filtered, ...actions];
|
||||
}
|
||||
return filtered;
|
||||
}
|
||||
);
|
||||
|
||||
private _handleSearchChanged = (ev: HASSDomEvent<{ value: string }>) => {
|
||||
this._search = ev.detail.value;
|
||||
};
|
||||
|
||||
static styles: CSSResultGroup = css`
|
||||
:host {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-picker-search-list": HaPickerSearchList;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { css, html, LitElement, type CSSResultGroup } from "lit";
|
||||
import { customElement, property, query } from "lit/decorators";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import "./input/ha-input-search";
|
||||
import type { HaInputSearch } from "./input/ha-input-search";
|
||||
|
||||
/** Search input for picker UIs; emits `search-changed`. */
|
||||
@customElement("ha-picker-search")
|
||||
export class HaPickerSearch extends LitElement {
|
||||
@property() public value = "";
|
||||
|
||||
@property() public placeholder?: string;
|
||||
|
||||
// eslint-disable-next-line lit/no-native-attributes
|
||||
@property({ type: Boolean }) public autofocus = false;
|
||||
|
||||
@query("ha-input-search") private _input?: HaInputSearch;
|
||||
|
||||
public focus() {
|
||||
// ha-input doesn't expose focus(); reach the wa-input it wraps.
|
||||
this._input?.shadowRoot?.querySelector<HTMLElement>("wa-input")?.focus();
|
||||
}
|
||||
|
||||
protected render() {
|
||||
return html`
|
||||
<ha-input-search
|
||||
appearance="outlined"
|
||||
.value=${this.value}
|
||||
.placeholder=${this.placeholder ?? ""}
|
||||
?autofocus=${this.autofocus}
|
||||
@input=${this._handleInput}
|
||||
></ha-input-search>
|
||||
`;
|
||||
}
|
||||
|
||||
private _handleInput = (ev: Event) => {
|
||||
const value = (ev.target as HaInputSearch).value ?? "";
|
||||
this.value = value;
|
||||
fireEvent(this, "search-changed", { value });
|
||||
};
|
||||
|
||||
static styles: CSSResultGroup = css`
|
||||
:host {
|
||||
display: block;
|
||||
padding: 0 var(--ha-space-3) var(--ha-space-3);
|
||||
}
|
||||
ha-input-search {
|
||||
width: 100%;
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-picker-search": HaPickerSearch;
|
||||
}
|
||||
interface HASSDomEvents {
|
||||
"search-changed": { value: string };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { css, html, LitElement, nothing, type CSSResultGroup } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { fireEvent } from "../common/dom/fire_event";
|
||||
import { isTouch } from "../util/is_touch";
|
||||
import "./chips/ha-chip-set";
|
||||
import "./chips/ha-filter-chip";
|
||||
|
||||
export interface PickerSection {
|
||||
id: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export type PickerSectionDef = PickerSection | "separator";
|
||||
|
||||
/** Section filter chip bar; emits `section-changed`. Toggling the active chip clears the filter. */
|
||||
@customElement("ha-picker-section-chips")
|
||||
export class HaPickerSectionChips extends LitElement {
|
||||
@property({ attribute: false }) public sections?: PickerSectionDef[];
|
||||
|
||||
@property() public selected?: string;
|
||||
|
||||
protected render() {
|
||||
if (!this.sections?.length) return nothing;
|
||||
return html`
|
||||
<ha-chip-set>
|
||||
${this.sections.map((section) =>
|
||||
section === "separator"
|
||||
? html`<div class="separator"></div>`
|
||||
: html`<ha-filter-chip
|
||||
@mousedown=${isTouch ? undefined : this._preventBlur}
|
||||
@click=${this._handleClick}
|
||||
data-section-id=${section.id}
|
||||
.selected=${this.selected === section.id}
|
||||
.label=${section.label}
|
||||
></ha-filter-chip>`
|
||||
)}
|
||||
</ha-chip-set>
|
||||
`;
|
||||
}
|
||||
|
||||
private _preventBlur = (ev: Event) => {
|
||||
ev.preventDefault();
|
||||
};
|
||||
|
||||
private _handleClick = (ev: Event) => {
|
||||
const id = (ev.currentTarget as HTMLElement).dataset.sectionId;
|
||||
if (!id) return;
|
||||
const next = this.selected === id ? undefined : id;
|
||||
this.selected = next;
|
||||
fireEvent(this, "section-changed", { section: next });
|
||||
};
|
||||
|
||||
static styles: CSSResultGroup = css`
|
||||
:host {
|
||||
display: block;
|
||||
padding: 0 var(--ha-space-3) var(--ha-space-3);
|
||||
}
|
||||
ha-chip-set {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
gap: var(--ha-space-2);
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
scrollbar-width: none;
|
||||
/* Room for the chip's focus ring (clipped by overflow-y: hidden). */
|
||||
padding: var(--ha-space-1) 0;
|
||||
margin: calc(-1 * var(--ha-space-1)) 0;
|
||||
}
|
||||
ha-chip-set::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
ha-chip-set ha-filter-chip {
|
||||
flex-shrink: 0;
|
||||
--md-filter-chip-selected-container-color: var(
|
||||
--ha-color-fill-primary-normal-hover
|
||||
);
|
||||
color: var(--primary-color);
|
||||
}
|
||||
.separator {
|
||||
height: var(--ha-space-8);
|
||||
width: 0;
|
||||
border: 1px solid var(--ha-color-border-neutral-quiet);
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ha-picker-section-chips": HaPickerSectionChips;
|
||||
}
|
||||
interface HASSDomEvents {
|
||||
"section-changed": { section: string | undefined };
|
||||
}
|
||||
}
|
||||
+145
-132
@@ -1,6 +1,5 @@
|
||||
import "@home-assistant/webawesome/dist/components/popover/popover";
|
||||
import { consume } from "@lit/context";
|
||||
import { mdiPlus, mdiTextureBox } from "@mdi/js";
|
||||
import { mdiPlaylistPlus, mdiPlus, mdiTextureBox } from "@mdi/js";
|
||||
import Fuse from "fuse.js";
|
||||
import type { HassServiceTarget } from "home-assistant-js-websocket";
|
||||
import type { PropertyValues } from "lit";
|
||||
@@ -53,19 +52,22 @@ import {
|
||||
multiTermSortedSearch,
|
||||
type FuseWeightedKey,
|
||||
} from "../resources/fuseMultiTerm";
|
||||
import type { HomeAssistant, ValueChangedEvent } from "../types";
|
||||
import type { HomeAssistant } from "../types";
|
||||
import { brandsUrl } from "../util/brands-url";
|
||||
import type { HaDevicePickerDeviceFilterFunc } from "./device/ha-device-picker";
|
||||
import "./ha-generic-picker";
|
||||
import type { HaGenericPicker } from "./ha-generic-picker";
|
||||
import "./ha-button";
|
||||
import type { PickerComboBoxItem } from "./ha-picker-combo-box";
|
||||
import "./ha-picker-list";
|
||||
import type { PickerListEntry, PickerListItem } from "./ha-picker-list";
|
||||
import "./ha-picker-popover";
|
||||
import "./ha-picker-search";
|
||||
import "./ha-picker-section-chips";
|
||||
import "./ha-svg-icon";
|
||||
import "./ha-tree-indicator";
|
||||
import "./target-picker/ha-target-picker-item-group";
|
||||
import "./target-picker/ha-target-picker-value-chip";
|
||||
|
||||
const SEPARATOR = "________";
|
||||
const CREATE_ID = "___create-new-entity___";
|
||||
const isTargetType = (value: string): value is TargetType =>
|
||||
value === "entity" ||
|
||||
value === "device" ||
|
||||
@@ -122,11 +124,15 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
|
||||
@state() private _configEntryLookup: Record<string, ConfigEntry> = {};
|
||||
|
||||
@state() private _pickerOpen = false;
|
||||
|
||||
@state() private _search = "";
|
||||
|
||||
@state()
|
||||
@consume({ context: labelsContext, subscribe: true })
|
||||
private _labelRegistry!: LabelRegistryEntry[];
|
||||
|
||||
@query("ha-generic-picker") private _picker?: HaGenericPicker;
|
||||
@query(".add-target-wrapper") private _addTargetWrapper?: HTMLElement;
|
||||
|
||||
private _newTarget?: TargetItem;
|
||||
|
||||
@@ -412,56 +418,92 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
},
|
||||
];
|
||||
|
||||
const items = this._buildListEntries(
|
||||
this._search,
|
||||
this._selectedSection,
|
||||
this.createDomains
|
||||
);
|
||||
|
||||
return html`
|
||||
<div class="add-target-wrapper">
|
||||
<ha-generic-picker
|
||||
.hass=${this.hass}
|
||||
<ha-button
|
||||
class="add-target-button"
|
||||
size="small"
|
||||
appearance="filled"
|
||||
.disabled=${this.disabled}
|
||||
.autofocus=${this.autofocus}
|
||||
.helper=${this.helper}
|
||||
.sections=${sections}
|
||||
.notFoundLabel=${this._noTargetFoundLabel}
|
||||
.emptyLabel=${this.hass.localize(
|
||||
"ui.components.target-picker.no_targets"
|
||||
)}
|
||||
.sectionTitleFunction=${this._sectionTitleFunction}
|
||||
.selectedSection=${this._selectedSection}
|
||||
.popoverAnchor=${this._replaceTargetAnchor}
|
||||
.rowRenderer=${this._renderRow}
|
||||
.getItems=${this._getItems}
|
||||
@value-changed=${this._targetPicked}
|
||||
@picker-closed=${this._handlePickerClosed}
|
||||
.addButtonLabel=${this.hass.localize(
|
||||
"ui.components.target-picker.add_target"
|
||||
)}
|
||||
.getAdditionalItems=${this._getAdditionalItems}
|
||||
@click=${this._openPicker}
|
||||
>
|
||||
</ha-generic-picker>
|
||||
<ha-svg-icon .path=${mdiPlaylistPlus} slot="start"></ha-svg-icon>
|
||||
${this.hass.localize("ui.components.target-picker.add_target")}
|
||||
</ha-button>
|
||||
<ha-picker-popover
|
||||
.open=${this._pickerOpen}
|
||||
.anchor=${this._replaceTargetAnchor ?? this._addTargetWrapper}
|
||||
.label=${this.hass.localize("ui.components.target-picker.add_target")}
|
||||
@closed=${this._handlePickerClosed}
|
||||
>
|
||||
<div class="picker-body">
|
||||
<ha-picker-search
|
||||
autofocus
|
||||
.value=${this._search}
|
||||
.placeholder=${this.hass.localize("ui.common.search")}
|
||||
@search-changed=${this._handleSearchChanged}
|
||||
></ha-picker-search>
|
||||
<ha-picker-section-chips
|
||||
.sections=${sections}
|
||||
.selected=${this._selectedSection}
|
||||
@section-changed=${this._handleSectionChanged}
|
||||
></ha-picker-section-chips>
|
||||
<ha-picker-list
|
||||
.items=${items}
|
||||
.rowRenderer=${this._renderRow}
|
||||
.currentSearch=${this._search}
|
||||
.notFoundLabel=${this._noTargetFoundLabel}
|
||||
.emptyLabel=${this.hass.localize(
|
||||
"ui.components.target-picker.no_targets"
|
||||
)}
|
||||
@item-selected=${this._handleItemSelected}
|
||||
></ha-picker-list>
|
||||
</div>
|
||||
</ha-picker-popover>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private _targetPicked(ev: ValueChangedEvent<string>) {
|
||||
ev.stopPropagation();
|
||||
const value = ev.detail.value;
|
||||
if (value.startsWith(CREATE_ID)) {
|
||||
this._createNewDomainElement(value.substring(CREATE_ID.length));
|
||||
return;
|
||||
}
|
||||
private _openPicker = () => {
|
||||
if (this.disabled) return;
|
||||
this._pickerOpen = true;
|
||||
};
|
||||
|
||||
private _handleSearchChanged = (ev: HASSDomEvent<{ value: string }>) => {
|
||||
this._search = ev.detail.value;
|
||||
};
|
||||
|
||||
private _handleSectionChanged = (
|
||||
ev: HASSDomEvent<{ section: string | undefined }>
|
||||
) => {
|
||||
this._selectedSection = ev.detail.section as
|
||||
| TargetTypeFloorless
|
||||
| undefined;
|
||||
};
|
||||
|
||||
private _handleItemSelected = (
|
||||
ev: HASSDomEvent<{ id: string; index: number; newTab?: boolean }>
|
||||
) => {
|
||||
ev.stopPropagation();
|
||||
const value = ev.detail.id;
|
||||
const [rawType, id] = value.split(SEPARATOR);
|
||||
|
||||
if (!id || !isTargetType(rawType)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this._replaceTarget) {
|
||||
this._replaceTargetItem(this._replaceTarget, { type: rawType, id });
|
||||
return;
|
||||
}
|
||||
this._pickerOpen = false;
|
||||
this._pendingPick = { type: rawType, id };
|
||||
};
|
||||
|
||||
this._addTarget(id, rawType);
|
||||
}
|
||||
// Commit fires on @closed (after the hide animation) to avoid flicker.
|
||||
private _pendingPick?: TargetItem;
|
||||
|
||||
private _replaceTargetItem(currentTarget: TargetItem, newTarget: TargetItem) {
|
||||
const value = this._replaceTargetInValue(
|
||||
@@ -734,18 +776,27 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
return;
|
||||
}
|
||||
this._replaceTarget = { type, id: ev.detail.id };
|
||||
this._picker?.open(undefined, {
|
||||
selectedValue: `${type}${SEPARATOR}${ev.detail.id}`,
|
||||
});
|
||||
this._pickerOpen = true;
|
||||
}
|
||||
|
||||
private _handlePickerClosed() {
|
||||
private _handlePickerClosed = () => {
|
||||
if (this._pendingPick) {
|
||||
const pick = this._pendingPick;
|
||||
this._pendingPick = undefined;
|
||||
if (this._replaceTarget) {
|
||||
this._replaceTargetItem(this._replaceTarget, pick);
|
||||
} else {
|
||||
this._addTarget(pick.id, pick.type);
|
||||
}
|
||||
}
|
||||
this._pickerOpen = false;
|
||||
this._search = "";
|
||||
if (this._replaceTarget) {
|
||||
this._selectedSection = undefined;
|
||||
}
|
||||
this._replaceTarget = undefined;
|
||||
this._replaceTargetAnchor = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
private _addItems(
|
||||
value: this["value"],
|
||||
@@ -782,55 +833,12 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
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 = getTargetComboBoxItemType(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(
|
||||
private _buildListEntries(
|
||||
searchString: string,
|
||||
section: TargetTypeFloorless | undefined,
|
||||
createDomains: this["createDomains"]
|
||||
): PickerListEntry[] {
|
||||
const items = this._getItemsMemoized(
|
||||
this.hass.localize,
|
||||
this.entityFilter,
|
||||
this.deviceFilter,
|
||||
@@ -840,9 +848,37 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
this._replaceTarget,
|
||||
searchString,
|
||||
this._configEntryLookup,
|
||||
this._selectedSection
|
||||
);
|
||||
};
|
||||
section
|
||||
) as PickerListEntry[];
|
||||
|
||||
const actions = this._buildActionEntries(createDomains);
|
||||
return actions.length ? [...items, ...actions] : items;
|
||||
}
|
||||
|
||||
private _buildActionEntries = memoizeOne(
|
||||
(createDomains: this["createDomains"]): PickerListItem[] => {
|
||||
if (!createDomains?.length) return [];
|
||||
return createDomains.map((domain) => ({
|
||||
id: `__create-helper__${SEPARATOR}${domain}`,
|
||||
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),
|
||||
}
|
||||
),
|
||||
secondary: this.hass.localize(
|
||||
"ui.components.entity.entity-picker.new_entity"
|
||||
),
|
||||
icon_path: mdiPlus,
|
||||
onSelect: ({ close }) => {
|
||||
close();
|
||||
this._createNewDomainElement(domain);
|
||||
},
|
||||
}));
|
||||
}
|
||||
);
|
||||
|
||||
private _getItemsMemoized = memoizeOne(
|
||||
(
|
||||
@@ -1083,36 +1119,6 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
});
|
||||
}
|
||||
|
||||
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(
|
||||
@@ -1256,14 +1262,21 @@ export class HaTargetPicker extends SubscribeMixin(LitElement) {
|
||||
);
|
||||
|
||||
static styles = css`
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.add-target-wrapper {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
display: block;
|
||||
margin-top: var(--ha-space-3);
|
||||
}
|
||||
|
||||
ha-generic-picker {
|
||||
width: 100%;
|
||||
.picker-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
padding-top: var(--ha-space-4);
|
||||
}
|
||||
|
||||
.items {
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { mdiClose, mdiDragHorizontalVariant, mdiPencil } from "@mdi/js";
|
||||
import {
|
||||
mdiClose,
|
||||
mdiDragHorizontalVariant,
|
||||
mdiPencil,
|
||||
mdiPlaylistPlus,
|
||||
} from "@mdi/js";
|
||||
import { css, html, LitElement, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { repeat } from "lit/directives/repeat";
|
||||
@@ -7,8 +12,10 @@ import { entityUseDeviceName } from "../../../common/entity/compute_entity_name"
|
||||
import { computeRTL } from "../../../common/util/compute_rtl";
|
||||
import "../../../components/entity/ha-entity-picker";
|
||||
import type { HaEntityPicker } from "../../../components/entity/ha-entity-picker";
|
||||
import "../../../components/ha-button";
|
||||
import "../../../components/ha-icon-button";
|
||||
import "../../../components/ha-sortable";
|
||||
import "../../../components/ha-svg-icon";
|
||||
import type { HaEntityPickerEntityFilterFunc } from "../../../data/entity/entity";
|
||||
import type { HomeAssistant } from "../../../types";
|
||||
import type { EntityConfig } from "../entity-rows/types";
|
||||
@@ -183,8 +190,12 @@ export class HuiEntityEditor extends LitElement {
|
||||
.hass=${this.hass}
|
||||
.entityFilter=${this.entityFilter}
|
||||
@value-changed=${this._addEntity}
|
||||
add-button
|
||||
></ha-entity-picker>
|
||||
>
|
||||
<ha-button slot="trigger" size="small" appearance="filled">
|
||||
<ha-svg-icon .path=${mdiPlaylistPlus} slot="start"></ha-svg-icon>
|
||||
${this.hass.localize("ui.components.entity.entity-picker.add")}
|
||||
</ha-button>
|
||||
</ha-entity-picker>
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import { mdiClose, mdiDragHorizontalVariant, mdiPencil } from "@mdi/js";
|
||||
import {
|
||||
mdiClose,
|
||||
mdiDragHorizontalVariant,
|
||||
mdiPencil,
|
||||
mdiPlaylistPlus,
|
||||
} from "@mdi/js";
|
||||
import { LitElement, css, html, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators";
|
||||
import { repeat } from "lit/directives/repeat";
|
||||
import { fireEvent } from "../../../common/dom/fire_event";
|
||||
import "../../../components/entity/ha-entity-picker";
|
||||
import type { HaEntityPicker } from "../../../components/entity/ha-entity-picker";
|
||||
import "../../../components/ha-button";
|
||||
import "../../../components/ha-icon-button";
|
||||
import "../../../components/ha-sortable";
|
||||
import "../../../components/ha-svg-icon";
|
||||
@@ -114,8 +120,12 @@ export class HuiEntitiesCardRowEditor extends LitElement {
|
||||
class="add-entity"
|
||||
.hass=${this.hass}
|
||||
@value-changed=${this._addEntity}
|
||||
add-button
|
||||
></ha-entity-picker>
|
||||
>
|
||||
<ha-button slot="trigger" size="small" appearance="filled">
|
||||
<ha-svg-icon .path=${mdiPlaylistPlus} slot="start"></ha-svg-icon>
|
||||
${this.hass!.localize("ui.components.entity.entity-picker.add")}
|
||||
</ha-button>
|
||||
</ha-entity-picker>
|
||||
`;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user