Skip to content
Open
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
83 changes: 75 additions & 8 deletions packages/popover/src/vaadin-popover-overlay-mixin.js
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this cover the other top-* and bottom-* positions as well? It looks just as broken when you use bottom-start for example.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The popover arrow also looks out of place when the position is adjusted:

Image

Not as critical as content not being visible, but we might still want to take it into account somehow as part of this fix.

Original file line number Diff line number Diff line change
Expand Up @@ -37,32 +37,80 @@ export const PopoverOverlayMixin = (superClass) =>

this.removeAttribute('arrow-centered');

// Clear any previous arrow positioning
const arrow = this.$.overlay.querySelector('[part="arrow"]');
if (arrow) {
arrow.style.insetInlineStart = '';
}

// Center the overlay horizontally
if (this.position === 'bottom' || this.position === 'top') {
const targetRect = this.positionTarget.getBoundingClientRect();
const overlayRect = this.$.overlay.getBoundingClientRect();
const viewportWidth = Math.min(window.innerWidth, document.documentElement.clientWidth);

const offset = targetRect.width / 2 - overlayRect.width / 2;

if (this.style.left) {
const left = overlayRect.left + offset;
if (left > 0) {
this.style.left = `${left}px`;
// Center the pointer arrow horizontally
const centeredLeft = overlayRect.left + offset;

// Constrain to viewport bounds
let finalLeft = centeredLeft;
let isCentered = true;

if (centeredLeft < 0) {
finalLeft = 0;
isCentered = false;
} else if (centeredLeft + overlayRect.width > viewportWidth) {
finalLeft = viewportWidth - overlayRect.width;
isCentered = false;
}

const clampedLeft = isCentered ? finalLeft : Math.max(0, finalLeft);
this.style.left = `${clampedLeft}px`;
if (isCentered) {
this.setAttribute('arrow-centered', '');
} else {
this.__repositionArrow(targetRect);
}
}

if (this.style.right) {
const right = parseFloat(this.style.right) + offset;
if (right > 0) {
this.style.right = `${right}px`;
// Center the pointer arrow horizontally
const centeredRight = parseFloat(this.style.right) + offset;
const centeredOverlayLeft = overlayRect.left - offset;

let finalRight = centeredRight;
let isCentered = true;

if (centeredOverlayLeft < 0) {
finalRight = centeredRight + centeredOverlayLeft;
isCentered = false;
} else if (centeredOverlayLeft + overlayRect.width > viewportWidth) {
finalRight = centeredRight + (centeredOverlayLeft + overlayRect.width - viewportWidth);
isCentered = false;
}

const clampedRight = isCentered ? finalRight : Math.max(0, finalRight);
this.style.right = `${clampedRight}px`;
if (isCentered) {
this.setAttribute('arrow-centered', '');
} else {
this.__repositionArrow(targetRect);
}
}
}

// Calculate arrow offset for aligned horizontal positions (bottom-start, bottom-end, top-start, top-end)
if (
this.position === 'bottom-start' ||
this.position === 'top-start' ||
this.position === 'bottom-end' ||
this.position === 'top-end'
) {
const targetRect = this.positionTarget.getBoundingClientRect();
this.__repositionArrow(targetRect);
}

// Center the overlay vertically
if (this.position === 'start' || this.position === 'end') {
const targetRect = this.positionTarget.getBoundingClientRect();
Expand All @@ -72,4 +120,23 @@ export const PopoverOverlayMixin = (superClass) =>
this.style.top = `${overlayRect.top + offset}px`;
}
}

/** @private */
__repositionArrow(targetRect) {
// When constrained, position arrow to point at target center
// Use requestAnimationFrame to get fresh measurements after position is applied
requestAnimationFrame(() => {
if (!this.opened) {
return;
}
const arrow = this.$.overlay.querySelector('[part="arrow"]');
if (!arrow) {
return;
}
const freshOverlayRect = this.$.overlay.getBoundingClientRect();
const targetCenter = targetRect.left + targetRect.width / 2;
const arrowOffset = targetCenter - freshOverlayRect.left;
arrow.style.insetInlineStart = `${arrowOffset}px`;
});
}
};
243 changes: 243 additions & 0 deletions packages/popover/test/position.test.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { expect } from '@vaadin/chai-plugins';
import { setViewport } from '@vaadin/test-runner-commands';
import { fixtureSync, nextRender, nextUpdate, oneEvent } from '@vaadin/testing-helpers';
import './not-animated-styles.js';
import '../src/vaadin-popover.js';
Expand Down Expand Up @@ -171,4 +172,246 @@ describe('position', () => {
});
});
});

