Use generic picker for language picker (#27631)

Co-authored-by: Bram Kragten <mail@bramkragten.nl>
This commit is contained in:
Wendelin
2025-10-27 15:02:13 +01:00
committed by GitHub
parent 8fbd0226fc
commit 453a2ac7f3
5 changed files with 116 additions and 114 deletions

View File

@@ -24,7 +24,7 @@ import "./ha-svg-icon";
@customElement("ha-generic-picker") @customElement("ha-generic-picker")
export class HaGenericPicker extends LitElement { export class HaGenericPicker extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant; @property({ attribute: false }) public hass?: HomeAssistant;
// eslint-disable-next-line lit/no-native-attributes // eslint-disable-next-line lit/no-native-attributes
@property({ type: Boolean }) public autofocus = false; @property({ type: Boolean }) public autofocus = false;
@@ -68,6 +68,21 @@ export class HaGenericPicker extends LitElement {
@property({ attribute: "not-found-label", type: String }) @property({ attribute: "not-found-label", type: String })
public notFoundLabel?: string; public notFoundLabel?: string;
@property({ attribute: "popover-placement" })
public popoverPlacement:
| "bottom"
| "top"
| "left"
| "right"
| "top-start"
| "top-end"
| "right-start"
| "right-end"
| "bottom-start"
| "bottom-end"
| "left-start"
| "left-end" = "bottom-start";
/** If set picker shows an add button instead of textbox when value isn't set */ /** If set picker shows an add button instead of textbox when value isn't set */
@property({ attribute: "add-button-label" }) public addButtonLabel?: string; @property({ attribute: "add-button-label" }) public addButtonLabel?: string;
@@ -135,7 +150,7 @@ export class HaGenericPicker extends LitElement {
style="--body-width: ${this._popoverWidth}px;" style="--body-width: ${this._popoverWidth}px;"
without-arrow without-arrow
distance="-4" distance="-4"
placement="bottom-start" .placement=${this.popoverPlacement}
for="picker" for="picker"
auto-size="vertical" auto-size="vertical"
auto-size-padding="16" auto-size-padding="16"
@@ -144,9 +159,7 @@ export class HaGenericPicker extends LitElement {
trap-focus trap-focus
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-label=${this.hass.localize( aria-label=${this.label || "Select option"}
"ui.components.target-picker.add_target"
)}
> >
${this._renderComboBox()} ${this._renderComboBox()}
</wa-popover> </wa-popover>
@@ -159,9 +172,7 @@ export class HaGenericPicker extends LitElement {
@closed=${this._hidePicker} @closed=${this._hidePicker}
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-label=${this.hass.localize( aria-label=${this.label || "Select option"}
"ui.components.target-picker.add_target"
)}
> >
${this._renderComboBox(true)} ${this._renderComboBox(true)}
</ha-bottom-sheet>` </ha-bottom-sheet>`
@@ -179,7 +190,8 @@ export class HaGenericPicker extends LitElement {
<ha-picker-combo-box <ha-picker-combo-box
.hass=${this.hass} .hass=${this.hass}
.allowCustomValue=${this.allowCustomValue} .allowCustomValue=${this.allowCustomValue}
.label=${this.searchLabel ?? this.hass.localize("ui.common.search")} .label=${this.searchLabel ??
(this.hass?.localize("ui.common.search") || "Search")}
.value=${this.value} .value=${this.value}
@value-changed=${this._valueChanged} @value-changed=${this._valueChanged}
.rowRenderer=${this.rowRenderer} .rowRenderer=${this.rowRenderer}

View File

@@ -1,56 +1,58 @@
import type { PropertyValues } from "lit"; import type { PropertyValues } from "lit";
import { css, html, LitElement } from "lit"; import { css, html, LitElement } from "lit";
import { customElement, property, query, state } from "lit/decorators"; import { customElement, property, state } 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 { stopPropagation } from "../common/dom/stop_propagation";
import { formatLanguageCode } from "../common/language/format_language"; import { formatLanguageCode } from "../common/language/format_language";
import { caseInsensitiveStringCompare } from "../common/string/compare"; import { caseInsensitiveStringCompare } from "../common/string/compare";
import type { FrontendLocaleData } from "../data/translation"; import type { FrontendLocaleData } from "../data/translation";
import { translationMetadata } from "../resources/translations-metadata"; import { translationMetadata } from "../resources/translations-metadata";
import type { HomeAssistant } from "../types"; import type { HomeAssistant, ValueChangedEvent } from "../types";
import "./ha-generic-picker";
import "./ha-list-item"; import "./ha-list-item";
import type { PickerComboBoxItem } from "./ha-picker-combo-box";
import "./ha-select"; import "./ha-select";
import type { HaSelect } from "./ha-select";
export const getLanguageOptions = ( export const getLanguageOptions = (
languages: string[], languages: string[],
nativeName: boolean, nativeName: boolean,
noSort: boolean, noSort: boolean,
locale?: FrontendLocaleData locale?: FrontendLocaleData
) => { ): PickerComboBoxItem[] => {
let options: { label: string; value: string }[] = []; let options: PickerComboBoxItem[] = [];
if (nativeName) { if (nativeName) {
const translations = translationMetadata.translations; const translations = translationMetadata.translations;
options = languages.map((lang) => { options = languages.map((lang) => {
let label = translations[lang]?.nativeName; let primary = translations[lang]?.nativeName;
if (!label) { if (!primary) {
try { try {
// this will not work if Intl.DisplayNames is polyfilled, it will return in the language of the user // this will not work if Intl.DisplayNames is polyfilled, it will return in the language of the user
label = new Intl.DisplayNames(lang, { primary = new Intl.DisplayNames(lang, {
type: "language", type: "language",
fallback: "code", fallback: "code",
}).of(lang)!; }).of(lang)!;
} catch (_err) { } catch (_err) {
label = lang; primary = lang;
} }
} }
return { return {
value: lang, id: lang,
label, primary,
search_labels: [primary],
}; };
}); });
} else if (locale) { } else if (locale) {
options = languages.map((lang) => ({ options = languages.map((lang) => ({
value: lang, id: lang,
label: formatLanguageCode(lang, locale), primary: formatLanguageCode(lang, locale),
search_labels: [formatLanguageCode(lang, locale)],
})); }));
} }
if (!noSort && locale) { if (!noSort && locale) {
options.sort((a, b) => options.sort((a, b) =>
caseInsensitiveStringCompare(a.label, b.label, locale.language) caseInsensitiveStringCompare(a.primary, b.primary, locale.language)
); );
} }
return options; return options;
@@ -80,115 +82,69 @@ export class HaLanguagePicker extends LitElement {
@state() _defaultLanguages: string[] = []; @state() _defaultLanguages: string[] = [];
@query("ha-select") private _select!: HaSelect;
protected firstUpdated(changedProps: PropertyValues) { protected firstUpdated(changedProps: PropertyValues) {
super.firstUpdated(changedProps); super.firstUpdated(changedProps);
this._computeDefaultLanguageOptions(); this._computeDefaultLanguageOptions();
} }
protected updated(changedProperties: PropertyValues) {
super.updated(changedProperties);
const localeChanged =
changedProperties.has("hass") &&
this.hass &&
changedProperties.get("hass") &&
changedProperties.get("hass").locale.language !==
this.hass.locale.language;
if (
changedProperties.has("languages") ||
changedProperties.has("value") ||
localeChanged
) {
this._select.layoutOptions();
if (!this.disabled && this._select.value !== this.value) {
fireEvent(this, "value-changed", { value: this._select.value });
}
if (!this.value) {
return;
}
const languageOptions = this._getLanguagesOptions(
this.languages ?? this._defaultLanguages,
this.nativeName,
this.noSort,
this.hass?.locale
);
const selectedItemIndex = languageOptions.findIndex(
(option) => option.value === this.value
);
if (selectedItemIndex === -1) {
this.value = undefined;
}
if (localeChanged) {
this._select.select(selectedItemIndex);
}
}
}
private _getLanguagesOptions = memoizeOne(getLanguageOptions); private _getLanguagesOptions = memoizeOne(getLanguageOptions);
private _computeDefaultLanguageOptions() { private _computeDefaultLanguageOptions() {
this._defaultLanguages = Object.keys(translationMetadata.translations); this._defaultLanguages = Object.keys(translationMetadata.translations);
} }
protected render() { private _getItems = () =>
const languageOptions = this._getLanguagesOptions( this._getLanguagesOptions(
this.languages ?? this._defaultLanguages, this.languages ?? this._defaultLanguages,
this.nativeName, this.nativeName,
this.noSort, this.noSort,
this.hass?.locale this.hass?.locale
); );
private _valueRenderer = (value) => {
const language = this._getItems().find(
(lang) => lang.id === value
)?.primary;
return html`<span slot="headline">${language ?? value}</span> `;
};
protected render() {
const value = const value =
this.value ?? this.value ??
(this.required && !this.disabled (this.required && !this.disabled ? this._getItems()[0].id : this.value);
? languageOptions[0]?.value
: this.value);
return html` return html`
<ha-select <ha-generic-picker
.label=${this.label ?? .hass=${this.hass}
.autofocus=${this.autofocus}
popover-placement="bottom-end"
.notFoundLabel=${this.hass?.localize(
"ui.components.language-picker.no_match"
)}
.placeholder=${this.label ??
(this.hass?.localize("ui.components.language-picker.language") || (this.hass?.localize("ui.components.language-picker.language") ||
"Language")} "Language")}
.value=${value || ""} .value=${value}
.required=${this.required} .valueRenderer=${this._valueRenderer}
.disabled=${this.disabled} .disabled=${this.disabled}
@selected=${this._changed} .getItems=${this._getItems}
@closed=${stopPropagation} @value-changed=${this._changed}
fixedMenuPosition hide-clear-icon
naturalMenuWidth ></ha-generic-picker>
.inlineArrow=${this.inlineArrow}
>
${languageOptions.length === 0
? html`<ha-list-item value=""
>${this.hass?.localize(
"ui.components.language-picker.no_languages"
) || "No languages"}</ha-list-item
>`
: languageOptions.map(
(option) => html`
<ha-list-item .value=${option.value}
>${option.label}</ha-list-item
>
`
)}
</ha-select>
`; `;
} }
static styles = css` static styles = css`
ha-select { ha-generic-picker {
width: 100%; width: 100%;
min-width: 200px;
display: block;
} }
`; `;
private _changed(ev): void { private _changed(ev: ValueChangedEvent<string>): void {
const target = ev.target as HaSelect; ev.stopPropagation();
if (this.disabled || target.value === "" || target.value === this.value) { this.value = ev.detail.value;
return;
}
this.value = target.value;
fireEvent(this, "value-changed", { value: this.value }); fireEvent(this, "value-changed", { value: this.value });
} }
} }

View File

@@ -69,7 +69,7 @@ export type PickerComboBoxSearchFn<T extends PickerComboBoxItem> = (
@customElement("ha-picker-combo-box") @customElement("ha-picker-combo-box")
export class HaPickerComboBox extends LitElement { export class HaPickerComboBox extends LitElement {
@property({ attribute: false }) public hass!: HomeAssistant; @property({ attribute: false }) public hass?: HomeAssistant;
// eslint-disable-next-line lit/no-native-attributes // eslint-disable-next-line lit/no-native-attributes
@property({ type: Boolean }) public autofocus = false; @property({ type: Boolean }) public autofocus = false;
@@ -140,7 +140,9 @@ export class HaPickerComboBox extends LitElement {
protected render() { protected render() {
return html`<ha-textfield return html`<ha-textfield
.label=${this.label ?? this.hass.localize("ui.common.search")} .label=${this.label ??
this.hass?.localize("ui.common.search") ??
"Search"}
@input=${this._filterChanged} @input=${this._filterChanged}
></ha-textfield> ></ha-textfield>
<lit-virtualizer <lit-virtualizer
@@ -159,12 +161,18 @@ export class HaPickerComboBox extends LitElement {
private _defaultNotFoundItem = memoizeOne( private _defaultNotFoundItem = memoizeOne(
( (
label: this["notFoundLabel"], label: this["notFoundLabel"],
localize: LocalizeFunc localize?: LocalizeFunc
): PickerComboBoxItemWithLabel => ({ ): PickerComboBoxItemWithLabel => ({
id: NO_MATCHING_ITEMS_FOUND_ID, id: NO_MATCHING_ITEMS_FOUND_ID,
primary: label || localize("ui.components.combo-box.no_match"), primary:
label ||
(localize && localize("ui.components.combo-box.no_match")) ||
"No matching items found",
icon_path: mdiMagnify, icon_path: mdiMagnify,
a11y_label: label || localize("ui.components.combo-box.no_match"), a11y_label:
label ||
(localize && localize("ui.components.combo-box.no_match")) ||
"No matching items found",
}) })
); );
@@ -189,13 +197,13 @@ export class HaPickerComboBox extends LitElement {
caseInsensitiveStringCompare( caseInsensitiveStringCompare(
entityA.sorting_label!, entityA.sorting_label!,
entityB.sorting_label!, entityB.sorting_label!,
this.hass.locale.language this.hass?.locale.language ?? navigator.language
) )
); );
if (!sortedItems.length) { if (!sortedItems.length) {
sortedItems.push( sortedItems.push(
this._defaultNotFoundItem(this.notFoundLabel, this.hass.localize) this._defaultNotFoundItem(this.notFoundLabel, this.hass?.localize)
); );
} }
@@ -249,8 +257,20 @@ export class HaPickerComboBox extends LitElement {
const textfield = ev.target as HaTextField; const textfield = ev.target as HaTextField;
const searchString = textfield.value.trim(); const searchString = textfield.value.trim();
if (!searchString) {
this._items = this._allItems;
return;
}
const index = this._fuseIndex(this._allItems); const index = this._fuseIndex(this._allItems);
const fuse = new HaFuse(this._allItems, { shouldSort: false }, index); const fuse = new HaFuse(
this._allItems,
{
shouldSort: false,
minMatchCharLength: Math.min(searchString.length, 2),
},
index
);
const results = fuse.multiTermsSearch(searchString); const results = fuse.multiTermsSearch(searchString);
let filteredItems = this._allItems as PickerComboBoxItem[]; let filteredItems = this._allItems as PickerComboBoxItem[];
@@ -258,7 +278,7 @@ export class HaPickerComboBox extends LitElement {
const items = results.map((result) => result.item); const items = results.map((result) => result.item);
if (items.length === 0) { if (items.length === 0) {
items.push( items.push(
this._defaultNotFoundItem(this.notFoundLabel, this.hass.localize) this._defaultNotFoundItem(this.notFoundLabel, this.hass?.localize)
); );
} }
const additionalItems = this._getAdditionalItems(searchString); const additionalItems = this._getAdditionalItems(searchString);
@@ -431,6 +451,17 @@ export class HaPickerComboBox extends LitElement {
private _pickSelectedItem = (ev: KeyboardEvent) => { private _pickSelectedItem = (ev: KeyboardEvent) => {
ev.stopPropagation(); ev.stopPropagation();
const firstItem = this._virtualizerElement?.items[0] as PickerComboBoxItem;
if (
this._virtualizerElement?.items.length === 1 &&
firstItem.id !== NO_MATCHING_ITEMS_FOUND_ID
) {
fireEvent(this, "value-changed", {
value: firstItem.id,
});
}
if (this._selectedItemIndex === -1) { if (this._selectedItemIndex === -1) {
return; return;
} }
@@ -438,7 +469,9 @@ export class HaPickerComboBox extends LitElement {
// if filter button is focused // if filter button is focused
ev.preventDefault(); ev.preventDefault();
const item: any = this._virtualizerElement?.items[this._selectedItemIndex]; const item = this._virtualizerElement?.items[
this._selectedItemIndex
] as PickerComboBoxItem;
if (item && item.id !== NO_MATCHING_ITEMS_FOUND_ID) { if (item && item.id !== NO_MATCHING_ITEMS_FOUND_ID) {
fireEvent(this, "value-changed", { value: item.id }); fireEvent(this, "value-changed", { value: item.id });
} }

View File

@@ -193,12 +193,12 @@ export class HaVoiceAssistantSetupDialog extends LitElement {
).map( ).map(
(lang) => (lang) =>
html`<ha-md-menu-item html`<ha-md-menu-item
.value=${lang.value} .value=${lang.id}
@click=${this._handlePickLanguage} @click=${this._handlePickLanguage}
@keydown=${this._handlePickLanguage} @keydown=${this._handlePickLanguage}
.selected=${this._language === lang.value} .selected=${this._language === lang.id}
> >
${lang.label} ${lang.primary}
</ha-md-menu-item>` </ha-md-menu-item>`
)} )}
</ha-md-button-menu>` </ha-md-button-menu>`

View File

@@ -758,6 +758,7 @@
}, },
"language-picker": { "language-picker": {
"language": "Language", "language": "Language",
"no_match": "No matching languages found",
"no_languages": "No languages available" "no_languages": "No languages available"
}, },
"tts-picker": { "tts-picker": {