diff --git a/web/src/admin/Routes.ts b/web/src/admin/Routes.ts
index 116a1ba564..c2e00b5425 100644
--- a/web/src/admin/Routes.ts
+++ b/web/src/admin/Routes.ts
@@ -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` `;
}),
];
+
+/**
+ * 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;
diff --git a/web/src/common/styles/authentik.css b/web/src/common/styles/authentik.css
index a686703435..ab5124d0dd 100644
--- a/web/src/common/styles/authentik.css
+++ b/web/src/common/styles/authentik.css
@@ -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 {
diff --git a/web/src/elements/AppIcon.css b/web/src/elements/AppIcon.css
new file mode 100644
index 0000000000..b66cf2942a
--- /dev/null
+++ b/web/src/elements/AppIcon.css
@@ -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));
+}
diff --git a/web/src/elements/AppIcon.ts b/web/src/elements/AppIcon.ts
index 84f2874711..08114dd648 100644
--- a/web/src/elements/AppIcon.ts
+++ b/web/src/elements/AppIcon.ts
@@ -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`
= K extends string ? `.${K}` | `?${K}` | K : K;
export type LitFC = (
props: P,
children?: null | SlottedTemplateResult,
-) => SlottedTemplateResult | SlottedTemplateResult[];
+) => SlottedTemplateResult | SlottedTemplateResult[] | null;
//#endregion
diff --git a/web/src/elements/utils/interactivity.ts b/web/src/elements/utils/interactivity.ts
new file mode 100644
index 0000000000..0d53c4b532
--- /dev/null
+++ b/web/src/elements/utils/interactivity.ts
@@ -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"))
+ );
+}
diff --git a/web/src/user/LibraryApplication/CardMenu.ts b/web/src/user/LibraryApplication/CardMenu.ts
index 6b1955601b..aa086f03d8 100644
--- a/web/src/user/LibraryApplication/CardMenu.ts
+++ b/web/src/user/LibraryApplication/CardMenu.ts
@@ -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 {
cardID: string;
descriptionID: string;
application: Application;
+ editURL?: string | URL | null;
}
export const CardMenu: LitFC = ({
application,
cardID,
descriptionID,
+ editURL,
...props
}) => {
- const { me, uiConfig } = rootInterface();
-
- 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`
⋮
@@ -64,7 +60,7 @@ export const CardMenu: LitFC
= ({
part="card-header-actions-menu"
style="position-anchor: ${menuAnchor};"
id=${menuID}
- popover
+ ?popover=${AnchorPositionSupported}
>
${metaPublisher || truncatedDescription
? html`
@@ -77,8 +73,8 @@ export const CardMenu: LitFC = ({
? html`
${metaPublisher}
`
- : nothing}
- ${metaPublisher
+ : null}
+ ${truncatedDescription
? html` = ({
>
${truncatedDescription}
`
- : nothing}
+ : null}
`
- : nothing}
+ : null}
${editURL
? html`
`
- : nothing}
+ : null}
`;
};
diff --git a/web/src/user/LibraryApplication/index.ts b/web/src/user/LibraryApplication/index.ts
index 655c64901c..e34b338cd2 100644
--- a/web/src/user/LibraryApplication/index.ts
+++ b/web/src/user/LibraryApplication/index.ts
@@ -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 {
application?: Application;
+ editURL?: string | URL | null;
background?: string | null;
appIndex: number;
groupIndex: number;
+ targetRef?: RefOrCallback | null;
}
export const AKLibraryApp: LitFC = ({
application,
+ editURL,
background,
appIndex,
groupIndex,
className = "",
+ targetRef,
...props
}) => {
if (!application) {
@@ -55,7 +59,7 @@ export const AKLibraryApp: LitFC = ({
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 = ({
});
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`
${rac
? html``
: html``}
${CardMenu({
application,
cardID,
descriptionID,
+ editURL,
})}
`;
diff --git a/web/src/user/LibraryPage/ApplicationList.css b/web/src/user/LibraryPage/ApplicationList.css
new file mode 100644
index 0000000000..c9cefe6478
--- /dev/null
+++ b/web/src/user/LibraryPage/ApplicationList.css
@@ -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;
+}
diff --git a/web/src/user/LibraryPage/ApplicationList.ts b/web/src/user/LibraryPage/ApplicationList.ts
new file mode 100644
index 0000000000..7c11b157f4
--- /dev/null
+++ b/web/src/user/LibraryPage/ApplicationList.ts
@@ -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;
+
+export interface AKLibraryApplicationListProps extends HTMLAttributes {
+ 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 = ({
+ groupedApps,
+ layout = LayoutType.row,
+ background,
+ selectedApp,
+ targetRef,
+ ...props
+}) => {
+ const columnCount = LayoutColumnCount[layout] ?? 1;
+ const { me, uiConfig } = rootInterface();
+ const canEdit = !!(uiConfig?.enabledFeatures.applicationEdit && me?.user.isSuperuser);
+
+ return html`
+ ${repeat(
+ groupedApps,
+ ([groupLabel]) => groupLabel,
+ ([groupLabel, apps], groupIndex) => {
+ const groupID = kebabCase(groupLabel);
+ const activeDescendantID =
+ selectedApp && apps.includes(selectedApp) ? `app-${selectedApp.pk}` : nothing;
+
+ return html`
+
+ ${groupLabel || msg("Ungrouped")}
+
+ ${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,
+ });
+ },
+ )}
+
+ `;
+ },
+ )}
+
`;
+};
diff --git a/web/src/user/LibraryPage/ak-library-application-list.css b/web/src/user/LibraryPage/ak-library-application-list.css
deleted file mode 100644
index 204fe06b21..0000000000
--- a/web/src/user/LibraryPage/ak-library-application-list.css
+++ /dev/null
@@ -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;
-}
diff --git a/web/src/user/LibraryPage/ak-library-application-list.ts b/web/src/user/LibraryPage/ak-library-application-list.ts
deleted file mode 100644
index 751493260d..0000000000
--- a/web/src/user/LibraryPage/ak-library-application-list.ts
+++ /dev/null
@@ -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;
-
-/**
- * @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`
- ${repeat(
- this.apps,
- ([groupLabel]) => groupLabel,
- ([groupLabel, apps], groupIndex) => {
- return html`
-
-
${groupLabel}
-
- ${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,
- }),
- )}
-
-
`;
- },
- )}
-
`;
- }
-}
-
-declare global {
- interface HTMLElementTagNameMap {
- "ak-library-application-list": LibraryPageApplicationList;
- }
-}
diff --git a/web/src/user/LibraryPage/ak-library-application-search-empty.ts b/web/src/user/LibraryPage/ak-library-application-search-empty.ts
deleted file mode 100644
index 144b5611a6..0000000000
--- a/web/src/user/LibraryPage/ak-library-application-search-empty.ts
+++ /dev/null
@@ -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`
-
-
-
${msg("Search returned no results.")}
-
-
`;
- }
-}
diff --git a/web/src/user/LibraryPage/ak-library-application-search.css b/web/src/user/LibraryPage/ak-library-application-search.css
deleted file mode 100644
index 8c94a2ec0a..0000000000
--- a/web/src/user/LibraryPage/ak-library-application-search.css
+++ /dev/null
@@ -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;
- }
-}
diff --git a/web/src/user/LibraryPage/ak-library-application-search.ts b/web/src/user/LibraryPage/ak-library-application-search.ts
deleted file mode 100644
index 33e61a7285..0000000000
--- a/web/src/user/LibraryPage/ak-library-application-search.ts
+++ /dev/null
@@ -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("q", "");
-
- protected searchInput = createRef();
-
- protected fuse = new Fuse([], {
- 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[]) => {
- 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` `;
- }
-}
-
-declare global {
- interface HTMLElementTagNameMap {
- "ak-library-list-search": LibraryPageApplicationSearch;
- }
-}
diff --git a/web/src/user/LibraryPage/ak-library-impl.css b/web/src/user/LibraryPage/ak-library-impl.css
index 7d90de79c4..e07a1ddb77 100644
--- a/web/src/user/LibraryPage/ak-library-impl.css
+++ b/web/src/user/LibraryPage/ak-library-impl.css
@@ -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;
+ }
+}
diff --git a/web/src/user/LibraryPage/ak-library-impl.ts b/web/src/user/LibraryPage/ak-library-impl.ts
index bcfecbc091..6897d4b75d 100644
--- a/web/src/user/LibraryPage/ak-library-impl.ts
+++ b/web/src/user/LibraryPage/ak-library-impl.ts
@@ -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();
+ 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();
+
+ #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([], {
+ 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("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): 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` `;
}
- renderSearch() {
- return html` `;
+ protected renderSearch() {
+ return html`
+
+ `;
}
- renderNoAppsFound() {
- return html` `;
+ protected renderNoAppsFound() {
+ return html`
+
+
+
+ ${msg("Search returned no results.")}
+
+
+
`;
}
- renderSearchEmpty() {
- return nothing;
- }
-
- renderState() {
+ protected renderState() {
if (!this.apps.some(appHasLaunchUrl)) {
return html` `;
}
- 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`
+
+ ${message}
+
${this.renderState()}
`;
}
+
+ //#endregion
}
diff --git a/web/src/user/LibraryPage/ak-library.ts b/web/src/user/LibraryPage/ak-library.ts
index 202892e84c..dcc4fd0dcb 100644
--- a/web/src/user/LibraryPage/ak-library.ts
+++ b/web/src/user/LibraryPage/ak-library.ts
@@ -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;
}
diff --git a/web/src/user/LibraryPage/events.ts b/web/src/user/LibraryPage/events.ts
deleted file mode 100644
index e6b1ed56e6..0000000000
--- a/web/src/user/LibraryPage/events.ts
+++ /dev/null
@@ -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;
- }
-}
diff --git a/web/src/user/index.entrypoint.ts b/web/src/user/index.entrypoint.ts
index 2d7ef33c77..158de69f07 100644
--- a/web/src/user/index.entrypoint.ts
+++ b/web/src/user/index.entrypoint.ts
@@ -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);
diff --git a/web/src/user/user-settings/UserSettingsPage.ts b/web/src/user/user-settings/UserSettingsPage.ts
index 2c7ef914a8..aaaa073dfb 100644
--- a/web/src/user/user-settings/UserSettingsPage.ts
+++ b/web/src/user/user-settings/UserSettingsPage.ts
@@ -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;
}
}