Compare commits

...

6 Commits

Author SHA1 Message Date
Teffen Ellis
d4a8703329 Fix theming. 2026-03-11 03:53:02 +01:00
Teffen Ellis
3db9450651 Fix invokers, lazy load. 2026-03-11 03:53:01 +01:00
Teffen Ellis
f55e53ea6a Fix url parameters, wizards. 2026-03-11 03:53:01 +01:00
Teffen Ellis
c2261d2528 Fix portal elements not using dialog scope. 2026-03-11 03:53:01 +01:00
Teffen Ellis
f5a772abbc Flesh out lazy modal. 2026-03-11 03:53:01 +01:00
Teffen Ellis
7bfd40f555 Flesh out ak-modal, about modal. 2026-03-11 03:53:00 +01:00
27 changed files with 1396 additions and 222 deletions

View File

@@ -3,137 +3,172 @@ import "#elements/EmptyState";
import { DEFAULT_CONFIG } from "#common/api/config";
import { globalAK } from "#common/global";
import { ModalButton } from "#elements/buttons/ModalButton";
import { WithBrandConfig } from "#elements/mixins/branding";
import { WithLicenseSummary } from "#elements/mixins/license";
import { AKModal } from "#elements/modals/ak-modal";
import { asInvoker } from "#elements/modals/utils";
import { ThemedImage } from "#elements/utils/images";
import { AdminApi, CapabilitiesEnum, LicenseSummaryStatusEnum } from "@goauthentik/api";
import { msg } from "@lit/localize";
import { css, html, TemplateResult } from "lit";
import { customElement } from "lit/decorators.js";
import { createRef, ref } from "lit/directives/ref.js";
import { until } from "lit/directives/until.js";
import { ref } from "lit-html/directives/ref.js";
import { styleMap } from "lit-html/directives/style-map.js";
import { customElement, state } from "lit/decorators.js";
import PFAbout from "@patternfly/patternfly/components/AboutModalBox/about-modal-box.css";
@customElement("ak-about-modal")
export class AboutModal extends WithLicenseSummary(WithBrandConfig(ModalButton)) {
static styles = [
...ModalButton.styles,
PFAbout,
css`
.pf-c-about-modal-box {
--pf-c-about-modal-box--BackgroundColor: var(--pf-global--palette--black-900);
}
const DEFAULT_BRAND_IMAGE = "/static/dist/assets/images/flow_background.jpg";
.pf-c-about-modal-box__hero {
background-image: url("/static/dist/assets/images/flow_background.jpg");
}
.pf-c-about-modal-box__brand {
--pf-c-about-modal-box__brand-image--Height: 6.25rem;
}
.pf-c-about-modal-box__brand i {
font-size: var(--pf-c-about-modal-box__brand-image--Height);
type AboutEntry = [label: string, content: string | TemplateResult];
async function fetchAboutDetails(): Promise<AboutEntry[]> {
const api = new AdminApi(DEFAULT_CONFIG);
const [status, version] = await Promise.all([
api.adminSystemRetrieve(),
api.adminVersionRetrieve(),
]);
let build: string | TemplateResult = msg("Release");
if (globalAK().config.capabilities.includes(CapabilitiesEnum.CanDebug)) {
build = msg("Development");
} else if (version.buildHash) {
build = html`<a
rel="noopener noreferrer"
href="https://github.com/goauthentik/authentik/commit/${version.buildHash}"
target="_blank"
>${version.buildHash}</a
>`;
}
return [
[msg("Version"), version.versionCurrent],
[msg("UI Version"), import.meta.env.AK_VERSION],
[msg("Build"), build],
[msg("Python version"), status.runtime.pythonVersion],
[msg("Platform"), status.runtime.platform],
[msg("Kernel"), status.runtime.uname],
[
msg("OpenSSL"),
`${status.runtime.opensslVersion} ${status.runtime.opensslFipsEnabled ? "FIPS" : ""}`,
],
];
}
@customElement("ak-about-modal")
export class AboutModal extends WithLicenseSummary(WithBrandConfig(AKModal)) {
static hostStyles = [
css`
.ak-c-modal:has(ak-about-modal) {
--ak-c-modal--BackgroundColor: var(--pf-global--palette--black-900);
--ak-c-modal--BorderColor: var(--pf-global--palette--black-600);
}
`,
];
async getAboutEntries(): Promise<[string, string | TemplateResult][]> {
const status = await new AdminApi(DEFAULT_CONFIG).adminSystemRetrieve();
const version = await new AdminApi(DEFAULT_CONFIG).adminVersionRetrieve();
let build: string | TemplateResult = msg("Release");
if (globalAK().config.capabilities.includes(CapabilitiesEnum.CanDebug)) {
build = msg("Development");
} else if (version.buildHash !== "") {
build = html`<a
rel="noopener noreferrer"
href="https://github.com/goauthentik/authentik/commit/${version.buildHash}"
target="_blank"
>${version.buildHash}</a
>`;
}
return [
[msg("Version"), version.versionCurrent],
[msg("UI Version"), import.meta.env.AK_VERSION],
[msg("Build"), build],
[msg("Python version"), status.runtime.pythonVersion],
[msg("Platform"), status.runtime.platform],
[msg("Kernel"), status.runtime.uname],
[
msg("OpenSSL"),
`${status.runtime.opensslVersion} ${status.runtime.opensslFipsEnabled ? "FIPS" : ""}`,
],
];
static styles = [
...AKModal.styles,
PFAbout,
css`
:host {
height: 100%;
}
.pf-c-about-modal-box {
--pf-c-about-modal-box--BackgroundColor: var(--ak-c-modal--BackgroundColor);
width: unset;
height: 100%;
max-height: unset;
max-width: unset;
z-index: unset;
position: unset;
box-shadow: unset;
}
`,
];
public static open = asInvoker(AboutModal);
@state()
protected entries: AboutEntry[] | null = null;
public refresh() {
return fetchAboutDetails().then((entries) => {
this.entries = entries;
});
}
#contentRef = createRef<HTMLDivElement>();
public connectedCallback(): void {
super.connectedCallback();
this.refresh();
}
#backdropListener = (event: PointerEvent) => {
// We only want to close the modal when the backdrop is clicked, not when it's children are clicked.
//#region Renderers
if (this.#contentRef.value?.contains(event.target as Node)) {
return;
}
this.close();
};
protected override renderCloseButton() {
return null;
}
protected override renderModal() {
protected override render() {
let product = this.brandingTitle;
if (this.licenseSummary?.status !== LicenseSummaryStatusEnum.Unlicensed) {
product += ` ${msg("Enterprise")}`;
}
return html`<div class="pf-c-backdrop" @click=${this.#backdropListener}>
<div class="pf-l-bullseye">
<div
${ref(this.#contentRef)}
class="pf-c-about-modal-box"
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
return html`<div
${ref(this.scrollContainerRef)}
class="pf-c-about-modal-box"
style=${styleMap({
"--pf-c-about-modal-box__hero--sm--BackgroundImage": `url(${DEFAULT_BRAND_IMAGE})`,
})}
aria-labelledby="modal-title"
>
<div class="pf-c-about-modal-box__close">
<button
class="pf-c-button pf-m-plain"
type="button"
@click=${this.closeListener}
aria-label=${msg("Close dialog")}
>
<div class="pf-c-about-modal-box__brand">
${ThemedImage({
src: this.brandingFavicon,
alt: msg("authentik Logo"),
className: "pf-c-about-modal-box__brand-image",
theme: this.activeTheme,
themedUrls: this.brandingFaviconThemedUrls,
})}
</div>
<div class="pf-c-about-modal-box__close">
<button class="pf-c-button pf-m-plain" type="button" @click=${this.close}>
<i class="fas fa-times" aria-hidden="true"></i>
</button>
</div>
<div class="pf-c-about-modal-box__header">
<h1 class="pf-c-title pf-m-4xl" id="modal-title">${product}</h1>
</div>
<div class="pf-c-about-modal-box__hero"></div>
<div class="pf-c-about-modal-box__content">
<div class="pf-c-about-modal-box__body">
<div class="pf-c-content">
${until(
this.getAboutEntries().then((entries) => {
return html`<dl>
${entries.map(([label, value]) => {
return html`<dt>${label}</dt>
<dd>${value}</dd>`;
})}
</dl>`;
}),
html`<ak-empty-state loading></ak-empty-state>`,
)}
</div>
</div>
<p class="pf-c-about-modal-box__strapline"></p>
<i class="fas fa-times" aria-hidden="true"></i>
</button>
</div>
<div class="pf-c-about-modal-box__brand">
${ThemedImage({
src: this.brandingFavicon,
alt: msg("authentik Logo"),
className: "pf-c-about-modal-box__brand-image",
theme: this.activeTheme,
themedUrls: this.brandingFaviconThemedUrls,
})}
</div>
<div class="pf-c-about-modal-box__header">
<h1 class="pf-c-title pf-m-4xl" id="modal-title">${product}</h1>
</div>
<div class="pf-c-about-modal-box__hero"></div>
<div class="pf-c-about-modal-box__content">
<div class="pf-c-about-modal-box__body">
<div class="pf-c-content">
${this.entries
? html`<dl>
${this.entries.map(([label, value]) => {
return html`<dt>${label}</dt>
<dd>${value}</dd>`;
})}
</dl>`
: html`<ak-empty-state loading></ak-empty-state>`}
</div>
</div>
<p class="pf-c-about-modal-box__strapline"></p>
</div>
</div>`;
}
//#endregion
}
declare global {

View File

@@ -1,4 +1,3 @@
import "#admin/AdminInterface/AboutModal";
import "#elements/banner/EnterpriseStatusBanner";
import "#elements/banner/VersionBanner";
import "#elements/messages/MessageContainer";
@@ -30,7 +29,6 @@ import {
renderNotificationDrawerPanel,
} from "#elements/notifications/utils";
import type { AboutModal } from "#admin/AdminInterface/AboutModal";
import Styles from "#admin/AdminInterface/index.entrypoint.css";
import { ROUTES } from "#admin/Routes";
@@ -38,7 +36,7 @@ import { CapabilitiesEnum } from "@goauthentik/api";
import { msg } from "@lit/localize";
import { CSSResult, html, nothing, PropertyValues, TemplateResult } from "lit";
import { customElement, eventOptions, property, query, state } from "lit/decorators.js";
import { customElement, eventOptions, property, state } from "lit/decorators.js";
import { classMap } from "lit/directives/class-map.js";
import PFButton from "@patternfly/patternfly/components/Button/button.css";
@@ -62,9 +60,6 @@ export class AdminInterface extends WithCapabilitiesConfig(
//#region Properties
@query("ak-about-modal")
public aboutModal?: AboutModal;
@property({ type: Boolean, reflect: true, attribute: "sidebar" })
public sidebarOpen = false;
@@ -201,7 +196,6 @@ export class AdminInterface extends WithCapabilitiesConfig(
</div>
</div>
${renderNotificationDrawerPanel(this.drawer)}
<ak-about-modal></ak-about-modal>
</div>
</div>

View File

@@ -40,6 +40,9 @@ import { ifDefined } from "lit/directives/if-defined.js";
export class ApplicationForm extends WithCapabilitiesConfig(ModelForm<Application, string>) {
#api = new CoreApi(DEFAULT_CONFIG);
protected entitySingular = msg("Application");
protected entityPlural = msg("Applications");
protected override async loadInstance(pk: string): Promise<Application> {
const app = await this.#api.coreApplicationsRetrieve({
slug: pk,

View File

@@ -5,24 +5,29 @@ import "#elements/ak-mdx/ak-mdx";
import "#elements/buttons/SpinnerButton/ak-spinner-button";
import "#elements/forms/DeleteBulkForm";
import "#elements/forms/ModalForm";
import "#elements/modals/ak-modal";
import "@patternfly/elements/pf-tooltip/pf-tooltip.js";
import "./ApplicationWizardHint.js";
import { DEFAULT_CONFIG } from "#common/api/config";
import { WithBrandConfig } from "#elements/mixins/branding";
import { asInvoker, renderModal } from "#elements/modals/utils";
import { getURLParam } from "#elements/router/RouteMatch";
import { PaginatedResponse, TableColumn } from "#elements/table/Table";
import { TablePage } from "#elements/table/TablePage";
import { SlottedTemplateResult } from "#elements/types";
import { ifPresent } from "#elements/utils/attributes";
import { ApplicationForm } from "#admin/applications/ApplicationForm";
import { AkApplicationWizard } from "#admin/applications/wizard/ak-application-wizard";
import { Application, CoreApi, PoliciesApi } from "@goauthentik/api";
import MDApplication from "~docs/add-secure-apps/applications/index.md";
import { msg, str } from "@lit/localize";
import { css, CSSResult, html, nothing, TemplateResult } from "lit";
import { css, CSSResult, html, nothing, PropertyValues, TemplateResult } from "lit";
import { customElement, property } from "lit/decorators.js";
import PFCard from "@patternfly/patternfly/components/Card/card.css";
@@ -69,6 +74,26 @@ export class ApplicationListPage extends WithBrandConfig(TablePage<Application>)
static styles: CSSResult[] = [...TablePage.styles, PFCard, applicationListStyle];
public override firstUpdated(changed: PropertyValues<this>): void {
super.firstUpdated(changed);
if (getURLParam("createWizard", false)) {
this.#openCreateWizard();
} else if (getURLParam("createForm", false)) {
this.#openCreateModal();
}
}
#openEditModal(event: Event) {
const pk = (event.currentTarget as HTMLElement).dataset.pk;
renderModal(html`<ak-application-form .instancePk=${pk}></ak-application-form>`);
}
#openCreateWizard = AkApplicationWizard.open;
#openCreateModal = asInvoker(ApplicationForm);
protected columns: TableColumn[] = [
["", undefined, msg("Application Icon")],
[msg("Name"), "name"],
@@ -133,21 +158,16 @@ export class ApplicationListPage extends WithBrandConfig(TablePage<Application>)
: html`-`,
html`${item.providerObj?.verboseName || msg("-")}`,
html`<div>
<ak-forms-modal>
<span slot="submit">${msg("Update")}</span>
<span slot="header">${msg("Update Application")}</span>
<ak-application-form slot="form" .instancePk=${item.slug}>
</ak-application-form>
<button
slot="trigger"
class="pf-c-button pf-m-plain"
aria-label=${msg(str`Edit "${item.name}"`)}
>
<pf-tooltip position="top" content=${msg("Edit")}>
<i class="fas fa-edit" aria-hidden="true"></i>
</pf-tooltip>
</button>
</ak-forms-modal>
<button
class="pf-c-button pf-m-plain"
aria-label=${msg(str`Edit "${item.name}"`)}
data-pk=${item.slug}
@click=${this.#openEditModal}
>
<pf-tooltip position="top" content=${msg("Edit")}>
<i class="fas fa-edit" aria-hidden="true"></i>
</pf-tooltip>
</button>
${item.launchUrl
? html`<a
href=${item.launchUrl}
@@ -165,21 +185,12 @@ export class ApplicationListPage extends WithBrandConfig(TablePage<Application>)
}
renderObjectCreate(): TemplateResult {
return html` <ak-application-wizard .open=${getURLParam("createWizard", false)}>
<button
slot="trigger"
class="pf-c-button pf-m-primary"
data-ouia-component-id="start-application-wizard"
>
${msg("Create with Provider")}
</button>
</ak-application-wizard>
<ak-forms-modal .open=${getURLParam("createForm", false)}>
<span slot="submit">${msg("Create")}</span>
<span slot="header">${msg("Create Application")}</span>
<ak-application-form slot="form"> </ak-application-form>
<button slot="trigger" class="pf-c-button pf-m-primary">${msg("Create")}</button>
</ak-forms-modal>`;
return html`<button class="pf-c-button pf-m-primary" @click=${this.#openCreateWizard}>
${msg("Create with Provider")}
</button>
<button class="pf-c-button pf-m-primary" @click=${this.#openCreateModal}>
${msg("Create")}
</button>`;
}
renderToolbar(): TemplateResult {

View File

@@ -50,6 +50,10 @@ export const providerTypePriority: ProviderModelNameEnum[] = [
@customElement("ak-application-wizard-main")
export class AkApplicationWizardMain extends AKElement {
protected createRenderRoot(): HTMLElement | DocumentFragment {
return this;
}
@state()
wizard: ApplicationWizardState = freshWizardState();

View File

@@ -1,28 +1,39 @@
import "./ak-application-wizard-main.js";
import { ModalButton } from "#elements/buttons/ModalButton";
import { bound } from "#elements/decorators/bound";
import { AKModal } from "#elements/modals/ak-modal";
import { asInvoker } from "#elements/modals/utils";
import { SlottedTemplateResult } from "#elements/types";
import { WizardCloseEvent } from "#components/ak-wizard/events";
import { html } from "lit";
import { css, CSSResult, html } from "lit";
import { customElement } from "lit/decorators.js";
@customElement("ak-application-wizard")
export class AkApplicationWizard extends ModalButton {
export class AkApplicationWizard extends AKModal {
public static override styles: CSSResult[] = [
...super.styles,
css`
[part="main"] {
display: block;
}
`,
];
public static open = asInvoker(AkApplicationWizard);
constructor() {
super();
this.addEventListener(WizardCloseEvent.eventName, this.onCloseEvent);
this.addEventListener(WizardCloseEvent.eventName, this.closeListener);
}
@bound
onCloseEvent(ev: WizardCloseEvent) {
ev.stopPropagation();
this.open = false;
protected renderCloseButton(): SlottedTemplateResult {
return null;
}
renderModalInner() {
return html` <ak-application-wizard-main> </ak-application-wizard-main>`;
render() {
return html`<ak-application-wizard-main part="main"></ak-application-wizard-main>`;
}
}

View File

@@ -371,21 +371,4 @@ export function applyBackgroundImageProperty(
target.style.setProperty(AKBackgroundImageProperty, `url("${nextURL.href}")`);
}
/**
* Returns the root interface element of the page.
*
* @deprecated Use context controllers to access the interface root instead.
*/
export function rootInterface<T extends HTMLElement = HTMLElement>(): T {
const element = document.body.querySelector<T>("[data-test-id=interface-root]");
if (!element) {
throw new Error(
`Could not find root interface element. Was this element added before the parent interface element?`,
);
}
return element;
}
//#endregion

View File

@@ -1,11 +1,11 @@
import "#elements/buttons/Dropdown";
import { StripHTMLTrustPolicy } from "#common/purify";
import { rootInterface } from "#common/theme";
import { FormAssociated, FormAssociatedElement } from "#elements/forms/form-associated-element";
import { PaginatedResponse } from "#elements/table/Table";
import { ifPresent } from "#elements/utils/attributes";
import { resolveInterface } from "#elements/utils/render-roots";
import Styles from "#components/ak-search-ql/styles.css";
@@ -143,8 +143,7 @@ export class QLSearch extends FormAssociatedElement<string> implements FormAssoc
public override connectedCallback() {
super.connectedCallback();
this.#scrollContainer =
rootInterface<LitElement>().renderRoot.querySelector("#main-content");
this.#scrollContainer = resolveInterface().renderRoot.querySelector("#main-content");
this.#scrollContainer?.addEventListener("scroll", this.#updateDropdownPosition, {
passive: true,

View File

@@ -7,7 +7,7 @@ import { AKElement } from "#elements/Base";
import { bound } from "#elements/decorators/bound";
import { Context, ContextProvider } from "@lit/context";
import { html, nothing } from "lit";
import { css, html, nothing } from "lit";
import { customElement, property } from "lit/decorators.js";
/**
@@ -25,6 +25,14 @@ import { customElement, property } from "lit/decorators.js";
@customElement("ak-wizard-steps")
export class WizardStepsManager extends AKElement {
public static styles = [
css`
:host {
display: block;
}
`,
];
@property({ type: String, attribute: true })
public currentStep?: string;
@@ -97,6 +105,10 @@ export class WizardStepsManager extends AKElement {
@bound
onSlotchange(ev: Event) {
console.debug(
"Slot change detected in WizardStepsManager; recalculating slots and step labels.",
ev,
);
ev.stopPropagation();
this.findSlots();
this.findSlot(this.currentStep);

View File

@@ -1,5 +1,10 @@
import { globalAK } from "#common/global";
import { createCSSResult, createStyleSheetUnsafe, StyleRoot } from "#common/stylesheets";
import {
createCSSResult,
createStyleSheetUnsafe,
setAdoptedStyleSheets,
StyleRoot,
} from "#common/stylesheets";
import { applyUITheme, ResolvedUITheme, resolveUITheme, ThemeChangeEvent } from "#common/theme";
import AKBase from "#styles/authentik/base.css" with { type: "bundled-text" };
@@ -34,7 +39,22 @@ export class AKElement extends LitElement implements AKElementProps {
public static styles?: Array<CSSResult | CSSModule>;
/**
* Host styles are styles that are applied to the element's render root,
* but are not scoped to the element itself.
*
* @remarks
*
* This is useful if the element is a wrapper around a third-party component
* that requires styles to be applied to the host, such as Patternfly's modals.
*/
public static hostStyles?: Array<CSSResult | CSSModule>;
private static hostStyleSheets: CSSStyleSheet[] | null = null;
protected static override finalizeStyles(styles: CSSResultGroup = []): CSSResultOrNative[] {
this.hostStyleSheets = this.hostStyles ? this.hostStyles.map(createStyleSheetUnsafe) : null;
const elementStyles = [
$PFBase,
// Route around TSC`s known-to-fail typechecking of `.flat(Infinity)`. Removes types.
@@ -102,10 +122,35 @@ export class AKElement extends LitElement implements AKElementProps {
this.activeTheme = preferredColorScheme;
}
const rootNode = this.getRootNode();
if (rootNode instanceof ShadowRoot) {
const { hostStyleSheets } = this.constructor as typeof AKElement;
if (hostStyleSheets) {
setAdoptedStyleSheets(rootNode, (currentStyleSheets) => {
return [...currentStyleSheets, ...hostStyleSheets];
});
}
}
}
public override disconnectedCallback(): void {
this.#themeAbortController?.abort();
const rootNode = this.getRootNode();
if (rootNode instanceof ShadowRoot) {
const { hostStyleSheets } = this.constructor as typeof AKElement;
if (hostStyleSheets) {
setAdoptedStyleSheets(rootNode, (currentStyleSheets) => {
return currentStyleSheets.filter((sheet) => !hostStyleSheets.includes(sheet));
});
}
}
super.disconnectedCallback();
}

View File

@@ -38,7 +38,7 @@ export abstract class Interface extends AKElement {
this.addController(new BrandingContextController(this, brand), BrandingContext);
this.addController(new ModalOrchestrationController());
this.dataset.testId = "interface-root";
this.id = "interface-root";
}
public override addController(

View File

@@ -239,11 +239,11 @@ export abstract class Form<T = Record<string, unknown>> extends AKElement {
@property({ type: String })
public autocomplete?: Exclude<AutoFillBase, "">;
@property({ type: String })
public headline?: string;
@property({ type: String, useDefault: true })
public headline?: string | null = null;
@property({ type: String, attribute: "action-label" })
public actionLabel?: string;
@property({ type: String, attribute: "action-label", useDefault: true })
public actionLabel: string | null = null;
//#endregion
@@ -254,6 +254,9 @@ export abstract class Form<T = Record<string, unknown>> extends AKElement {
@state()
protected nonFieldErrors: readonly string[] | null = null;
protected entitySingular?: string;
protected entityPlural?: string;
static styles: CSSResult[] = [
PFCard,
PFButton,
@@ -317,6 +320,20 @@ export abstract class Form<T = Record<string, unknown>> extends AKElement {
};
}
/**
* An overridable method for formatting the form headline.
*/
protected formatHeadline(headline = this.headline): string {
return headline || "";
}
/**
* An overridable method for formatting the submit button label.
*/
protected formatSubmitLabel(actionLabel = this.actionLabel): string {
return actionLabel || msg("Submit");
}
//#region Public methods
public reset(): void {
@@ -512,20 +529,22 @@ export abstract class Form<T = Record<string, unknown>> extends AKElement {
}
/**
* An overridable method for rendering the form heading.
* An overridable method for rendering the form header.
*
* @remarks
* If this form is slotted, such as in a modal, this method will not render anything,
* allowing the slot parent to provide the heading in a more visually appropriate manner.
* allowing the slot parent to provide the header in a more visually appropriate manner.
*/
protected renderHeading(): SlottedTemplateResult {
return guard([this.assignedSlot, this.headline], () => {
if (this.assignedSlot) {
public renderHeader(force?: boolean): SlottedTemplateResult {
const { assignedSlot, headline } = this;
return guard([force, assignedSlot, headline], () => {
if (!force && assignedSlot && !assignedSlot.name) {
return nothing;
}
return html`<header>
<h1 class="pf-c-title pf-m-2xl">${this.headline}</h1>
<h1 class="pf-c-title pf-m-2xl">${this.formatHeadline(headline)}</h1>
</header>`;
});
}
@@ -537,23 +556,33 @@ export abstract class Form<T = Record<string, unknown>> extends AKElement {
* If this form is slotted, such as in a modal, this method will not render anything,
* allowing the slot parent to provide the actions in a more visually appropriate manner.
*/
protected renderActions(): SlottedTemplateResult {
return guard([this.assignedSlot], () => {
if (this.assignedSlot) {
public renderActions(force?: boolean): SlottedTemplateResult {
const { assignedSlot, actionLabel } = this;
return guard([force, assignedSlot, actionLabel], () => {
if (!force && assignedSlot && !assignedSlot.name) {
return nothing;
}
return html`<fieldset part="form-actions" class="pf-c-card__footer">
<legend class="sr-only">${msg("Form actions")}</legend>
<button
type="submit"
form="form"
type="button"
class="pf-c-button pf-m-primary"
@click=${(event: Event) => {
this.submit(
new SubmitEvent("submit", {
submitter: event.currentTarget as HTMLButtonElement,
cancelable: true,
bubbles: true,
composed: true,
}),
);
}}
part="submit-button"
formmethod="dialog"
aria-description=${msg("Submit action")}
>
${this.actionLabel || msg("Submit")}
${this.formatSubmitLabel(actionLabel)}
</button>
</fieldset>`;
});
@@ -563,8 +592,12 @@ export abstract class Form<T = Record<string, unknown>> extends AKElement {
* An overridable method for rendering the form when it is visible.
*/
protected renderVisible(): SlottedTemplateResult {
return html`${this.renderHeading()}${this.renderNonFieldErrors()}
${this.renderFormWrapper()}${this.renderActions()}`;
return [
this.renderHeader(),
this.renderNonFieldErrors(),
this.renderFormWrapper(),
this.renderActions(),
];
}
protected override render(): SlottedTemplateResult {

View File

@@ -8,6 +8,7 @@ import { SlottedTemplateResult } from "#elements/types";
import { ConsoleLogger } from "#logger/browser";
import { msg, str } from "@lit/localize";
import { html } from "lit";
import { property } from "lit/decorators.js";
@@ -109,6 +110,22 @@ export abstract class ModelForm<
});
};
protected override formatSubmitLabel(): string {
return this.#instancePk ? msg("Update") : msg("Create");
}
protected override formatHeadline(): string {
const verb = this.#instancePk ? msg("Edit") : msg("New");
const noun = this.entitySingular;
if (!noun) return verb;
return msg(str`${verb} ${noun}`, {
id: "model-form.headline",
desc: "The headline for a form that creates or updates a model instance.",
});
}
public override reset(): void {
super.reset();

View File

@@ -1,4 +1,5 @@
import { randomId } from "#elements/utils/randomId";
import { resolveInterface } from "#elements/utils/render-roots";
import { autoUpdate, computePosition, flip, hide } from "@floating-ui/dom";
@@ -68,13 +69,17 @@ export class Portal extends LitElement implements IPortal {
this.setAttribute("data-ouia-component-type", "ak-portal");
this.setAttribute("data-ouia-component-id", this.getAttribute("id") || randomId());
this.dropdownContainer = document.createElement("div");
this.dropdownContainer = this.ownerDocument.createElement("div");
this.dropdownContainer.dataset.managedBy = "ak-portal";
if (this.name) {
this.dropdownContainer.dataset.managedFor = this.name;
}
document.body.append(this.dropdownContainer);
const interfaceElement = resolveInterface();
const container =
interfaceElement.renderRoot.querySelector("dialog") || this.ownerDocument.body;
container.append(this.dropdownContainer);
if (!this.anchor) {
throw new Error("Tether entrance initialized incorrectly: missing anchor");

View File

@@ -229,11 +229,13 @@ export class SearchSelectView extends AKElement implements ISearchSelectView {
this.setAttribute("data-ouia-component-safe", "true");
}
public override firstUpdated() {
public override firstUpdated(changed: PropertyValues<this>) {
super.firstUpdated(changed);
// Route around Lit's scheduling algorithm complaining about re-renders
window.setTimeout(() => {
requestAnimationFrame(() => {
this.inputRefIsAvailable = Boolean(this.#inputRef?.value);
}, 0);
});
}
connectedCallback() {

View File

@@ -0,0 +1,496 @@
import { PFSize } from "#common/enums";
import { AKElement } from "#elements/Base";
import { AKFormSubmittedEvent } from "#elements/forms/events";
import { Form } from "#elements/forms/Form";
import Styles from "#elements/modals/styles.css";
import { SlottedTemplateResult } from "#elements/types";
import { ConsoleLogger, Logger } from "#logger/browser";
import { msg } from "@lit/localize";
import { CSSResult, html, PropertyValues } from "lit";
import { createRef } from "lit-html/directives/ref.js";
import { customElement, property, state } from "lit/decorators.js";
import { guard } from "lit/directives/guard.js";
import PFButton from "@patternfly/patternfly/components/Button/button.css";
import PFCard from "@patternfly/patternfly/components/Card/card.css";
import PFContent from "@patternfly/patternfly/components/Content/content.css";
import PFForm from "@patternfly/patternfly/components/Form/form.css";
import PFFormControl from "@patternfly/patternfly/components/FormControl/form-control.css";
import PFPage from "@patternfly/patternfly/components/Page/page.css";
import PFTitle from "@patternfly/patternfly/components/Title/title.css";
@customElement("ak-modal")
export class AKModal extends AKElement {
static shadowRootOptions: ShadowRootInit = {
...AKElement.shadowRootOptions,
delegatesFocus: true,
};
/**
* Whether the modal should open the parent dialog element when it is connected to the DOM.
*/
static openOnConnect = true;
public static styles: CSSResult[] = [
PFButton,
PFForm,
PFTitle,
PFFormControl,
PFPage,
PFCard,
PFContent,
Styles,
];
#hostResizeObserver: ResizeObserver;
//#region Protected Properties
protected logger: Logger;
/**
* An optional Lit ref which can automatically synchronize the modal's height with the element's height.
*/
protected scrollContainerRef = createRef<HTMLElement>();
declare parentElement: HTMLDialogElement | null;
//#region Public Properties
@property({ type: String, useDefault: true })
public headline: string | null = null;
/**
* Whether the parent dialog element is currently open.
*/
public get open(): boolean {
return this.parentElement?.open ?? false;
}
@property({ type: Boolean, attribute: false, reflect: false })
public set open(value: boolean) {
if (value) {
this.show();
} else {
this.close();
}
}
@property({ type: String })
public size: PFSize = PFSize.Large;
protected defaultSlot: HTMLSlotElement;
@state()
protected form: Form | null = null;
//#endregion
//#region Public methods
/**
* Show the modal, rendering its contents.
*/
public show() {
const dialogElement = this.parentElement;
if (!dialogElement) {
this.logger.debug("No parentElement, cannot show modal", this);
return;
}
dialogElement.addEventListener("transitionend", this.fadeInListener, {
once: true,
passive: true,
});
dialogElement.showModal();
dialogElement.classList.add("fade-in");
}
/**
* Close the modal, fading it out and then removing it from the DOM,
* optionally with a return value.
*
* @param returnValue The return value for the dialog, if any.
*/
public close(returnValue?: string) {
const dialogElement = this.parentElement;
if (!dialogElement) {
this.logger.debug("No parentElement, cannot close modal", this);
return;
}
dialogElement.addEventListener(
"transitionend",
(event) => this.delegateClose(event, returnValue),
{ once: true, passive: true },
);
dialogElement.classList.remove("fade-in", "fade-in-complete");
}
//#endregion
//#region Event listeners
/**
* A stable reference to the dialog's open event listener.
*
* This is useful for simplifying adding and removing event listeners.
*/
protected showListener = () => {
this.show();
};
#heightSyncFrameID = -1;
/**
* A map of observed elements to their expected sizes,
* used to prevent unnecessary height synchronizations.
*/
#expectedSizes = new WeakMap<Element, number>();
protected lastScrollHeight = 0;
protected synchronizeHeight: ResizeObserverCallback = ([entry]) => {
this.#heightSyncFrameID = requestAnimationFrame(() => {
const dialogElement = this.parentElement;
if (!dialogElement || !dialogElement.open) {
return;
}
const expectedSize = this.#expectedSizes.get(entry.target);
const blockSize = Math.ceil(entry.borderBoxSize[0].blockSize);
const desiredSize = Math.max(entry.target.scrollHeight, blockSize);
if (desiredSize === expectedSize) {
return;
}
dialogElement.style.height = desiredSize + "px";
this.#expectedSizes.set(entry.target, desiredSize);
});
};
#heightResetAnimationFrameID = -1;
protected resetHeight = () => {
cancelAnimationFrame(this.#heightResetAnimationFrameID);
cancelAnimationFrame(this.#heightSyncFrameID);
this.#heightResetAnimationFrameID = requestAnimationFrame(() => {
if (this.parentElement) {
this.parentElement.style.height = "";
}
this.#heightResetAnimationFrameID = requestAnimationFrame(() => {
const scrollContainer = this.scrollContainerRef.value || this;
const scrollHeight = scrollContainer.scrollHeight;
this.lastScrollHeight = scrollHeight;
});
});
};
protected fadeInListener = async (event: TransitionEvent) => {
this.logger.debug("fade-in complete", event);
this.removeEventListener("transitionend", this.fadeInListener);
const dialogElement = this.parentElement;
if (!dialogElement) {
this.logger.debug("Skipping height synchronization, no dialog element", this);
return;
}
dialogElement.classList.add("fade-in-complete");
dialogElement.style.height = dialogElement.clientHeight + "px";
const scrollContainer = this.scrollContainerRef.value || this;
this.#hostResizeObserver.observe(scrollContainer);
window.addEventListener("resize", this.resetHeight, {
passive: true,
});
};
#closing = false;
/**
* Delegate the close action to the parent dialog element,
* ensuring that the correct event listeners are triggered and the modal is properly closed.
*/
protected delegateClose(event: TransitionEvent, returnValue?: string) {
if (this.#closing) {
return;
}
this.logger.debug("Closing", event);
this.#closing = true;
this.parentElement?.close(returnValue);
requestAnimationFrame(() => {
this.#closing = false;
this.#hostResizeObserver.unobserve(this);
window.removeEventListener("resize", this.resetHeight);
this.resetHeight();
});
}
/**
* A stable reference to the dialog's close event listener.
*
* This is useful for simplifying adding and removing event listeners.
*/
protected closeListener = () => {
this.close();
};
/**
* A stable reference to the dialog's backdrop click event listener.
*
* @remarks
* Note that if the browser supports {@linkcode HTMLDialogElement}'s `closeBy` property,
* the backdrop click may trigger a "cancel" event instead of a "click" event.
*/
protected backdropClickListener = (event: Event) => {
if (event.target === this.parentElement) {
this.close();
}
};
/**
* A stable reference to the dialog's cancel event listener.
*
* This is useful for simplifying adding and removing event listeners.
*/
protected cancelListener = (event: Event) => {
if (!this.parentElement) {
return;
}
event.preventDefault();
this.close();
};
//#endregion
//#region Lifecycle
/**
* A bound render method that can be safely passed as a callback without losing the correct `this` context.
*
* @remarks
* This allows the implementing class to use `render`, reducing the mental
* overhead of which method should be used for rendering content.
*/
protected renderContent: () => unknown;
public constructor() {
super();
this.logger = ConsoleLogger.prefix(this.tagName.toLowerCase());
this.renderContent = this.render.bind(this);
this.render = this.renderInternal;
this.#hostResizeObserver = new ResizeObserver(this.synchronizeHeight);
this.defaultSlot = this.ownerDocument.createElement("slot");
}
public override connectedCallback(): void {
super.connectedCallback();
if (!this.parentElement) {
this.logger.debug("Skipping connectedCallback, no parentElement", this);
return;
}
if (!(this.parentElement instanceof HTMLDialogElement)) {
throw new TypeError(
`authentik/modal: ${this.tagName.toLowerCase()} must be placed inside a <dialog> element.`,
);
}
const tagName = this.tagName.toLowerCase();
this.parentElement.dataset.akModal = tagName;
this.parentElement.classList.add("ak-c-modal", this.size);
// eslint-disable-next-line wc/no-self-class
this.classList.add("ak-c-modal__content");
this.parentElement.addEventListener("cancel", this.cancelListener);
this.parentElement.addEventListener("click", this.backdropClickListener, { passive: true });
this.addEventListener(AKFormSubmittedEvent.eventName, this.closeListener);
const { openOnConnect } = this.constructor as typeof AKModal;
if (openOnConnect) {
this.show();
}
}
public override disconnectedCallback(): void {
super.disconnectedCallback();
this.#hostResizeObserver.disconnect();
window.removeEventListener("resize", this.resetHeight);
}
public override updated(changedProperties: PropertyValues<this>): void {
super.updated(changedProperties);
const assignedElements = this.defaultSlot.assignedElements({ flatten: true });
const form =
assignedElements.find((element): element is Form => element instanceof Form) ?? null;
if (form && form !== this.form) {
this.form = form;
this.form.viewportCheck = false;
}
const dialogElement = this.parentElement;
if (dialogElement && dialogElement.open) {
requestAnimationFrame(() => {
const scrollContainer = this.scrollContainerRef.value || this;
const scrollHeight = scrollContainer.scrollHeight;
if (scrollHeight !== this.lastScrollHeight) {
this.resetHeight();
}
});
}
}
//#endregion
//#region Render
/**
* An overridable method that determines whether the modal content should be rendered.
*
* By default, the modal content is only rendered when the modal is open,
* to avoid unnecessary rendering and potential issues with elements that
* require being in the DOM to function properly (e.g., autofocus).
*/
protected shouldRenderModalContent(): boolean {
return this.open;
}
/**
* The internal render method, including the close button and header, which are common to all modals.
*/
protected renderInternal() {
if (!this.shouldRenderModalContent()) {
return super.render();
}
return [
this.renderCloseButton(),
this.renderHeader(),
this.renderContent(),
this.renderActions(),
];
}
protected renderCloseButton(): SlottedTemplateResult {
return html`<button
@click=${this.closeListener}
class="pf-c-button pf-m-plain"
type="button"
aria-label=${msg("Close dialog")}
>
<i class="fas fa-times" aria-hidden="true"></i>
</button>`;
}
/**
* Render the modal header.
*
* This method may be overridden to customize the modal header.
*
* @protected
* @abstract
*/
protected renderHeader(): SlottedTemplateResult {
const { headline, form } = this;
const hasHeaderSlot = this.hasSlotted("header");
return guard([headline, hasHeaderSlot, form], () => {
if (!headline && !hasHeaderSlot && !form) {
return null;
}
return html`<header class="ak-c-modal__header">
<div class="ak-c-modal__title">
<h1 class="ak-c-modal__title-text" id="modal-title">
${this.headline}
<slot name="header"></slot>
${form ? form.renderHeader(true) : null}
</h1>
</div>
</header>`;
});
}
/**
* Render the modal actions.
*
* This method may be overridden to customize the modal actions.
*
* @protected
* @abstract
*/
protected renderActions(): SlottedTemplateResult {
const { form } = this;
const hasActionsSlot = this.hasSlotted("actions");
return guard([hasActionsSlot, form], () => {
if (!hasActionsSlot && !form) {
return null;
}
return html`<footer class="ak-c-modal__footer">
<slot name="actions"></slot>
${form ? form.renderActions(true) : null}
</footer>`;
});
}
/**
* Render the modal content.
*
* Note that this method is only called when the modal is open.
*
* @protected
* @abstract
*/
protected render(): unknown {
return html`<div class="ak-c-modal__body">${this.defaultSlot}</div>`;
}
//#endregion
}
declare global {
interface HTMLElementTagNameMap {
"ak-modal": AKModal;
}
}

View File

@@ -0,0 +1,144 @@
:host {
display: block;
}
/* #region Buttons */
:host > .pf-c-button {
position: absolute;
top: var(--ak-c-modal--c-button--Top);
right: var(--ak-c-modal--c-button--Right);
}
:host > .pf-c-button + .ak-c-modal__header {
margin-right: var(--ak-c-modal--c-button--sibling--MarginRight);
}
/* #endregion */
/* #region Header */
.ak-c-modal__header {
display: flex;
flex-direction: column;
flex-shrink: 0;
padding-top: var(--ak-c-modal__header--PaddingTop);
padding-bottom: var(--ak-c-modal__header--PaddingBottom);
padding-right: var(--ak-c-modal__header--PaddingRight);
padding-left: var(--ak-c-modal__header--PaddingLeft);
}
.ak-c-modal__header.pf-m-help {
display: flex;
flex-direction: row;
}
.ak-c-modal__header:last-child {
padding-bottom: var(--ak-c-modal__header--last-child--PaddingBottom);
}
.ak-c-modal__header + .ak-c-modal__body,
.ak-c-modal__header + slot + .ak-c-modal__body {
/* --ak-c-modal__body--PaddingTop: var(--ak-c-modal__header--body--PaddingTop); */
--ak-c-modal__body--PaddingTop: 0;
}
.ak-c-modal__header-main {
flex-grow: 1;
min-width: 0;
}
/* #endregion */
/* #region Title */
.ak-c-modal__title,
.ak-c-modal__title-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.ak-c-modal__title {
flex: 0 0 auto;
font-family: var(--ak-c-modal__title--FontFamily);
font-size: var(--ak-c-modal__title--FontSize);
line-height: var(--ak-c-modal__title--LineHeight);
}
.ak-c-modal__title.pf-m-icon {
display: flex;
}
.ak-c-modal__title-icon {
margin-right: var(--ak-c-modal__title-icon--MarginRight);
color: var(--ak-c-modal__title-icon--Color);
}
.ak-c-modal__description {
padding-top: var(--ak-c-modal__description--PaddingTop);
}
/* #endregion */
/* #region Body */
.ak-c-modal__body {
flex: 1 1 auto;
max-height: var(--ak-c-modal__body--MaxHeight);
min-height: var(--ak-c-modal__body--MinHeight);
padding-top: var(--ak-c-modal__body--PaddingTop);
padding-right: var(--ak-c-modal__body--PaddingRight);
padding-left: var(--ak-c-modal__body--PaddingLeft);
overflow-x: hidden;
overflow-y: auto;
overscroll-behavior: contain;
word-break: break-word;
-webkit-overflow-scrolling: touch;
max-height: calc(
75dvh - 2rem - var(--ak-c-modal__header--PaddingTop) -
var(--ak-c-modal__footer--PaddingBottom)
);
}
.ak-c-modal__body:last-child {
padding-bottom: var(--ak-c-modal__body--last-child--PaddingBottom);
}
.ak-c-modal__footer {
display: flex;
flex: 0 0 auto;
align-items: center;
padding-top: var(--ak-c-modal__footer--PaddingTop);
padding-right: var(--ak-c-modal__footer--PaddingRight);
padding-bottom: var(--ak-c-modal__footer--PaddingBottom);
padding-left: var(--ak-c-modal__footer--PaddingLeft);
gap: var(--pf-global--spacer--xs);
@media screen and (min-width: 576px) {
gap: var(--pf-global--spacer--sm);
}
}
/* #endregion */
/* #region Footer */
fieldset.ak-c-modal__footer {
--ak-legend-padding-inline-base: var(--pf-global--spacer--md);
padding-block: calc(var(--ak-legend-padding-inline-base) / 2);
border-inline: none;
border-block-end: none;
--ak-c-modal__footer--c-button--sm--MarginRight: var(
--ak-c-modal__footer--c-button--MarginRight
);
& > ak-spinner-button:not(:last-child) {
margin-right: var(--ak-c-modal__footer--c-button--MarginRight);
}
}
/* #endregion */

View File

@@ -0,0 +1,127 @@
/**
* @file Modal rendering utilities.
*/
import "#elements/modals/ak-modal";
import { AKModal } from "#elements/modals/ak-modal";
import { SlottedTemplateResult } from "#elements/types";
import { isAKElementConstructor } from "#elements/utils/unsafe";
import { html, render } from "lit";
/**
* Resolves the container element for a dialog, given an optional parent element or selector.
*
* If the parent element is not provided or cannot be resolved, the document body is returned.
*
* @param ownerDocument The document to query for the parent element. Defaults to the global document.
* @param parentElement An optional HTMLElement or selector string to use as the dialog container.
*
* @returns The resolved container HTMLElement for the dialog.
*/
export function resolveDialogContainer(
ownerDocument: Document = document,
parentElement?: HTMLElement | string | null,
): HTMLElement {
if (parentElement instanceof HTMLElement) {
return parentElement;
}
if (typeof parentElement === "string") {
const resolvedElement = ownerDocument.querySelector(parentElement);
if (resolvedElement instanceof HTMLElement) {
return resolvedElement;
}
}
return ownerDocument.body;
}
export interface RenderDialogInit {
ownerDocument?: Document;
parentElement?: HTMLElement | string | null;
closedBy?: ClosedBy;
classList?: string[];
signal?: AbortSignal;
}
/**
* Renders a dialog with the given template.
*
* @param renderable The template to render inside the dialog.
* @param init Initialization options for the dialog.
*
* @returns A promise that resolves when the dialog is closed.
*/
export function renderDialog(
renderable: unknown,
{
ownerDocument = document,
signal,
parentElement = ownerDocument.getElementById("interface-root"),
closedBy = "any",
classList = [],
}: RenderDialogInit = {},
): Promise<void> {
const dialog = ownerDocument.createElement("dialog");
dialog.classList.add("ak-c-modal", ...classList);
dialog.closedBy = closedBy;
const resolvers = Promise.withResolvers<void>();
const container = resolveDialogContainer(ownerDocument, parentElement);
const shadowRoot = container.shadowRoot ?? container;
shadowRoot.appendChild(dialog);
const dispose = () => {
dialog.close();
dialog.remove();
resolvers.resolve();
};
dialog.addEventListener("close", dispose);
signal?.addEventListener("abort", dispose);
render(renderable, dialog);
return resolvers.promise;
}
/**
* Renders a modal dialog with the given template.
*
* @param renderable The template to render inside the modal.
* @param init Initialization options for the modal.
* @returns A promise that resolves when the modal is closed.
*/
export function renderModal(renderable: unknown, init?: RenderDialogInit): Promise<void> {
return renderDialog(html`<ak-modal>${renderable}</ak-modal>`, init);
}
export type ModalChildRenderer = () => SlottedTemplateResult;
/**
* A utility function that takes either a {@linkcode CustomElementConstructor}
* or a {@linkcode ModalChildRenderer} and returns a function that renders the corresponding modal dialog.
*
* @param input The input to render as a modal dialog, either a custom element constructor or a function that returns a template result.
* @param init Initialization options for the modal dialog.
*/
export function asInvoker(
input: CustomElementConstructor | (() => SlottedTemplateResult),
init?: RenderDialogInit,
) {
return () => {
const child = isAKElementConstructor(input) ? new input() : (input as ModalChildRenderer)();
if (child instanceof AKModal) {
return renderDialog(child, init);
}
renderModal(child, init);
};
}

View File

@@ -1,12 +1,11 @@
import { globalAK } from "#common/global";
import { rootInterface } from "#common/theme";
import { DefaultBrand } from "#common/ui/config";
import { AKElement } from "#elements/Base";
import { WithLicenseSummary } from "#elements/mixins/license";
import { WithVersion } from "#elements/mixins/version";
import type { AdminInterface } from "#admin/AdminInterface/index.entrypoint";
import { AboutModal } from "#admin/AdminInterface/AboutModal";
import { LicenseSummaryStatusEnum } from "@goauthentik/api";
@@ -48,15 +47,13 @@ export class SidebarVersion extends WithLicenseSummary(WithVersion(AKElement)) {
if (this.licenseSummary.status !== LicenseSummaryStatusEnum.Unlicensed) {
product += ` ${msg("Enterprise")}`;
}
return html`<button
part="trigger"
role="contentinfo"
aria-label=${msg("Open about dialog")}
class="pf-c-button pf-m-plain"
@click=${() => {
const int = rootInterface<AdminInterface>();
int?.aboutModal?.show();
}}
@click=${AboutModal.open}
>
<p
role="heading"

View File

@@ -1,5 +0,0 @@
export function getRootStyle(selector: string, element: HTMLElement = document.documentElement) {
return getComputedStyle(element, null).getPropertyValue(selector);
}
export default getRootStyle;

View File

@@ -0,0 +1,18 @@
import { LitElement } from "lit";
/**
* Returns the root interface element of the page.
*
* @param ownerDocument The document to query for the interface element. Defaults to the global document.
*/
export function resolveInterface<T extends HTMLElement = LitElement>(ownerDocument = document): T {
const element = ownerDocument.getElementById("interface-root") as T | null;
if (!element) {
throw new Error(
`Could not find root interface element. Was this element added before the parent interface element?`,
);
}
return element;
}

View File

@@ -31,7 +31,9 @@ export function assertAKRegisteredElement(
* Type predicate to determine if a given {@linkcode CustomElementConstructor}
* extends {@linkcode AKElement}.
*/
export function isAKElementConstructor(input: CustomElementConstructor): input is typeof AKElement {
export function isAKElementConstructor(
input: CustomElementConstructor | (() => SlottedTemplateResult),
): input is typeof AKElement {
return Object.prototype.isPrototypeOf.call(AKElement, input);
}

View File

@@ -1,5 +1,6 @@
@import "./base/common.css";
@import "./base/scrollbars.css";
@import "./base/modal.css";
@import "./components/Alert/alert.css";
@import "./components/Banner/banner.css";
@import "./components/Label/label.css";
@@ -10,7 +11,6 @@
@import "./components/Drawer/drawer.css";
@import "./components/Description/description.css";
@import "./components/Card/card.css";
@import "./components/Modal/modal.css";
@import "./components/Content/content.css";
@import "./components/Table/table.css";
@import "./components/Form/form.css";

View File

@@ -0,0 +1,232 @@
/* #region Variables */
.ak-c-modal {
--ak-c-modal--BackgroundColor: var(--pf-global--BackgroundColor--100);
--ak-c-modal--BoxShadow: var(--pf-global--BoxShadow--xl);
--ak-c-modal--ZIndex: var(--pf-global--ZIndex--xl);
--ak-c-modal--Width: 100%;
--ak-c-modal--MaxWidth: calc(100% - var(--pf-global--spacer--xl));
--ak-c-modal--MarginBlockStart: 10dvh;
--ak-c-modal--BorderColor: var(--pf-global--BackgroundColor--150);
--ak-c-modal__backdrop--BackgroundColor: transparent;
--ak-c-modal__backdrop--BackdropFilter: blur(0);
--ak-c-modal__backdrop--active--BackgroundColor: hsla(0, 0%, 0%, 0.5);
--ak-c-modal__backdrop--active--BackdropFilter: blur(2px);
}
/* #endregion */
/* #region Dialog */
.ak-c-modal {
box-sizing: content-box;
scrollbar-gutter: stable;
background-color: var(--ak-c-modal--BackgroundColor);
box-shadow: var(--ak-c-modal--BoxShadow);
padding: var(--ak-c-modal--Padding);
z-index: var(--ak-c-modal--ZIndex);
width: var(--ak-c-modal--Width);
max-width: var(--ak-c-modal--MaxWidth);
background-color: var(--ak-c-modal--BackgroundColor);
box-shadow: var(--ak-c-modal--BoxShadow);
border-color: var(--ak-c-modal--BorderColor);
&:focus-visible {
outline: none;
}
}
/* #endregion */
/* #region Transitions */
.ak-c-modal {
--ak-c-modal--TransitionDuration--fast: 100ms;
--ak-c-modal--TransitionDuration--slow: 150ms;
margin-block-start: var(--ak-c-modal--MarginBlockStart);
interpolate-size: allow-keywords;
transition:
height var(--ak-c-modal--TransitionDuration--fast),
opacity var(--ak-c-modal--TransitionDuration--slow),
transform var(--ak-c-modal--TransitionDuration--slow),
overlay var(--ak-c-modal--TransitionDuration--slow) allow-discrete,
display var(--ak-c-modal--TransitionDuration--slow) allow-discrete;
transition-timing-function: ease-in-out;
opacity: 0;
height: 0;
transform: translateY(1em);
&::backdrop {
backdrop-filter: var(--ak-c-modal__backdrop--BackdropFilter);
background-color: var(--ak-c-modal__backdrop--BackgroundColor);
transition:
backdrop-filter,
overlay allow-discrete,
background-color;
transition-timing-function: ease-in-out;
transition-duration: var(--ak-c-modal--TransitionDuration--slow);
}
&.fade-in {
--ak-c-modal__backdrop--BackdropFilter: var(--ak-c-modal__backdrop--active--BackdropFilter);
--ak-c-modal__backdrop--BackgroundColor: var(
--ak-c-modal__backdrop--active--BackgroundColor
);
opacity: 1;
height: calc-size(max-content, size);
}
@media screen and (prefers-reduced-motion: reduce) {
/* A small duration is still needed to smoothly handle display changes. */
--ak-c-modal--TransitionDuration--fast: 25ms;
--ak-c-modal--TransitionDuration--slow: 50ms;
}
}
.ak-c-modal[open] {
transform: translateY(0);
@starting-style {
transform: translateY(-1em);
}
}
/* #endregion */
/* #region Buttons */
.ak-c-modal {
--ak-c-modal--c-button--Top: var(--pf-global--spacer--lg);
--ak-c-modal--c-button--Right: var(--pf-global--spacer--md);
--ak-c-modal--c-button--sibling--MarginRight: calc(
var(--pf-global--spacer--xl) + var(--pf-global--spacer--sm)
);
}
/* #endregion */
/* #region Header */
.ak-c-modal {
--ak-c-modal__header--PaddingTop: var(--pf-global--spacer--lg);
--ak-c-modal__header--PaddingBottom: var(--pf-global--spacer--lg);
--ak-c-modal__header--PaddingRight: var(--pf-global--spacer--lg);
--ak-c-modal__header--PaddingLeft: var(--pf-global--spacer--lg);
--ak-c-modal__header--last-child--PaddingBottom: var(--pf-global--spacer--lg);
--ak-c-modal__header--body--PaddingTop: var(--pf-global--spacer--md);
--ak-c-modal__title--LineHeight: var(--pf-global--LineHeight--sm);
--ak-c-modal__title--FontFamily: var(--pf-global--FontFamily--heading--sans-serif);
--ak-c-modal__title--FontSize: var(--pf-global--FontSize--2xl);
--ak-c-modal__title-icon--MarginRight: var(--pf-global--spacer--sm);
--ak-c-modal__title-icon--Color: var(--pf-global--Color--100);
--ak-c-modal__description--PaddingTop: var(--pf-global--spacer--xs);
}
/* #endregion */
/* #region Body */
.ak-c-modal {
--ak-c-modal__body--MinHeight: calc(
var(--pf-global--FontSize--md) * var(--pf-global--LineHeight--md)
);
--ak-c-modal__body--MaxHeight: calc(75dvh - var(--pf-global--spacer--2xl));
--ak-c-modal__body--PaddingTop: var(--pf-global--spacer--lg);
--ak-c-modal__body--PaddingRight: var(--pf-global--spacer--lg);
--ak-c-modal__body--PaddingLeft: var(--pf-global--spacer--lg);
--ak-c-modal__body--last-child--PaddingBottom: var(--pf-global--spacer--lg);
}
/* #endregion */
/* #region Footer */
.ak-c-modal {
--ak-c-modal__footer--PaddingTop: var(--pf-global--spacer--lg);
--ak-c-modal__footer--PaddingRight: var(--pf-global--spacer--lg);
--ak-c-modal__footer--PaddingBottom: var(--pf-global--spacer--lg);
--ak-c-modal__footer--PaddingLeft: var(--pf-global--spacer--lg);
}
/* #endregion */
/* #region Modifiers */
.ak-c-modal {
--ak-c-modal--m-sm--sm--MaxWidth: 35rem;
--ak-c-modal--m-md--Width: 52.5rem;
--ak-c-modal--m-lg--lg--MaxWidth: 70rem;
--ak-c-modal--m-align-top--spacer: var(--pf-global--spacer--sm);
--ak-c-modal--m-align-top--xl--spacer: var(--pf-global--spacer--xl);
--ak-c-modal--m-align-top--MarginTop: var(--ak-c-modal--m-align-top--spacer);
--ak-c-modal--m-align-top--MaxHeight: calc(
100% - min(var(--ak-c-modal--m-align-top--spacer), var(--pf-global--spacer--2xl)) -
var(--ak-c-modal--m-align-top--spacer)
);
--ak-c-modal--m-align-top--MaxWidth: calc(
100% - min(var(--ak-c-modal--m-align-top--spacer) * 2, var(--pf-global--spacer--xl))
);
--ak-c-modal--m-danger__title-icon--Color: var(--pf-global--danger-color--100);
--ak-c-modal--m-warning__title-icon--Color: var(--pf-global--warning-color--100);
--ak-c-modal--m-success__title-icon--Color: var(--pf-global--success-color--100);
--ak-c-modal--m-info__title-icon--Color: var(--pf-global--info-color--100);
--ak-c-modal--m-default__title-icon--Color: var(--pf-global--default-color--200);
@media (min-width: 1200px) {
--ak-c-modal--m-align-top--spacer: var(--ak-c-modal--m-align-top--xl--spacer);
}
&.pf-m-sm {
--ak-c-modal--Width: var(--ak-c-modal--m-sm--sm--MaxWidth);
}
&.pf-m-md {
--ak-c-modal--Width: var(--ak-c-modal--m-md--Width);
}
&.pf-m-lg {
--ak-c-modal--Width: var(--ak-c-modal--m-lg--lg--MaxWidth);
}
&.pf-m-align-top {
top: var(--ak-c-modal--m-align-top--MarginTop);
align-self: flex-start;
max-width: var(--ak-c-modal--m-align-top--MaxWidth);
max-height: var(--ak-c-modal--m-align-top--MaxHeight);
}
&.pf-m-danger {
--ak-c-modal__title-icon--Color: var(--ak-c-modal--m-danger__title-icon--Color);
}
&.pf-m-warning {
--ak-c-modal__title-icon--Color: var(--ak-c-modal--m-warning__title-icon--Color);
}
&.pf-m-success {
--ak-c-modal__title-icon--Color: var(--ak-c-modal--m-success__title-icon--Color);
}
&.pf-m-default {
--ak-c-modal__title-icon--Color: var(--ak-c-modal--m-default__title-icon--Color);
}
&.pf-m-info {
--ak-c-modal__title-icon--Color: var(--ak-c-modal--m-info__title-icon--Color);
}
}
/* #endregion */

View File

@@ -1,14 +0,0 @@
/* #region Dark Theme */
[data-theme="dark"] .pf-c-modal-box,
:host([theme="dark"]) .pf-c-modal-box {
background-color: var(--ak-dark-background);
.pf-c-modal-box__header,
.pf-c-modal-box__footer,
.pf-c-modal-box__body {
background-color: var(--ak-dark-background);
}
}
/* #endregion */

View File

@@ -11,6 +11,7 @@
@import "./base/globals.css";
@import "./base/common.css";
@import "./base/placeholder.css";
@import "./base/modal.css";
@import "#styles/locales/ja/globals.css";
@import "#styles/locales/ko/globals.css";
@import "#styles/locales/zh/globals.css";

22
web/types/dom.d.ts vendored Normal file
View File

@@ -0,0 +1,22 @@
/**
* @file Global DOM-related types.
*/
export {};
declare global {
/**
* The possible values for the `closedBy` property of {@linkcode HTMLDialogElement}.
*/
type ClosedBy = "any" | "closerequest" | "none";
interface HTMLDialogElement {
/**
* Indicates the types of user actions that can be used to close the associated <dialog> element.
*
* @attr closedby
* @see {@link https://developer.mozilla.org/en-US/docs/Web/API/HTMLDialogElement/closedBy MDN}
*/
closedBy: ClosedBy;
}
}