Compare commits

...

3 Commits

Author SHA1 Message Date
Paul Bottein 8580c99f0a WIP: autofocus, search reset, keyboard nav, cleanup 2026-05-22 10:06:10 +02:00
Paul Bottein 3cf2d9d6dd WIP: migrate entity picker to composable components 2026-05-21 15:23:25 +02:00
Paul Bottein 081212eab1 WIP: split target picker into multiple components 2026-05-21 15:23:25 +02:00
9 changed files with 1299 additions and 236 deletions
+134 -98
View File
@@ -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 {
+394
View File
@@ -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;
}
}
+257
View File
@@ -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;
}
}
+188
View File
@@ -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;
}
}
+60
View File
@@ -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 };
}
}
+94
View File
@@ -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
View File
@@ -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>
`;
}