diff --git a/web/.gitignore b/web/.gitignore index 4dddfa73e0..1d7ebbe353 100644 --- a/web/.gitignore +++ b/web/.gitignore @@ -6,6 +6,7 @@ src/locales/*.ts xliff/pseudo[_-]LOCALE.xlf +xliff/en[_-]XA.xlf ### Node ### # Logs diff --git a/web/lit-localize.json b/web/lit-localize.json index 7526918739..beac52907f 100644 --- a/web/lit-localize.json +++ b/web/lit-localize.json @@ -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": { diff --git a/web/scripts/build-locales.mjs b/web/scripts/build-locales.mjs index 15e90234d0..ff025c6db9 100644 --- a/web/scripts/build-locales.mjs +++ b/web/scripts/build-locales.mjs @@ -52,7 +52,7 @@ const EmittedLocalesDirectory = resolve( ); const targetLocales = localizeRules.targetLocales.filter((localeCode) => { - return localeCode !== "pseudo-LOCALE"; + return localeCode !== "en-XA"; }); //#endregion diff --git a/web/scripts/pseudolocalize.mjs b/web/scripts/pseudolocalize.mjs index ca661e7203..0afc1579a6 100644 --- a/web/scripts/pseudolocalize.mjs +++ b/web/scripts/pseudolocalize.mjs @@ -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)); diff --git a/web/src/common/global.ts b/web/src/common/global.ts index d6fe4c947b..ce93884a8b 100644 --- a/web/src/common/global.ts +++ b/web/src/common/global.ts @@ -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; }; diff --git a/web/src/common/sets.ts b/web/src/common/sets.ts new file mode 100644 index 0000000000..3b501dea0c --- /dev/null +++ b/web/src/common/sets.ts @@ -0,0 +1,8 @@ +/** + * @file Set utilities. + */ + +/** + * Given a {@linkcode Set}, extract the type of its elements. + */ +export type UnwrapSet> = T extends Set ? U : never; diff --git a/web/src/common/ui/locale/cjk.ts b/web/src/common/ui/locale/cjk.ts new file mode 100644 index 0000000000..d348fab62f --- /dev/null +++ b/web/src/common/ui/locale/cjk.ts @@ -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; + +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; + +/** + * A set of **supported language tags** representing Chinese, Japanese, and Korean languages. + */ +export const CJKLanguageTags = new Set(Object.values(CJKLanguageTag)); + +//#endregion + +export const HanScriptTag = { + Simplified: "Hans", + Traditional: "Hant", +} as const satisfies Record; + +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 = 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 diff --git a/web/src/common/ui/locale/definitions.ts b/web/src/common/ui/locale/definitions.ts index eea118bf61..2da39a1965 100644 --- a/web/src/common/ui/locale/definitions.ts +++ b/web/src/common/ui/locale/definitions.ts @@ -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 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 = { - [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 Promise> = { - [sourceLocale]: () => Promise.resolve(sourceTargetModule), - [PseudoLocale]: () => import("#locales/pseudo-LOCALE"), +export const LocaleLoaderRecord: Record Promise> = { + [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 Promise 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 = { - [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( - Object.entries(LocalePatternRecord).map(([code, pattern]) => [pattern, code as TargetLocale]), -); diff --git a/web/src/common/ui/locale/format.ts b/web/src/common/ui/locale/format.ts new file mode 100644 index 0000000000..7fc9cfd73b --- /dev/null +++ b/web/src/common/ui/locale/format.ts @@ -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 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(); + const displayNames = new Map(); + + // 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`
` : null} + `; + }, + ); +} diff --git a/web/src/common/ui/locale/utils.ts b/web/src/common/ui/locale/utils.ts index 7f2664681c..66c41fbe47 100644 --- a/web/src/common/ui/locale/utils.ts +++ b/web/src/common/ui/locale/utils.ts @@ -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(); + +//#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; diff --git a/web/src/elements/controllers/LocaleContextController.ts b/web/src/elements/controllers/LocaleContextController.ts index 5043c848d8..e4514380fa 100644 --- a/web/src/elements/controllers/LocaleContextController.ts +++ b/web/src/elements/controllers/LocaleContextController.ts @@ -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, localeHint?: TargetLocale) { + constructor(host: ReactiveElementHost, localeHint?: TargetLanguageTag) { this.#host = host; const contextValue = configureLocalization({ diff --git a/web/src/elements/locale/ak-locale-select.css b/web/src/elements/locale/ak-locale-select.css new file mode 100644 index 0000000000..d76db41e13 --- /dev/null +++ b/web/src/elements/locale/ak-locale-select.css @@ -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; +} diff --git a/web/src/elements/locale/ak-locale-select.ts b/web/src/elements/locale/ak-locale-select.ts new file mode 100644 index 0000000000..26573099a0 --- /dev/null +++ b/web/src/elements/locale/ak-locale-select.ts @@ -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) => { + 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(); + + 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): 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` + `; + }); + } + + //#endregion +} + +declare global { + interface HTMLElementTagNameMap { + "ak-locale-select": AKLocaleSelect; + } +} diff --git a/web/src/elements/mixins/locale.ts b/web/src/elements/mixins/locale.ts index c132e7649d..4af490be47 100644 --- a/web/src/elements/mixins/locale.ts +++ b/web/src/elements/mixins/locale.ts @@ -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; /** - * The current locale code. + * The current locale language tag. + * + * @format BCP 47 */ - locale: TargetLocale; + activeLanguageTag: TargetLanguageTag; } /** @@ -60,11 +62,11 @@ export const WithLocale = createMixin( }) 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); } } diff --git a/web/src/flow/FlowExecutor.css b/web/src/flow/FlowExecutor.css index cec759eb88..172d0a1d07 100644 --- a/web/src/flow/FlowExecutor.css +++ b/web/src/flow/FlowExecutor.css @@ -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; + } } diff --git a/web/src/flow/FlowExecutor.ts b/web/src/flow/FlowExecutor.ts index 4feecd7bca..9f4c2547bf 100644 --- a/web/src/flow/FlowExecutor.ts +++ b/web/src/flow/FlowExecutor.ts @@ -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 `; } - #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``, - ); - - return html`
- - -
`; - } - //#endregion //#region Render @@ -542,9 +494,12 @@ export class FlowExecutor public override render(): TemplateResult { const { component } = this.challenge || {}; - return html` + return html` + +
`; }; diff --git a/web/src/flow/stages/identification/RememberMeController.ts b/web/src/flow/stages/identification/RememberMeController.ts index 32f2e8b924..66960b3f03 100644 --- a/web/src/flow/stages/identification/RememberMeController.ts +++ b/web/src/flow/stages/identification/RememberMeController.ts @@ -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); } `, ]; diff --git a/web/src/flow/stages/identification/styles.css b/web/src/flow/stages/identification/styles.css index 593260e0d0..9c94a7bfdd 100644 --- a/web/src/flow/stages/identification/styles.css +++ b/web/src/flow/stages/identification/styles.css @@ -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; } diff --git a/web/src/flow/stages/prompt/PromptStage.ts b/web/src/flow/stages/prompt/PromptStage.ts index 311d6257dd..65fa94a725 100644 --- a/web/src/flow/stages/prompt/PromptStage.ts +++ b/web/src/flow/stages/prompt/PromptStage.ts @@ -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} `; })}`; 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``, - ); - return html``; } default: diff --git a/web/src/locale-codes.ts b/web/src/locale-codes.ts index 3b5495f585..96ac03f2fd 100644 --- a/web/src/locale-codes.ts +++ b/web/src/locale-codes.ts @@ -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`, diff --git a/web/src/styles/authentik/base/variables.css b/web/src/styles/authentik/base/variables.css index 4544d1b60a..61e1b5536d 100644 --- a/web/src/styles/authentik/base/variables.css +++ b/web/src/styles/authentik/base/variables.css @@ -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; diff --git a/web/src/styles/authentik/components/Login/login.css b/web/src/styles/authentik/components/Login/login.css index 909dcb6826..cf0ffbed67 100644 --- a/web/src/styles/authentik/components/Login/login.css +++ b/web/src/styles/authentik/components/Login/login.css @@ -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 */ diff --git a/web/src/styles/authentik/static.global.css b/web/src/styles/authentik/static.global.css index 7ad1bb5163..568a4c6bdb 100644 --- a/web/src/styles/authentik/static.global.css +++ b/web/src/styles/authentik/static.global.css @@ -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;