fix(ui): keep floating menus inside viewport and clipping containers
This commit is contained in:
1 parent
9520651330
commit
7c1fcd19d4
16 files changed
+138
-33
No files matched your search
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@wrnexus/csr",
|
||||
"version": "0.8.74",
|
||||
"version": "0.8.75",
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"exports": {
|
||||
|
||||
@@ -3244,6 +3244,16 @@ export const REACTIVE_RUNTIME = String.raw`
|
||||
function clampAnchored(element) {
|
||||
// Measure the untouched position; the offset is recomputed from scratch.
|
||||
element.style.translate = "";
|
||||
// Restore authored limits before measuring, including after a resize.
|
||||
if (element.__wrnAnchorLimits) {
|
||||
var saved = element.__wrnAnchorLimits;
|
||||
element.style.maxWidth = saved[0];
|
||||
element.style.maxHeight = saved[1];
|
||||
element.style.minWidth = saved[2];
|
||||
element.style.minHeight = saved[3];
|
||||
element.style.overflow = saved[4];
|
||||
element.style.boxSizing = saved[5];
|
||||
}
|
||||
var arrow = element.querySelector("[data-wrn-anchor-arrow]");
|
||||
if (arrow) arrow.style.translate = "";
|
||||
if (!isRenderedElement(element)) return;
|
||||
@@ -3251,6 +3261,38 @@ export const REACTIVE_RUNTIME = String.raw`
|
||||
var rect = element.getBoundingClientRect();
|
||||
var viewportWidth = window.innerWidth || document.documentElement.clientWidth;
|
||||
var viewportHeight = window.innerHeight || document.documentElement.clientHeight;
|
||||
var visual = window.visualViewport;
|
||||
var left = (visual ? visual.offsetLeft : 0) + ANCHOR_MARGIN;
|
||||
var top = (visual ? visual.offsetTop : 0) + ANCHOR_MARGIN;
|
||||
var right = left + (visual ? visual.width : viewportWidth) - ANCHOR_MARGIN * 2;
|
||||
var bottom = top + (visual ? visual.height : viewportHeight) - ANCHOR_MARGIN * 2;
|
||||
// A preview, sidebar or scroll container may clip well before the screen edge.
|
||||
for (var parent = element.parentElement; parent && parent !== document.documentElement; parent = parent.parentElement) {
|
||||
var css = window.getComputedStyle(parent);
|
||||
var clipX = /auto|scroll|hidden|clip/.test(css.overflowX || css.overflow);
|
||||
var clipY = /auto|scroll|hidden|clip/.test(css.overflowY || css.overflow);
|
||||
if (!clipX && !clipY) continue;
|
||||
var bounds = parent.getBoundingClientRect();
|
||||
if (clipX) {
|
||||
left = Math.max(left, bounds.left + parent.clientLeft + ANCHOR_MARGIN);
|
||||
right = Math.min(right, bounds.left + parent.clientLeft + parent.clientWidth - ANCHOR_MARGIN);
|
||||
}
|
||||
if (clipY) {
|
||||
top = Math.max(top, bounds.top + parent.clientTop + ANCHOR_MARGIN);
|
||||
bottom = Math.min(bottom, bounds.top + parent.clientTop + parent.clientHeight - ANCHOR_MARGIN);
|
||||
}
|
||||
}
|
||||
if (right <= left || bottom <= top) return;
|
||||
if (!element.__wrnAnchorLimits) element.__wrnAnchorLimits = [element.style.maxWidth, element.style.maxHeight, element.style.minWidth, element.style.minHeight, element.style.overflow, element.style.boxSizing];
|
||||
if (rect.width > right - left || rect.height > bottom - top) {
|
||||
element.style.boxSizing = "border-box";
|
||||
element.style.minWidth = "0px";
|
||||
element.style.minHeight = "0px";
|
||||
element.style.maxWidth = Math.min(rect.width, right - left) + "px";
|
||||
element.style.maxHeight = Math.min(rect.height, bottom - top) + "px";
|
||||
element.style.overflow = "auto";
|
||||
rect = element.getBoundingClientRect();
|
||||
}
|
||||
|
||||
/*
|
||||
* Clamp only while the overlay's own trigger is on screen.
|
||||
@@ -3263,7 +3305,7 @@ export const REACTIVE_RUNTIME = String.raw`
|
||||
* one very much does need moving; skipping it left the menu invisible
|
||||
* with no way to reach it. The trigger tells the two apart.
|
||||
*/
|
||||
var host = element.closest("[data-ui-component]") || element.parentElement;
|
||||
var host = element.parentElement && element.parentElement.closest("[data-wrn-menu]") || element.closest("[data-ui-component]") || element.parentElement;
|
||||
var anchor = host ? host.getBoundingClientRect() : rect;
|
||||
var anchorVisible =
|
||||
anchor.bottom > 0 &&
|
||||
@@ -3278,17 +3320,20 @@ export const REACTIVE_RUNTIME = String.raw`
|
||||
var shiftX = 0;
|
||||
var shiftY = 0;
|
||||
|
||||
if (rect.width < viewportWidth - ANCHOR_MARGIN * 2) {
|
||||
if (rect.left < ANCHOR_MARGIN) shiftX = ANCHOR_MARGIN - rect.left;
|
||||
else if (rect.right > viewportWidth - ANCHOR_MARGIN) {
|
||||
shiftX = viewportWidth - ANCHOR_MARGIN - rect.right;
|
||||
if (rect.width <= right - left) {
|
||||
if (rect.left < left) shiftX = left - rect.left;
|
||||
else if (rect.right > right) {
|
||||
shiftX = right - rect.right;
|
||||
}
|
||||
}
|
||||
|
||||
if (rect.height < viewportHeight - ANCHOR_MARGIN * 2) {
|
||||
if (rect.top < ANCHOR_MARGIN) shiftY = ANCHOR_MARGIN - rect.top;
|
||||
else if (rect.bottom > viewportHeight - ANCHOR_MARGIN) {
|
||||
shiftY = viewportHeight - ANCHOR_MARGIN - rect.bottom;
|
||||
if (rect.height <= bottom - top) {
|
||||
if (rect.top < top) shiftY = top - rect.top;
|
||||
else if (rect.bottom > bottom) {
|
||||
// Prefer opening above the trigger when there is enough room.
|
||||
shiftY = anchor.top - rect.height - ANCHOR_MARGIN >= top && rect.top >= anchor.bottom
|
||||
? anchor.top - rect.height - ANCHOR_MARGIN - rect.top
|
||||
: bottom - rect.bottom;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3392,6 +3437,10 @@ export const REACTIVE_RUNTIME = String.raw`
|
||||
|
||||
window.addEventListener("resize", scheduleAnchoredReposition);
|
||||
window.addEventListener("scroll", scheduleAnchoredReposition, true);
|
||||
if (window.visualViewport) {
|
||||
window.visualViewport.addEventListener("resize", scheduleAnchoredReposition);
|
||||
window.visualViewport.addEventListener("scroll", scheduleAnchoredReposition);
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { test, expect } from "bun:test";
|
||||
import { Window } from "happy-dom";
|
||||
import { REACTIVE_RUNTIME } from "../src/reactive-runtime.ts";
|
||||
|
||||
function fixture(panel: Record<string, number>, clipped = false) {
|
||||
const win = new Window();
|
||||
win.document.body.innerHTML = '<div id="clip"><div data-ui-component="ComboBox"><div data-wrn-anchored></div></div></div>';
|
||||
const element = win.document.querySelector('[data-wrn-anchored]')! as any;
|
||||
const host = element.parentElement;
|
||||
const rect = (left: number, top: number, width: number, height: number) => ({left, top, width, height, right:left+width, bottom:top+height});
|
||||
host.getBoundingClientRect = () => rect(270, 170, 40, 30);
|
||||
element.getBoundingClientRect = () => rect(panel.left, panel.top,
|
||||
Math.min(panel.width, parseFloat(element.style.maxWidth) || Infinity),
|
||||
Math.min(panel.height, parseFloat(element.style.maxHeight) || Infinity));
|
||||
if (clipped) {
|
||||
const clip = win.document.querySelector('#clip')! as any;
|
||||
clip.style.overflow = 'hidden';
|
||||
clip.getBoundingClientRect = () => rect(100, 50, 220, 220);
|
||||
Object.defineProperty(clip, 'clientWidth', {value:220});
|
||||
Object.defineProperty(clip, 'clientHeight', {value:220});
|
||||
}
|
||||
const start = REACTIVE_RUNTIME.indexOf(' function isRenderedElement(');
|
||||
const end = REACTIVE_RUNTIME.indexOf(' function repositionAnchored(', start);
|
||||
const clamp = new Function('window', 'document', 'ANCHOR_MARGIN', REACTIVE_RUNTIME.slice(start,end) + '; return clampAnchored;')(win, win.document, 8);
|
||||
return {win, element, clamp};
|
||||
}
|
||||
|
||||
test('anchored panels fit clipping containers and positioning does not drift', async () => {
|
||||
const {win, element, clamp} = fixture({left:40,top:208,width:180,height:100}, true);
|
||||
clamp(element);
|
||||
expect(element.style.translate).toBe('68px -146px');
|
||||
clamp(element);
|
||||
expect(element.style.translate).toBe('68px -146px');
|
||||
await win.happyDOM.close();
|
||||
});
|
||||
|
||||
test('oversized panels become scrollable and recover authored sizing when space returns', async () => {
|
||||
const {win, element, clamp} = fixture({left:40,top:60,width:500,height:400}, true);
|
||||
element.style.minWidth = '300px';
|
||||
clamp(element);
|
||||
expect(element.style.maxWidth).toBe('204px');
|
||||
expect(element.style.maxHeight).toBe('204px');
|
||||
expect(element.style.overflow).toBe('auto');
|
||||
element.parentElement.parentElement.style.overflow = 'visible';
|
||||
clamp(element);
|
||||
expect(element.style.minWidth).toBe('300px');
|
||||
expect(element.style.maxWidth).toBe('');
|
||||
await win.happyDOM.close();
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user