web: rework ak-search-select dropdown as an anchored popover (retire ak-portal) (#24104)

* docs: design for ak-search-select anchored popover (no portal)

* docs: implementation plan for ak-search-select anchored popover

* web/elements: render search-select menu as anchored popover, drop ak-portal

* web/elements: stretch search-select menu to input width and wrap long labels

* web/e2e: locate search-select options via anchored popover menu

* web/elements: close search-select on anchor hidden; JS positioning fallback for Firefox

* Unify feature check constant.

* web/elements: gate search-select on anchor-size support; rAF-track fallback across shadow scrollers

* web/elements: gate anchor CSS behind mode attribute so Firefox partial support can't fight the JS fallback

* web/elements: position search-select menu with JS in all browsers; drop unreliable CSS anchor path

* web/elements: contain search-select menu scroll and add a brief fade-in

* web/elements: let search-select menu scroll chain (drop overscroll-behavior: contain)

* web/elements: forward search-select menu overscroll to the scroll container behind it

* docs: remove ak-search-select design/plan working docs

* web/elements: keep native CSS anchor positioning for non-Firefox browsers, JS fallback for Firefox

* Add box shadow.

* Clean up linter warnings.

* Remove debug.

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Signed-off-by: Teffen Ellis <592134+GirlBossRush@users.noreply.github.com>

* Move into constant.

* Add computed value.

---------

Signed-off-by: Teffen Ellis <592134+GirlBossRush@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
Teffen Ellis
2026-07-30 18:05:36 +01:00
committed by GitHub
parent 934f56d90a
commit d88d51e74c
10 changed files with 368 additions and 225 deletions

View File

@@ -183,8 +183,9 @@ export class FormFixture extends PageFixture {
}
const button = this.page
// ---
.locator(`div[data-managed-for*="${fieldName}"] button`, {
.locator("ak-search-select-view")
.filter({ has: this.page.locator(`input[name="${fieldName}"]`) })
.locator("ak-list-select [part='ak-list-select-button']", {
hasText: pattern,
});

View File

@@ -95,7 +95,6 @@
"@codemirror/legacy-modes": "^6.5.3",
"@codemirror/theme-one-dark": "^6.1.3",
"@eslint/js": "catalog:",
"@floating-ui/dom": "^1.8.0",
"@formatjs/intl-listformat": "^8.3.12",
"@fortawesome/fontawesome-free": "^7.3.0",
"@goauthentik/api": "link:packages/client-ts",

23
web/pnpm-lock.yaml generated
View File

@@ -82,9 +82,6 @@ importers:
'@eslint/js':
specifier: 'catalog:'
version: 9.39.5
'@floating-ui/dom':
specifier: ^1.8.0
version: 1.8.0
'@formatjs/intl-listformat':
specifier: ^8.3.12
version: 8.3.12
@@ -946,15 +943,6 @@ packages:
resolution: {integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==}
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
'@floating-ui/core@1.8.0':
resolution: {integrity: sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==}
'@floating-ui/dom@1.8.0':
resolution: {integrity: sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg==}
'@floating-ui/utils@0.2.12':
resolution: {integrity: sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==}
'@formatjs/fast-memoize@3.1.7':
resolution: {integrity: sha512-zXfhLpvA6T7+efdt9JLbBwZ00tT7NsBMDVnDu8rpHeNNv8KfRZAMo2gkG0k9lK/Nzc//3kJ9pImsfuJxk3KhUA==}
@@ -7017,17 +7005,6 @@ snapshots:
'@eslint/core': 0.17.0
levn: 0.4.1
'@floating-ui/core@1.8.0':
dependencies:
'@floating-ui/utils': 0.2.12
'@floating-ui/dom@1.8.0':
dependencies:
'@floating-ui/core': 1.8.0
'@floating-ui/utils': 0.2.12
'@floating-ui/utils@0.2.12': {}
'@formatjs/fast-memoize@3.1.7': {}
'@formatjs/intl-listformat@8.3.12':

View File

@@ -52,6 +52,7 @@ export class ListSelect extends AKElement implements IListSelect {
:host {
overflow: visible;
z-index: 9999;
box-shadow: var(--pf-global--BoxShadow--md-bottom);
}
:host([hidden]) {

View File

@@ -0,0 +1,17 @@
import { isFirefox } from "#elements/utils/useragent";
export const AnchorPositionSupported: boolean =
CSS.supports("position-anchor", "--x") && CSS.supports("top", "anchor(bottom)");
/**
* Whether the browser supports the `anchor-size()` function for sizing an element
* against its anchor (e.g. `width: anchor-size(width)`).
*
* @remarks
* This is a *separate* capability from {@link AnchorPositionSupported}: Firefox
* (through at least 152) ships `position-anchor` and `anchor()` but not
* `anchor-size()`, so a consumer that sizes against its anchor must check this too
* or the sizing declaration is silently dropped.
*/
export const AnchorSizeSupported: boolean =
CSS.supports("width", "anchor-size(width)") && !isFirefox();

View File

@@ -1,151 +0,0 @@
import { randomId } from "#elements/utils/randomId";
import { findTopmost } from "#elements/utils/render-roots";
import { autoUpdate, computePosition, flip, hide } from "@floating-ui/dom";
import { LitElement, nothing } from "lit";
import { customElement, property } from "lit/decorators.js";
/**
* @class Portal
* @element ak-portal
*
* An intermediate class to handle a menu and its position.
*
* It has no rendering of its own, and mostly is just a pass-through for options to the menu.
* DOTADIW: it tracks the top-of-DOM object into which we render our menu, guaranteeing that it
* appears above everything else, and operates the positioning control for it.
*
* - @prop anchor (HTMLElement): The component which will be visually associated with the portaled popup.
* - @attr open (boolean): whether or not the component is visible
* - @attr name (string): (optional) used to managed the relationship the portal mediates.
*/
export interface IPortal {
anchor?: HTMLElement;
open: boolean;
name?: string;
}
@customElement("ak-portal")
export class Portal extends LitElement implements IPortal {
/**
* The host element which will be our reference point for rendering. Is not necessarily
* the element that receives the events.
*
* @prop
*/
@property({ type: Object, attribute: false })
anchor!: HTMLElement;
/**
* Whether or not the content is visible
*
* @attr
*/
@property({ type: Boolean, reflect: true })
open = false;
/**
* The name; used mostly for the management layer.
*
* @attr
*/
@property()
name?: string;
/**
* The tether object.
*/
dropdownContainer!: HTMLDivElement;
public cleanup?: () => void;
connected = false;
content!: Element;
connectedCallback() {
super.connectedCallback();
this.setAttribute("data-ouia-component-type", "ak-portal");
this.setAttribute("data-ouia-component-id", this.getAttribute("id") || randomId());
this.dropdownContainer = this.ownerDocument.createElement("div");
this.dropdownContainer.dataset.managedBy = "ak-portal";
if (this.name) {
this.dropdownContainer.dataset.managedFor = this.name;
}
const topmost = findTopmost(this.ownerDocument);
topmost.append(this.dropdownContainer);
if (!this.anchor) {
throw new Error("Tether entrance initialized incorrectly: missing anchor");
}
this.connected = true;
if (!this.firstElementChild) {
throw new Error("No content to be portaled included in the tag");
}
this.content = this.firstElementChild;
}
disconnectedCallback(): void {
this.connected = false;
this.dropdownContainer?.remove();
this.cleanup?.();
super.disconnectedCallback();
}
setPosition() {
if (!(this.anchor && this.dropdownContainer)) {
throw new Error("Tether initialized incorrectly: missing anchor or tether destination");
}
this.cleanup = autoUpdate(this.anchor, this.dropdownContainer, async () => {
const { x, y } = await computePosition(this.anchor, this.dropdownContainer, {
placement: "bottom-start",
strategy: "fixed",
middleware: [flip(), hide()],
});
Object.assign(this.dropdownContainer.style, {
"position": "fixed",
"display": "block",
"z-index": "9999",
"top": 0,
"left": 0,
"transform": `translate(${x}px, ${y}px)`,
});
});
}
public override performUpdate() {
this.removeAttribute("data-ouia-component-safe");
super.performUpdate();
}
render() {
this.dropdownContainer.appendChild(this.content);
// This is a dummy object that just has to exist to be the communications channel between
// the tethered object and its anchor.
return nothing;
}
updated() {
(this.content as HTMLElement).style.display = "none";
if (this.anchor && this.dropdownContainer && this.open && !this.hidden) {
(this.content as HTMLElement).style.display = "";
this.setPosition();
}
// Testing should always check if this component is open, even if it's set safe.
this.setAttribute("data-ouia-component-safe", "true");
}
}
declare global {
interface HTMLElementTagNameMap {
"ak-portal": Portal;
}
}

View File

@@ -0,0 +1,88 @@
.pf-c-select {
--pf-c-select__toggle-wrapper--MaxWidth: initial;
}
input.pf-c-select__toggle-typeahead {
anchor-name: --ak-search-select-anchor;
}
ak-list-select[popover] {
/**
* Strip the UA popover default (centered, bordered) so the menu is a bare box.
* Placement is done either with CSS anchor positioning
* (see the [data-anchor-css] rule) or imperatively in #positionMenu.
*/
position: fixed;
margin: 0;
inset: auto;
padding: 0;
border: 0;
background: transparent;
max-height: 40vh;
/**
* Host is the single scroll container (see the part rule below),
* so scroll state is readable here for wheel forwarding.
*/
overflow-y: auto;
}
/**
* Native CSS anchor positioning — used only where it is both supported and reliable
* (not Firefox; see #elements/dialogs/positioning).
*
* Tracks scrolling for free, so no per-frame JS placement is needed.
*/
:host([data-anchor-css]) ak-list-select[popover] {
position: absolute;
position-anchor: --ak-search-select-anchor;
top: anchor(bottom);
left: anchor(left);
width: anchor-size(width);
/* Flip above the input when there is no room below. */
position-try-fallbacks: flip-block;
}
/**
* The inner PatternFly dropdown is inline-block (content width);
* stretch it to fill the (input-width) host so the menu matches the input.
*/
ak-list-select[popover]::part(ak-list-select-wrapper) {
width: 100%;
}
/**
* Neutralize ak-list-select's own scroll cap so the popover host is the
* sole scroll container.
*/
ak-list-select[popover]::part(ak-list-select) {
max-height: none;
overflow: visible;
}
/**
* PatternFly menu items are white-space: nowrap; let long option labels
* wrap inside the anchor-width menu instead of overflowing.
*/
ak-list-select[popover]::part(ak-list-select-button),
ak-list-select[popover]::part(ak-list-select-label),
ak-list-select[popover]::part(ak-list-select-desc) {
white-space: normal;
overflow-wrap: anywhere;
}
/* Brief fade-in when the menu opens. */
@media (prefers-reduced-motion: no-preference) {
@keyframes ak-search-select-menu-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
ak-list-select[popover]:popover-open {
animation: ak-search-select-menu-in 120ms ease-out;
}
}

View File

@@ -1,16 +1,17 @@
import "#elements/ak-list-select/ak-list-select";
import "#elements/forms/SearchSelect/ak-portal";
import { findFlatOptions, findOptionsSubset, groupOptions, optionsToFlat } from "./utils.js";
import { ListSelect } from "#elements/ak-list-select/ak-list-select";
import { AKElement } from "#elements/Base";
import { AnchorPositionSupported, AnchorSizeSupported } from "#elements/dialogs/positioning";
import Styles from "#elements/forms/SearchSelect/ak-search-select-view.css";
import type { GroupedOptions, SelectOption, SelectOptions } from "#elements/types";
import { ifPresent } from "#elements/utils/attributes";
import { randomId } from "#elements/utils/randomId";
import { msg } from "@lit/localize";
import { css, CSSResult, html, nothing, PropertyValues } from "lit";
import { CSSResult, html, PropertyValues } from "lit";
import { customElement, property, state } from "lit/decorators.js";
import { ifDefined } from "lit/directives/if-defined.js";
import { createRef, ref, Ref } from "lit/directives/ref.js";
@@ -19,6 +20,17 @@ import PFForm from "@patternfly/patternfly/components/Form/form.css";
import PFFormControl from "@patternfly/patternfly/components/FormControl/form-control.css";
import PFSelect from "@patternfly/patternfly/components/Select/select.css";
const DEFAULT_REFOCUS_DELAY = 250;
/**
* Whether this browser can position *and* size the menu against its anchor purely
* in CSS. When true the menu uses native anchor positioning (which tracks scrolling
* for free); otherwise it is placed imperatively. `AnchorSizeSupported` already
* excludes Firefox, whose anchor positioning mis-renders inside a `<dialog>` despite
* reporting support — see {@link "#elements/dialogs/positioning"}.
*/
const CSSAnchorPositioningSupported = AnchorPositionSupported && AnchorSizeSupported;
export interface ISearchSelectView {
options: SelectOptions;
value?: string;
@@ -73,14 +85,11 @@ export interface ISearchSelectView {
@customElement("ak-search-select-view")
export class SearchSelectView extends AKElement implements ISearchSelectView {
static styles: CSSResult[] = [
// ---
PFForm,
PFFormControl,
PFSelect,
css`
.pf-c-select {
--pf-c-select__toggle-wrapper--MaxWidth: initial;
}
`,
Styles,
];
//#region Properties
@@ -217,11 +226,6 @@ export class SearchSelectView extends AKElement implements ISearchSelectView {
@state()
protected displayValue = "";
// Tracks when the inputRef is populated, so we can safely reschedule the
// render of the dropdown with respect to it.
@state()
protected inputRefIsAvailable = false;
/**
* Permanent identity with the portal so focus events can be checked.
*/
@@ -243,22 +247,42 @@ export class SearchSelectView extends AKElement implements ISearchSelectView {
//#region Lifecycle
public override updated() {
this.#syncMenuVisibility();
this.setAttribute("data-ouia-component-safe", "true");
}
public override firstUpdated(changed: PropertyValues<this>) {
super.firstUpdated(changed);
/**
* Reconcile the popover's actual open state with `this.open`.
* Called from `updated()` so it runs after the menu has rendered.
*/
#syncMenuVisibility() {
const menu = this.#menuRef.value;
if (!menu) return;
// Route around Lit's scheduling algorithm complaining about re-renders
requestAnimationFrame(() => {
this.inputRefIsAvailable = Boolean(this.#inputRef?.value);
});
const popoverOpen = menu.matches(":popover-open");
if (this.open && !this.readOnly && !popoverOpen) {
menu.showPopover();
// Start tracking synchronously (not via the async `toggle` event) so the
// fallback places the menu in the same frame it becomes visible — no flash
// at the UA default position.
this.#startAnchorTracking();
} else if ((!this.open || this.readOnly) && popoverOpen) {
menu.hidePopover();
}
}
connectedCallback() {
super.connectedCallback();
this.setAttribute("data-ouia-component-type", "ak-search-select-view");
this.setAttribute("data-ouia-component-id", this.getAttribute("id") || randomId());
// Styling hook: opt this instance into the CSS anchor-positioning block.
this.toggleAttribute("data-anchor-css", CSSAnchorPositioningSupported);
}
disconnectedCallback() {
this.#stopAnchorTracking();
super.disconnectedCallback();
}
// TODO: Reconcile value <-> display value, Reconcile option changes to value <-> displayValue
@@ -273,13 +297,210 @@ export class SearchSelectView extends AKElement implements ISearchSelectView {
//#region Event Listeners
#clickListener = (_ev: Event) => {
/** Timestamp of the last browser-driven popover close (see reopen guard). */
#lastLightDismiss = -Infinity;
#clickListener = (event: Event) => {
if (this.readOnly) return;
this.open = !this.open;
const computedRefocusDelay = getComputedStyle(this)?.getPropertyValue(
"--ak-search-select--RefocusDelay",
);
const refocusDelay = parseInt(computedRefocusDelay, 10) || DEFAULT_REFOCUS_DELAY;
// If this same click just light-dismissed the open popover, treat it as a
// close: leave `open` false instead of toggling it back on.
const dismissedByThisClick = event.timeStamp - this.#lastLightDismiss < refocusDelay;
this.open = dismissedByThisClick ? false : !this.open;
this.#inputRef.value?.focus();
};
/**
* Reflect browser-driven popover state changes (light dismiss, Esc) back
* into `this.open`, keeping component state authoritative.
*/
#menuToggleListener = (event: ToggleEvent) => {
// Opening is handled synchronously in #syncMenuVisibility; here we only
// react to closes (including browser-driven light dismiss / Esc).
if (event.newState === "open") return;
this.#stopAnchorTracking();
// Record when the browser closes the popover so a click on the input
// that *caused* the dismiss doesn't immediately reopen it.
this.#lastLightDismiss = event.timeStamp;
if (this.open) {
this.open = false;
}
};
/**
* Forward wheel scrolling to the input's nearest scrollable ancestor once the
* menu itself can't scroll any further in that direction. The menu is a
* top-layer, fixed-position popover, so the browser chains its overscroll to the
* viewport rather than to the (e.g. modal dialog) container behind it — meaning
* scrolling over the menu would otherwise appear stuck.
*/
#menuWheelListener = (event: WheelEvent) => {
const menu = this.#menuRef.value;
if (!menu) return;
const goingDown = event.deltaY > 0;
const menuCanScroll = goingDown
? Math.ceil(menu.scrollTop + menu.clientHeight) < menu.scrollHeight
: menu.scrollTop > 0;
if (menuCanScroll) return;
const scroller = this.#findScrollableAncestor();
if (!scroller) return;
const deltaY = (() => {
switch (event.deltaMode) {
case WheelEvent.DOM_DELTA_LINE: {
const lh = parseFloat(getComputedStyle(scroller).lineHeight);
return event.deltaY * (Number.isFinite(lh) ? lh : 16);
}
case WheelEvent.DOM_DELTA_PAGE:
return event.deltaY * scroller.clientHeight;
default:
return event.deltaY;
}
})();
scroller.scrollTop += deltaY;
event.preventDefault();
};
/**
* Walk the flattened (composed) tree upward from this element — crossing shadow
* boundaries and slots — to the nearest vertically scrollable ancestor.
*/
#findScrollableAncestor(): HTMLElement | null {
const composedParent = (node: Node): Node | null => {
const slot = (node as Element).assignedSlot;
if (slot) return slot;
const parent = node.parentNode;
return parent instanceof ShadowRoot ? parent.host : parent;
};
for (let node = composedParent(this); node; node = composedParent(node)) {
if (!(node instanceof HTMLElement)) continue;
const { overflowY } = getComputedStyle(node);
const scrollable =
overflowY === "auto" || overflowY === "scroll" || overflowY === "overlay";
if (scrollable && node.scrollHeight > node.clientHeight) {
return node;
}
}
return null;
}
#anchorObserver?: IntersectionObserver;
#startAnchorTracking() {
const input = this.#inputRef.value;
const menu = this.#menuRef.value;
if (!input || !menu) return;
// Close the menu when its anchor input is no longer visible — scrolled out
// of the viewport, clipped away by a scroll container, or hidden. This works
// in every browser regardless of anchor-positioning support.
this.#anchorObserver?.disconnect();
this.#anchorObserver = new IntersectionObserver(
(entries) => {
if (entries.some((entry) => !entry.isIntersecting)) {
this.open = false;
}
},
{ threshold: 0 },
);
this.#anchorObserver.observe(input);
// Native CSS anchor positioning handles placement and tracks scrolling on its
// own — nothing else to do.
if (CSSAnchorPositioningSupported) return;
// Otherwise position the menu imperatively and keep it in sync. We can't rely
// on a global scroll listener: `scroll` events are `composed: false`, so
// scrolling inside a shadow-rendered container (e.g. a modal dialog body)
// never reaches `window`. Instead we re-place the menu each animation frame
// while open, which also covers nested scrollers, layout shifts, and resizes.
let lastGeometry = "";
const reflow = () => {
const rect = this.#inputRef.value?.getBoundingClientRect();
if (rect) {
const geometry = `${rect.left},${rect.top},${rect.bottom},${rect.width},${window.innerHeight}`;
if (geometry !== lastGeometry) {
lastGeometry = geometry;
this.#positionMenu();
}
}
this.#reflowFrame = requestAnimationFrame(reflow);
};
this.#positionMenu();
this.#reflowFrame = requestAnimationFrame(reflow);
}
#reflowFrame?: number;
#stopAnchorTracking() {
this.#anchorObserver?.disconnect();
this.#anchorObserver = undefined;
if (this.#reflowFrame !== undefined) {
cancelAnimationFrame(this.#reflowFrame);
this.#reflowFrame = undefined;
}
}
/**
* Position the menu against the input imperatively, matching the CSS
* anchor-positioning behavior (below by default, flip above when there's no
* room, width matched to the input, capped height). Only used where CSS anchor
* positioning is unavailable.
*/
#positionMenu() {
const input = this.#inputRef.value;
const menu = this.#menuRef.value;
if (!input || !menu) return;
const rect = input.getBoundingClientRect();
const viewportHeight = window.innerHeight;
const maxHeight = Math.round(viewportHeight * 0.4);
const menuHeight = Math.min(menu.offsetHeight || maxHeight, maxHeight);
const spaceBelow = viewportHeight - rect.bottom;
const flipUp = spaceBelow < menuHeight && rect.top > spaceBelow;
menu.style.position = "fixed";
menu.style.left = `${Math.round(rect.left)}px`;
menu.style.width = `${Math.round(rect.width)}px`;
menu.style.maxHeight = `${maxHeight}px`;
if (flipUp) {
menu.style.top = "auto";
menu.style.bottom = `${Math.round(viewportHeight - rect.top)}px`;
} else {
menu.style.bottom = "auto";
menu.style.top = `${Math.round(rect.bottom)}px`;
}
}
setFromMatchList(value?: string) {
if (!value) return;
@@ -474,8 +695,6 @@ export class SearchSelectView extends AKElement implements ISearchSelectView {
//#region Render
public override render() {
const { open } = this;
const emptyOption = this.blankable
? this.emptyOption || this.placeholder || msg("Select an option...")
: null;
@@ -506,29 +725,22 @@ export class SearchSelectView extends AKElement implements ISearchSelectView {
</div>
</div>
</div>
${this.inputRefIsAvailable
? html`
<ak-portal
name=${ifDefined(this.name)}
.anchor=${this.#inputRef.value}
?open=${open}
>
<ak-list-select
popover="auto"
id="menu-${this.getAttribute("data-ouia-component-id")}"
${ref(this.#menuRef)}
.options=${this.managedOptions}
value=${ifDefined(this.value)}
@change=${this.#changeListener}
@blur=${this.#blurListener}
@toggle=${this.#menuToggleListener}
@wheel=${this.#menuWheelListener}
emptyOption=${ifPresent(emptyOption)}
actionLabel=${ifPresent(this.actionLabel)}
@ak-select-action=${this.#actionListener}
@keydown=${this.#listKeydownListener}
@keyup=${this.#listKeyupListener}
></ak-list-select>
</ak-portal>
`
: nothing}`;
></ak-list-select>`;
}
//#endregion

View File

@@ -5,6 +5,7 @@ import "#elements/Divider";
import { truncateWords } from "#common/strings";
import { AnchorPositionSupported } from "#elements/dialogs/positioning";
import { LitFC } from "#elements/types";
import { Application } from "@goauthentik/api";
@@ -15,8 +16,6 @@ import type { HTMLAttributes } from "react";
import { msg, str } from "@lit/localize";
import { html } from "lit";
export const AnchorPositionSupported = CSS.supports("position-anchor", "--test");
export interface CardMenuProps extends HTMLAttributes<HTMLDivElement> {
cardID: string;
descriptionID: string;

View File

@@ -3,11 +3,11 @@ import { type AppGroupEntry, ViewMode } from "./types.js";
import { LayoutType } from "#common/ui/config";
import { AnchorPositionSupported } from "#elements/dialogs/positioning";
import { ApplicationRoute } from "#elements/router/builders";
import { LitFC } from "#elements/types";
import { ifPresent } from "#elements/utils/attributes";
import { AnchorPositionSupported } from "#user/LibraryApplication/CardMenu";
import { AKLibraryApp } from "#user/LibraryApplication/index";
import { Application } from "@goauthentik/api";