From b29d1e041c77f4094213e9e7121eafe26386a722 Mon Sep 17 00:00:00 2001 From: Teffen Ellis <592134+GirlBossRush@users.noreply.github.com> Date: Thu, 7 May 2026 19:24:27 +0000 Subject: [PATCH] web: add grid/list view toggle to user application library (#22126) Implements the long-requested list view for the user-facing application library so customers with naturally long application names (e.g. AWS accounts of the form ` ()`) can scan their apps without name truncation. - New icon button in the page header swaps between grid (default) and list mode; preference persisted per-browser via StorageAccessor.local ("ak-library-view-mode") to mirror the RememberMe pattern. - List mode renders each app as a wide row using a CSS subgrid so icon, name+meta, and action menu line up consistently across rows. The full application name is shown without clamp; description, publisher, and slug appear inline as supplementary metadata. - The kebab action menu (CardMenu) is shared with the grid card, re-anchored to the kebab's right edge in list mode so the popover stays inside the viewport. - Demo blueprint `blueprints/example/long-named-aws-applications.yaml` ships 25 fixture applications with title-cased Greek-prefixed names spread across five groups, ~19 with brand image icons and the rest using letter insignias. Marked `instantiate: "false"` so it stays out of fresh dev environments unless explicitly applied. Co-Authored-By: Agent (authentik-i22126-animated-specific-xanthous) <279763771+playpen-agent@users.noreply.github.com> --- web/src/user/LibraryPage/ApplicationList.css | 161 ++++++++++++++++++- web/src/user/LibraryPage/ApplicationList.ts | 56 ++++--- web/src/user/LibraryPage/LibraryAppRow.ts | 125 ++++++++++++++ web/src/user/LibraryPage/ak-library-impl.css | 14 +- web/src/user/LibraryPage/ak-library-impl.ts | 77 +++++++++ web/src/user/LibraryPage/types.ts | 8 + 6 files changed, 420 insertions(+), 21 deletions(-) create mode 100644 web/src/user/LibraryPage/LibraryAppRow.ts diff --git a/web/src/user/LibraryPage/ApplicationList.css b/web/src/user/LibraryPage/ApplicationList.css index 929ca6628035..fbd1d354cffd 100644 --- a/web/src/user/LibraryPage/ApplicationList.css +++ b/web/src/user/LibraryPage/ApplicationList.css @@ -108,7 +108,7 @@ /* #region Icon */ -ak-app-icon { +[part="app-list"]:not([data-view-mode="list"]) 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); @@ -286,3 +286,162 @@ ak-app-icon { -apple-system, monospace; } + +/* #region List view */ + +[part="app-list"][data-view-mode="list"] { + display: block; + + [part="app-group"] { + display: block; + width: 100%; + } + + .app-group-rows { + list-style: none; + margin: 0; + padding: 0; + display: grid; + grid-template-columns: auto 1fr auto; + align-items: center; + } + + .app-row { + position: relative; + display: grid; + grid-template-columns: subgrid; + grid-column: 1 / -1; + align-items: center; + gap: var(--pf-global--spacer--md); + padding: var(--pf-global--spacer--sm) var(--pf-global--spacer--md); + border-block-end: 1px solid var(--pf-global--BorderColor--300); + background-color: transparent; + transition: background-color 120ms ease-in-out; + + &:last-child { + border-block-end: none; + } + + &:hover { + background-color: color-mix( + in srgb, + var(--pf-c-card--BackgroundColor) 100%, + var(--pf-global--BorderColor--100) 35% + ); + } + + &[aria-selected="true"] { + background-color: color-mix( + in srgb, + var(--pf-c-card--BackgroundColor) 100%, + var(--ak-accent) 12% + ); + } + } + + .app-row-link { + display: grid; + grid-template-columns: subgrid; + grid-column: 1 / span 2; + align-items: center; + gap: var(--pf-global--spacer--md); + min-width: 0; + text-decoration: none; + color: inherit; + cursor: pointer; + + &:hover .row-title, + &:focus-visible .row-title { + text-decoration: underline; + } + + &:focus-visible { + outline: 2px solid var(--ak-accent); + outline-offset: 2px; + border-radius: var(--pf-global--BorderRadius--sm); + } + } + + [part="row-icon-host"] { + flex: 0 0 auto; + align-self: center; + width: auto; + + &::part(icon) { + position: static; + inset: auto; + } + } + + .row-text { + display: flex; + flex-direction: column; + flex: 1 1 auto; + min-width: 0; + gap: 0.125rem; + } + + .row-title { + font-weight: 600; + line-height: 1.3; + word-break: break-word; + overflow-wrap: anywhere; + } + + .row-meta { + color: var(--pf-global--Color--200); + font-size: var(--pf-global--FontSize--sm); + line-height: 1.3; + word-break: break-word; + overflow-wrap: anywhere; + } + + /* Re-anchor the kebab menu (CardMenu) to the right edge of the row + * instead of the absolutely-positioned card overlay used in grid view. */ + [part="card-header-actions"] { + position: static; + inset: auto; + flex: 0 0 auto; + align-self: center; + } + + [part="card-header-actions-button"] { + --pf-c-dropdown__toggle--PaddingTop: var(--pf-global--spacer--xs); + --pf-c-dropdown__toggle--PaddingBottom: var(--pf-global--spacer--xs); + --pf-c-dropdown__toggle--PaddingLeft: var(--pf-global--spacer--sm); + --pf-c-dropdown__toggle--PaddingRight: var(--pf-global--spacer--sm); + + color: var(--pf-global--Color--200); + border-radius: var(--pf-global--BorderRadius--sm); + + &:hover, + &:focus-visible { + color: var(--ak-accent); + background-color: var(--pf-c-card--m-flat--BorderColor); + } + } + + [part="card-header-actions-icon"] { + font-size: 1.1rem; + line-height: 1; + } + + /* Anchor the popover to the kebab's right edge so it opens down-left + * and stays inside the viewport (grid view's center anchor pushes it + * off the screen when the kebab sits at a row's right edge). */ + &[data-anchor-strategy="anchor-position"] [part="card-header-actions-menu"] { + inset-inline-start: auto; + inset-inline-end: anchor(end); + inset-block-start: anchor(end); + + @media (min-width: 390px) { + inset-inline-start: auto; + } + } + + &[data-anchor-strategy="fallback"] [part="card-header-actions-menu"] { + inset-block-start: 100%; + inset-inline-start: auto; + inset-inline-end: 0; + } +} diff --git a/web/src/user/LibraryPage/ApplicationList.ts b/web/src/user/LibraryPage/ApplicationList.ts index 1bcee1c92f17..a8f1d2ba4b1c 100644 --- a/web/src/user/LibraryPage/ApplicationList.ts +++ b/web/src/user/LibraryPage/ApplicationList.ts @@ -1,4 +1,5 @@ -import type { AppGroupEntry } from "./types.js"; +import { LibraryAppRow } from "./LibraryAppRow.js"; +import { type AppGroupEntry, ViewMode } from "./types.js"; import { LayoutType } from "#common/ui/config"; @@ -30,6 +31,7 @@ export interface AKLibraryApplicationListProps extends HTMLAttributes = ({ editable, groupedApps, layout = LayoutType.row, + viewMode = ViewMode.Grid, background, selectedApp, targetRef, ...props }) => { const columnCount = LayoutColumnCount[layout] ?? 1; + const isList = viewMode === ViewMode.List; return html`
= ({ ([groupLabel]) => groupLabel, ([groupLabel, apps], groupIndex) => { const groupID = kebabCase(groupLabel); + const inner = repeat( + apps, + (application) => application.pk, + (application) => { + const selected = selectedApp === application; + + const editURL = editable + ? ApplicationRoute.EditURL(application.slug) + : null; + + if (isList) { + return LibraryAppRow({ + application, + editURL, + targetRef: selected ? targetRef : null, + }); + } + + return AKLibraryApp({ + application, + background, + editURL, + targetRef: selected ? targetRef : null, + }); + }, + ); return html`
= ({ >

${groupLabel || msg("Ungrouped")}

- ${repeat( - apps, - (application) => application.pk, - (application) => { - const selected = selectedApp === application; - - const editURL = editable - ? ApplicationRoute.EditURL(application.slug) - : null; - - return AKLibraryApp({ - application, - background, - editURL, - targetRef: selected ? targetRef : null, - }); - }, - )} + ${isList + ? html`
    + ${inner} +
` + : inner}
`; }, diff --git a/web/src/user/LibraryPage/LibraryAppRow.ts b/web/src/user/LibraryPage/LibraryAppRow.ts new file mode 100644 index 000000000000..1a3b6c6915b5 --- /dev/null +++ b/web/src/user/LibraryPage/LibraryAppRow.ts @@ -0,0 +1,125 @@ +import "#elements/AppIcon"; +import "#user/LibraryApplication/RACLaunchEndpointModal"; + +import { PFSize } from "#common/enums"; + +import { modalInvoker } from "#elements/dialogs"; +import { LitFC } from "#elements/types"; +import { ifPresent } from "#elements/utils/attributes"; + +import { CardMenu } from "#user/LibraryApplication/CardMenu"; +import { RACLaunchEndpointLaunch } from "#user/LibraryApplication/RACLaunchEndpointModal"; + +import { Application } from "@goauthentik/api"; + +import { spread } from "@open-wc/lit-helpers"; +import { kebabCase } from "change-case"; +import type { HTMLAttributes } from "react"; + +import { msg, str } from "@lit/localize"; +import { html, nothing } from "lit"; +import { ref, RefOrCallback } from "lit/directives/ref.js"; + +const RAC_LAUNCH_URL = "goauthentik.io://providers/rac/launch"; + +export interface LibraryAppRowProps extends HTMLAttributes { + application?: Application; + editURL?: string | URL | null; + targetRef?: RefOrCallback | null; +} + +/** + * A single application rendered as a wide row for the library list view. + * + * Shows the full application name without truncation, plus secondary metadata + * (description, publisher, slug, group) on the same row to support + * search-by-substring use cases like "AWS account ID alongside account name". + */ +export const LibraryAppRow: LitFC = ({ + application, + editURL, + targetRef, + ...props +}) => { + if (!application) { + return html``; + } + + const dataID = kebabCase(application.name); + const rowID = `app-row-${application.pk}`; + const titleID = `${rowID}-title`; + const metaID = `${rowID}-meta`; + const descriptionID = `${rowID}-description`; + + const rac = application.launchUrl === RAC_LAUNCH_URL; + const primaryRef = targetRef ? ref(targetRef) : nothing; + + const metaParts: string[] = []; + if (application.metaDescription) metaParts.push(application.metaDescription); + if (application.metaPublisher) metaParts.push(application.metaPublisher); + if (application.slug) metaParts.push(application.slug); + + const linkProps = { + "aria-label": msg(str`Open "${application.name}"`, { + id: "library.application.row.aria-label", + desc: "Screen reader label for the application list row", + }), + "aria-labelledby": `${titleID}${metaParts.length ? ` ${metaID}` : ""}`, + "tabindex": "0", + "class": "app-row-link", + "part": "row-link", + "id": rowID, + ...props, + }; + + const inner = html` + +
+
${application.name}
+ ${metaParts.length + ? html`
+ ${metaParts.join(" ยท ")} +
` + : nothing} +
+ `; + + const launcher = rac + ? html`
+ ${inner} +
` + : html`${inner}`; + + return html``; +}; diff --git a/web/src/user/LibraryPage/ak-library-impl.css b/web/src/user/LibraryPage/ak-library-impl.css index 65bf4bbd1db2..f199a3f07eb5 100644 --- a/web/src/user/LibraryPage/ak-library-impl.css +++ b/web/src/user/LibraryPage/ak-library-impl.css @@ -14,7 +14,8 @@ flex-direction: row; justify-content: space-between; flex-flow: row wrap; - gap: var(--pf-global--spacer--lg); + align-items: center; + gap: var(--pf-global--spacer--xs); container-type: inline-size; .pf-c-page__title { @@ -22,6 +23,17 @@ flex: 1 1 auto; } + .library-view-toggle { + flex: 0 0 auto; + font-size: var(--pf-global--FontSize--xl); + line-height: 1; + + .ak-c-vector-icon { + width: 1.5rem; + height: 1.5rem; + } + } + search { display: flex; flex: 0 0 36ch; diff --git a/web/src/user/LibraryPage/ak-library-impl.ts b/web/src/user/LibraryPage/ak-library-impl.ts index 59bee4b37e57..ea95cda8ebaf 100644 --- a/web/src/user/LibraryPage/ak-library-impl.ts +++ b/web/src/user/LibraryPage/ak-library-impl.ts @@ -1,11 +1,13 @@ import "#elements/EmptyState"; import "#user/LibraryApplication/index"; +import "@patternfly/elements/pf-tooltip/pf-tooltip.js"; import "./ak-library-application-empty-list.js"; import Styles from "./ak-library-impl.css"; import AKLibraryApplicationListStyles from "./ApplicationList.css"; import { AKLibraryApplicationList } from "./ApplicationList.js"; import { appHasLaunchUrl } from "./LibraryPageImpl.utils.js"; +import { VIEW_MODE_STORAGE, ViewMode } from "./types.js"; import { groupBy } from "#common/utils"; @@ -41,6 +43,12 @@ import PFGrid from "@patternfly/patternfly/layouts/Grid/grid.css"; import PFDisplay from "@patternfly/patternfly/utilities/Display/display.css"; import PFSpacing from "@patternfly/patternfly/utilities/Spacing/spacing.css"; +function readStoredViewMode(): ViewMode { + return VIEW_MODE_STORAGE.read(ViewMode.Grid) === ViewMode.List + ? ViewMode.List + : ViewMode.Grid; +} + /** * List of Applications available * @@ -134,6 +142,9 @@ export class LibraryPage extends WithSession(AKElement) { @state() protected visibleApplications: Application[] = []; + @state() + protected viewMode: ViewMode = readStoredViewMode(); + /** * The active element to select when the user presses Enter outside of a form. */ @@ -299,6 +310,7 @@ export class LibraryPage extends WithSession(AKElement) { return AKLibraryApplicationList({ editable, layout: layout.type, + viewMode: this.viewMode, background: theme.cardBackground, selectedApp, groupedApps, @@ -306,6 +318,68 @@ export class LibraryPage extends WithSession(AKElement) { }); } + #viewToggleListener = () => { + const next = this.viewMode === ViewMode.Grid ? ViewMode.List : ViewMode.Grid; + this.viewMode = next; + VIEW_MODE_STORAGE.write(next); + }; + + protected renderViewToggle() { + // Show the icon and tooltip for the mode the user will switch *to* on click. + const showsListMode = this.viewMode === ViewMode.Grid; + const tooltipContent = showsListMode + ? msg("Switch to list view", { + id: "user.library.view-toggle.to-list", + desc: "Tooltip on the library view toggle when grid view is active", + }) + : msg("Switch to grid view", { + id: "user.library.view-toggle.to-grid", + desc: "Tooltip on the library view toggle when list view is active", + }); + + const icon = showsListMode + ? html`` + : html``; + + return html``; + } + protected renderSearch() { return html`
@@ -430,9 +504,12 @@ export class LibraryPage extends WithSession(AKElement) { } protected override render() { + const hasApps = this.apps.some(appHasLaunchUrl); + return html`

${msg("My applications")}

+ ${hasApps ? this.renderViewToggle() : nothing} ${this.searchEnabled ? this.renderSearch() : nothing}