From 640558ad35d034d50124d43dabb564cb7704bd07 Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Thu, 26 Mar 2026 17:05:05 +0100 Subject: [PATCH] Add composed/text mode toggle to entity name picker (#30337) --- .../entity/ha-entity-name-picker.ts | 537 +++++++++++------- src/translations/en.json | 4 +- 2 files changed, 324 insertions(+), 217 deletions(-) diff --git a/src/components/entity/ha-entity-name-picker.ts b/src/components/entity/ha-entity-name-picker.ts index fc35dfaeef..e56e07ef4b 100644 --- a/src/components/entity/ha-entity-name-picker.ts +++ b/src/components/entity/ha-entity-name-picker.ts @@ -1,7 +1,8 @@ -import { mdiDragHorizontalVariant, mdiPlus } from "@mdi/js"; import type { RenderItemFunction } from "@lit-labs/virtualizer/virtualize"; +import { mdiDragHorizontalVariant, mdiPlus } from "@mdi/js"; +import type { PropertyValues } from "lit"; import { css, html, LitElement, nothing } from "lit"; -import { customElement, property, query } from "lit/decorators"; +import { customElement, property, query, state } from "lit/decorators"; import { repeat } from "lit/directives/repeat"; import memoizeOne from "memoize-one"; import { ensureArray } from "../../common/array/ensure-array"; @@ -17,12 +18,14 @@ import type { HomeAssistant, ValueChangedEvent } from "../../types"; import "../chips/ha-assist-chip"; import "../chips/ha-chip-set"; import "../chips/ha-input-chip"; +import "../ha-button-toggle-group"; import "../ha-combo-box-item"; import "../ha-generic-picker"; import type { HaGenericPicker } from "../ha-generic-picker"; import "../ha-input-helper-text"; import type { PickerComboBoxItem } from "../ha-picker-combo-box"; import "../ha-sortable"; +import "../input/ha-input"; const rowRenderer: RenderItemFunction = (item) => html` @@ -73,10 +76,291 @@ export class HaEntityNamePicker extends LitElement { @property({ type: Boolean, reflect: true }) public disabled = false; - @query("ha-generic-picker", true) private _picker?: HaGenericPicker; + @state() private _mode?: "composed" | "custom"; + + @query("ha-generic-picker") private _picker?: HaGenericPicker; private _editIndex?: number; + connectedCallback(): void { + super.connectedCallback(); + if (this.hasUpdated) { + const items = this._toItems(this.value); + this._mode = + items.length === 1 && items[0].type === "text" ? "custom" : "composed"; + } + } + + protected willUpdate(_changedProperties: PropertyValues): void { + if (this._mode === undefined) { + const items = this._toItems(this.value); + this._mode = + items.length === 1 && items[0].type === "text" ? "custom" : "composed"; + } + } + + protected render() { + const modeButtons = [ + { + label: this.hass.localize( + "ui.components.entity.entity-name-picker.mode_composed" + ), + value: "composed", + }, + { + label: this.hass.localize( + "ui.components.entity.entity-name-picker.mode_custom" + ), + value: "custom", + }, + ]; + + return html` +
+
+ ${this.label ? html`` : nothing} + +
+
+ ${this._mode === "custom" + ? this._renderTextInput() + : this._renderPicker()} +
+
+ ${this.helper + ? html` + + ${this.helper} + + ` + : nothing} + `; + } + + private _renderTextInput() { + const items = this._items; + const value = + items.length === 1 && items[0].type === "text" ? items[0].text || "" : ""; + return html` + + `; + } + + private _renderPicker() { + const value = this._items; + const validTypes = this._validTypes(this.entityId); + + return html` + +
+ + + ${repeat( + this._items, + (item) => item, + (item: EntityNameItem, idx) => { + const label = this._formatItem(item); + const isValid = validTypes.has(item.type); + return html` + + + ${label} + + `; + } + )} + ${this.disabled + ? nothing + : html` + + + + `} + + +
+
+ `; + } + + private _modeChanged(ev: CustomEvent) { + ev.stopPropagation(); + this._mode = ev.detail.value as "composed" | "custom"; + } + + private _textInputChanged(ev: Event) { + const value = (ev.target as HTMLInputElement).value; + const newValue: EntityNameItem[] = value + ? [{ type: "text", text: value }] + : []; + this._setValue(newValue); + } + + private async _addItem(ev: Event) { + ev.stopPropagation(); + this._editIndex = undefined; + await this.updateComplete; + await this._picker?.open(); + } + + private async _editItem(ev: Event) { + ev.stopPropagation(); + const idx = parseInt( + (ev.currentTarget as HTMLElement).dataset.idx || "", + 10 + ); + this._editIndex = idx; + await this.updateComplete; + await this._picker?.open(); + const value = this._items[idx]; + // Pre-fill the field value when editing a text item + if (value.type === "text" && value.text) { + this._picker?.setFieldValue(value.text); + } + } + + private async _moveItem(ev: CustomEvent) { + ev.stopPropagation(); + const { oldIndex, newIndex } = ev.detail; + const value = this._items; + const newValue = value.concat(); + const element = newValue.splice(oldIndex, 1)[0]; + newValue.splice(newIndex, 0, element); + this._setValue(newValue); + } + + private async _removeItem(ev: Event) { + ev.stopPropagation(); + const value = [...this._items]; + const idx = parseInt((ev.target as HTMLElement).dataset.idx || "", 10); + value.splice(idx, 1); + this._setValue(value); + } + + private _pickerValueChanged(ev: ValueChangedEvent): void { + ev.stopPropagation(); + const value = ev.detail.value; + + if (this.disabled || !value) { + return; + } + + const item: EntityNameItem = parseOptionValue(value); + + const newValue = [...this._items]; + + if (this._editIndex != null) { + newValue[this._editIndex] = item; + this._editIndex = undefined; + } else { + newValue.push(item); + } + + this._setValue(newValue); + + if (this._picker) { + this._picker.value = undefined; + } + } + + private _setValue(value: EntityNameItem[]) { + const newValue = this._toValue(value); + this.value = newValue; + fireEvent(this, "value-changed", { + value: newValue, + }); + } + + private get _items(): EntityNameItem[] { + return this._toItems(this.value); + } + + private _toItems = memoizeOne((value?: typeof this.value) => { + if (typeof value === "string") { + if (value === "") { + return []; + } + return [{ type: "text", text: value } satisfies EntityNameItem]; + } + return value ? ensureArray(value) : [...DEFAULT_ENTITY_NAME]; + }); + + private _toValue = memoizeOne( + (items: EntityNameItem[]): typeof this.value => { + if (items.length === 0) { + return ""; + } + if (items.length === 1) { + const item = items[0]; + return item.type === "text" ? item.text : item; + } + return items; + } + ); + + private _formatItem = (item: EntityNameItem) => { + if (item.type === "text") { + return `"${item.text}"`; + } + if (KNOWN_TYPES.has(item.type)) { + return this.hass.localize( + `ui.components.entity.entity-name-picker.types.${item.type as EntityNameType}` + ); + } + return item.type; + }; + private _validTypes = memoizeOne((entityId?: string) => { const options = new Set(["text"]); if (!entityId) { @@ -161,157 +445,6 @@ export class HaEntityNamePicker extends LitElement { }) ); - private _formatItem = (item: EntityNameItem) => { - if (item.type === "text") { - return `"${item.text}"`; - } - if (KNOWN_TYPES.has(item.type)) { - return this.hass.localize( - `ui.components.entity.entity-name-picker.types.${item.type as EntityNameType}` - ); - } - return item.type; - }; - - protected render() { - const value = this._items; - const validTypes = this._validTypes(this.entityId); - - return html` - ${this.label ? html`` : nothing} - -
- - - ${repeat( - this._items, - (item) => item, - (item: EntityNameItem, idx) => { - const label = this._formatItem(item); - const isValid = validTypes.has(item.type); - return html` - - - ${label} - - `; - } - )} - ${this.disabled - ? nothing - : html` - - - - `} - - -
-
- ${this._renderHelper()} - `; - } - - private _renderHelper() { - return this.helper - ? html` - - ${this.helper} - - ` - : nothing; - } - - private async _addItem(ev: Event) { - ev.stopPropagation(); - this._editIndex = undefined; - await this.updateComplete; - await this._picker?.open(); - } - - private async _editItem(ev: Event) { - ev.stopPropagation(); - const idx = parseInt( - (ev.currentTarget as HTMLElement).dataset.idx || "", - 10 - ); - this._editIndex = idx; - await this.updateComplete; - await this._picker?.open(); - const value = this._items[idx]; - // Pre-fill the field value when editing a text item - if (value.type === "text" && value.text) { - this._picker?.setFieldValue(value.text); - } - } - - private get _items(): EntityNameItem[] { - return this._toItems(this.value); - } - - private _toItems = memoizeOne((value?: typeof this.value) => { - if (typeof value === "string") { - if (value === "") { - return []; - } - return [{ type: "text", text: value } satisfies EntityNameItem]; - } - return value ? ensureArray(value) : [...DEFAULT_ENTITY_NAME]; - }); - - private _toValue = memoizeOne( - (items: EntityNameItem[]): typeof this.value => { - if (items.length === 0) { - return ""; - } - if (items.length === 1) { - const item = items[0]; - return item.type === "text" ? item.text : item; - } - return items; - } - ); - private _getPickerValue(): string | undefined { if (this._editIndex != null) { const item = this._items[this._editIndex]; @@ -362,58 +495,6 @@ export class HaEntityNamePicker extends LitElement { return filteredItems; }; - private async _moveItem(ev: CustomEvent) { - ev.stopPropagation(); - const { oldIndex, newIndex } = ev.detail; - const value = this._items; - const newValue = value.concat(); - const element = newValue.splice(oldIndex, 1)[0]; - newValue.splice(newIndex, 0, element); - this._setValue(newValue); - } - - private async _removeItem(ev: Event) { - ev.stopPropagation(); - const value = [...this._items]; - const idx = parseInt((ev.target as HTMLElement).dataset.idx || "", 10); - value.splice(idx, 1); - this._setValue(value); - } - - private _pickerValueChanged(ev: ValueChangedEvent): void { - ev.stopPropagation(); - const value = ev.detail.value; - - if (this.disabled || !value) { - return; - } - - const item: EntityNameItem = parseOptionValue(value); - - const newValue = [...this._items]; - - if (this._editIndex != null) { - newValue[this._editIndex] = item; - this._editIndex = undefined; - } else { - newValue.push(item); - } - - this._setValue(newValue); - - if (this._picker) { - this._picker.value = undefined; - } - } - - private _setValue(value: EntityNameItem[]) { - const newValue = this._toValue(value); - this.value = newValue; - fireEvent(this, "value-changed", { - value: newValue, - }); - } - static styles = css` :host { position: relative; @@ -421,13 +502,42 @@ export class HaEntityNamePicker extends LitElement { } .container { + --ha-input-padding-bottom: 0; + display: flex; + flex-direction: column; + gap: var(--ha-space-2); + } + + .header { + display: flex; + justify-content: space-between; + align-items: center; + } + + label { + display: block; + font-weight: 500; + } + + .content { + display: flex; + gap: var(--ha-space-2); + align-items: flex-end; + } + + ha-generic-picker, + ha-input { + width: 100%; + } + + .field { position: relative; background-color: var(--mdc-text-field-fill-color, whitesmoke); border-radius: var(--ha-border-radius-sm); border-end-end-radius: var(--ha-border-radius-square); border-end-start-radius: var(--ha-border-radius-square); } - .container:after { + .field:after { display: block; content: ""; position: absolute; @@ -445,30 +555,25 @@ export class HaEntityNamePicker extends LitElement { height 180ms ease-in-out, background-color 180ms ease-in-out; } - :host([disabled]) .container:after { + :host([disabled]) .field:after { background-color: var( --mdc-text-field-disabled-line-color, rgba(0, 0, 0, 0.42) ); } - .container:focus-within:after { + .field:focus-within:after { height: 2px; background-color: var(--mdc-theme-primary); } - label { - display: block; - margin: 0 0 var(--ha-space-2); + ha-chip-set { + padding: var(--ha-space-3); } .add { order: 1; } - ha-chip-set { - padding: var(--ha-space-2) var(--ha-space-2); - } - .invalid { text-decoration: line-through; } diff --git a/src/translations/en.json b/src/translations/en.json index 9f7fd42daa..11bf098099 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -724,8 +724,10 @@ "floor_missing": "No floor assigned", "device_missing": "No related device" }, + "mode_composed": "Composed", + "mode_custom": "Custom", "add": "Add", - "search": "Search or enter custom name", + "search": "Search name", "custom_name": "Custom name" }, "entity-attribute-picker": {