Compare commits

..

1 Commits

Author SHA1 Message Date
Wendelin
53a799e184 Migrate ha-selector-select to use ha-generic-picker component 2025-12-18 16:21:04 +01:00
5 changed files with 165 additions and 283 deletions

View File

@@ -1,22 +1,27 @@
import type { PropertyValues } from "lit";
import { LitElement, html, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import memoizeOne from "memoize-one";
import { customElement, property, query, state } from "lit/decorators";
import { ensureArray } from "../../common/array/ensure-array";
import { fireEvent } from "../../common/dom/fire_event";
import { getStates } from "../../common/entity/get_states";
import type { HomeAssistant, ValueChangedEvent } from "../../types";
import "../ha-generic-picker";
import type { PickerComboBoxItem } from "../ha-picker-combo-box";
import "../ha-combo-box";
import type { HaComboBox } from "../ha-combo-box";
interface StateOption {
value: string;
label: string;
}
@customElement("ha-entity-state-picker")
export class HaEntityStatePicker extends LitElement {
class HaEntityStatePicker extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
@property({ attribute: false }) public entityId?: string | string[];
@property() public attribute?: string;
@property({ attribute: false }) public extraOptions?: PickerComboBoxItem[];
@property({ attribute: false }) public extraOptions?: any[];
// eslint-disable-next-line lit/no-native-attributes
@property({ type: Boolean }) public autofocus = false;
@@ -37,111 +42,59 @@ export class HaEntityStatePicker extends LitElement {
@property() public helper?: string;
private _getItems = memoizeOne(
(
hass: HomeAssistant,
entityId: string | string[] | undefined,
attribute: string | undefined,
hideStates: string[] | undefined,
extraOptions: PickerComboBoxItem[] | undefined
): PickerComboBoxItem[] => {
const entityIds = entityId ? ensureArray(entityId) : [];
@state() private _opened = false;
const entitiesOptions = entityIds.map<PickerComboBoxItem[]>(
(entityIdItem) => {
const stateObj = hass.states[entityIdItem] || {
entity_id: entityIdItem,
attributes: {},
};
@query("ha-combo-box", true) private _comboBox!: HaComboBox;
const states = getStates(hass, stateObj, attribute).filter(
(s) => !hideStates?.includes(s)
);
protected shouldUpdate(changedProps: PropertyValues) {
return !(!changedProps.has("_opened") && this._opened);
}
return states
.map((s) => {
const primary = attribute
? hass.formatEntityAttributeValue(stateObj, attribute, s)
: hass.formatEntityState(stateObj, s);
return {
id: s,
primary,
search_labels: {
primary,
id: s,
},
sorting_label: primary,
};
})
.filter((option) => option.id && option.primary);
}
);
protected updated(changedProps: PropertyValues) {
if (
(changedProps.has("_opened") && this._opened) ||
changedProps.has("entityId") ||
changedProps.has("attribute") ||
changedProps.has("extraOptions")
) {
const entityIds = this.entityId ? ensureArray(this.entityId) : [];
const options: PickerComboBoxItem[] = [];
const entitiesOptions = entityIds.map<StateOption[]>((entityId) => {
const stateObj = this.hass.states[entityId] || {
entity_id: entityId,
attributes: {},
};
const states = getStates(this.hass, stateObj, this.attribute).filter(
(s) => !this.hideStates?.includes(s)
);
return states.map((s) => ({
value: s,
label: this.attribute
? this.hass.formatEntityAttributeValue(stateObj, this.attribute, s)
: this.hass.formatEntityState(stateObj, s),
}));
});
const options: StateOption[] = [];
const optionsSet = new Set<string>();
for (const entityOptions of entitiesOptions) {
for (const option of entityOptions) {
if (!optionsSet.has(option.id)) {
optionsSet.add(option.id);
if (!optionsSet.has(option.value)) {
optionsSet.add(option.value);
options.push(option);
}
}
}
if (extraOptions) {
// Filter out any extraOptions with empty primary or id fields
const validExtraOptions = extraOptions.filter(
(option) => option.id && option.primary
);
options.unshift(...validExtraOptions);
if (this.extraOptions) {
options.unshift(...this.extraOptions);
}
return options;
(this._comboBox as any).filteredItems = options;
}
);
private _getFilteredItems = (
_searchString?: string,
_section?: string
): PickerComboBoxItem[] =>
this._getItems(
this.hass,
this.entityId,
this.attribute,
this.hideStates,
this.extraOptions
);
private _getAdditionalItems = (
searchString?: string
): PickerComboBoxItem[] => {
if (!this.allowCustomValue || !searchString) {
return [];
}
// Check if the search string matches an existing item
const items = this._getFilteredItems();
const existingItem = items.find((item) => item.id === searchString);
// Only return custom value option if it doesn't match an existing item
if (!existingItem) {
return [
{
id: searchString,
primary: searchString,
secondary: `"${searchString}"`,
search_labels: {
primary: searchString,
secondary: `"${searchString}"`,
id: searchString,
},
sorting_label: searchString,
},
];
}
return [];
};
}
protected render() {
if (!this.hass) {
@@ -149,40 +102,48 @@ export class HaEntityStatePicker extends LitElement {
}
return html`
<ha-generic-picker
<ha-combo-box
.hass=${this.hass}
.allowCustomValue=${this.allowCustomValue}
.disabled=${this.disabled || !this.entityId}
.value=${this._value}
.autofocus=${this.autofocus}
.required=${this.required}
.label=${this.label ??
this.hass.localize("ui.components.entity.entity-state-picker.state")}
.disabled=${this.disabled || !this.entityId}
.required=${this.required}
.helper=${this.helper}
.value=${this.value}
.getItems=${this._getFilteredItems}
.getAdditionalItems=${this._getAdditionalItems}
.notFoundLabel=${this.hass.localize("ui.components.combo-box.no_match")}
.customValueLabel=${this.hass.localize(
"ui.components.entity.entity-state-picker.add_custom_state" as any
)}
.allowCustomValue=${this.allowCustomValue}
item-id-path="value"
item-value-path="value"
item-label-path="label"
@opened-changed=${this._openedChanged}
@value-changed=${this._valueChanged}
>
</ha-generic-picker>
</ha-combo-box>
`;
}
private get _value() {
return this.value || "";
}
private _openedChanged(ev: ValueChangedEvent<boolean>) {
this._opened = ev.detail.value;
}
private _valueChanged(ev: ValueChangedEvent<string>) {
ev.stopPropagation();
const newValue = ev.detail.value;
if (newValue !== this.value) {
if (newValue !== this._value) {
this._setValue(newValue);
}
}
private _setValue(value: string | undefined) {
private _setValue(value: string) {
this.value = value;
fireEvent(this, "value-changed", { value });
fireEvent(this, "change");
setTimeout(() => {
fireEvent(this, "value-changed", { value });
fireEvent(this, "change");
}, 0);
}
}

View File

@@ -11,13 +11,13 @@ import {
import { customElement, property, query, state } from "lit/decorators";
import { ifDefined } from "lit/directives/if-defined";
import { fireEvent } from "../common/dom/fire_event";
import { localizeContext } from "../data/context";
import { PickerMixin } from "../mixins/picker-mixin";
import { localizeContext } from "../data/context";
import type { HomeAssistant } from "../types";
import "./ha-combo-box-item";
import type { HaComboBoxItem } from "./ha-combo-box-item";
import "./ha-icon";
import "./ha-icon-button";
import "./ha-icon";
declare global {
interface HASSDomEvents {
@@ -194,10 +194,7 @@ export class HaPickerField extends PickerMixin(LitElement) {
.placeholder {
color: var(--secondary-text-color);
}
:host([invalid]) .placeholder {
color: var(--mdc-theme-error, var(--error-color, #b00020));
padding: 0 8px;
}
.unknown {

View File

@@ -1,7 +1,8 @@
import { mdiDragHorizontalVariant } from "@mdi/js";
import { LitElement, css, html, nothing } from "lit";
import { customElement, property, query } from "lit/decorators";
import { customElement, property } from "lit/decorators";
import { repeat } from "lit/directives/repeat";
import memoizeOne from "memoize-one";
import { ensureArray } from "../../common/array/ensure-array";
import { fireEvent } from "../../common/dom/fire_event";
import { stopPropagation } from "../../common/dom/stop_propagation";
@@ -11,9 +12,8 @@ import type { HomeAssistant } from "../../types";
import "../chips/ha-chip-set";
import "../chips/ha-input-chip";
import "../ha-checkbox";
import "../ha-combo-box";
import type { HaComboBox } from "../ha-combo-box";
import "../ha-formfield";
import "../ha-generic-picker";
import "../ha-input-helper-text";
import "../ha-list-item";
import "../ha-radio";
@@ -40,8 +40,6 @@ export class HaSelectSelector extends LitElement {
@property({ type: Boolean }) public required = true;
@query("ha-combo-box", true) private comboBox!: HaComboBox;
private _itemMoved(ev: CustomEvent): void {
ev.stopPropagation();
const { oldIndex, newIndex } = ev.detail;
@@ -59,15 +57,8 @@ export class HaSelectSelector extends LitElement {
});
}
private _filter = "";
protected render() {
const options =
this.selector.select?.options?.map((option) =>
typeof option === "object"
? (option as SelectOption)
: ({ value: option, label: option } as SelectOption)
) || [];
const options = this._getOptions(this.selector);
const translationKey = this.selector.select?.translation_key;
@@ -165,10 +156,6 @@ export class HaSelectSelector extends LitElement {
const value =
!this.value || this.value === "" ? [] : ensureArray(this.value);
const optionItems = options.filter(
(option) => !option.disabled && !value?.includes(option.value)
);
return html`
${value?.length
? html`
@@ -212,50 +199,33 @@ export class HaSelectSelector extends LitElement {
`
: nothing}
<ha-combo-box
item-value-path="value"
item-label-path="label"
<ha-generic-picker
.hass=${this.hass}
.label=${this.label}
.helper=${this.helper}
.disabled=${this.disabled}
.required=${this.required && !value.length}
.value=${""}
.items=${optionItems}
.addButtonLabel=${this.label}
.getItems=${this._getItems(options, value, true)}
.allowCustomValue=${this.selector.select.custom_value ?? false}
@filter-changed=${this._filterChanged}
@value-changed=${this._comboBoxValueChanged}
@opened-changed=${this._openedChanged}
></ha-combo-box>
></ha-generic-picker>
`;
}
if (this.selector.select?.custom_value) {
if (
this.value !== undefined &&
!Array.isArray(this.value) &&
!options.find((option) => option.value === this.value)
) {
options.unshift({ value: this.value, label: this.value });
}
const optionItems = options.filter((option) => !option.disabled);
return html`
<ha-combo-box
item-value-path="value"
item-label-path="label"
<ha-generic-picker
.hass=${this.hass}
.label=${this.label}
.helper=${this.helper}
.disabled=${this.disabled}
.required=${this.required}
.items=${optionItems}
.value=${this.value}
@filter-changed=${this._filterChanged}
.getItems=${this._getItems(options)}
.value=${this.value as string | undefined}
@value-changed=${this._comboBoxValueChanged}
@opened-changed=${this._openedChanged}
></ha-combo-box>
allow-custom-value
></ha-generic-picker>
`;
}
@@ -274,7 +244,7 @@ export class HaSelectSelector extends LitElement {
>
${options.map(
(item: SelectOption) => html`
<ha-list-item .value=${item.value} .disabled=${item.disabled}
<ha-list-item .value=${item.value} .disabled=${!!item.disabled}
>${item.label}</ha-list-item
>
`
@@ -291,6 +261,30 @@ export class HaSelectSelector extends LitElement {
: "";
}
private _getOptions = memoizeOne(
(selector: SelectSelector) =>
selector.select?.options?.map((option) =>
typeof option === "object"
? (option as SelectOption)
: ({ value: option, label: option } as SelectOption)
) || []
);
private _getItems = memoizeOne(
(options: SelectOption[], value?: string[], multiple = false) => {
const filteredOptions = options.filter((option) =>
!option.disabled && !multiple ? true : !value?.includes(option.value)
);
return () =>
filteredOptions.map((option) => ({
id: option.value,
primary: option.label,
sorting_label: option.label,
}));
}
);
private get _mode(): "list" | "dropdown" | "box" {
return (
this.selector.select?.mode ||
@@ -355,8 +349,6 @@ export class HaSelectSelector extends LitElement {
fireEvent(this, "value-changed", {
value,
});
await this.updateComplete;
this._filterChanged();
}
private _comboBoxValueChanged(ev: CustomEvent): void {
@@ -374,49 +366,17 @@ export class HaSelectSelector extends LitElement {
return;
}
const currentValue =
!this.value || this.value === "" ? [] : ensureArray(this.value);
const currentValue = !this.value ? [] : ensureArray(this.value);
if (newValue !== undefined && currentValue.includes(newValue)) {
return;
}
setTimeout(() => {
this._filterChanged();
this.comboBox.setInputValue("");
}, 0);
fireEvent(this, "value-changed", {
value: [...currentValue, newValue],
});
}
private _openedChanged(ev?: CustomEvent): void {
if (ev?.detail.value) {
this._filterChanged();
}
}
private _filterChanged(ev?: CustomEvent): void {
this._filter = ev?.detail.value || "";
const filteredItems = this.comboBox.items?.filter((item) => {
const label = item.label || item.value;
return label.toLowerCase().includes(this._filter?.toLowerCase());
});
if (
this._filter &&
this.selector.select?.custom_value &&
filteredItems &&
!filteredItems.some((item) => (item.label || item.value) === this._filter)
) {
filteredItems.unshift({ label: this._filter, value: this._filter });
}
this.comboBox.filteredItems = filteredItems;
}
static styles = css`
:host {
position: relative;

View File

@@ -5,15 +5,13 @@ import { customElement, property, state } from "lit/decorators";
import { fireEvent } from "../../../common/dom/fire_event";
import "../../../components/ha-alert";
import "../../../components/ha-button";
import "../../../components/ha-dialog-footer";
import "../../../components/ha-combo-box";
import { createCloseHeading } from "../../../components/ha-dialog";
import "../../../components/ha-fade-in";
import "../../../components/ha-generic-picker";
import "../../../components/ha-markdown";
import "../../../components/ha-password-field";
import type { PickerComboBoxItem } from "../../../components/ha-picker-combo-box";
import "../../../components/ha-spinner";
import "../../../components/ha-textfield";
import "../../../components/ha-wa-dialog";
import type {
ApplicationCredential,
ApplicationCredentialsConfig,
@@ -61,10 +59,6 @@ export class DialogAddApplicationCredential extends LitElement {
@state() private _config?: ApplicationCredentialsConfig;
@state() private _open = false;
@state() private _invalid = false;
public showDialog(params: AddApplicationCredentialDialogParams) {
this._params = params;
this._domain = params.selectedDomain;
@@ -75,7 +69,6 @@ export class DialogAddApplicationCredential extends LitElement {
this._clientSecret = "";
this._error = undefined;
this._loading = false;
this._open = true;
this._fetchConfig();
}
@@ -97,16 +90,16 @@ export class DialogAddApplicationCredential extends LitElement {
? domainToName(this.hass.localize, this._domain!)
: "";
return html`
<ha-wa-dialog
.hass=${this.hass}
.open=${this._open}
<ha-dialog
open
@closed=${this._abortDialog}
.preventScrimClose=${!!this._domain ||
!!this._name ||
!!this._clientId ||
!!this._clientSecret}
.headerTitle=${this.hass.localize(
"ui.panel.config.application_credentials.editor.caption"
scrimClickAction
escapeKeyAction
.heading=${createCloseHeading(
this.hass,
this.hass.localize(
"ui.panel.config.application_credentials.editor.caption"
)
)}
>
${!this._config
@@ -172,23 +165,20 @@ export class DialogAddApplicationCredential extends LitElement {
: nothing}
${this._params.selectedDomain
? nothing
: html`<ha-generic-picker
: html`<ha-combo-box
name="domain"
.hass=${this.hass}
.label=${this.hass.localize(
"ui.panel.config.application_credentials.editor.domain"
)}
.value=${this._domain}
.invalid=${this._invalid && !this._domain}
.getItems=${this._getDomainItems}
.items=${this._domains}
item-id-path="id"
item-value-path="id"
item-label-path="name"
required
.disabled=${!this._domains}
.valueRenderer=${this._domainRenderer}
@value-changed=${this._handleDomainPicked}
.errorMessage=${this.hass.localize(
"ui.common.error_required"
)}
></ha-generic-picker>`}
></ha-combo-box>`}
${this._description
? html`<ha-markdown
breaks
@@ -202,10 +192,9 @@ export class DialogAddApplicationCredential extends LitElement {
"ui.panel.config.application_credentials.editor.name"
)}
.value=${this._name}
.invalid=${this._invalid && !this._name}
required
@input=${this._handleValueChanged}
.errorMessage=${this.hass.localize(
.validationMessage=${this.hass.localize(
"ui.common.error_required"
)}
dialogInitialFocus
@@ -217,10 +206,9 @@ export class DialogAddApplicationCredential extends LitElement {
"ui.panel.config.application_credentials.editor.client_id"
)}
.value=${this._clientId}
.invalid=${this._invalid && !this._clientId}
required
@input=${this._handleValueChanged}
.errorMessage=${this.hass.localize(
.validationMessage=${this.hass.localize(
"ui.common.error_required"
)}
dialogInitialFocus
@@ -235,10 +223,9 @@ export class DialogAddApplicationCredential extends LitElement {
)}
name="clientSecret"
.value=${this._clientSecret}
.invalid=${this._invalid && !this._clientSecret}
required
@input=${this._handleValueChanged}
.errorMessage=${this.hass.localize(
.validationMessage=${this.hass.localize(
"ui.common.error_required"
)}
.helper=${this.hass.localize(
@@ -248,33 +235,30 @@ export class DialogAddApplicationCredential extends LitElement {
></ha-password-field>
</div>
<ha-dialog-footer slot="footer">
<ha-button
appearance="plain"
slot="secondaryAction"
@click=${this._closeDialog}
.disabled=${this._loading}
>
${this.hass.localize("ui.common.cancel")}
</ha-button>
<ha-button
slot="primaryAction"
@click=${this._addApplicationCredential}
.loading=${this._loading}
>
${this.hass.localize(
"ui.panel.config.application_credentials.editor.add"
)}
</ha-button>
</ha-dialog-footer>`}
</ha-wa-dialog>
<ha-button
appearance="plain"
slot="secondaryAction"
@click=${this._abortDialog}
.disabled=${this._loading}
>
${this.hass.localize("ui.common.cancel")}
</ha-button>
<ha-button
slot="primaryAction"
.disabled=${!this._domain ||
!this._clientId ||
!this._clientSecret}
@click=${this._addApplicationCredential}
.loading=${this._loading}
>
${this.hass.localize(
"ui.panel.config.application_credentials.editor.add"
)}
</ha-button>`}
</ha-dialog>
`;
}
private _closeDialog() {
this._open = false;
}
public closeDialog() {
this._params = undefined;
this._domains = undefined;
@@ -319,16 +303,9 @@ export class DialogAddApplicationCredential extends LitElement {
private async _addApplicationCredential(ev) {
ev.preventDefault();
if (
!this._domain ||
!this._name ||
!this._clientId ||
!this._clientSecret
) {
this._invalid = true;
if (!this._domain || !this._clientId || !this._clientSecret) {
return;
}
this._invalid = false;
this._loading = true;
this._error = "";
@@ -351,20 +328,6 @@ export class DialogAddApplicationCredential extends LitElement {
this.closeDialog();
}
private _getDomainItems = (): PickerComboBoxItem[] =>
this._domains?.map((domain) => ({
id: domain.id,
primary: domain.name,
sorting_label: domain.name,
})) ?? [];
private _domainRenderer = (domainId: string) => {
const domain = this._domains?.find((d) => d.id === domainId);
return html`<span slot="headline"
>${domain ? domain.name : domainId}</span
>`;
};
static get styles(): CSSResultGroup {
return [
haStyleDialog,
@@ -375,12 +338,15 @@ export class DialogAddApplicationCredential extends LitElement {
}
.row {
display: flex;
padding: var(--ha-space-2) 0;
padding: 8px 0;
}
ha-combo-box {
display: block;
margin-bottom: 24px;
}
ha-textfield {
display: block;
margin-top: var(--ha-space-4);
margin-bottom: var(--ha-space-4);
margin-bottom: 24px;
}
a {
text-decoration: none;
@@ -389,8 +355,7 @@ export class DialogAddApplicationCredential extends LitElement {
--mdc-icon-size: 16px;
}
ha-markdown {
margin-top: var(--ha-space-4);
margin-bottom: var(--ha-space-4);
margin-bottom: 16px;
}
ha-fade-in {
display: flex;

View File

@@ -680,8 +680,7 @@
"show_attributes": "Show attributes"
},
"entity-state-picker": {
"state": "State",
"add_custom_state": "Add custom state"
"state": "State"
},
"entity-state-content-picker": {
"add": "Add"