mirror of
https://github.com/goauthentik/authentik.git
synced 2026-06-17 19:09:11 +03:00
web/a11y: User library -- fix issues surrounding element focus, ARIA labeling. (cherry-pick #17522 to version-2025.10) (#17828)
web/a11y: User library -- fix issues surrounding element focus, ARIA labeling. (#17522) * web/a11y: Fix issues surrounding element focus, aria labeling. * web: Fix focus * web: Fix nested focus * web: Fix menu visibility when anchor positioning is not supported. * web: Fix icon fallback behavior, labels. * web: Fix flickering, descriptions. * web: Fix excess width on mobile. * web: Fix rendering artifacts on mobile. * web: Remove aria-controls behavior. - This is buggy, similar to aria-owns, and may cause crashes. * web: Fix tabpanel focus attempting to scroll page. * web: Fix issues surrounding consistent tab panel parameter testing. * web: add shared helpers. * web: Tidy comments. Co-authored-by: Teffen Ellis <592134+GirlBossRush@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
parent
449742fbc0
commit
b72709ebbc
@@ -1,5 +1,7 @@
|
||||
import "#admin/admin-overview/AdminOverviewPage";
|
||||
|
||||
import { globalAK } from "#common/global";
|
||||
|
||||
import { ID_REGEX, Route, SLUG_REGEX, UUID_REGEX } from "#elements/router/Route";
|
||||
|
||||
import { html } from "lit";
|
||||
@@ -158,3 +160,14 @@ export const ROUTES: Route[] = [
|
||||
return html`<ak-enterprise-license-list></ak-enterprise-license-list>`;
|
||||
}),
|
||||
];
|
||||
|
||||
/**
|
||||
* Application route helpers.
|
||||
*
|
||||
* @TODO: This API isn't quite right yet. Revisit after the hash router is replaced.
|
||||
*/
|
||||
export const ApplicationRoute = {
|
||||
EditURL(slug: string, base = globalAK().api.base) {
|
||||
return `${base}if/admin/#/core/applications/${slug}`;
|
||||
},
|
||||
} as const;
|
||||
|
||||
@@ -182,6 +182,17 @@ html > form > input {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@media not (prefers-contrast: more) {
|
||||
.less-contrast-sr-only {
|
||||
position: absolute;
|
||||
left: -10000px;
|
||||
top: auto;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
/* #endregion */
|
||||
|
||||
/* #region Icons */
|
||||
@@ -529,7 +540,8 @@ fieldset {
|
||||
}
|
||||
|
||||
.pf-c-form__helper-text {
|
||||
text-wrap: pretty;
|
||||
text-wrap: balance;
|
||||
text-wrap: pretty; /* Supporting browsers. */
|
||||
}
|
||||
|
||||
::placeholder {
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
:host {
|
||||
--icon-border: 0;
|
||||
|
||||
--app-icon-shadow-blend-color: color-mix(
|
||||
in srgb,
|
||||
var(--app-icon--shadow-background-color, var(--pf-global--BackgroundColor--150)) 100%,
|
||||
black 100%
|
||||
);
|
||||
|
||||
display: flex;
|
||||
place-content: center;
|
||||
|
||||
height: calc(var(--icon-height) + var(--icon-border) + var(--icon-border));
|
||||
width: calc(var(--icon-height) + var(--icon-border) + var(--icon-border));
|
||||
}
|
||||
|
||||
:host([size="pf-m-lg"]) {
|
||||
--icon-height: 4rem;
|
||||
--icon-border: 0.25rem;
|
||||
}
|
||||
|
||||
:host([size="pf-m-md"]) {
|
||||
--icon-height: 2rem;
|
||||
--icon-border: 0.125rem;
|
||||
}
|
||||
|
||||
:host([size="pf-m-sm"]) {
|
||||
--icon-height: 1rem;
|
||||
--icon-border: 0.125rem;
|
||||
}
|
||||
|
||||
:host([size="pf-m-xl"]) {
|
||||
--icon-height: 6rem;
|
||||
--icon-border: 0.25rem;
|
||||
}
|
||||
|
||||
.icon {
|
||||
font-size: var(--icon-font-size, var(--icon-height));
|
||||
color: var(--ak-global--Color--100);
|
||||
padding: var(--icon-border);
|
||||
max-height: calc(var(--icon-height) + var(--icon-border) + var(--icon-border));
|
||||
line-height: 1;
|
||||
filter: drop-shadow(-0.5px 0px 0px var(--app-icon-shadow-blend-color));
|
||||
max-height: calc(var(--icon-height) + var(--icon-border) + var(--icon-border));
|
||||
}
|
||||
+42
-80
@@ -1,102 +1,64 @@
|
||||
import { PFSize } from "#common/enums";
|
||||
|
||||
import Styles from "#elements/AppIcon.css";
|
||||
import { AKElement } from "#elements/Base";
|
||||
|
||||
import { match, P } from "ts-pattern";
|
||||
|
||||
import { msg } from "@lit/localize";
|
||||
import { css, CSSResult, html, TemplateResult } from "lit";
|
||||
import { msg, str } from "@lit/localize";
|
||||
import { CSSResult, html, TemplateResult } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
|
||||
import PFFAIcons from "@patternfly/patternfly/base/patternfly-fa-icons.css";
|
||||
import PFAvatar from "@patternfly/patternfly/components/Avatar/avatar.css";
|
||||
|
||||
export interface IAppIcon {
|
||||
name?: string;
|
||||
icon?: string;
|
||||
size?: PFSize;
|
||||
name?: string | null;
|
||||
icon?: string | null;
|
||||
size?: PFSize | null;
|
||||
}
|
||||
|
||||
@customElement("ak-app-icon")
|
||||
export class AppIcon extends AKElement implements IAppIcon {
|
||||
@property({ type: String })
|
||||
name?: string;
|
||||
public static readonly FontAwesomeProtocol = "fa://";
|
||||
|
||||
static styles: CSSResult[] = [PFFAIcons, Styles];
|
||||
|
||||
@property({ type: String })
|
||||
icon?: string;
|
||||
public name: string | null = null;
|
||||
|
||||
@property({ type: String })
|
||||
public icon: string | null = null;
|
||||
|
||||
@property({ reflect: true })
|
||||
size: PFSize = PFSize.Medium;
|
||||
|
||||
static styles: CSSResult[] = [
|
||||
PFFAIcons,
|
||||
PFAvatar,
|
||||
css`
|
||||
:host {
|
||||
max-height: calc(var(--icon-height) + var(--icon-border) + var(--icon-border));
|
||||
|
||||
display: flex;
|
||||
place-content: center;
|
||||
}
|
||||
:host([size="pf-m-lg"]) {
|
||||
--icon-height: 4rem;
|
||||
--icon-border: 0.25rem;
|
||||
}
|
||||
:host([size="pf-m-md"]) {
|
||||
--icon-height: 2rem;
|
||||
--icon-border: 0.125rem;
|
||||
}
|
||||
:host([size="pf-m-sm"]) {
|
||||
--icon-height: 1rem;
|
||||
--icon-border: 0.125rem;
|
||||
}
|
||||
:host([size="pf-m-xl"]) {
|
||||
--icon-height: 6rem;
|
||||
--icon-border: 0.25rem;
|
||||
}
|
||||
.pf-c-avatar {
|
||||
--pf-c-avatar--BorderRadius: 0;
|
||||
--pf-c-avatar--Height: calc(
|
||||
var(--icon-height) + var(--icon-border) + var(--icon-border)
|
||||
);
|
||||
--pf-c-avatar--Width: calc(
|
||||
var(--icon-height) + var(--icon-border) + var(--icon-border)
|
||||
);
|
||||
}
|
||||
.icon {
|
||||
--app-icon-shadow-blend-color: color-mix(
|
||||
in srgb,
|
||||
var(--app-icon--shadow-background-color, var(--pf-global--BackgroundColor--150))
|
||||
100%,
|
||||
black 100%
|
||||
);
|
||||
|
||||
font-size: var(--icon-font-size, var(--icon-height));
|
||||
color: var(--ak-global--Color--100);
|
||||
padding: var(--icon-border);
|
||||
max-height: calc(var(--icon-height) + var(--icon-border) + var(--icon-border));
|
||||
line-height: calc(var(--icon-height) + var(--icon-border) + var(--icon-border));
|
||||
filter: drop-shadow(-0.5px 0px 0px var(--app-icon-shadow-blend-color));
|
||||
}
|
||||
|
||||
div {
|
||||
height: calc(var(--icon-height) + var(--icon-border) + var(--icon-border));
|
||||
}
|
||||
`,
|
||||
];
|
||||
public size: PFSize = PFSize.Medium;
|
||||
|
||||
render(): TemplateResult {
|
||||
// prettier-ignore
|
||||
return match([this.name, this.icon])
|
||||
.with([P.nullish, P.nullish],
|
||||
() => html`<div><i part="icon" aria-hidden="true" class="icon fas fa-question-circle"></i></div>`)
|
||||
.with([P._, P.string.startsWith("fa://")],
|
||||
([_name, icon]) => html`<div><i part="icon" aria-hidden="true" class="icon fas ${icon.replaceAll("fa://", "")}"></i></div>`)
|
||||
.with([P._, P.string],
|
||||
([_name, icon]) => html`<img part="icon" aria-hidden="true" class="icon pf-c-avatar" src="${icon}" alt="${msg("Application Icon")}" />`)
|
||||
.with([P.string, P.nullish],
|
||||
([name]) => html`<span part="icon" aria-hidden="true" class="icon">${name.charAt(0).toUpperCase()}</span>`)
|
||||
.exhaustive();
|
||||
const applicationName = this.name ?? msg("Application");
|
||||
const label = msg(str`${applicationName} Icon`);
|
||||
|
||||
if (this.icon?.startsWith(AppIcon.FontAwesomeProtocol)) {
|
||||
return html`<i
|
||||
part="icon font-awesome"
|
||||
role="img"
|
||||
aria-label=${label}
|
||||
class="icon fas ${this.icon.slice(AppIcon.FontAwesomeProtocol.length)}"
|
||||
></i>`;
|
||||
}
|
||||
|
||||
const insignia = this.name?.charAt(0).toUpperCase() ?? "�";
|
||||
|
||||
if (this.icon) {
|
||||
return html`<img
|
||||
part="icon image"
|
||||
role="img"
|
||||
aria-label=${label}
|
||||
class="icon"
|
||||
src=${this.icon}
|
||||
alt=${insignia}
|
||||
/>`;
|
||||
}
|
||||
|
||||
return html`<span part="icon insignia" role="img" aria-label=${label} class="icon"
|
||||
>${insignia}</span
|
||||
>`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,6 @@ import { EVENT_LOCALE_CHANGE, EVENT_LOCALE_REQUEST } from "#common/constants";
|
||||
import { AKElement } from "#elements/Base";
|
||||
import { customEvent } from "#elements/utils/customEvents";
|
||||
|
||||
import { html } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
|
||||
/**
|
||||
@@ -26,6 +25,10 @@ import { customElement, property } from "lit/decorators.js";
|
||||
*/
|
||||
@customElement("ak-locale-context")
|
||||
export class LocaleContext extends WithBrandConfig(AKElement) {
|
||||
protected createRenderRoot(): HTMLElement | DocumentFragment {
|
||||
return this;
|
||||
}
|
||||
|
||||
/// @attribute The text representation of the current locale */
|
||||
@property({ attribute: true, type: String })
|
||||
locale = DEFAULT_LOCALE;
|
||||
@@ -90,10 +93,6 @@ export class LocaleContext extends WithBrandConfig(AKElement) {
|
||||
// works just fine for almost every use case.
|
||||
this.dispatchEvent(customEvent(EVENT_LOCALE_CHANGE));
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`<slot></slot>`;
|
||||
}
|
||||
}
|
||||
|
||||
export default LocaleContext;
|
||||
|
||||
@@ -65,7 +65,7 @@ export type LitPropertyKey<K> = K extends string ? `.${K}` | `?${K}` | K : K;
|
||||
export type LitFC<P> = (
|
||||
props: P,
|
||||
children?: null | SlottedTemplateResult,
|
||||
) => SlottedTemplateResult | SlottedTemplateResult[];
|
||||
) => SlottedTemplateResult | SlottedTemplateResult[] | null;
|
||||
|
||||
//#endregion
|
||||
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
export function isInteractiveElement(target: Element | null | undefined): target is HTMLElement {
|
||||
if (!target || !(target instanceof HTMLElement)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (target.hasAttribute("disabled") || target.inert) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { tabIndex } = target;
|
||||
|
||||
// Despite our type definitions, this method isn't available in all browsers,
|
||||
// so we fallback to assuming the element is visible.
|
||||
const visible = target.checkVisibility?.() ?? true;
|
||||
|
||||
return (
|
||||
visible &&
|
||||
(tabIndex === 0 ||
|
||||
tabIndex === -1 ||
|
||||
target.matches("button, [role='button'], a[href], input, select, textarea"))
|
||||
);
|
||||
}
|
||||
@@ -2,60 +2,56 @@ import "#elements/AppIcon";
|
||||
import "#user/LibraryApplication/RACLaunchEndpointModal";
|
||||
import "#elements/buttons/Dropdown";
|
||||
|
||||
import { globalAK } from "#common/global";
|
||||
import { truncateWords } from "#common/strings";
|
||||
import { rootInterface } from "#common/theme";
|
||||
|
||||
import { LitFC } from "#elements/types";
|
||||
|
||||
import type { UserInterface } from "#user/index.entrypoint";
|
||||
|
||||
import { Application } from "@goauthentik/api";
|
||||
|
||||
import { spread } from "@open-wc/lit-helpers";
|
||||
import type { HTMLAttributes } from "react";
|
||||
|
||||
import { msg, str } from "@lit/localize";
|
||||
import { html, nothing } from "lit";
|
||||
import { html } from "lit";
|
||||
|
||||
export const AnchorPositionSupported = CSS.supports("position-anchor", "--test");
|
||||
|
||||
export interface CardMenuProps extends HTMLAttributes<HTMLDivElement> {
|
||||
cardID: string;
|
||||
descriptionID: string;
|
||||
application: Application;
|
||||
editURL?: string | URL | null;
|
||||
}
|
||||
|
||||
export const CardMenu: LitFC<CardMenuProps> = ({
|
||||
application,
|
||||
cardID,
|
||||
descriptionID,
|
||||
editURL,
|
||||
...props
|
||||
}) => {
|
||||
const { me, uiConfig } = rootInterface<UserInterface>();
|
||||
|
||||
const editURL =
|
||||
uiConfig?.enabledFeatures.applicationEdit && me?.user.isSuperuser
|
||||
? `${globalAK().api.base}if/admin/#/core/applications/${application.slug}`
|
||||
: null;
|
||||
|
||||
const { metaDescription, metaPublisher } = application;
|
||||
const truncatedDescription = truncateWords(metaDescription, 50);
|
||||
|
||||
const menuID = `${cardID}-actions-menu`;
|
||||
const menuAnchor = `--${cardID}-actions-menu-anchor`;
|
||||
|
||||
if (!metaPublisher && !truncatedDescription && !editURL) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const applicationName = application.name || msg("application");
|
||||
|
||||
return html`<div class="pf-c-dropdown" part="card-header-actions" ${spread(props)}>
|
||||
<button
|
||||
part="card-header-actions-button"
|
||||
class="pf-c-dropdown__toggle"
|
||||
type="button"
|
||||
id="add-mfa-toggle"
|
||||
style="anchor-name: ${menuAnchor};"
|
||||
aria-haspopup="menu"
|
||||
aria-controls=${menuID}
|
||||
popovertarget=${menuID}
|
||||
popovertargetaction="toggle"
|
||||
tabindex="0"
|
||||
aria-label=${msg(str`Actions for "${application.name}"`)}
|
||||
tabindex="-1"
|
||||
aria-label=${msg(str`Actions for "${applicationName}"`)}
|
||||
>
|
||||
<span part="card-header-actions-icon" class="pf-c-dropdown__toggle-text">⋮</span>
|
||||
</button>
|
||||
@@ -64,7 +60,7 @@ export const CardMenu: LitFC<CardMenuProps> = ({
|
||||
part="card-header-actions-menu"
|
||||
style="position-anchor: ${menuAnchor};"
|
||||
id=${menuID}
|
||||
popover
|
||||
?popover=${AnchorPositionSupported}
|
||||
>
|
||||
${metaPublisher || truncatedDescription
|
||||
? html`<li role="presentation">
|
||||
@@ -77,8 +73,8 @@ export const CardMenu: LitFC<CardMenuProps> = ({
|
||||
? html`<div part="card-header-action-publisher">
|
||||
<small>${metaPublisher}</small>
|
||||
</div>`
|
||||
: nothing}
|
||||
${metaPublisher
|
||||
: null}
|
||||
${truncatedDescription
|
||||
? html`<p
|
||||
class="pf-c-content"
|
||||
part="card-header-action-description"
|
||||
@@ -86,24 +82,24 @@ export const CardMenu: LitFC<CardMenuProps> = ({
|
||||
>
|
||||
${truncatedDescription}
|
||||
</p>`
|
||||
: nothing}
|
||||
: null}
|
||||
</div>
|
||||
</li>
|
||||
<hr class="pf-c-divider" />`
|
||||
: nothing}
|
||||
: null}
|
||||
${editURL
|
||||
? html`<li role="presentation">
|
||||
<a
|
||||
part="card-header-action"
|
||||
role="menuitem"
|
||||
href=${editURL}
|
||||
href=${editURL.toString()}
|
||||
class="pf-c-dropdown__menu-item"
|
||||
>
|
||||
<i class="fas fa-edit" aria-hidden="true"></i>
|
||||
<i class="fas fa-edit" role="img"></i>
|
||||
${msg(str`Edit application...`)}</a
|
||||
>
|
||||
</li>`
|
||||
: nothing}
|
||||
: null}
|
||||
</menu>
|
||||
</div>`;
|
||||
};
|
||||
|
||||
@@ -17,26 +17,30 @@ import { kebabCase } from "change-case";
|
||||
import type { HTMLAttributes } from "react";
|
||||
|
||||
import { msg, str } from "@lit/localize";
|
||||
import { html } from "lit";
|
||||
import { html, nothing } from "lit";
|
||||
import { classMap } from "lit/directives/class-map.js";
|
||||
import { createRef, ref } from "lit/directives/ref.js";
|
||||
import { createRef, ref, RefOrCallback } from "lit/directives/ref.js";
|
||||
import { styleMap } from "lit/directives/style-map.js";
|
||||
|
||||
const RAC_LAUNCH_URL = "goauthentik.io://providers/rac/launch";
|
||||
|
||||
export interface AKLibraryAppProps extends HTMLAttributes<HTMLDivElement> {
|
||||
application?: Application;
|
||||
editURL?: string | URL | null;
|
||||
background?: string | null;
|
||||
appIndex: number;
|
||||
groupIndex: number;
|
||||
targetRef?: RefOrCallback | null;
|
||||
}
|
||||
|
||||
export const AKLibraryApp: LitFC<AKLibraryAppProps> = ({
|
||||
application,
|
||||
editURL,
|
||||
background,
|
||||
appIndex,
|
||||
groupIndex,
|
||||
className = "",
|
||||
targetRef,
|
||||
...props
|
||||
}) => {
|
||||
if (!application) {
|
||||
@@ -55,7 +59,7 @@ export const AKLibraryApp: LitFC<AKLibraryAppProps> = ({
|
||||
modalRef.value?.show();
|
||||
};
|
||||
|
||||
const cardID = `app-card-${groupIndex}-${appIndex}`;
|
||||
const cardID = `app-${application.pk}`;
|
||||
const titleID = `${cardID}-title`;
|
||||
const descriptionID = `${cardID}-description`;
|
||||
const cardHeader = CardHeader({
|
||||
@@ -64,31 +68,37 @@ export const AKLibraryApp: LitFC<AKLibraryAppProps> = ({
|
||||
});
|
||||
|
||||
const rac = application.launchUrl === RAC_LAUNCH_URL;
|
||||
const primaryRef = targetRef ? ref(targetRef) : nothing;
|
||||
|
||||
const extendedProps = {
|
||||
"aria-label": msg(str`Open "${application.name}"`),
|
||||
"tabindex": "0",
|
||||
"class": "card-header-aspect-wrapper",
|
||||
"title": ifPresent(application.name),
|
||||
"id": cardID,
|
||||
...props,
|
||||
};
|
||||
|
||||
return html`<div
|
||||
role="gridcell"
|
||||
part="app-card"
|
||||
part="card-wrapper"
|
||||
data-application-name=${ifPresent(dataID)}
|
||||
aria-labelledby=${titleID}
|
||||
aria-describedby=${descriptionID}
|
||||
style=${styleMap({ background: background || null })}
|
||||
${spread(props)}
|
||||
>
|
||||
<div part="card" class="pf-c-card pf-m-hoverable pf-m-compact ${classMap(classes)}">
|
||||
<ak-app-icon
|
||||
part="card-header-icon"
|
||||
exportparts="icon:card-header-icon"
|
||||
size=${PFSize.Large}
|
||||
name=${application.name}
|
||||
icon=${ifPresent(application.metaIcon)}
|
||||
></ak-app-icon>
|
||||
${rac
|
||||
? html`<div
|
||||
${primaryRef}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@click=${launchModal}
|
||||
class="card-header-aspect-wrapper"
|
||||
aria-label=${msg(str`Open "${application.name}"`)}
|
||||
title=${ifPresent(application.name)}
|
||||
${spread(extendedProps)}
|
||||
>
|
||||
<ak-library-rac-endpoint-launch
|
||||
${ref(modalRef)}
|
||||
@@ -97,18 +107,17 @@ export const AKLibraryApp: LitFC<AKLibraryAppProps> = ({
|
||||
${cardHeader}
|
||||
</div>`
|
||||
: html`<a
|
||||
tabindex="0"
|
||||
class="card-header-aspect-wrapper"
|
||||
aria-label=${msg(str`Open "${application.name}"`)}
|
||||
title=${ifPresent(application.name)}
|
||||
${primaryRef}
|
||||
href=${ifPresent(application.launchUrl)}
|
||||
target=${ifPresent(application.openInNewTab, "_blank")}
|
||||
${spread(extendedProps)}
|
||||
>${cardHeader}</a
|
||||
>`}
|
||||
${CardMenu({
|
||||
application,
|
||||
cardID,
|
||||
descriptionID,
|
||||
editURL,
|
||||
})}
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
@@ -0,0 +1,291 @@
|
||||
/* #region Host */
|
||||
|
||||
:host {
|
||||
--app-card-aspect-ratio: 4 / 3;
|
||||
--app-card-min-width: 6.5rem;
|
||||
--app-group-header-min-height: calc(var(--app-card-min-width) / 4);
|
||||
--app-icon-offset: 1rem;
|
||||
--app-card-row-coefficient: 3.5;
|
||||
|
||||
@media (min-width: 390px) {
|
||||
--app-card-aspect-ratio: 1;
|
||||
--app-group-template-columns: repeat(auto-fill, var(--app-card-min-width));
|
||||
}
|
||||
|
||||
@media (min-width: 409px) {
|
||||
--app-card-min-width: 7rem;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
--app-icon-offset: 0.5rem;
|
||||
--app-card-min-width: 10rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* #region Card */
|
||||
|
||||
.card-header-aspect-wrapper {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
|
||||
padding-inline: 1rem;
|
||||
padding-block: calc(var(--pf-global--LineHeight--md) * var(--app-card-title-padding));
|
||||
height: 100%;
|
||||
position: relative;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
[part="card"] {
|
||||
--pf-c-card--BoxShadow: var(--pf-global--BoxShadow--md-bottom);
|
||||
--pf-c-card--BackgroundColor: var(--pf-global--BackgroundColor--150);
|
||||
|
||||
transition: box-shadow 150ms ease-in-out;
|
||||
border: 0.5px solid var(--pf-global--BorderColor--100);
|
||||
height: 100%;
|
||||
|
||||
&:hover {
|
||||
--pf-c-card--m-hoverable--hover--BoxShadow: var(--pf-global--BoxShadow--xl-bottom);
|
||||
}
|
||||
}
|
||||
|
||||
[data-anchor-strategy="anchor-position"] [part="card"] {
|
||||
transform: translate3d(0, 0, 0); /* Fixes rendering artifacts on mobile. */
|
||||
}
|
||||
|
||||
/* #region Header */
|
||||
|
||||
[part="card-header"] {
|
||||
padding: 0 !important;
|
||||
|
||||
display: grid;
|
||||
grid-template-rows: repeat(
|
||||
auto-fill,
|
||||
minmax(calc(var(--app-card-min-width) / var(--app-card-row-coefficient)), auto)
|
||||
);
|
||||
|
||||
&:hover {
|
||||
text-decoration: none;
|
||||
|
||||
.pf-c-card__title {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* #region Title */
|
||||
|
||||
[part="card-title"] {
|
||||
padding: 0 !important;
|
||||
z-index: 1;
|
||||
text-stroke-width: 0.15em;
|
||||
text-stroke-color: var(--pf-c-card--BackgroundColor);
|
||||
|
||||
-webkit-text-stroke-width: 0.15em;
|
||||
-webkit-text-stroke-color: var(--pf-c-card--BackgroundColor);
|
||||
paint-order: stroke fill;
|
||||
|
||||
display: flex;
|
||||
grid-row: -2 / -2;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
align-items: center;
|
||||
|
||||
.clamp-wrapper {
|
||||
--clamp-padding: calc(0.1em * var(--app-card-row-coefficient));
|
||||
|
||||
display: box;
|
||||
display: -webkit-box;
|
||||
line-clamp: 2;
|
||||
-webkit-line-clamp: 2;
|
||||
box-orient: vertical;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
text-align: center;
|
||||
text-wrap: balance;
|
||||
line-height: 1.2;
|
||||
padding-block: var(--clamp-padding);
|
||||
max-height: calc((var(--pf-global--LineHeight--md) * 2rem) - (var(--clamp-padding) / 2));
|
||||
}
|
||||
}
|
||||
|
||||
/* #region Icon */
|
||||
|
||||
ak-app-icon {
|
||||
--icon-height: 50%;
|
||||
--icon-font-size: calc(var(--app-card-min-width) / 2.3);
|
||||
--app-icon--shadow-background-color: var(--pf-c-card--BackgroundColor);
|
||||
|
||||
&::part(icon) {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
object-fit: contain;
|
||||
place-self: center;
|
||||
inset-block-start: -1.5rem;
|
||||
inset-block-end: var(--app-icon-offset, 0);
|
||||
padding: 0;
|
||||
|
||||
@media (max-width: 767px) {
|
||||
inset-block-start: -0.75rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* #region Group Header */
|
||||
|
||||
[part="app-group-header"] {
|
||||
@media not (prefers-contrast: more) {
|
||||
--ak-legend-padding-inline-base: 1rem;
|
||||
padding-block-start: 0 !important;
|
||||
padding-inline: 0 !important;
|
||||
margin-inline: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* #region App List */
|
||||
|
||||
[part="app-list"] {
|
||||
display: grid;
|
||||
gap: var(--pf-global--spacer--md);
|
||||
|
||||
grid-template-columns: repeat(var(--app-list-column-count, 1), 1fr);
|
||||
justify-items: center;
|
||||
|
||||
@media (max-width: 767px) {
|
||||
--app-list-column-count: 1 !important;
|
||||
justify-items: normal;
|
||||
}
|
||||
}
|
||||
|
||||
/* #region App Group */
|
||||
|
||||
[part="app-group"] {
|
||||
--app-group-border-color: transparent;
|
||||
|
||||
display: grid;
|
||||
gap: var(--pf-global--spacer--md);
|
||||
grid-template-columns: var(--app-group-template-columns, 1fr);
|
||||
width: round(down, 100%, calc(var(--app-card-min-width) / var(--app-list-column-count, 1)));
|
||||
grid-auto-rows: minmax(min-content, 0);
|
||||
|
||||
@media (max-width: 767px) {
|
||||
width: auto;
|
||||
grid-template-rows: auto;
|
||||
}
|
||||
|
||||
@media not (prefers-contrast: more) {
|
||||
padding: 0 !important;
|
||||
border: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* #region Group Separator */
|
||||
|
||||
[part="app-group-separator"] {
|
||||
grid-column: 1 / -1;
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
/* #region Card Wrapper */
|
||||
|
||||
[part="card-wrapper"] {
|
||||
aspect-ratio: var(--app-card-aspect-ratio);
|
||||
position: relative;
|
||||
|
||||
contain-intrinsic-size: var(--app-card-min-width);
|
||||
|
||||
&[aria-selected="true"] {
|
||||
outline: auto var(--ak-accent);
|
||||
}
|
||||
}
|
||||
|
||||
/* #region Card Header Actions Description */
|
||||
|
||||
[part="card-header-action-description"] {
|
||||
text-wrap: balance;
|
||||
text-wrap: pretty; /* Supporting browsers. */
|
||||
}
|
||||
|
||||
/* #region Card Header Actions */
|
||||
|
||||
[part="card-header-actions"] {
|
||||
position: absolute;
|
||||
inset-block-start: 0;
|
||||
inset-inline-end: 0;
|
||||
}
|
||||
|
||||
/* #region Card Header Actions Menu */
|
||||
|
||||
[part="card-header-actions-menu"] {
|
||||
inset: auto;
|
||||
border: none;
|
||||
|
||||
width: max-content;
|
||||
min-width: 10ch;
|
||||
max-width: 20ch;
|
||||
/* This drop shadow both adds contrast and forces Firefox to order the layers correctly. */
|
||||
filter: drop-shadow(0px 3px 1px var(--pf-global--BackgroundColor--dark-transparent-200));
|
||||
|
||||
li {
|
||||
list-style-type: none;
|
||||
}
|
||||
}
|
||||
|
||||
[data-anchor-strategy="anchor-position"] [part="card-header-actions-menu"] {
|
||||
position: fixed;
|
||||
inset-inline-start: auto;
|
||||
inset-block-start: anchor(end);
|
||||
inset-inline-end: anchor(end);
|
||||
|
||||
transform: translate3d(0, 0, 0); /* Fixes rendering artifacts on mobile. */
|
||||
|
||||
&:popover-open {
|
||||
display: block;
|
||||
}
|
||||
|
||||
@media (min-width: 390px) {
|
||||
inset-inline-start: anchor(center);
|
||||
}
|
||||
}
|
||||
|
||||
/* Fallback if popover + anchor positioning is not supported */
|
||||
[data-anchor-strategy="fallback"] [part="card-header-actions-menu"] {
|
||||
display: none;
|
||||
position: absolute;
|
||||
inset-inline: auto calc(100% - 1.5em);
|
||||
|
||||
@media (min-width: 390px) {
|
||||
inset-block-start: 0;
|
||||
inset-inline-start: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
[data-anchor-strategy="fallback"]
|
||||
[part="card-header-actions"]:focus-within
|
||||
[part="card-header-actions-menu"] {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* #region Card Header Actions Button */
|
||||
|
||||
[part="card-header-actions-button"] {
|
||||
border: 0.5px solid transparent;
|
||||
border-end-start-radius: var(--pf-global--BorderRadius--sm);
|
||||
|
||||
--pf-c-dropdown__toggle--before--BorderWidth: 0;
|
||||
|
||||
&:hover {
|
||||
--pf-c-dropdown__toggle--BackgroundColor: var(--pf-c-card--m-flat--BorderColor);
|
||||
border-color: var(--pf-c-card--m-flat--BorderColor);
|
||||
}
|
||||
}
|
||||
|
||||
/* #region Card Header Actions icon */
|
||||
|
||||
[part="card-header-actions-icon"] {
|
||||
font-weight: bold;
|
||||
font-family:
|
||||
system-ui,
|
||||
-apple-system,
|
||||
monospace;
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import type { AppGroupEntry } from "./types.js";
|
||||
|
||||
import { rootInterface } from "#common/theme";
|
||||
import { LayoutType } from "#common/ui/config";
|
||||
|
||||
import { LitFC } from "#elements/types";
|
||||
import { ifPresent } from "#elements/utils/attributes";
|
||||
|
||||
import { UserInterface } from "#user/index.entrypoint";
|
||||
import { AnchorPositionSupported } from "#user/LibraryApplication/CardMenu";
|
||||
import { AKLibraryApp } from "#user/LibraryApplication/index";
|
||||
|
||||
import { ApplicationRoute } from "#admin/Routes";
|
||||
|
||||
import { Application } from "@goauthentik/api";
|
||||
|
||||
import { spread } from "@open-wc/lit-helpers";
|
||||
import { kebabCase } from "change-case";
|
||||
import { HTMLAttributes } from "react";
|
||||
|
||||
import { msg } from "@lit/localize";
|
||||
import { html, nothing } from "lit";
|
||||
import { RefOrCallback } from "lit/directives/ref.js";
|
||||
import { repeat } from "lit/directives/repeat.js";
|
||||
|
||||
const LayoutColumnCount = {
|
||||
[LayoutType.row]: 1,
|
||||
[LayoutType.column_2]: 2,
|
||||
[LayoutType.column_3]: 3,
|
||||
} as const satisfies Record<LayoutType, number>;
|
||||
|
||||
export interface AKLibraryApplicationListProps extends HTMLAttributes<HTMLDivElement> {
|
||||
groupedApps: AppGroupEntry[];
|
||||
layout: LayoutType;
|
||||
background?: string | null;
|
||||
selectedApp?: Application | null;
|
||||
targetRef?: RefOrCallback | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the current library list of a User's Applications.
|
||||
*/
|
||||
export const AKLibraryApplicationList: LitFC<AKLibraryApplicationListProps> = ({
|
||||
groupedApps,
|
||||
layout = LayoutType.row,
|
||||
background,
|
||||
selectedApp,
|
||||
targetRef,
|
||||
...props
|
||||
}) => {
|
||||
const columnCount = LayoutColumnCount[layout] ?? 1;
|
||||
const { me, uiConfig } = rootInterface<UserInterface>();
|
||||
const canEdit = !!(uiConfig?.enabledFeatures.applicationEdit && me?.user.isSuperuser);
|
||||
|
||||
return html`<div
|
||||
role="presentation"
|
||||
part="app-list"
|
||||
data-anchor-strategy=${AnchorPositionSupported ? "anchor-position" : "fallback"}
|
||||
style="--app-list-column-count: ${columnCount}"
|
||||
${spread(props)}
|
||||
>
|
||||
${repeat(
|
||||
groupedApps,
|
||||
([groupLabel]) => groupLabel,
|
||||
([groupLabel, apps], groupIndex) => {
|
||||
const groupID = kebabCase(groupLabel);
|
||||
const activeDescendantID =
|
||||
selectedApp && apps.includes(selectedApp) ? `app-${selectedApp.pk}` : nothing;
|
||||
|
||||
return html`<fieldset
|
||||
data-group-id=${ifPresent(groupID)}
|
||||
part="app-group"
|
||||
data-group-index=${groupIndex}
|
||||
data-app-count=${apps.length}
|
||||
aria-activedescendant=${activeDescendantID}
|
||||
>
|
||||
<legend
|
||||
class="pf-c-content ${!groupLabel ? "less-contrast-sr-only" : ""}"
|
||||
part="app-group-header"
|
||||
>
|
||||
<h2 id=${`app-group-${groupID}`}>${groupLabel || msg("Ungrouped")}</h2>
|
||||
</legend>
|
||||
${repeat(
|
||||
apps,
|
||||
(application) => application.pk,
|
||||
(application, appIndex) => {
|
||||
const selected = selectedApp === application;
|
||||
|
||||
const editURL = canEdit
|
||||
? ApplicationRoute.EditURL(application.slug)
|
||||
: null;
|
||||
|
||||
return AKLibraryApp({
|
||||
application,
|
||||
appIndex,
|
||||
groupIndex,
|
||||
background,
|
||||
editURL,
|
||||
"targetRef": selected ? targetRef : null,
|
||||
"aria-selected": selected,
|
||||
});
|
||||
},
|
||||
)}
|
||||
<hr part="app-group-separator" aria-hidden="true" />
|
||||
</fieldset>`;
|
||||
},
|
||||
)}
|
||||
</div>`;
|
||||
};
|
||||
@@ -1,225 +0,0 @@
|
||||
:host {
|
||||
--app-card-aspect-ratio: 4 / 3;
|
||||
--app-card-min-width: 6.5rem;
|
||||
--app-group-header-min-height: calc(var(--app-card-min-width) / 4);
|
||||
--app-icon-offset: 1rem;
|
||||
--app-card-row-coefficient: 3.5;
|
||||
|
||||
@media (min-width: 390px) {
|
||||
--app-card-aspect-ratio: 1;
|
||||
--app-group-template-columns: repeat(auto-fill, var(--app-card-min-width));
|
||||
}
|
||||
|
||||
@media (min-width: 409px) {
|
||||
--app-card-min-width: 7rem;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
--app-icon-offset: 0.5rem;
|
||||
--app-card-min-width: 10rem;
|
||||
}
|
||||
}
|
||||
|
||||
.card-header-aspect-wrapper {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
|
||||
padding-inline: 1rem;
|
||||
padding-block: calc(var(--pf-global--LineHeight--md) * var(--app-card-title-padding));
|
||||
height: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.pf-c-card {
|
||||
--pf-c-card--BoxShadow: var(--pf-global--BoxShadow--md-bottom);
|
||||
--pf-c-card--BackgroundColor: var(--pf-global--BackgroundColor--150);
|
||||
|
||||
transition: box-shadow 150ms ease-in-out;
|
||||
border: 0.5px solid var(--pf-global--BorderColor--100);
|
||||
height: 100%;
|
||||
|
||||
&:hover {
|
||||
--pf-c-card--m-hoverable--hover--BoxShadow: var(--pf-global--BoxShadow--xl-bottom);
|
||||
}
|
||||
|
||||
.pf-c-card__header {
|
||||
padding: 0 !important;
|
||||
|
||||
display: grid;
|
||||
grid-template-rows: repeat(
|
||||
auto-fill,
|
||||
minmax(calc(var(--app-card-min-width) / var(--app-card-row-coefficient)), auto)
|
||||
);
|
||||
|
||||
&:hover {
|
||||
text-decoration: none;
|
||||
|
||||
.pf-c-card__title {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.pf-c-card__title {
|
||||
padding: 0 !important;
|
||||
z-index: 1;
|
||||
text-stroke-width: 0.15em;
|
||||
text-stroke-color: var(--pf-c-card--BackgroundColor);
|
||||
|
||||
-webkit-text-stroke-width: 0.15em;
|
||||
-webkit-text-stroke-color: var(--pf-c-card--BackgroundColor);
|
||||
paint-order: stroke fill;
|
||||
|
||||
display: flex;
|
||||
grid-row: -2 / -2;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
align-items: center;
|
||||
|
||||
.clamp-wrapper {
|
||||
--clamp-padding: calc(0.1em * var(--app-card-row-coefficient));
|
||||
|
||||
display: box;
|
||||
display: -webkit-box;
|
||||
line-clamp: 2;
|
||||
-webkit-line-clamp: 2;
|
||||
box-orient: vertical;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
text-align: center;
|
||||
text-wrap: balance;
|
||||
line-height: 1.2;
|
||||
padding-block: var(--clamp-padding);
|
||||
max-height: calc(
|
||||
(var(--pf-global--LineHeight--md) * 2rem) - (var(--clamp-padding) / 2)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[part="card-header-icon"] {
|
||||
--icon-height: 50%;
|
||||
--icon-font-size: calc(var(--app-card-min-width) / 2.3);
|
||||
|
||||
&::part(icon) {
|
||||
--app-icon--shadow-background-color: var(--pf-c-card--BackgroundColor);
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
object-fit: contain;
|
||||
place-self: center;
|
||||
inset-block-start: -1.5rem;
|
||||
inset-block-end: var(--app-icon-offset, 0);
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
[part="app-group-header"] {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
[part="app-list"] {
|
||||
display: grid;
|
||||
gap: var(--pf-global--spacer--md);
|
||||
|
||||
grid-template-columns: repeat(var(--app-list-column-count, 1), 1fr);
|
||||
justify-items: center;
|
||||
|
||||
@media (max-width: 767px) {
|
||||
--app-list-column-count: 1 !important;
|
||||
justify-items: normal;
|
||||
}
|
||||
}
|
||||
|
||||
[part="app-group"] {
|
||||
--app-group-border-color: transparent;
|
||||
|
||||
display: grid;
|
||||
gap: var(--pf-global--spacer--md);
|
||||
grid-template-columns: var(--app-group-template-columns, 1fr);
|
||||
width: round(down, 100%, calc(var(--app-card-min-width) / var(--app-list-column-count, 1)));
|
||||
grid-auto-rows: minmax(min-content, 0);
|
||||
|
||||
@media (max-width: 767px) {
|
||||
width: auto;
|
||||
grid-template-rows: auto;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
border-inline-start: 0.5px solid var(--app-group-border-color);
|
||||
padding-inline-start: calc(1rem - 0.5px);
|
||||
}
|
||||
|
||||
@media (prefers-contrast: more) {
|
||||
&:not([data-group-index="0"]) {
|
||||
--app-group-border-color: var(--pf-global--BorderColor--200);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[part="app-group-separator"] {
|
||||
grid-column: 1 / -1;
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
[part="app-card"] {
|
||||
aspect-ratio: var(--app-card-aspect-ratio);
|
||||
position: relative;
|
||||
|
||||
&[aria-selected="true"] {
|
||||
outline: auto var(--ak-accent);
|
||||
}
|
||||
}
|
||||
|
||||
[part="card-header-action-description"] {
|
||||
text-wrap: pretty;
|
||||
max-width: 30ch;
|
||||
}
|
||||
|
||||
[part="card-header-actions"] {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
[part="card-header-actions-menu"] {
|
||||
position: fixed;
|
||||
inset: unset;
|
||||
inset-block-start: anchor(end);
|
||||
inset-inline-end: anchor(end);
|
||||
width: fit-content;
|
||||
min-width: unset;
|
||||
border: none;
|
||||
|
||||
@media (min-width: 390px) {
|
||||
inset-inline-start: anchor(center);
|
||||
}
|
||||
}
|
||||
|
||||
[part="card-header-actions-button"] {
|
||||
border: 0.5px solid transparent;
|
||||
border-end-start-radius: var(--pf-global--BorderRadius--sm);
|
||||
|
||||
--pf-c-dropdown__toggle--before--BorderWidth: 0;
|
||||
|
||||
&:hover {
|
||||
--pf-c-dropdown__toggle--BackgroundColor: var(--pf-c-card--m-flat--BorderColor);
|
||||
border-color: var(--pf-c-card--m-flat--BorderColor);
|
||||
}
|
||||
|
||||
&[aria-expanded="true"] {
|
||||
border-color: var(--pf-c-card--m-flat--BorderColor);
|
||||
|
||||
&:before {
|
||||
--pf-c-dropdown__toggle--before--BorderBottomColor: var(--pf-global--active-color--100);
|
||||
border-bottom-width: var(--pf-c-dropdown__toggle--focus--before--BorderBottomWidth);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[part="card-header-actions-icon"] {
|
||||
font-weight: bold;
|
||||
font-family:
|
||||
system-ui,
|
||||
-apple-system,
|
||||
monospace;
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
import Styles from "./ak-library-application-list.css";
|
||||
import type { AppGroupEntry } from "./types.js";
|
||||
|
||||
import { LayoutType } from "#common/ui/config";
|
||||
|
||||
import { AKElement } from "#elements/Base";
|
||||
import { ifPresent } from "#elements/utils/attributes";
|
||||
|
||||
import { AKLibraryApp } from "#user/LibraryApplication/index";
|
||||
|
||||
import { Application } from "@goauthentik/api";
|
||||
|
||||
import { kebabCase } from "change-case";
|
||||
|
||||
import { msg } from "@lit/localize";
|
||||
import { html } from "lit";
|
||||
import { customElement, property } from "lit/decorators.js";
|
||||
import { repeat } from "lit/directives/repeat.js";
|
||||
|
||||
import PFButton from "@patternfly/patternfly/components/Button/button.css";
|
||||
import PFCard from "@patternfly/patternfly/components/Card/card.css";
|
||||
import PFContent from "@patternfly/patternfly/components/Content/content.css";
|
||||
import PFDivider from "@patternfly/patternfly/components/Divider/divider.css";
|
||||
import PFDropdown from "@patternfly/patternfly/components/Dropdown/dropdown.css";
|
||||
import PFEmptyState from "@patternfly/patternfly/components/EmptyState/empty-state.css";
|
||||
import PFGrid from "@patternfly/patternfly/layouts/Grid/grid.css";
|
||||
import PFBase from "@patternfly/patternfly/patternfly-base.css";
|
||||
|
||||
const LayoutColumnCount = {
|
||||
[LayoutType.row]: 1,
|
||||
[LayoutType.column_2]: 2,
|
||||
[LayoutType.column_3]: 3,
|
||||
} as const satisfies Record<LayoutType, number>;
|
||||
|
||||
/**
|
||||
* @element ak-library-application-list
|
||||
* @class LibraryPageApplicationList
|
||||
*
|
||||
* Renders the current library list of a User's Applications.
|
||||
*
|
||||
*/
|
||||
@customElement("ak-library-application-list")
|
||||
export class LibraryPageApplicationList extends AKElement {
|
||||
static styles = [
|
||||
// ---
|
||||
PFBase,
|
||||
PFEmptyState,
|
||||
PFDropdown,
|
||||
PFContent,
|
||||
PFGrid,
|
||||
PFButton,
|
||||
PFCard,
|
||||
PFDivider,
|
||||
Styles,
|
||||
];
|
||||
|
||||
@property({ attribute: true })
|
||||
public layout: LayoutType = LayoutType.row;
|
||||
|
||||
@property({ attribute: true })
|
||||
public background: string | null = null;
|
||||
|
||||
@property({ attribute: false })
|
||||
public selected: Application | null = null;
|
||||
|
||||
@property({ attribute: false })
|
||||
public apps: AppGroupEntry[] = [];
|
||||
|
||||
render() {
|
||||
const columnCount = LayoutColumnCount[this.layout] ?? 1;
|
||||
return html`<div
|
||||
part="app-list"
|
||||
style="--app-list-column-count: ${columnCount}"
|
||||
aria-colcount=${columnCount}
|
||||
role="grid"
|
||||
aria-label=${msg("Available applications")}
|
||||
>
|
||||
${repeat(
|
||||
this.apps,
|
||||
([groupLabel]) => groupLabel,
|
||||
([groupLabel, apps], groupIndex) => {
|
||||
return html`<div
|
||||
role="rowgroup"
|
||||
data-group-id=${ifPresent(kebabCase(groupLabel))}
|
||||
aria-labelledby="app-group-${groupIndex}"
|
||||
part="app-group"
|
||||
data-group-index=${groupIndex}
|
||||
data-app-count=${apps.length}
|
||||
>
|
||||
<div class="pf-c-content" part="app-group-header">
|
||||
<h2 id="app-group-${groupIndex}">${groupLabel}</h2>
|
||||
</div>
|
||||
${repeat(
|
||||
apps,
|
||||
(application) => application.pk,
|
||||
(application, appIndex) =>
|
||||
AKLibraryApp({
|
||||
application,
|
||||
appIndex,
|
||||
groupIndex,
|
||||
"part": "app-card",
|
||||
"background": this.background,
|
||||
"aria-live": "polite",
|
||||
"aria-selected": this.selected === application,
|
||||
}),
|
||||
)}
|
||||
<hr part="app-group-separator" aria-hidden="true" />
|
||||
</div>`;
|
||||
},
|
||||
)}
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ak-library-application-list": LibraryPageApplicationList;
|
||||
}
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
import { AKElement } from "#elements/Base";
|
||||
|
||||
import { msg } from "@lit/localize";
|
||||
import { html } from "lit";
|
||||
import { customElement } from "lit/decorators.js";
|
||||
|
||||
import PFContent from "@patternfly/patternfly/components/Content/content.css";
|
||||
import PFEmptyState from "@patternfly/patternfly/components/EmptyState/empty-state.css";
|
||||
import PFBase from "@patternfly/patternfly/patternfly-base.css";
|
||||
import PFSpacing from "@patternfly/patternfly/utilities/Spacing/spacing.css";
|
||||
|
||||
/**
|
||||
* Library Page Application List Empty
|
||||
*
|
||||
* Display a message if there are no applications defined in the current instance. If the user is an
|
||||
* administrator, provide a link to the "Create a new application" page.
|
||||
*/
|
||||
|
||||
@customElement("ak-library-application-search-empty")
|
||||
export class LibraryPageApplicationSearchEmpty extends AKElement {
|
||||
static styles = [PFBase, PFEmptyState, PFContent, PFSpacing];
|
||||
|
||||
render() {
|
||||
return html` <div class="pf-c-empty-state pf-m-full-height">
|
||||
<div class="pf-c-empty-state__content">
|
||||
<i class="fas fa-cubes pf-c-empty-state__icon" aria-hidden="true"></i>
|
||||
<h1 class="pf-c-title pf-m-lg">${msg("Search returned no results.")}</h1>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
input[name="application-search"] {
|
||||
background-color: transparent;
|
||||
display: block;
|
||||
width: 100%;
|
||||
font-size: var(--pf-global--FontSize--xl);
|
||||
|
||||
border-inline: none;
|
||||
border-block-start: none;
|
||||
|
||||
&:focus,
|
||||
&:hover {
|
||||
--pf-c-form-control--BorderBottomColor: var(--ak-accent);
|
||||
}
|
||||
}
|
||||
|
||||
input[name="application-search"] {
|
||||
@media not (prefers-contrast: more) {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
@@ -1,166 +0,0 @@
|
||||
import Styles from "./ak-library-application-search.css";
|
||||
import {
|
||||
LibraryPageSearchEmpty,
|
||||
LibraryPageSearchReset,
|
||||
LibraryPageSearchSelected,
|
||||
LibraryPageSearchUpdated,
|
||||
} from "./events.js";
|
||||
|
||||
import { AKElement } from "#elements/Base";
|
||||
import { getURLParam, updateURLParams } from "#elements/router/RouteMatch";
|
||||
import { ifPresent } from "#elements/utils/attributes";
|
||||
|
||||
import type { Application } from "@goauthentik/api";
|
||||
|
||||
import Fuse, { FuseResult } from "fuse.js";
|
||||
|
||||
import { msg } from "@lit/localize";
|
||||
import { html } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators.js";
|
||||
import { createRef, ref } from "lit/directives/ref.js";
|
||||
|
||||
import PFFormControl from "@patternfly/patternfly/components/FormControl/form-control.css";
|
||||
import PFBase from "@patternfly/patternfly/patternfly-base.css";
|
||||
import PFDisplay from "@patternfly/patternfly/utilities/Display/display.css";
|
||||
|
||||
/**
|
||||
* @element ak-library-list-search
|
||||
*
|
||||
* @class LibraryPageApplicationSearch
|
||||
*
|
||||
* @classdesc
|
||||
*
|
||||
* The interface between our list of applications shown to the user, an input box, and the Fuse
|
||||
* fuzzy search library.
|
||||
*
|
||||
* @fires LibraryPageSearchUpdated
|
||||
* @fires LibraryPageSearchEmpty
|
||||
* @fires LibraryPageSearchReset
|
||||
*
|
||||
*/
|
||||
@customElement("ak-library-application-search")
|
||||
export class LibraryPageApplicationSearch extends AKElement {
|
||||
static styles = [
|
||||
// ---
|
||||
PFBase,
|
||||
PFDisplay,
|
||||
PFFormControl,
|
||||
Styles,
|
||||
];
|
||||
|
||||
@property({ attribute: false })
|
||||
set apps(value: Application[]) {
|
||||
this.fuse.setCollection(value);
|
||||
}
|
||||
|
||||
@state()
|
||||
protected query = getURLParam<string | null>("q", "");
|
||||
|
||||
protected searchInput = createRef<HTMLInputElement>();
|
||||
|
||||
protected fuse = new Fuse<Application>([], {
|
||||
keys: [
|
||||
{ name: "name", weight: 3 },
|
||||
"slug",
|
||||
"group",
|
||||
{ name: "metaDescription", weight: 0.5 },
|
||||
{ name: "metaPublisher", weight: 0.5 },
|
||||
],
|
||||
findAllMatches: true,
|
||||
includeScore: true,
|
||||
shouldSort: true,
|
||||
ignoreFieldNorm: true,
|
||||
useExtendedSearch: true,
|
||||
threshold: 0.3,
|
||||
});
|
||||
|
||||
public override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
|
||||
if (this.query) {
|
||||
const matchingApps = this.fuse.search(this.query);
|
||||
|
||||
if (matchingApps.length) {
|
||||
this.#dispatchSelected(matchingApps);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public reset(): void {
|
||||
const searchInput = this.searchInput.value;
|
||||
|
||||
if (searchInput) {
|
||||
searchInput.value = "";
|
||||
}
|
||||
|
||||
this.query = "";
|
||||
|
||||
updateURLParams({
|
||||
q: this.query,
|
||||
});
|
||||
|
||||
this.dispatchEvent(new LibraryPageSearchReset());
|
||||
}
|
||||
|
||||
#dispatchSelected = (apps: FuseResult<Application>[]) => {
|
||||
this.dispatchEvent(new LibraryPageSearchUpdated(apps.map((app) => app.item)));
|
||||
};
|
||||
|
||||
#inputListener = (event: InputEvent) => {
|
||||
this.query = (event.target as HTMLInputElement).value;
|
||||
|
||||
if (!this.query) {
|
||||
return this.reset();
|
||||
}
|
||||
|
||||
updateURLParams({
|
||||
q: this.query,
|
||||
});
|
||||
|
||||
const apps = this.fuse.search(this.query);
|
||||
|
||||
if (!apps.length) {
|
||||
this.dispatchEvent(new LibraryPageSearchEmpty());
|
||||
return;
|
||||
}
|
||||
|
||||
this.#dispatchSelected(apps);
|
||||
};
|
||||
|
||||
#keyDownListener = (event: KeyboardEvent) => {
|
||||
switch (event.key) {
|
||||
case "Escape": {
|
||||
event.preventDefault();
|
||||
this.reset();
|
||||
return;
|
||||
}
|
||||
case "Enter": {
|
||||
event.preventDefault();
|
||||
this.dispatchEvent(new LibraryPageSearchSelected());
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
render() {
|
||||
return html`<input
|
||||
${ref(this.searchInput)}
|
||||
part="search-input"
|
||||
name="application-search"
|
||||
@input=${this.#inputListener}
|
||||
@keydown=${this.#keyDownListener}
|
||||
type="search"
|
||||
class="pf-c-form-control"
|
||||
autofocus
|
||||
aria-label=${msg("Application search")}
|
||||
placeholder=${msg("Search for an application by name...")}
|
||||
value=${ifPresent(this.query)}
|
||||
/>`;
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface HTMLElementTagNameMap {
|
||||
"ak-library-list-search": LibraryPageApplicationSearch;
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,8 @@
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.search-container {
|
||||
search {
|
||||
display: flex;
|
||||
flex: 0 0 36ch;
|
||||
|
||||
@container (width < 650px) {
|
||||
@@ -26,6 +27,10 @@
|
||||
font-size: var(--pf-global--FontSize--md);
|
||||
}
|
||||
}
|
||||
|
||||
form {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,3 +42,32 @@
|
||||
background-color: transparent;
|
||||
padding-inline: 0;
|
||||
}
|
||||
|
||||
input[name="application-search"] {
|
||||
background-color: transparent;
|
||||
display: block;
|
||||
width: 100%;
|
||||
font-size: var(--pf-global--FontSize--xl);
|
||||
|
||||
border-inline: none;
|
||||
border-block-start: none;
|
||||
|
||||
&:focus,
|
||||
&:hover {
|
||||
--pf-c-form-control--BorderBottomColor: var(--ak-accent);
|
||||
}
|
||||
|
||||
/**
|
||||
* Despite the misleading name, this refers to the chevron appearing next to the
|
||||
* search input, which we want to hide since it overlaps with the native search reset icon.
|
||||
*/
|
||||
&::-webkit-calendar-picker-indicator,
|
||||
&::-webkit-list-button {
|
||||
display: none !important;
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
|
||||
@media not (prefers-contrast: more) {
|
||||
outline: none;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,10 @@
|
||||
import "#elements/EmptyState";
|
||||
import "#user/LibraryApplication/index";
|
||||
import "./ak-library-application-empty-list.js";
|
||||
import "./ak-library-application-list.js";
|
||||
import "./ak-library-application-search-empty.js";
|
||||
import "./ak-library-application-search.js";
|
||||
|
||||
import Styles from "./ak-library-impl.css";
|
||||
import {
|
||||
LibraryPageSearchEmpty,
|
||||
LibraryPageSearchReset,
|
||||
LibraryPageSearchSelected,
|
||||
LibraryPageSearchUpdated,
|
||||
} from "./events.js";
|
||||
import AKLibraryApplicationListStyles from "./ApplicationList.css";
|
||||
import { AKLibraryApplicationList } from "./ApplicationList.js";
|
||||
import { appHasLaunchUrl } from "./LibraryPageImpl.utils.js";
|
||||
import type { PageUIConfig } from "./types.js";
|
||||
|
||||
@@ -19,20 +12,34 @@ import { groupBy } from "#common/utils";
|
||||
|
||||
import { AKSkipToContent } from "#elements/a11y/ak-skip-to-content";
|
||||
import { AKElement } from "#elements/Base";
|
||||
import { bound } from "#elements/decorators/bound";
|
||||
import { intersectionObserver } from "#elements/decorators/intersection-observer";
|
||||
import { getURLParam, updateURLParams } from "#elements/router/RouteMatch";
|
||||
import { ifPresent } from "#elements/utils/attributes";
|
||||
import { FocusTarget } from "#elements/utils/focus";
|
||||
import { isInteractiveElement } from "#elements/utils/interactivity";
|
||||
|
||||
import type { Application } from "@goauthentik/api";
|
||||
|
||||
import { msg } from "@lit/localize";
|
||||
import { html, nothing } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators.js";
|
||||
import Fuse from "fuse.js";
|
||||
|
||||
import { msg, str } from "@lit/localize";
|
||||
import { html, nothing, PropertyValues } from "lit";
|
||||
import { customElement, property, state } from "lit/decorators.js";
|
||||
import { createRef } from "lit/directives/ref.js";
|
||||
import { repeat } from "lit/directives/repeat.js";
|
||||
|
||||
import PFButton from "@patternfly/patternfly/components/Button/button.css";
|
||||
import PFCard from "@patternfly/patternfly/components/Card/card.css";
|
||||
import PFContent from "@patternfly/patternfly/components/Content/content.css";
|
||||
import PFDivider from "@patternfly/patternfly/components/Divider/divider.css";
|
||||
import PFDropdown from "@patternfly/patternfly/components/Dropdown/dropdown.css";
|
||||
import PFEmptyState from "@patternfly/patternfly/components/EmptyState/empty-state.css";
|
||||
import PFFormControl from "@patternfly/patternfly/components/FormControl/form-control.css";
|
||||
import PFPage from "@patternfly/patternfly/components/Page/page.css";
|
||||
import PFGrid from "@patternfly/patternfly/layouts/Grid/grid.css";
|
||||
import PFBase from "@patternfly/patternfly/patternfly-base.css";
|
||||
import PFDisplay from "@patternfly/patternfly/utilities/Display/display.css";
|
||||
import PFSpacing from "@patternfly/patternfly/utilities/Spacing/spacing.css";
|
||||
|
||||
/**
|
||||
* List of Applications available
|
||||
@@ -54,9 +61,19 @@ export class LibraryPage extends AKElement {
|
||||
PFEmptyState,
|
||||
PFPage,
|
||||
PFContent,
|
||||
PFFormControl,
|
||||
PFButton,
|
||||
PFCard,
|
||||
PFDivider,
|
||||
PFDropdown,
|
||||
PFGrid,
|
||||
PFSpacing,
|
||||
AKLibraryApplicationListStyles,
|
||||
Styles,
|
||||
];
|
||||
|
||||
//#region Properties
|
||||
|
||||
/**
|
||||
* Controls showing the "Switch to Admin" button.
|
||||
*
|
||||
@@ -65,13 +82,23 @@ export class LibraryPage extends AKElement {
|
||||
@property({ type: Boolean })
|
||||
public admin = false;
|
||||
|
||||
#applications: Application[] = [];
|
||||
|
||||
/**
|
||||
* The *complete* list of applications for this user. Not paginated.
|
||||
*
|
||||
* @attr
|
||||
*/
|
||||
@property({ attribute: false, type: Array })
|
||||
public apps: Application[] = [];
|
||||
public get apps(): Application[] {
|
||||
return this.#applications;
|
||||
}
|
||||
|
||||
public set apps(value: Application[]) {
|
||||
this.#applications = value;
|
||||
|
||||
this.fuse.setCollection(this.searchEnabled ? this.#applications : []);
|
||||
}
|
||||
|
||||
/**
|
||||
* The aggregate uiConfig, derived from user, brand, and instance data.
|
||||
@@ -81,140 +108,299 @@ export class LibraryPage extends AKElement {
|
||||
@property({ attribute: false })
|
||||
public uiConfig!: PageUIConfig;
|
||||
|
||||
@state()
|
||||
protected selectedApp: Application | null = null;
|
||||
public get searchEnabled(): boolean {
|
||||
return this.uiConfig?.searchEnabled ?? true;
|
||||
}
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region State
|
||||
|
||||
protected autofocusTarget = new FocusTarget<HTMLInputElement>();
|
||||
public override focus = () => this.autofocusTarget.focus({ preventScroll: true });
|
||||
|
||||
protected get selectedApp(): Application | null {
|
||||
if (!this.query) return null;
|
||||
|
||||
return this.visibleApplications[0] || null;
|
||||
}
|
||||
|
||||
@intersectionObserver()
|
||||
public visible = false;
|
||||
|
||||
@state()
|
||||
filteredApps: Application[] = [];
|
||||
protected visibleApplications: Application[] = [];
|
||||
|
||||
/**
|
||||
* The active element to select when the user presses Enter outside of a form.
|
||||
*/
|
||||
protected targetRef = createRef<HTMLElement>();
|
||||
|
||||
#query: string | null = null;
|
||||
|
||||
protected get query(): string | null {
|
||||
return this.#query;
|
||||
}
|
||||
|
||||
protected set query(nextQuery: string | null) {
|
||||
this.#query = nextQuery;
|
||||
|
||||
if (nextQuery && this.searchEnabled) {
|
||||
this.visibleApplications = this.fuse
|
||||
.search(nextQuery)
|
||||
.map((result) => result.item)
|
||||
.filter(appHasLaunchUrl);
|
||||
} else {
|
||||
this.visibleApplications = this.apps.filter(appHasLaunchUrl);
|
||||
}
|
||||
|
||||
updateURLParams({
|
||||
q: this.#query,
|
||||
});
|
||||
}
|
||||
|
||||
protected fuse = new Fuse<Application>([], {
|
||||
keys: [
|
||||
{ name: "name", weight: 3 },
|
||||
"slug",
|
||||
"group",
|
||||
{ name: "metaDescription", weight: 0.5 },
|
||||
{ name: "metaPublisher", weight: 0.5 },
|
||||
],
|
||||
findAllMatches: true,
|
||||
includeScore: true,
|
||||
shouldSort: true,
|
||||
ignoreFieldNorm: true,
|
||||
useExtendedSearch: true,
|
||||
threshold: 0.3,
|
||||
});
|
||||
|
||||
public pageTitle = msg("My Applications");
|
||||
|
||||
connectedCallback() {
|
||||
//#region Lifecycle
|
||||
|
||||
public override connectedCallback() {
|
||||
super.connectedCallback();
|
||||
this.filteredApps = this.apps;
|
||||
if (this.filteredApps === undefined) {
|
||||
throw new Error(
|
||||
"Application.results should never be undefined when passed to the Library Page.",
|
||||
);
|
||||
}
|
||||
this.addEventListener(LibraryPageSearchUpdated.eventName, this.searchUpdated);
|
||||
this.addEventListener(LibraryPageSearchReset.eventName, this.searchReset);
|
||||
this.addEventListener(LibraryPageSearchEmpty.eventName, this.searchEmpty);
|
||||
this.addEventListener(LibraryPageSearchSelected.eventName, this.launchRequest);
|
||||
|
||||
this.query = getURLParam<string | null>("q", "");
|
||||
|
||||
this.addEventListener(
|
||||
"focus",
|
||||
this.autofocusTarget.toEventListener({
|
||||
preventScroll: true,
|
||||
}),
|
||||
);
|
||||
|
||||
document.addEventListener("visibilitychange", this.#visibilityListener);
|
||||
|
||||
window.addEventListener("keydown", this.#rootKeyDownListener);
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
this.removeEventListener(LibraryPageSearchUpdated.eventName, this.searchUpdated);
|
||||
this.removeEventListener(LibraryPageSearchReset.eventName, this.searchReset);
|
||||
this.removeEventListener(LibraryPageSearchEmpty.eventName, this.searchEmpty);
|
||||
this.removeEventListener(LibraryPageSearchSelected.eventName, this.launchRequest);
|
||||
public override disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
|
||||
document.removeEventListener("visibilitychange", this.#visibilityListener);
|
||||
window.removeEventListener("keydown", this.#rootKeyDownListener);
|
||||
}
|
||||
|
||||
@bound
|
||||
searchUpdated(event: LibraryPageSearchUpdated) {
|
||||
event.stopPropagation();
|
||||
const apps = event.apps;
|
||||
if (apps.length <= 0) {
|
||||
throw new Error(
|
||||
"LibaryPageSearchUpdated had empty results body. This must not happen.",
|
||||
);
|
||||
}
|
||||
this.filteredApps = apps;
|
||||
this.selectedApp = apps[0];
|
||||
public override firstUpdated(changedProperties: PropertyValues<this>): void {
|
||||
super.firstUpdated(changedProperties);
|
||||
|
||||
requestAnimationFrame(() => {
|
||||
this.focus();
|
||||
const { target } = this.autofocusTarget;
|
||||
|
||||
if (!target) return;
|
||||
|
||||
// Place cursor at end of input.
|
||||
target.selectionEnd = target.value.length;
|
||||
target.selectionStart = target.value.length;
|
||||
});
|
||||
}
|
||||
|
||||
@bound
|
||||
launchRequest(event: LibraryPageSearchSelected) {
|
||||
event.stopPropagation();
|
||||
if (!this.selectedApp?.launchUrl) {
|
||||
//#endregion
|
||||
|
||||
//#region Event Listeners
|
||||
|
||||
#inputListener = (event: KeyboardEvent) => {
|
||||
const inputElement = event.target as HTMLInputElement;
|
||||
|
||||
this.query = inputElement.value;
|
||||
};
|
||||
|
||||
#changeListener = () => {
|
||||
if (this.targetRef.value && this.visibleApplications.length === 1) {
|
||||
this.targetRef.value.focus();
|
||||
this.targetRef.value.click();
|
||||
return;
|
||||
}
|
||||
if (!this.selectedApp.openInNewTab) {
|
||||
window.location.assign(this.selectedApp?.launchUrl);
|
||||
} else {
|
||||
window.open(this.selectedApp.launchUrl);
|
||||
};
|
||||
|
||||
#submitListener = (event: SubmitEvent) => {
|
||||
event.preventDefault();
|
||||
|
||||
if (this.targetRef.value) {
|
||||
this.targetRef.value.focus();
|
||||
this.targetRef.value.click();
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@bound
|
||||
searchReset(event: LibraryPageSearchReset) {
|
||||
event.stopPropagation();
|
||||
this.filteredApps = this.apps;
|
||||
this.selectedApp = null;
|
||||
}
|
||||
#rootKeyDownListener = (event: KeyboardEvent) => {
|
||||
if (event.defaultPrevented || event.key !== "Enter") {
|
||||
return;
|
||||
}
|
||||
|
||||
@bound
|
||||
searchEmpty(event: LibraryPageSearchEmpty) {
|
||||
event.stopPropagation();
|
||||
this.filteredApps = [];
|
||||
this.selectedApp = null;
|
||||
}
|
||||
if (this.autofocusTarget.target?.matches(":focus")) {
|
||||
// Let the input handle the event.
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.renderRoot instanceof ShadowRoot) {
|
||||
const focusedElement = this.renderRoot.activeElement;
|
||||
if (isInteractiveElement(focusedElement)) {
|
||||
focusedElement.click();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
#visibilityListener = () => {
|
||||
if (document.visibilityState !== "visible") return;
|
||||
if (!this.visible) return;
|
||||
|
||||
this.focus();
|
||||
};
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region Rendering
|
||||
|
||||
renderApps() {
|
||||
const { selectedApp } = this;
|
||||
const { layout, background } = this.uiConfig;
|
||||
|
||||
const groupedApps = groupBy(
|
||||
this.filteredApps.filter(appHasLaunchUrl),
|
||||
(app) => app.group || "",
|
||||
).sort(([groupLabelA, groupAppsA], [groupLabelB, groupAppsB]) => {
|
||||
if (selectedApp) {
|
||||
if (groupAppsA.includes(selectedApp)) return -1;
|
||||
if (groupAppsB.includes(selectedApp)) return 1;
|
||||
}
|
||||
const groupedApps = groupBy(this.visibleApplications, (app) => app.group || "").sort(
|
||||
([groupLabelA, groupAppsA], [groupLabelB, groupAppsB]) => {
|
||||
if (selectedApp) {
|
||||
if (groupAppsA.includes(selectedApp)) return -1;
|
||||
if (groupAppsB.includes(selectedApp)) return 1;
|
||||
}
|
||||
|
||||
return groupLabelA.localeCompare(groupLabelB);
|
||||
return groupLabelA.localeCompare(groupLabelB);
|
||||
},
|
||||
);
|
||||
|
||||
return AKLibraryApplicationList({
|
||||
layout,
|
||||
background,
|
||||
selectedApp,
|
||||
groupedApps,
|
||||
targetRef: this.targetRef,
|
||||
});
|
||||
|
||||
return html`<ak-library-application-list
|
||||
layout=${layout}
|
||||
background=${ifPresent(background)}
|
||||
.selected=${ifPresent(selectedApp)}
|
||||
.apps=${groupedApps}
|
||||
></ak-library-application-list>`;
|
||||
}
|
||||
|
||||
renderSearch() {
|
||||
return html`<ak-library-application-search
|
||||
class="search-container"
|
||||
.apps=${this.apps}
|
||||
></ak-library-application-search>`;
|
||||
protected renderSearch() {
|
||||
return html`<search title=${msg("Applications")}>
|
||||
<form @submit=${this.#submitListener} id="application-search-form">
|
||||
<input
|
||||
${this.autofocusTarget.toRef()}
|
||||
part="search-input"
|
||||
name="application-search"
|
||||
id="application-search-input"
|
||||
@input=${this.#inputListener}
|
||||
@change=${this.#changeListener}
|
||||
type="search"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
class="pf-c-form-control"
|
||||
autofocus
|
||||
placeholder=${msg("Search for an application by name...")}
|
||||
value=${ifPresent(this.query)}
|
||||
list="application-search-options"
|
||||
/>
|
||||
<datalist id="application-search-options">
|
||||
${repeat(
|
||||
this.visibleApplications,
|
||||
(application) => application.pk,
|
||||
(app) => {
|
||||
return html`<option value=${app.name}></option>`;
|
||||
},
|
||||
)}
|
||||
</datalist>
|
||||
</form>
|
||||
</search>`;
|
||||
}
|
||||
|
||||
renderNoAppsFound() {
|
||||
return html`<ak-library-application-search-empty></ak-library-application-search-empty>`;
|
||||
protected renderNoAppsFound() {
|
||||
return html`<div class="pf-c-empty-state pf-m-full-height" tabindex="-1">
|
||||
<div class="pf-c-empty-state__content">
|
||||
<i class="fas fa-cubes pf-c-empty-state__icon" aria-hidden="true"></i>
|
||||
<h3 class="pf-c-title pf-m-lg" id="no-results-title">
|
||||
${msg("Search returned no results.")}
|
||||
</h3>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
renderSearchEmpty() {
|
||||
return nothing;
|
||||
}
|
||||
|
||||
renderState() {
|
||||
protected renderState() {
|
||||
if (!this.apps.some(appHasLaunchUrl)) {
|
||||
return html`<ak-library-application-empty-list
|
||||
?admin=${this.admin}
|
||||
></ak-library-application-empty-list>`;
|
||||
}
|
||||
return this.filteredApps.some(appHasLaunchUrl) // prettier-ignore
|
||||
? this.renderApps()
|
||||
: this.renderNoAppsFound();
|
||||
|
||||
if (this.visibleApplications.length) {
|
||||
return this.renderApps();
|
||||
}
|
||||
|
||||
return this.renderNoAppsFound();
|
||||
}
|
||||
|
||||
render() {
|
||||
public override render() {
|
||||
const count = this.visibleApplications.length;
|
||||
const { query } = this;
|
||||
|
||||
let message: string;
|
||||
|
||||
if (query) {
|
||||
// We must present the count within the label to ensure that the screen reader
|
||||
// considers the update significant enough to read on each change,
|
||||
// rather than the on just the first render.
|
||||
message =
|
||||
count === 1
|
||||
? msg(str`${count} application found for "${query}"`)
|
||||
: msg(str`${count} applications found for "${query}"`);
|
||||
} else {
|
||||
message =
|
||||
count === 1
|
||||
? msg(str`${count} application available`)
|
||||
: msg(str`${count} applications available`);
|
||||
}
|
||||
|
||||
return html`<div class="pf-c-page__main">
|
||||
<div class="pf-c-page__header pf-c-content">
|
||||
<h1 class="pf-c-page__title">${msg("My applications")}</h1>
|
||||
${this.uiConfig.searchEnabled ? this.renderSearch() : nothing}
|
||||
${this.searchEnabled ? this.renderSearch() : nothing}
|
||||
</div>
|
||||
<main
|
||||
${AKSkipToContent.ref}
|
||||
tabindex="-1"
|
||||
id="main-content"
|
||||
class="pf-c-page__main-section"
|
||||
aria-label=${msg("Application list")}
|
||||
>
|
||||
<output
|
||||
class="sr-only"
|
||||
for="application-search-input"
|
||||
form="application-search-form"
|
||||
aria-live="polite"
|
||||
>
|
||||
<p>${message}</p>
|
||||
</output>
|
||||
${this.renderState()}
|
||||
</main>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
//#endregion
|
||||
}
|
||||
|
||||
@@ -34,6 +34,8 @@ const coreApi = () => new CoreApi(DEFAULT_CONFIG);
|
||||
@localized()
|
||||
@customElement("ak-library")
|
||||
export class LibraryPage extends AKElement {
|
||||
static shadowRootOptions = { ...AKElement.shadowRootOptions, delegatesFocus: true };
|
||||
|
||||
protected createRenderRoot(): HTMLElement | DocumentFragment {
|
||||
return this;
|
||||
}
|
||||
|
||||
@@ -1,72 +0,0 @@
|
||||
import type { Application } from "@goauthentik/api";
|
||||
|
||||
/**
|
||||
* @class LibraryPageSearchUpdated
|
||||
*
|
||||
* Indicates that the user has made a query that resulted in some
|
||||
* applications being filtered-for.
|
||||
*
|
||||
*/
|
||||
export class LibraryPageSearchUpdated extends Event {
|
||||
static readonly eventName = "authentik.library.search-updated";
|
||||
/**
|
||||
* @attr apps: The list of those entries found by the current search.
|
||||
*/
|
||||
constructor(public apps: Application[]) {
|
||||
super(LibraryPageSearchUpdated.eventName, { composed: true, bubbles: true });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @class LibraryPageSearchReset
|
||||
*
|
||||
* Indicates that the user has emptied the search field. Intended to
|
||||
* signal that all available apps are to be displayed.
|
||||
*
|
||||
*/
|
||||
export class LibraryPageSearchReset extends Event {
|
||||
static readonly eventName = "authentik.library.search-reset";
|
||||
constructor() {
|
||||
super(LibraryPageSearchReset.eventName, { composed: true, bubbles: true });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @class LibraryPageSearchEmpty
|
||||
*
|
||||
* Indicates that the user has made a query that resulted in an empty
|
||||
* list being returned. Intended to signal that an alternative "No
|
||||
* matching applications found" message be displayed.
|
||||
*
|
||||
*/
|
||||
export class LibraryPageSearchEmpty extends Event {
|
||||
static readonly eventName = "authentik.library.search-empty";
|
||||
|
||||
constructor() {
|
||||
super(LibraryPageSearchEmpty.eventName, { composed: true, bubbles: true });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @class LibraryPageSearchEmpty
|
||||
*
|
||||
* Indicates that the user has pressed "Enter" while focused on the
|
||||
* search box. Intended to signal that the currently highlighted search
|
||||
* entry (if any) should be activated.
|
||||
*
|
||||
*/
|
||||
export class LibraryPageSearchSelected extends Event {
|
||||
static readonly eventName = "authentik.library.search-item-selected";
|
||||
constructor() {
|
||||
super(LibraryPageSearchSelected.eventName, { composed: true, bubbles: true });
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface GlobalEventHandlersEventMap {
|
||||
[LibraryPageSearchUpdated.eventName]: LibraryPageSearchUpdated;
|
||||
[LibraryPageSearchReset.eventName]: LibraryPageSearchReset;
|
||||
[LibraryPageSearchEmpty.eventName]: LibraryPageSearchEmpty;
|
||||
[LibraryPageSearchSelected.eventName]: LibraryPageSearchSelected;
|
||||
}
|
||||
}
|
||||
@@ -261,6 +261,10 @@ class UserInterfacePresentation extends WithBrandConfig(AKElement) {
|
||||
//
|
||||
@customElement("ak-interface-user")
|
||||
export class UserInterface extends WithBrandConfig(AuthenticatedInterface) {
|
||||
public static shadowRootOptions = { ...AKElement.shadowRootOptions, delegatesFocus: true };
|
||||
|
||||
public override tabIndex = -1;
|
||||
|
||||
@property({ type: Boolean })
|
||||
notificationDrawerOpen = getURLParam("notificationDrawerOpen", false);
|
||||
|
||||
|
||||
@@ -75,7 +75,7 @@ export class UserSettingsPage extends AKElement {
|
||||
@media screen and (min-width: 1200px) {
|
||||
:host {
|
||||
width: 90rem;
|
||||
width: 90rem;
|
||||
max-width: 100%;
|
||||
align-self: center;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user