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 { css, html, LitElement } from "lit";
import { property } from "lit/decorators";
import { html, LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators";
import memoizeOne from "memoize-one";
import { fireEvent } from "../../common/dom/fire_event";
import { stringCompare } from "../../common/string/compare";
import type { User } from "../../data/user";
import { fetchUsers } from "../../data/user";
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-list-item";
interface UserComboBoxItem extends PickerComboBoxItem {
user?: User;
}
@customElement("ha-user-picker")
class HaUserPicker extends LitElement {
public hass?: HomeAssistant;
@property({ attribute: false }) public hass!: HomeAssistant;
@property() public label?: string;
@property() public placeholder?: string;
@property({ attribute: false }) public noUserLabel?: string;
@property() public value = "";
@ -24,78 +33,124 @@ class HaUserPicker extends LitElement {
@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) {
return [];
}
return users
.filter((user) => !user.system_generated)
.sort((a, b) =>
stringCompare(a.name, b.name, this.hass!.locale.language)
);
.map<UserComboBoxItem>((user) => ({
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 {
const placeholder =
this.placeholder ?? this.hass.localize("ui.components.user-picker.user");
return html`
<ha-select
.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
<ha-generic-picker
.hass=${this.hass}
.user=${user}
slot="graphic"
></ha-user-badge>
${user.name}
</ha-list-item>
`
.autofocus=${this.autofocus}
.label=${this.label}
.notFoundLabel=${this.hass.localize(
"ui.components.user-picker.no_match"
)}
</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) {
super.firstUpdated(changedProps);
if (this.users === undefined) {
fetchUsers(this.hass!).then((users) => {
this.users = users;
});
}
}
private _valueChanged(ev) {
const value = ev.detail.value;
private _userChanged(ev) {
const newValue = ev.target.value;
if (newValue !== this.value) {
this.value = newValue;
setTimeout(() => {
fireEvent(this, "value-changed", { value: newValue });
this.value = value;
fireEvent(this, "value-changed", { value });
fireEvent(this, "change");
}, 0);
}
}
static styles = css`
:host {
display: inline-block;
}
`;
}
customElements.define("ha-user-picker", HaUserPicker);
declare global {
interface HTMLElementTagNameMap {
"ha-user-picker": HaUserPicker;

View File

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

View File

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