Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 61 additions & 2 deletions src/components/popover/popover.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,9 @@ describe('Popover', () => {
describe('With initial open state', () => {
beforeEach(async () => {
const root = await fixture<HTMLElement>(createNonSlottedPopover(true));
popover = root.querySelector('igc-popover') as IgcPopoverComponent;
popover = root.querySelector(
IgcPopoverComponent.tagName
) as IgcPopoverComponent;
anchor = root.querySelector('#btn') as HTMLButtonElement;
});

Expand Down Expand Up @@ -199,7 +201,9 @@ describe('Popover', () => {
describe('With initial closed state', () => {
beforeEach(async () => {
const root = await fixture<HTMLElement>(createNonSlottedPopover());
popover = root.querySelector('igc-popover') as IgcPopoverComponent;
popover = root.querySelector(
IgcPopoverComponent.tagName
) as IgcPopoverComponent;
anchor = root.querySelector('#btn') as HTMLButtonElement;
});

Expand Down Expand Up @@ -279,4 +283,59 @@ describe('Popover', () => {
});
});
});

describe('Positioning strategy', () => {
function createStickyPopover(level: 'parent' | 'grandparent') {
const popover = html`
<igc-popover open anchor="btn">
<p style="border: 1px solid #ccc">Message</p>
</igc-popover>
`;

const inner = html`
<button id="btn" type="button">Show message</button>
${popover}
`;

return level === 'parent'
? html`<div style="position: sticky; top: 0">${inner}</div>`
: html`
<div style="position: sticky; top: 0">
<div>${inner}</div>
</div>
`;
}

it('uses the `fixed` strategy with a directly sticky ancestor', async () => {
const root = await fixture<HTMLElement>(createStickyPopover('parent'));
const popover = root.querySelector(
IgcPopoverComponent.tagName
) as IgcPopoverComponent;
await waitForPaint(popover);

expect(getFloater(popover).style.position).to.equal('fixed');
});

it('uses the `fixed` strategy with a non-direct sticky ancestor', async () => {
const root = await fixture<HTMLElement>(
createStickyPopover('grandparent')
);
const popover = root.querySelector(
IgcPopoverComponent.tagName
) as IgcPopoverComponent;
await waitForPaint(popover);

expect(getFloater(popover).style.position).to.equal('fixed');
});

it('uses the `absolute` strategy without a sticky ancestor', async () => {
const root = await fixture<HTMLElement>(createNonSlottedPopover(true));
const popover = root.querySelector(
IgcPopoverComponent.tagName
) as IgcPopoverComponent;
await waitForPaint(popover);

expect(getFloater(popover).style.position).to.equal('absolute');
});
});
});
67 changes: 52 additions & 15 deletions src/components/popover/popover.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { registerComponent } from '../common/definitions/register.js';
import {
first,
getElementByIdFromRoot,
getRoot,
isString,
roundByDPR,
setStyles,
Expand All @@ -46,6 +47,26 @@ export type PopoverPlacement =
| 'left-start'
| 'left-end';

/**
* Walks up the DOM tree of the given element, crossing shadow boundaries, and
* returns whether any ancestor is positioned as `sticky`.
*/
function hasStickyAncestor(element: Element): boolean {
let node: Element | null = element;

while (node) {
if (getComputedStyle(node).position === 'sticky') {
return true;
}

const root = getRoot(node);
node =
node.parentElement ?? (root instanceof ShadowRoot ? root.host : null);
}

return false;
}

/* blazorSuppress */
/**
* @element igc-popover
Expand Down Expand Up @@ -78,6 +99,16 @@ export default class IgcPopoverComponent extends LitElement {
private _dispose?: ReturnType<typeof autoUpdate>;
private _target?: Element;

/**
* The positioning strategy resolved when the popover is opened. The `fixed`
* strategy is used when the anchor has a `position: sticky` ancestor, otherwise
* the default `absolute` strategy is used. Cached here to avoid repeated DOM
* traversals and style reflows on every scroll/resize reposition.
*
* Also, time to migrate to CSS Anchor positioning!!!
*/
private _strategy: 'absolute' | 'fixed' = 'absolute';

private readonly _slots = addSlotController(this, {
slots: setSlots('anchor'),
onChange: this._handleSlotChange,
Expand Down Expand Up @@ -163,21 +194,25 @@ export default class IgcPopoverComponent extends LitElement {
//#region Life-cycle hooks

protected override update(properties: PropertyValues<this>): void {
let targetChanged = false;

if (properties.has('anchor')) {
const target = isString(this.anchor)
? getElementByIdFromRoot(this, this.anchor)
: this.anchor;

if (target) {
this._target = target;
targetChanged = true;
}
}

if (this.hasUpdated && properties.has('open')) {
this._setOpenState(this.open);
} else if (this.hasUpdated && this.open) {
targetChanged ? this._resetAutoUpdate() : this._updatePosition();
}

this._updateState();
super.update(properties);
}

Expand Down Expand Up @@ -213,7 +248,10 @@ export default class IgcPopoverComponent extends LitElement {
}

this._target = possibleTarget;
this._updateState();

if (this.open) {
this._resetAutoUpdate();
}
}

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

this._strategy = hasStickyAncestor(this._target) ? 'fixed' : 'absolute';

this._dispose = autoUpdate(
this._target,
this._container,
this._updatePosition.bind(this)
);
}

private _clearDispose(): Promise<void> {
return new Promise((resolve) => {
this._dispose?.();
this._dispose = undefined;
resolve();
});
private _clearDispose(): void {
this._dispose?.();
this._dispose = undefined;
}

private async _updateState(): Promise<void> {
if (this.open) {
await this._clearDispose();
this._setDispose();
}
private _resetAutoUpdate(): void {
this._clearDispose();
this._setDispose();
}

//#endregion

//#region Internal position API
Expand Down Expand Up @@ -308,17 +342,20 @@ export default class IgcPopoverComponent extends LitElement {
return;
}

const strategy = this._strategy;

const { x, y, middlewareData, placement } = await computePosition(
this._target,
this._container,
{
placement: this.placement ?? 'bottom-start',
middleware: this._createMiddleware(),
strategy: 'absolute',
strategy,
}
);

setStyles(this._container, {
position: strategy,
left: '0',
top: '0',
transform: `translate(${roundByDPR(x)}px,${roundByDPR(y)}px)`,
Expand Down
1 change: 0 additions & 1 deletion src/components/popover/themes/light/popover.base.scss
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
}

:popover-open {
position: absolute;
overflow: visible;
isolation: isolate;
height: fit-content;
Expand Down
Loading