Skip to content

Commit c4d4bde

Browse files
fix(#3540): add manual positioning to Popover when the browser does not support CSS anchor positioning
1 parent e9e2866 commit c4d4bde

3 files changed

Lines changed: 105 additions & 22 deletions

File tree

libs/react-components/specs/app-header-menu.browser.spec.tsx

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -53,10 +53,13 @@ describe("AppHeaderMenu", () => {
5353

5454
// Close menu with Escape
5555
await userEvent.keyboard("{Escape}");
56-
await vi.waitFor(() => {
57-
const popoverContent = result.getByTestId("popover-content");
58-
expect(popoverContent.element().checkVisibility()).toBeFalsy();
59-
});
56+
await vi.waitFor(
57+
() => {
58+
const popoverContent = result.getByTestId("popover-content");
59+
expect(popoverContent.element().checkVisibility()).toBeFalsy();
60+
},
61+
{ timeout: 10000 },
62+
);
6063

6164
// 2. Dismiss the notification banner
6265
const banner = result.getByTestId("test-banner");

libs/react-components/specs/popover.browser.spec.tsx

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -55,9 +55,12 @@ describe("Popover", () => {
5555
// Press Escape to close (native popover light dismiss)
5656
await userEvent.keyboard("{Escape}");
5757

58-
await vi.waitFor(() => {
59-
expect(closeButton).not.toBeVisible();
60-
});
58+
await vi.waitFor(
59+
() => {
60+
expect(closeButton).not.toBeVisible();
61+
},
62+
{ timeout: 10000 },
63+
);
6164
});
6265

6366
it("should return focus to trigger after closing with Escape - issue3067", async () => {
@@ -89,9 +92,12 @@ describe("Popover", () => {
8992
});
9093

9194
// Focus should be on the popover target button
92-
await vi.waitFor(() => {
93-
expect(popoverTarget.element().matches(":focus-within")).toBeTruthy();
94-
});
95+
await vi.waitFor(
96+
() => {
97+
expect(popoverTarget.element().matches(":focus-within")).toBeTruthy();
98+
},
99+
{ timeout: 10000 },
100+
);
95101
});
96102

97103
it("should close Popover A when Popover B is opened (popover='auto' behavior)", async () => {

libs/web-components/src/components/popover/Popover.svelte

Lines changed: 86 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,10 @@
6767
// Private
6868
let _rootEl: HTMLElement;
6969
let _popoverEl: HTMLElement;
70+
const _needsManualPositioning =
71+
typeof document !== "undefined" &&
72+
!("anchorName" in document.documentElement.style);
73+
let _positionRafId: number | null = null;
7074
7175
// Reactive
7276
let _targetEl: HTMLElement;
@@ -122,13 +126,65 @@
122126
});
123127
124128
onDestroy(() => {
129+
stopManualPositioning();
125130
window.removeEventListener("resize", updateAutoPosition);
126131
// true was passed when the listener was added, so it's necesary to be passed here as well
127132
window.removeEventListener("popstate", handleUrlChange, true);
128133
});
129134
130135
// Functions
131136
137+
function updatePopoverPosition() {
138+
if (!_isOpen || !_targetEl || !_popoverEl) return;
139+
140+
const targetRect = _targetEl.getBoundingClientRect();
141+
const xOffset = hoffset ? parseFloat(hoffset) : 0;
142+
const yOffset = voffset ? parseFloat(voffset) : 3;
143+
144+
// Recalculate auto position based on current viewport space
145+
if (position === "auto") {
146+
const popoverRect = _popoverEl.getBoundingClientRect();
147+
const spaceAbove = targetRect.top;
148+
const spaceBelow = window.innerHeight - targetRect.bottom;
149+
150+
_autoPosition =
151+
spaceBelow < popoverRect.height && spaceAbove > spaceBelow
152+
? "above"
153+
: "below";
154+
}
155+
156+
const isAbove =
157+
position === "above" ||
158+
(position === "auto" && _autoPosition === "above");
159+
160+
if (isAbove) {
161+
_popoverEl.style.top = `${targetRect.top - yOffset}px`;
162+
_popoverEl.style.left = `${targetRect.left + xOffset}px`;
163+
_popoverEl.style.transform = "translateY(-100%)";
164+
} else {
165+
_popoverEl.style.top = `${targetRect.bottom + yOffset}px`;
166+
_popoverEl.style.left = `${targetRect.left + xOffset}px`;
167+
_popoverEl.style.transform = "";
168+
}
169+
}
170+
171+
function startManualPositioning() {
172+
if (!_needsManualPositioning) return;
173+
174+
const loop = () => {
175+
updatePopoverPosition();
176+
_positionRafId = requestAnimationFrame(loop);
177+
};
178+
_positionRafId = requestAnimationFrame(loop);
179+
}
180+
181+
function stopManualPositioning() {
182+
if (_positionRafId !== null) {
183+
cancelAnimationFrame(_positionRafId);
184+
_positionRafId = null;
185+
}
186+
}
187+
132188
function isPopoverOpen(): boolean {
133189
try {
134190
return _popoverEl.matches(":popover-open");
@@ -188,8 +244,7 @@
188244
}
189245
}
190246
191-
function handleNativeToggle(e: Event) {
192-
const toggleEvent = e as Event & { newState?: "open" | "closed" };
247+
function handleNativeToggle(toggleEvent: ToggleEvent) {
193248
if (toggleEvent.newState === "open") {
194249
_isOpen = true;
195250
} else if (toggleEvent.newState === "closed") {
@@ -203,17 +258,28 @@
203258
// Dispatch _open/_close events for consumer components
204259
// (MenuButton, AppHeader, AppHeaderMenu, Dropdown)
205260
if (_isOpen) {
206-
dispatch(_rootEl, "_open", {}, { bubbles: true });
261+
dispatch(_rootEl, "_open");
207262
requestAnimationFrame(updateAutoPosition); // same vs await tick(), make sure popover element is fully rendered before we measure its dimension
263+
startManualPositioning();
208264
} else {
209-
_targetEl.focus();
210-
dispatch(_rootEl, "_close", {}, { bubbles: true });
265+
stopManualPositioning();
266+
_targetEl?.focus();
267+
dispatch(_rootEl, "_close");
211268
}
212269
}
213270
214271
function closePopover() {
215272
if (_isOpen) {
216273
_popoverEl?.hidePopover(); // browser will fire and trigger handleNativeToggle
274+
// If the browser doesn't support the API we have to trigger the toggle event manually.
275+
if (_needsManualPositioning) {
276+
const event = new ToggleEvent("toggle", {
277+
bubbles: true,
278+
newState: "closed",
279+
oldState: "open",
280+
});
281+
handleNativeToggle(event); // in case the browser doesn't fire toggle event, we need to manually update the state
282+
}
217283
}
218284
}
219285
@@ -227,18 +293,24 @@
227293
_popoverEl.hidePopover();
228294
_isOpen = false;
229295
} else {
296+
// If the Popover API is not supported, we need to manually close other
297+
// popovers before opening a new one.
298+
if (_needsManualPositioning) {
299+
document.body.dispatchEvent(
300+
new CustomEvent("goa:closePopover", {
301+
detail: { target: _targetEl },
302+
bubbles: true,
303+
}),
304+
);
305+
}
230306
_popoverEl.showPopover();
231307
_isOpen = true;
232308
requestAnimationFrame(updateAutoPosition);
233309
}
234310
}
235311
236312
function updateAutoPosition() {
237-
if (!_isOpen || !_targetEl || !_popoverEl) {
238-
return;
239-
}
240-
241-
if (position !== "auto") {
313+
if (position !== "auto" || !_isOpen || !_targetEl || !_popoverEl) {
242314
return;
243315
}
244316
@@ -301,7 +373,10 @@
301373
style={styles(
302374
style("width", position !== "right" ? width : undefined),
303375
style("min-width", minwidth),
304-
style("max-width", position !== "right" && width ? `max(${width}, ${maxwidth})` : maxwidth),
376+
style(
377+
"max-width",
378+
position !== "right" && width ? `max(${width}, ${maxwidth})` : maxwidth,
379+
),
305380
style("padding", _padded ? "var(--goa-space-m)" : "0"),
306381
)}
307382
>
@@ -355,7 +430,6 @@
355430
filter: var(--goa-popover-shadow, none);
356431
border: var(--goa-popover-border, none);
357432
margin: 0;
358-
359433
position-anchor: --goa-popover-target;
360434
inset-block-start: anchor(bottom);
361435
inset-inline-start: anchor(left);

0 commit comments

Comments
 (0)