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:
authentik-automation[bot]
2025-11-01 17:05:19 +01:00
committed by GitHub
parent 449742fbc0
commit b72709ebbc
22 changed files with 912 additions and 861 deletions
+13
View File
@@ -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;
+13 -1
View File
@@ -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 {
+45
View File
@@ -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
View File
@@ -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;
+1 -1
View File
@@ -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
+22
View File
@@ -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"))
);
}
+21 -25
View File
@@ -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">&vellip;</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>
&nbsp;${msg(str`Edit application...`)}</a
>
</li>`
: nothing}
: null}
</menu>
</div>`;
};
+24 -15
View File
@@ -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;
}
+109
View File
@@ -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;
}
}
+35 -1
View File
@@ -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;
}
}
+285 -99
View File
@@ -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
}
+2
View File
@@ -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;
}
-72
View File
@@ -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;
}
}
+4
View File
@@ -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;
}
}