Improve user picker UI and search (#25514)

Improve user picker
pull/25516/head
Paul Bottein 2025-05-19 12:53:19 +02:00 committed by GitHub
parent 5cbadaa5f9
commit 2a4c6c9af5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 149 additions and 116 deletions

View File

@ -1,21 +1,30 @@
import type { ComboBoxLitRenderer } from "@vaadin/combo-box/lit";
import type { TemplateResult } from "lit"; import type { TemplateResult } from "lit";
import { css, html, LitElement } from "lit"; import { html, LitElement, nothing } from "lit";
import { property } from "lit/decorators"; import { customElement, property } from "lit/decorators";
import memoizeOne from "memoize-one"; import memoizeOne from "memoize-one";
import { fireEvent } from "../../common/dom/fire_event"; import { fireEvent } from "../../common/dom/fire_event";
import { stringCompare } from "../../common/string/compare";
import type { User } from "../../data/user"; import type { User } from "../../data/user";
import { fetchUsers } from "../../data/user"; import { fetchUsers } from "../../data/user";
import type { HomeAssistant } from "../../types"; import type { HomeAssistant } from "../../types";
import "../ha-select"; import "../ha-combo-box-item";
import "../ha-generic-picker";
import type { PickerComboBoxItem } from "../ha-picker-combo-box";
import type { PickerValueRenderer } from "../ha-picker-field";
import "./ha-user-badge"; import "./ha-user-badge";
import "../ha-list-item";
interface UserComboBoxItem extends PickerComboBoxItem {
user?: User;
}
@customElement("ha-user-picker")
class HaUserPicker extends LitElement { class HaUserPicker extends LitElement {
public hass?: HomeAssistant; @property({ attribute: false }) public hass!: HomeAssistant;
@property() public label?: string; @property() public label?: string;
@property() public placeholder?: string;
@property({ attribute: false }) public noUserLabel?: string; @property({ attribute: false }) public noUserLabel?: string;
@property() public value = ""; @property() public value = "";
@ -24,78 +33,124 @@ class HaUserPicker extends LitElement {
@property({ type: Boolean }) public disabled = false; @property({ type: Boolean }) public disabled = false;
private _sortedUsers = memoizeOne((users?: User[]) => { protected firstUpdated(changedProps) {
super.firstUpdated(changedProps);
if (!this.users) {
this._fetchUsers();
}
}
private async _fetchUsers() {
this.users = await fetchUsers(this.hass);
}
private usersMap = memoizeOne((users?: User[]): Map<string, User> => {
if (!users) {
return new Map();
}
return new Map(users.map((user) => [user.id, user]));
});
private _valueRenderer: PickerValueRenderer = (value) => {
const user = this.usersMap(this.users).get(value);
if (!user) {
return html` <span slot="headline">${value}</span> `;
}
return html`
<ha-user-badge
slot="start"
.hass=${this.hass}
.user=${user}
></ha-user-badge>
<span slot="headline">${user.name}</span>
`;
};
private _rowRenderer: ComboBoxLitRenderer<UserComboBoxItem> = (item) => {
const user = item.user;
if (!user) {
return html`<ha-combo-box-item type="button" compact>
${item.icon
? html`<ha-icon slot="start" .icon=${item.icon}></ha-icon>`
: item.icon_path
? html`<ha-svg-icon
slot="start"
.path=${item.icon_path}
></ha-svg-icon>`
: nothing}
<span slot="headline">${item.primary}</span>
${item.secondary
? html`<span slot="supporting-text">${item.secondary}</span>`
: nothing}
</ha-combo-box-item>`;
}
return html`
<ha-combo-box-item type="button" compact>
<ha-user-badge
slot="start"
.hass=${this.hass}
.user=${item.user}
></ha-user-badge>
<span slot="headline">${item.primary}</span>
</ha-combo-box-item>
`;
};
private _getUsers = memoizeOne((users?: User[]) => {
if (!users) { if (!users) {
return []; return [];
} }
return users return users
.filter((user) => !user.system_generated) .filter((user) => !user.system_generated)
.sort((a, b) => .map<UserComboBoxItem>((user) => ({
stringCompare(a.name, b.name, this.hass!.locale.language) id: user.id,
); primary: user.name,
domain_name: user.name,
search_labels: [user.name, user.id, user.username].filter(
Boolean
) as string[],
sorting_label: user.name,
user,
}));
}); });
private _getItems = () => this._getUsers(this.users);
protected render(): TemplateResult { protected render(): TemplateResult {
const placeholder =
this.placeholder ?? this.hass.localize("ui.components.user-picker.user");
return html` return html`
<ha-select <ha-generic-picker
.label=${this.label}
.disabled=${this.disabled}
.value=${this.value}
@selected=${this._userChanged}
>
${this.users?.length === 0
? html`<ha-list-item value="">
${this.noUserLabel ||
this.hass?.localize("ui.components.user-picker.no_user")}
</ha-list-item>`
: ""}
${this._sortedUsers(this.users).map(
(user) => html`
<ha-list-item graphic="avatar" .value=${user.id}>
<ha-user-badge
.hass=${this.hass} .hass=${this.hass}
.user=${user} .autofocus=${this.autofocus}
slot="graphic" .label=${this.label}
></ha-user-badge> .notFoundLabel=${this.hass.localize(
${user.name} "ui.components.user-picker.no_match"
</ha-list-item>
`
)} )}
</ha-select> .placeholder=${placeholder}
.value=${this.value}
.getItems=${this._getItems}
.valueRenderer=${this._valueRenderer}
.rowRenderer=${this._rowRenderer}
@value-changed=${this._valueChanged}
>
</ha-generic-picker>
`; `;
} }
protected firstUpdated(changedProps) { private _valueChanged(ev) {
super.firstUpdated(changedProps); const value = ev.detail.value;
if (this.users === undefined) {
fetchUsers(this.hass!).then((users) => {
this.users = users;
});
}
}
private _userChanged(ev) { this.value = value;
const newValue = ev.target.value; fireEvent(this, "value-changed", { value });
if (newValue !== this.value) {
this.value = newValue;
setTimeout(() => {
fireEvent(this, "value-changed", { value: newValue });
fireEvent(this, "change"); fireEvent(this, "change");
}, 0);
} }
} }
static styles = css`
:host {
display: inline-block;
}
`;
}
customElements.define("ha-user-picker", HaUserPicker);
declare global { declare global {
interface HTMLElementTagNameMap { interface HTMLElementTagNameMap {
"ha-user-picker": HaUserPicker; "ha-user-picker": HaUserPicker;

View File

@ -1,4 +1,3 @@
import { mdiClose } from "@mdi/js";
import { css, html, LitElement, nothing } from "lit"; import { css, html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators"; import { customElement, property } from "lit/decorators";
import { guard } from "lit/directives/guard"; import { guard } from "lit/directives/guard";
@ -6,13 +5,15 @@ import memoizeOne from "memoize-one";
import { fireEvent } from "../../common/dom/fire_event"; import { fireEvent } from "../../common/dom/fire_event";
import type { User } from "../../data/user"; import type { User } from "../../data/user";
import { fetchUsers } from "../../data/user"; import { fetchUsers } from "../../data/user";
import type { ValueChangedEvent, HomeAssistant } from "../../types"; import type { HomeAssistant, ValueChangedEvent } from "../../types";
import "../ha-icon-button"; import "../ha-icon-button";
import "./ha-user-picker"; import "./ha-user-picker";
@customElement("ha-users-picker") @customElement("ha-users-picker")
class HaUsersPickerLight extends LitElement { class HaUsersPicker extends LitElement {
@property({ attribute: false }) public hass?: HomeAssistant; @property({ attribute: false }) public hass!: HomeAssistant;
@property() public label?: string;
@property({ attribute: false }) public value?: string[]; @property({ attribute: false }) public value?: string[];
@ -29,13 +30,15 @@ class HaUsersPickerLight extends LitElement {
protected firstUpdated(changedProps) { protected firstUpdated(changedProps) {
super.firstUpdated(changedProps); super.firstUpdated(changedProps);
if (this.users === undefined) { if (!this.users) {
fetchUsers(this.hass!).then((users) => { this._fetchUsers();
this.users = users;
});
} }
} }
private async _fetchUsers() {
this.users = await fetchUsers(this.hass);
}
protected render() { protected render() {
if (!this.hass || !this.users) { if (!this.hass || !this.users) {
return nothing; return nothing;
@ -43,15 +46,13 @@ class HaUsersPickerLight extends LitElement {
const notSelectedUsers = this._notSelectedUsers(this.users, this.value); const notSelectedUsers = this._notSelectedUsers(this.users, this.value);
return html` return html`
${this.label ? html`<label>${this.label}</label>` : nothing}
${guard([notSelectedUsers], () => ${guard([notSelectedUsers], () =>
this.value?.map( this.value?.map(
(user_id, idx) => html` (user_id, idx) => html`
<div> <div>
<ha-user-picker <ha-user-picker
.label=${this.pickedUserLabel} .placeholder=${this.pickedUserLabel}
.noUserLabel=${this.hass!.localize(
"ui.components.user-picker.remove_user"
)}
.index=${idx} .index=${idx}
.hass=${this.hass} .hass=${this.hass}
.value=${user_id} .value=${user_id}
@ -63,28 +64,20 @@ class HaUsersPickerLight extends LitElement {
.disabled=${this.disabled} .disabled=${this.disabled}
@value-changed=${this._userChanged} @value-changed=${this._userChanged}
></ha-user-picker> ></ha-user-picker>
<ha-icon-button
.userId=${user_id}
.label=${this.hass!.localize(
"ui.components.user-picker.remove_user"
)}
.path=${mdiClose}
@click=${this._removeUser}
>
></ha-icon-button
>
</div> </div>
` `
) )
)} )}
<div>
<ha-user-picker <ha-user-picker
.label=${this.pickUserLabel || .placeholder=${this.pickUserLabel ||
this.hass!.localize("ui.components.user-picker.add_user")} this.hass!.localize("ui.components.user-picker.add_user")}
.hass=${this.hass} .hass=${this.hass}
.users=${notSelectedUsers} .users=${notSelectedUsers}
.disabled=${this.disabled || !notSelectedUsers?.length} .disabled=${this.disabled || !notSelectedUsers?.length}
@value-changed=${this._addUser} @value-changed=${this._addUser}
></ha-user-picker> ></ha-user-picker>
</div>
`; `;
} }
@ -120,12 +113,12 @@ class HaUsersPickerLight extends LitElement {
}); });
} }
private _userChanged(event: ValueChangedEvent<string>) { private _userChanged(ev: ValueChangedEvent<string | undefined>) {
event.stopPropagation(); ev.stopPropagation();
const index = (event.currentTarget as any).index; const index = (ev.currentTarget as any).index;
const newValue = event.detail.value; const newValue = ev.detail.value;
const newUsers = [...this._currentUsers]; const newUsers = [...this._currentUsers];
if (newValue === "") { if (!newValue) {
newUsers.splice(index, 1); newUsers.splice(index, 1);
} else { } else {
newUsers.splice(index, 1, newValue); newUsers.splice(index, 1, newValue);
@ -148,24 +141,15 @@ class HaUsersPickerLight extends LitElement {
this._updateUsers([...currentUsers, toAdd]); this._updateUsers([...currentUsers, toAdd]);
} }
private _removeUser(event) { static override styles = css`
const userId = (event.currentTarget as any).userId;
this._updateUsers(this._currentUsers.filter((user) => user !== userId));
}
static styles = css`
:host {
display: block;
}
div { div {
display: flex; margin-top: 8px;
align-items: center;
} }
`; `;
} }
declare global { declare global {
interface HTMLElementTagNameMap { interface HTMLElementTagNameMap {
"ha-users-picker": HaUsersPickerLight; "ha-users-picker": HaUsersPicker;
} }
} }

View File

@ -48,12 +48,6 @@ export class HaEventTrigger extends LitElement implements TriggerElement {
"ui.panel.config.automation.editor.triggers.type.event.context_users" "ui.panel.config.automation.editor.triggers.type.event.context_users"
)} )}
<ha-users-picker <ha-users-picker
.pickedUserLabel=${this.hass.localize(
"ui.panel.config.automation.editor.triggers.type.event.context_user_picked"
)}
.pickUserLabel=${this.hass.localize(
"ui.panel.config.automation.editor.triggers.type.event.context_user_pick"
)}
.hass=${this.hass} .hass=${this.hass}
.disabled=${this.disabled} .disabled=${this.disabled}
.value=${this._wrapUsersInArray(context?.user_id)} .value=${this._wrapUsersInArray(context?.user_id)}

View File

@ -647,9 +647,9 @@
"none": "None" "none": "None"
}, },
"user-picker": { "user-picker": {
"no_user": "No user", "no_match": "No matching users found",
"add_user": "Add user", "user": "User",
"remove_user": "Remove user" "add_user": "Add user"
}, },
"blueprint-picker": { "blueprint-picker": {
"select_blueprint": "Select a blueprint" "select_blueprint": "Select a blueprint"