fix(ui): keep floating menus inside viewport and clipping containers
Quality / quality (ubuntu-latest) (push) Failing after 4s
Quality / quality (windows-latest) (push) Canceled after 0s

This commit is contained in:
Clintchiz committed 2026-10-19 17:51:15 +05:30
1 parent 9520651330
commit 7c1fcd19d4
16 files changed
+138 -33

No files matched your search

+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@wrnexus/csr",
"version": "0.8.74",
"version": "0.8.75",
"type": "module",
"main": "src/index.ts",
"exports": {
+58 -9
View File
@@ -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();
});