diff --git a/gallery/src/pages/components/ha-selector.ts b/gallery/src/pages/components/ha-selector.ts
index 0f50f6421f..4420b483ed 100644
--- a/gallery/src/pages/components/ha-selector.ts
+++ b/gallery/src/pages/components/ha-selector.ts
@@ -416,6 +416,34 @@ const SCHEMAS: {
},
},
},
+ items: {
+ name: "Items",
+ selector: {
+ object: {
+ label_field: "name",
+ description_field: "value",
+ multiple: true,
+ fields: {
+ name: {
+ label: "Name",
+ selector: { text: {} },
+ required: true,
+ },
+ value: {
+ label: "Value",
+ selector: {
+ number: {
+ mode: "slider",
+ min: 0,
+ max: 100,
+ unit_of_measurement: "%",
+ },
+ },
+ },
+ },
+ },
+ },
+ },
},
},
];
diff --git a/src/components/ha-selector/ha-selector-object.ts b/src/components/ha-selector/ha-selector-object.ts
index f2b9040b20..bca45148b3 100644
--- a/src/components/ha-selector/ha-selector-object.ts
+++ b/src/components/ha-selector/ha-selector-object.ts
@@ -1,16 +1,27 @@
-import type { PropertyValues } from "lit";
-import { html, LitElement } from "lit";
+import { mdiClose, mdiDelete, mdiDrag, mdiPencil } from "@mdi/js";
+import { css, html, LitElement, nothing, type PropertyValues } from "lit";
import { customElement, property, query } from "lit/decorators";
+import memoizeOne from "memoize-one";
+import { ensureArray } from "../../common/array/ensure-array";
import { fireEvent } from "../../common/dom/fire_event";
+import type { ObjectSelector } from "../../data/selector";
+import { formatSelectorValue } from "../../data/selector/format_selector_value";
+import { showFormDialog } from "../../dialogs/form/show-form-dialog";
import type { HomeAssistant } from "../../types";
-import "../ha-yaml-editor";
+import type { HaFormSchema } from "../ha-form/types";
import "../ha-input-helper-text";
+import "../ha-md-list";
+import "../ha-md-list-item";
+import "../ha-sortable";
+import "../ha-yaml-editor";
import type { HaYamlEditor } from "../ha-yaml-editor";
@customElement("ha-selector-object")
export class HaObjectSelector extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant;
+ @property({ attribute: false }) public selector!: ObjectSelector;
+
@property() public value?: any;
@property() public label?: string;
@@ -23,11 +34,136 @@ export class HaObjectSelector extends LitElement {
@property({ type: Boolean }) public required = true;
- @query("ha-yaml-editor", true) private _yamlEditor!: HaYamlEditor;
+ @property({ attribute: false }) public localizeValue?: (
+ key: string
+ ) => string;
+
+ @query("ha-yaml-editor", true) private _yamlEditor?: HaYamlEditor;
private _valueChangedFromChild = false;
+ private _computeLabel = (schema: HaFormSchema): string => {
+ const translationKey = this.selector.object?.translation_key;
+
+ if (this.localizeValue && translationKey) {
+ const label = this.localizeValue(
+ `${translationKey}.fields.${schema.name}`
+ );
+ if (label) {
+ return label;
+ }
+ }
+ return this.selector.object?.fields?.[schema.name]?.label || schema.name;
+ };
+
+ private _renderItem(item: any, index: number) {
+ const labelField =
+ this.selector.object!.label_field ||
+ Object.keys(this.selector.object!.fields!)[0];
+
+ const labelSelector = this.selector.object!.fields![labelField].selector;
+
+ const label = labelSelector
+ ? formatSelectorValue(this.hass, item[labelField], labelSelector)
+ : "";
+
+ let description = "";
+
+ const descriptionField = this.selector.object!.description_field;
+ if (descriptionField) {
+ const descriptionSelector =
+ this.selector.object!.fields![descriptionField].selector;
+
+ description = descriptionSelector
+ ? formatSelectorValue(
+ this.hass,
+ item[descriptionField],
+ descriptionSelector
+ )
+ : "";
+ }
+
+ const reorderable = this.selector.object!.multiple || false;
+ const multiple = this.selector.object!.multiple || false;
+ return html`
+
+ ${reorderable
+ ? html`
+
+ `
+ : nothing}
+ ${label}
+ ${description
+ ? html`
+ ${description}
+
`
+ : nothing}
+
+
+
+ `;
+ }
+
protected render() {
+ if (!this.selector.object) {
+ return nothing;
+ }
+
+ if (this.selector.object.fields) {
+ if (this.selector.object.multiple) {
+ const items = ensureArray(this.value ?? []);
+ return html`
+ ${this.label ? html`` : nothing}
+
+
+
+ ${items.map((item, index) => this._renderItem(item, index))}
+
+
+
+ ${this.hass.localize("ui.common.add")}
+
+
+ `;
+ }
+
+ return html`
+ ${this.label ? html`` : nothing}
+
+ ${this.value
+ ? html`
+ ${this._renderItem(this.value, 0)}
+ `
+ : html`
+
+ ${this.hass.localize("ui.common.add")}
+
+ `}
+
+ `;
+ }
+
return html` {
+ if (!selector.object || !selector.object.fields) {
+ return [];
+ }
+ return Object.entries(selector.object.fields).map(([key, field]) => ({
+ name: key,
+ selector: field.selector,
+ required: field.required ?? false,
+ }));
+ });
+
+ private _itemMoved(ev) {
+ ev.stopPropagation();
+ const newIndex = ev.detail.newIndex;
+ const oldIndex = ev.detail.oldIndex;
+ if (!this.selector.object!.multiple) {
+ return;
+ }
+ const newValue = ensureArray(this.value ?? []).concat();
+ const item = newValue.splice(oldIndex, 1)[0];
+ newValue.splice(newIndex, 0, item);
+ fireEvent(this, "value-changed", { value: newValue });
+ }
+
+ private async _addItem(ev) {
+ ev.stopPropagation();
+
+ const newItem = await showFormDialog(this, {
+ title: this.hass.localize("ui.common.add"),
+ schema: this._schema(this.selector),
+ data: {},
+ computeLabel: this._computeLabel,
+ submitText: this.hass.localize("ui.common.add"),
+ });
+
+ if (newItem === null) {
+ return;
+ }
+
+ if (!this.selector.object!.multiple) {
+ fireEvent(this, "value-changed", { value: newItem });
+ return;
+ }
+
+ const newValue = ensureArray(this.value ?? []).concat();
+ newValue.push(newItem);
+ fireEvent(this, "value-changed", { value: newValue });
+ }
+
+ private async _editItem(ev) {
+ ev.stopPropagation();
+ const item = ev.currentTarget.item;
+ const index = ev.currentTarget.index;
+
+ const updatedItem = await showFormDialog(this, {
+ title: this.hass.localize("ui.common.edit"),
+ schema: this._schema(this.selector),
+ data: item,
+ computeLabel: this._computeLabel,
+ submitText: this.hass.localize("ui.common.save"),
+ });
+
+ if (updatedItem === null) {
+ return;
+ }
+
+ if (!this.selector.object!.multiple) {
+ fireEvent(this, "value-changed", { value: updatedItem });
+ return;
+ }
+
+ const newValue = ensureArray(this.value ?? []).concat();
+ newValue[index] = updatedItem;
+ fireEvent(this, "value-changed", { value: newValue });
+ }
+
+ private _deleteItem(ev) {
+ ev.stopPropagation();
+ const index = ev.currentTarget.index;
+
+ if (!this.selector.object!.multiple) {
+ fireEvent(this, "value-changed", { value: undefined });
+ return;
+ }
+
+ const newValue = ensureArray(this.value ?? []).concat();
+ newValue.splice(index, 1);
+ fireEvent(this, "value-changed", { value: newValue });
+ }
+
protected updated(changedProps: PropertyValues) {
super.updated(changedProps);
- if (changedProps.has("value") && !this._valueChangedFromChild) {
+ if (
+ changedProps.has("value") &&
+ !this._valueChangedFromChild &&
+ this._yamlEditor
+ ) {
this._yamlEditor.setValue(this.value);
}
this._valueChangedFromChild = false;
@@ -63,6 +293,42 @@ export class HaObjectSelector extends LitElement {
}
fireEvent(this, "value-changed", { value });
}
+
+ static get styles() {
+ return [
+ css`
+ ha-md-list {
+ gap: 8px;
+ }
+ ha-md-list-item {
+ border: 1px solid var(--divider-color);
+ border-radius: 8px;
+ --ha-md-list-item-gap: 0;
+ --md-list-item-top-space: 0;
+ --md-list-item-bottom-space: 0;
+ --md-list-item-leading-space: 12px;
+ --md-list-item-trailing-space: 4px;
+ --md-list-item-two-line-container-height: 48px;
+ --md-list-item-one-line-container-height: 48px;
+ }
+ .handle {
+ cursor: move;
+ padding: 8px;
+ margin-inline-start: -8px;
+ }
+ label {
+ margin-bottom: 8px;
+ display: block;
+ }
+ ha-md-list-item .label,
+ ha-md-list-item .description {
+ text-overflow: ellipsis;
+ overflow: hidden;
+ white-space: nowrap;
+ }
+ `,
+ ];
+ }
}
declare global {
diff --git a/src/data/selector.ts b/src/data/selector.ts
index 60d2b133fe..c76a458a05 100644
--- a/src/data/selector.ts
+++ b/src/data/selector.ts
@@ -334,8 +334,20 @@ export interface NumberSelector {
} | null;
}
+interface ObjectSelectorField {
+ selector: Selector;
+ label?: string;
+ required?: boolean;
+}
+
export interface ObjectSelector {
- object: {} | null;
+ object?: {
+ label_field?: string;
+ description_field?: string;
+ translation_key?: string;
+ fields?: Record;
+ multiple?: boolean;
+ } | null;
}
export interface AssistPipelineSelector {
diff --git a/src/data/selector/format_selector_value.ts b/src/data/selector/format_selector_value.ts
new file mode 100644
index 0000000000..47950879d3
--- /dev/null
+++ b/src/data/selector/format_selector_value.ts
@@ -0,0 +1,104 @@
+import { ensureArray } from "../../common/array/ensure-array";
+import { computeAreaName } from "../../common/entity/compute_area_name";
+import { computeDeviceName } from "../../common/entity/compute_device_name";
+import { computeEntityName } from "../../common/entity/compute_entity_name";
+import { getEntityContext } from "../../common/entity/context/get_entity_context";
+import { blankBeforeUnit } from "../../common/translations/blank_before_unit";
+import type { HomeAssistant } from "../../types";
+import type { Selector } from "../selector";
+
+export const formatSelectorValue = (
+ hass: HomeAssistant,
+ value: any,
+ selector?: Selector
+) => {
+ if (value == null) {
+ return "";
+ }
+
+ if (!selector) {
+ return ensureArray(value).join(", ");
+ }
+
+ if ("text" in selector) {
+ const { prefix, suffix } = selector.text || {};
+
+ const texts = ensureArray(value);
+ return texts
+ .map((text) => `${prefix || ""}${text}${suffix || ""}`)
+ .join(", ");
+ }
+
+ if ("number" in selector) {
+ const { unit_of_measurement } = selector.number || {};
+ const numbers = ensureArray(value);
+ return numbers
+ .map((number) => {
+ const num = Number(number);
+ if (isNaN(num)) {
+ return number;
+ }
+ return unit_of_measurement
+ ? `${num}${blankBeforeUnit(unit_of_measurement, hass.locale)}${unit_of_measurement}`
+ : num.toString();
+ })
+ .join(", ");
+ }
+
+ if ("floor" in selector) {
+ const floors = ensureArray(value);
+ return floors
+ .map((floorId) => {
+ const floor = hass.floors[floorId];
+ if (!floor) {
+ return floorId;
+ }
+ return floor.name || floorId;
+ })
+ .join(", ");
+ }
+
+ if ("area" in selector) {
+ const areas = ensureArray(value);
+ return areas
+ .map((areaId) => {
+ const area = hass.areas[areaId];
+ if (!area) {
+ return areaId;
+ }
+ return computeAreaName(area);
+ })
+ .join(", ");
+ }
+
+ if ("entity" in selector) {
+ const entities = ensureArray(value);
+ return entities
+ .map((entityId) => {
+ const stateObj = hass.states[entityId];
+ if (!stateObj) {
+ return entityId;
+ }
+ const { device } = getEntityContext(stateObj, hass);
+ const deviceName = device ? computeDeviceName(device) : undefined;
+ const entityName = computeEntityName(stateObj, hass);
+ return [deviceName, entityName].filter(Boolean).join(" ") || entityId;
+ })
+ .join(", ");
+ }
+
+ if ("device" in selector) {
+ const devices = ensureArray(value);
+ return devices
+ .map((deviceId) => {
+ const device = hass.devices[deviceId];
+ if (!device) {
+ return deviceId;
+ }
+ return device.name || deviceId;
+ })
+ .join(", ");
+ }
+
+ return ensureArray(value).join(", ");
+};
diff --git a/src/dialogs/form/dialog-form.ts b/src/dialogs/form/dialog-form.ts
new file mode 100644
index 0000000000..af755a2224
--- /dev/null
+++ b/src/dialogs/form/dialog-form.ts
@@ -0,0 +1,89 @@
+import { css, html, LitElement, nothing } from "lit";
+import { customElement, property, state } from "lit/decorators";
+import { fireEvent } from "../../common/dom/fire_event";
+import "../../components/ha-button";
+import { createCloseHeading } from "../../components/ha-dialog";
+import "../../components/ha-form/ha-form";
+import type { HomeAssistant } from "../../types";
+import type { HassDialog } from "../make-dialog-manager";
+import type { FormDialogData, FormDialogParams } from "./show-form-dialog";
+import { haStyleDialog } from "../../resources/styles";
+
+@customElement("dialog-form")
+export class DialogForm
+ extends LitElement
+ implements HassDialog
+{
+ @property({ attribute: false }) public hass?: HomeAssistant;
+
+ @state() private _params?: FormDialogParams;
+
+ @state() private _data: FormDialogData = {};
+
+ public async showDialog(params: FormDialogParams): Promise {
+ this._params = params;
+ this._data = params.data || {};
+ }
+
+ public closeDialog() {
+ this._params = undefined;
+ this._data = {};
+ fireEvent(this, "dialog-closed", { dialog: this.localName });
+ return true;
+ }
+
+ private _submit(): void {
+ this._params?.submit?.(this._data);
+ this.closeDialog();
+ }
+
+ private _cancel(): void {
+ this._params?.cancel?.();
+ this.closeDialog();
+ }
+
+ private _valueChanged(ev: CustomEvent): void {
+ this._data = ev.detail.value;
+ }
+
+ protected render() {
+ if (!this._params || !this.hass) {
+ return nothing;
+ }
+
+ return html`
+
+
+
+
+ ${this._params.cancelText || this.hass.localize("ui.common.cancel")}
+
+
+ ${this._params.submitText || this.hass.localize("ui.common.save")}
+
+
+ `;
+ }
+
+ static styles = [haStyleDialog, css``];
+}
+
+declare global {
+ interface HTMLElementTagNameMap {
+ "dialog-form": DialogForm;
+ }
+}
diff --git a/src/dialogs/form/show-form-dialog.ts b/src/dialogs/form/show-form-dialog.ts
new file mode 100644
index 0000000000..3cd49fd6ce
--- /dev/null
+++ b/src/dialogs/form/show-form-dialog.ts
@@ -0,0 +1,45 @@
+import { fireEvent } from "../../common/dom/fire_event";
+import type { HaFormSchema } from "../../components/ha-form/types";
+
+export type FormDialogData = Record;
+
+export interface FormDialogParams {
+ title: string;
+ schema: HaFormSchema[];
+ data?: FormDialogData;
+ submit?: (data?: FormDialogData) => void;
+ cancel?: () => void;
+ computeLabel?: (schema, data) => string | undefined;
+ computeHelper?: (schema) => string | undefined;
+ submitText?: string;
+ cancelText?: string;
+}
+
+export const showFormDialog = (
+ element: HTMLElement,
+ dialogParams: FormDialogParams
+) =>
+ new Promise((resolve) => {
+ const origCancel = dialogParams.cancel;
+ const origSubmit = dialogParams.submit;
+
+ fireEvent(element, "show-dialog", {
+ dialogTag: "dialog-form",
+ dialogImport: () => import("./dialog-form"),
+ dialogParams: {
+ ...dialogParams,
+ cancel: () => {
+ resolve(null);
+ if (origCancel) {
+ origCancel();
+ }
+ },
+ submit: (data: FormDialogData) => {
+ resolve(data);
+ if (origSubmit) {
+ origSubmit(data);
+ }
+ },
+ },
+ });
+ });