Files
authentik/web/src/elements/Base.ts
Mmx233 54fad67e11 web: fix Brand CSS not applied to nested Shadow DOM components (#19892)
* web: fix Brand CSS not applied to nested Shadow DOM components

After PR #17444, Brand CSS was only applied when ThemeChangeEvent fired.
Components created after the initial event never received the custom styles.

This fix immediately applies Brand CSS when a style root is set, ensuring
all nested Shadow DOM components (like flow stages) receive brand styling
regardless of when they are created.

* Update web/src/elements/Base.ts

Signed-off-by: Teffen Ellis <592134+GirlBossRush@users.noreply.github.com>

* Clarify.

---------

Signed-off-by: Teffen Ellis <592134+GirlBossRush@users.noreply.github.com>
Co-authored-by: Teffen Ellis <592134+GirlBossRush@users.noreply.github.com>
2026-02-01 07:51:34 +01:00

228 lines
7.2 KiB
TypeScript

import { globalAK } from "#common/global";
import { createCSSResult, createStyleSheetUnsafe, StyleRoot } from "#common/stylesheets";
import { applyUITheme, ResolvedUITheme, resolveUITheme, ThemeChangeEvent } from "#common/theme";
import AKBase from "#styles/authentik/base.css" with { type: "bundled-text" };
import PFBase from "#styles/patternfly/base.css" with { type: "bundled-text" };
import { localized } from "@lit/localize";
import { CSSResult, CSSResultGroup, CSSResultOrNative, LitElement, PropertyValues } from "lit";
import { property } from "lit/decorators.js";
/**
* Patternfly base styles, providing common variables and resets.
*
* @remarks
*
* This style sheet **must** be included before any other styles that depend on Patternfly variables.
*/
const $PFBase = createStyleSheetUnsafe(PFBase);
/**
* authentik base styles, providing overrides to Patternfly's initial definitions,
* and additional customizations.
*/
const $AKBase = createStyleSheetUnsafe(AKBase);
export interface AKElementProps {
activeTheme: ResolvedUITheme;
}
@localized()
export class AKElement extends LitElement implements AKElementProps {
//#region Static Properties
public static styles?: Array<CSSResult | CSSModule>;
protected static override finalizeStyles(styles: CSSResultGroup = []): CSSResultOrNative[] {
const elementStyles = [
$PFBase,
// Route around TSC`s known-to-fail typechecking of `.flat(Infinity)`. Removes types.
...([styles] as Array<unknown>).flat(Infinity),
$AKBase,
// Restore types. Safe: we control AKBase and PFBase in this file, and `styles` are
// typed on function signature.
] as CSSResultOrNative[];
// Remove duplicates in reverse order to preserve last-insert-wins semantics of CSS. See:
// https://github.com/lit/lit/blob/main/packages/reactive-element/src/reactive-element.ts#L945
const elementSet = new Set(elementStyles.reverse());
// Reverse again because the return type is an array, and process as a CSSResult
return Array.from(elementSet).reverse().map(createCSSResult);
}
//#endregion
//#region Lifecycle
constructor() {
super();
const { brand } = globalAK();
this.#customCSSStyleSheet = brand?.brandingCustomCss
? createStyleSheetUnsafe(brand.brandingCustomCss)
: null;
if (process.env.NODE_ENV === "development") {
const updatedCallback = this.updated;
this.updated = function updatedWrapper(args: PropertyValues) {
updatedCallback?.call(this, args);
const unregisteredElements = this.renderRoot.querySelectorAll(
`:not(:defined):not([data-registration="lazy"])`,
);
if (!unregisteredElements.length) return;
for (const element of unregisteredElements) {
console.debug("Unregistered custom element found in the DOM", element);
}
throw new TypeError(
`${unregisteredElements.length} unregistered custom elements found in the DOM. See console for details.`,
);
};
}
}
public override connectedCallback(): void {
super.connectedCallback();
if (this.renderRoot !== this) {
property({
attribute: "theme",
type: String,
reflect: true,
})(this, "activeTheme");
const hint =
this.ownerDocument.documentElement.dataset.theme || globalAK().brand.uiTheme;
const preferredColorScheme = resolveUITheme(hint);
this.activeTheme = preferredColorScheme;
}
}
public override disconnectedCallback(): void {
this.#themeAbortController?.abort();
super.disconnectedCallback();
}
/**
* Returns the node into which the element should render.
*
* @see {LitElement.createRenderRoot} for more information.
*/
protected override createRenderRoot(): HTMLElement | DocumentFragment {
const renderRoot = super.createRenderRoot();
this.styleRoot ??= renderRoot;
return renderRoot;
}
//#endregion
//#region Properties
/**
* The resolved theme of the current element.
*
* @remarks
*
* This property is lazy-initialized when the element is connected.
*
* Unlike the browser's current color scheme, this is a value that can be
* resolved to a specific theme, i.e. dark or light.
*
* @attr ("light" | "dark") activeTheme
*/
public activeTheme!: ResolvedUITheme;
//#endregion
//#region Private Properties
/**
* A custom CSS style sheet to apply to the element.
*
* @deprecated Use CSS parts and custom properties instead.
*
* @remarks
* The use of injected style sheets may result in brittle styles that are hard to
* maintain across authentik versions.
*
*/
readonly #customCSSStyleSheet: CSSStyleSheet | null;
/**
* A controller to abort theme updates, such as when the element is disconnected.
*/
#themeAbortController: AbortController | null = null;
/**
* The style root to which the theme is applied.
*/
#styleRoot?: StyleRoot;
/**
* The style root to which the theme is applied.
*/
protected get styleRoot(): StyleRoot | undefined {
return this.#styleRoot;
}
protected set styleRoot(nextStyleRoot: StyleRoot | undefined) {
this.#themeAbortController?.abort();
this.#styleRoot = nextStyleRoot;
if (!nextStyleRoot) return;
this.#themeAbortController = new AbortController();
document.addEventListener(
ThemeChangeEvent.eventName,
(event) => {
applyUITheme(nextStyleRoot, this.#customCSSStyleSheet);
this.activeTheme = event.theme;
},
{
signal: this.#themeAbortController.signal,
},
);
if (this.#customCSSStyleSheet) {
applyUITheme(nextStyleRoot, this.#customCSSStyleSheet);
}
}
protected hasSlotted(name: string | null) {
const isNotNestedSlot = (start: Element) => {
let node = start.parentNode;
while (node && node !== this) {
if (node instanceof Element && node.hasAttribute("slot")) {
return false;
}
node = node.parentNode;
}
return true;
};
// All child slots accessible from the component's LightDOM that match the request
const allChildSlotRequests =
typeof name === "string"
? [...this.querySelectorAll(`[slot="${name}"]`)]
: [...this.children].filter((child) => {
const slotAttr = child.getAttribute("slot");
return !slotAttr || slotAttr === "";
});
// All child slots accessible from the LightDom that match the request *and* are not nested
// within another slotted element.
return allChildSlotRequests.filter((node) => isNotNestedSlot(node)).length > 0;
}
//#endregion
}