From 453a2ac7f3b81641c32f9f8227cf0520c60bdcaa Mon Sep 17 00:00:00 2001 From: Wendelin <12148533+wendevlin@users.noreply.github.com> Date: Mon, 27 Oct 2025 15:02:13 +0100 Subject: [PATCH] Use generic picker for language picker (#27631) Co-authored-by: Bram Kragten --- src/components/ha-generic-picker.ts | 30 ++-- src/components/ha-language-picker.ts | 140 ++++++------------ src/components/ha-picker-combo-box.ts | 53 +++++-- .../voice-assistant-setup-dialog.ts | 6 +- src/translations/en.json | 1 + 5 files changed, 116 insertions(+), 114 deletions(-) diff --git a/src/components/ha-generic-picker.ts b/src/components/ha-generic-picker.ts index 0ca80285df..256d4c29b4 100644 --- a/src/components/ha-generic-picker.ts +++ b/src/components/ha-generic-picker.ts @@ -24,7 +24,7 @@ import "./ha-svg-icon"; @customElement("ha-generic-picker") 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 @property({ type: Boolean }) public autofocus = false; @@ -68,6 +68,21 @@ export class HaGenericPicker extends LitElement { @property({ attribute: "not-found-label", type: 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 */ @property({ attribute: "add-button-label" }) public addButtonLabel?: string; @@ -135,7 +150,7 @@ export class HaGenericPicker extends LitElement { style="--body-width: ${this._popoverWidth}px;" without-arrow distance="-4" - placement="bottom-start" + .placement=${this.popoverPlacement} for="picker" auto-size="vertical" auto-size-padding="16" @@ -144,9 +159,7 @@ export class HaGenericPicker extends LitElement { trap-focus role="dialog" aria-modal="true" - aria-label=${this.hass.localize( - "ui.components.target-picker.add_target" - )} + aria-label=${this.label || "Select option"} > ${this._renderComboBox()} @@ -159,9 +172,7 @@ export class HaGenericPicker extends LitElement { @closed=${this._hidePicker} role="dialog" aria-modal="true" - aria-label=${this.hass.localize( - "ui.components.target-picker.add_target" - )} + aria-label=${this.label || "Select option"} > ${this._renderComboBox(true)} ` @@ -179,7 +190,8 @@ export class HaGenericPicker extends LitElement { { - let options: { label: string; value: string }[] = []; +): PickerComboBoxItem[] => { + let options: PickerComboBoxItem[] = []; if (nativeName) { const translations = translationMetadata.translations; options = languages.map((lang) => { - let label = translations[lang]?.nativeName; - if (!label) { + let primary = translations[lang]?.nativeName; + if (!primary) { try { // 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", fallback: "code", }).of(lang)!; } catch (_err) { - label = lang; + primary = lang; } } return { - value: lang, - label, + id: lang, + primary, + search_labels: [primary], }; }); } else if (locale) { options = languages.map((lang) => ({ - value: lang, - label: formatLanguageCode(lang, locale), + id: lang, + primary: formatLanguageCode(lang, locale), + search_labels: [formatLanguageCode(lang, locale)], })); } if (!noSort && locale) { options.sort((a, b) => - caseInsensitiveStringCompare(a.label, b.label, locale.language) + caseInsensitiveStringCompare(a.primary, b.primary, locale.language) ); } return options; @@ -80,115 +82,69 @@ export class HaLanguagePicker extends LitElement { @state() _defaultLanguages: string[] = []; - @query("ha-select") private _select!: HaSelect; - protected firstUpdated(changedProps: PropertyValues) { super.firstUpdated(changedProps); 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 _computeDefaultLanguageOptions() { this._defaultLanguages = Object.keys(translationMetadata.translations); } - protected render() { - const languageOptions = this._getLanguagesOptions( + private _getItems = () => + this._getLanguagesOptions( this.languages ?? this._defaultLanguages, this.nativeName, this.noSort, this.hass?.locale ); + private _valueRenderer = (value) => { + const language = this._getItems().find( + (lang) => lang.id === value + )?.primary; + return html`${language ?? value} `; + }; + + protected render() { const value = this.value ?? - (this.required && !this.disabled - ? languageOptions[0]?.value - : this.value); + (this.required && !this.disabled ? this._getItems()[0].id : this.value); return html` - - ${languageOptions.length === 0 - ? html`${this.hass?.localize( - "ui.components.language-picker.no_languages" - ) || "No languages"}` - : languageOptions.map( - (option) => html` - ${option.label} - ` - )} - + .getItems=${this._getItems} + @value-changed=${this._changed} + hide-clear-icon + > `; } static styles = css` - ha-select { + ha-generic-picker { width: 100%; + min-width: 200px; + display: block; } `; - private _changed(ev): void { - const target = ev.target as HaSelect; - if (this.disabled || target.value === "" || target.value === this.value) { - return; - } - this.value = target.value; + private _changed(ev: ValueChangedEvent): void { + ev.stopPropagation(); + this.value = ev.detail.value; fireEvent(this, "value-changed", { value: this.value }); } } diff --git a/src/components/ha-picker-combo-box.ts b/src/components/ha-picker-combo-box.ts index f775ea71f5..7cfa5b9242 100644 --- a/src/components/ha-picker-combo-box.ts +++ b/src/components/ha-picker-combo-box.ts @@ -69,7 +69,7 @@ export type PickerComboBoxSearchFn = ( @customElement("ha-picker-combo-box") 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 @property({ type: Boolean }) public autofocus = false; @@ -140,7 +140,9 @@ export class HaPickerComboBox extends LitElement { protected render() { return html` ({ 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, - 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( entityA.sorting_label!, entityB.sorting_label!, - this.hass.locale.language + this.hass?.locale.language ?? navigator.language ) ); if (!sortedItems.length) { 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 searchString = textfield.value.trim(); + if (!searchString) { + this._items = this._allItems; + return; + } + 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); let filteredItems = this._allItems as PickerComboBoxItem[]; @@ -258,7 +278,7 @@ export class HaPickerComboBox extends LitElement { const items = results.map((result) => result.item); if (items.length === 0) { items.push( - this._defaultNotFoundItem(this.notFoundLabel, this.hass.localize) + this._defaultNotFoundItem(this.notFoundLabel, this.hass?.localize) ); } const additionalItems = this._getAdditionalItems(searchString); @@ -431,6 +451,17 @@ export class HaPickerComboBox extends LitElement { private _pickSelectedItem = (ev: KeyboardEvent) => { 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) { return; } @@ -438,7 +469,9 @@ export class HaPickerComboBox extends LitElement { // if filter button is focused 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) { fireEvent(this, "value-changed", { value: item.id }); } diff --git a/src/dialogs/voice-assistant-setup/voice-assistant-setup-dialog.ts b/src/dialogs/voice-assistant-setup/voice-assistant-setup-dialog.ts index d42d425c89..9d3f47ae43 100644 --- a/src/dialogs/voice-assistant-setup/voice-assistant-setup-dialog.ts +++ b/src/dialogs/voice-assistant-setup/voice-assistant-setup-dialog.ts @@ -193,12 +193,12 @@ export class HaVoiceAssistantSetupDialog extends LitElement { ).map( (lang) => html` - ${lang.label} + ${lang.primary} ` )} ` diff --git a/src/translations/en.json b/src/translations/en.json index 48af411a18..14f23a56ca 100644 --- a/src/translations/en.json +++ b/src/translations/en.json @@ -758,6 +758,7 @@ }, "language-picker": { "language": "Language", + "no_match": "No matching languages found", "no_languages": "No languages available" }, "tts-picker": {