Skip to content

Commit a65ea81

Browse files
dominic-rclaude
authored andcommitted
web: keep the search-select dropdown inside its modal (#24812)
* web: keep search-select popover inside its dialog and dedupe file upload entry points Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * web: prevent focus auto-scroll from shifting the search-select anchor while placing the menu Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * web: always place the search-select menu below its anchor Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * web: add back --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent e3ddcb9 commit a65ea81

4 files changed

Lines changed: 131 additions & 115 deletions

File tree

web/src/admin/files/FileListPage.ts

Lines changed: 1 addition & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ import { docLink } from "#common/global";
1010

1111
import { ModalInvokerButton } from "#elements/dialogs";
1212
import { WithCapabilitiesConfig } from "#elements/mixins/capabilities";
13-
import { getURLParam } from "#elements/router/RouteMatch";
1413
import { PaginatedResponse, TableColumn } from "#elements/table/Table";
1514
import { TablePage } from "#elements/table/TablePage";
1615
import { SlottedTemplateResult } from "#elements/types";
@@ -20,7 +19,7 @@ import { FileUploadForm } from "#admin/files/FileUploadForm";
2019
import { AdminApi, CapabilitiesEnum, FileList, UsageEnum } from "@goauthentik/api";
2120

2221
import { msg } from "@lit/localize";
23-
import { html, PropertyValues, TemplateResult } from "lit";
22+
import { html, TemplateResult } from "lit";
2423
import { customElement, property } from "lit/decorators.js";
2524

2625
export type FileListItem = Pick<FileList, "name" | "url" | "mimeType">;
@@ -41,14 +40,6 @@ export class FileListPage extends WithCapabilitiesConfig(TablePage<FileListItem>
4140
@property({ type: String, useDefault: true })
4241
public order: FileListOrderKey = "name";
4342

44-
public override firstUpdated(changed: PropertyValues<this>): void {
45-
super.firstUpdated(changed);
46-
47-
if (getURLParam("upload", false) && this.can(CapabilitiesEnum.CanSaveMedia)) {
48-
FileUploadForm.showModal();
49-
}
50-
}
51-
5243
async apiEndpoint(): Promise<PaginatedResponse<FileListItem>> {
5344
const api = aki(AdminApi);
5445
const items = await api.adminFileList({

web/src/elements/dialogs/positioning.ts

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,115 @@ export const AnchorPositionSupported: boolean =
1515
*/
1616
export const AnchorSizeSupported: boolean =
1717
CSS.supports("width", "anchor-size(width)") && !isFirefox();
18+
19+
/**
20+
* Tallest an anchored popover may grow, as a fraction of the viewport.
21+
*/
22+
const MAX_POPOVER_VIEWPORT_RATIO = 0.4;
23+
24+
/**
25+
* Breathing room kept between an anchored popover and the edge of its boundary.
26+
*/
27+
const BOUNDARY_INSET = 8;
28+
29+
/**
30+
* Shortest popover worth rendering. Inside a boundary too small for even this, the popover
31+
* overhangs rather than collapsing into an unusable sliver.
32+
*/
33+
const MIN_POPOVER_HEIGHT = 128;
34+
35+
/**
36+
* Walk the flattened (composed) tree upward from `node`, crossing shadow boundaries and
37+
* slots.
38+
*/
39+
export function* composedAncestors(node: Node): Generator<HTMLElement> {
40+
const composedParent = (current: Node): Node | null => {
41+
const slot = (current as Element).assignedSlot;
42+
if (slot) return slot;
43+
44+
const parent = current.parentNode;
45+
return parent instanceof ShadowRoot ? parent.host : parent;
46+
};
47+
48+
for (let current = composedParent(node); current; current = composedParent(current)) {
49+
if (current instanceof HTMLElement) yield current;
50+
}
51+
}
52+
53+
function scrollableY(element: HTMLElement): boolean {
54+
const { overflowY } = getComputedStyle(element);
55+
56+
return overflowY === "auto" || overflowY === "scroll" || overflowY === "overlay";
57+
}
58+
59+
/**
60+
* The nearest vertically scrollable ancestor of `node`, crossing shadow boundaries.
61+
*/
62+
export function findScrollableAncestor(node: Node): HTMLElement | null {
63+
for (const ancestor of composedAncestors(node)) {
64+
if (scrollableY(ancestor) && ancestor.scrollHeight > ancestor.clientHeight) {
65+
return ancestor;
66+
}
67+
}
68+
69+
return null;
70+
}
71+
72+
/**
73+
* The vertical band an anchored popover has to stay within: the dialog containing its
74+
* anchor, or the viewport when the anchor isn't in one.
75+
*
76+
* @remarks
77+
* A popover renders in the top layer, positioned against the viewport, so nothing clips it
78+
* to the dialog it belongs to — without this it overhangs the dialog's edges and paints
79+
* over the backdrop.
80+
*/
81+
export function popoverBoundaryBand(anchor: Node): { top: number; bottom: number } {
82+
const viewportHeight = window.innerHeight;
83+
84+
for (const ancestor of composedAncestors(anchor)) {
85+
if (!(ancestor instanceof HTMLDialogElement)) continue;
86+
87+
const rect = ancestor.getBoundingClientRect();
88+
89+
return {
90+
top: Math.max(0, rect.top),
91+
bottom: Math.min(viewportHeight, rect.bottom),
92+
};
93+
}
94+
95+
return { top: 0, bottom: viewportHeight };
96+
}
97+
98+
export interface AnchoredPopoverPlacementOptions {
99+
/**
100+
* Size the popover to its anchor's width. Menus that belong to a text input want this;
101+
* free-standing menus size themselves.
102+
*/
103+
matchAnchorWidth?: boolean;
104+
}
105+
106+
/**
107+
* Place a top-layer popover directly under its anchor, never taller than the space its
108+
* boundary leaves below the anchor.
109+
*/
110+
export function placeAnchoredPopover(
111+
anchor: HTMLElement,
112+
popover: HTMLElement,
113+
{ matchAnchorWidth }: AnchoredPopoverPlacementOptions = {},
114+
): void {
115+
const rect = anchor.getBoundingClientRect();
116+
const bounds = popoverBoundaryBand(anchor);
117+
118+
const ceiling = Math.round(window.innerHeight * MAX_POPOVER_VIEWPORT_RATIO);
119+
const spaceBelow = Math.max(bounds.bottom - rect.bottom - BOUNDARY_INSET, MIN_POPOVER_HEIGHT);
120+
121+
popover.style.position = "fixed";
122+
popover.style.left = `${Math.round(rect.left)}px`;
123+
popover.style.top = `${Math.round(rect.bottom)}px`;
124+
popover.style.maxHeight = `${Math.round(Math.min(ceiling, spaceBelow))}px`;
125+
126+
if (matchAnchorWidth) {
127+
popover.style.width = `${Math.round(rect.width)}px`;
128+
}
129+
}

web/src/elements/forms/SearchSelect/SearchSelectMenuController.ts

Lines changed: 16 additions & 82 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,10 @@
11
import type { ListSelect } from "#elements/ak-list-select/ak-list-select";
2-
import { AnchorPositionSupported, AnchorSizeSupported } from "#elements/dialogs/positioning";
2+
import { findScrollableAncestor, placeAnchoredPopover } from "#elements/dialogs/positioning";
33

44
import type { ReactiveController, ReactiveControllerHost } from "lit";
55

66
const DEFAULT_REFOCUS_DELAY = 250;
77

8-
/**
9-
* Firefox reports support for anchor positioning but mis-renders anchored elements inside
10-
* dialogs, so use the shared capability checks rather than CSS.supports directly.
11-
*/
12-
const CSSAnchorPositioningSupported = AnchorPositionSupported && AnchorSizeSupported;
13-
148
interface SearchSelectMenuHost extends ReactiveControllerHost, HTMLElement {
159
open: boolean;
1610
readOnly: boolean;
@@ -33,11 +27,6 @@ export class SearchSelectMenuController implements ReactiveController {
3327
host.addController(this);
3428
}
3529

36-
public hostConnected() {
37-
// Styling hook: opt this instance into the CSS anchor-positioning block.
38-
this.host.toggleAttribute("data-anchor-css", CSSAnchorPositioningSupported);
39-
}
40-
4130
/**
4231
* Reconcile the popover's actual open state with the host's `open` state.
4332
* Called after the host updates so the menu has rendered.
@@ -54,9 +43,9 @@ export class SearchSelectMenuController implements ReactiveController {
5443

5544
if (this.host.open && !this.host.readOnly && !popoverOpen) {
5645
menu.showPopover();
57-
// Start tracking synchronously (not via the async `toggle` event) so the
58-
// fallback places the menu in the same frame it becomes visible — no flash
59-
// at the UA default position.
46+
// Start tracking synchronously (not via the async `toggle` event) so the menu
47+
// is placed in the same frame it becomes visible — no flash at the UA default
48+
// position.
6049
this.#startTracking();
6150
} else if ((!this.host.open || this.host.readOnly) && popoverOpen) {
6251
menu.hidePopover();
@@ -77,7 +66,9 @@ export class SearchSelectMenuController implements ReactiveController {
7766
const dismissedByThisClick = event.timeStamp - this.#lastLightDismiss < refocusDelay;
7867

7968
this.host.open = dismissedByThisClick ? false : !this.host.open;
80-
this.getInput()?.focus();
69+
// preventScroll: an auto-scroll here would shift the anchor in the same beat the
70+
// menu is being placed against it.
71+
this.getInput()?.focus({ preventScroll: true });
8172
};
8273

8374
public readonly handleMenuToggle = (event: ToggleEvent) => {
@@ -114,7 +105,7 @@ export class SearchSelectMenuController implements ReactiveController {
114105

115106
if (menuCanScroll) return;
116107

117-
const scroller = this.#findScrollableAncestor();
108+
const scroller = findScrollableAncestor(this.host);
118109
if (!scroller) return;
119110

120111
const deltaY = (() => {
@@ -134,42 +125,13 @@ export class SearchSelectMenuController implements ReactiveController {
134125
event.preventDefault();
135126
};
136127

137-
/**
138-
* Walk the flattened (composed) tree upward from the host — crossing shadow
139-
* boundaries and slots — to the nearest vertically scrollable ancestor.
140-
*/
141-
#findScrollableAncestor(): HTMLElement | null {
142-
const composedParent = (node: Node): Node | null => {
143-
const slot = (node as Element).assignedSlot;
144-
if (slot) return slot;
145-
146-
const parent = node.parentNode;
147-
return parent instanceof ShadowRoot ? parent.host : parent;
148-
};
149-
150-
for (let node = composedParent(this.host); node; node = composedParent(node)) {
151-
if (!(node instanceof HTMLElement)) continue;
152-
153-
const { overflowY } = getComputedStyle(node);
154-
const scrollable =
155-
overflowY === "auto" || overflowY === "scroll" || overflowY === "overlay";
156-
157-
if (scrollable && node.scrollHeight > node.clientHeight) {
158-
return node;
159-
}
160-
}
161-
162-
return null;
163-
}
164-
165128
#startTracking() {
166129
const input = this.getInput();
167130
const menu = this.getMenu();
168131
if (!input || !menu) return;
169132

170133
// Close the menu when its anchor input is no longer visible — scrolled out
171-
// of the viewport, clipped away by a scroll container, or hidden. This works
172-
// in every browser regardless of anchor-positioning support.
134+
// of the viewport, clipped away by a scroll container, or hidden.
173135
this.#anchorObserver?.disconnect();
174136
this.#anchorObserver = new IntersectionObserver(
175137
(entries) => {
@@ -181,21 +143,17 @@ export class SearchSelectMenuController implements ReactiveController {
181143
);
182144
this.#anchorObserver.observe(input);
183145

184-
// Native CSS anchor positioning handles placement and tracks scrolling on its
185-
// own — nothing else to do.
186-
if (CSSAnchorPositioningSupported) return;
187-
188-
// Otherwise position the menu imperatively and keep it in sync. We can't rely
189-
// on a global scroll listener: `scroll` events are `composed: false`, so
190-
// scrolling inside a shadow-rendered container (e.g. a modal dialog body)
191-
// never reaches `window`. Instead we re-place the menu each animation frame
192-
// while open, which also covers nested scrollers, layout shifts, and resizes.
146+
// Position the menu imperatively and keep it in sync. We can't rely on a global
147+
// scroll listener: `scroll` events are `composed: false`, so scrolling inside a
148+
// shadow-rendered container (e.g. a modal dialog body) never reaches `window`.
149+
// Instead we re-place the menu each animation frame while open, which also covers
150+
// nested scrollers, layout shifts, resizes, and options arriving late.
193151
let lastGeometry = "";
194152
const reflow = () => {
195153
const rect = this.getInput()?.getBoundingClientRect();
196154

197155
if (rect) {
198-
const geometry = `${rect.left},${rect.top},${rect.bottom},${rect.width},${window.innerHeight}`;
156+
const geometry = `${rect.left},${rect.top},${rect.bottom},${rect.width},${window.innerHeight},${menu.scrollHeight}`;
199157

200158
if (geometry !== lastGeometry) {
201159
lastGeometry = geometry;
@@ -220,35 +178,11 @@ export class SearchSelectMenuController implements ReactiveController {
220178
}
221179
}
222180

223-
/**
224-
* Position the menu against the input imperatively, matching the CSS
225-
* anchor-positioning behavior (below by default, flip above when there's no
226-
* room, width matched to the input, capped height). Only used where CSS anchor
227-
* positioning is unavailable.
228-
*/
229181
#positionMenu() {
230182
const input = this.getInput();
231183
const menu = this.getMenu();
232184
if (!input || !menu) return;
233185

234-
const rect = input.getBoundingClientRect();
235-
const viewportHeight = window.innerHeight;
236-
const maxHeight = Math.round(viewportHeight * 0.4);
237-
const menuHeight = Math.min(menu.offsetHeight || maxHeight, maxHeight);
238-
const spaceBelow = viewportHeight - rect.bottom;
239-
const flipUp = spaceBelow < menuHeight && rect.top > spaceBelow;
240-
241-
menu.style.position = "fixed";
242-
menu.style.left = `${Math.round(rect.left)}px`;
243-
menu.style.width = `${Math.round(rect.width)}px`;
244-
menu.style.maxHeight = `${maxHeight}px`;
245-
246-
if (flipUp) {
247-
menu.style.top = "auto";
248-
menu.style.bottom = `${Math.round(viewportHeight - rect.top)}px`;
249-
} else {
250-
menu.style.bottom = "auto";
251-
menu.style.top = `${Math.round(rect.bottom)}px`;
252-
}
186+
placeAnchoredPopover(input, menu, { matchAnchorWidth: true });
253187
}
254188
}

web/src/elements/forms/SearchSelect/ak-search-select-view.css

Lines changed: 2 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,11 @@
22
--pf-c-select__toggle-wrapper--MaxWidth: initial;
33
}
44

5-
input.pf-c-select__toggle-typeahead {
6-
anchor-name: --ak-search-select-anchor;
7-
}
8-
95
ak-list-select[popover] {
106
/**
117
* Strip the UA popover default (centered, bordered) so the menu is a bare box.
12-
* Placement is done either with CSS anchor positioning
13-
* (see the [data-anchor-css] rule) or imperatively in #positionMenu.
8+
* Placement and height come from placeAnchoredPopover, which — unlike CSS anchor
9+
* positioning — can hold the menu inside the dialog it was opened from.
1410
*/
1511
position: fixed;
1612
margin: 0;
@@ -26,23 +22,6 @@ ak-list-select[popover] {
2622
overflow-y: auto;
2723
}
2824

29-
/**
30-
* Native CSS anchor positioning — used only where it is both supported and reliable
31-
* (not Firefox; see #elements/dialogs/positioning).
32-
*
33-
* Tracks scrolling for free, so no per-frame JS placement is needed.
34-
*/
35-
:host([data-anchor-css]) ak-list-select[popover] {
36-
position: absolute;
37-
position-anchor: --ak-search-select-anchor;
38-
top: anchor(bottom);
39-
left: anchor(left);
40-
width: anchor-size(width);
41-
42-
/* Flip above the input when there is no room below. */
43-
position-try-fallbacks: flip-block;
44-
}
45-
4625
/**
4726
* The inner PatternFly dropdown is inline-block (content width);
4827
* stretch it to fill the (input-width) host so the menu matches the input.

0 commit comments

Comments
 (0)