describe('viewport constraint', () => {
let constraintPopover, constraintTarget, constraintOverlay;

beforeEach(async () => {
constraintPopover = fixtureSync('<vaadin-popover></vaadin-popover>');
constraintPopover.renderer = (root) => {
if (!root.firstChild) {
const div = document.createElement('div');
div.textContent = 'This is a long popover content that will extend beyond edges';
root.appendChild(div);
}
};
await nextRender();
constraintOverlay = constraintPopover.shadowRoot.querySelector('vaadin-popover-overlay');
});

async function openAndMeasure() {
constraintPopover.opened = true;
await oneEvent(constraintOverlay, 'vaadin-overlay-open');
const overlayRect = constraintOverlay.$.overlay.getBoundingClientRect();
const targetRect = constraintTarget.getBoundingClientRect();
return { overlayRect, targetRect };
}

['bottom', 'top'].forEach((position) => {
['left', 'right'].forEach((edge) => {
describe(`${position} position near ${edge} edge`, () => {
beforeEach(async () => {
// Place target very close to edge with small width
// This ensures centered popover would extend beyond edge
const topPosition = position === 'top' ? '300px' : '100px';
const edgeStyle = edge === 'left' ? 'left: 5px' : 'right: 5px';
constraintTarget = fixtureSync(
`<div style="width: 50px; height: 50px; position: absolute; ${edgeStyle}; top: ${topPosition};"></div>`,
);
constraintPopover.position = position;
constraintPopover.target = constraintTarget;
await nextUpdate(constraintPopover);
});

it(`should constrain popover to stay within viewport on ${edge} edge`, async () => {
const { overlayRect } = await openAndMeasure();
const viewportWidth = Math.min(window.innerWidth, document.documentElement.clientWidth);

// Popover should not extend beyond viewport edges
expect(overlayRect.left).to.be.at.least(0);
expect(overlayRect.right).to.be.at.most(viewportWidth);
});

it('should not have arrow-centered attribute when constrained', async () => {
await openAndMeasure();
expect(constraintOverlay.hasAttribute('arrow-centered')).to.be.false;
});
});
});
});

describe('centered position with sufficient space', () => {
beforeEach(async () => {
// Place target in center with sufficient space
await setViewport({ width: 1024, height: 768 });
const viewportWidth = Math.min(window.innerWidth, document.documentElement.clientWidth);
const centerX = viewportWidth / 2 - 50; // Center a 100px wide element
constraintTarget = fixtureSync(
`<div style="width: 100px; height: 100px; position: absolute; left: ${centerX}px; top: 200px;"></div>`,
);
constraintPopover.position = 'bottom';
constraintPopover.target = constraintTarget;
await nextUpdate(constraintPopover);
});

it('should center the popover when there is sufficient space', async () => {
const { overlayRect, targetRect } = await openAndMeasure();

const offset = targetRect.width / 2 - overlayRect.width / 2;
const expectedLeft = targetRect.left + offset;

expect(overlayRect.left).to.be.closeTo(expectedLeft, 1);
});

it('should stay within viewport even when centered', async () => {
const { overlayRect } = await openAndMeasure();
const viewportWidth = Math.min(window.innerWidth, document.documentElement.clientWidth);

expect(overlayRect.left).to.be.at.least(0);
expect(overlayRect.right).to.be.at.most(viewportWidth);
});

it('should have arrow-centered attribute when centered', async () => {
await openAndMeasure();
expect(constraintOverlay.hasAttribute('arrow-centered')).to.be.true;
});
});

describe('RTL mode near edges', () => {
before(() => {
document.documentElement.setAttribute('dir', 'rtl');
});

after(() => {
document.documentElement.removeAttribute('dir');
});

['left', 'right'].forEach((edge) => {
describe(`near ${edge} edge`, () => {
beforeEach(async () => {
// Place target near the edge in RTL mode
const edgeStyle = edge === 'left' ? 'left: 5px' : 'right: 5px';
constraintTarget = fixtureSync(
`<div style="width: 50px; height: 50px; position: absolute; ${edgeStyle}; top: 100px;"></div>`,
);
constraintPopover.position = 'bottom';
constraintPopover.target = constraintTarget;
await nextUpdate(constraintPopover);
});

it('should constrain popover to stay within viewport', async () => {
const { overlayRect } = await openAndMeasure();
const viewportWidth = Math.min(window.innerWidth, document.documentElement.clientWidth);

// Popover should not extend beyond viewport edges
expect(overlayRect.left).to.be.at.least(0);
expect(overlayRect.right).to.be.at.most(viewportWidth);
});

it('should be shifted from centered position when constrained', async () => {
const { overlayRect, targetRect } = await openAndMeasure();
const viewportWidth = Math.min(window.innerWidth, document.documentElement.clientWidth);

const targetCenter = targetRect.left + targetRect.width / 2;
const overlayCenter = overlayRect.left + overlayRect.width / 2;

// Should be shifted
expect(overlayCenter).to.not.equal(targetCenter);

// Should be at the appropriate edge
if (edge === 'left') {
expect(overlayRect.left).to.equal(0);
} else {
expect(overlayRect.right).to.equal(viewportWidth);
}
});
});
});
});

describe('horizontally aligned positions', () => {
[
{ positions: ['bottom-start', 'top-start'], edges: ['left', 'right'] },
{ positions: ['bottom-end', 'top-end'], edges: ['left', 'right'] },
].forEach(({ positions, edges }) => {
positions.forEach((position) => {
edges.forEach((edge) => {
describe(`${position} near ${edge} edge`, () => {
beforeEach(async () => {
const topPosition = position.startsWith('top') ? '300px' : '100px';
const edgeStyle = edge === 'left' ? 'left: 5px' : 'right: 5px';
constraintTarget = fixtureSync(
`<div style="width: 50px; height: 50px; position: absolute; ${edgeStyle}; top: ${topPosition};"></div>`,
);
constraintPopover.position = position;
constraintPopover.target = constraintTarget;
await nextUpdate(constraintPopover);
});

it('should constrain popover to stay within viewport', async () => {
const { overlayRect } = await openAndMeasure();
const viewportWidth = Math.min(window.innerWidth, document.documentElement.clientWidth);

expect(overlayRect.left).to.be.at.least(0);
expect(overlayRect.right).to.be.at.most(viewportWidth);
});
});
});
});
});
});

describe('vertically centered positions', () => {
['start', 'end'].forEach((position) => {
['top', 'bottom'].forEach((edge) => {
describe(`${position} near ${edge} edge`, () => {
beforeEach(async () => {
const leftPosition = position === 'start' ? '100px' : 'auto';
const rightPosition = position === 'end' ? '100px' : 'auto';
const topPosition = edge === 'top' ? '5px' : 'auto';
const bottomPosition = edge === 'bottom' ? '5px' : 'auto';

constraintTarget = fixtureSync(
`<div style="width: 50px; height: 50px; position: absolute; left: ${leftPosition}; right: ${rightPosition}; top: ${topPosition}; bottom: ${bottomPosition};"></div>`,
);
constraintPopover.position = position;
constraintPopover.target = constraintTarget;
await nextUpdate(constraintPopover);
});

it('should constrain popover to stay within viewport', async () => {
const { overlayRect } = await openAndMeasure();
const viewportHeight = Math.min(window.innerHeight, document.documentElement.clientHeight);

expect(overlayRect.top).to.be.at.least(0);
expect(overlayRect.bottom).to.be.at.most(viewportHeight);
});
});
});
});
});

describe('vertically aligned positions', () => {
[
{ position: 'start-top', edge: 'top' },
{ position: 'end-top', edge: 'top' },
{ position: 'start-bottom', edge: 'bottom' },
{ position: 'end-bottom', edge: 'bottom' },
].forEach(({ position, edge }) => {
describe(`${position} near ${edge} edge`, () => {
beforeEach(async () => {
const leftPosition = position.startsWith('start') ? '100px' : 'auto';
const rightPosition = position.startsWith('end') ? '100px' : 'auto';
const topPosition = edge === 'top' ? '5px' : 'auto';
const bottomPosition = edge === 'bottom' ? '5px' : 'auto';

constraintTarget = fixtureSync(
`<div style="width: 50px; height: 50px; position: absolute; left: ${leftPosition}; right: ${rightPosition}; top: ${topPosition}; bottom: ${bottomPosition};"></div>`,
);
constraintPopover.position = position;
constraintPopover.target = constraintTarget;
await nextUpdate(constraintPopover);
});

it('should constrain popover to stay within viewport', async () => {
const { overlayRect } = await openAndMeasure();
const viewportHeight = Math.min(window.innerHeight, document.documentElement.clientHeight);

expect(overlayRect.top).to.be.at.least(0);
expect(overlayRect.bottom).to.be.at.most(viewportHeight);
});
});
});
});
});
});
Loading