mirror of
https://github.com/goauthentik/authentik.git
synced 2026-06-17 19:09:11 +03:00
web: a11y -- ak-sidebar, ak-modal, cards (#15690)
* web: a11y -- ak-sidebar * web: Fix paths, nesting. Allow for skipping. * web: a11y Modal button. * web: a11y -- alert, message * web: Add utils. * web: Fix types. * web: Tidy types. Fix alignment.
This commit is contained in:
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* @file Unique ID utilities.
|
||||
*/
|
||||
|
||||
/**
|
||||
* A global ID generator.
|
||||
*
|
||||
* @singleton
|
||||
* @runtime common
|
||||
*
|
||||
* @category IDs
|
||||
*/
|
||||
export class IDGenerator {
|
||||
static #sequenceIndex = 0;
|
||||
static #elementIndex = 0;
|
||||
|
||||
/**
|
||||
* Create a new ID for an HTML element.
|
||||
*
|
||||
* This ID will be unique for the lifetime of the page and will not be
|
||||
* exposed on the `window` object.
|
||||
*
|
||||
* @param {string | number} [name] An optional name to use for the element.
|
||||
*/
|
||||
static elementID(name) {
|
||||
name = name || ++this.#elementIndex;
|
||||
|
||||
return "«ak-" + name + "»";
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new ID.
|
||||
*/
|
||||
static next() {
|
||||
this.#sequenceIndex += 1;
|
||||
|
||||
return this.#sequenceIndex;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a random ID in hexadecimal format.
|
||||
*
|
||||
* @param {number} [characterLength]
|
||||
*/
|
||||
static randomID(characterLength = 6) {
|
||||
const bytes = crypto.getRandomValues(new Uint8Array(characterLength / 2));
|
||||
|
||||
return Array.from(bytes, (a) => a.toString(16)).join("");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* @file Helpers for running tests.
|
||||
*/
|
||||
|
||||
/**
|
||||
* A function that returns a promise.
|
||||
* @template {never[]} [A=never[]]
|
||||
* @typedef {(...args: A) => Promise<unknown>} Thenable
|
||||
*/
|
||||
|
||||
/**
|
||||
* A tuple of a function and its arguments.
|
||||
* @template {Thenable} [T=Thenable]
|
||||
* @typedef {[T, Parameters<T>]} SerializedThenable
|
||||
*/
|
||||
|
||||
/**
|
||||
* Executes a sequence of promise-returning functions in series
|
||||
* @template {Thenable[]} T
|
||||
* @param {{ [K in keyof T]: [T[K], ...Parameters<T[K]>] }} sequence
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function series(...sequence) {
|
||||
for (const [thenable, ...args] of sequence) {
|
||||
await thenable(...args);
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { AdminApi, CapabilitiesEnum, LicenseSummaryStatusEnum } from "@goauthent
|
||||
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 PFAbout from "@patternfly/patternfly/components/AboutModalBox/about-modal-box.css";
|
||||
@@ -56,21 +57,32 @@ export class AboutModal extends WithLicenseSummary(WithBrandConfig(ModalButton))
|
||||
];
|
||||
}
|
||||
|
||||
renderModal() {
|
||||
#contentRef = createRef<HTMLDivElement>();
|
||||
|
||||
#backdropListener = (event: PointerEvent) => {
|
||||
// We only want to close the modal when the backdrop is clicked, not when it's children are clicked.
|
||||
|
||||
if (this.#contentRef.value?.contains(event.target as Node)) {
|
||||
return;
|
||||
}
|
||||
this.close();
|
||||
};
|
||||
|
||||
protected override renderModal() {
|
||||
let product = this.brandingTitle;
|
||||
|
||||
if (this.licenseSummary?.status !== LicenseSummaryStatusEnum.Unlicensed) {
|
||||
product += ` ${msg("Enterprise")}`;
|
||||
}
|
||||
return html`<div
|
||||
class="pf-c-backdrop"
|
||||
@click=${(e: PointerEvent) => {
|
||||
e.stopPropagation();
|
||||
this.closeModal();
|
||||
}}
|
||||
>
|
||||
return html`<div class="pf-c-backdrop" @click=${this.#backdropListener}>
|
||||
<div class="pf-l-bullseye">
|
||||
<div class="pf-c-about-modal-box" role="dialog" aria-modal="true">
|
||||
<div
|
||||
${ref(this.#contentRef)}
|
||||
class="pf-c-about-modal-box"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="modal-title"
|
||||
>
|
||||
<div class="pf-c-about-modal-box__brand">
|
||||
<img
|
||||
class="pf-c-about-modal-box__brand-image"
|
||||
@@ -79,18 +91,12 @@ export class AboutModal extends WithLicenseSummary(WithBrandConfig(ModalButton))
|
||||
/>
|
||||
</div>
|
||||
<div class="pf-c-about-modal-box__close">
|
||||
<button
|
||||
class="pf-c-button pf-m-plain"
|
||||
type="button"
|
||||
@click=${() => {
|
||||
this.open = false;
|
||||
}}
|
||||
>
|
||||
<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">${product}</h1>
|
||||
<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">
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import { ID_REGEX, SLUG_REGEX, UUID_REGEX } from "#elements/router/Route";
|
||||
import { SidebarItemProperties } from "#elements/sidebar/SidebarItem";
|
||||
import { LitPropertyRecord } from "#elements/types";
|
||||
|
||||
import { spread } from "@open-wc/lit-helpers";
|
||||
|
||||
import { msg } from "@lit/localize";
|
||||
import { html, nothing, TemplateResult } from "lit";
|
||||
import { ifDefined } from "lit/directives/if-defined.js";
|
||||
import { repeat } from "lit/directives/repeat.js";
|
||||
|
||||
// The second attribute type is of string[] to help with the 'activeWhen' control, which was
|
||||
@@ -11,7 +14,7 @@ import { repeat } from "lit/directives/repeat.js";
|
||||
type SidebarEntry = [
|
||||
path: string | null,
|
||||
label: string,
|
||||
attributes?: Record<string, any> | string[] | null, // eslint-disable-line
|
||||
attributes?: LitPropertyRecord<SidebarItemProperties> | string[] | null,
|
||||
children?: SidebarEntry[],
|
||||
];
|
||||
|
||||
@@ -32,8 +35,7 @@ export function renderSidebarItem([
|
||||
properties.path = path;
|
||||
}
|
||||
|
||||
return html`<ak-sidebar-item ${spread(properties)}>
|
||||
${label ? html`<span slot="label">${label}</span>` : nothing}
|
||||
return html`<ak-sidebar-item label=${ifDefined(label)} ${spread(properties)}>
|
||||
${children ? renderSidebarItems(children) : nothing}
|
||||
</ak-sidebar-item>`;
|
||||
}
|
||||
|
||||
@@ -31,6 +31,7 @@ import { ROUTES } from "#admin/Routes";
|
||||
|
||||
import { CapabilitiesEnum, SessionUser, UiThemeEnum } from "@goauthentik/api";
|
||||
|
||||
import { msg } from "@lit/localize";
|
||||
import { css, CSSResult, html, nothing, TemplateResult } from "lit";
|
||||
import { customElement, eventOptions, property, query } from "lit/decorators.js";
|
||||
import { classMap } from "lit/directives/class-map.js";
|
||||
@@ -163,16 +164,18 @@ export class AdminInterface extends WithCapabilitiesConfig(AuthenticatedInterfac
|
||||
}
|
||||
|
||||
async firstUpdated(): Promise<void> {
|
||||
this.user = await me();
|
||||
me().then((session) => {
|
||||
this.user = session;
|
||||
|
||||
const canAccessAdmin =
|
||||
this.user.user.isSuperuser ||
|
||||
// TODO: somehow add `access_admin_interface` to the API schema
|
||||
this.user.user.systemPermissions.includes("access_admin_interface");
|
||||
const canAccessAdmin =
|
||||
this.user.user.isSuperuser ||
|
||||
// TODO: somehow add `access_admin_interface` to the API schema
|
||||
this.user.user.systemPermissions.includes("access_admin_interface");
|
||||
|
||||
if (!canAccessAdmin && this.user.user.pk > 0) {
|
||||
window.location.assign("/if/user/");
|
||||
}
|
||||
if (!canAccessAdmin && this.user.user.pk > 0) {
|
||||
window.location.assign("/if/user/");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
render(): TemplateResult {
|
||||
@@ -191,13 +194,14 @@ export class AdminInterface extends WithCapabilitiesConfig(AuthenticatedInterfac
|
||||
};
|
||||
|
||||
return html` <ak-locale-context>
|
||||
<ak-skip-to-content></ak-skip-to-content>
|
||||
<div class="pf-c-page">
|
||||
<ak-page-navbar ?open=${this.sidebarOpen} @sidebar-toggle=${this.sidebarListener}>
|
||||
<ak-version-banner></ak-version-banner>
|
||||
<ak-enterprise-status interface="admin"></ak-enterprise-status>
|
||||
</ak-page-navbar>
|
||||
|
||||
<ak-sidebar class="${classMap(sidebarClasses)}">
|
||||
<ak-sidebar ?hidden=${!this.sidebarOpen} class="${classMap(sidebarClasses)}">
|
||||
${renderSidebarItems(AdminSidebarEntries)}
|
||||
${this.can(CapabilitiesEnum.IsEnterprise)
|
||||
? renderSidebarItems(AdminSidebarEnterpriseEntries)
|
||||
@@ -209,9 +213,10 @@ export class AdminInterface extends WithCapabilitiesConfig(AuthenticatedInterfac
|
||||
<div class="pf-c-drawer__main">
|
||||
<div class="pf-c-drawer__content">
|
||||
<div class="pf-c-drawer__body">
|
||||
<main class="pf-c-page__main">
|
||||
<div class="pf-c-page__main">
|
||||
<ak-router-outlet
|
||||
role="main"
|
||||
aria-label="${msg("Main content")}"
|
||||
class="pf-c-page__main"
|
||||
tabindex="-1"
|
||||
id="main-content"
|
||||
@@ -219,7 +224,7 @@ export class AdminInterface extends WithCapabilitiesConfig(AuthenticatedInterfac
|
||||
.routes=${ROUTES}
|
||||
>
|
||||
</ak-router-outlet>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ak-notification-drawer
|
||||
|
||||
@@ -3,14 +3,15 @@ import { PFSize } from "#common/enums";
|
||||
import { APIError, parseAPIResponseError, pluckErrorDetail } from "#common/errors/network";
|
||||
|
||||
import { AggregateCard } from "#elements/cards/AggregateCard";
|
||||
import { SlottedTemplateResult } from "#elements/types";
|
||||
|
||||
import { msg } from "@lit/localize";
|
||||
import { html, nothing, PropertyValues, TemplateResult } from "lit";
|
||||
import { html, nothing, PropertyValues } from "lit";
|
||||
import { state } from "lit/decorators.js";
|
||||
|
||||
export interface AdminStatus {
|
||||
icon: string;
|
||||
message?: TemplateResult;
|
||||
message?: SlottedTemplateResult;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -95,8 +96,8 @@ export abstract class AdminStatusCard<T> extends AggregateCard {
|
||||
*
|
||||
* @returns TemplateResult displaying the value
|
||||
*/
|
||||
protected renderValue(): TemplateResult {
|
||||
return html`${this.value}`;
|
||||
protected renderValue(): SlottedTemplateResult {
|
||||
return this.value ? html`${this.value}` : nothing;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -105,7 +106,7 @@ export abstract class AdminStatusCard<T> extends AggregateCard {
|
||||
* @param status - AdminStatus object containing icon and message
|
||||
* @returns TemplateResult for status display
|
||||
*/
|
||||
private renderStatus(status: AdminStatus): TemplateResult {
|
||||
private renderStatus(status: AdminStatus): SlottedTemplateResult {
|
||||
return html`
|
||||
<p><i class="${status.icon}"></i> ${this.renderValue()}</p>
|
||||
${status.message ? html`<p class="subtext">${status.message}</p>` : nothing}
|
||||
@@ -118,9 +119,9 @@ export abstract class AdminStatusCard<T> extends AggregateCard {
|
||||
* @param error - Error message to display
|
||||
* @returns TemplateResult for error display
|
||||
*/
|
||||
private renderError(error: string): TemplateResult {
|
||||
private renderError(error: string): SlottedTemplateResult {
|
||||
return html`
|
||||
<p><i class="fa fa-times"></i> ${msg("Failed to fetch")}</p>
|
||||
<p><i aria-hidden="true" class="fa fa-times"></i> ${msg("Failed to fetch")}</p>
|
||||
<p class="subtext">${error}</p>
|
||||
`;
|
||||
}
|
||||
@@ -130,7 +131,7 @@ export abstract class AdminStatusCard<T> extends AggregateCard {
|
||||
*
|
||||
* @returns TemplateResult for loading spinner
|
||||
*/
|
||||
private renderLoading(): TemplateResult {
|
||||
private renderLoading(): SlottedTemplateResult {
|
||||
return html`<ak-spinner size="${PFSize.Large}"></ak-spinner>`;
|
||||
}
|
||||
|
||||
@@ -139,7 +140,7 @@ export abstract class AdminStatusCard<T> extends AggregateCard {
|
||||
*
|
||||
* @returns TemplateResult for current component state
|
||||
*/
|
||||
renderInner(): TemplateResult {
|
||||
renderInner(): SlottedTemplateResult {
|
||||
return html`
|
||||
<p class="center-value">
|
||||
${
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { DEFAULT_CONFIG } from "#common/api/config";
|
||||
|
||||
import { SlottedTemplateResult } from "#elements/types";
|
||||
|
||||
import { AdminStatus, AdminStatusCard } from "#admin/admin-overview/cards/AdminStatusCard";
|
||||
|
||||
import { AdminApi, OutpostsApi, SystemInfo } from "@goauthentik/api";
|
||||
|
||||
import { msg } from "@lit/localize";
|
||||
import { html, TemplateResult } from "lit";
|
||||
import { html, nothing } from "lit";
|
||||
import { customElement, state } from "lit/decorators.js";
|
||||
|
||||
@customElement("ak-admin-status-system")
|
||||
@@ -82,12 +84,12 @@ export class SystemStatusCard extends AdminStatusCard<SystemInfo> {
|
||||
});
|
||||
}
|
||||
|
||||
renderHeader(): TemplateResult {
|
||||
return html`${msg("System status")}`;
|
||||
renderHeader(): SlottedTemplateResult {
|
||||
return msg("System status");
|
||||
}
|
||||
|
||||
renderValue(): TemplateResult {
|
||||
return html`${this.statusSummary}`;
|
||||
renderValue(): SlottedTemplateResult {
|
||||
return this.statusSummary ? html`${this.statusSummary}` : nothing;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -84,7 +84,7 @@ export class ApplicationListPage extends WithBrandConfig(TablePage<Application>)
|
||||
];
|
||||
}
|
||||
|
||||
renderSidebarAfter(): TemplateResult {
|
||||
protected renderSidebarAfter(): TemplateResult {
|
||||
return html`<div class="pf-c-sidebar__panel pf-m-width-25">
|
||||
<div class="pf-c-card">
|
||||
<div class="pf-c-card__body">
|
||||
|
||||
@@ -12,7 +12,7 @@ import { TablePage } from "#elements/table/TablePage";
|
||||
import { InitialPermissions, RbacApi } from "@goauthentik/api";
|
||||
|
||||
import { msg } from "@lit/localize";
|
||||
import { html, TemplateResult } from "lit";
|
||||
import { html, HTMLTemplateResult, TemplateResult } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
import { ifDefined } from "lit/directives/if-defined.js";
|
||||
|
||||
@@ -68,7 +68,7 @@ export class InitialPermissionsListPage extends TablePage<InitialPermissions> {
|
||||
</ak-forms-delete-bulk>`;
|
||||
}
|
||||
|
||||
render(): TemplateResult {
|
||||
render(): HTMLTemplateResult {
|
||||
return html`<ak-page-header
|
||||
icon=${this.pageIcon()}
|
||||
header=${this.pageTitle()}
|
||||
|
||||
@@ -12,7 +12,7 @@ import { TablePage } from "#elements/table/TablePage";
|
||||
import { RbacApi, Role } from "@goauthentik/api";
|
||||
|
||||
import { msg } from "@lit/localize";
|
||||
import { html, TemplateResult } from "lit";
|
||||
import { html, HTMLTemplateResult, TemplateResult } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
import { ifDefined } from "lit/directives/if-defined.js";
|
||||
|
||||
@@ -66,7 +66,7 @@ export class RoleListPage extends TablePage<Role> {
|
||||
</ak-forms-delete-bulk>`;
|
||||
}
|
||||
|
||||
render(): TemplateResult {
|
||||
render(): HTMLTemplateResult {
|
||||
return html`<ak-page-header
|
||||
icon=${this.pageIcon()}
|
||||
header=${this.pageTitle()}
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
} from "@goauthentik/api";
|
||||
|
||||
import { msg } from "@lit/localize";
|
||||
import { CSSResult, html, TemplateResult } from "lit";
|
||||
import { CSSResult, html, HTMLTemplateResult, TemplateResult } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators.js";
|
||||
import { ifDefined } from "lit/directives/if-defined.js";
|
||||
|
||||
@@ -171,7 +171,7 @@ export class InvitationListPage extends TablePage<Invitation> {
|
||||
`;
|
||||
}
|
||||
|
||||
render(): TemplateResult {
|
||||
render(): HTMLTemplateResult {
|
||||
return html`<ak-page-header
|
||||
icon=${this.pageIcon()}
|
||||
header=${this.pageTitle()}
|
||||
|
||||
@@ -36,17 +36,17 @@ export interface IAlert {
|
||||
* make, as well as in in-line documentation.
|
||||
*/
|
||||
@customElement("ak-alert")
|
||||
export class Alert extends AKElement implements IAlert {
|
||||
export class AKAlert extends AKElement implements IAlert {
|
||||
/**
|
||||
* Whether or not to display the entire component's contents in-line or not.
|
||||
*
|
||||
* @attr
|
||||
*/
|
||||
@property({ type: Boolean })
|
||||
inline = false;
|
||||
public inline?: boolean;
|
||||
|
||||
@property({ type: Boolean })
|
||||
plain = false;
|
||||
public plain?: boolean;
|
||||
|
||||
/**
|
||||
* Method of determining severity
|
||||
@@ -62,7 +62,7 @@ export class Alert extends AKElement implements IAlert {
|
||||
* @attr
|
||||
*/
|
||||
@property()
|
||||
icon = "fa-exclamation-circle";
|
||||
public icon = "fa-exclamation-circle";
|
||||
|
||||
static styles = [
|
||||
PFBase,
|
||||
@@ -78,10 +78,11 @@ export class Alert extends AKElement implements IAlert {
|
||||
const level = levelNames.includes(this.level)
|
||||
? `pf-m-${this.level}`
|
||||
: (this.level as string);
|
||||
|
||||
return {
|
||||
"pf-c-alert": true,
|
||||
"pf-m-inline": this.inline,
|
||||
"pf-m-plain": this.plain,
|
||||
"pf-m-inline": !!this.inline,
|
||||
"pf-m-plain": !!this.plain,
|
||||
[level]: true,
|
||||
};
|
||||
}
|
||||
@@ -89,9 +90,9 @@ export class Alert extends AKElement implements IAlert {
|
||||
render() {
|
||||
return html`<div class="${classMap(this.classmap)}">
|
||||
<div class="pf-c-alert__icon">
|
||||
<i class="fas ${this.icon}"></i>
|
||||
<i aria-hidden="true" class="fas ${this.icon}"></i>
|
||||
</div>
|
||||
<h4 class="pf-c-alert__title"><slot></slot></h4>
|
||||
<h4 role="presentation" class="pf-c-alert__title"><slot></slot></h4>
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
@@ -103,6 +104,6 @@ export function akAlert(properties: IAlert, content: SlottedTemplateResult = not
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ak-alert": Alert;
|
||||
"ak-alert": AKAlert;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { AKElement } from "#elements/Base";
|
||||
|
||||
import { msg } from "@lit/localize";
|
||||
import { css, html } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
|
||||
import PFBase from "@patternfly/patternfly/patternfly-base.css";
|
||||
|
||||
@customElement("ak-skip-to-content")
|
||||
export class AKSkipToContent extends AKElement {
|
||||
static styles = [
|
||||
PFBase,
|
||||
css`
|
||||
.show-on-focus:not(:focus) {
|
||||
width: 1px !important;
|
||||
height: 1px !important;
|
||||
padding: 0 !important;
|
||||
overflow: hidden !important;
|
||||
clip: rect(1px, 1px, 1px, 1px) !important;
|
||||
border: 0 !important;
|
||||
}
|
||||
|
||||
.show-on-focus {
|
||||
position: absolute !important;
|
||||
}
|
||||
|
||||
.skip-to-content {
|
||||
z-index: 99999;
|
||||
}
|
||||
|
||||
button {
|
||||
color: var(--ak-dark-foreground);
|
||||
z-index: 99999;
|
||||
background-color: var(--ak-accent);
|
||||
font-family: var(--pf-global--FontFamily--heading--sans-serif);
|
||||
padding: var(--pf-global--spacer--md);
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
@property({ type: String })
|
||||
public flowTo: string = "main-content";
|
||||
|
||||
#skipToContent = () => {
|
||||
const element =
|
||||
this.parentElement?.querySelector<HTMLElement>(`#${this.flowTo}`) ||
|
||||
document.getElementById(this.flowTo);
|
||||
|
||||
if (!element) {
|
||||
console.warn(`Could not find element with ID "${this.flowTo}"`);
|
||||
return;
|
||||
}
|
||||
|
||||
element.scrollIntoView();
|
||||
element.focus?.();
|
||||
};
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<button
|
||||
@click=${this.#skipToContent}
|
||||
type="button"
|
||||
class="show-on-focus js-skip-to-content"
|
||||
>
|
||||
${msg("Skip to content")}
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ak-skip-to-content": AKSkipToContent;
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import { SlottedTemplateResult } from "../types.js";
|
||||
|
||||
import { PFSize } from "#common/enums";
|
||||
|
||||
import { AKElement } from "#elements/Base";
|
||||
@@ -35,21 +37,15 @@ export const MODAL_BUTTON_STYLES = css`
|
||||
`;
|
||||
|
||||
@customElement("ak-modal-button")
|
||||
export class ModalButton extends AKElement {
|
||||
//#region Properties
|
||||
|
||||
export abstract class ModalButton extends AKElement {
|
||||
@property()
|
||||
size: PFSize = PFSize.Large;
|
||||
public size: PFSize = PFSize.Large;
|
||||
|
||||
@property({ type: Boolean })
|
||||
open = false;
|
||||
public open = false;
|
||||
|
||||
@property({ type: Boolean })
|
||||
locked = false;
|
||||
|
||||
//#endregion
|
||||
|
||||
handlerBound = false;
|
||||
public locked = false;
|
||||
|
||||
static styles: CSSResult[] = [
|
||||
PFBase,
|
||||
@@ -74,50 +70,63 @@ export class ModalButton extends AKElement {
|
||||
`,
|
||||
];
|
||||
|
||||
closeModal() {
|
||||
this.resetForms();
|
||||
this.open = false;
|
||||
}
|
||||
|
||||
resetForms(): void {
|
||||
for (const form of this.querySelectorAll<Form | HTMLFormElement>("[slot=form]")) {
|
||||
public resetForms(): void {
|
||||
this.querySelectorAll<Form>("[slot=form]").forEach((form) => {
|
||||
form.reset?.();
|
||||
}
|
||||
}
|
||||
|
||||
onClick(): void {
|
||||
this.open = true;
|
||||
this.dispatchEvent(new ModalShowEvent(this));
|
||||
this.querySelectorAll("*").forEach((child) => {
|
||||
if ("requestUpdate" in child) {
|
||||
(child as AKElement).requestUpdate();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
//#region Render
|
||||
/**
|
||||
* Close the modal.
|
||||
*/
|
||||
public close = () => {
|
||||
this.resetForms();
|
||||
this.open = false;
|
||||
};
|
||||
|
||||
renderModalInner(): TemplateResult | typeof nothing {
|
||||
/**
|
||||
* Show the modal.
|
||||
*/
|
||||
public show = (): void => {
|
||||
this.open = true;
|
||||
|
||||
this.dispatchEvent(new ModalShowEvent(this));
|
||||
|
||||
this.querySelectorAll<AKElement>("*").forEach((child) => {
|
||||
child.requestUpdate?.();
|
||||
});
|
||||
};
|
||||
|
||||
#closeListener = () => {
|
||||
this.dispatchEvent(new ModalHideEvent(this));
|
||||
};
|
||||
|
||||
#backdropListener = (event: PointerEvent) => {
|
||||
event.stopPropagation();
|
||||
};
|
||||
|
||||
/**
|
||||
* @abstract
|
||||
*/
|
||||
protected renderModalInner(): SlottedTemplateResult {
|
||||
return html`<slot name="modal"></slot>`;
|
||||
}
|
||||
|
||||
renderModal(): TemplateResult {
|
||||
return html`<div
|
||||
class="pf-c-backdrop"
|
||||
@click=${(e: PointerEvent) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
/**
|
||||
* @abstract
|
||||
*/
|
||||
protected renderModal(): SlottedTemplateResult {
|
||||
return html`<div class="pf-c-backdrop" @click=${this.#backdropListener}>
|
||||
<div class="pf-l-bullseye">
|
||||
<div
|
||||
class="pf-c-modal-box ${this.size} ${this.locked ? "locked" : ""}"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="modal-title"
|
||||
aria-describedby="modal-description"
|
||||
>
|
||||
<button
|
||||
@click=${() => {
|
||||
this.dispatchEvent(new ModalHideEvent(this));
|
||||
}}
|
||||
@click=${this.#closeListener}
|
||||
class="pf-c-button pf-m-plain"
|
||||
type="button"
|
||||
aria-label=${msg("Close dialog")}
|
||||
@@ -131,7 +140,7 @@ export class ModalButton extends AKElement {
|
||||
}
|
||||
|
||||
render(): TemplateResult {
|
||||
return html` <slot name="trigger" @click=${() => this.onClick()}></slot>
|
||||
return html` <slot name="trigger" @click=${this.show}></slot>
|
||||
${this.open ? this.renderModal() : nothing}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { SlottedTemplateResult } from "../types";
|
||||
|
||||
import { AKElement } from "#elements/Base";
|
||||
|
||||
import { css, CSSResult, html, nothing, TemplateResult } from "lit";
|
||||
import { css, CSSResult, html, nothing } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
import { ifDefined } from "lit/directives/if-defined.js";
|
||||
|
||||
@@ -101,28 +103,34 @@ export class AggregateCard extends AKElement implements IAggregateCard {
|
||||
`,
|
||||
];
|
||||
|
||||
renderInner(): TemplateResult {
|
||||
renderInner(): SlottedTemplateResult {
|
||||
return html`<slot></slot>`;
|
||||
}
|
||||
|
||||
renderHeaderLink(): TemplateResult {
|
||||
return html`${this.headerLink
|
||||
? html`<a href="${this.headerLink}">
|
||||
<i class="fa fa-link"> </i>
|
||||
</a>`
|
||||
: ""}`;
|
||||
renderHeaderLink(): SlottedTemplateResult {
|
||||
if (!this.headerLink) {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
return html`<a href="${this.headerLink}">
|
||||
<i aria-hidden="true" class="fa fa-link"> </i>
|
||||
</a>`;
|
||||
}
|
||||
|
||||
renderHeader(): TemplateResult {
|
||||
return html`${this.header ? this.header : ""}`;
|
||||
renderHeader(): SlottedTemplateResult {
|
||||
return this.header ?? nothing;
|
||||
}
|
||||
|
||||
render(): TemplateResult {
|
||||
return html`<div class="pf-c-card pf-c-card-aggregate">
|
||||
render(): SlottedTemplateResult {
|
||||
return html`<div
|
||||
aria-label="${ifDefined(this.header)}"
|
||||
role="region"
|
||||
class="pf-c-card pf-c-card-aggregate"
|
||||
>
|
||||
<div class="pf-c-card__header pf-l-flex pf-m-justify-content-space-between">
|
||||
<div class="pf-c-card__title">
|
||||
${this.icon
|
||||
? html`<i class="${ifDefined(this.icon)}"></i> `
|
||||
? html`<i aria-hidden="true" class="${this.icon}"></i> `
|
||||
: nothing}${this.renderHeader()}
|
||||
</div>
|
||||
${this.renderHeaderLink()}
|
||||
|
||||
@@ -50,6 +50,7 @@ export class QuickActionsCard extends AKElement implements IQuickActionsCard {
|
||||
<a class="pf-u-mb-xl" href=${url} ${external ? 'target="_blank"' : ""}>
|
||||
${external
|
||||
? html`${label} <i
|
||||
aria-hidden="true"
|
||||
class="fas fa-external-link-alt ak-external-link"
|
||||
></i>`
|
||||
: label}
|
||||
@@ -57,7 +58,7 @@ export class QuickActionsCard extends AKElement implements IQuickActionsCard {
|
||||
</li>`;
|
||||
|
||||
return html` <ak-aggregate-card icon="fa fa-share" header=${this.title} left-justified>
|
||||
<ul class="pf-c-list">
|
||||
<ul aria-label="${msg("Quick actions")}" class="pf-c-list">
|
||||
${map(this.actions, renderItem)}
|
||||
</ul>
|
||||
</ak-aggregate-card>`;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { LitElement, ReactiveController } from "lit";
|
||||
|
||||
interface ModalElement extends LitElement {
|
||||
closeModal(): void | boolean;
|
||||
close(): void | boolean;
|
||||
}
|
||||
|
||||
export class ModalShowEvent extends Event {
|
||||
@@ -81,7 +81,7 @@ export class ModalOrchestrationController implements ReactiveController {
|
||||
|
||||
if (!modalIsLive(modal)) return;
|
||||
|
||||
if (modal.closeModal() !== false) {
|
||||
if (modal.close() !== false) {
|
||||
this.#scheduleCleanup(modal);
|
||||
}
|
||||
};
|
||||
@@ -99,7 +99,7 @@ export class ModalOrchestrationController implements ReactiveController {
|
||||
|
||||
if (!modalIsLive(modal)) continue;
|
||||
|
||||
if (modal.closeModal() !== false) {
|
||||
if (modal.close() !== false) {
|
||||
this.#scheduleCleanup(modal);
|
||||
}
|
||||
|
||||
|
||||
@@ -23,32 +23,37 @@ export abstract class ModelForm<T, PKT extends string | number> extends Form<T>
|
||||
}
|
||||
|
||||
@property({ attribute: false })
|
||||
set instancePk(value: PKT) {
|
||||
this._instancePk = value;
|
||||
public set instancePk(value: PKT) {
|
||||
this.#instancePk = value;
|
||||
|
||||
if (this.viewportCheck && !this.isInViewport) {
|
||||
return;
|
||||
}
|
||||
if (this._isLoading) {
|
||||
|
||||
if (this.#loading) {
|
||||
return;
|
||||
}
|
||||
this._isLoading = true;
|
||||
|
||||
this.#loading = true;
|
||||
|
||||
this.load().then(() => {
|
||||
this.loadInstance(value).then((instance) => {
|
||||
this.instance = instance;
|
||||
this._isLoading = false;
|
||||
this.#loading = false;
|
||||
this.requestUpdate();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private _instancePk?: PKT;
|
||||
#instancePk?: PKT;
|
||||
|
||||
// Keep track if we've loaded the model instance
|
||||
private _initialLoad = false;
|
||||
// Keep track if we've done the general data loading of load()
|
||||
private _initialDataLoad = false;
|
||||
#initialLoad = false;
|
||||
|
||||
private _isLoading = false;
|
||||
// Keep track if we've done the general data loading of load()
|
||||
#initialDataLoad = false;
|
||||
|
||||
#loading = false;
|
||||
|
||||
@property({ attribute: false })
|
||||
instance?: T = this.defaultInstance;
|
||||
@@ -59,9 +64,10 @@ export abstract class ModelForm<T, PKT extends string | number> extends Form<T>
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
this.addEventListener(EVENT_REFRESH, () => {
|
||||
if (!this._instancePk) return;
|
||||
this.loadInstance(this._instancePk).then((instance) => {
|
||||
if (!this.#instancePk) return;
|
||||
this.loadInstance(this.#instancePk).then((instance) => {
|
||||
this.instance = instance;
|
||||
});
|
||||
});
|
||||
@@ -69,11 +75,11 @@ export abstract class ModelForm<T, PKT extends string | number> extends Form<T>
|
||||
|
||||
reset(): void {
|
||||
this.instance = undefined;
|
||||
this._initialLoad = false;
|
||||
this.#initialLoad = false;
|
||||
}
|
||||
|
||||
renderVisible(): TemplateResult {
|
||||
if ((this._instancePk && !this.instance) || !this._initialDataLoad) {
|
||||
if ((this.#instancePk && !this.instance) || !this.#initialDataLoad) {
|
||||
return html`<ak-empty-state loading></ak-empty-state>`;
|
||||
}
|
||||
return super.renderVisible();
|
||||
@@ -83,19 +89,20 @@ export abstract class ModelForm<T, PKT extends string | number> extends Form<T>
|
||||
// if we're in viewport now and haven't loaded AND have a PK set, load now
|
||||
// Or if we don't check for viewport in some cases
|
||||
const viewportVisible = this.isInViewport || !this.viewportCheck;
|
||||
if (this._instancePk && !this._initialLoad && viewportVisible) {
|
||||
this.instancePk = this._instancePk;
|
||||
this._initialLoad = true;
|
||||
} else if (!this._initialDataLoad && viewportVisible) {
|
||||
if (this.#instancePk && !this.#initialLoad && viewportVisible) {
|
||||
this.instancePk = this.#instancePk;
|
||||
this.#initialLoad = true;
|
||||
} else if (!this.#initialDataLoad && viewportVisible) {
|
||||
// else if since if the above case triggered that will also call this.load(), so
|
||||
// ensure we don't load again
|
||||
this.load().then(() => {
|
||||
this._initialDataLoad = true;
|
||||
this.#initialDataLoad = true;
|
||||
// Class attributes changed in this.load() might not be @property()
|
||||
// or @state() so let's trigger a re-render to be sure we get updated
|
||||
this.requestUpdate();
|
||||
});
|
||||
}
|
||||
|
||||
return super.render();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,21 +67,25 @@ export class Portal extends LitElement implements IPortal {
|
||||
super.connectedCallback();
|
||||
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.dataset.managedBy = "ak-portal";
|
||||
if (this.name) {
|
||||
this.dropdownContainer.dataset.managedFor = this.name;
|
||||
}
|
||||
|
||||
document.body.append(this.dropdownContainer);
|
||||
|
||||
if (!this.anchor) {
|
||||
throw new Error("Tether entrance initialized incorrectly: missing anchor");
|
||||
}
|
||||
|
||||
this.connected = true;
|
||||
if (this.firstElementChild) {
|
||||
this.content = this.firstElementChild as Element;
|
||||
} else {
|
||||
|
||||
if (!this.firstElementChild) {
|
||||
throw new Error("No content to be portaled included in the tag");
|
||||
}
|
||||
this.content = this.firstElementChild;
|
||||
}
|
||||
|
||||
disconnectedCallback(): void {
|
||||
|
||||
@@ -2,73 +2,137 @@ import { MessageLevel } from "#common/messages";
|
||||
|
||||
import { AKElement } from "#elements/Base";
|
||||
|
||||
import { CSSResult, html, TemplateResult } from "lit";
|
||||
import { msg } from "@lit/localize";
|
||||
import { CSSResult, html, nothing, PropertyValues } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
import { classMap } from "lit/directives/class-map.js";
|
||||
import { ifDefined } from "lit/directives/if-defined.js";
|
||||
|
||||
import PFAlert from "@patternfly/patternfly/components/Alert/alert.css";
|
||||
import PFAlertGroup from "@patternfly/patternfly/components/AlertGroup/alert-group.css";
|
||||
import PFButton from "@patternfly/patternfly/components/Button/button.css";
|
||||
import PFBase from "@patternfly/patternfly/patternfly-base.css";
|
||||
|
||||
/**
|
||||
* An error message returned from an API endpoint.
|
||||
*
|
||||
* @remarks
|
||||
* This interface must align with the server-side event dispatcher.
|
||||
*
|
||||
* @see {@link ../authentik/core/templates/base/skeleton.html}
|
||||
*/
|
||||
export interface APIMessage {
|
||||
level: MessageLevel;
|
||||
message: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
const LEVEL_ICON_MAP = {
|
||||
error: "fas fa-exclamation-circle",
|
||||
warning: "fas fa-exclamation-triangle",
|
||||
success: "fas fa-check-circle",
|
||||
info: "fas fa-info",
|
||||
const LevelIconMap = {
|
||||
[MessageLevel.error]: "fas fa-exclamation-circle",
|
||||
[MessageLevel.warning]: "fas fa-exclamation-triangle",
|
||||
[MessageLevel.success]: "fas fa-check-circle",
|
||||
[MessageLevel.info]: "fas fa-info",
|
||||
} as const satisfies Record<MessageLevel, string>;
|
||||
|
||||
const LevelARIALiveMap = {
|
||||
[MessageLevel.error]: "assertive",
|
||||
[MessageLevel.warning]: "assertive",
|
||||
[MessageLevel.success]: "polite",
|
||||
[MessageLevel.info]: "polite",
|
||||
} as const satisfies Record<MessageLevel, string>;
|
||||
|
||||
@customElement("ak-message")
|
||||
export class Message extends AKElement {
|
||||
@property({ attribute: false })
|
||||
message?: APIMessage;
|
||||
|
||||
@property({ type: Number })
|
||||
removeAfter = 8000;
|
||||
|
||||
@property({ attribute: false })
|
||||
onRemove?: (m: APIMessage) => void;
|
||||
|
||||
static styles: CSSResult[] = [PFBase, PFButton, PFAlert, PFAlertGroup];
|
||||
|
||||
firstUpdated(): void {
|
||||
setTimeout(() => {
|
||||
if (!this.message) return;
|
||||
if (!this.onRemove) return;
|
||||
this.onRemove(this.message);
|
||||
}, this.removeAfter);
|
||||
//#region Properties
|
||||
|
||||
@property({ type: String })
|
||||
public description?: string;
|
||||
|
||||
@property({ type: String })
|
||||
public level?: MessageLevel;
|
||||
|
||||
@property({ attribute: false })
|
||||
public onDismiss?: (message: APIMessage) => void;
|
||||
|
||||
@property({ type: Boolean })
|
||||
public live?: boolean;
|
||||
|
||||
@property({ type: Number })
|
||||
public lifetime?: number = 8_000;
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Lifecycle
|
||||
|
||||
#timeoutID = -1;
|
||||
|
||||
#scheduleDismiss = () => {
|
||||
clearTimeout(this.#timeoutID);
|
||||
this.#timeoutID = -1;
|
||||
|
||||
if (typeof this.lifetime !== "number" || !isFinite(this.lifetime)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.onDismiss) return;
|
||||
|
||||
this.#timeoutID = setTimeout(this.onDismiss, this.lifetime);
|
||||
};
|
||||
|
||||
public firstUpdated() {
|
||||
this.#scheduleDismiss();
|
||||
}
|
||||
|
||||
render(): TemplateResult {
|
||||
return html`<li class="pf-c-alert-group__item">
|
||||
public willUpdate(changed: PropertyValues<this>) {
|
||||
if (changed.has("lifetime") && this.lifetime) {
|
||||
this.#scheduleDismiss();
|
||||
}
|
||||
}
|
||||
|
||||
public disconnectedCallback() {
|
||||
clearTimeout(this.#timeoutID);
|
||||
}
|
||||
|
||||
//#endregion
|
||||
|
||||
public render() {
|
||||
const { description, level = MessageLevel.info } = this;
|
||||
const ariaLive = this.live ? LevelARIALiveMap[level] : "off";
|
||||
|
||||
return html`<li
|
||||
role="status"
|
||||
aria-live="${ariaLive}"
|
||||
aria-atomic="true"
|
||||
aria-labelledby="message-title"
|
||||
aria-describedby=${ifDefined(description ? "message-description" : undefined)}
|
||||
class="pf-c-alert-group__item"
|
||||
>
|
||||
<div
|
||||
class="pf-c-alert pf-m-${this.message?.level} ${this.message?.level ===
|
||||
MessageLevel.error
|
||||
? "pf-m-danger"
|
||||
: ""}"
|
||||
class="${classMap({
|
||||
"pf-c-alert": true,
|
||||
[`pf-m-${level}`]: true,
|
||||
"pf-m-danger": level === MessageLevel.error,
|
||||
})}"
|
||||
>
|
||||
<div class="pf-c-alert__icon">
|
||||
<i class="${this.message ? LEVEL_ICON_MAP[this.message.level] : ""}"></i>
|
||||
<i class="${LevelIconMap[level]}"></i>
|
||||
</div>
|
||||
<p class="pf-c-alert__title">${this.message?.message}</p>
|
||||
${this.message?.description &&
|
||||
html`<div class="pf-c-alert__description">
|
||||
<p>${this.message.description}</p>
|
||||
</div>`}
|
||||
<p class="pf-c-alert__title" id="message-title">
|
||||
<slot></slot>
|
||||
</p>
|
||||
${description
|
||||
? html`<div class="pf-c-alert__description" id="message-description">
|
||||
<p>${description}</p>
|
||||
</div>`
|
||||
: nothing}
|
||||
<div class="pf-c-alert__action">
|
||||
<button
|
||||
aria-label=${msg("Dismiss")}
|
||||
class="pf-c-button pf-m-plain"
|
||||
type="button"
|
||||
@click=${() => {
|
||||
if (!this.message) return;
|
||||
if (!this.onRemove) return;
|
||||
this.onRemove(this.message);
|
||||
}}
|
||||
@click=${this.onDismiss}
|
||||
>
|
||||
<i class="fas fa-times" aria-hidden="true"></i>
|
||||
</button>
|
||||
|
||||
@@ -12,16 +12,20 @@ import { APIMessage } from "#elements/messages/Message";
|
||||
import { instanceOfValidationError } from "@goauthentik/api";
|
||||
|
||||
import { msg } from "@lit/localize";
|
||||
import { css, CSSResult, html, TemplateResult } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
import { css, CSSResult, html } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators.js";
|
||||
import { ifDefined } from "lit/directives/if-defined.js";
|
||||
|
||||
import PFAlertGroup from "@patternfly/patternfly/components/AlertGroup/alert-group.css";
|
||||
import PFBase from "@patternfly/patternfly/patternfly-base.css";
|
||||
|
||||
/**
|
||||
* Adds a message to the message container, displaying it to the user.
|
||||
*
|
||||
* @param message The message to display.
|
||||
* @param unique Whether to only display the message if the title is unique.
|
||||
*
|
||||
* @todo Consider making this a static method on singleton {@linkcode MessageContainer}
|
||||
*/
|
||||
export function showMessage(message: APIMessage, unique = false): void {
|
||||
const container = document.querySelector<MessageContainer>("ak-message-container");
|
||||
@@ -75,8 +79,8 @@ export function showAPIErrorMessage(error: APIError, unique = false): void {
|
||||
|
||||
@customElement("ak-message-container")
|
||||
export class MessageContainer extends AKElement {
|
||||
@property({ attribute: false })
|
||||
messages: APIMessage[] = [];
|
||||
@state()
|
||||
protected messages: APIMessage[] = [];
|
||||
|
||||
@property()
|
||||
alignment: "top" | "bottom" = "top";
|
||||
@@ -99,6 +103,9 @@ export class MessageContainer extends AKElement {
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
// Note: This seems to be susceptible to race conditions.
|
||||
// Events are dispatched regardless if the message container is listening.
|
||||
|
||||
window.addEventListener(EVENT_WS_MESSAGE, ((e: CustomEvent<WSMessage>) => {
|
||||
if (e.detail.message_type !== WS_MSG_TYPE_MESSAGE) return;
|
||||
|
||||
@@ -110,31 +117,38 @@ export class MessageContainer extends AKElement {
|
||||
}) as EventListener);
|
||||
}
|
||||
|
||||
addMessage(message: APIMessage, unique = false): void {
|
||||
public addMessage(message: APIMessage, unique = false): void {
|
||||
if (unique) {
|
||||
const matchIndex = this.messages.findIndex((m) => m.message === message.message);
|
||||
const match = this.messages.some((m) => m.message === message.message);
|
||||
|
||||
if (matchIndex !== -1) return;
|
||||
if (match) return;
|
||||
}
|
||||
|
||||
this.messages.push(message);
|
||||
this.requestUpdate();
|
||||
this.messages = [...this.messages, message];
|
||||
}
|
||||
|
||||
render(): TemplateResult {
|
||||
return html`<ul class="pf-c-alert-group pf-m-toast">
|
||||
${Array.from(this.messages)
|
||||
.reverse()
|
||||
.map((message) => {
|
||||
return html`<ak-message
|
||||
.message=${message}
|
||||
.onRemove=${(m: APIMessage) => {
|
||||
this.messages = this.messages.filter((v) => v !== m);
|
||||
this.requestUpdate();
|
||||
}}
|
||||
>
|
||||
</ak-message>`;
|
||||
})}
|
||||
#removeMessage = (message: APIMessage) => {
|
||||
this.messages = this.messages.filter((v) => v !== message);
|
||||
};
|
||||
|
||||
render() {
|
||||
return html`<ul
|
||||
role="region"
|
||||
aria-label="${msg("Status messages")}"
|
||||
class="pf-c-alert-group pf-m-toast"
|
||||
>
|
||||
${this.messages.toReversed().map((message, idx) => {
|
||||
const { message: title, description, level } = message;
|
||||
|
||||
return html`<ak-message
|
||||
?live=${idx === 0}
|
||||
level=${level}
|
||||
description=${ifDefined(description)}
|
||||
.onDismiss=${() => this.#removeMessage(message)}
|
||||
>
|
||||
${title}
|
||||
</ak-message>`;
|
||||
})}
|
||||
</ul>`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { UiThemeEnum } from "@goauthentik/api";
|
||||
|
||||
import { msg } from "@lit/localize";
|
||||
import { css, CSSResult, html, TemplateResult } from "lit";
|
||||
import { customElement } from "lit/decorators.js";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
|
||||
import PFNav from "@patternfly/patternfly/components/Nav/nav.css";
|
||||
import PFPage from "@patternfly/patternfly/components/Page/page.css";
|
||||
@@ -23,11 +23,25 @@ export class Sidebar extends AKElement {
|
||||
z-index: 100;
|
||||
--pf-c-page__sidebar--Transition: 0 !important;
|
||||
}
|
||||
|
||||
.pf-c-nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow-y: hidden;
|
||||
--pf-c-nav__link--hover--before--BorderBottomWidth: 1px;
|
||||
}
|
||||
|
||||
.pf-c-nav__link:hover::before {
|
||||
--pf-c-nav__link--before--BorderColor: transparent;
|
||||
}
|
||||
|
||||
.pf-c-nav__link.pf-m-current::after,
|
||||
.pf-c-nav__link.pf-m-current:hover::after,
|
||||
.pf-c-nav__item.pf-m-current:not(.pf-m-expanded) .pf-c-nav__link::after {
|
||||
--pf-c-nav__link--m-current--after--BorderColor: #fd4b2d;
|
||||
}
|
||||
|
||||
:host([theme="light"]) {
|
||||
border-right-color: transparent !important;
|
||||
}
|
||||
@@ -36,12 +50,6 @@ export class Sidebar extends AKElement {
|
||||
--pf-c-nav__section--section--MarginTop: var(--pf-global--spacer--sm);
|
||||
}
|
||||
|
||||
nav {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
.pf-c-nav__list {
|
||||
flex-grow: 1;
|
||||
overflow-y: auto;
|
||||
@@ -61,16 +69,25 @@ export class Sidebar extends AKElement {
|
||||
`,
|
||||
];
|
||||
|
||||
@property({ type: Boolean })
|
||||
hidden = false;
|
||||
|
||||
render(): TemplateResult {
|
||||
return html`<nav
|
||||
return html`<div
|
||||
class="pf-c-nav ${this.activeTheme === UiThemeEnum.Light ? "pf-m-light" : ""}"
|
||||
aria-label=${msg("Global")}
|
||||
role="presentation"
|
||||
>
|
||||
<ul class="pf-c-nav__list">
|
||||
<ul
|
||||
id="global-nav"
|
||||
?hidden=${this.hidden}
|
||||
aria-label=${msg("Global navigation")}
|
||||
role="navigation"
|
||||
class="pf-c-nav__list"
|
||||
>
|
||||
<slot></slot>
|
||||
</ul>
|
||||
<ak-sidebar-version></ak-sidebar-version>
|
||||
</nav>`;
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,13 +2,21 @@ import { ROUTE_SEPARATOR } from "#common/constants";
|
||||
|
||||
import { AKElement } from "#elements/Base";
|
||||
|
||||
import { css, CSSResult, html, TemplateResult } from "lit";
|
||||
import { msg, str } from "@lit/localize";
|
||||
import { css, CSSResult, html, nothing, TemplateResult } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
import { ifDefined } from "lit/directives/if-defined.js";
|
||||
|
||||
import PFNav from "@patternfly/patternfly/components/Nav/nav.css";
|
||||
import PFPage from "@patternfly/patternfly/components/Page/page.css";
|
||||
import PFBase from "@patternfly/patternfly/patternfly-base.css";
|
||||
|
||||
export interface SidebarItemProperties {
|
||||
path?: string;
|
||||
activeWhen?: string[];
|
||||
expanded?: boolean;
|
||||
}
|
||||
|
||||
@customElement("ak-sidebar-item")
|
||||
export class SidebarItem extends AKElement {
|
||||
static styles: CSSResult[] = [
|
||||
@@ -67,32 +75,35 @@ export class SidebarItem extends AKElement {
|
||||
];
|
||||
|
||||
@property()
|
||||
path?: string;
|
||||
public path?: string;
|
||||
|
||||
@property({ type: String })
|
||||
public label?: string;
|
||||
|
||||
activeMatchers: RegExp[] = [];
|
||||
|
||||
@property({ type: Boolean })
|
||||
expanded = false;
|
||||
public expanded = false;
|
||||
|
||||
@property({ type: Boolean })
|
||||
isActive = false;
|
||||
public current?: boolean;
|
||||
|
||||
@property({ type: Boolean })
|
||||
isAbsoluteLink = false;
|
||||
public isAbsoluteLink = false;
|
||||
|
||||
@property({ type: Boolean })
|
||||
highlight = false;
|
||||
public highlight?: boolean;
|
||||
|
||||
parent?: SidebarItem;
|
||||
public parent?: SidebarItem;
|
||||
|
||||
get childItems(): SidebarItem[] {
|
||||
public get childItems(): SidebarItem[] {
|
||||
const children = Array.from(this.querySelectorAll<SidebarItem>("ak-sidebar-item") || []);
|
||||
children.forEach((child) => (child.parent = this));
|
||||
return children;
|
||||
}
|
||||
|
||||
@property({ attribute: false })
|
||||
set activeWhen(regexp: string[]) {
|
||||
public set activeWhen(regexp: string[]) {
|
||||
regexp.forEach((r) => {
|
||||
this.activeMatchers.push(new RegExp(r));
|
||||
});
|
||||
@@ -108,7 +119,7 @@ export class SidebarItem extends AKElement {
|
||||
this.childItems.forEach((item) => {
|
||||
this.expandParentRecursive(activePath, item);
|
||||
});
|
||||
this.isActive = this.matchesPath(activePath);
|
||||
this.current = this.matchesPath(activePath);
|
||||
}
|
||||
|
||||
private matchesPath(path: string): boolean {
|
||||
@@ -136,36 +147,53 @@ export class SidebarItem extends AKElement {
|
||||
|
||||
renderWithChildren() {
|
||||
return html`<li
|
||||
aria-label=${ifDefined(this.label)}
|
||||
role="heading"
|
||||
class="pf-c-nav__item ${this.expanded ? "pf-m-expandable pf-m-expanded" : ""}"
|
||||
>
|
||||
<button
|
||||
class="pf-c-nav__link"
|
||||
aria-expanded="true"
|
||||
aria-label=${this.expanded
|
||||
? msg(str`Collapse ${this.label}`)
|
||||
: msg(str`Expand ${this.label}`)}
|
||||
aria-expanded=${this.expanded ? "true" : "false"}
|
||||
aria-controls="subnav-${this.path}"
|
||||
@click=${() => {
|
||||
this.expanded = !this.expanded;
|
||||
}}
|
||||
>
|
||||
<slot name="label"></slot>
|
||||
${this.label}
|
||||
<span class="pf-c-nav__toggle">
|
||||
<span class="pf-c-nav__toggle-icon">
|
||||
<i class="fas fa-angle-right" aria-hidden="true"></i>
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
<section class="pf-c-nav__subnav" ?hidden=${!this.expanded}>
|
||||
<ul class="pf-c-nav__list">
|
||||
<slot></slot>
|
||||
<div class="pf-c-nav__subnav" ?hidden=${!this.expanded}>
|
||||
<ul
|
||||
id="subnav-${this.path}"
|
||||
role="navigation"
|
||||
aria-label=${msg(str`${this.label} navigation`)}
|
||||
class="pf-c-nav__list"
|
||||
?hidden=${!this.expanded}
|
||||
>
|
||||
${this.expanded ? html`<slot></slot>` : nothing}
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
</li>`;
|
||||
}
|
||||
|
||||
renderWithPathAndChildren() {
|
||||
return html`<li
|
||||
role="presentation"
|
||||
aria-label=${ifDefined(this.label)}
|
||||
class="pf-c-nav__item ${this.expanded ? "pf-m-expandable pf-m-expanded" : ""}"
|
||||
>
|
||||
<slot name="label"></slot>
|
||||
${this.label}
|
||||
<button
|
||||
aria-label=${this.expanded
|
||||
? msg(str`Collapse ${this.label}`)
|
||||
: msg(str`Expand ${this.label}`)}
|
||||
class="pf-c-nav__link"
|
||||
aria-expanded="true"
|
||||
@click=${() => {
|
||||
@@ -178,31 +206,29 @@ export class SidebarItem extends AKElement {
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
<section class="pf-c-nav__subnav" ?hidden=${!this.expanded}>
|
||||
<div class="pf-c-nav__subnav" ?hidden=${!this.expanded}>
|
||||
<ul class="pf-c-nav__list">
|
||||
<slot></slot>
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
</li>`;
|
||||
}
|
||||
|
||||
renderWithPath() {
|
||||
return html`
|
||||
<a
|
||||
id="sidebar-nav-link-${this.path}"
|
||||
href="${this.isAbsoluteLink ? "" : "#"}${this.path}"
|
||||
class="pf-c-nav__link ${this.isActive ? "pf-m-current" : ""}"
|
||||
class="pf-c-nav__link ${this.current ? "pf-m-current" : ""}"
|
||||
aria-current=${ifDefined(this.current ? "page" : undefined)}
|
||||
>
|
||||
<slot name="label"></slot>
|
||||
${this.label}
|
||||
</a>
|
||||
`;
|
||||
}
|
||||
|
||||
renderWithLabel() {
|
||||
return html`
|
||||
<span class="pf-c-nav__link">
|
||||
<slot name="label"></slot>
|
||||
</span>
|
||||
`;
|
||||
return html` <span class="pf-c-nav__link"> ${this.label} </span> `;
|
||||
}
|
||||
|
||||
renderInner() {
|
||||
@@ -210,7 +236,11 @@ export class SidebarItem extends AKElement {
|
||||
return this.path ? this.renderWithPathAndChildren() : this.renderWithChildren();
|
||||
}
|
||||
|
||||
return html`<li class="pf-c-nav__item">
|
||||
return html`<li
|
||||
role="presentation"
|
||||
aria-label=${ifDefined(this.label)}
|
||||
class="pf-c-nav__item"
|
||||
>
|
||||
${this.path ? this.renderWithPath() : this.renderWithLabel()}
|
||||
</li>`;
|
||||
}
|
||||
|
||||
@@ -51,14 +51,32 @@ export class SidebarVersion extends WithLicenseSummary(WithVersion(AKElement)) {
|
||||
product += ` ${msg("Enterprise")}`;
|
||||
}
|
||||
return html`<button
|
||||
role="contentinfo"
|
||||
aria-label=${msg("Open about dialog")}
|
||||
class="pf-c-button pf-m-plain"
|
||||
@click=${() => {
|
||||
const int = rootInterface<AdminInterface>();
|
||||
int?.aboutModal?.onClick();
|
||||
int?.aboutModal?.show();
|
||||
}}
|
||||
>
|
||||
<p class="pf-c-title">${product}</p>
|
||||
<p class="pf-c-title">${msg(str`Version ${this.version?.versionCurrent || ""}`)}</p>
|
||||
<p
|
||||
role="heading"
|
||||
aria-level="1"
|
||||
aria-label=${msg("Product name")}
|
||||
id="sidebar-version-product"
|
||||
class="pf-c-title"
|
||||
>
|
||||
${product}
|
||||
</p>
|
||||
<p
|
||||
role="heading"
|
||||
aria-level="1"
|
||||
aria-label=${msg("Product version")}
|
||||
id="sidebar-version-product"
|
||||
class="pf-c-title"
|
||||
>
|
||||
${msg(str`Version ${this.version?.versionCurrent || ""}`)}
|
||||
</p>
|
||||
</button>`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import "../Alert.js";
|
||||
|
||||
import { Alert, type IAlert } from "../Alert.js";
|
||||
import { AKAlert, type IAlert } from "../Alert.js";
|
||||
|
||||
import type { Meta, StoryObj } from "@storybook/web-components";
|
||||
|
||||
@@ -9,7 +9,7 @@ import { ifDefined } from "lit/directives/if-defined.js";
|
||||
|
||||
type IAlertForTesting = IAlert & { message: string };
|
||||
|
||||
const metadata: Meta<Alert> = {
|
||||
const metadata: Meta<AKAlert> = {
|
||||
title: "Elements/<ak-alert>",
|
||||
component: "ak-alert",
|
||||
parameters: {
|
||||
|
||||
@@ -8,11 +8,12 @@ import { formatElapsedTime } from "#common/temporal";
|
||||
|
||||
import { AKElement } from "#elements/Base";
|
||||
import { PaginatedResponse, Table, TableColumn } from "#elements/table/Table";
|
||||
import { SlottedTemplateResult } from "#elements/types";
|
||||
|
||||
import { SyncStatus, SystemTask, SystemTaskStatusEnum } from "@goauthentik/api";
|
||||
|
||||
import { msg } from "@lit/localize";
|
||||
import { css, CSSResult, html, TemplateResult } from "lit";
|
||||
import { css, CSSResult, html, nothing, TemplateResult } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators.js";
|
||||
|
||||
import PFButton from "@patternfly/patternfly/components/Button/button.css";
|
||||
@@ -87,12 +88,12 @@ export class SyncStatusTable extends Table<SystemTask> {
|
||||
</td>`;
|
||||
}
|
||||
|
||||
renderToolbarContainer() {
|
||||
return html``;
|
||||
protected override renderToolbarContainer(): SlottedTemplateResult {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
renderTablePagination() {
|
||||
return html``;
|
||||
protected override renderTablePagination(): SlottedTemplateResult {
|
||||
return nothing;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -304,7 +304,7 @@ export abstract class Table<T> extends WithLicenseSummary(AKElement) implements
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
renderEmpty(inner?: SlottedTemplateResult): TemplateResult {
|
||||
protected renderEmpty(inner?: SlottedTemplateResult): TemplateResult {
|
||||
return html`<tbody role="rowgroup">
|
||||
<tr role="row">
|
||||
<td role="cell" colspan="8">
|
||||
@@ -466,11 +466,11 @@ export abstract class Table<T> extends WithLicenseSummary(AKElement) implements
|
||||
>`;
|
||||
}
|
||||
|
||||
renderToolbarSelected(): SlottedTemplateResult {
|
||||
protected renderToolbarSelected(): SlottedTemplateResult {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
renderToolbarAfter(): SlottedTemplateResult {
|
||||
protected renderToolbarAfter(): SlottedTemplateResult {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
@@ -508,7 +508,7 @@ export abstract class Table<T> extends WithLicenseSummary(AKElement) implements
|
||||
</div>`;
|
||||
}
|
||||
|
||||
renderToolbarContainer(): TemplateResult {
|
||||
protected renderToolbarContainer(): SlottedTemplateResult {
|
||||
return html`<div class="pf-c-toolbar">
|
||||
<div class="pf-c-toolbar__content">
|
||||
${this.renderSearch()}
|
||||
@@ -562,7 +562,7 @@ export abstract class Table<T> extends WithLicenseSummary(AKElement) implements
|
||||
return this.checkbox && this.checkboxChip;
|
||||
}
|
||||
|
||||
renderChipGroup(): TemplateResult {
|
||||
protected renderChipGroup(): TemplateResult {
|
||||
return html`<ak-chip-group>
|
||||
${this.selectedElements.map((el) => {
|
||||
return html`<ak-chip>${this.renderSelectedChip(el)}</ak-chip>`;
|
||||
@@ -571,7 +571,7 @@ export abstract class Table<T> extends WithLicenseSummary(AKElement) implements
|
||||
}
|
||||
|
||||
/* A simple pagination display, shown at both the top and bottom of the page. */
|
||||
renderTablePagination(): TemplateResult {
|
||||
protected renderTablePagination(): SlottedTemplateResult {
|
||||
const handler = (page: number) => {
|
||||
this.page = page;
|
||||
this.fetch();
|
||||
@@ -587,7 +587,7 @@ export abstract class Table<T> extends WithLicenseSummary(AKElement) implements
|
||||
`;
|
||||
}
|
||||
|
||||
renderTable(): TemplateResult {
|
||||
protected renderTable(): TemplateResult {
|
||||
const renderBottomPagination = () =>
|
||||
html`<div class="pf-c-pagination pf-m-bottom">${this.renderTablePagination()}</div>`;
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { SlottedTemplateResult } from "../types.js";
|
||||
|
||||
import { PFSize } from "#common/enums";
|
||||
|
||||
import { AKElement } from "#elements/Base";
|
||||
@@ -7,7 +9,7 @@ import type { Form } from "#elements/forms/Form";
|
||||
import { Table } from "#elements/table/Table";
|
||||
|
||||
import { msg } from "@lit/localize";
|
||||
import { CSSResult, html, TemplateResult } from "lit";
|
||||
import { CSSResult, html, nothing, TemplateResult } from "lit";
|
||||
import { property } from "lit/decorators.js";
|
||||
|
||||
import PFBackdrop from "@patternfly/patternfly/components/Backdrop/backdrop.css";
|
||||
@@ -17,23 +19,24 @@ import PFPage from "@patternfly/patternfly/components/Page/page.css";
|
||||
import PFBullseye from "@patternfly/patternfly/layouts/Bullseye/bullseye.css";
|
||||
import PFStack from "@patternfly/patternfly/layouts/Stack/stack.css";
|
||||
|
||||
export abstract class TableModal<T> extends Table<T> {
|
||||
export abstract class TableModal<T extends object> extends Table<T> {
|
||||
@property()
|
||||
size: PFSize = PFSize.Large;
|
||||
public size: PFSize = PFSize.Large;
|
||||
|
||||
@property({ type: Boolean })
|
||||
set open(value: boolean) {
|
||||
this._open = value;
|
||||
if (value) {
|
||||
set open(nextValue: boolean) {
|
||||
this.#open = nextValue;
|
||||
|
||||
if (nextValue) {
|
||||
this.fetch();
|
||||
}
|
||||
}
|
||||
|
||||
get open(): boolean {
|
||||
return this._open;
|
||||
return this.#open;
|
||||
}
|
||||
|
||||
_open = false;
|
||||
#open = false;
|
||||
|
||||
static styles: CSSResult[] = [
|
||||
...super.styles,
|
||||
@@ -53,10 +56,10 @@ export abstract class TableModal<T> extends Table<T> {
|
||||
return super.fetch();
|
||||
}
|
||||
|
||||
closeModal() {
|
||||
public close = () => {
|
||||
this.resetForms();
|
||||
this.open = false;
|
||||
}
|
||||
};
|
||||
|
||||
resetForms(): void {
|
||||
for (const form of this.querySelectorAll<Form | HTMLFormElement>("[slot=form]")) {
|
||||
@@ -64,45 +67,48 @@ export abstract class TableModal<T> extends Table<T> {
|
||||
}
|
||||
}
|
||||
|
||||
onClick(): void {
|
||||
public show = () => {
|
||||
this.open = true;
|
||||
this.dispatchEvent(new ModalShowEvent(this));
|
||||
|
||||
this.querySelectorAll("*").forEach((child) => {
|
||||
if ("requestUpdate" in child) {
|
||||
(child as AKElement).requestUpdate();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
#closeListener = () => {
|
||||
this.open = false;
|
||||
};
|
||||
|
||||
#backdropListener(event: PointerEvent) {
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
renderModalInner(): TemplateResult {
|
||||
return this.renderTable();
|
||||
}
|
||||
|
||||
renderModal(): TemplateResult {
|
||||
return html`<div
|
||||
class="pf-c-backdrop"
|
||||
@click=${(e: PointerEvent) => {
|
||||
e.stopPropagation();
|
||||
}}
|
||||
>
|
||||
/**
|
||||
* @abstract
|
||||
*/
|
||||
protected renderModal(): SlottedTemplateResult {
|
||||
return html`<div class="pf-c-backdrop" @click=${this.#backdropListener}>
|
||||
<div class="pf-l-bullseye">
|
||||
<div class="pf-c-modal-box ${this.size}" role="dialog" aria-modal="true">
|
||||
<button
|
||||
@click=${() => (this.open = false)}
|
||||
@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>
|
||||
${this.renderModalInner()}
|
||||
${this.renderTable()}
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
render(): TemplateResult {
|
||||
return html` <slot name="trigger" @click=${() => this.onClick()}></slot>
|
||||
${this.open ? this.renderModal() : ""}`;
|
||||
return html` <slot name="trigger" @click=${this.show}></slot>
|
||||
${this.open ? this.renderModal() : nothing}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import "#components/ak-page-header";
|
||||
|
||||
import { updateURLParams } from "#elements/router/RouteMatch";
|
||||
import { Table } from "#elements/table/Table";
|
||||
import { SlottedTemplateResult } from "#elements/types";
|
||||
|
||||
import { msg } from "@lit/localize";
|
||||
import { CSSResult, html, nothing, TemplateResult } from "lit";
|
||||
@@ -10,32 +12,57 @@ import PFContent from "@patternfly/patternfly/components/Content/content.css";
|
||||
import PFPage from "@patternfly/patternfly/components/Page/page.css";
|
||||
import PFSidebar from "@patternfly/patternfly/components/Sidebar/sidebar.css";
|
||||
|
||||
export abstract class TablePage<T> extends Table<T> {
|
||||
abstract pageTitle(): string;
|
||||
abstract pageDescription(): string | undefined;
|
||||
abstract pageIcon(): string;
|
||||
|
||||
export abstract class TablePage<T extends object> extends Table<T> {
|
||||
static styles: CSSResult[] = [...super.styles, PFPage, PFContent, PFSidebar];
|
||||
|
||||
renderSidebarBefore(): TemplateResult {
|
||||
return html``;
|
||||
}
|
||||
//#region Abstract methods
|
||||
|
||||
renderSidebarAfter(): TemplateResult {
|
||||
return html``;
|
||||
}
|
||||
/**
|
||||
* The title of the page.
|
||||
* @abstract
|
||||
*/
|
||||
abstract pageTitle(): string;
|
||||
|
||||
// Optionally render section above the table
|
||||
renderSectionBefore(): TemplateResult {
|
||||
return html``;
|
||||
}
|
||||
/**
|
||||
* The description of the page.
|
||||
* @abstract
|
||||
*/
|
||||
abstract pageDescription(): string | undefined;
|
||||
|
||||
// Optionally render section below the table
|
||||
renderSectionAfter(): TemplateResult {
|
||||
return html``;
|
||||
}
|
||||
/**
|
||||
* The icon to display in the page header.
|
||||
* @abstract
|
||||
*/
|
||||
abstract pageIcon(): string;
|
||||
|
||||
renderEmpty(inner?: TemplateResult): TemplateResult {
|
||||
/**
|
||||
* Render content before the sidebar.
|
||||
* @abstract
|
||||
*/
|
||||
protected renderSidebarBefore?(): TemplateResult;
|
||||
|
||||
/**
|
||||
* Render content after the sidebar.
|
||||
* @abstract
|
||||
*/
|
||||
protected renderSidebarAfter?(): TemplateResult;
|
||||
|
||||
/**
|
||||
* Render content before the main section.
|
||||
* @abstract
|
||||
*/
|
||||
protected renderSectionBefore?(): TemplateResult;
|
||||
|
||||
/**
|
||||
* Render content after the main section.
|
||||
* @abstract
|
||||
*/
|
||||
protected renderSectionAfter?(): TemplateResult;
|
||||
|
||||
/**
|
||||
* Render the empty state.
|
||||
*/
|
||||
protected renderEmpty(inner?: TemplateResult): TemplateResult {
|
||||
return super.renderEmpty(html`
|
||||
${inner
|
||||
? inner
|
||||
@@ -49,9 +76,21 @@ export abstract class TablePage<T> extends Table<T> {
|
||||
`);
|
||||
}
|
||||
|
||||
renderEmptyClearSearch(): TemplateResult {
|
||||
if (this.search === "") {
|
||||
return html``;
|
||||
protected clearSearch = () => {
|
||||
this.search = "";
|
||||
|
||||
this.requestUpdate();
|
||||
|
||||
updateURLParams({
|
||||
search: "",
|
||||
});
|
||||
|
||||
return this.fetch();
|
||||
};
|
||||
|
||||
protected renderEmptyClearSearch(): SlottedTemplateResult {
|
||||
if (!this.search) {
|
||||
return nothing;
|
||||
}
|
||||
return html`<button
|
||||
@click=${() => {
|
||||
@@ -66,25 +105,29 @@ export abstract class TablePage<T> extends Table<T> {
|
||||
</button>`;
|
||||
}
|
||||
|
||||
render(): TemplateResult {
|
||||
render() {
|
||||
return html`<ak-page-header
|
||||
icon=${this.pageIcon()}
|
||||
header=${this.pageTitle()}
|
||||
description=${ifDefined(this.pageDescription())}
|
||||
>
|
||||
</ak-page-header>
|
||||
${this.renderSectionBefore()}
|
||||
<section class="pf-c-page__main-section pf-m-no-padding-mobile">
|
||||
${this.renderSectionBefore?.()}
|
||||
<section
|
||||
id="table-page-main"
|
||||
aria-label=${this.pageTitle()}
|
||||
class="pf-c-page__main-section pf-m-no-padding-mobile"
|
||||
>
|
||||
<div class="pf-c-sidebar pf-m-gutter">
|
||||
<div class="pf-c-sidebar__main">
|
||||
${this.renderSidebarBefore()}
|
||||
${this.renderSidebarBefore?.()}
|
||||
<div class="pf-c-sidebar__content">
|
||||
<div class="pf-c-card">${this.renderTable()}</div>
|
||||
</div>
|
||||
${this.renderSidebarAfter()}
|
||||
${this.renderSidebarAfter?.()}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
${this.renderSectionAfter()}`;
|
||||
${this.renderSectionAfter?.()}`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ import { OwnPropertyRecord, Writeable } from "#common/types";
|
||||
|
||||
import type { LitElement, nothing, ReactiveControllerHost, TemplateResult } from "lit";
|
||||
|
||||
//#region HTML Helpers
|
||||
|
||||
/**
|
||||
* Utility type to extract a record of tag names which correspond to a given type.
|
||||
*
|
||||
@@ -13,11 +15,49 @@ export type HTMLElementTagNameMapOf<T> = {
|
||||
: never]: HTMLElementTagNameMap[K];
|
||||
};
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Element Properties
|
||||
|
||||
/**
|
||||
*
|
||||
* Given an element and a base class, pluck the properties not defined on the base class.
|
||||
*/
|
||||
export type TemplatedProperties<
|
||||
T extends HTMLElement,
|
||||
Base extends Element = HTMLElement,
|
||||
> = Partial<OwnPropertyRecord<T, Base>>;
|
||||
|
||||
/**
|
||||
* Given a record-like object, prefixes each key with a dot, allowing it to be spread into a
|
||||
* template literal.
|
||||
*
|
||||
* ```ts
|
||||
* interface MyElementProperties {
|
||||
* foo: string;
|
||||
* bar: number;
|
||||
* }
|
||||
*
|
||||
* const properties {} as LitPropertyRecord<MyElementProperties>
|
||||
*
|
||||
* console.log(properties) // { '.foo': string; '.bar': number }
|
||||
* ```
|
||||
*/
|
||||
export type LitPropertyRecord<T extends object> = {
|
||||
[K in keyof T as K extends string ? LitPropertyKey<K> : never]: T[K];
|
||||
};
|
||||
|
||||
/**
|
||||
* A type that represents a property key that can be used in a LitPropertyRecord.
|
||||
*
|
||||
* @see {@linkcode LitPropertyRecord}
|
||||
*/
|
||||
export type LitPropertyKey<K> = K extends string ? `.${K}` | `?${K}` | K : K;
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Host/Controller
|
||||
|
||||
/**
|
||||
* A custom element which may be used as a host for a ReactiveController.
|
||||
*
|
||||
@@ -27,6 +67,10 @@ export type TemplatedProperties<
|
||||
*/
|
||||
export type ReactiveElementHost<T> = Partial<ReactiveControllerHost & Writeable<T>> & HTMLElement;
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Constructors
|
||||
|
||||
export type AbstractLitElementConstructor<T = unknown> = abstract new (
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
...args: any[]
|
||||
@@ -35,6 +79,10 @@ export type AbstractLitElementConstructor<T = unknown> = abstract new (
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
export type LitElementConstructor<T = unknown> = new (...args: any[]) => LitElement & T;
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Mixins
|
||||
|
||||
/**
|
||||
* A constructor that has been extended with a mixin.
|
||||
*/
|
||||
|
||||
@@ -102,7 +102,7 @@ export class LibraryApplication extends AKElement {
|
||||
return html`<div class="pf-c-card__header">
|
||||
<a
|
||||
@click=${() => {
|
||||
this.racEndpointLaunch?.onClick();
|
||||
this.racEndpointLaunch?.show();
|
||||
}}
|
||||
>
|
||||
<ak-app-icon
|
||||
@@ -115,7 +115,7 @@ export class LibraryApplication extends AKElement {
|
||||
<div class="pf-c-card__title">
|
||||
<a
|
||||
@click=${() => {
|
||||
this.racEndpointLaunch?.onClick();
|
||||
this.racEndpointLaunch?.show();
|
||||
}}
|
||||
>
|
||||
${this.application.name}
|
||||
|
||||
@@ -224,7 +224,6 @@ class UserInterfacePresentation extends WithBrandConfig(AKElement) {
|
||||
<div class="pf-c-drawer__body">
|
||||
<main class="pf-c-page__main">
|
||||
<ak-router-outlet
|
||||
role="main"
|
||||
class="pf-l-bullseye__item pf-c-page__main"
|
||||
tabindex="-1"
|
||||
id="main-content"
|
||||
|
||||
Reference in New Issue
Block a user