Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
161 changes: 160 additions & 1 deletion web/src/user/LibraryPage/ApplicationList.css
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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;
}
}
56 changes: 37 additions & 19 deletions web/src/user/LibraryPage/ApplicationList.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -30,6 +31,7 @@ export interface AKLibraryApplicationListProps extends HTMLAttributes<HTMLDivEle
editable?: boolean;
groupedApps: AppGroupEntry[];
layout: LayoutType;
viewMode?: ViewMode;
background?: string | null;
selectedApp?: Application | null;
targetRef?: RefOrCallback | null;
Expand All @@ -42,16 +44,19 @@ export const AKLibraryApplicationList: LitFC<AKLibraryApplicationListProps> = ({
editable,
groupedApps,
layout = LayoutType.row,
viewMode = ViewMode.Grid,
background,
selectedApp,
targetRef,
...props
}) => {
const columnCount = LayoutColumnCount[layout] ?? 1;
const isList = viewMode === ViewMode.List;

return html`<div
role="presentation"
part="app-list"
data-view-mode=${viewMode}
data-anchor-strategy=${AnchorPositionSupported ? "anchor-position" : "fallback"}
style="--app-list-column-count: ${columnCount}"
${spread(props)}
Expand All @@ -61,6 +66,32 @@ export const AKLibraryApplicationList: LitFC<AKLibraryApplicationListProps> = ({
([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`<fieldset
class="ak-c-fieldset"
Expand All @@ -75,24 +106,11 @@ export const AKLibraryApplicationList: LitFC<AKLibraryApplicationListProps> = ({
>
<h2 id=${`app-group-${groupID}`}>${groupLabel || msg("Ungrouped")}</h2>
</legend>
${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`<ul part="app-group-rows" class="app-group-rows" role="list">
${inner}
</ul>`
: inner}
<hr part="app-group-separator" aria-hidden="true" />
</fieldset>`;
},
Expand Down
125 changes: 125 additions & 0 deletions web/src/user/LibraryPage/LibraryAppRow.ts
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement> {
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<LibraryAppRowProps> = ({
application,
editURL,
targetRef,
...props
}) => {
if (!application) {
return html`<ak-spinner></ak-spinner>`;
}

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`
<ak-app-icon
exportparts="icon:row-icon"
part="row-icon-host"
size=${PFSize.Medium}
name=${application.name}
icon=${ifPresent(application.metaIconUrl)}
.iconThemedUrls=${application.metaIconThemedUrls}
></ak-app-icon>
<div part="row-text" class="row-text">
<div id=${titleID} part="row-title" class="row-title">${application.name}</div>
${metaParts.length
? html`<div id=${metaID} part="row-meta" class="row-meta">
${metaParts.join(" · ")}
</div>`
: nothing}
</div>
`;

const launcher = rac
? html`<div
${primaryRef}
role="button"
${modalInvoker(RACLaunchEndpointLaunch, { app: application })}
${spread(linkProps)}
>
${inner}
</div>`
: html`<a
${primaryRef}
href=${ifPresent(application.launchUrl)}
target=${ifPresent(application.openInNewTab, "_blank")}
${spread(linkProps)}
>${inner}</a
>`;

return html`<li
part="row"
class="app-row"
data-application-name=${ifPresent(dataID)}
role="presentation"
>
${launcher}
${CardMenu({
application,
cardID: rowID,
descriptionID,
editURL,
})}
</li>`;
};
Loading
Loading