Skip to content

Commit 70c8e22

Browse files
authored
fix(popover): Positioning in sticky containers (#2256)
Closes #2255
1 parent 5fe70eb commit 70c8e22

3 files changed

Lines changed: 113 additions & 18 deletions

File tree

src/components/popover/popover.spec.ts

Lines changed: 61 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -171,7 +171,9 @@ describe('Popover', () => {
171171
describe('With initial open state', () => {
172172
beforeEach(async () => {
173173
const root = await fixture<HTMLElement>(createNonSlottedPopover(true));
174-
popover = root.querySelector('igc-popover') as IgcPopoverComponent;
174+
popover = root.querySelector(
175+
IgcPopoverComponent.tagName
176+
) as IgcPopoverComponent;
175177
anchor = root.querySelector('#btn') as HTMLButtonElement;
176178
});
177179

@@ -199,7 +201,9 @@ describe('Popover', () => {
199201
describe('With initial closed state', () => {
200202
beforeEach(async () => {
201203
const root = await fixture<HTMLElement>(createNonSlottedPopover());
202-
popover = root.querySelector('igc-popover') as IgcPopoverComponent;
204+
popover = root.querySelector(
205+
IgcPopoverComponent.tagName
206+
) as IgcPopoverComponent;
203207
anchor = root.querySelector('#btn') as HTMLButtonElement;
204208
});
205209

@@ -279,4 +283,59 @@ describe('Popover', () => {
279283
});
280284
});
281285
});
286+
287+
describe('Positioning strategy', () => {
288+
function createStickyPopover(level: 'parent' | 'grandparent') {
289+
const popover = html`
290+
<igc-popover open anchor="btn">
291+
<p style="border: 1px solid #ccc">Message</p>
292+
</igc-popover>
293+
`;
294+
295+
const inner = html`
296+
<button id="btn" type="button">Show message</button>
297+
${popover}
298+
`;
299+
300+
return level === 'parent'
301+
? html`<div style="position: sticky; top: 0">${inner}</div>`
302+
: html`
303+
<div style="position: sticky; top: 0">
304+
<div>${inner}</div>
305+
</div>
306+
`;
307+
}
308+
309+
it('uses the `fixed` strategy with a directly sticky ancestor', async () => {
310+
const root = await fixture<HTMLElement>(createStickyPopover('parent'));
311+
const popover = root.querySelector(
312+
IgcPopoverComponent.tagName
313+
) as IgcPopoverComponent;
314+
await waitForPaint(popover);
315+
316+
expect(getFloater(popover).style.position).to.equal('fixed');
317+
});
318+
319+
it('uses the `fixed` strategy with a non-direct sticky ancestor', async () => {
320+
const root = await fixture<HTMLElement>(
321+
createStickyPopover('grandparent')
322+
);
323+
const popover = root.querySelector(
324+
IgcPopoverComponent.tagName
325+
) as IgcPopoverComponent;
326+
await waitForPaint(popover);
327+
328+
expect(getFloater(popover).style.position).to.equal('fixed');
329+
});
330+
331+
it('uses the `absolute` strategy without a sticky ancestor', async () => {
332+
const root = await fixture<HTMLElement>(createNonSlottedPopover(true));
333+
const popover = root.querySelector(
334+
IgcPopoverComponent.tagName
335+
) as IgcPopoverComponent;
336+
await waitForPaint(popover);
337+
338+
expect(getFloater(popover).style.position).to.equal('absolute');
339+
});
340+
});
282341
});

src/components/popover/popover.ts

Lines changed: 52 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import { registerComponent } from '../common/definitions/register.js';
2323
import {
2424
first,
2525
getElementByIdFromRoot,
26+
getRoot,
2627
isString,
2728
roundByDPR,
2829
setStyles,
@@ -46,6 +47,26 @@ export type PopoverPlacement =
4647
| 'left-start'
4748
| 'left-end';
4849

50+
/**
51+
* Walks up the DOM tree of the given element, crossing shadow boundaries, and
52+
* returns whether any ancestor is positioned as `sticky`.
53+
*/
54+
function hasStickyAncestor(element: Element): boolean {
55+
let node: Element | null = element;
56+
57+
while (node) {
58+
if (getComputedStyle(node).position === 'sticky') {
59+
return true;
60+
}
61+
62+
const root = getRoot(node);
63+
node =
64+
node.parentElement ?? (root instanceof ShadowRoot ? root.host : null);
65+
}
66+
67+
return false;
68+
}
69+
4970
/* blazorSuppress */
5071
/**
5172
* @element igc-popover
@@ -78,6 +99,16 @@ export default class IgcPopoverComponent extends LitElement {
7899
private _dispose?: ReturnType<typeof autoUpdate>;
79100
private _target?: Element;
80101

102+
/**
103+
* The positioning strategy resolved when the popover is opened. The `fixed`
104+
* strategy is used when the anchor has a `position: sticky` ancestor, otherwise
105+
* the default `absolute` strategy is used. Cached here to avoid repeated DOM
106+
* traversals and style reflows on every scroll/resize reposition.
107+
*
108+
* Also, time to migrate to CSS Anchor positioning!!!
109+
*/
110+
private _strategy: 'absolute' | 'fixed' = 'absolute';
111+
81112
private readonly _slots = addSlotController(this, {
82113
slots: setSlots('anchor'),
83114
onChange: this._handleSlotChange,
@@ -163,21 +194,25 @@ export default class IgcPopoverComponent extends LitElement {
163194
//#region Life-cycle hooks
164195

165196
protected override update(properties: PropertyValues<this>): void {
197+
let targetChanged = false;
198+
166199
if (properties.has('anchor')) {
167200
const target = isString(this.anchor)
168201
? getElementByIdFromRoot(this, this.anchor)
169202
: this.anchor;
170203

171204
if (target) {
172205
this._target = target;
206+
targetChanged = true;
173207
}
174208
}
175209

176210
if (this.hasUpdated && properties.has('open')) {
177211
this._setOpenState(this.open);
212+
} else if (this.hasUpdated && this.open) {
213+
targetChanged ? this._resetAutoUpdate() : this._updatePosition();
178214
}
179215

180-
this._updateState();
181216
super.update(properties);
182217
}
183218

@@ -213,7 +248,10 @@ export default class IgcPopoverComponent extends LitElement {
213248
}
214249

215250
this._target = possibleTarget;
216-
this._updateState();
251+
252+
if (this.open) {
253+
this._resetAutoUpdate();
254+
}
217255
}
218256

219257
//#region Internal open state API
@@ -234,28 +272,24 @@ export default class IgcPopoverComponent extends LitElement {
234272
return;
235273
}
236274

275+
this._strategy = hasStickyAncestor(this._target) ? 'fixed' : 'absolute';
276+
237277
this._dispose = autoUpdate(
238278
this._target,
239279
this._container,
240280
this._updatePosition.bind(this)
241281
);
242282
}
243283

244-
private _clearDispose(): Promise<void> {
245-
return new Promise((resolve) => {
246-
this._dispose?.();
247-
this._dispose = undefined;
248-
resolve();
249-
});
284+
private _clearDispose(): void {
285+
this._dispose?.();
286+
this._dispose = undefined;
250287
}
251288

252-
private async _updateState(): Promise<void> {
253-
if (this.open) {
254-
await this._clearDispose();
255-
this._setDispose();
256-
}
289+
private _resetAutoUpdate(): void {
290+
this._clearDispose();
291+
this._setDispose();
257292
}
258-
259293
//#endregion
260294

261295
//#region Internal position API
@@ -308,17 +342,20 @@ export default class IgcPopoverComponent extends LitElement {
308342
return;
309343
}
310344

345+
const strategy = this._strategy;
346+
311347
const { x, y, middlewareData, placement } = await computePosition(
312348
this._target,
313349
this._container,
314350
{
315351
placement: this.placement ?? 'bottom-start',
316352
middleware: this._createMiddleware(),
317-
strategy: 'absolute',
353+
strategy,
318354
}
319355
);
320356

321357
setStyles(this._container, {
358+
position: strategy,
322359
left: '0',
323360
top: '0',
324361
transform: `translate(${roundByDPR(x)}px,${roundByDPR(y)}px)`,

src/components/popover/themes/light/popover.base.scss

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66
}
77

88
:popover-open {
9-
position: absolute;
109
overflow: visible;
1110
isolation: isolate;
1211
height: fit-content;

0 commit comments

Comments
 (0)