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:
Teffen Ellis
2025-07-21 20:20:16 +02:00
committed by GitHub
parent 9d7c733024
commit 26766360d5
32 changed files with 752 additions and 314 deletions
+50
View File
@@ -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("");
}
}
+27
View File
@@ -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);
}
}
+23 -17
View File
@@ -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">
+5 -3
View File
@@ -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>&nbsp;${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>&nbsp;${msg("Failed to fetch")}</p>
<p><i aria-hidden="true" class="fa fa-times"></i>&nbsp;${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()}
+2 -2
View File
@@ -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()}
+10 -9
View File
@@ -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;
}
}
+49 -40
View File
@@ -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}`;
}
+21 -13
View File
@@ -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>&nbsp;`
? html`<i aria-hidden="true" class="${this.icon}"></i>&nbsp;`
: nothing}${this.renderHeader()}
</div>
${this.renderHeaderLink()}
+2 -1
View File
@@ -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}&nbsp;<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);
}
+26 -19
View File
@@ -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 {
+102 -38
View File
@@ -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>
+37 -23
View File
@@ -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>`;
}
}
+28 -11
View File
@@ -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>`;
}
}
+57 -27
View File
@@ -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>`;
}
+21 -3
View File
@@ -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>`;
}
}
+2 -2
View File
@@ -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: {
+6 -5
View File
@@ -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;
}
}
+7 -7
View File
@@ -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>`;
+32 -26
View File
@@ -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}`;
}
}
+72 -29
View File
@@ -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?.()}`;
}
}
+48
View File
@@ -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.
*/
+2 -2
View File
@@ -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}
-1
View File
@@ -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"