Add entity name alias toggle and drag-to-reorder aliases in voice settings (#30201)

* Add entity name alias toggle and drag-to-reorder aliases in voice settings

* Fix reorder

* Update src/panels/config/voice-assistants/entity-voice-settings.ts

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>

* Use map instead of repeat

* Improve key

---------

Co-authored-by: Petar Petrov <MindFreeze@users.noreply.github.com>
This commit is contained in:
Paul Bottein
2026-03-18 16:42:54 +01:00
committed by GitHub
parent cfd0e72609
commit ddec792ae3
5 changed files with 137 additions and 55 deletions

View File

@@ -12,6 +12,8 @@ class AliasesEditor extends LitElement {
@property({ type: Boolean }) public disabled = false;
@property({ type: Boolean }) public sortable = false;
protected render() {
if (!this.aliases) {
return nothing;
@@ -22,6 +24,8 @@ class AliasesEditor extends LitElement {
.hass=${this.hass}
.value=${this.aliases}
.disabled=${this.disabled}
.sortable=${this.sortable}
update-on-blur
.label=${this.hass!.localize("ui.dialogs.aliases.label")}
.removeLabel=${this.hass!.localize("ui.dialogs.aliases.remove")}
.addLabel=${this.hass!.localize("ui.dialogs.aliases.add")}
@@ -32,8 +36,9 @@ class AliasesEditor extends LitElement {
`;
}
private _aliasesChanged(value) {
fireEvent(this, "value-changed", { value });
private _aliasesChanged(ev: CustomEvent) {
ev.stopPropagation();
fireEvent(this, "value-changed", { value: ev.detail.value });
}
}

View File

@@ -1,13 +1,15 @@
import { mdiDeleteOutline, mdiPlus } from "@mdi/js";
import { mdiDeleteOutline, mdiDragHorizontalVariant, mdiPlus } from "@mdi/js";
import type { CSSResultGroup } from "lit";
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 { haStyle } from "../resources/styles";
import type { HomeAssistant } from "../types";
import "./ha-button";
import "./ha-icon-button";
import "./ha-input-helper-text";
import "./ha-sortable";
import "./ha-textfield";
import type { HaTextField } from "./ha-textfield";
@@ -40,40 +42,65 @@ class HaMultiTextField extends LitElement {
@property({ type: Number }) public max?: number;
@property({ type: Boolean }) public sortable = false;
@property({ type: Boolean, attribute: "update-on-blur" })
public updateOnBlur = false;
protected render() {
return html`
${this._items.map((item, index) => {
const indexSuffix = `${this.itemIndex ? ` ${index + 1}` : ""}`;
return html`
<div class="layout horizontal center-center row">
<ha-textfield
.suffix=${this.inputSuffix}
.prefix=${this.inputPrefix}
.type=${this.inputType}
.autocomplete=${this.autocomplete}
.disabled=${this.disabled}
dialogInitialFocus=${index}
.index=${index}
class="flex-auto"
.label=${`${this.label ? `${this.label}${indexSuffix}` : ""}`}
.value=${item}
?data-last=${index === this._items.length - 1}
@input=${this._editItem}
@keydown=${this._keyDown}
></ha-textfield>
<ha-icon-button
.disabled=${this.disabled}
.index=${index}
slot="navigationIcon"
.label=${this.removeLabel ??
this.hass?.localize("ui.common.remove") ??
"Remove"}
@click=${this._removeItem}
.path=${mdiDeleteOutline}
></ha-icon-button>
</div>
`;
})}
<ha-sortable
handle-selector=".handle"
draggable-selector=".row"
.disabled=${!this.sortable || this.disabled}
@item-moved=${this._itemMoved}
>
<div class="items">
${repeat(
this._items,
(item, index) => `${item}-${index}`,
(item, index) => {
const indexSuffix = `${this.itemIndex ? ` ${index + 1}` : ""}`;
return html`
<div class="layout horizontal center-center row">
<ha-textfield
.suffix=${this.inputSuffix}
.prefix=${this.inputPrefix}
.type=${this.inputType}
.autocomplete=${this.autocomplete}
.disabled=${this.disabled}
dialogInitialFocus=${index}
.index=${index}
class="flex-auto"
.label=${`${this.label ? `${this.label}${indexSuffix}` : ""}`}
.value=${item}
?data-last=${index === this._items.length - 1}
@input=${this._editItem}
@change=${this._editItem}
@keydown=${this._keyDown}
></ha-textfield>
<ha-icon-button
.disabled=${this.disabled}
.index=${index}
slot="navigationIcon"
.label=${this.removeLabel ??
this.hass?.localize("ui.common.remove") ??
"Remove"}
@click=${this._removeItem}
.path=${mdiDeleteOutline}
></ha-icon-button>
${this.sortable
? html`<ha-svg-icon
class="handle"
.path=${mdiDragHorizontalVariant}
></ha-svg-icon>`
: nothing}
</div>
`;
}
)}
</div>
</ha-sortable>
<div class="layout horizontal">
<ha-button
size="small"
@@ -118,6 +145,12 @@ class HaMultiTextField extends LitElement {
}
private async _editItem(ev: Event) {
if (this.updateOnBlur && ev.type === "input") {
return;
}
if (!this.updateOnBlur && ev.type === "change") {
return;
}
const index = (ev.target as any).index;
const items = [...this._items];
items[index] = (ev.target as any).value;
@@ -131,6 +164,15 @@ class HaMultiTextField extends LitElement {
}
}
private _itemMoved(ev: CustomEvent): void {
ev.stopPropagation();
const { oldIndex, newIndex } = ev.detail;
const items = [...this._items];
const [moved] = items.splice(oldIndex, 1);
items.splice(newIndex, 0, moved);
this._fireChanged(items);
}
private async _removeItem(ev: Event) {
const index = (ev.target as any).index;
const items = [...this._items];
@@ -156,6 +198,11 @@ class HaMultiTextField extends LitElement {
ha-icon-button {
display: block;
}
.handle {
cursor: grab;
padding: 8px;
margin: -8px;
}
`,
];
}

View File

@@ -900,7 +900,6 @@ export class EntityRegistrySettingsEditor extends LitElement {
${this.entry.aliases.filter((a) => a !== null).length
? this.entry.aliases
.filter((a): a is string => a !== null)
.sort((a, b) => stringCompare(a, b, this.hass.locale.language))
.join(", ")
: this.hass.localize(
"ui.dialogs.entity_registry.editor.no_aliases"

View File

@@ -5,6 +5,7 @@ import { customElement, property, state } from "lit/decorators";
import memoizeOne from "memoize-one";
import { isComponentLoaded } from "../../../common/config/is_component_loaded";
import { fireEvent } from "../../../common/dom/fire_event";
import { computeStateName } from "../../../common/entity/compute_state_name";
import type {
EntityDomainFilter,
EntityDomainFilterFunc,
@@ -287,27 +288,55 @@ export class EntityVoiceSettings extends SubscribeMixin(LitElement) {
}
)}
</ha-alert>`
: html`<ha-aliases-editor
.hass=${this.hass}
.aliases=${(this._aliases ?? this.entry.aliases).filter(
(a): a is string => a !== null
)}
@value-changed=${this._aliasesChanged}
@blur=${this._saveAliases}
></ha-aliases-editor>`}
: html`
<ha-md-list-item>
<span slot="headline">
${this.hass.states[this.entityId]
? computeStateName(this.hass.states[this.entityId])
: this.entityId}
</span>
<span slot="supporting-text">
${this.hass.localize(
"ui.dialogs.voice-settings.entity_name_alias_description"
)}
</span>
<ha-switch
slot="end"
.checked=${(this._aliases ?? this.entry.aliases).includes(null)}
@change=${this._toggleEntityNameAlias}
></ha-switch>
</ha-md-list-item>
<ha-aliases-editor
.hass=${this.hass}
.aliases=${(this._aliases ?? this.entry.aliases).filter(
(a): a is string => a !== null
)}
sortable
@value-changed=${this._aliasesChanged}
></ha-aliases-editor>
`}
`;
}
private _aliasesChanged(ev) {
const currentLength =
this._aliases?.length ?? this.entry?.aliases?.length ?? 0;
this._aliases = ev.detail.value;
// if an entry was deleted, then save changes
if (currentLength > ev.detail.value.length) {
this._saveAliases();
private async _toggleEntityNameAlias(ev) {
const enabled = ev.target.checked;
const currentAliases = this._aliases ?? this.entry?.aliases ?? [];
if (enabled) {
this._aliases = [null, ...currentAliases.filter((a) => a !== null)];
} else {
this._aliases = currentAliases.filter((a): a is string => a !== null);
}
await this._saveAliases();
}
private _aliasesChanged(ev) {
const currentAliases = this._aliases ?? this.entry?.aliases ?? [];
const hasNull = currentAliases.includes(null);
const nullAliases: (string | null)[] = hasNull ? [null] : [];
const newStringAliases: string[] = ev.detail.value;
this._aliases = [...nullAliases, ...newStringAliases];
this._saveAliases();
}
private async _2faChanged(ev) {
@@ -326,7 +355,8 @@ export class EntityVoiceSettings extends SubscribeMixin(LitElement) {
if (!this._aliases) {
return;
}
const nullAliases = (this.entry?.aliases ?? []).filter((a) => a === null);
const hasNull = this._aliases.includes(null);
const nullAliases: null[] = hasNull ? [null] : [];
const stringAliases = this._aliases
.filter((a): a is string => a !== null)
.map((alias) => alias.trim())

View File

@@ -1885,8 +1885,9 @@
"voice-settings": {
"expose_header": "Expose",
"aliases_header": "Aliases",
"aliases_description": "Aliases are supported by Assist and Google Assistant.",
"aliases_description": "Aliases are alternative names to call your entity. Only supported by Assist and Google Assistant.",
"aliases_no_unique_id": "Aliases are not supported for entities without a unique ID. See the {faq_link} for more detail.",
"entity_name_alias_description": "Default name. Disable it if you want your voice assistants to ignore it and just use aliases.",
"ask_pin": "Ask for PIN",
"manual_config": "Managed in configuration.yaml",
"unsupported": "Unsupported",