From 65161ce5813c9ec283cc5415461e4b14f6d08bf8 Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Thu, 20 Apr 2023 16:12:49 +0200 Subject: [PATCH] Language selector (#16253) * Add language selector * Use intl display names * Use language picker in general settings and profile * Add nativeName option * Add format language util * Add display-name polyfill * Add native name to selector * Rename variable --- build-scripts/gulp/locale-data.cjs | 1 + package.json | 1 + src/common/language/format_language.ts | 22 +++ src/common/translations/localize.ts | 16 +++ src/components/ha-language-picker.ts | 125 ++++++++++++++++++ .../ha-selector/ha-selector-language.ts | 49 +++++++ src/components/ha-selector/ha-selector.ts | 1 + src/data/assist_pipeline.ts | 5 + src/data/selector.ts | 8 ++ .../config/core/ha-config-section-general.ts | 49 ++----- .../config/voice-assistants/assist-pref.ts | 5 +- .../dialog-voice-assistant-pipeline-detail.ts | 20 ++- .../ha-config-voice-assistants-assistants.ts | 1 + src/panels/profile/ha-pick-language-row.ts | 46 ++----- src/resources/compatibility.ts | 2 + yarn.lock | 12 ++ 16 files changed, 287 insertions(+), 76 deletions(-) create mode 100644 src/common/language/format_language.ts create mode 100644 src/components/ha-language-picker.ts create mode 100644 src/components/ha-selector/ha-selector-language.ts diff --git a/build-scripts/gulp/locale-data.cjs b/build-scripts/gulp/locale-data.cjs index 00bf705f43..4dee68abe2 100755 --- a/build-scripts/gulp/locale-data.cjs +++ b/build-scripts/gulp/locale-data.cjs @@ -19,6 +19,7 @@ const modules = { "intl-relativetimeformat": "RelativeTimeFormat", "intl-datetimeformat": "DateTimeFormat", "intl-numberformat": "NumberFormat", + "intl-displaynames": "DisplayNames", }; gulp.task("create-locale-data", (done) => { diff --git a/package.json b/package.json index 0598c649c7..f0841a80d9 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "@codemirror/view": "6.9.5", "@egjs/hammerjs": "2.0.17", "@formatjs/intl-datetimeformat": "6.5.1", + "@formatjs/intl-displaynames": "6.3.1", "@formatjs/intl-getcanonicallocales": "2.1.0", "@formatjs/intl-locale": "3.1.1", "@formatjs/intl-numberformat": "8.3.5", diff --git a/src/common/language/format_language.ts b/src/common/language/format_language.ts new file mode 100644 index 0000000000..76180912a9 --- /dev/null +++ b/src/common/language/format_language.ts @@ -0,0 +1,22 @@ +import memoizeOne from "memoize-one"; +import { FrontendLocaleData } from "../../data/translation"; + +export const formatLanguageCode = ( + languageCode: string, + locale: FrontendLocaleData +) => { + try { + return formatLanguageCodeMem(locale)?.of(languageCode) ?? languageCode; + } catch { + return languageCode; + } +}; + +const formatLanguageCodeMem = memoizeOne((locale: FrontendLocaleData) => + Intl && "DisplayNames" in Intl + ? new Intl.DisplayNames(locale.language, { + type: "language", + fallback: "code", + }) + : undefined +); diff --git a/src/common/translations/localize.ts b/src/common/translations/localize.ts index c79f50b4bf..65c00f3b6c 100644 --- a/src/common/translations/localize.ts +++ b/src/common/translations/localize.ts @@ -2,6 +2,7 @@ import { shouldPolyfill as shouldPolyfillLocale } from "@formatjs/intl-locale/li import { shouldPolyfill as shouldPolyfillPluralRules } from "@formatjs/intl-pluralrules/lib/should-polyfill"; import { shouldPolyfill as shouldPolyfillRelativeTime } from "@formatjs/intl-relativetimeformat/lib/should-polyfill"; import { shouldPolyfill as shouldPolyfillDateTime } from "@formatjs/intl-datetimeformat/lib/should-polyfill"; +import { shouldPolyfill as shouldPolyfillDisplayName } from "@formatjs/intl-displaynames/lib/should-polyfill"; import IntlMessageFormat from "intl-messageformat"; import { Resources, TranslationDict } from "../../types"; import { getLocalLanguage } from "../../util/common-translation"; @@ -83,6 +84,10 @@ if (__BUILD__ === "latest") { polyfills.push(import("@formatjs/intl-datetimeformat/polyfill")); polyfills.push(import("@formatjs/intl-datetimeformat/add-all-tz")); } + if (shouldPolyfillDisplayName(locale)) { + polyfills.push(import("@formatjs/intl-displaynames/polyfill")); + polyfills.push(import("@formatjs/intl-displaynames/locale-data/en")); + } } export const polyfillsLoaded = @@ -216,6 +221,17 @@ export const loadPolyfillLocales = async (language: string) => { // @ts-ignore Intl.DateTimeFormat.__addLocaleData(await result.json()); } + if ( + Intl.DisplayNames && + // @ts-ignore + typeof Intl.DisplayNames.__addLocaleData === "function" + ) { + const result = await fetch( + `/static/locale-data/intl-displaynames/${language}.json` + ); + // @ts-ignore + Intl.DisplayNames.__addLocaleData(await result.json()); + } } catch (e) { // Ignore } diff --git a/src/components/ha-language-picker.ts b/src/components/ha-language-picker.ts new file mode 100644 index 0000000000..bbd53e7c8e --- /dev/null +++ b/src/components/ha-language-picker.ts @@ -0,0 +1,125 @@ +import { + css, + CSSResultGroup, + html, + LitElement, + PropertyValues, + TemplateResult, +} from "lit"; +import { customElement, property, state } from "lit/decorators"; +import memoizeOne from "memoize-one"; +import { fireEvent } from "../common/dom/fire_event"; +import { stopPropagation } from "../common/dom/stop_propagation"; +import { formatLanguageCode } from "../common/language/format_language"; +import { caseInsensitiveStringCompare } from "../common/string/compare"; +import { HomeAssistant } from "../types"; +import "./ha-list-item"; +import "./ha-select"; +import type { HaSelect } from "./ha-select"; + +@customElement("ha-language-picker") +export class HaLanguagePicker extends LitElement { + @property() public value?: string; + + @property() public label?: string; + + @property() public languages?: string[]; + + @property({ attribute: false }) public hass!: HomeAssistant; + + @property({ type: Boolean, reflect: true }) public disabled = false; + + @property({ type: Boolean }) public required = false; + + @property({ type: Boolean }) public nativeName = false; + + @state() _defaultLanguages: string[] = []; + + protected firstUpdated(changedProps: PropertyValues) { + super.firstUpdated(changedProps); + this._computeDefaultLanguageOptions(); + } + + private _getLanguagesOptions = memoizeOne( + (languages: string[], language: string, nativeName: boolean) => { + let options: { label: string; value: string }[] = []; + + if (nativeName) { + const translations = this.hass.translationMetadata.translations; + options = languages.map((lang) => ({ + value: lang, + label: translations[lang]?.nativeName ?? lang, + })); + } else { + options = languages.map((lang) => ({ + value: lang, + label: formatLanguageCode(lang, this.hass.locale), + })); + } + + options.sort((a, b) => + caseInsensitiveStringCompare(a.label, b.label, language) + ); + return options; + } + ); + + private _computeDefaultLanguageOptions() { + if (!this.hass.translationMetadata?.translations) { + return; + } + + this._defaultLanguages = Object.keys( + this.hass.translationMetadata.translations + ); + } + + protected render(): TemplateResult { + const value = this.value; + + const languageOptions = this._getLanguagesOptions( + this.languages ?? this._defaultLanguages, + this.hass.locale.language, + this.nativeName + ); + + return html` + + ${languageOptions.map( + (option) => html` + ${option.label} + ` + )} + + `; + } + + static get styles(): CSSResultGroup { + return css` + ha-select { + width: 100%; + } + `; + } + + private _changed(ev): void { + const target = ev.target as HaSelect; + this.value = target.value; + fireEvent(this, "value-changed", { value: this.value }); + } +} + +declare global { + interface HTMLElementTagNameMap { + "ha-language-picker": HaLanguagePicker; + } +} diff --git a/src/components/ha-selector/ha-selector-language.ts b/src/components/ha-selector/ha-selector-language.ts new file mode 100644 index 0000000000..b9b76362d6 --- /dev/null +++ b/src/components/ha-selector/ha-selector-language.ts @@ -0,0 +1,49 @@ +import { css, html, LitElement } from "lit"; +import { customElement, property } from "lit/decorators"; +import { LanguageSelector } from "../../data/selector"; +import { HomeAssistant } from "../../types"; +import "../ha-language-picker"; + +@customElement("ha-selector-language") +export class HaLanguageSelector extends LitElement { + @property() public hass!: HomeAssistant; + + @property() public selector!: LanguageSelector; + + @property() public value?: any; + + @property() public label?: string; + + @property() public helper?: string; + + @property({ type: Boolean }) public disabled = false; + + @property({ type: Boolean }) public required = true; + + protected render() { + return html` + + `; + } + + static styles = css` + ha-language-picker { + width: 100%; + } + `; +} + +declare global { + interface HTMLElementTagNameMap { + "ha-selector-language": HaLanguageSelector; + } +} diff --git a/src/components/ha-selector/ha-selector.ts b/src/components/ha-selector/ha-selector.ts index 72a76e31b6..77d880daf6 100644 --- a/src/components/ha-selector/ha-selector.ts +++ b/src/components/ha-selector/ha-selector.ts @@ -27,6 +27,7 @@ const LOAD_ELEMENTS = { entity: () => import("./ha-selector-entity"), statistic: () => import("./ha-selector-statistic"), file: () => import("./ha-selector-file"), + language: () => import("./ha-selector-language"), navigation: () => import("./ha-selector-navigation"), number: () => import("./ha-selector-number"), object: () => import("./ha-selector-object"), diff --git a/src/data/assist_pipeline.ts b/src/data/assist_pipeline.ts index dd8eca29fb..abf370681f 100644 --- a/src/data/assist_pipeline.ts +++ b/src/data/assist_pipeline.ts @@ -296,3 +296,8 @@ export const deleteAssistPipeline = (hass: HomeAssistant, pipelineId: string) => type: "assist_pipeline/pipeline/delete", pipeline_id: pipelineId, }); + +export const fetchAssistPipelineLanguages = (hass: HomeAssistant) => + hass.callWS<{ languages: string[] }>({ + type: "assist_pipeline/language/list", + }); diff --git a/src/data/selector.ts b/src/data/selector.ts index c4a08644fa..4f07b479cb 100644 --- a/src/data/selector.ts +++ b/src/data/selector.ts @@ -26,6 +26,7 @@ export type Selector = | LegacyEntitySelector | FileSelector | IconSelector + | LanguageSelector | LocationSelector | MediaSelector | NavigationSelector @@ -209,6 +210,13 @@ export interface IconSelector { } | null; } +export interface LanguageSelector { + language: { + languages?: string[]; + native_name?: boolean; + } | null; +} + export interface LocationSelector { location: { radius?: boolean; icon?: string } | null; } diff --git a/src/panels/config/core/ha-config-section-general.ts b/src/panels/config/core/ha-config-section-general.ts index 31abfd8346..0d6e95dbc7 100644 --- a/src/panels/config/core/ha-config-section-general.ts +++ b/src/panels/config/core/ha-config-section-general.ts @@ -6,13 +6,16 @@ import memoizeOne from "memoize-one"; import { UNIT_C } from "../../../common/const"; import { stopPropagation } from "../../../common/dom/stop_propagation"; import { navigate } from "../../../common/navigate"; -import { caseInsensitiveStringCompare } from "../../../common/string/compare"; import "../../../components/buttons/ha-progress-button"; import type { HaProgressButton } from "../../../components/buttons/ha-progress-button"; import { getCountryOptions } from "../../../components/country-datalist"; import { getCurrencyOptions } from "../../../components/currency-datalist"; +import "../../../components/ha-alert"; import "../../../components/ha-card"; +import "../../../components/ha-checkbox"; +import type { HaCheckbox } from "../../../components/ha-checkbox"; import "../../../components/ha-formfield"; +import "../../../components/ha-language-picker"; import "../../../components/ha-radio"; import type { HaRadio } from "../../../components/ha-radio"; import "../../../components/ha-select"; @@ -22,13 +25,10 @@ import "../../../components/map/ha-locations-editor"; import type { MarkerLocation } from "../../../components/map/ha-locations-editor"; import { ConfigUpdateValues, saveCoreConfig } from "../../../data/core"; import { SYMBOL_TO_ISO } from "../../../data/currency"; +import { showConfirmationDialog } from "../../../dialogs/generic/show-dialog-box"; import "../../../layouts/hass-subpage"; import { haStyle } from "../../../resources/styles"; import type { HomeAssistant } from "../../../types"; -import "../../../components/ha-alert"; -import { showConfirmationDialog } from "../../../dialogs/generic/show-dialog-box"; -import type { HaCheckbox } from "../../../components/ha-checkbox"; -import "../../../components/ha-checkbox"; @customElement("ha-config-section-general") class HaConfigSectionGeneral extends LitElement { @@ -54,8 +54,6 @@ class HaConfigSectionGeneral extends LitElement { @state() private _location?: [number, number]; - @state() private _languages?: { value: string; label: string }[]; - @state() private _error?: string; @state() private _updateUnits?: boolean; @@ -255,25 +253,19 @@ class HaConfigSectionGeneral extends LitElement { ` )} - - ${this._languages?.map( - ({ value, label }) => - html`${label}` - )} + ${this.narrow ? html` @@ -330,25 +322,10 @@ class HaConfigSectionGeneral extends LitElement { this._timeZone = this.hass.config.time_zone || "Etc/GMT"; this._name = this.hass.config.location_name; this._updateUnits = true; - this._computeLanguages(); } - private _computeLanguages() { - if (!this.hass.translationMetadata?.translations) { - return; - } - this._languages = Object.entries(this.hass.translationMetadata.translations) - .sort((a, b) => - caseInsensitiveStringCompare( - a[1].nativeName, - b[1].nativeName, - this.hass.locale.language - ) - ) - .map(([value, metaData]) => ({ - value, - label: metaData.nativeName, - })); + private _handleLanguageChange(ev) { + this._language = ev.detail.value; } private _handleChange(ev) { diff --git a/src/panels/config/voice-assistants/assist-pref.ts b/src/panels/config/voice-assistants/assist-pref.ts index 79aa1c5a45..bd26b9ca03 100644 --- a/src/panels/config/voice-assistants/assist-pref.ts +++ b/src/panels/config/voice-assistants/assist-pref.ts @@ -21,6 +21,7 @@ import { showConfirmationDialog } from "../../../dialogs/generic/show-dialog-box import type { HomeAssistant } from "../../../types"; import { showVoiceAssistantPipelineDetailDialog } from "./show-dialog-voice-assistant-pipeline-detail"; import { brandsUrl } from "../../../util/brands-url"; +import { formatLanguageCode } from "../../../common/language/format_language"; export class AssistPref extends LitElement { @property({ attribute: false }) public hass!: HomeAssistant; @@ -76,7 +77,9 @@ export class AssistPref extends LitElement { .id=${pipeline.id} > ${pipeline.name} - ${pipeline.language} + + ${formatLanguageCode(pipeline.language, this.hass.locale)} + ${this._preferred === pipeline.id ? html`
+ (supportedLanguages: string[]) => [ { name: "name", @@ -133,7 +145,9 @@ export class DialogVoiceAssistantPipelineDetail extends LitElement { name: "language", required: true, selector: { - text: {}, + language: { + languages: supportedLanguages, + }, }, }, { diff --git a/src/panels/config/voice-assistants/ha-config-voice-assistants-assistants.ts b/src/panels/config/voice-assistants/ha-config-voice-assistants-assistants.ts index 4d8c1d7c1c..76ae8707d8 100644 --- a/src/panels/config/voice-assistants/ha-config-voice-assistants-assistants.ts +++ b/src/panels/config/voice-assistants/ha-config-voice-assistants-assistants.ts @@ -1,4 +1,5 @@ import "@polymer/paper-item/paper-item"; +import "@polymer/paper-item/paper-item-body"; import { css, html, LitElement, nothing } from "lit"; import { customElement, property } from "lit/decorators"; import { isComponentLoaded } from "../../../common/config/is_component_loaded"; diff --git a/src/panels/profile/ha-pick-language-row.ts b/src/panels/profile/ha-pick-language-row.ts index 7bab38abdc..d33eb48941 100644 --- a/src/panels/profile/ha-pick-language-row.ts +++ b/src/panels/profile/ha-pick-language-row.ts @@ -1,10 +1,9 @@ -import "@material/mwc-list/mwc-list-item"; -import { css, html, LitElement, PropertyValues } from "lit"; -import { customElement, property, state } from "lit/decorators"; +import { css, html, LitElement } from "lit"; +import { customElement, property } from "lit/decorators"; import { fireEvent } from "../../common/dom/fire_event"; -import "../../components/ha-select"; +import "../../components/ha-language-picker"; import "../../components/ha-settings-row"; -import { HomeAssistant, Translation } from "../../types"; +import { HomeAssistant } from "../../types"; @customElement("ha-pick-language-row") export class HaPickLanguageRow extends LitElement { @@ -12,13 +11,6 @@ export class HaPickLanguageRow extends LitElement { @property() public narrow!: boolean; - @state() private _languages: (Translation & { key: string })[] = []; - - protected firstUpdated(changedProps: PropertyValues) { - super.firstUpdated(changedProps); - this._computeLanguages(); - } - protected render() { return html` @@ -33,43 +25,25 @@ export class HaPickLanguageRow extends LitElement { >${this.hass.localize("ui.panel.profile.language.link_promo")} - - ${this._languages.map( - (language) => html` - ${language.nativeName} - ` - )} - + `; } - private _computeLanguages() { - if (!this.hass.translationMetadata?.translations) { - return; - } - this._languages = Object.keys( - this.hass.translationMetadata.translations - ).map((key) => ({ - key, - ...this.hass.translationMetadata.translations[key], - })); - } - private _languageSelectionChanged(ev) { // Only fire event if language was changed. This prevents select updates when // responding to hass changes. - if (ev.target.value !== this.hass.language) { + if (ev.detail.value !== this.hass.language) { fireEvent(this, "hass-language-select", ev.target.value); } } diff --git a/src/resources/compatibility.ts b/src/resources/compatibility.ts index 5e89e2f11a..e9c678c9fc 100644 --- a/src/resources/compatibility.ts +++ b/src/resources/compatibility.ts @@ -15,6 +15,8 @@ import "@formatjs/intl-relativetimeformat/locale-data/en"; import "@formatjs/intl-datetimeformat/polyfill"; import "@formatjs/intl-datetimeformat/locale-data/en"; import "@formatjs/intl-datetimeformat/add-all-tz"; +import "@formatjs/intl-displaynames/polyfill"; +import "@formatjs/intl-displaynames/locale-data/en"; // To use comlink under ES5 import "proxy-polyfill"; diff --git a/yarn.lock b/yarn.lock index 4c9a8ac08f..70c816befe 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1582,6 +1582,17 @@ __metadata: languageName: node linkType: hard +"@formatjs/intl-displaynames@npm:6.3.1": + version: 6.3.1 + resolution: "@formatjs/intl-displaynames@npm:6.3.1" + dependencies: + "@formatjs/ecma402-abstract": 1.14.3 + "@formatjs/intl-localematcher": 0.2.32 + tslib: ^2.4.0 + checksum: a8f43cda125adf8ac34d890f6b2c8b77525d5dd1452fecc5053b16dc40142d5b00b4f0dfc26c73fdf03b38e84ed2936fe4eb453414f611f79e260374f8afb642 + languageName: node + linkType: hard + "@formatjs/intl-enumerator@npm:1.2.1": version: 1.2.1 resolution: "@formatjs/intl-enumerator@npm:1.2.1" @@ -9448,6 +9459,7 @@ __metadata: "@codemirror/view": 6.9.5 "@egjs/hammerjs": 2.0.17 "@formatjs/intl-datetimeformat": 6.5.1 + "@formatjs/intl-displaynames": 6.3.1 "@formatjs/intl-getcanonicallocales": 2.1.0 "@formatjs/intl-locale": 3.1.1 "@formatjs/intl-numberformat": 8.3.5