mirror of
https://github.com/goauthentik/authentik.git
synced 2026-08-30 18:51:39 -07:00
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:
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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
23
web/pnpm-lock.yaml
generated
@@ -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':
|
||||
|
||||
@@ -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]) {
|
||||
|
||||
17
web/src/elements/dialogs/positioning.ts
Normal file
17
web/src/elements/dialogs/positioning.ts
Normal 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();
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user