diff --git a/web/src/common/ui/locale/format.ts b/web/src/common/ui/locale/format.ts index 5e2ab44608..23fe43625e 100644 --- a/web/src/common/ui/locale/format.ts +++ b/web/src/common/ui/locale/format.ts @@ -196,6 +196,13 @@ export function formatLocaleDisplayNames( return entries.sort(createIntlCollator(activeLocaleTag, collatorOptions)); } +/** + * Format the display name for a single locale, using the same logic as the options list. + * + * @param languageTag The locale to format. + * @param localizedDisplayName The localized display name for the locale + * @param relativeDisplayName The relative display name for the locale. + */ export function formatRelativeLocaleDisplayName( languageTag: TargetLanguageTag, localizedDisplayName: string, diff --git a/web/src/elements/locale/ak-locale-select.ts b/web/src/elements/locale/ak-locale-select.ts index 1a49f2ee64..001731b2d7 100644 --- a/web/src/elements/locale/ak-locale-select.ts +++ b/web/src/elements/locale/ak-locale-select.ts @@ -3,6 +3,7 @@ import { formatLocaleDisplayNames } from "#common/ui/locale/format"; import { setSessionLocale } from "#common/ui/locale/utils"; import { AKElement } from "#elements/Base"; +import { listen } from "#elements/decorators/listen"; import Styles from "#elements/locale/ak-locale-select.css"; import { LocaleOptions } from "#elements/locale/utils"; import { WithCapabilitiesConfig } from "#elements/mixins/capabilities"; @@ -12,8 +13,8 @@ import { CapabilitiesEnum } from "@goauthentik/api"; import { LOCALE_STATUS_EVENT, LocaleStatusEventDetail, msg } from "@lit/localize"; import { html, PropertyValues } from "lit"; +import { guard } from "lit-html/directives/guard.js"; import { customElement, state } from "lit/decorators.js"; -import { guard } from "lit/directives/guard.js"; import { createRef, ref } from "lit/directives/ref.js"; @customElement("ak-locale-select") @@ -25,21 +26,52 @@ export class AKLocaleSelect extends WithLocale(WithCapabilitiesConfig(AKElement) public static readonly styles = [Styles]; + #previousActiveLanguageTag: TargetLanguageTag | null = null; + //#region Listeners - #localeChangeListener = (event: Event) => { + /** + * An event listener for when the user selects a different locale from the dropdown. + * + * Note that their choice may not be immediately reflected in the UI. + */ + protected localeChangeListener = (event: Event) => { const select = event.target as HTMLSelectElement; - const locale = select.value as TargetLanguageTag; + const nextActiveLanguageTag = select.value as TargetLanguageTag; this.blur(); requestAnimationFrame(() => { - this.activeLanguageTag = locale; - setSessionLocale(locale); + this.#previousActiveLanguageTag = this.activeLanguageTag; + this.activeLanguageTag = nextActiveLanguageTag; + + setSessionLocale(nextActiveLanguageTag); }); }; - #localeStatusListener = (event: CustomEvent) => { + @listen(LOCALE_STATUS_EVENT, { target: window }) + protected localeStatusListener = (event: CustomEvent) => { + if (!this.ready || event.detail.status !== "ready") { + return; + } + + const { readyLocale } = event.detail; + + this.requestUpdate( + "activeLanguageTag", + this.#previousActiveLanguageTag, + undefined, + true, + readyLocale, + ); + }; + + /** + * An event listener which only reacts to the locale being ready. + * This is used to delay showing the select until the locale is loaded, + * preventing a flash of unlocalized content and avoiding expensive localization operations during initial render. + */ + protected localeReadyStatusListener = (event: CustomEvent) => { if (event.detail.status !== "ready") { return; } @@ -90,7 +122,7 @@ export class AKLocaleSelect extends WithLocale(WithCapabilitiesConfig(AKElement) public override connectedCallback(): void { super.connectedCallback(); - window.addEventListener(LOCALE_STATUS_EVENT, this.#localeStatusListener, { + window.addEventListener(LOCALE_STATUS_EVENT, this.localeReadyStatusListener, { once: true, passive: true, }); @@ -99,7 +131,7 @@ export class AKLocaleSelect extends WithLocale(WithCapabilitiesConfig(AKElement) public override disconnectedCallback(): void { super.disconnectedCallback(); window.clearTimeout(this.#readyTimeout); - window.removeEventListener(LOCALE_STATUS_EVENT, this.#localeStatusListener); + window.removeEventListener(LOCALE_STATUS_EVENT, this.localeReadyStatusListener); } public override firstUpdated(changed: PropertyValues): void { @@ -108,7 +140,7 @@ export class AKLocaleSelect extends WithLocale(WithCapabilitiesConfig(AKElement) // Fallback to ready if the network is taking too long. this.#readyTimeout = window.setTimeout(() => { this.ready = true; - window.removeEventListener(LOCALE_STATUS_EVENT, this.#localeStatusListener); + window.removeEventListener(LOCALE_STATUS_EVENT, this.localeReadyStatusListener); }, 250); } @@ -154,7 +186,7 @@ export class AKLocaleSelect extends WithLocale(WithCapabilitiesConfig(AKElement) ${ref(this.#selectRef)} part="select" id="locale-selector" - @change=${this.#localeChangeListener} + @change=${this.localeChangeListener} class="ak-m-capitalize" name="locale" > diff --git a/web/src/elements/locale/utils.ts b/web/src/elements/locale/utils.ts index 1636a019f0..330c57aa72 100644 --- a/web/src/elements/locale/utils.ts +++ b/web/src/elements/locale/utils.ts @@ -17,7 +17,7 @@ export interface LocaleOptionsProps { export const LocaleOptions: LitFC = ({ entries, activeLocaleTag }) => { return repeat( entries, - ([languageTag]) => languageTag, + ([languageTag]) => `${activeLocaleTag}-${languageTag}`, ([languageTag, localizedDisplayName, relativeDisplayName]) => { const pseudo = languageTag === PseudoLanguageTag; diff --git a/web/src/elements/mixins/locale.ts b/web/src/elements/mixins/locale.ts index b484211fbf..161d15d902 100644 --- a/web/src/elements/mixins/locale.ts +++ b/web/src/elements/mixins/locale.ts @@ -38,6 +38,13 @@ export interface LocaleMixin { * The current locale language tag. * * @format BCP 47 + * + * @remarks + * + * This may load asynchronously, which Lit will not know about. + * + * Use {@linkcode LOCALE_STATUS_EVENT} to listen for when the locale + * is ready after setting a new language tag. */ activeLanguageTag: TargetLanguageTag; } diff --git a/web/src/flow/FlowExecutor.ts b/web/src/flow/FlowExecutor.ts index b25e9bfbe2..f1cfdfafa7 100644 --- a/web/src/flow/FlowExecutor.ts +++ b/web/src/flow/FlowExecutor.ts @@ -47,7 +47,7 @@ import { import { spread } from "@open-wc/lit-helpers"; import { match, P } from "ts-pattern"; -import { msg } from "@lit/localize"; +import { LOCALE_STATUS_EVENT, LocaleStatusEventDetail, msg } from "@lit/localize"; import { CSSResult, html, nothing, PropertyValues } from "lit"; import { customElement, property } from "lit/decorators.js"; import { guard } from "lit/directives/guard.js"; @@ -204,6 +204,13 @@ export class FlowExecutor extends WithBrandConfig(Interface) implements StageHos window.location.reload(); }; + @listen(LOCALE_STATUS_EVENT, { target: window }) + protected localeStatusListener = (event: CustomEvent) => { + if (event.detail.status === "ready") { + this.refresh(); + } + }; + private setFlowErrorChallenge(error: APIError) { this.challenge = { component: "ak-stage-flow-error",