Skip to content

Commit 36d59e5

Browse files
feat: add option to keep dialog within viewport when dragging (#11030) (#11076)
* feat: add option to keep dialog within viewport when dragging * add delta for assertions in Firefox * clarify that the property only affects dragging Co-authored-by: Sascha Ißbrücker <sissbruecker@vaadin.com>
1 parent fdb891d commit 36d59e5

File tree

4 files changed

+119
-2
lines changed

4 files changed

+119
-2
lines changed

packages/dialog/src/vaadin-dialog-draggable-mixin.d.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,4 +21,12 @@ export declare class DialogDraggableMixinClass {
2121
* "`draggable-leaf-only`" class name.
2222
*/
2323
draggable: boolean;
24+
25+
/**
26+
* Set to true to prevent dragging the dialog outside the viewport bounds.
27+
* When enabled, all four edges of the dialog will remain visible during dragging.
28+
* The dialog may still become partially hidden when the viewport is resized.
29+
* @attr {boolean} keep-in-viewport
30+
*/
31+
keepInViewport: boolean;
2432
}

packages/dialog/src/vaadin-dialog-draggable-mixin.js

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,19 @@ export const DialogDraggableMixin = (superClass) =>
3131
reflectToAttribute: true,
3232
},
3333

34+
/**
35+
* Set to true to prevent dragging the dialog outside the viewport bounds.
36+
* When enabled, all four edges of the dialog will remain visible during dragging.
37+
* The dialog may still become partially hidden when the viewport is resized.
38+
* @attr {boolean} keep-in-viewport
39+
* @type {boolean}
40+
*/
41+
keepInViewport: {
42+
type: Boolean,
43+
value: false,
44+
reflectToAttribute: true,
45+
},
46+
3447
/** @private */
3548
_touchDevice: {
3649
type: Boolean,
@@ -106,8 +119,22 @@ export const DialogDraggableMixin = (superClass) =>
106119
_drag(e) {
107120
const event = getMouseOrFirstTouchEvent(e);
108121
if (eventInWindow(event)) {
109-
const top = this._originalBounds.top + (event.pageY - this._originalMouseCoords.top);
110-
const left = this._originalBounds.left + (event.pageX - this._originalMouseCoords.left);
122+
let top = this._originalBounds.top + (event.pageY - this._originalMouseCoords.top);
123+
let left = this._originalBounds.left + (event.pageX - this._originalMouseCoords.left);
124+
125+
if (this.keepInViewport) {
126+
const { width, height } = this._originalBounds;
127+
// Get the overlay container's position to account for its offset from the viewport
128+
const containerBounds = this.$.overlay.getBoundingClientRect();
129+
// Calculate bounds so the dialog's visual edges stay within the viewport
130+
const minLeft = -containerBounds.left;
131+
const maxLeft = window.innerWidth - containerBounds.left - width;
132+
const minTop = -containerBounds.top;
133+
const maxTop = window.innerHeight - containerBounds.top - height;
134+
left = Math.max(minLeft, Math.min(left, maxLeft));
135+
top = Math.max(minTop, Math.min(top, maxTop));
136+
}
137+
111138
this.top = top;
112139
this.left = left;
113140
}

packages/dialog/test/draggable-resizable.test.js

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -658,6 +658,87 @@ describe('draggable', () => {
658658
await nextUpdate(dialog);
659659
expect(dialog.$.overlay.hasAttribute('draggable')).to.be.false;
660660
});
661+
662+
describe('keepInViewport', () => {
663+
// Helper to drag to absolute coordinates within the viewport
664+
function dragTo(target, toX, toY) {
665+
const targetBounds = target.getBoundingClientRect();
666+
const fromXY = {
667+
x: Math.floor(targetBounds.left + targetBounds.width / 2),
668+
y: Math.floor(targetBounds.top + targetBounds.height / 2),
669+
};
670+
const toXY = { x: toX, y: toY };
671+
dispatchMouseEvent(target, 'mousedown', fromXY);
672+
dispatchMouseEvent(target, 'mousemove', fromXY);
673+
dispatchMouseEvent(target, 'mousemove', toXY);
674+
dispatchMouseEvent(target, 'mouseup', toXY);
675+
}
676+
677+
beforeEach(async () => {
678+
dialog.keepInViewport = true;
679+
await nextUpdate(dialog);
680+
});
681+
682+
it('should not drag dialog past left viewport edge', async () => {
683+
dragTo(container, 0, bounds.top + bounds.height / 2);
684+
await nextRender();
685+
686+
const draggedBounds = container.getBoundingClientRect();
687+
expect(Math.floor(draggedBounds.left)).to.be.closeTo(0, 1);
688+
});
689+
690+
it('should not drag dialog past top viewport edge', async () => {
691+
dragTo(container, bounds.left + bounds.width / 2, 0);
692+
await nextRender();
693+
694+
const draggedBounds = container.getBoundingClientRect();
695+
expect(Math.floor(draggedBounds.top)).to.closeTo(0, 1);
696+
});
697+
698+
it('should not drag dialog past right viewport edge', async () => {
699+
dragTo(container, window.innerWidth, bounds.top + bounds.height / 2);
700+
await nextRender();
701+
702+
const draggedBounds = container.getBoundingClientRect();
703+
expect(Math.floor(draggedBounds.right)).to.closeTo(window.innerWidth, 1);
704+
});
705+
706+
it('should not drag dialog past bottom viewport edge', async () => {
707+
dragTo(container, bounds.left + bounds.width / 2, window.innerHeight);
708+
await nextRender();
709+
710+
const draggedBounds = container.getBoundingClientRect();
711+
expect(Math.floor(draggedBounds.bottom)).to.closeTo(window.innerHeight, 1);
712+
});
713+
714+
it('should allow normal dragging within viewport', async () => {
715+
const initialBounds = container.getBoundingClientRect();
716+
dx = 50;
717+
drag(container);
718+
await nextRender();
719+
const draggedBounds = container.getBoundingClientRect();
720+
expect(Math.floor(draggedBounds.top)).to.be.eql(Math.floor(initialBounds.top + 50));
721+
expect(Math.floor(draggedBounds.left)).to.be.eql(Math.floor(initialBounds.left + 50));
722+
});
723+
724+
it('should allow dragging outside of viewport with keepInViewport disabled', async () => {
725+
dialog.keepInViewport = false;
726+
await nextUpdate(dialog);
727+
728+
dragTo(container, 0, bounds.top + bounds.height / 2);
729+
await nextRender();
730+
731+
const draggedBounds = container.getBoundingClientRect();
732+
expect(Math.floor(draggedBounds.left)).to.lessThan(0);
733+
});
734+
735+
it('should reflect keepInViewport attribute', async () => {
736+
expect(dialog.hasAttribute('keep-in-viewport')).to.be.true;
737+
dialog.keepInViewport = false;
738+
await nextUpdate(dialog);
739+
expect(dialog.hasAttribute('keep-in-viewport')).to.be.false;
740+
});
741+
});
661742
});
662743

663744
describe('touch', () => {

packages/dialog/test/typings/dialog.types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ dialog.addEventListener('closed', (event) => {
5050
assertType<boolean>(dialog.opened);
5151
assertType<boolean>(dialog.modeless);
5252
assertType<boolean>(dialog.draggable);
53+
assertType<boolean>(dialog.keepInViewport);
5354
assertType<boolean>(dialog.resizable);
5455
assertType<boolean>(dialog.noCloseOnEsc);
5556
assertType<boolean>(dialog.noCloseOnOutsideClick);

0 commit comments

Comments
 (0)