mirror of
https://github.com/goauthentik/authentik
synced 2026-05-15 03:16:22 +02:00
Compare commits
11 Commits
sdko/remov
...
a11y-comma
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a9abf27162 | ||
|
|
5233e1c331 | ||
|
|
70fd4ee5c3 | ||
|
|
4a773aaaa2 | ||
|
|
385992b067 | ||
|
|
d18d1340ac | ||
|
|
9f2a153dfa | ||
|
|
d572475d0f | ||
|
|
5ab896fa6b | ||
|
|
867561884b | ||
|
|
398ec11b15 |
@@ -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`
|
||||
dialog.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 {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import "#admin/AdminInterface/AboutModal";
|
||||
import "#elements/banner/EnterpriseStatusBanner";
|
||||
import "#elements/banner/VersionBanner";
|
||||
import "#elements/messages/MessageContainer";
|
||||
@@ -10,6 +9,7 @@ import {
|
||||
createAdminSidebarEnterpriseEntries,
|
||||
createAdminSidebarEntries,
|
||||
renderSidebarItems,
|
||||
SidebarEntry,
|
||||
} from "./AdminSidebar.js";
|
||||
|
||||
import { isAPIResultReady } from "#common/api/responses";
|
||||
@@ -29,16 +29,16 @@ import {
|
||||
readDrawerParams,
|
||||
renderNotificationDrawerPanel,
|
||||
} from "#elements/notifications/utils";
|
||||
import { navigate } from "#elements/router/RouterOutlet";
|
||||
|
||||
import type { AboutModal } from "#admin/AdminInterface/AboutModal";
|
||||
import Styles from "#admin/AdminInterface/index.entrypoint.css";
|
||||
import { ROUTES } from "#admin/Routes";
|
||||
|
||||
import { CapabilitiesEnum } from "@goauthentik/api";
|
||||
|
||||
import { msg } from "@lit/localize";
|
||||
import { LOCALE_STATUS_EVENT, LocaleStatusEventDetail, 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";
|
||||
@@ -60,14 +60,14 @@ export class AdminInterface extends WithCapabilitiesConfig(
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Properties
|
||||
|
||||
@query("ak-about-modal")
|
||||
public aboutModal?: AboutModal;
|
||||
//#region Public Properties
|
||||
|
||||
@property({ type: Boolean, reflect: true, attribute: "sidebar" })
|
||||
public sidebarOpen = false;
|
||||
|
||||
@property({ type: Array })
|
||||
public entries: readonly SidebarEntry[] = createAdminSidebarEntries();
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Public Methods
|
||||
@@ -76,9 +76,14 @@ export class AdminInterface extends WithCapabilitiesConfig(
|
||||
this.sidebarOpen = !this.sidebarOpen;
|
||||
};
|
||||
|
||||
public synchronizeSidebarEntries = () => {
|
||||
this.logger.debug("Synchronizing sidebar entries with current locale");
|
||||
this.entries = createAdminSidebarEntries();
|
||||
};
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Lifecycle
|
||||
//#region Event Listeners
|
||||
|
||||
#sidebarMatcher: MediaQueryList;
|
||||
#sidebarMediaQueryListener = (event: MediaQueryListEvent) => {
|
||||
@@ -99,6 +104,17 @@ export class AdminInterface extends WithCapabilitiesConfig(
|
||||
persistDrawerParams(event.drawer);
|
||||
};
|
||||
|
||||
@listen(LOCALE_STATUS_EVENT)
|
||||
localeStatusListener = (event: CustomEvent<LocaleStatusEventDetail>) => {
|
||||
if (event.detail.status === "ready") {
|
||||
this.synchronizeSidebarEntries();
|
||||
}
|
||||
};
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Lifecycle
|
||||
|
||||
constructor() {
|
||||
configureSentry();
|
||||
|
||||
@@ -110,6 +126,41 @@ export class AdminInterface extends WithCapabilitiesConfig(
|
||||
this.sidebarOpen = this.#sidebarMatcher.matches;
|
||||
}
|
||||
|
||||
#refreshCommandsFrameID = -1;
|
||||
|
||||
#refreshCommands = () => {
|
||||
const commands = [
|
||||
{
|
||||
label: msg("Create a new application..."),
|
||||
action: () => navigate("/core/applications", { createWizard: true }),
|
||||
prefix: msg("Jump to", { id: "command-palette.prefix.jump-to" }),
|
||||
group: msg("Applications"),
|
||||
},
|
||||
{
|
||||
label: msg("Check the logs"),
|
||||
action: () => navigate("/events/log"),
|
||||
group: msg("Events"),
|
||||
},
|
||||
{
|
||||
label: msg("Manage users"),
|
||||
action: () => navigate("/identity/users"),
|
||||
group: msg("Users"),
|
||||
},
|
||||
...this.entries.flatMap(([, label, , children]) => [
|
||||
...(children ?? []).map(([path, childLabel]) => ({
|
||||
label: childLabel,
|
||||
prefix: msg("Jump to", { id: "command-palette.prefix.jump-to" }),
|
||||
group: label,
|
||||
action: () => {
|
||||
navigate(path!);
|
||||
},
|
||||
})),
|
||||
]),
|
||||
];
|
||||
|
||||
this.commandPalette.modal.setCommands(commands);
|
||||
};
|
||||
|
||||
public connectedCallback() {
|
||||
super.connectedCallback();
|
||||
|
||||
@@ -121,11 +172,19 @@ export class AdminInterface extends WithCapabilitiesConfig(
|
||||
public disconnectedCallback(): void {
|
||||
super.disconnectedCallback();
|
||||
|
||||
cancelAnimationFrame(this.#refreshCommandsFrameID);
|
||||
|
||||
this.#sidebarMatcher.removeEventListener("change", this.#sidebarMediaQueryListener);
|
||||
|
||||
WebsocketClient.close();
|
||||
}
|
||||
|
||||
public firstUpdated(changedProperties: PropertyValues<this>): void {
|
||||
super.firstUpdated(changedProperties);
|
||||
|
||||
this.#refreshCommandsFrameID = requestAnimationFrame(this.#refreshCommands);
|
||||
}
|
||||
|
||||
public override updated(changedProperties: PropertyValues<this>): void {
|
||||
super.updated(changedProperties);
|
||||
|
||||
@@ -158,62 +217,62 @@ export class AdminInterface extends WithCapabilitiesConfig(
|
||||
};
|
||||
|
||||
return html`<div class="pf-c-page">
|
||||
<ak-page-navbar>
|
||||
<button
|
||||
slot="toggle"
|
||||
aria-controls="global-nav"
|
||||
class="pf-c-button pf-m-plain"
|
||||
@click=${this.toggleSidebar}
|
||||
aria-label=${this.sidebarOpen
|
||||
? msg("Collapse navigation")
|
||||
: msg("Expand navigation")}
|
||||
aria-expanded=${this.sidebarOpen ? "true" : "false"}
|
||||
>
|
||||
<i aria-hidden="true" class="fas fa-bars"></i>
|
||||
</button>
|
||||
<ak-page-navbar>
|
||||
<button
|
||||
slot="toggle"
|
||||
aria-controls="global-nav"
|
||||
class="pf-c-button pf-m-plain"
|
||||
@click=${this.toggleSidebar}
|
||||
aria-label=${this.sidebarOpen
|
||||
? msg("Collapse navigation")
|
||||
: msg("Expand navigation")}
|
||||
aria-expanded=${this.sidebarOpen ? "true" : "false"}
|
||||
>
|
||||
<i aria-hidden="true" class="fas fa-bars"></i>
|
||||
</button>
|
||||
|
||||
<ak-version-banner></ak-version-banner>
|
||||
<ak-enterprise-status interface="admin"></ak-enterprise-status>
|
||||
</ak-page-navbar>
|
||||
<ak-version-banner></ak-version-banner>
|
||||
<ak-enterprise-status interface="admin"></ak-enterprise-status>
|
||||
</ak-page-navbar>
|
||||
|
||||
<ak-sidebar ?hidden=${!this.sidebarOpen} class="${classMap(sidebarClasses)}"
|
||||
>${renderSidebarItems(createAdminSidebarEntries())}
|
||||
${this.can(CapabilitiesEnum.IsEnterprise)
|
||||
? renderSidebarItems(createAdminSidebarEnterpriseEntries())
|
||||
: nothing}
|
||||
</ak-sidebar>
|
||||
<ak-sidebar ?hidden=${!this.sidebarOpen} class="${classMap(sidebarClasses)}"
|
||||
>${renderSidebarItems(this.entries)}
|
||||
${this.can(CapabilitiesEnum.IsEnterprise)
|
||||
? renderSidebarItems(createAdminSidebarEnterpriseEntries())
|
||||
: nothing}
|
||||
</ak-sidebar>
|
||||
|
||||
<div class="pf-c-page__drawer">
|
||||
<div class="pf-c-drawer ${classMap(drawerClasses)}">
|
||||
<div class="pf-c-drawer__main">
|
||||
<div class="pf-c-drawer__content">
|
||||
<div class="pf-c-drawer__body">
|
||||
<ak-router-outlet
|
||||
role="presentation"
|
||||
class="pf-c-page__main"
|
||||
tabindex="-1"
|
||||
id="main-content"
|
||||
default-url="/administration/overview"
|
||||
.routes=${ROUTES}
|
||||
@ak-route-change=${this.routeChangeListener}
|
||||
>
|
||||
</ak-router-outlet>
|
||||
<div class="pf-c-page__drawer">
|
||||
<div class="pf-c-drawer ${classMap(drawerClasses)}">
|
||||
<div class="pf-c-drawer__main">
|
||||
<div class="pf-c-drawer__content">
|
||||
<div class="pf-c-drawer__body">
|
||||
<ak-router-outlet
|
||||
role="presentation"
|
||||
class="pf-c-page__main"
|
||||
tabindex="-1"
|
||||
id="main-content"
|
||||
default-url="/administration/overview"
|
||||
.routes=${ROUTES}
|
||||
@ak-route-change=${this.routeChangeListener}
|
||||
>
|
||||
</ak-router-outlet>
|
||||
</div>
|
||||
</div>
|
||||
${renderNotificationDrawerPanel(this.drawer)}
|
||||
</div>
|
||||
${renderNotificationDrawerPanel(this.drawer)}
|
||||
<ak-about-modal></ak-about-modal>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="pf-c-page__sidebar-backdrop"
|
||||
aria-label=${this.sidebarOpen ? msg("Close sidebar") : msg("Open sidebar")}
|
||||
@click=${this.toggleSidebar}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
></div>
|
||||
<div
|
||||
class="pf-c-page__sidebar-backdrop"
|
||||
aria-label=${this.sidebarOpen ? msg("Close sidebar") : msg("Open sidebar")}
|
||||
@click=${this.toggleSidebar}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
${this.commandPalette}`;
|
||||
}
|
||||
|
||||
//#endregion
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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>`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
13
web/src/common/collections.ts
Normal file
13
web/src/common/collections.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Given an array or length, return logical index of the element at the given delta.
|
||||
* This is effectively a modulo loop, allowing for positive and negative deltas.
|
||||
*/
|
||||
export function torusIndex(lengthLike: number | ArrayLike<number>, delta: number): number {
|
||||
const length = typeof lengthLike === "number" ? lengthLike : lengthLike.length;
|
||||
|
||||
if (delta < 0) {
|
||||
return (length + delta) % length;
|
||||
}
|
||||
|
||||
return ((delta % length) + length) % length;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import "#elements/buttons/Dropdown";
|
||||
|
||||
import { torusIndex } from "#common/collections";
|
||||
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";
|
||||
|
||||
@@ -33,20 +34,6 @@ export class QL extends DjangoQL {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Given an array or length, return logical index of the element at the given delta.
|
||||
* This is effectively a modulo loop, allowing for positive and negative deltas.
|
||||
*/
|
||||
function torusIndex(lengthLike: number | ArrayLike<number>, delta: number): number {
|
||||
const length = typeof lengthLike === "number" ? lengthLike : lengthLike.length;
|
||||
|
||||
if (delta < 0) {
|
||||
return (length + delta) % length;
|
||||
}
|
||||
|
||||
return ((delta % length) + length) % length;
|
||||
}
|
||||
|
||||
@customElement("ak-search-ql")
|
||||
export class QLSearch extends FormAssociatedElement<string> implements FormAssociated {
|
||||
static shadowRootOptions = { ...LitElement.shadowRootOptions, delegatesFocus: true };
|
||||
@@ -143,8 +130,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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import "#elements/commands/ak-command-palette";
|
||||
|
||||
import { globalAK } from "#common/global";
|
||||
import { applyDocumentTheme, createUIThemeEffect } from "#common/theme";
|
||||
|
||||
import { AKElement } from "#elements/Base";
|
||||
import { AKCommandPalette } from "#elements/commands/ak-command-palette";
|
||||
import { BrandingContextController } from "#elements/controllers/BrandContextController";
|
||||
import { ConfigContextController } from "#elements/controllers/ConfigContextController";
|
||||
import { ContextControllerRegistry } from "#elements/controllers/ContextControllerRegistry";
|
||||
@@ -11,6 +14,8 @@ import { ReactiveContextController } from "#elements/controllers/ReactiveContext
|
||||
import { BrandingContext } from "#elements/mixins/branding";
|
||||
import { AuthentikConfigContext } from "#elements/mixins/config";
|
||||
|
||||
import { ConsoleLogger, Logger } from "#logger/browser";
|
||||
|
||||
import { Context, ContextType } from "@lit/context";
|
||||
import { ReactiveController } from "lit";
|
||||
|
||||
@@ -18,6 +23,8 @@ import { ReactiveController } from "lit";
|
||||
* The base interface element for the application.
|
||||
*/
|
||||
export abstract class Interface extends AKElement {
|
||||
protected logger: Logger;
|
||||
|
||||
/**
|
||||
* Private map of controllers to their registry keys.
|
||||
*
|
||||
@@ -26,9 +33,17 @@ export abstract class Interface extends AKElement {
|
||||
*/
|
||||
#registryKeys = new WeakMap<ReactiveController, ContextType<Context<unknown, unknown>>>();
|
||||
|
||||
/**
|
||||
* The command palette instance. This must be inserted by the extending class,
|
||||
* as the palette may depend on a context that is not available at the time of this class's construction.
|
||||
*/
|
||||
public readonly commandPalette: AKCommandPalette;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
this.logger = ConsoleLogger.prefix(this.tagName.toLowerCase());
|
||||
|
||||
const { config, brand, locale } = globalAK();
|
||||
|
||||
createUIThemeEffect(applyDocumentTheme);
|
||||
@@ -38,7 +53,8 @@ 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";
|
||||
this.commandPalette = this.ownerDocument.createElement("ak-command-palette");
|
||||
}
|
||||
|
||||
public override addController(
|
||||
|
||||
@@ -1,13 +1,21 @@
|
||||
import { CURRENT_CLASS, EVENT_REFRESH } from "#common/constants";
|
||||
|
||||
import { AKElement } from "#elements/Base";
|
||||
import {
|
||||
CommandPaletteState,
|
||||
PaletteCommandAction,
|
||||
PaletteCommandDefinition,
|
||||
} from "#elements/commands/shared";
|
||||
import { intersectionObserver } from "#elements/decorators/intersection-observer";
|
||||
import { getURLParams, updateURLParams } from "#elements/router/RouteMatch";
|
||||
import Styles from "#elements/Tabs.css" with { type: "bundled-text" };
|
||||
import { ifPresent } from "#elements/utils/attributes";
|
||||
import { isFocusable } from "#elements/utils/focus";
|
||||
|
||||
import { msg } from "@lit/localize";
|
||||
import { CSSResult, html, LitElement, TemplateResult } from "lit";
|
||||
import { capitalCase } from "change-case";
|
||||
|
||||
import { msg, str } from "@lit/localize";
|
||||
import { CSSResult, html, LitElement, PropertyValues, TemplateResult } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators.js";
|
||||
import { createRef, ref } from "lit/directives/ref.js";
|
||||
|
||||
@@ -30,16 +38,59 @@ export class Tabs extends AKElement {
|
||||
|
||||
@state()
|
||||
protected tabs: ReadonlyMap<string, Element> = new Map();
|
||||
/**
|
||||
* Whether the tab is visible in the viewport.
|
||||
*/
|
||||
@intersectionObserver()
|
||||
public visible = false;
|
||||
|
||||
#focusTargetRef = createRef<HTMLSlotElement>();
|
||||
#observer: MutationObserver | null = null;
|
||||
|
||||
#commands = new CommandPaletteState<string>();
|
||||
|
||||
#updateTabs = (): void => {
|
||||
this.tabs = new Map(
|
||||
Array.from(this.querySelectorAll(":scope > [slot^='page-']"), (element) => {
|
||||
return [element.getAttribute("slot") || "", element];
|
||||
}),
|
||||
);
|
||||
|
||||
requestAnimationFrame(this.#updateCommands);
|
||||
};
|
||||
|
||||
#updateCommands = (): void => {
|
||||
const commands: PaletteCommandDefinition<string>[] = [];
|
||||
|
||||
if (!this.visible) {
|
||||
this.#commands.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
const group = msg(str`Landmark: ${capitalCase(this.pageIdentifier)}`);
|
||||
const prefix = msg("Switch to tab", { id: "command-palette.switch-to-tab" });
|
||||
|
||||
const action: PaletteCommandAction<string> = (slotName) => {
|
||||
this.activateTab(slotName);
|
||||
};
|
||||
|
||||
for (const [slotName, tabPanel] of this.tabs) {
|
||||
if (this.activeTabName === slotName) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const label = tabPanel.getAttribute("aria-label") || slotName;
|
||||
|
||||
commands.push({
|
||||
label,
|
||||
action,
|
||||
group,
|
||||
prefix,
|
||||
details: slotName,
|
||||
});
|
||||
}
|
||||
|
||||
this.#commands.set(commands);
|
||||
};
|
||||
|
||||
public override connectedCallback(): void {
|
||||
@@ -78,9 +129,18 @@ export class Tabs extends AKElement {
|
||||
|
||||
public override disconnectedCallback(): void {
|
||||
this.#observer?.disconnect();
|
||||
this.#commands.clear();
|
||||
super.disconnectedCallback();
|
||||
}
|
||||
|
||||
public override updated(changedProperties: PropertyValues<this>): void {
|
||||
super.updated(changedProperties);
|
||||
|
||||
if (changedProperties.has("visible")) {
|
||||
this.#updateCommands();
|
||||
}
|
||||
}
|
||||
|
||||
public findActiveTabPanel(): Element | null {
|
||||
return this.querySelector(`[slot='${this.activeTabName}']`);
|
||||
}
|
||||
|
||||
180
web/src/elements/commands/ak-command-palette-modal.css
Normal file
180
web/src/elements/commands/ak-command-palette-modal.css
Normal file
@@ -0,0 +1,180 @@
|
||||
:host {
|
||||
--ak-c-command-palette--PaddingBlock: calc(1em * var(--pf-global--LineHeight--sm));
|
||||
--ak-c-command-palette--PaddingInline: calc(
|
||||
2 * var(--pf-global--spacer--form-element) + var(--pf-global--spacer--sm)
|
||||
);
|
||||
|
||||
--ak-c-command-palette--Translucency: 25%;
|
||||
|
||||
--ak-c-command-palette__group--BorderColor: var(
|
||||
--pf-global--BackgroundColor--dark-transparent-200
|
||||
);
|
||||
|
||||
--ak-fieldset--BorderColor: var(--pf-global--palette--purple-500);
|
||||
|
||||
--ak-c-command-palette__item--BackgroundColor: transparent;
|
||||
--ak-c-command-palette__item--Color: var(--pf-global--palette--purple-700);
|
||||
|
||||
--ak-c-command-palette__item--Color: var(--pf-global--palette--blue-50);
|
||||
--ak-c-command-palette__item--selected--BackgroundColor: var(--pf-global--palette--blue-400);
|
||||
--ak-c-command-palette__item--hover--BackgroundColor: var(--pf-global--palette--purple-600);
|
||||
--ak-c-command-palette__item--hover-selected--BackgroundColor: var(
|
||||
--pf-global--palette--blue-300
|
||||
);
|
||||
|
||||
--pf-global--Color--100: var(--pf-global--palette--purple-50);
|
||||
|
||||
@media (prefers-reduced-transparency: reduce) {
|
||||
--ak-c-command-palette--Translucency: 0%;
|
||||
}
|
||||
will-change: opacity, text-decoration-color, background-color, color;
|
||||
transform: translate3d(0, 0, 0); /* Fixes rendering artifacts. */
|
||||
}
|
||||
|
||||
:host {
|
||||
display: block;
|
||||
min-height: min(10rem, 20dvh);
|
||||
padding: var(--pf-global--spacer--sm);
|
||||
padding-block-end: var(--pf-global--spacer--md);
|
||||
color: var(--pf-global--Color--100);
|
||||
}
|
||||
|
||||
[part="command-field"] {
|
||||
border-bottom: 0.5px solid var(--pf-global--palette--black-600);
|
||||
margin-block-end: var(--pf-global--spacer--xs);
|
||||
padding-block-end: var(--pf-global--spacer--xs);
|
||||
margin-inline: var(--pf-global--spacer--sm);
|
||||
}
|
||||
|
||||
#command-input {
|
||||
background: transparent;
|
||||
display: block;
|
||||
width: 100%;
|
||||
font-size: var(--pf-global--FontSize--2xl);
|
||||
padding-block: var(--pf-global--spacer--form-element);
|
||||
padding-inline-start: calc(var(--ak-c-command-palette--PaddingInline) * 2);
|
||||
padding-inline-end: var(--ak-c-command-palette--PaddingInline);
|
||||
border: none;
|
||||
|
||||
outline: none;
|
||||
|
||||
&::placeholder {
|
||||
color: var(--pf-global--palette--purple-100);
|
||||
font-weight: 100;
|
||||
font-family: var(--pf-global--FontFamily--heading--sans-serif);
|
||||
}
|
||||
}
|
||||
|
||||
[part="results-group"] {
|
||||
border-width: 0.5px;
|
||||
padding-inline: 0 !important;
|
||||
padding-block-end: 0 !important;
|
||||
|
||||
legend {
|
||||
padding-block: var(--pf-global--spacer--xs) !important;
|
||||
}
|
||||
|
||||
&:not(:first-child) {
|
||||
margin-block-start: var(--pf-global--spacer--md);
|
||||
}
|
||||
}
|
||||
|
||||
[part="results"] {
|
||||
overflow-y: auto;
|
||||
max-height: calc(100dvh - (1.75 * var(--ak-c-modal--MarginBlockStart)));
|
||||
}
|
||||
|
||||
[part="results-list"] {
|
||||
margin-block-start: calc(var(--pf-global--spacer--sm) * -1);
|
||||
}
|
||||
|
||||
[part="command-item-label"] {
|
||||
grid-row: label;
|
||||
font-family: var(--pf-global--FontFamily--heading--sans-serif);
|
||||
}
|
||||
|
||||
[part="command-item-prefix"] {
|
||||
grid-area: prefix;
|
||||
font-variant: all-small-caps;
|
||||
font-weight: bold;
|
||||
color: var(--pf-global--palette--gold-200);
|
||||
}
|
||||
|
||||
[part="command-item-suffix"] {
|
||||
grid-area: suffix;
|
||||
font-variant: all-small-caps;
|
||||
font-weight: bold;
|
||||
display: flex;
|
||||
color: var(--pf-global--palette--gold-200);
|
||||
justify-content: end;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
[part="command-item-description"] {
|
||||
grid-area: description;
|
||||
}
|
||||
|
||||
[part="group-heading"] {
|
||||
margin-block: var(--pf-global--spacer--sm);
|
||||
margin-inline: var(--pf-global--spacer--sm);
|
||||
}
|
||||
|
||||
.command-item {
|
||||
&.selected {
|
||||
--ak-c-command-palette__item--BackgroundColor: var(
|
||||
--ak-c-command-palette__item--selected--BackgroundColor
|
||||
);
|
||||
--ak-c-command-palette__item--hover--BackgroundColor: var(
|
||||
--ak-c-command-palette__item--hover-selected--BackgroundColor
|
||||
);
|
||||
--ak-c-command-palette__item--AccentColor: var(--ak-accent);
|
||||
}
|
||||
}
|
||||
|
||||
[part="command-button"] {
|
||||
display: grid;
|
||||
grid-template-areas:
|
||||
"prefix prefix prefix suffix"
|
||||
"icon label label suffix"
|
||||
"icon description description suffix";
|
||||
width: 100%;
|
||||
background: transparent;
|
||||
padding: var(--pf-global--spacer--md);
|
||||
border: none;
|
||||
column-gap: var(--pf-global--spacer--sm);
|
||||
border-inline-start: 3px solid var(--ak-c-command-palette__item--AccentColor, transparent);
|
||||
background-color: color-mix(
|
||||
var(--ak-c-command-palette__item--BackgroundColor),
|
||||
transparent var(--ak-c-command-palette--Translucency)
|
||||
);
|
||||
width: 100%;
|
||||
border-radius: 0;
|
||||
text-align: start;
|
||||
color: var(--ak-c-command-palette__item--Color);
|
||||
|
||||
&:hover {
|
||||
background-color: color-mix(
|
||||
var(--ak-c-command-palette__item--hover--BackgroundColor),
|
||||
transparent var(--ak-c-command-palette--Translucency)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
[part="input-label"] {
|
||||
cursor: pointer;
|
||||
position: absolute;
|
||||
inset-block-start: var(--ak-c-command-palette--PaddingBlock);
|
||||
inset-inline-start: var(--ak-c-command-palette--PaddingInline);
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
|
||||
z-index: 1;
|
||||
pointer-events: none;
|
||||
|
||||
.icon {
|
||||
display: block;
|
||||
height: var(--pf-global--icon--FontSize--lg);
|
||||
fill: currentColor;
|
||||
stroke: currentColor;
|
||||
}
|
||||
}
|
||||
540
web/src/elements/commands/ak-command-palette-modal.ts
Normal file
540
web/src/elements/commands/ak-command-palette-modal.ts
Normal file
@@ -0,0 +1,540 @@
|
||||
import "#elements/EmptyState";
|
||||
|
||||
import { torusIndex } from "#common/collections";
|
||||
import { PFSize } from "#common/enums";
|
||||
|
||||
import Styles from "#elements/commands/ak-command-palette-modal.css";
|
||||
import { AKCommandChangeEvent } from "#elements/commands/events";
|
||||
import { PaletteCommandDefinition } from "#elements/commands/shared";
|
||||
import { listen } from "#elements/decorators/listen";
|
||||
import { AKModal } from "#elements/modals/ak-modal";
|
||||
import { asInvoker } from "#elements/modals/utils";
|
||||
import { SlottedTemplateResult } from "#elements/types";
|
||||
import { FocusTarget } from "#elements/utils/focus";
|
||||
|
||||
import { AboutModal } from "#admin/AdminInterface/AboutModal";
|
||||
|
||||
import Fuse from "fuse.js";
|
||||
|
||||
import { msg, str } from "@lit/localize";
|
||||
import { html, PropertyValues } from "lit";
|
||||
import { guard } from "lit-html/directives/guard.js";
|
||||
import { createRef, ref } from "lit-html/directives/ref.js";
|
||||
import { customElement, property, state } from "lit/decorators.js";
|
||||
import { repeat } from "lit/directives/repeat.js";
|
||||
|
||||
function openDocsSearch(query: string) {
|
||||
const url = new URL("/search", import.meta.env.AK_DOCS_URL);
|
||||
url.searchParams.set("q", query);
|
||||
|
||||
window.open(url, "_ak_docs", "noopener,noreferrer");
|
||||
}
|
||||
|
||||
function createCommonCommands(): PaletteCommandDefinition<unknown>[] {
|
||||
return [
|
||||
{
|
||||
label: msg("Integrations"),
|
||||
prefix: msg("View", { id: "command-palette.prefix.view" }),
|
||||
action: () => window.open("https://integrations.goauthentik.io/", "_blank"),
|
||||
group: msg("Documentation"),
|
||||
},
|
||||
{
|
||||
label: msg("Release notes"),
|
||||
action: () => window.open(import.meta.env.AK_DOCS_RELEASE_NOTES_URL, "_blank"),
|
||||
prefix: msg("View", { id: "command-palette.prefix.view" }),
|
||||
suffix: msg(str`New in ${import.meta.env.AK_VERSION}`, {
|
||||
id: "command-palette.suffix.new-in",
|
||||
}),
|
||||
group: msg("authentik"),
|
||||
},
|
||||
{
|
||||
label: msg("About authentik"),
|
||||
action: AboutModal.open,
|
||||
prefix: msg("View", { id: "command-palette.prefix.view" }),
|
||||
group: msg("authentik"),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@customElement("ak-command-palette-modal")
|
||||
export class AKCommandPaletteModal extends AKModal {
|
||||
static openOnConnect = false;
|
||||
|
||||
static styles = [...AKModal.styles, Styles];
|
||||
|
||||
static open = asInvoker(AKCommandPaletteModal);
|
||||
|
||||
protected autofocusTarget = new FocusTarget<HTMLInputElement>();
|
||||
protected formRef = createRef<HTMLFormElement>();
|
||||
|
||||
#scrollCommandFrameID = -1;
|
||||
#autoFocusFrameID = -1;
|
||||
|
||||
// TODO: Fix form references.
|
||||
declare form: null;
|
||||
|
||||
protected get value() {
|
||||
return this.autofocusTarget.target?.value.trim() || "";
|
||||
}
|
||||
|
||||
protected fuse = new Fuse<PaletteCommandDefinition>([], {
|
||||
keys: [
|
||||
// ---
|
||||
{ name: "label", weight: 3 },
|
||||
"description",
|
||||
"group",
|
||||
{
|
||||
name: "keywords",
|
||||
getFn: (command) => command.keywords?.join(" ") || "",
|
||||
weight: 2,
|
||||
},
|
||||
],
|
||||
findAllMatches: true,
|
||||
includeScore: true,
|
||||
shouldSort: true,
|
||||
ignoreFieldNorm: true,
|
||||
useExtendedSearch: true,
|
||||
threshold: 0.3,
|
||||
});
|
||||
|
||||
//#region Public Properties
|
||||
|
||||
@property({ type: Number, attribute: false, useDefault: true })
|
||||
public selectionIndex = 1;
|
||||
|
||||
public get selectedCommand(): PaletteCommandDefinition | null {
|
||||
if (this.selectionIndex === -1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.filteredCommands[this.selectionIndex] || null;
|
||||
}
|
||||
|
||||
@property({ type: Number, attribute: false, useDefault: true })
|
||||
public maxCount = 20;
|
||||
|
||||
@property({ type: Array, attribute: false, useDefault: true })
|
||||
public filteredCommands: readonly PaletteCommandDefinition<unknown>[] = [];
|
||||
|
||||
/**
|
||||
* A map of the currently filtered commands to their index in the flattened commands array,
|
||||
* used to normalize the selection index while rendering groups.
|
||||
*/
|
||||
#filteredCommandsIndex = new Map<PaletteCommandDefinition<unknown>, number>();
|
||||
/**
|
||||
* A flattened array of all commands in the command palette, used for filtering and selection.
|
||||
*/
|
||||
#flattenedCommands: PaletteCommandDefinition<unknown>[] = [];
|
||||
|
||||
@state()
|
||||
public commands = new Set<readonly PaletteCommandDefinition<unknown>[]>();
|
||||
|
||||
public override size = PFSize.Medium;
|
||||
|
||||
public override focus = this.autofocusTarget.focus;
|
||||
|
||||
//#region Public Methods
|
||||
|
||||
public setCommands = (
|
||||
commands?: readonly PaletteCommandDefinition<unknown>[] | null,
|
||||
previousCommands?: readonly PaletteCommandDefinition<unknown>[] | null,
|
||||
) => {
|
||||
if (previousCommands) {
|
||||
this.commands.delete(previousCommands);
|
||||
}
|
||||
|
||||
if (commands) {
|
||||
this.commands.add(commands);
|
||||
this.#flattenedCommands = Array.from(this.commands).reverse().flat();
|
||||
}
|
||||
|
||||
const { target } = this.autofocusTarget;
|
||||
|
||||
if (target) {
|
||||
target.value = "";
|
||||
}
|
||||
|
||||
if (this.open && (commands || previousCommands)) {
|
||||
this.requestUpdate("commands");
|
||||
}
|
||||
};
|
||||
|
||||
public scrollCommandIntoView = () => {
|
||||
const id = `command-${this.selectionIndex}`;
|
||||
|
||||
const element = this.renderRoot.querySelector(`#${id}`);
|
||||
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
|
||||
const legend = element.closest("fieldset")?.querySelector("legend");
|
||||
|
||||
legend?.scrollIntoView({
|
||||
behavior: "auto",
|
||||
block: "nearest",
|
||||
});
|
||||
|
||||
element.scrollIntoView({
|
||||
behavior: "auto",
|
||||
block: "nearest",
|
||||
});
|
||||
};
|
||||
|
||||
//#region Lifecycle
|
||||
|
||||
public override connectedCallback(): void {
|
||||
super.connectedCallback();
|
||||
this.addEventListener("focus", this.autofocusTarget.toEventListener());
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
this.setCommands([
|
||||
{
|
||||
label: msg("Documentation"),
|
||||
action: () => openDocsSearch(this.value),
|
||||
keywords: [msg("Docs"), msg("Readme"), msg("Help")],
|
||||
prefix: msg("View", { id: "command-palette.prefix.view" }),
|
||||
suffix: msg("New Tab", { id: "command-palette.suffix.view-docs" }),
|
||||
group: msg("Documentation"),
|
||||
},
|
||||
...createCommonCommands(),
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
public override updated(changedProperties: PropertyValues<this>): void {
|
||||
super.updated(changedProperties);
|
||||
|
||||
if (changedProperties.has("commands")) {
|
||||
this.fuse.setCollection(this.#flattenedCommands);
|
||||
this.selectionIndex = 0;
|
||||
this.synchronizeFilteredCommands();
|
||||
}
|
||||
|
||||
if (changedProperties.has("open") && this.open) {
|
||||
cancelAnimationFrame(this.#autoFocusFrameID);
|
||||
|
||||
this.#autoFocusFrameID = requestAnimationFrame(() => {
|
||||
this.autofocusTarget.focus();
|
||||
this.autofocusTarget.target?.select();
|
||||
});
|
||||
}
|
||||
|
||||
if (changedProperties.has("selectionIndex")) {
|
||||
cancelAnimationFrame(this.#scrollCommandFrameID);
|
||||
this.#scrollCommandFrameID = requestAnimationFrame(this.scrollCommandIntoView);
|
||||
}
|
||||
}
|
||||
|
||||
//#endregion
|
||||
|
||||
public synchronizeFilteredCommands = () => {
|
||||
cancelAnimationFrame(this.#scrollCommandFrameID);
|
||||
|
||||
this.selectionIndex = 0;
|
||||
|
||||
const { value } = this;
|
||||
|
||||
if (value) {
|
||||
const filteredCommands = this.fuse
|
||||
.search(value, {
|
||||
limit: this.maxCount,
|
||||
})
|
||||
.map((result) => result.item);
|
||||
|
||||
filteredCommands.push({
|
||||
group: msg("Documentation"),
|
||||
label: msg(str`Search the docs for "${value}"`),
|
||||
prefix: msg("Open", { id: "command-palette.prefix.open" }),
|
||||
suffix: msg("New Tab", { id: "command-palette.suffix.view-docs" }),
|
||||
action: () => openDocsSearch(value),
|
||||
});
|
||||
|
||||
this.filteredCommands = filteredCommands;
|
||||
} else {
|
||||
this.filteredCommands = this.#flattenedCommands.slice(0, this.maxCount);
|
||||
}
|
||||
|
||||
this.#filteredCommandsIndex = new Map(
|
||||
this.filteredCommands.map((command, index) => [command, index]),
|
||||
);
|
||||
|
||||
this.#scrollCommandFrameID = requestAnimationFrame(this.scrollCommandIntoView);
|
||||
};
|
||||
|
||||
public submit() {
|
||||
const form = this.formRef.value;
|
||||
|
||||
if (!form) return;
|
||||
|
||||
const submitEvent = new SubmitEvent("submit", {
|
||||
submitter: this,
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
cancelable: true,
|
||||
});
|
||||
|
||||
form.dispatchEvent(submitEvent);
|
||||
}
|
||||
|
||||
#submitListener = (event: SubmitEvent) => {
|
||||
let commandIndex: number;
|
||||
if (event.submitter instanceof HTMLElement && event.submitter.dataset.index) {
|
||||
commandIndex = parseInt(event.submitter.dataset.index, 10);
|
||||
} else {
|
||||
commandIndex = this.selectionIndex;
|
||||
}
|
||||
|
||||
const command = this.filteredCommands[commandIndex];
|
||||
|
||||
if (!command) return;
|
||||
|
||||
this.open = false;
|
||||
command.action(command.details || null);
|
||||
};
|
||||
|
||||
#commandClickListener = (event: MouseEvent) => {
|
||||
const target = event.currentTarget as HTMLElement;
|
||||
const index = parseInt(target.dataset.index!, 10);
|
||||
|
||||
if (isNaN(index)) return;
|
||||
|
||||
this.selectionIndex = index;
|
||||
};
|
||||
|
||||
//#region Event Listeners
|
||||
|
||||
@listen(AKCommandChangeEvent, {
|
||||
target: this,
|
||||
})
|
||||
protected commandChangeListener = (event: AKCommandChangeEvent) => {
|
||||
this.setCommands(event.commands, event.previousCommands);
|
||||
};
|
||||
|
||||
#keydownListener = (event: KeyboardEvent) => {
|
||||
const visibleCommandsCount = this.filteredCommands.length;
|
||||
|
||||
if (!this.open) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key === "Enter" && this.form) {
|
||||
this.submit();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (!visibleCommandsCount) {
|
||||
return;
|
||||
}
|
||||
|
||||
switch (event.key) {
|
||||
case "ArrowDown":
|
||||
event.preventDefault();
|
||||
|
||||
this.selectionIndex = torusIndex(visibleCommandsCount, this.selectionIndex + 1);
|
||||
return;
|
||||
|
||||
case "ArrowUp":
|
||||
event.preventDefault();
|
||||
this.selectionIndex = torusIndex(visibleCommandsCount, this.selectionIndex - 1);
|
||||
|
||||
return;
|
||||
|
||||
case "Enter":
|
||||
event.preventDefault();
|
||||
this.submit();
|
||||
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
#focusListener = () => {
|
||||
this.selectionIndex = this.selectionIndex === -1 ? 0 : this.selectionIndex;
|
||||
|
||||
this.synchronizeFilteredCommands();
|
||||
};
|
||||
|
||||
//#region Rendering
|
||||
|
||||
protected override renderCloseButton(): SlottedTemplateResult {
|
||||
return null;
|
||||
}
|
||||
|
||||
protected renderCommands() {
|
||||
const { selectionIndex, value, filteredCommands } = this;
|
||||
|
||||
return guard([filteredCommands, selectionIndex, value], () => {
|
||||
const grouped = Object.groupBy(filteredCommands, (command) => command.group || "");
|
||||
|
||||
return html`<div
|
||||
part="results"
|
||||
role="listbox"
|
||||
id="command-suggestions"
|
||||
aria-label=${msg("Query suggestions")}
|
||||
>
|
||||
${repeat(
|
||||
Object.entries(grouped),
|
||||
(_, groupIdx) => `group-${groupIdx}`,
|
||||
([groupLabel, commands], groupIdx) => html`
|
||||
<fieldset part="results-group">
|
||||
<legend
|
||||
class="pf-c-content ${!groupLabel
|
||||
? "sr-only more-contrast-only"
|
||||
: ""}"
|
||||
>
|
||||
<h2>${groupLabel || msg("Ungrouped")}</h2>
|
||||
</legend>
|
||||
|
||||
<ul
|
||||
part="results-list"
|
||||
data-group-index=${groupIdx}
|
||||
role="presentation"
|
||||
>
|
||||
${repeat(
|
||||
commands!,
|
||||
(_, commandIdx) => `group-${groupIdx}-command-${commandIdx}`,
|
||||
(command) => {
|
||||
const absoluteIdx =
|
||||
this.#filteredCommandsIndex.get(command) ?? -1;
|
||||
const { label, prefix, suffix, description } = command;
|
||||
|
||||
const selected = selectionIndex === absoluteIdx;
|
||||
return html`<li
|
||||
role="presentation"
|
||||
id="command-${absoluteIdx}"
|
||||
aria-selected=${selected ? "true" : "false"}
|
||||
class="command-item ${selected ? "selected" : ""}"
|
||||
part="command-item"
|
||||
>
|
||||
<button
|
||||
part="command-button"
|
||||
type="submit"
|
||||
formmethod="dialog"
|
||||
data-index=${absoluteIdx}
|
||||
@click=${this.#commandClickListener}
|
||||
aria-labelledby="command-${absoluteIdx}-label"
|
||||
aria-describedby="command-${absoluteIdx}-description"
|
||||
>
|
||||
${prefix
|
||||
? html`<div
|
||||
part="command-item-prefix"
|
||||
id="command-${absoluteIdx}-prefix"
|
||||
>
|
||||
${prefix}
|
||||
</div>`
|
||||
: null}
|
||||
<div
|
||||
part="command-item-label"
|
||||
id="command-${absoluteIdx}-label"
|
||||
>
|
||||
${label}
|
||||
</div>
|
||||
${suffix
|
||||
? html`<div
|
||||
part="command-item-suffix"
|
||||
id="command-${absoluteIdx}-suffix"
|
||||
>
|
||||
${suffix}
|
||||
</div>`
|
||||
: null}
|
||||
<div
|
||||
part="command-item-description"
|
||||
id="command-${absoluteIdx}-description"
|
||||
>
|
||||
${description || ""}
|
||||
</div>
|
||||
</button>
|
||||
</li>`;
|
||||
},
|
||||
)}
|
||||
</ul>
|
||||
</fieldset>
|
||||
`,
|
||||
)}
|
||||
</div>`;
|
||||
});
|
||||
}
|
||||
|
||||
protected override render() {
|
||||
const { value, filteredCommands } = this;
|
||||
|
||||
return html`<form
|
||||
${ref(this.formRef)}
|
||||
method="dialog"
|
||||
class="command-palette-form"
|
||||
@submit=${this.#submitListener}
|
||||
>
|
||||
<div
|
||||
class="input"
|
||||
aria-expanded=${this.open ? "true" : "false"}
|
||||
aria-autocomplete="list"
|
||||
role="combobox"
|
||||
aria-label=${msg("Command palette")}
|
||||
aria-haspopup="listbox"
|
||||
aria-activedescendant=${this.selectionIndex === -1
|
||||
? ""
|
||||
: `command-${this.selectionIndex}`}
|
||||
>
|
||||
<div part="command-field">
|
||||
<label
|
||||
part="input-label"
|
||||
for="command-input"
|
||||
@click=${this.show}
|
||||
aria-label=${msg("Type a command...", {
|
||||
id: "command-palette-placeholder",
|
||||
desc: "Label for the command palette input",
|
||||
})}
|
||||
>
|
||||
<svg
|
||||
class="icon"
|
||||
role="img"
|
||||
aria-hidden="true"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
viewBox="0 0 32 32"
|
||||
>
|
||||
<path
|
||||
d="m29 27.586-7.552-7.552a11.018 11.018 0 1 0-1.414 1.414L27.586 29ZM4 13a9 9 0 1 1 9 9 9.01 9.01 0 0 1-9-9"
|
||||
/>
|
||||
</svg>
|
||||
</label>
|
||||
|
||||
<input
|
||||
${this.autofocusTarget.toRef()}
|
||||
autofocus
|
||||
id="command-input"
|
||||
name="command"
|
||||
aria-controls="command-suggestions"
|
||||
type="search"
|
||||
placeholder=${msg("What are you looking for?", {
|
||||
id: "command-palette-placeholder-extended",
|
||||
desc: "Placeholder for the command palette input",
|
||||
})}
|
||||
class="pf-c-control command-input"
|
||||
autocomplete="off"
|
||||
autocapitalize="off"
|
||||
spellcheck="false"
|
||||
@input=${this.synchronizeFilteredCommands}
|
||||
@focus=${this.#focusListener}
|
||||
@keydown=${this.#keydownListener}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
${this.renderCommands()}
|
||||
${!value && !filteredCommands.length
|
||||
? html`<ak-empty-state icon="pf-icon-module"
|
||||
><span>${msg("No commands")}</span>
|
||||
<div slot="body">${msg("No commands are currently available.")}</div>
|
||||
</ak-empty-state>`
|
||||
: null}
|
||||
</form>`;
|
||||
}
|
||||
|
||||
//#endregion
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ak-command-palette-modal": AKCommandPaletteModal;
|
||||
}
|
||||
}
|
||||
27
web/src/elements/commands/ak-command-palette.css
Normal file
27
web/src/elements/commands/ak-command-palette.css
Normal file
@@ -0,0 +1,27 @@
|
||||
ak-command-palette {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.ak-c-modal:has(ak-command-palette-modal) {
|
||||
overflow: visible;
|
||||
--ak-c-modal--MarginBlockStart: 25dvh;
|
||||
--ak-c-modal--BackgroundColor: var(--pf-global--palette--purple-700);
|
||||
--ak-c-modal--BorderColor: var(--pf-global--BackgroundColor--dark-300);
|
||||
--ak-c-modal__backdrop--active--BackdropFilter: blur(1px);
|
||||
--ak-c-modal__backdrop--active--BackgroundColor: hsla(0, 0%, 0%, 0.25);
|
||||
|
||||
--ak-c-command-palette--Translucency: 5%;
|
||||
--ak-c-command-palette--BackdropFilter: blur(4px);
|
||||
|
||||
border-radius: var(--pf-global--BorderRadius--sm);
|
||||
backdrop-filter: var(--ak-c-command-palette--BackdropFilter);
|
||||
background-color: color-mix(
|
||||
var(--ak-c-modal--BackgroundColor),
|
||||
transparent var(--ak-c-command-palette--Translucency)
|
||||
);
|
||||
|
||||
@media (prefers-reduced-transparency: reduce) {
|
||||
--ak-c-command-palette--Translucency: 0%;
|
||||
--ak-c-command-palette--BackdropFilter: blur(0);
|
||||
}
|
||||
}
|
||||
66
web/src/elements/commands/ak-command-palette.ts
Normal file
66
web/src/elements/commands/ak-command-palette.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import "#elements/commands/ak-command-palette-modal";
|
||||
|
||||
import HostStyles from "./ak-command-palette.css";
|
||||
|
||||
import { AKElement } from "#elements/Base";
|
||||
import { AKCommandPaletteModal } from "#elements/commands/ak-command-palette-modal";
|
||||
import { listen } from "#elements/decorators/listen";
|
||||
|
||||
import { ConsoleLogger, Logger } from "#logger/browser";
|
||||
|
||||
import { PropertyValues } from "lit";
|
||||
import { customElement } from "lit/decorators.js";
|
||||
|
||||
@customElement("ak-command-palette")
|
||||
export class AKCommandPalette extends AKElement {
|
||||
public static hostStyles = [HostStyles];
|
||||
|
||||
protected createRenderRoot(): HTMLElement | DocumentFragment {
|
||||
return this;
|
||||
}
|
||||
|
||||
protected logger: Logger;
|
||||
|
||||
public readonly dialog: HTMLDialogElement;
|
||||
public readonly modal: AKCommandPaletteModal;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
this.logger = ConsoleLogger.prefix(this.tagName.toLowerCase());
|
||||
|
||||
this.dialog = this.ownerDocument.createElement("dialog");
|
||||
this.modal = this.ownerDocument.createElement("ak-command-palette-modal");
|
||||
|
||||
this.dialog.appendChild(this.modal);
|
||||
}
|
||||
|
||||
@listen("keydown", { passive: false, capture: true })
|
||||
protected keydownListener = (event: KeyboardEvent) => {
|
||||
if (event.key !== "k" || (!event.metaKey && !event.ctrlKey)) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.logger.info("Toggling command palette");
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
this.modal.open = !this.modal.open;
|
||||
};
|
||||
|
||||
protected firstUpdated(_changedProperties: PropertyValues): void {
|
||||
super.firstUpdated(_changedProperties);
|
||||
// DEBUGGING
|
||||
this.modal.open = true;
|
||||
}
|
||||
|
||||
protected override render() {
|
||||
return this.dialog;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ak-command-palette": AKCommandPalette;
|
||||
}
|
||||
}
|
||||
28
web/src/elements/commands/events.ts
Normal file
28
web/src/elements/commands/events.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { PaletteCommandDefinition } from "#elements/commands/shared";
|
||||
|
||||
/**
|
||||
* Event dispatched when the available commands in the command palette change.
|
||||
* This is used by the command palette to update the list of available commands.
|
||||
*/
|
||||
export class AKCommandChangeEvent<D = unknown> extends Event {
|
||||
public static readonly eventName = "ak-command-change";
|
||||
|
||||
public readonly commands: readonly PaletteCommandDefinition<D>[];
|
||||
public readonly previousCommands: readonly PaletteCommandDefinition<D>[] | null;
|
||||
|
||||
constructor(
|
||||
commands: PaletteCommandDefinition<D>[],
|
||||
previousCommands?: PaletteCommandDefinition<D>[] | null,
|
||||
) {
|
||||
super(AKCommandChangeEvent.eventName, { bubbles: true, composed: true });
|
||||
|
||||
this.commands = commands;
|
||||
this.previousCommands = previousCommands ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface WindowEventMap {
|
||||
[AKCommandChangeEvent.eventName]: AKCommandChangeEvent;
|
||||
}
|
||||
}
|
||||
41
web/src/elements/commands/shared.ts
Normal file
41
web/src/elements/commands/shared.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { AKCommandChangeEvent } from "#elements/commands/events";
|
||||
import { SlottedTemplateResult } from "#elements/types";
|
||||
|
||||
export type PaletteCommandAction<D = unknown> = (data: D) => unknown | Promise<unknown>;
|
||||
|
||||
export interface PaletteCommandDefinition<D = unknown> {
|
||||
label: SlottedTemplateResult;
|
||||
keywords?: string[];
|
||||
prefix?: SlottedTemplateResult;
|
||||
suffix?: SlottedTemplateResult;
|
||||
description?: SlottedTemplateResult;
|
||||
group?: string;
|
||||
details?: D;
|
||||
action: PaletteCommandAction<D>;
|
||||
}
|
||||
|
||||
export interface CommandPaletteStateInit<D = unknown> {
|
||||
commands?: PaletteCommandDefinition<D>[] | null;
|
||||
target?: EventTarget;
|
||||
}
|
||||
|
||||
export class CommandPaletteState<D = unknown> {
|
||||
#commands: PaletteCommandDefinition<D>[] | null = null;
|
||||
#target: EventTarget;
|
||||
|
||||
constructor({ commands = null, target = window }: CommandPaletteStateInit<D> = {}) {
|
||||
this.#commands = commands;
|
||||
this.#target = target ?? window;
|
||||
}
|
||||
|
||||
public set(nextCommands: PaletteCommandDefinition<D>[] | null): void {
|
||||
const previousCommands = this.#commands;
|
||||
this.#commands = nextCommands;
|
||||
|
||||
this.#target.dispatchEvent(new AKCommandChangeEvent(nextCommands ?? [], previousCommands));
|
||||
}
|
||||
|
||||
public clear(): void {
|
||||
this.set(null);
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,30 @@
|
||||
import { DEFAULT_CONFIG } from "#common/api/config";
|
||||
import { type APIResult, isAPIResultReady } from "#common/api/responses";
|
||||
import { globalAK } from "#common/global";
|
||||
import { applyThemeChoice, formatColorScheme } from "#common/theme";
|
||||
import { createUIConfig, DefaultUIConfig } from "#common/ui/config";
|
||||
import { autoDetectLanguage } from "#common/ui/locale/utils";
|
||||
import { me } from "#common/users";
|
||||
|
||||
import { CommandPaletteState, PaletteCommandDefinition } from "#elements/commands/shared";
|
||||
import { ReactiveContextController } from "#elements/controllers/ReactiveContextController";
|
||||
import { AKConfigMixin, kAKConfig } from "#elements/mixins/config";
|
||||
import { kAKLocale, type LocaleMixin } from "#elements/mixins/locale";
|
||||
import { SessionContext, SessionMixin, UIConfigContext } from "#elements/mixins/session";
|
||||
import {
|
||||
canAccessAdmin,
|
||||
SessionContext,
|
||||
SessionMixin,
|
||||
UIConfigContext,
|
||||
} from "#elements/mixins/session";
|
||||
import { AKDrawerChangeEvent } from "#elements/notifications/events";
|
||||
import type { ReactiveElementHost } from "#elements/types";
|
||||
|
||||
import { SessionUser } from "@goauthentik/api";
|
||||
import { CoreApi, SessionUser } from "@goauthentik/api";
|
||||
|
||||
import { setUser } from "@sentry/browser";
|
||||
|
||||
import { ContextProvider } from "@lit/context";
|
||||
import { msg } from "@lit/localize";
|
||||
|
||||
/**
|
||||
* A controller that provides the session information to the element.
|
||||
@@ -52,38 +62,128 @@ export class SessionContextController extends ReactiveContextController<APIResul
|
||||
return me(requestInit);
|
||||
}
|
||||
|
||||
#refreshCommandsFrameID = -1;
|
||||
|
||||
#commands = new CommandPaletteState({
|
||||
target: this.host,
|
||||
});
|
||||
|
||||
protected doRefresh(session: APIResult<SessionUser>): void {
|
||||
this.context.setValue(session);
|
||||
this.host.session = session;
|
||||
|
||||
if (isAPIResultReady(session)) {
|
||||
const localeHint: string | undefined = session.user.settings.locale;
|
||||
if (!isAPIResultReady(session)) return;
|
||||
|
||||
if (localeHint) {
|
||||
const locale = autoDetectLanguage(localeHint);
|
||||
this.logger.info(`Activating user's configured locale '${locale}'`);
|
||||
this.host[kAKLocale]?.setLocale(locale);
|
||||
}
|
||||
const localeHint: string | undefined = session.user.settings.locale;
|
||||
|
||||
const { settings = {} } = session.user || {};
|
||||
|
||||
const nextUIConfig = createUIConfig(settings);
|
||||
this.uiConfigContext.setValue(nextUIConfig);
|
||||
this.host.uiConfig = nextUIConfig;
|
||||
const colorScheme = formatColorScheme(nextUIConfig.theme.base);
|
||||
|
||||
applyThemeChoice(colorScheme, this.host.ownerDocument);
|
||||
|
||||
const config = this.host[kAKConfig];
|
||||
|
||||
if (config?.errorReporting.sendPii) {
|
||||
this.logger.info("Sentry with PII enabled.");
|
||||
|
||||
setUser({ email: session.user.email });
|
||||
}
|
||||
if (localeHint) {
|
||||
const locale = autoDetectLanguage(localeHint);
|
||||
this.logger.info(`Activating user's configured locale '${locale}'`);
|
||||
this.host[kAKLocale]?.setLocale(locale);
|
||||
}
|
||||
|
||||
const { settings = {} } = session.user || {};
|
||||
|
||||
const nextUIConfig = createUIConfig(settings);
|
||||
this.uiConfigContext.setValue(nextUIConfig);
|
||||
this.host.uiConfig = nextUIConfig;
|
||||
const colorScheme = formatColorScheme(nextUIConfig.theme.base);
|
||||
|
||||
applyThemeChoice(colorScheme, this.host.ownerDocument);
|
||||
|
||||
const config = this.host[kAKConfig];
|
||||
|
||||
if (config?.errorReporting.sendPii) {
|
||||
this.logger.info("Sentry with PII enabled.");
|
||||
|
||||
setUser({ email: session.user.email });
|
||||
}
|
||||
|
||||
this.#refreshCommandsFrameID = requestAnimationFrame(this.#refreshCommands);
|
||||
}
|
||||
|
||||
#refreshCommands = (): void => {
|
||||
const session = this.context.value;
|
||||
|
||||
if (!isAPIResultReady(session)) {
|
||||
this.#commands.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
const base = globalAK().api.base;
|
||||
const group = msg("Session");
|
||||
|
||||
const commands: PaletteCommandDefinition[] = [
|
||||
{
|
||||
label: msg("Sign out"),
|
||||
suffix: msg("Reloads page", { id: "command-palette.prefix.reloads-page" }),
|
||||
keywords: [msg("Logout"), msg("Log off"), msg("Sign off")],
|
||||
group,
|
||||
action: () => {
|
||||
window.location.assign(`${base}flows/-/default/invalidation/`);
|
||||
},
|
||||
},
|
||||
{
|
||||
label: msg("User settings"),
|
||||
prefix: msg("Navigate to", { id: "command-palette.prefix.navigate" }),
|
||||
group,
|
||||
action: () => {
|
||||
window.location.assign(`${base}if/user/#/settings`);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const { notificationDrawer, apiDrawer } = this.host.uiConfig?.enabledFeatures ?? {};
|
||||
const drawerGroup = msg("Interface");
|
||||
|
||||
if (apiDrawer) {
|
||||
commands.push({
|
||||
label: msg("API requests drawer", {
|
||||
id: "command-palette.label.api-requests-drawer",
|
||||
}),
|
||||
prefix: msg("Toggle", { id: "command-palette.prefix.toggle" }),
|
||||
group: drawerGroup,
|
||||
action: AKDrawerChangeEvent.dispatchAPIToggle,
|
||||
});
|
||||
}
|
||||
|
||||
if (notificationDrawer) {
|
||||
commands.push({
|
||||
label: msg("Notifications drawer", {
|
||||
id: "command-palette.label.notifications-drawer",
|
||||
}),
|
||||
prefix: msg("Toggle", { id: "command-palette.prefix.toggle" }),
|
||||
group: drawerGroup,
|
||||
action: AKDrawerChangeEvent.dispatchNotificationsToggle,
|
||||
});
|
||||
}
|
||||
|
||||
if (canAccessAdmin(session.user)) {
|
||||
commands.push({
|
||||
label: msg("Admin interface"),
|
||||
prefix: msg("Navigate to", { id: "command-palette.prefix.navigate" }),
|
||||
group,
|
||||
action: () => {
|
||||
window.location.assign(`${base}if/admin/`);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
if (session.original) {
|
||||
commands.push({
|
||||
label: msg("Stop impersonation"),
|
||||
suffix: msg("Reloads page", { id: "command-palette.prefix.reloads-page" }),
|
||||
group,
|
||||
action: async () => {
|
||||
await new CoreApi(DEFAULT_CONFIG).coreUsersImpersonateEndRetrieve();
|
||||
window.location.reload();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
this.#commands.set(commands);
|
||||
};
|
||||
|
||||
public override hostConnected() {
|
||||
this.logger.debug("Host connected, refreshing session");
|
||||
this.refresh();
|
||||
@@ -91,6 +191,7 @@ export class SessionContextController extends ReactiveContextController<APIResul
|
||||
|
||||
public override hostDisconnected() {
|
||||
this.context.clearCallbacks();
|
||||
cancelAnimationFrame(this.#refreshCommandsFrameID);
|
||||
|
||||
super.hostDisconnected();
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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() {
|
||||
|
||||
496
web/src/elements/modals/ak-modal.ts
Normal file
496
web/src/elements/modals/ak-modal.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
144
web/src/elements/modals/styles.css
Normal file
144
web/src/elements/modals/styles.css
Normal 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-fieldset__legend--PaddingInlineBase: var(--pf-global--spacer--md);
|
||||
padding-block: calc(var(--ak-fieldset__legend--PaddingInlineBase) / 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 */
|
||||
127
web/src/elements/modals/utils.ts
Normal file
127
web/src/elements/modals/utils.ts
Normal 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);
|
||||
};
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
export function getRootStyle(selector: string, element: HTMLElement = document.documentElement) {
|
||||
return getComputedStyle(element, null).getPropertyValue(selector);
|
||||
}
|
||||
|
||||
export default getRootStyle;
|
||||
18
web/src/elements/utils/render-roots.ts
Normal file
18
web/src/elements/utils/render-roots.ts
Normal 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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
232
web/src/styles/authentik/base/modal.css
Normal file
232
web/src/styles/authentik/base/modal.css
Normal 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 */
|
||||
@@ -3,11 +3,11 @@
|
||||
margin-block-end: var(--pf-global--spacer--md);
|
||||
|
||||
@media not (prefers-contrast: more) {
|
||||
--ak-legend-margin-inline-start: calc(
|
||||
var(--pf-c-card--child--PaddingLeft) - var(--ak-legend-padding-inline-base)
|
||||
--ak-fieldset__legend--MarginInlineStart: calc(
|
||||
var(--pf-c-card--child--PaddingLeft) - var(--ak-fieldset__legend--PaddingInlineBase)
|
||||
);
|
||||
--ak-legend-margin-inline-end: calc(
|
||||
var(--pf-c-card--child--PaddingRight) - var(--ak-legend-padding-inline-base)
|
||||
var(--pf-c-card--child--PaddingRight) - var(--ak-fieldset__legend--PaddingInlineBase)
|
||||
);
|
||||
|
||||
border-width: 0;
|
||||
|
||||
@@ -76,34 +76,34 @@
|
||||
/* #region Fields */
|
||||
|
||||
fieldset {
|
||||
--ak-fieldset-border-width: thin;
|
||||
--ak-fieldset-border-color: var(--pf-global--BackgroundColor--light-100);
|
||||
--ak-legend-margin-inline-base: var(--pf-global--spacer--sm);
|
||||
--ak-legend-padding-inline-base: var(--pf-global--spacer--sm);
|
||||
--ak-fieldset--BorderWidth: thin;
|
||||
--ak-fieldset__legend--MarginInlineBase: var(--pf-global--spacer--sm);
|
||||
--ak-fieldset__legend--PaddingInlineBase: var(--pf-global--spacer--sm);
|
||||
|
||||
border-color: var(--ak-fieldset-border-color);
|
||||
border-width: var(--ak-fieldset-border-width);
|
||||
|
||||
padding: var(--ak-legend-padding-inline-base) !important;
|
||||
border-color: var(--ak-fieldset--BorderColor, var(--pf-global--BackgroundColor--light-100));
|
||||
|
||||
@media (prefers-contrast: more) {
|
||||
--ak-fieldset-border-color: var(--pf-global--BorderColor--200);
|
||||
border-color: var(--ak-fieldset--BorderColor, var(--pf-global--BorderColor--200));
|
||||
}
|
||||
|
||||
@media (prefers-contrast: less) {
|
||||
--ak-fieldset-border-color: transparent;
|
||||
border-color: var(--ak-fieldset--BorderColor, transparent);
|
||||
}
|
||||
|
||||
border-width: var(--ak-fieldset--BorderWidth);
|
||||
|
||||
padding: var(--ak-fieldset__legend--PaddingInlineBase) !important;
|
||||
|
||||
& > legend {
|
||||
line-height: 1;
|
||||
padding: var(--ak-legend-padding-inline-base) !important;
|
||||
padding: var(--ak-fieldset__legend--PaddingInlineBase) !important;
|
||||
margin-inline-start: var(
|
||||
--ak-legend-margin-inline-start,
|
||||
var(--ak-legend-margin-inline-base)
|
||||
--ak-fieldset__legend--MarginInlineStart,
|
||||
var(--ak-fieldset__legend--MarginInlineBase)
|
||||
) !important;
|
||||
margin-inline-end: var(
|
||||
--ak-legend-margin-inline-end,
|
||||
var(--ak-legend-margin-inline-base)
|
||||
var(--ak-fieldset__legend--MarginInlineBase)
|
||||
) !important;
|
||||
}
|
||||
|
||||
@@ -111,8 +111,8 @@ fieldset {
|
||||
border-width: 0;
|
||||
|
||||
&:not(.pf-c-modal-box__footer) {
|
||||
--ak-legend-padding-inline-base: 0;
|
||||
--ak-legend-margin-inline-base: 0;
|
||||
--ak-fieldset__legend--PaddingInlineBase: 0;
|
||||
--ak-fieldset__legend--MarginInlineBase: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,8 +137,9 @@ fieldset {
|
||||
}
|
||||
|
||||
&.pf-c-modal-box__footer {
|
||||
--ak-legend-padding-inline-base: var(--pf-global--spacer--md);
|
||||
padding-block: calc(var(--ak-legend-padding-inline-base) / 2);
|
||||
--ak-fieldset__legend--PaddingInlineBase: var(--pf-global--spacer--md);
|
||||
|
||||
padding-block: calc(var(--ak-fieldset__legend--PaddingInlineBase) / 2);
|
||||
border-inline: none;
|
||||
border-block-end: none;
|
||||
|
||||
@@ -209,14 +210,17 @@ fieldset {
|
||||
}
|
||||
|
||||
fieldset {
|
||||
--ak-fieldset-border-color: var(--pf-global--BackgroundColor--dark-transparent-200);
|
||||
border-color: var(
|
||||
--ak-fieldset--BorderColor,
|
||||
var(--pf-global--BackgroundColor--dark-transparent-200)
|
||||
);
|
||||
|
||||
@media (prefers-contrast: more) {
|
||||
--ak-fieldset-border-color: var(--pf-global--BorderColor--300);
|
||||
border-color: var(--ak-fieldset--BorderColor, var(--pf-global--BorderColor--300));
|
||||
}
|
||||
|
||||
@media (prefers-contrast: less) {
|
||||
--ak-fieldset-border-color: transparent;
|
||||
border-color: var(--ak-fieldset--BorderColor, transparent);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 */
|
||||
@@ -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";
|
||||
|
||||
@@ -132,7 +132,7 @@ ak-app-icon {
|
||||
|
||||
[part="app-group-header"] {
|
||||
@media not (prefers-contrast: more) {
|
||||
--ak-legend-padding-inline-base: 1rem;
|
||||
--ak-fieldset__legend--PaddingInlineBase: 1rem;
|
||||
padding-block-start: 0 !important;
|
||||
padding-inline: 0 !important;
|
||||
margin-inline: 0 !important;
|
||||
|
||||
@@ -182,7 +182,8 @@ class UserInterface extends WithBrandConfig(WithSession(AuthenticatedInterface))
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
</div>
|
||||
${this.commandPalette}`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
22
web/types/dom.d.ts
vendored
Normal file
22
web/types/dom.d.ts
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user