This repository has been archived on 2024-05-31. You can view files and clone it, but cannot push or open issues or pull requests.
authentik/web/src/elements/Base.ts
Ken Sternberg 4e5ea05987
web: refactor locale handler into top-level context handler (#6022)
* web: begin refactoring the application for future development

This commit:

- Deletes a bit of code.
- Extracts *all* of the Locale logic into a single folder, turns management of the Locale files over
  to Lit itself, and restricts our responsibility to setting the locale on startup and when the user
  changes the locale. We do this by converting a lot of internal calls into events; a request to
  change a locale isn't a function call, it's an event emitted asking `REQUEST_LOCALE_CHANGE`. We've
  even eliminated the `DETECT_LOCALE_CHANGE` event, which redrew elements with text in them, since
  Lit's own `@localized()` decorator does that for us automagically.
- We wrap our interfaces in an `ak-locale-context` that handles the startup and listens for the
  `REQUEST_LOCALE_CHANGE` event.
- ... and that's pretty much it.  Adding `@localized()` as a default behavior to `AKElement` means
  no more custom localization is needed *anywhere*.

* web: improve the localization experience

This commit fixes the Storybook story for the localization context component,
and fixes the localization initialization pass so that it is only called once
per interface environment initialization.  Since all our interfaces share the
same environment (the Django server), this preserves functionality across
all interfaces.

---------

Co-authored-by: Jens Langhammer <jens@goauthentik.io>
2023-07-07 14:23:10 +00:00

193 lines
6.4 KiB
TypeScript

import { config, tenant } from "@goauthentik/common/api/config";
import { EVENT_THEME_CHANGE } from "@goauthentik/common/constants";
import { UIConfig, uiConfig } from "@goauthentik/common/ui/config";
import { adaptCSS } from "@goauthentik/common/utils";
import { localized } from "@lit/localize";
import { LitElement } from "lit";
import { state } from "lit/decorators.js";
import AKGlobal from "@goauthentik/common/styles/authentik.css";
import ThemeDark from "@goauthentik/common/styles/theme-dark.css";
import PFBase from "@patternfly/patternfly/patternfly-base.css";
import { Config, CurrentTenant, UiThemeEnum } from "@goauthentik/api";
export function rootInterface<T extends Interface>(): T | undefined {
const el = Array.from(document.body.querySelectorAll("*")).filter(
(el) => el instanceof Interface,
);
return el[0] as T;
}
let css: Promise<string[]> | undefined;
function fetchCustomCSS(): Promise<string[]> {
if (!css) {
css = Promise.all(
Array.of(...document.head.querySelectorAll<HTMLLinkElement>("link[data-inject]")).map(
(link) => {
return fetch(link.href)
.then((res) => {
return res.text();
})
.finally(() => {
return "";
});
},
),
);
}
return css;
}
export interface AdoptedStyleSheetsElement {
adoptedStyleSheets: readonly CSSStyleSheet[];
}
const QUERY_MEDIA_COLOR_LIGHT = "(prefers-color-scheme: light)";
@localized()
export class AKElement extends LitElement {
_mediaMatcher?: MediaQueryList;
_mediaMatcherHandler?: (ev?: MediaQueryListEvent) => void;
_activeTheme?: UiThemeEnum;
get activeTheme(): UiThemeEnum | undefined {
return this._activeTheme;
}
constructor() {
super();
}
protected createRenderRoot(): ShadowRoot | Element {
const root = super.createRenderRoot() as ShadowRoot;
let styleRoot: AdoptedStyleSheetsElement = root;
if ("ShadyDOM" in window) {
styleRoot = document;
}
styleRoot.adoptedStyleSheets = adaptCSS([...styleRoot.adoptedStyleSheets, AKGlobal]);
this._initTheme(styleRoot);
this._initCustomCSS(styleRoot);
return root;
}
async getTheme(): Promise<UiThemeEnum> {
return rootInterface()?.getTheme() || UiThemeEnum.Automatic;
}
async _initTheme(root: AdoptedStyleSheetsElement): Promise<void> {
// Early activate theme based on media query to prevent light flash
// when dark is preferred
this._activateTheme(
root,
window.matchMedia(QUERY_MEDIA_COLOR_LIGHT).matches
? UiThemeEnum.Light
: UiThemeEnum.Dark,
);
this._applyTheme(root, await this.getTheme());
}
private async _initCustomCSS(root: AdoptedStyleSheetsElement): Promise<void> {
const sheets = await fetchCustomCSS();
sheets.map((css) => {
if (css === "") {
return;
}
new CSSStyleSheet().replace(css).then((sheet) => {
root.adoptedStyleSheets = [...root.adoptedStyleSheets, sheet];
});
});
}
_applyTheme(root: AdoptedStyleSheetsElement, theme?: UiThemeEnum): void {
if (!theme) {
theme = UiThemeEnum.Automatic;
}
if (theme === UiThemeEnum.Automatic) {
// Create a media matcher to automatically switch the theme depending on
// prefers-color-scheme
if (!this._mediaMatcher) {
this._mediaMatcher = window.matchMedia(QUERY_MEDIA_COLOR_LIGHT);
this._mediaMatcherHandler = (ev?: MediaQueryListEvent) => {
const theme =
ev?.matches || this._mediaMatcher?.matches
? UiThemeEnum.Light
: UiThemeEnum.Dark;
this._activateTheme(root, theme);
};
this._mediaMatcher.addEventListener("change", this._mediaMatcherHandler);
}
return;
} else if (this._mediaMatcher && this._mediaMatcherHandler) {
// Theme isn't automatic and we have a matcher configured, remove the matcher
// to prevent changes
this._mediaMatcher.removeEventListener("change", this._mediaMatcherHandler);
this._mediaMatcher = undefined;
}
this._activateTheme(root, theme);
}
static themeToStylesheet(theme?: UiThemeEnum): CSSStyleSheet | undefined {
if (theme === UiThemeEnum.Dark) {
return ThemeDark;
}
return undefined;
}
_activateTheme(root: AdoptedStyleSheetsElement, theme: UiThemeEnum) {
if (theme === this._activeTheme) {
return;
}
// Make sure we only get to this callback once we've picked a concise theme choice
this.dispatchEvent(
new CustomEvent(EVENT_THEME_CHANGE, {
bubbles: true,
composed: true,
detail: theme,
}),
);
this.setAttribute("theme", theme);
const stylesheet = AKElement.themeToStylesheet(theme);
const oldStylesheet = AKElement.themeToStylesheet(this._activeTheme);
if (stylesheet) {
root.adoptedStyleSheets = [...root.adoptedStyleSheets, stylesheet];
}
if (oldStylesheet) {
root.adoptedStyleSheets = root.adoptedStyleSheets.filter((v) => v !== oldStylesheet);
}
this._activeTheme = theme;
this.requestUpdate();
}
}
export class Interface extends AKElement {
@state()
tenant?: CurrentTenant;
@state()
uiConfig?: UIConfig;
@state()
config?: Config;
constructor() {
super();
document.adoptedStyleSheets = [...document.adoptedStyleSheets, PFBase];
tenant().then((tenant) => (this.tenant = tenant));
config().then((config) => (this.config = config));
}
_activateTheme(root: AdoptedStyleSheetsElement, theme: UiThemeEnum): void {
super._activateTheme(root, theme);
super._activateTheme(document, theme);
}
async getTheme(): Promise<UiThemeEnum> {
if (!this.uiConfig) {
this.uiConfig = await uiConfig();
}
return this.uiConfig.theme?.base || UiThemeEnum.Automatic;
}
}