diff --git a/packages/popover/src/vaadin-popover-overlay-mixin.js b/packages/popover/src/vaadin-popover-overlay-mixin.js index 3f3c2acdaa3..6bee65c9122 100644 --- a/packages/popover/src/vaadin-popover-overlay-mixin.js +++ b/packages/popover/src/vaadin-popover-overlay-mixin.js @@ -37,39 +37,160 @@ export const PopoverOverlayMixin = (superClass) => this.removeAttribute('arrow-centered'); + // Clear any previous arrow positioning + const arrow = this.__getArrow(); + if (arrow) { + arrow.style.insetInlineStart = ''; + } + + const targetRect = this.__getTargetRect(); + const overlayRect = this.__getOverlayRect(); + // Center the overlay horizontally if (this.position === 'bottom' || this.position === 'top') { - const targetRect = this.positionTarget.getBoundingClientRect(); - const overlayRect = this.$.overlay.getBoundingClientRect(); - 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 - this.setAttribute('arrow-centered', ''); - } + this.__updateLeft(left, targetRect, overlayRect, true); } - if (this.style.right) { const right = parseFloat(this.style.right) + offset; - if (right > 0) { - this.style.right = `${right}px`; - // Center the pointer arrow horizontally - this.setAttribute('arrow-centered', ''); - } + const centeredOverlayLeft = overlayRect.left - offset; + this.__updateRight(right, centeredOverlayLeft, targetRect, overlayRect, true); } } - // Center the overlay vertically - if (this.position === 'start' || this.position === 'end') { - const targetRect = this.positionTarget.getBoundingClientRect(); - const overlayRect = this.$.overlay.getBoundingClientRect(); + // Constrain aligned horizontal positions to viewport + if ( + this.position === 'bottom-start' || + this.position === 'top-start' || + this.position === 'bottom-end' || + this.position === 'top-end' + ) { + if (this.style.left) { + const left = overlayRect.left; + this.__updateLeft(left, targetRect, overlayRect, false); + } + if (this.style.right) { + const right = parseFloat(this.style.right); + this.__updateRight(right, overlayRect.left, targetRect, overlayRect, false); + } + } + // Constrain vertically centered positions (start, end) + if (this.position === 'start' || this.position === 'end') { const offset = targetRect.height / 2 - overlayRect.height / 2; - this.style.top = `${overlayRect.top + offset}px`; + const top = overlayRect.top + offset; + this.__updateTop(top, targetRect, overlayRect, true); + } + + // Constrain vertically aligned positions (start-top, end-top, start-bottom, end-bottom) + if ( + this.position === 'start-top' || + this.position === 'end-top' || + this.position === 'start-bottom' || + this.position === 'end-bottom' + ) { + const top = overlayRect.top; + this.__updateTop(top, targetRect, overlayRect, false); + } + } + + /** @private */ + __updateRight(right, centeredOverlayLeft, targetRect, overlayRect, isCentered) { + if (centeredOverlayLeft < 0) { + right += centeredOverlayLeft; + this.__repositionArrow(targetRect); + } else { + const viewportWidth = this.__getViewportWidth(); + if (centeredOverlayLeft + overlayRect.width > viewportWidth) { + right += centeredOverlayLeft + overlayRect.width - viewportWidth; + this.__repositionArrow(targetRect); + } else if (isCentered) { + this.setAttribute('arrow-centered', ''); + } + } + + this.style.right = `${right}px`; + } + + /** @private */ + __updateLeft(left, targetRect, overlayRect, isCentered) { + if (left < 0) { + left = 0; + this.__repositionArrow(targetRect); + } else { + const viewportWidth = this.__getViewportWidth(); + if (left + overlayRect.width > viewportWidth) { + left = viewportWidth - overlayRect.width; + this.__repositionArrow(targetRect); + } else if (isCentered) { + this.setAttribute('arrow-centered', ''); + } } + + this.style.left = `${left}px`; + } + + /** @private */ + __updateTop(top, targetRect, overlayRect, isCentered) { + if (top < 0) { + top = 0; + this.__repositionArrow(targetRect); + } else { + const viewportHeight = this.__getViewportHeight(); + if (top + overlayRect.height > viewportHeight) { + top = viewportHeight - overlayRect.height; + this.__repositionArrow(targetRect); + } else if (isCentered) { + this.setAttribute('arrow-centered', ''); + } + } + + this.style.top = `${top}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.__getArrow(); + if (!arrow) { + return; + } + const overlayRect = this.__getOverlayRect(); + const targetCenter = targetRect.left + targetRect.width / 2; + const arrowOffset = targetCenter - overlayRect.left; + arrow.style.insetInlineStart = `${arrowOffset}px`; + }); + } + + /** @private */ + __getArrow() { + return this.$.overlay.querySelector('[part="arrow"]'); + } + + /** @private */ + __getViewportHeight() { + return Math.min(window.innerHeight, document.documentElement.clientHeight); + } + + /** @private */ + __getViewportWidth() { + return Math.min(window.innerWidth, document.documentElement.clientWidth); + } + + /** @private */ + __getOverlayRect() { + return this.$.overlay.getBoundingClientRect(); + } + + /** @private */ + __getTargetRect() { + return this.positionTarget.getBoundingClientRect(); } }; diff --git a/packages/popover/test/position.test.js b/packages/popover/test/position.test.js index d6c3dbf58b6..369a0faca1e 100644 --- a/packages/popover/test/position.test.js +++ b/packages/popover/test/position.test.js @@ -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'; @@ -171,4 +172,246 @@ describe('position', () => { }); }); }); + + describe('viewport constraint', () => { + let constraintPopover, constraintTarget, constraintOverlay; + + beforeEach(async () => { + constraintPopover = fixtureSync(''); + 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( + `
`, + ); + 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( + `
`, + ); + 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( + `
`, + ); + 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( + `
`, + ); + 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( + `
`, + ); + 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( + `
`, + ); + 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); + }); + }); + }); + }); + }); });