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`
`) - .with([P._, P.string.startsWith("fa://")], - ([_name, icon]) => html`
`) - .with([P._, P.string], - ([_name, icon]) => html``) - .with([P.string, P.nullish], - ([name]) => html``) - .exhaustive(); + const applicationName = this.name ?? msg("Application"); + const label = msg(str`${applicationName} Icon`); + + if (this.icon?.startsWith(AppIcon.FontAwesomeProtocol)) { + return html``; + } + + const insignia = this.name?.charAt(0).toUpperCase() ?? "�"; + + if (this.icon) { + return html`${insignia}`; + } + + return html`${insignia}`; } } diff --git a/web/src/elements/ak-locale-context/ak-locale-context.ts b/web/src/elements/ak-locale-context/ak-locale-context.ts index 581bf172d4..f29d1a2bf9 100644 --- a/web/src/elements/ak-locale-context/ak-locale-context.ts +++ b/web/src/elements/ak-locale-context/ak-locale-context.ts @@ -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``; - } } export default LocaleContext; diff --git a/web/src/elements/types.ts b/web/src/elements/types.ts index cc9a1b83cb..992a7c85ca 100644 --- a/web/src/elements/types.ts +++ b/web/src/elements/types.ts @@ -65,7 +65,7 @@ export type LitPropertyKey = 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`
  • - +  ${msg(str`Edit application...`)}
  • ` - : 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`
    = ({ ${cardHeader}
    ` : html`${cardHeader}`} ${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` +
    + + + ${repeat( + this.visibleApplications, + (application) => application.pk, + (app) => { + 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`

    ${msg("My applications")}

    - ${this.uiConfig.searchEnabled ? this.renderSearch() : nothing} + ${this.searchEnabled ? this.renderSearch() : nothing}
    + +

    ${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; } }