Files
authentik/web/src/common/theme.ts
Jens L. 0cf6bff93c tests/e2e: add test for authentication flow in compatibility mode (#14392)
* tests/e2e: add test for authentication flow in compatibility mode

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* web: Add prefix class to CSS for easier debugging of constructed stylesheets.

- Use CSS variables for highlighter.

* web: Fix issue where MDX components apply styles out of order.

* web: Fix hover color.

* web: Fix CSS module types. Clean up globals.

* web: Fix issues surrounding availability of shadow root in compatibility mode.

* web: Fix typo.

* web: Partial fixes for storybook dark theme.

* web: Fix overflow.

* web: Fix issues surrounding competing interfaces attempting to apply styles.

* fix padding in ak-alert in. markdown

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

* web: Minimize use of sub-module exports.

---------

Signed-off-by: Jens Langhammer <jens@goauthentik.io>
Co-authored-by: Teffen Ellis <teffen@sister.software>
2025-05-15 16:51:11 +02:00

326 lines
8.9 KiB
TypeScript

/**
* @file Theme utilities.
*/
import {
type StyleRoot,
createStyleSheetUnsafe,
setAdoptedStyleSheets,
} from "@goauthentik/common/stylesheets.js";
import { UIConfig } from "@goauthentik/common/ui/config.js";
import AKBase from "@goauthentik/common/styles/authentik.css";
import AKBaseDark from "@goauthentik/common/styles/theme-dark.css";
import PFBase from "@patternfly/patternfly/patternfly-base.css";
import { Config, CurrentBrand, UiThemeEnum } from "@goauthentik/api";
//#region Stylesheet Exports
/**
* A global style sheet for the Patternfly base styles.
*
* @remarks
*
* While a component *may* import its own instance of the PFBase style sheet,
* this instance ensures referential identity.
*/
export const $PFBase = createStyleSheetUnsafe(PFBase);
/**
* A global style sheet for the authentik base styles.
*
* @see {@linkcode $PFBase} for details.
*/
export const $AKBase = createStyleSheetUnsafe(AKBase);
/**
* A global style sheet for the authentik dark theme.
*
* @see {@linkcode $PFBase} for details.
*/
export const $AKBaseDark = createStyleSheetUnsafe(AKBaseDark);
//#endregion
//#region Scheme Types
/**
* Valid CSS color scheme values.
*
* @link {@link https://developer.mozilla.org/en-US/docs/Web/CSS/@media/prefers-color-scheme | MDN}
*
* @category CSS
*/
export type CSSColorSchemeValue = "dark" | "light" | "auto";
/**
* A CSS color scheme value that can be preferred by the user, i.e. not `"auto"`.
*
* @category CSS
*/
export type ResolvedCSSColorSchemeValue = Exclude<CSSColorSchemeValue, "auto">;
//#endregion
//#region UI Theme Types
/**
* A UI color scheme value that can be preferred by the user.
*
* i.e. not an lack of preference or unknown value.
*
* @category CSS
*/
export type ResolvedUITheme = typeof UiThemeEnum.Light | typeof UiThemeEnum.Dark;
/**
* A mapping of theme values to their respective inversion.
*
* @category CSS
*/
export const UIThemeInversion = {
dark: "light",
light: "dark",
} as const satisfies Record<ResolvedUITheme, ResolvedUITheme>;
/**
* Either a valid CSS color scheme value, or a theme preference.
*/
export type UIThemeHint = CSSColorSchemeValue | UiThemeEnum;
//#endregion
//#region Scheme Functions
/**
* Creates an event target for the given color scheme.
*
* @param colorScheme The color scheme to target.
* @returns A {@linkcode MediaQueryList} that can be used to listen for changes to the color scheme.
*
* @see {@link https://developer.mozilla.org/en-US/docs/Web/API/MediaQueryList | MDN}
*
* @category CSS
*/
export function createColorSchemeTarget(colorScheme: ResolvedCSSColorSchemeValue): MediaQueryList {
return window.matchMedia(`(prefers-color-scheme: ${colorScheme})`);
}
/**
* Formats the given input into a valid CSS color scheme value.
*
* If the input is not provided, it defaults to "auto".
*
* @category CSS
*/
export function formatColorScheme(theme: ResolvedUITheme): ResolvedCSSColorSchemeValue;
export function formatColorScheme(
colorScheme: ResolvedCSSColorSchemeValue,
): ResolvedCSSColorSchemeValue;
export function formatColorScheme(hint?: UIThemeHint): CSSColorSchemeValue;
export function formatColorScheme(hint?: UIThemeHint): CSSColorSchemeValue {
if (!hint) return "auto";
switch (hint) {
case "dark":
case UiThemeEnum.Dark:
return "dark";
case "light":
case UiThemeEnum.Light:
return "light";
case "auto":
case UiThemeEnum.Automatic:
return "auto";
default:
console.warn(`Unknown color scheme hint: ${hint}. Defaulting to "auto".`);
return "auto";
}
}
//#endregion
//#region Theme Functions
/**
* Resolve the current UI theme based on the user's preference or the provided color scheme.
*
* @param hint The color scheme hint to use.
*
* @category CSS
*/
export function resolveUITheme(
hint?: UIThemeHint,
defaultUITheme: ResolvedUITheme = UiThemeEnum.Light,
): ResolvedUITheme {
const colorScheme = formatColorScheme(hint);
if (colorScheme !== "auto") return colorScheme;
// Given that we don't know the user's preference,
// we can determine the theme based on whether the default theme is
// currently being overridden.
const colorSchemeInversion = formatColorScheme(UIThemeInversion[defaultUITheme]);
const mediaQueryList = createColorSchemeTarget(colorSchemeInversion);
return mediaQueryList.matches ? colorSchemeInversion : defaultUITheme;
}
/**
* Effect listener invoked when the color scheme changes.
*/
export type UIThemeListener = (currentUITheme: ResolvedUITheme) => void;
/**
* Effect destructor invoked when cleanup is required.
*/
export type UIThemeDestructor = () => void;
/**
* Create an effect that runs UI theme changes.
*
* @returns A cleanup function that removes the effect.
*/
export function createUIThemeEffect(
effect: UIThemeListener,
listenerOptions?: AddEventListenerOptions,
): UIThemeDestructor {
const colorSchemeTarget = resolveUITheme();
const invertedColorSchemeTarget = UIThemeInversion[colorSchemeTarget];
let previousUITheme: ResolvedUITheme | undefined;
// First, wrap the effect to ensure we can abort it.
const changeListener = (event: MediaQueryListEvent) => {
if (listenerOptions?.signal?.aborted) return;
const currentUITheme = event.matches ? colorSchemeTarget : invertedColorSchemeTarget;
if (previousUITheme === currentUITheme) return;
previousUITheme = currentUITheme;
effect(currentUITheme);
};
const mediaQueryList = createColorSchemeTarget(colorSchemeTarget);
// Trigger the effect immediately.
effect(colorSchemeTarget);
// Listen for changes to the color scheme...
mediaQueryList.addEventListener("change", changeListener, listenerOptions);
// Finally, allow the caller to remove the effect.
const cleanup = () => {
mediaQueryList.removeEventListener("change", changeListener);
};
listenerOptions?.signal?.addEventListener("abort", cleanup);
return cleanup;
}
//#endregion
//#region Theme Element
/**
* Applies the current UI theme to the given style root.
*
* @param styleRoot The style root to apply the theme to.
* @param currentUITheme The current UI theme to apply.
* @param additionalStyleSheets Additional style sheets to apply, in addition to the theme's base sheets.
* @category CSS
*
* @see {@linkcode setAdoptedStyleSheets} for caveats.
*/
export function applyUITheme(
styleRoot: StyleRoot,
currentUITheme: ResolvedUITheme = resolveUITheme(),
...additionalStyleSheets: Array<CSSStyleSheet | undefined | null>
): void {
setAdoptedStyleSheets(styleRoot, (currentStyleSheets) => {
const appendedSheets = additionalStyleSheets.filter(Boolean) as CSSStyleSheet[];
if (currentUITheme === UiThemeEnum.Dark) {
return [...currentStyleSheets, $AKBaseDark, ...appendedSheets];
}
return [
...currentStyleSheets.filter((styleSheet) => styleSheet !== $AKBaseDark),
...appendedSheets,
];
});
}
/**
* Applies the given theme to the document, i.e. the `<html>` element.
*
* @param hint The color scheme hint to use.
*/
export function applyDocumentTheme(hint: CSSColorSchemeValue | UIThemeHint = "auto"): void {
const preferredColorScheme = formatColorScheme(hint);
const applyStyleSheets: UIThemeListener = (currentUITheme) => {
console.debug(`authentik/theme (document): switching to ${currentUITheme} theme`);
setAdoptedStyleSheets(document, (currentStyleSheets) => {
if (currentUITheme === "dark") {
return [...currentStyleSheets, $PFBase, $AKBase, $AKBaseDark];
}
return [
...currentStyleSheets.filter((styleSheet) => styleSheet !== $AKBaseDark),
$PFBase,
$AKBase,
];
});
document.documentElement.dataset.theme = currentUITheme;
};
if (preferredColorScheme === "auto") {
createUIThemeEffect(applyStyleSheets);
return;
}
applyStyleSheets(preferredColorScheme);
}
/**
* An element that can be themed.
*/
export interface ThemedElement extends HTMLElement {
/**
* The brand information for the current theme.
*/
readonly brand?: CurrentBrand;
/**
* The UI configuration for the current theme,
* typically injected through a Lit Mixin.
*
* @see {@linkcode UIConfig} for details.
*/
readonly uiConfig?: UIConfig;
/**
* An authentik configuration initially provided by the server.
*/
readonly config?: Config;
activeTheme: ResolvedUITheme;
}
/**
* Returns the root interface element of the page.
*
* @todo Can this be handled with a Lit Mixin?
*/
export function rootInterface<T extends ThemedElement = ThemedElement>(): T | null {
const element = document.body.querySelector<T>("[data-ak-interface-root]");
return element;
}
//#endregion