mirror of
https://github.com/goauthentik/authentik.git
synced 2026-06-17 19:09:11 +03:00
web: Locale selector UI fixes (#18972)
* Fix alignment, focus. * Clean up. * Tidy click area. * Fix compatibility mode. * Fix alignment. * Fix issues surrounding labels, alignment, consistency. * Update web/src/common/ui/locale/format.ts Signed-off-by: Teffen Ellis <592134+GirlBossRush@users.noreply.github.com> * Tidy hover states. * Tidy. * Clean up parsing. * Tidy comments, usage. * Always use script naming over region. * Remove unused. * Spacing. --------- Signed-off-by: Teffen Ellis <592134+GirlBossRush@users.noreply.github.com>
This commit is contained in:
@@ -6,6 +6,7 @@
|
||||
|
||||
src/locales/*.ts
|
||||
xliff/pseudo[_-]LOCALE.xlf
|
||||
xliff/en[_-]XA.xlf
|
||||
|
||||
### Node ###
|
||||
# Logs
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
"cs-CZ",
|
||||
"de-DE",
|
||||
"en",
|
||||
"en-XA",
|
||||
"es-ES",
|
||||
"fi-FI",
|
||||
"fr-FR",
|
||||
@@ -17,8 +18,7 @@
|
||||
"ru-RU",
|
||||
"tr-TR",
|
||||
"zh-Hans",
|
||||
"zh-Hant",
|
||||
"pseudo-LOCALE"
|
||||
"zh-Hant"
|
||||
],
|
||||
"tsConfig": "./tsconfig.json",
|
||||
"output": {
|
||||
|
||||
@@ -52,7 +52,7 @@ const EmittedLocalesDirectory = resolve(
|
||||
);
|
||||
|
||||
const targetLocales = localizeRules.targetLocales.filter((localeCode) => {
|
||||
return localeCode !== "pseudo-LOCALE";
|
||||
return localeCode !== "en-XA";
|
||||
});
|
||||
|
||||
//#endregion
|
||||
|
||||
@@ -23,7 +23,7 @@ import { makeFormatter } from "@lit/localize-tools/lib/formatters/index.js";
|
||||
import { sortProgramMessages } from "@lit/localize-tools/lib/messages.js";
|
||||
import { TransformLitLocalizer } from "@lit/localize-tools/lib/modes/transform.js";
|
||||
|
||||
const pseudoLocale = /** @type {Locale} */ ("pseudo-LOCALE");
|
||||
const pseudoLocale = /** @type {Locale} */ ("en-XA");
|
||||
const targetLocales = [pseudoLocale];
|
||||
const __dirname = fileURLToPath(new URL(".", import.meta.url));
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { TargetLocale } from "#common/ui/locale/definitions";
|
||||
import { TargetLanguageTag } from "#common/ui/locale/definitions";
|
||||
import { autoDetectLanguage } from "#common/ui/locale/utils";
|
||||
|
||||
import {
|
||||
@@ -13,7 +13,7 @@ const convertedSymbol = Symbol("ak-converted");
|
||||
|
||||
export interface GlobalAuthentik {
|
||||
[convertedSymbol]?: boolean;
|
||||
locale: TargetLocale;
|
||||
locale: TargetLanguageTag;
|
||||
flow?: {
|
||||
layout: FlowLayoutEnum;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* @file Set utilities.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Given a {@linkcode Set}, extract the type of its elements.
|
||||
*/
|
||||
export type UnwrapSet<T extends Set<unknown>> = T extends Set<infer U> ? U : never;
|
||||
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* @file Han Script utilities
|
||||
*/
|
||||
|
||||
import { UnwrapSet } from "#common/sets";
|
||||
import { TargetLanguageTag } from "#common/ui/locale/definitions";
|
||||
|
||||
//#region Constants
|
||||
|
||||
/**
|
||||
* An enum-like record of language tag constants that require special handling.
|
||||
*/
|
||||
export const CJKLanguageTag = {
|
||||
HanSimplified: "zh-Hans",
|
||||
HanTraditional: "zh-Hant",
|
||||
Japanese: "ja-JP",
|
||||
Korean: "ko-KR",
|
||||
} as const satisfies Record<string, TargetLanguageTag>;
|
||||
|
||||
export type CJKLanguageTag = (typeof CJKLanguageTag)[keyof typeof CJKLanguageTag];
|
||||
|
||||
/**
|
||||
* A set of **supported language tags** representing languages using Han scripts, i.e. Chinese.
|
||||
*/
|
||||
export const HanLanguageTags = new Set([
|
||||
CJKLanguageTag.HanSimplified,
|
||||
CJKLanguageTag.HanTraditional,
|
||||
] as const satisfies TargetLanguageTag[]);
|
||||
|
||||
export type HanLanguageTag = UnwrapSet<typeof HanLanguageTags>;
|
||||
|
||||
/**
|
||||
* A set of **supported language tags** representing Chinese, Japanese, and Korean languages.
|
||||
*/
|
||||
export const CJKLanguageTags = new Set<CJKLanguageTag>(Object.values(CJKLanguageTag));
|
||||
|
||||
//#endregion
|
||||
|
||||
export const HanScriptTag = {
|
||||
Simplified: "Hans",
|
||||
Traditional: "Hant",
|
||||
} as const satisfies Record<string, string>;
|
||||
|
||||
export type HanScriptTag = (typeof HanScriptTag)[keyof typeof HanScriptTag];
|
||||
|
||||
/**
|
||||
* Mapping of regions to their conventional script for Chinese.
|
||||
*
|
||||
* Covers major regions; others fall back to CLDR via `Intl.Locale.maximize`.
|
||||
*/
|
||||
export const ZHRegionToHanScript: ReadonlyMap<string, HanScriptTag> = new Map([
|
||||
["TW", HanScriptTag.Traditional], // Taiwan
|
||||
["HK", HanScriptTag.Traditional], // Hong Kong
|
||||
["MO", HanScriptTag.Traditional], // Macau
|
||||
["CN", HanScriptTag.Simplified], // China
|
||||
["SG", HanScriptTag.Simplified], // Singapore
|
||||
["MY", HanScriptTag.Simplified], // Malaysia
|
||||
]);
|
||||
|
||||
/**
|
||||
* Resolve a Chinese locale to it's preferred script tag.
|
||||
*
|
||||
* Priority:
|
||||
* 1. Explicit script subtag (zh-Hant, zh-Hans)
|
||||
* 2. Known region mapping (TW, HK, CN, etc.)
|
||||
* 3. CLDR maximize() inference
|
||||
* 4. Fallback to Simplified (Hans)
|
||||
*
|
||||
* @see {@linkcode resolveChineseScriptLegacy} for a regex-based approach.
|
||||
*/
|
||||
export function resolveChineseScript(locale: Intl.Locale): HanScriptTag {
|
||||
if (locale.script === HanScriptTag.Traditional || locale.script === HanScriptTag.Simplified) {
|
||||
return locale.script;
|
||||
}
|
||||
|
||||
const scriptViaRegion = locale.region ? ZHRegionToHanScript.get(locale.region) : null;
|
||||
|
||||
if (scriptViaRegion) {
|
||||
return scriptViaRegion;
|
||||
}
|
||||
|
||||
try {
|
||||
const maximized = locale.maximize();
|
||||
|
||||
if (
|
||||
maximized.script === HanScriptTag.Traditional ||
|
||||
maximized.script === HanScriptTag.Simplified
|
||||
) {
|
||||
return maximized.script;
|
||||
}
|
||||
} catch (_error) {
|
||||
// maximize() not supported or failed
|
||||
}
|
||||
|
||||
return HanScriptTag.Simplified;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve Chinese locale fallback to either zh-Hans or zh-Hant.
|
||||
*/
|
||||
export function resolveChineseFallback(
|
||||
candidate: string,
|
||||
): typeof CJKLanguageTag.HanSimplified | typeof CJKLanguageTag.HanTraditional {
|
||||
// Explicit script?
|
||||
if (/[-_]hant\b/i.test(candidate)) return CJKLanguageTag.HanTraditional;
|
||||
if (/[-_]hans\b/i.test(candidate)) return CJKLanguageTag.HanSimplified;
|
||||
|
||||
// Traditional region?
|
||||
if (/[-_](tw|hk|mo)\b/i.test(candidate)) return CJKLanguageTag.HanTraditional;
|
||||
|
||||
return CJKLanguageTag.HanSimplified;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve Chinese script using a regex-based approach for browser compatibility.
|
||||
*
|
||||
* @see {@linkcode resolveChineseScript} to resolve from {@linkcode Intl.Locale}
|
||||
*/
|
||||
export function resolveChineseScriptLegacy(candidate: string): HanScriptTag {
|
||||
// Explicit script?
|
||||
if (/[-_]hant\b/i.test(candidate)) return HanScriptTag.Traditional;
|
||||
if (/[-_]hans\b/i.test(candidate)) return HanScriptTag.Simplified;
|
||||
|
||||
// Traditional region?
|
||||
if (/[-_](tw|hk|mo)\b/i.test(candidate)) return HanScriptTag.Traditional;
|
||||
|
||||
return HanScriptTag.Simplified;
|
||||
}
|
||||
|
||||
//#region Type Guards
|
||||
|
||||
export function isCJKLanguageTag(languageTag: string): languageTag is CJKLanguageTag {
|
||||
return CJKLanguageTags.has(languageTag as CJKLanguageTag);
|
||||
}
|
||||
|
||||
export function isHanLanguageTag(languageTag: string): languageTag is HanLanguageTag {
|
||||
return HanLanguageTags.has(languageTag as HanLanguageTag);
|
||||
}
|
||||
|
||||
//#endregion
|
||||
@@ -1,14 +1,15 @@
|
||||
import { type allLocales, sourceLocale } from "../../../locale-codes.js";
|
||||
import { type allLocales, sourceLocale as SourceLanguageTag } from "../../../locale-codes.js";
|
||||
|
||||
import type { LocaleModule } from "@lit/localize";
|
||||
import { msg, str } from "@lit/localize";
|
||||
|
||||
export type TargetLocale = (typeof allLocales)[number];
|
||||
export type TargetLanguageTag = (typeof allLocales)[number];
|
||||
|
||||
/**
|
||||
* The pseudo locale code.
|
||||
* The language tag representing the pseudo-locale for testing.
|
||||
*/
|
||||
export const PseudoLocale = "pseudo-LOCALE" satisfies TargetLocale;
|
||||
const PseudoLanguageTag = "en-XA" as const satisfies TargetLanguageTag;
|
||||
|
||||
export { PseudoLanguageTag, SourceLanguageTag };
|
||||
|
||||
/**
|
||||
* A dummy locale module representing the source locale (English).
|
||||
@@ -21,99 +22,6 @@ const sourceTargetModule: LocaleModule = {
|
||||
templates: {},
|
||||
};
|
||||
|
||||
/**
|
||||
* A record mapping locale codes to their respective human-readable labels.
|
||||
*
|
||||
* @remarks
|
||||
* These are thunked functions to allow for localization via `msg()`.
|
||||
*/
|
||||
export const LocaleLabelRecord: Record<TargetLocale, () => string> = {
|
||||
[sourceLocale]: () => msg("English", { id: "en" }),
|
||||
[PseudoLocale]: () => msg("Pseudolocale", { id: "pseudo-LOCALE" }),
|
||||
"cs-CZ": () => msg("Czech", { id: "cs-CZ" }),
|
||||
"de-DE": () => msg("German", { id: "de-DE" }),
|
||||
"es-ES": () => msg("Spanish", { id: "es-ES" }),
|
||||
"fi-FI": () => msg("Finnish", { id: "fi-FI" }),
|
||||
"fr-FR": () => msg("French", { id: "fr-FR" }),
|
||||
"it-IT": () => msg("Italian", { id: "it-IT" }),
|
||||
"ja-JP": () => msg("Japanese", { id: "ja-JP" }),
|
||||
"ko-KR": () => msg("Korean", { id: "ko-KR" }),
|
||||
"nl-NL": () => msg("Dutch", { id: "nl-NL" }),
|
||||
"pl-PL": () => msg("Polish", { id: "pl-PL" }),
|
||||
"pt-BR": () => msg("Portuguese", { id: "pt-BR" }),
|
||||
"ru-RU": () => msg("Russian", { id: "ru-RU" }),
|
||||
"tr-TR": () => msg("Turkish", { id: "tr-TR" }),
|
||||
"zh-Hans": () => msg("Chinese Simplified", { id: "zh-Hans" }),
|
||||
"zh-Hant": () => msg("Chinese Traditional", { id: "zh-Hant" }),
|
||||
};
|
||||
|
||||
/**
|
||||
* A record mapping locale codes to their respective human-readable labels in their own language.
|
||||
*
|
||||
* @remarks
|
||||
* These are not thunked, as they are already localized.
|
||||
*/
|
||||
export const TranslatedLabelRecord: Record<TargetLocale, string> = {
|
||||
[sourceLocale]: "English",
|
||||
[PseudoLocale]: "Pseudolocale",
|
||||
"cs-CZ": "Čeština",
|
||||
"de-DE": "Deutsch",
|
||||
"es-ES": "Español",
|
||||
"fi-FI": "Suomi",
|
||||
"fr-FR": "Français",
|
||||
"it-IT": "Italiano",
|
||||
"ja-JP": "日本語",
|
||||
"ko-KR": "한국어",
|
||||
"nl-NL": "Nederlands",
|
||||
"pl-PL": "Polski",
|
||||
"pt-BR": "Português",
|
||||
"ru-RU": "Русский",
|
||||
"tr-TR": "Türkçe",
|
||||
"zh-Hans": "简体中文",
|
||||
"zh-Hant": "繁體中文",
|
||||
};
|
||||
|
||||
/**
|
||||
* A tuple representing a locale label and its corresponding code.
|
||||
*/
|
||||
export type LocaleOption = [label: string, code: TargetLocale];
|
||||
|
||||
/**
|
||||
* Format the locale options for use in a user-facing element.
|
||||
*
|
||||
* @param locales locales argument for locale-sensitive sorting.
|
||||
* @param collatorOptions Optional collator options for locale-sensitive sorting.
|
||||
* @returns An array of locale options sorted by their labels.
|
||||
*/
|
||||
export function formatLocaleOptions(
|
||||
locales?: Intl.LocalesArgument,
|
||||
collatorOptions?: Intl.CollatorOptions,
|
||||
): LocaleOption[] {
|
||||
const options = Object.entries(LocaleLabelRecord)
|
||||
.map(([_code, label]) => {
|
||||
const code = _code as TargetLocale;
|
||||
|
||||
const translatedLabel = TranslatedLabelRecord[code];
|
||||
|
||||
const localeLabel = label();
|
||||
let localizedMessage: string;
|
||||
|
||||
if (localeLabel === translatedLabel) {
|
||||
localizedMessage = localeLabel;
|
||||
} else {
|
||||
localizedMessage = msg(str`${localeLabel} (${translatedLabel})`, {
|
||||
id: "locale-option-localized-label",
|
||||
desc: "Locale option label showing the localized language name along with the native language name in parentheses. The first placeholder is the localized language name, the second is the native language name.",
|
||||
});
|
||||
}
|
||||
|
||||
return [localizedMessage, code];
|
||||
})
|
||||
.sort(([aLabel], [bLabel]) => aLabel.localeCompare(bLabel, locales, collatorOptions));
|
||||
|
||||
return options as LocaleOption[];
|
||||
}
|
||||
|
||||
/**
|
||||
* A record mapping locale codes to their respective module loaders.
|
||||
*
|
||||
@@ -121,9 +29,9 @@ export function formatLocaleOptions(
|
||||
* The `import` statements **must** reference a locale module path,
|
||||
* as this is how ESBuild identifies which files to include in the build.
|
||||
*/
|
||||
export const LocaleLoaderRecord: Record<TargetLocale, () => Promise<LocaleModule>> = {
|
||||
[sourceLocale]: () => Promise.resolve(sourceTargetModule),
|
||||
[PseudoLocale]: () => import("#locales/pseudo-LOCALE"),
|
||||
export const LocaleLoaderRecord: Record<TargetLanguageTag, () => Promise<LocaleModule>> = {
|
||||
[SourceLanguageTag]: () => Promise.resolve(sourceTargetModule),
|
||||
[PseudoLanguageTag]: () => import("#locales/en-XA"),
|
||||
"cs-CZ": () => import("#locales/cs-CZ"),
|
||||
"de-DE": () => import("#locales/de-DE"),
|
||||
"es-ES": () => import("#locales/es-ES"),
|
||||
@@ -140,61 +48,3 @@ export const LocaleLoaderRecord: Record<TargetLocale, () => Promise<LocaleModule
|
||||
"zh-Hans": () => import("#locales/zh-Hans"),
|
||||
"zh-Hant": () => import("#locales/zh-Hant"),
|
||||
};
|
||||
|
||||
/**
|
||||
* A record mapping locale codes to their respective regex patterns.
|
||||
*
|
||||
* @remarks
|
||||
* While this isn't too useful on its own, we use it to build the {@linkcode LocalePatternCodeMap}
|
||||
* while ensuring that TypeScript can verify that all locale codes are covered.
|
||||
*
|
||||
* The matchers try to conform loosely to [RFC 5646](https://www.rfc-editor.org/rfc/rfc5646.txt),
|
||||
* "Tags for the Identification of Languages."
|
||||
* In practice, language tags have been seen using both hyphens and underscores.
|
||||
*
|
||||
* Chinese language (`zh` or _Zhongwen_) can have a subtag indicating script:
|
||||
*
|
||||
* - `Hans`: Simplified
|
||||
* - `Hant`: Traditional
|
||||
*
|
||||
* Alternatively, the subtag can indicate a region with a predominant script.
|
||||
* The fallback is simplified Chinese.
|
||||
*/
|
||||
export const LocalePatternRecord: Record<TargetLocale, RegExp> = {
|
||||
[sourceLocale]: /^en([_-]|$)/i,
|
||||
[PseudoLocale]: /^pseudo/i,
|
||||
"cs-CZ": /^cs([_-]|$)/i,
|
||||
"de-DE": /^de([_-]|$)/i,
|
||||
"es-ES": /^es([_-]|$)/i,
|
||||
"fi-FI": /^fi([_-]|$)/i,
|
||||
"fr-FR": /^fr([_-]|$)/i,
|
||||
"it-IT": /^it([_-]|$)/i,
|
||||
"ja-JP": /^ja([_-]|$)/i,
|
||||
"ko-KR": /^ko([_-]|$)/i,
|
||||
"nl-NL": /^nl([_-]|$)/i,
|
||||
"pl-PL": /^pl([_-]|$)/i,
|
||||
"pt-BR": /^pt([_-]|$)/i,
|
||||
"ru-RU": /^ru([_-]|$)/i,
|
||||
"tr-TR": /^tr([_-]|$)/i,
|
||||
/**
|
||||
* Traditional Chinese.
|
||||
*
|
||||
* The region subtag is required.
|
||||
*/
|
||||
"zh-Hant": /^zh[_-](TW|HK|MO|Hant)/i,
|
||||
/**
|
||||
* Simplified Chinese.
|
||||
*
|
||||
* The region subtag is optional.
|
||||
*/
|
||||
"zh-Hans": /^zh([_-](CN|SG|MY|Hans)|$)/i,
|
||||
};
|
||||
|
||||
/**
|
||||
* A mapping of regex patterns to locale codes for matching user-supplied locale strings.
|
||||
*
|
||||
* @see {@linkcode LocalePatternRecord} for the source of this map.
|
||||
*/
|
||||
export const LocalePatternCodeMap = new Map<RegExp, TargetLocale>(
|
||||
Object.entries(LocalePatternRecord).map(([code, pattern]) => [pattern, code as TargetLocale]),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
import { allLocales } from "../../../locale-codes.js";
|
||||
|
||||
import { CJKLanguageTag, isCJKLanguageTag, isHanLanguageTag } from "#common/ui/locale/cjk";
|
||||
import {
|
||||
PseudoLanguageTag,
|
||||
SourceLanguageTag,
|
||||
TargetLanguageTag,
|
||||
} from "#common/ui/locale/definitions";
|
||||
import { safeParseLocale } from "#common/ui/locale/utils";
|
||||
|
||||
import { msg, str } from "@lit/localize";
|
||||
import { html } from "lit";
|
||||
import { repeat } from "lit/directives/repeat.js";
|
||||
|
||||
/**
|
||||
* Safely get a minimized locale ID, with fallback for older browsers.
|
||||
*/
|
||||
function getMinimizedLocaleID(tag: string): string {
|
||||
const locale = safeParseLocale(tag);
|
||||
if (!locale) {
|
||||
return tag.split(/[-_]/)[0].toLowerCase();
|
||||
}
|
||||
|
||||
try {
|
||||
return locale.minimize().baseName;
|
||||
} catch {
|
||||
return locale.language;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the appropriate locale ID for display purposes.
|
||||
* Han scripts use full baseName; others use just the language.
|
||||
*/
|
||||
function getDisplayLocaleID(tag: TargetLanguageTag): string {
|
||||
const locale = safeParseLocale(tag);
|
||||
if (!locale) {
|
||||
return tag;
|
||||
}
|
||||
|
||||
if (isHanLanguageTag(tag)) {
|
||||
return locale.baseName;
|
||||
}
|
||||
|
||||
return locale.language;
|
||||
}
|
||||
|
||||
export function formatDisplayName(
|
||||
localeID: Intl.Locale | Intl.UnicodeBCP47LocaleIdentifier,
|
||||
fallback?: string,
|
||||
languageNames?: Intl.DisplayNames,
|
||||
): string {
|
||||
const id = typeof localeID === "string" ? localeID : localeID.baseName;
|
||||
fallback ??= id;
|
||||
|
||||
languageNames ??= new Intl.DisplayNames([id], {
|
||||
type: "language",
|
||||
});
|
||||
|
||||
try {
|
||||
return languageNames.of(id) || fallback;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a localized display name, normalize it for comparison or sorting,
|
||||
* removing diacritics and other marks.
|
||||
*/
|
||||
export function normalizeDisplayName(displayName: string): string {
|
||||
return displayName
|
||||
.normalize("NFD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* A triple representing a locale and its corresponding display names.
|
||||
*/
|
||||
export type LocaleDisplay = [
|
||||
locale: TargetLanguageTag,
|
||||
localizedDisplayName: string,
|
||||
relativeDisplayName: string,
|
||||
];
|
||||
|
||||
export function createIntlCollator(
|
||||
activeLocale: Intl.UnicodeBCP47LocaleIdentifier,
|
||||
options: Intl.CollatorOptions,
|
||||
) {
|
||||
const activeIsCJK = isCJKLanguageTag(activeLocale);
|
||||
|
||||
return ([aLocale, aName]: LocaleDisplay, [bLocale, bName]: LocaleDisplay) => {
|
||||
// Active locale always first
|
||||
if (activeLocale === aLocale) return -1;
|
||||
if (activeLocale === bLocale) return 1;
|
||||
|
||||
// Pseudo locale always last
|
||||
if (PseudoLanguageTag === aLocale) return 1;
|
||||
if (PseudoLanguageTag === bLocale) return -1;
|
||||
|
||||
const aIsCJK = isCJKLanguageTag(aLocale);
|
||||
const bIsCJK = isCJKLanguageTag(bLocale);
|
||||
|
||||
// Group CJK languages together
|
||||
if (aIsCJK !== bIsCJK) {
|
||||
return aIsCJK ? (activeIsCJK ? -1 : 1) : activeIsCJK ? 1 : -1;
|
||||
}
|
||||
|
||||
// Within CJK: group Han scripts together
|
||||
if (aIsCJK && bIsCJK) {
|
||||
const aIsHan = isHanLanguageTag(aLocale);
|
||||
const bIsHan = isHanLanguageTag(bLocale);
|
||||
|
||||
if (aIsHan !== bIsHan) {
|
||||
return aIsHan ? -1 : 1;
|
||||
}
|
||||
}
|
||||
|
||||
return aName.localeCompare(bName, activeLocale, options);
|
||||
};
|
||||
}
|
||||
|
||||
export interface FormatLocaleOptionsInit {
|
||||
languageNames?: Intl.DisplayNames;
|
||||
collatorOptions?: Intl.CollatorOptions;
|
||||
debug?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pre-defined display names for locales that need special handling.
|
||||
* These use minimized IDs or explicit fallbacks.
|
||||
*/
|
||||
const SPECIAL_LOCALE_FALLBACKS: ReadonlyMap<TargetLanguageTag, () => string> = new Map([
|
||||
[SourceLanguageTag, () => msg("English", { id: "en" })],
|
||||
[CJKLanguageTag.HanSimplified, () => msg("Chinese (Simplified)", { id: "zh-Hans" })],
|
||||
[CJKLanguageTag.HanTraditional, () => msg("Chinese (Traditional)", { id: "zh-Hant" })],
|
||||
[CJKLanguageTag.Japanese, () => msg("Japanese", { id: "ja-JP" })],
|
||||
[CJKLanguageTag.Korean, () => msg("Korean", { id: "ko-KR" })],
|
||||
[PseudoLanguageTag, () => msg("English (Pseudo-Accents)", { id: "en-XA" })],
|
||||
]);
|
||||
|
||||
/**
|
||||
* Format the locale options for use in a user-facing element.
|
||||
*
|
||||
* @returns An array of locale options sorted by their labels.
|
||||
*/
|
||||
export function formatLocaleDisplayNames(
|
||||
activeLanguageTag: Intl.UnicodeBCP47LocaleIdentifier | Intl.Locale,
|
||||
{ collatorOptions = {}, languageNames, debug }: FormatLocaleOptionsInit = {},
|
||||
): LocaleDisplay[] {
|
||||
const activeLocaleTag =
|
||||
typeof activeLanguageTag === "string" ? activeLanguageTag : activeLanguageTag.baseName;
|
||||
|
||||
languageNames ??= new Intl.DisplayNames(activeLocaleTag, {
|
||||
type: "language",
|
||||
});
|
||||
|
||||
const usedLanguages = new Set<string>();
|
||||
const displayNames = new Map<TargetLanguageTag, string>();
|
||||
|
||||
// Process all locales
|
||||
for (const tag of allLocales) {
|
||||
// Skip pseudo unless debug
|
||||
if (tag === PseudoLanguageTag && !debug) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const specialFallback = SPECIAL_LOCALE_FALLBACKS.get(tag);
|
||||
|
||||
if (specialFallback) {
|
||||
const localeID = isHanLanguageTag(tag)
|
||||
? tag // Prefer the display name over region minimization.
|
||||
: getMinimizedLocaleID(tag);
|
||||
|
||||
displayNames.set(tag, formatDisplayName(localeID, specialFallback(), languageNames));
|
||||
} else {
|
||||
// Standard locales: prefer language-only if not already used
|
||||
const locale = safeParseLocale(tag);
|
||||
const language = locale?.language ?? tag.split(/[-_]/)[0].toLowerCase();
|
||||
|
||||
const localeID = usedLanguages.has(language) ? (locale?.baseName ?? tag) : language;
|
||||
|
||||
usedLanguages.add(language);
|
||||
displayNames.set(tag, formatDisplayName(localeID, language, languageNames));
|
||||
}
|
||||
}
|
||||
|
||||
// Build display entries with relative names
|
||||
const entries: LocaleDisplay[] = Array.from(displayNames, ([tag, localizedName]) => {
|
||||
const relativeLanguageNames = new Intl.DisplayNames(tag, { type: "language" });
|
||||
const localeID = getDisplayLocaleID(tag);
|
||||
const relativeName = formatDisplayName(localeID, localizedName, relativeLanguageNames);
|
||||
|
||||
return [tag, localizedName, relativeName];
|
||||
});
|
||||
|
||||
return entries.sort(createIntlCollator(activeLocaleTag, collatorOptions));
|
||||
}
|
||||
|
||||
export function renderLocaleDisplayNames(
|
||||
entries: LocaleDisplay[],
|
||||
activeLocaleTag: TargetLanguageTag | null,
|
||||
) {
|
||||
return repeat(
|
||||
entries,
|
||||
([languageTag]) => languageTag,
|
||||
([languageTag, localizedDisplayName, relativeDisplayName]) => {
|
||||
const pseudo = languageTag === PseudoLanguageTag;
|
||||
|
||||
const same =
|
||||
relativeDisplayName &&
|
||||
normalizeDisplayName(relativeDisplayName) ===
|
||||
normalizeDisplayName(localizedDisplayName);
|
||||
|
||||
let localizedMessage = localizedDisplayName;
|
||||
|
||||
if (!same && !pseudo) {
|
||||
localizedMessage = msg(str`${relativeDisplayName} (${localizedDisplayName})`, {
|
||||
id: "locale-option-localized-label",
|
||||
desc: "Locale option label showing the localized language name along with the native language name in parentheses.",
|
||||
});
|
||||
}
|
||||
|
||||
return html`${pseudo ? html`<hr />` : null}
|
||||
<option value=${languageTag} ?selected=${languageTag === activeLocaleTag}>
|
||||
${localizedMessage}
|
||||
</option>`;
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -1,12 +1,99 @@
|
||||
import { sourceLocale } from "../../../locale-codes.js";
|
||||
import { allLocales, sourceLocale as SourceLanguageTag } from "../../../locale-codes.js";
|
||||
|
||||
import { LocalePatternCodeMap, TargetLocale } from "#common/ui/locale/definitions";
|
||||
import { resolveChineseScript, resolveChineseScriptLegacy } from "#common/ui/locale/cjk";
|
||||
import { PseudoLanguageTag, TargetLanguageTag } from "#common/ui/locale/definitions";
|
||||
|
||||
export function getBestMatchLocale(locale: string): TargetLocale | null {
|
||||
const [, localeCode] =
|
||||
Iterator.from(LocalePatternCodeMap).find(([pattern]) => pattern.test(locale)) || [];
|
||||
//#region Cache
|
||||
|
||||
return localeCode ?? null;
|
||||
const localeCache = new Map<string, Intl.Locale | null>();
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Locale Matching
|
||||
|
||||
/**
|
||||
* Parse a locale string with caching and fallback for invalid/unsupported locales.
|
||||
*/
|
||||
export function safeParseLocale(candidate: string): Intl.Locale | null {
|
||||
if (localeCache.has(candidate)) {
|
||||
return localeCache.get(candidate)!;
|
||||
}
|
||||
|
||||
let locale: Intl.Locale | null = null;
|
||||
try {
|
||||
locale = new Intl.Locale(candidate);
|
||||
} catch {
|
||||
// Invalid locale string
|
||||
}
|
||||
|
||||
localeCache.set(candidate, locale);
|
||||
return locale;
|
||||
}
|
||||
|
||||
interface ParsedLocale {
|
||||
tag: TargetLanguageTag;
|
||||
language: string;
|
||||
script?: string;
|
||||
region?: string;
|
||||
}
|
||||
|
||||
let parsedSupportedLocales: ParsedLocale[] | null = null;
|
||||
|
||||
/**
|
||||
* Lazily parse and cache supported locales.
|
||||
*/
|
||||
function getParsedSupportedLocales(): ParsedLocale[] {
|
||||
if (!parsedSupportedLocales) {
|
||||
parsedSupportedLocales = allLocales.map((tag) => {
|
||||
const locale = safeParseLocale(tag);
|
||||
|
||||
return {
|
||||
tag,
|
||||
language: locale?.language ?? tag.split(/[-_]/)[0].toLowerCase(),
|
||||
script: locale?.script,
|
||||
region: locale?.region,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
return parsedSupportedLocales;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the best matching supported locale for a given locale string.
|
||||
*/
|
||||
export function getBestMatchLocale(candidate: string): TargetLanguageTag | null {
|
||||
// Normalize common variations
|
||||
const normalized = candidate.trim();
|
||||
if (!normalized) return null;
|
||||
|
||||
const locale = safeParseLocale(normalized);
|
||||
const language = locale?.language ?? normalized.split(/[-_]/)[0].toLowerCase();
|
||||
|
||||
// Pseudo-locale
|
||||
if (language === "en") {
|
||||
const region = locale?.region ?? normalized.split(/[-_]/)[1]?.toUpperCase();
|
||||
|
||||
if (region === "XA") {
|
||||
return PseudoLanguageTag;
|
||||
}
|
||||
|
||||
return SourceLanguageTag;
|
||||
}
|
||||
|
||||
// Chinese Han script
|
||||
if (language === "zh") {
|
||||
const script = locale
|
||||
? resolveChineseScript(locale)
|
||||
: resolveChineseScriptLegacy(normalized);
|
||||
|
||||
return `${language}-${script}`;
|
||||
}
|
||||
|
||||
const parsed = getParsedSupportedLocales();
|
||||
const match = parsed.find((p) => p.language === language);
|
||||
|
||||
return match?.tag ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -20,24 +107,31 @@ export function getBestMatchLocale(locale: string): TargetLocale | null {
|
||||
* one that has a supported locale. Then, from *that*, we have to extract that first supported
|
||||
* locale.
|
||||
*/
|
||||
export function findSupportedLocale(candidates: string[]): TargetLocale | null {
|
||||
const candidate = candidates.find((candidate) => getBestMatchLocale(candidate));
|
||||
return candidate ? getBestMatchLocale(candidate) : null;
|
||||
export function findSupportedLocale(candidates: string[]): TargetLanguageTag | null {
|
||||
for (const candidate of candidates) {
|
||||
const match = getBestMatchLocale(candidate);
|
||||
if (match) return match;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Persistence
|
||||
|
||||
const sessionLocaleKey = "authentik:locale";
|
||||
|
||||
/**
|
||||
* Persist the given locale code to sessionStorage.
|
||||
*/
|
||||
export function setSessionLocale(locale: TargetLocale | null): void {
|
||||
export function setSessionLocale(languageTag: TargetLanguageTag | null): void {
|
||||
try {
|
||||
if (!locale || locale === sourceLocale) {
|
||||
if (!languageTag || languageTag === SourceLanguageTag) {
|
||||
sessionStorage?.removeItem?.(sessionLocaleKey);
|
||||
return;
|
||||
}
|
||||
|
||||
sessionStorage?.setItem?.(sessionLocaleKey, locale);
|
||||
sessionStorage?.setItem?.(sessionLocaleKey, languageTag);
|
||||
} catch (error) {
|
||||
console.debug("authentik/locale: Unable to persist locale to sessionStorage", error);
|
||||
}
|
||||
@@ -56,6 +150,10 @@ export function getSessionLocale(): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Auto-Detection
|
||||
|
||||
/**
|
||||
* Auto-detect the best locale to use from several sources.
|
||||
*
|
||||
@@ -73,7 +171,10 @@ export function getSessionLocale(): string | null {
|
||||
* 5. A provided fallback locale code
|
||||
* 6. The source locale (English)
|
||||
*/
|
||||
export function autoDetectLanguage(localeHint?: string, fallbackLocaleCode?: string): TargetLocale {
|
||||
export function autoDetectLanguage(
|
||||
localeHint?: string,
|
||||
fallbackLocaleCode?: string,
|
||||
): TargetLanguageTag {
|
||||
let localeParam: string | null = null;
|
||||
|
||||
if (self.location) {
|
||||
@@ -85,8 +186,8 @@ export function autoDetectLanguage(localeHint?: string, fallbackLocaleCode?: str
|
||||
const sessionLocale = getSessionLocale();
|
||||
|
||||
const candidates = [
|
||||
sessionLocale,
|
||||
localeParam,
|
||||
sessionLocale,
|
||||
localeHint,
|
||||
self.navigator?.language,
|
||||
fallbackLocaleCode,
|
||||
@@ -96,13 +197,13 @@ export function autoDetectLanguage(localeHint?: string, fallbackLocaleCode?: str
|
||||
|
||||
if (!firstSupportedLocale) {
|
||||
console.debug(`authentik/locale: Falling back to source locale`, {
|
||||
sourceLocale,
|
||||
SourceLanguageTag,
|
||||
localeHint,
|
||||
fallbackLocaleCode,
|
||||
candidates,
|
||||
});
|
||||
|
||||
return sourceLocale;
|
||||
return SourceLanguageTag;
|
||||
}
|
||||
|
||||
return firstSupportedLocale;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { sourceLocale, targetLocales } from "../../locale-codes.js";
|
||||
|
||||
import { LocaleLabelRecord, LocaleLoaderRecord, TargetLocale } from "#common/ui/locale/definitions";
|
||||
import { LocaleLoaderRecord, TargetLanguageTag } from "#common/ui/locale/definitions";
|
||||
import { formatDisplayName } from "#common/ui/locale/format";
|
||||
import { autoDetectLanguage } from "#common/ui/locale/utils";
|
||||
|
||||
import { kAKLocale, LocaleContext, LocaleMixin } from "#elements/mixins/locale";
|
||||
@@ -26,19 +27,24 @@ export class LocaleContextController implements ReactiveController {
|
||||
* Attempts to apply the given locale code.
|
||||
* @param nextLocale A user or agent preferred locale code.
|
||||
*/
|
||||
#applyLocale(nextLocale: TargetLocale) {
|
||||
const currentLocale = this.#context.value.getLocale();
|
||||
const label = LocaleLabelRecord[nextLocale]();
|
||||
#applyLocale(nextLocale: TargetLanguageTag) {
|
||||
const activeLanguageTag = this.#context.value.getLocale();
|
||||
|
||||
if (currentLocale === nextLocale) {
|
||||
this.#log("Skipping locale update, already set to:", label);
|
||||
const languageNames = new Intl.DisplayNames([nextLocale, sourceLocale], {
|
||||
type: "language",
|
||||
});
|
||||
|
||||
const displayName = formatDisplayName(nextLocale, nextLocale, languageNames);
|
||||
|
||||
if (activeLanguageTag === nextLocale) {
|
||||
this.#log("Skipping locale update, already set to:", displayName);
|
||||
return;
|
||||
}
|
||||
|
||||
this.#context.value.setLocale(nextLocale);
|
||||
this.#host.locale = nextLocale;
|
||||
this.#host.activeLanguageTag = nextLocale;
|
||||
|
||||
this.#log("Applied locale:", label);
|
||||
this.#log("Applied locale:", displayName);
|
||||
}
|
||||
|
||||
// #region Attribute Observation
|
||||
@@ -107,10 +113,15 @@ export class LocaleContextController implements ReactiveController {
|
||||
#loadLocale = (_locale: string) => {
|
||||
// TypeScript cannot infer the type here, but Lit Localize will only call this
|
||||
// function with one of the `targetLocales`.
|
||||
const locale = _locale as TargetLocale;
|
||||
const label = LocaleLabelRecord[locale]();
|
||||
const locale = _locale as TargetLanguageTag;
|
||||
|
||||
this.#log(`Loading "${label}" module...`);
|
||||
const languageNames = new Intl.DisplayNames([locale, sourceLocale], {
|
||||
type: "language",
|
||||
});
|
||||
|
||||
const displayName = formatDisplayName(locale, locale, languageNames);
|
||||
|
||||
this.#log(`Loading "${displayName}" module...`);
|
||||
|
||||
const loader = LocaleLoaderRecord[locale];
|
||||
|
||||
@@ -124,7 +135,7 @@ export class LocaleContextController implements ReactiveController {
|
||||
* @param host The host element.
|
||||
* @param localeHint The initial locale code to set.
|
||||
*/
|
||||
constructor(host: ReactiveElementHost<LocaleMixin>, localeHint?: TargetLocale) {
|
||||
constructor(host: ReactiveElementHost<LocaleMixin>, localeHint?: TargetLanguageTag) {
|
||||
this.#host = host;
|
||||
|
||||
const contextValue = configureLocalization({
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
:host {
|
||||
--ak-c-locale-select--PaddingInline: 0.5em;
|
||||
--ak-c-locale-select--PaddingBlock: 0.25em;
|
||||
--ak-c-locale-select__select--PaddingInline: calc(
|
||||
var(--ak-c-locale-select--PaddingInline) * 4.5
|
||||
);
|
||||
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
align-items: center;
|
||||
user-select: none;
|
||||
gap: var(--pf-global--spacer--sm);
|
||||
padding-inline: var(--pf-global--spacer--sm);
|
||||
padding-block: var(--pf-global--spacer--xs);
|
||||
overflow: hidden;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
[part="select"] {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
opacity: var(--ak-c-locale-select--Opacity, 1);
|
||||
|
||||
/* Compatibility mode */
|
||||
background-color: var(--ak-c-locale-select--BackgroundColor, transparent) !important;
|
||||
color: inherit !important;
|
||||
text-decoration: underline;
|
||||
text-decoration-color: var(--ak-c-locale-select--TextDecorationColor, transparent);
|
||||
|
||||
border-radius: var(--pf-global--BorderRadius--sm);
|
||||
outline: 1px solid var(--ak-c-locale--select--OutlineColor, transparent);
|
||||
|
||||
border: none;
|
||||
|
||||
&:focus {
|
||||
outline: 1px solid var(--ak-c-locale--select--OutlineColor, transparent);
|
||||
|
||||
outline-offset: initial;
|
||||
}
|
||||
|
||||
padding-inline-start: var(
|
||||
--ak-c-locale-select__select--PaddingInlineStart,
|
||||
var(--ak-c-locale-select__select--PaddingInline)
|
||||
) !important;
|
||||
padding-inline-end: var(
|
||||
--ak-c-locale-select__select--PaddingInlineEnd,
|
||||
var(--ak-c-locale-select__select--PaddingInline)
|
||||
) !important;
|
||||
padding-block: var(--ak-c-locale-select--PaddingBlock) !important;
|
||||
|
||||
appearance: none;
|
||||
|
||||
&::picker-icon {
|
||||
/* Browser compatibility */
|
||||
color: transparent;
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
[part="label"] {
|
||||
padding-inline-start: calc(var(--ak-c-locale-select--PaddingInline) * 1.25);
|
||||
background-color: var(--ak-c-locale-select--label--BackgroundColor, transparent);
|
||||
color: var(--ak-c-locale-select--label--Color, inherit);
|
||||
cursor: pointer;
|
||||
position: absolute;
|
||||
inset-block-start: 0.625em;
|
||||
inset-inline-start: 0.5em;
|
||||
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
|
||||
.icon {
|
||||
display: block;
|
||||
height: var(--pf-global--FontSize--xl);
|
||||
stroke-width: 0.5;
|
||||
stroke: currentColor;
|
||||
fill: currentColor;
|
||||
}
|
||||
}
|
||||
|
||||
:host::after {
|
||||
padding-inline-end: var(--ak-c-locale-select--PaddingInline);
|
||||
inset-inline-end: var(--ak-c-locale-select--PaddingInline);
|
||||
|
||||
content: "⋯";
|
||||
background-color: var(--ak-c-locale-select--label--BackgroundColor, transparent);
|
||||
color: var(--ak-c-locale-select--label--Color, inherit);
|
||||
cursor: pointer;
|
||||
position: absolute;
|
||||
opacity: var(--ak-c-locale-select__after--Opacity, 0);
|
||||
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
[part="select"] {
|
||||
transition-property: opacity, text-decoration-color;
|
||||
transition-duration: 0.2s;
|
||||
transition-timing-function: ease-in-out;
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import { TargetLanguageTag } from "#common/ui/locale/definitions";
|
||||
import { formatLocaleDisplayNames, renderLocaleDisplayNames } from "#common/ui/locale/format";
|
||||
import { setSessionLocale } from "#common/ui/locale/utils";
|
||||
|
||||
import { AKElement } from "#elements/Base";
|
||||
import Styles from "#elements/locale/ak-locale-select.css";
|
||||
import { WithCapabilitiesConfig } from "#elements/mixins/capabilities";
|
||||
import { WithLocale } from "#elements/mixins/locale";
|
||||
|
||||
import { CapabilitiesEnum } from "@goauthentik/api";
|
||||
|
||||
import { LOCALE_STATUS_EVENT, LocaleStatusEventDetail, msg } from "@lit/localize";
|
||||
import { html, PropertyValues } from "lit";
|
||||
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")
|
||||
export class AKLocaleSelect extends WithLocale(WithCapabilitiesConfig(AKElement)) {
|
||||
public static shadowRootOptions = {
|
||||
...AKElement.shadowRootOptions,
|
||||
delegatesFocus: true,
|
||||
};
|
||||
|
||||
public static readonly styles = [Styles];
|
||||
|
||||
//#region Listeners
|
||||
|
||||
#localeChangeListener = (event: Event) => {
|
||||
const select = event.target as HTMLSelectElement;
|
||||
const locale = select.value as TargetLanguageTag;
|
||||
|
||||
this.blur();
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
this.activeLanguageTag = locale;
|
||||
setSessionLocale(locale);
|
||||
});
|
||||
};
|
||||
|
||||
#localeStatusListener = (event: CustomEvent<LocaleStatusEventDetail>) => {
|
||||
if (event.detail.status !== "ready") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.ready) {
|
||||
this.ready = true;
|
||||
window.clearTimeout(this.#readyTimeout);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Show the locale select dropdown.
|
||||
*/
|
||||
public show = () => {
|
||||
const selectElement = this.#selectRef.value;
|
||||
|
||||
if (!selectElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Gracefully degrade if not supported.
|
||||
try {
|
||||
selectElement.showPicker();
|
||||
} catch (_error) {
|
||||
selectElement.focus();
|
||||
}
|
||||
};
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Lifecycle
|
||||
|
||||
/**
|
||||
* Indicates whether the locale select is ready to be displayed.
|
||||
*
|
||||
* @remarks
|
||||
*
|
||||
* This avoids showing the select before the locale is initialized,
|
||||
* preventing a flash of unlocalized content and avoiding expensive localization
|
||||
* operations during initial render.
|
||||
*/
|
||||
@state()
|
||||
protected ready = false;
|
||||
|
||||
#readyTimeout = -1;
|
||||
#selectRef = createRef<HTMLSelectElement>();
|
||||
|
||||
public override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
|
||||
this.addEventListener("click", this.show);
|
||||
|
||||
window.addEventListener(LOCALE_STATUS_EVENT, this.#localeStatusListener, {
|
||||
once: true,
|
||||
passive: true,
|
||||
});
|
||||
}
|
||||
|
||||
public override disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
window.clearTimeout(this.#readyTimeout);
|
||||
window.removeEventListener(LOCALE_STATUS_EVENT, this.#localeStatusListener);
|
||||
}
|
||||
|
||||
public override firstUpdated(changed: PropertyValues<this>): void {
|
||||
super.firstUpdated(changed);
|
||||
|
||||
// Fallback to ready if the network is taking too long.
|
||||
this.#readyTimeout = window.setTimeout(() => {
|
||||
this.ready = true;
|
||||
window.removeEventListener(LOCALE_STATUS_EVENT, this.#localeStatusListener);
|
||||
}, 250);
|
||||
}
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Render
|
||||
|
||||
protected override render() {
|
||||
if (!this.ready) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const activeLocaleTag = this.activeLanguageTag;
|
||||
const debug = this.can(CapabilitiesEnum.CanDebug);
|
||||
|
||||
return guard([activeLocaleTag, debug], () => {
|
||||
const entries = formatLocaleDisplayNames(activeLocaleTag, {
|
||||
debug,
|
||||
});
|
||||
|
||||
return html`<label
|
||||
part="label"
|
||||
for="locale-selector"
|
||||
@click=${this.show}
|
||||
aria-label=${msg("Select language", {
|
||||
id: "language-selector-label",
|
||||
desc: "Label for the language selection dropdown",
|
||||
})}
|
||||
>
|
||||
<svg
|
||||
class="icon"
|
||||
role="img"
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 32 32"
|
||||
>
|
||||
<path
|
||||
d="M27.85 29H30l-6-15h-2.35l-6 15h2.15l1.6-4h6.85Zm-7.65-6 2.62-6.56L25.45 23ZM18 7V5h-7V2H9v3H2v2h10.74a14.7 14.7 0 0 1-3.19 6.18A13.5 13.5 0 0 1 7.26 9h-2.1a16.5 16.5 0 0 0 3 5.58A16.8 16.8 0 0 1 3 18l.75 1.86A18.5 18.5 0 0 0 9.53 16a16.9 16.9 0 0 0 5.76 3.84L16 18a14.5 14.5 0 0 1-5.12-3.37A17.64 17.64 0 0 0 14.8 7Z"
|
||||
/>
|
||||
</svg>
|
||||
</label>
|
||||
<select
|
||||
${ref(this.#selectRef)}
|
||||
part="select"
|
||||
id="locale-selector"
|
||||
@change=${this.#localeChangeListener}
|
||||
class="pf-c-form-control"
|
||||
name="locale"
|
||||
>
|
||||
${renderLocaleDisplayNames(entries, activeLocaleTag)}
|
||||
</select>`;
|
||||
});
|
||||
}
|
||||
|
||||
//#endregion
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ak-locale-select": AKLocaleSelect;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { TargetLocale } from "#common/ui/locale/definitions";
|
||||
import { TargetLanguageTag } from "#common/ui/locale/definitions";
|
||||
|
||||
import { createMixin } from "#elements/types";
|
||||
|
||||
@@ -37,9 +37,11 @@ export interface LocaleMixin {
|
||||
readonly [kAKLocale]: Readonly<LocaleContextValue>;
|
||||
|
||||
/**
|
||||
* The current locale code.
|
||||
* The current locale language tag.
|
||||
*
|
||||
* @format BCP 47
|
||||
*/
|
||||
locale: TargetLocale;
|
||||
activeLanguageTag: TargetLanguageTag;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -60,11 +62,11 @@ export const WithLocale = createMixin<LocaleMixin>(
|
||||
})
|
||||
public [kAKLocale]!: LocaleContextValue;
|
||||
|
||||
public get locale(): TargetLocale {
|
||||
return this[kAKLocale].getLocale() as TargetLocale;
|
||||
public get activeLanguageTag(): TargetLanguageTag {
|
||||
return this[kAKLocale].getLocale() as TargetLanguageTag;
|
||||
}
|
||||
|
||||
public set locale(value: TargetLocale) {
|
||||
public set activeLanguageTag(value: TargetLanguageTag) {
|
||||
this[kAKLocale].setLocale(value);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,46 +8,85 @@
|
||||
|
||||
.inspector-toggle {
|
||||
position: absolute;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
inset-inline-end: var(--pf-global--spacer--md);
|
||||
inset-block-start: var(--pf-global--spacer--md);
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.locale-selector {
|
||||
position: absolute;
|
||||
inset-block-start: 1rem;
|
||||
inset-inline-start: 1rem;
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
flex-flow: row;
|
||||
align-items: center;
|
||||
gap: var(--pf-global--spacer--sm);
|
||||
padding-inline-start: var(--pf-global--spacer--sm);
|
||||
padding-block: var(--pf-global--spacer--xs);
|
||||
border-radius: var(--pf-global--BorderRadius--sm);
|
||||
border-style: solid;
|
||||
border-color: transparent;
|
||||
border-width: thin;
|
||||
[part="locale-select"] {
|
||||
--ak-c-flow-executor__locale-select--Padding: var(--pf-global--spacer--md);
|
||||
--ak-c-flow-executor__locale-select--Color: var(--pf-global--Color--light-100);
|
||||
--ak-c-locale-select--label--Color: var(--ak-c-flow-executor__locale-select--Color);
|
||||
|
||||
&:has(select:hover) {
|
||||
cursor: pointer;
|
||||
border-color: var(--pf-global--Color--100);
|
||||
/* Compatibility mode */
|
||||
& {
|
||||
color: var(--ak-c-flow-executor__locale-select--Color);
|
||||
position: absolute;
|
||||
inset-block-start: var(--ak-c-flow-executor__locale-select--Padding);
|
||||
inset-inline-start: var(--ak-c-flow-executor__locale-select--Padding);
|
||||
font-weight: 500;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
select {
|
||||
background-color: transparent;
|
||||
color: var(--pf-global--Color--100);
|
||||
/* Slight differences in browser hover states. */
|
||||
&:has(select:hover),
|
||||
&:hover {
|
||||
--ak-c-locale-select--label--Color: var(
|
||||
--ak-c-flow-executor__locale-select--Color--hover,
|
||||
var(--ak-c-flow-executor__locale-select--Color)
|
||||
);
|
||||
--ak-c-locale-select--BackgroundColor: var(
|
||||
--ak-c-flow-executor__locale-select--BackgroundColor--hover
|
||||
);
|
||||
--ak-c-locale-select--TextDecorationColor: var(--ak-c-locale-select--label--Color);
|
||||
--ak-c-locale-select__after--Opacity: 1;
|
||||
|
||||
border: none;
|
||||
color: var(--ak-c-flow-executor__locale-select--Color--hover);
|
||||
|
||||
&:focus {
|
||||
outline: none;
|
||||
@media (prefers-contrast: more) {
|
||||
--ak-c-flow-executor__locale-select--Color--hover: var(
|
||||
--pf-global--primary-color--dark-100
|
||||
);
|
||||
|
||||
--ak-c-locale--select--OutlineColor: var(
|
||||
--ak-c-flow-executor__locale-select--Color--hover
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
appearance: base-select;
|
||||
&::part(select) {
|
||||
color: var(--ak-c-flow-executor__locale-select--Color);
|
||||
}
|
||||
|
||||
&::picker-icon {
|
||||
color: transparent;
|
||||
filter: var(--ak-global--background-contrast-Filter);
|
||||
|
||||
grid-area: header;
|
||||
|
||||
/* At least a third of the card cut-off is available. */
|
||||
@media (width <= 61.25rem) and (height <= 61.25rem) {
|
||||
--ak-global--background-contrast-Filter: none;
|
||||
--ak-c-flow-executor__locale-select--Color: var(--ak-global--background-contrast);
|
||||
|
||||
grid-area: main;
|
||||
}
|
||||
|
||||
@media (width <= 61.25rem) and (height <= 61.25rem) and (not (prefers-contrast: more)) {
|
||||
--ak-c-locale-select--Opacity: 0;
|
||||
|
||||
&:hover {
|
||||
--ak-c-locale-select--Opacity: 1;
|
||||
--ak-c-locale-select__after--Opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media (width >= 61.25rem) and (prefers-contrast: more) {
|
||||
--ak-c-flow-executor__locale-select--BackgroundColor--hover: var(
|
||||
--pf-global--BackgroundColor--150
|
||||
);
|
||||
}
|
||||
|
||||
/* Card is fully masked to mobile background. */
|
||||
@media (width <= 35rem) {
|
||||
grid-row: header;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import "#flow/stages/authenticator_webauthn/WebAuthnAuthenticatorRegisterStage";
|
||||
import "#elements/LoadingOverlay";
|
||||
import "#elements/locale/ak-locale-select";
|
||||
import "#flow/components/ak-brand-footer";
|
||||
import "#flow/components/ak-flow-card";
|
||||
import "#flow/sources/apple/AppleLoginInit";
|
||||
@@ -21,14 +22,11 @@ import { pluckErrorDetail } from "#common/errors/network";
|
||||
import { globalAK } from "#common/global";
|
||||
import { configureSentry } from "#common/sentry/index";
|
||||
import { applyBackgroundImageProperty } from "#common/theme";
|
||||
import { formatLocaleOptions, PseudoLocale, TargetLocale } from "#common/ui/locale/definitions";
|
||||
import { setSessionLocale } from "#common/ui/locale/utils";
|
||||
import { WebsocketClient, WSMessage } from "#common/ws";
|
||||
|
||||
import { Interface } from "#elements/Interface";
|
||||
import { WithBrandConfig } from "#elements/mixins/branding";
|
||||
import { WithCapabilitiesConfig } from "#elements/mixins/capabilities";
|
||||
import { WithLocale } from "#elements/mixins/locale";
|
||||
import { LitPropertyRecord } from "#elements/types";
|
||||
import { exportParts } from "#elements/utils/attributes";
|
||||
import { renderImage } from "#elements/utils/images";
|
||||
@@ -51,7 +49,6 @@ import { spread } from "@open-wc/lit-helpers";
|
||||
import { msg } from "@lit/localize";
|
||||
import { CSSResult, html, nothing, PropertyValues, TemplateResult } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators.js";
|
||||
import { repeat } from "lit/directives/repeat.js";
|
||||
import { unsafeHTML } from "lit/directives/unsafe-html.js";
|
||||
import { until } from "lit/directives/until.js";
|
||||
|
||||
@@ -64,7 +61,7 @@ import PFTitle from "@patternfly/patternfly/components/Title/title.css";
|
||||
|
||||
@customElement("ak-flow-executor")
|
||||
export class FlowExecutor
|
||||
extends WithCapabilitiesConfig(WithBrandConfig(WithLocale(Interface)))
|
||||
extends WithCapabilitiesConfig(WithBrandConfig(Interface))
|
||||
implements StageHost
|
||||
{
|
||||
static readonly DefaultLayout: FlowLayoutEnum =
|
||||
@@ -486,51 +483,6 @@ export class FlowExecutor
|
||||
</button>`;
|
||||
}
|
||||
|
||||
#localeChangeListener = (event: Event) => {
|
||||
const select = event.target as HTMLSelectElement;
|
||||
const locale = select.value as TargetLocale;
|
||||
|
||||
this.locale = locale;
|
||||
|
||||
setSessionLocale(locale);
|
||||
};
|
||||
|
||||
protected renderLocaleSelector() {
|
||||
const { locale } = this;
|
||||
let localeOptions = formatLocaleOptions();
|
||||
|
||||
if (!this.can(CapabilitiesEnum.CanDebug)) {
|
||||
localeOptions = localeOptions.filter(([, code]) => code !== PseudoLocale);
|
||||
}
|
||||
|
||||
const options = repeat(
|
||||
localeOptions,
|
||||
([_locale, code]) => code,
|
||||
([label, code]) =>
|
||||
html`<option value=${code} ?selected=${code === locale}>${label}</option>`,
|
||||
);
|
||||
|
||||
return html`<div class="locale-selector">
|
||||
<label
|
||||
for="locale-selector"
|
||||
aria-label=${msg("Select language", {
|
||||
id: "language-selector-label",
|
||||
desc: "Label for the language selection dropdown",
|
||||
})}
|
||||
>
|
||||
<i class="fa fa-globe" aria-hidden="true"></i>
|
||||
</label>
|
||||
<select
|
||||
id="locale-selector"
|
||||
@change=${this.#localeChangeListener}
|
||||
class="pf-c-form-control"
|
||||
name="locale"
|
||||
>
|
||||
${options}
|
||||
</select>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Render
|
||||
@@ -542,9 +494,12 @@ export class FlowExecutor
|
||||
public override render(): TemplateResult {
|
||||
const { component } = this.challenge || {};
|
||||
|
||||
return html`<header class="pf-c-login__header">
|
||||
${this.renderLocaleSelector()} ${this.renderInspectorButton()}
|
||||
</header>
|
||||
return html` <ak-locale-select
|
||||
part="locale-select"
|
||||
exportparts="label:locale-select-label,select:locale-select-select"
|
||||
></ak-locale-select>
|
||||
|
||||
<header class="pf-c-login__header">${this.renderInspectorButton()}</header>
|
||||
<main
|
||||
data-layout=${this.layout}
|
||||
class="pf-c-login__main"
|
||||
|
||||
@@ -192,7 +192,7 @@ export class CaptchaStage extends BaseStage<CaptchaChallenge, CaptchaChallengeRe
|
||||
sitekey: this.challenge.siteKey,
|
||||
callback: this.onTokenChange,
|
||||
size: "invisible",
|
||||
hl: this.locale,
|
||||
hl: this.activeLanguageTag,
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -227,7 +227,7 @@ export class CaptchaStage extends BaseStage<CaptchaChallenge, CaptchaChallengeRe
|
||||
sitekey: this.challenge.siteKey,
|
||||
callback: this.onTokenChange,
|
||||
size: "invisible",
|
||||
hl: this.locale,
|
||||
hl: this.activeLanguageTag,
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -253,7 +253,7 @@ export class CaptchaStage extends BaseStage<CaptchaChallenge, CaptchaChallengeRe
|
||||
data-theme="${this.activeTheme}"
|
||||
data-callback="callback"
|
||||
data-size="flexible"
|
||||
data-language=${ifPresent(this.locale)}
|
||||
data-language=${ifPresent(this.activeLanguageTag)}
|
||||
></div>`;
|
||||
};
|
||||
|
||||
|
||||
@@ -11,8 +11,9 @@ export class AkRememberMeController implements ReactiveController {
|
||||
static styles = [
|
||||
css`
|
||||
.remember-me-switch {
|
||||
display: inline-block;
|
||||
padding-top: 0.25rem;
|
||||
display: flex;
|
||||
padding-top: var(--pf-global--spacer--sm);
|
||||
gap: var(--pf-global--spacer--sm);
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
fieldset[name="login-sources"] {
|
||||
--ak-login-sources-padding-inline: var(--pf-global--spacer--xl);
|
||||
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
flex-flow: row wrap;
|
||||
justify-content: center;
|
||||
gap: var(--pf-global--spacer--sm);
|
||||
|
||||
/* compatibility-mode-fix */
|
||||
|
||||
& {
|
||||
flex: 1 1 auto;
|
||||
display: flex;
|
||||
flex-flow: row wrap;
|
||||
justify-content: center;
|
||||
gap: var(--pf-global--spacer--sm);
|
||||
|
||||
padding-inline: var(--ak-login-sources-padding-inline) !important;
|
||||
padding-block-start: var(--pf-global--spacer--md) !important;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import "#elements/Divider";
|
||||
import "#flow/components/ak-flow-card";
|
||||
|
||||
import { formatLocaleOptions, PseudoLocale } from "#common/ui/locale/definitions";
|
||||
import { formatLocaleDisplayNames, renderLocaleDisplayNames } from "#common/ui/locale/format";
|
||||
import { getBestMatchLocale } from "#common/ui/locale/utils";
|
||||
|
||||
import { CapabilitiesEnum, WithCapabilitiesConfig } from "#elements/mixins/capabilities";
|
||||
import { WithCapabilitiesConfig } from "#elements/mixins/capabilities";
|
||||
|
||||
import { AKFormErrors } from "#components/ak-field-errors";
|
||||
import { AKLabel } from "#components/ak-label";
|
||||
@@ -219,22 +219,12 @@ ${prompt.initialValue}</textarea
|
||||
</div> `;
|
||||
})}`;
|
||||
case PromptTypeEnum.AkLocale: {
|
||||
let localeOptions = formatLocaleOptions();
|
||||
const selected = prompt.initialValue
|
||||
const entries = formatLocaleDisplayNames(this.activeLanguageTag);
|
||||
|
||||
const currentLanguageTag = prompt.initialValue
|
||||
? getBestMatchLocale(prompt.initialValue)
|
||||
: null;
|
||||
|
||||
if (!this.can(CapabilitiesEnum.CanDebug)) {
|
||||
localeOptions = localeOptions.filter(([, code]) => code !== PseudoLocale);
|
||||
}
|
||||
|
||||
const options = localeOptions.map(
|
||||
([label, code]) =>
|
||||
html`<option value=${code} ?selected=${code === selected}>
|
||||
${label}
|
||||
</option>`,
|
||||
);
|
||||
|
||||
return html`<select
|
||||
class="pf-c-form-control"
|
||||
id=${fieldId}
|
||||
@@ -244,14 +234,14 @@ ${prompt.initialValue}</textarea
|
||||
desc: "Label for the language selection dropdown",
|
||||
})}
|
||||
>
|
||||
<option value="" ?selected=${!selected}>
|
||||
<option value="" ?selected=${!currentLanguageTag}>
|
||||
${msg("Auto-detect", {
|
||||
id: "locale-auto-detect-option",
|
||||
desc: "Label for the auto-detect locale option in language selection dropdown",
|
||||
})}
|
||||
</option>
|
||||
<hr />
|
||||
${options}
|
||||
${renderLocaleDisplayNames(entries, currentLanguageTag)}
|
||||
</select>`;
|
||||
}
|
||||
default:
|
||||
|
||||
@@ -13,6 +13,7 @@ export const sourceLocale = `en`;
|
||||
export const targetLocales = [
|
||||
`cs-CZ`,
|
||||
`de-DE`,
|
||||
`en-XA`,
|
||||
`es-ES`,
|
||||
`fi-FI`,
|
||||
`fr-FR`,
|
||||
@@ -21,7 +22,6 @@ export const targetLocales = [
|
||||
`ko-KR`,
|
||||
`nl-NL`,
|
||||
`pl-PL`,
|
||||
`pseudo-LOCALE`,
|
||||
`pt-BR`,
|
||||
`ru-RU`,
|
||||
`tr-TR`,
|
||||
@@ -36,6 +36,7 @@ export const allLocales = [
|
||||
`cs-CZ`,
|
||||
`de-DE`,
|
||||
`en`,
|
||||
`en-XA`,
|
||||
`es-ES`,
|
||||
`fi-FI`,
|
||||
`fr-FR`,
|
||||
@@ -44,7 +45,6 @@ export const allLocales = [
|
||||
`ko-KR`,
|
||||
`nl-NL`,
|
||||
`pl-PL`,
|
||||
`pseudo-LOCALE`,
|
||||
`pt-BR`,
|
||||
`ru-RU`,
|
||||
`tr-TR`,
|
||||
|
||||
@@ -33,6 +33,10 @@
|
||||
--ak-dark-background-lighter: #2b2e33;
|
||||
|
||||
--ak-global--background-contrast: var(--pf-global--Color--100);
|
||||
--ak-global--background-contrast-Filter: drop-shadow(
|
||||
0 0 2px
|
||||
var(--ak-locale-select--ShadowBlendColor, var(--pf-global--BackgroundColor--dark-200))
|
||||
);
|
||||
|
||||
/* Minimum width after which the sidebar becomes automatic */
|
||||
--ak-sidebar--minimum-auto-width: 80rem;
|
||||
|
||||
@@ -287,7 +287,7 @@
|
||||
flex-direction: column;
|
||||
align-self: end;
|
||||
justify-content: center;
|
||||
padding-inline: var(--pf-global--spacer--2xl);
|
||||
padding-inline: var(--pf-global--spacer--xl) !important;
|
||||
padding-block: var(--ak-login__footer--PaddingBlock) !important;
|
||||
min-height: calc((var(--ak-login__footer--PaddingBlock) * 2) + 1rem);
|
||||
line-height: var(--pf-global--LineHeight--md);
|
||||
@@ -298,6 +298,10 @@
|
||||
@media (max-width: 35rem) {
|
||||
color: var(--pf-global--Color--200);
|
||||
}
|
||||
|
||||
@media (min-width: 35rem) and (min-height: 17.5rem) {
|
||||
filter: var(--ak-global--background-contrast-Filter);
|
||||
}
|
||||
}
|
||||
|
||||
/* #endregion */
|
||||
|
||||
@@ -11,6 +11,9 @@
|
||||
@import "./components/Form/form.css";
|
||||
@import "./components/Login/login.css";
|
||||
@import "./components/Icon/icon.css";
|
||||
@import "#elements/locale/ak-locale-select.css";
|
||||
@import "#elements/locale/ak-locale-select.css";
|
||||
@import "#flow/FlowExecutor.css";
|
||||
|
||||
.pf-c-login__main-body {
|
||||
display: flex;
|
||||
|
||||
Reference in New Issue
Block a user