Thanks to MarcusDger and xet7 . Fixes #5667

This commit is contained in:
Lauri Ojansivu committed 2026-07-06 15:12:21 +03:00
1 parent b26494766f
commit ac368de06a
5 files changed
+326 -102

No files matched your search

+18
View File
@@ -39,6 +39,24 @@ them up next.
[#3823](https://github.com/wekan/wekan/issues/3823), [#3138](https://github.com/wekan/wekan/issues/3138),
[#2204](https://github.com/wekan/wekan/issues/2204) (restrict permanent delete to the Admin role).
# Upcoming WeKan release
This release fixes the following bugs:
- **[Fix #5667: date-picker calendar stays fully visible when opened low on a scrolled page](https://github.com/wekan/wekan/issues/5667)**:
Opening a date field (due/start/end date, or a date custom field) low on the screen showed the
calendar popup extending past the visible area, and — because the pop-over is `position: absolute`
(document coordinates) — scrolling to reach it moved the calendar along with the page, so the full
calendar could never be seen (the workaround was to close it, drag the field to the center and
reopen). `Popup._getOffset` computed the space above/below the opener and the clamped `top` from
the opener's DOCUMENT offset mixed with the VIEWPORT height, ignoring the page scroll, so on a
scrolled page the anchored popup landed outside the visible viewport. The geometry now runs in
viewport coordinates (subtracting the page scroll) and clamps the popup fully within the visible
viewport, then converts back to document coordinates for the absolute style; when the page is not
scrolled the output is unchanged. Extracted the math into the pure, unit-tested
`client/lib/popupOffset.js`, used by `client/lib/popup.js`. Covered by `tests/popupOffset.test.cjs`.
Thanks to MarcusDger and xet7.
# v9.79 2026-07-06 WeKan ® release
This release fixes the following bugs:
+27 -101
View File
@@ -4,6 +4,7 @@ import { Tracker } from 'meteor/tracker';
import { TAPi18n } from '/imports/i18n';
import { EscapeActions } from '/client/lib/escapeActions';
import { Utils } from '/client/lib/utils';
import { computePopupOffset } from '/client/lib/popupOffset';
window.Popup = new (class {
constructor() {
@@ -222,108 +223,33 @@ window.Popup = new (class {
return () => {
Utils.windowResizeDep.depend();
if (Utils.isMiniScreen()) return { left: 0, top: 0 };
// #5667: read the current page scroll and the opener's document offset,
// then delegate the geometry to the pure, unit-tested computePopupOffset.
// It lays the popup out in VIEWPORT coordinates — so a date-picker opened
// low on a scrolled page stays fully visible instead of extending past the
// visible area — and returns document coordinates for the absolute style.
const hasOpener = !!($element && $element.length);
const offset = hasOpener ? $element.offset() : null;
const opener = hasOpener
? { top: offset.top, left: offset.left, height: $element.outerHeight() }
: null;
const viewportWidth = $(window).width();
const viewportHeight = $(window).height();
const viewportPadding = 10;
// Calculate actual popup width based on CSS: min(380px, 55vw)
const popupWidth = Math.min(380, viewportWidth * 0.55);
// Card details popup: dock it to the top of the viewport so it overlays
// the global and board header bars (its z-index already sits above them)
// instead of opening downward from the clicked minicard.
if (popupName === 'cardDetailsPopup') {
const cardWidth = Math.min(600, viewportWidth * 0.8);
const centeredLeft = (viewportWidth - cardWidth) / 2;
// top is forced to 0 in CSS (.pop-over[data-popup="cardDetailsPopup"])
// so the card overlays the header bars; use the full viewport height.
return {
left: Math.max(viewportPadding, centeredLeft),
top: 0,
maxHeight: viewportHeight,
};
}
// If the opener element is missing (e.g., programmatic open), fallback to viewport origin
if (!$element || $element.length === 0) {
return {
left: viewportPadding,
top: viewportPadding,
maxHeight: viewportHeight - viewportPadding * 2,
};
}
const offset = $element.offset();
const openerTop = offset.top;
const openerBottom = offset.top + $element.outerHeight();
const clampedLeft = Math.max(
viewportPadding,
Math.min(offset.left, viewportWidth - popupWidth - viewportPadding),
);
// Check if this is an admin panel edit popup
const isAdminEditPopup = $element.hasClass('edit-user') ||
$element.hasClass('edit-org') ||
$element.hasClass('edit-team');
if (isAdminEditPopup) {
// Center the popup horizontally and use full height
const centeredLeft = (viewportWidth - popupWidth) / 2;
return {
left: Math.max(viewportPadding, centeredLeft), // Ensure popup doesn't go off screen
top: viewportPadding, // Start from top with small margin
maxHeight: viewportHeight - viewportPadding * 2, // Use full height minus small margins
};
}
// Calculate available heights around the opener
const spaceBelow = viewportHeight - openerBottom - viewportPadding;
const spaceAbove = openerTop - viewportPadding;
const preferBelow = spaceBelow >= spaceAbove;
// For language popup, don't use dynamic height to avoid overlapping board
const isLanguagePopup = $element.hasClass('js-change-language');
let availableHeight;
if (isLanguagePopup) {
// For language popup, position content area below right vertical scrollbar
const languageTop = openerBottom;
const languageAvailable = Math.max(
0,
viewportHeight - languageTop - viewportPadding,
);
const calculatedHeight = Math.min(
languageAvailable,
viewportHeight * 0.5,
); // Max 50% of viewport
return {
left: clampedLeft,
top: languageTop,
maxHeight: Math.max(calculatedHeight, 0),
};
} else {
// For other popups, choose the side with more space and keep popup fully in viewport.
if (preferBelow) {
availableHeight = Math.max(0, Math.min(spaceBelow, viewportHeight * 0.8));
return {
left: clampedLeft,
top: openerBottom,
maxHeight: availableHeight,
};
}
availableHeight = Math.max(0, Math.min(spaceAbove, viewportHeight * 0.8));
const top = Math.max(viewportPadding, openerTop - availableHeight);
return {
left: clampedLeft,
top,
maxHeight: availableHeight,
};
}
return computePopupOffset({
viewportWidth: $(window).width(),
viewportHeight: $(window).height(),
scrollTop: $(window).scrollTop() || 0,
scrollLeft: $(window).scrollLeft() || 0,
opener,
popupName,
isMiniScreen: Utils.isMiniScreen(),
isAdminEditPopup:
hasOpener &&
($element.hasClass('edit-user') ||
$element.hasClass('edit-org') ||
$element.hasClass('edit-team')),
isLanguagePopup: hasOpener && $element.hasClass('js-change-language'),
viewportPadding: 10,
});
};
}
+118
View File
@@ -0,0 +1,118 @@
'use strict';
// Pure geometry for Popup._getOffset — extracted so the positioning math is
// unit-testable without a DOM / Meteor (see tests/popupOffset.test.cjs).
//
// #5667 ("Problem when scrolling in a map with date fields"): a date-picker
// calendar (due/start/end date, or a date custom field) opened low on the screen
// appeared partly outside the visible area, and — because the pop-over is
// `position: absolute` (document coordinates) — trying to scroll it into view
// moved it along with the page, so the calendar could never be seen fully.
//
// Root cause: _getOffset computed the space above / below the opener (and the
// clamped `top`) from the opener's DOCUMENT offset (`$el.offset()`) mixed with
// the VIEWPORT height, ignoring the page scroll. On a scrolled page that placed
// the anchored popup outside the visible viewport.
//
// Fix: do the space / clamp math in VIEWPORT coordinates (subtract
// scrollTop/scrollLeft), then convert the chosen top/left back to DOCUMENT
// coordinates (add the scroll back) for the absolute style. The popup box is then
// always laid out fully within the visible viewport. When the page is not
// scrolled (scrollTop === scrollLeft === 0) the output is identical to the
// previous behaviour, so nothing regresses in the common case.
function computePopupOffset(params) {
const {
viewportWidth,
viewportHeight,
scrollTop = 0,
scrollLeft = 0,
// Opener document offset { top, left, height }, or null for a programmatic
// open with no reference element.
opener = null,
popupName,
isMiniScreen = false,
isAdminEditPopup = false,
isLanguagePopup = false,
viewportPadding = 10,
} = params;
if (isMiniScreen) return { left: 0, top: 0 };
// Actual popup width from CSS: min(380px, 55vw).
const popupWidth = Math.min(380, viewportWidth * 0.55);
// Card details popup: docked to the top of the viewport (CSS also forces
// top:0) so it overlays the header bars instead of opening from the minicard.
if (popupName === 'cardDetailsPopup') {
const cardWidth = Math.min(600, viewportWidth * 0.8);
const centeredLeft = (viewportWidth - cardWidth) / 2;
return {
left: Math.max(viewportPadding, centeredLeft),
top: scrollTop, // viewport top in document coords
maxHeight: viewportHeight,
};
}
// No opener element: fall back to the top-left of the VISIBLE viewport.
if (!opener) {
return {
left: viewportPadding + scrollLeft,
top: viewportPadding + scrollTop,
maxHeight: viewportHeight - viewportPadding * 2,
};
}
// Opener position in viewport coordinates (this is the #5667 fix: the previous
// code used the raw document offset here).
const openerTopVp = opener.top - scrollTop;
const openerBottomVp = openerTopVp + opener.height;
const openerLeftVp = opener.left - scrollLeft;
const clampedLeftVp = Math.max(
viewportPadding,
Math.min(openerLeftVp, viewportWidth - popupWidth - viewportPadding),
);
const clampedLeft = clampedLeftVp + scrollLeft;
// Admin panel edit popups: horizontally centered, anchored to the viewport top.
if (isAdminEditPopup) {
const centeredLeft = (viewportWidth - popupWidth) / 2;
return {
left: Math.max(viewportPadding, centeredLeft),
top: viewportPadding + scrollTop,
maxHeight: viewportHeight - viewportPadding * 2,
};
}
const spaceBelow = viewportHeight - openerBottomVp - viewportPadding;
const spaceAbove = openerTopVp - viewportPadding;
const preferBelow = spaceBelow >= spaceAbove;
// Language popup: fixed-ish height below the opener, capped at 50% viewport.
if (isLanguagePopup) {
const languageTopVp = openerBottomVp;
const languageAvailable = Math.max(
0,
viewportHeight - languageTopVp - viewportPadding,
);
const calculatedHeight = Math.min(languageAvailable, viewportHeight * 0.5);
return {
left: clampedLeft,
top: languageTopVp + scrollTop,
maxHeight: Math.max(calculatedHeight, 0),
};
}
// Other popups: open on the side with more room and stay fully in the viewport.
if (preferBelow) {
const maxHeight = Math.max(0, Math.min(spaceBelow, viewportHeight * 0.8));
return { left: clampedLeft, top: openerBottomVp + scrollTop, maxHeight };
}
const maxHeight = Math.max(0, Math.min(spaceAbove, viewportHeight * 0.8));
const topVp = Math.max(viewportPadding, openerTopVp - maxHeight);
return { left: clampedLeft, top: topVp + scrollTop, maxHeight };
}
module.exports = { computePopupOffset };
+1 -1
View File
@@ -25,7 +25,7 @@
},
"scripts": {
"test": "meteor test --once --driver-package meteortesting:mocha",
"test:unit:node": "node tests/filenameSanitizer.test.cjs && node tests/cardBoardConsistency.test.cjs && node tests/cardUrl.test.cjs && node tests/defaultSwimlane.test.cjs && node tests/cardMoveModifier.test.cjs && node tests/authenticationMethod.test.cjs && node tests/linkedCardTarget.test.cjs && node tests/staleWhileRevalidate.test.cjs && node tests/restArrayParam.test.cjs && node tests/minicardLabelHeight.test.cjs && node tests/dateUtils.normalizeDigits.test.cjs && node tests/reconcileBoardTeamMembers.test.cjs && node tests/rulesBoardSelector.test.cjs && node tests/notificationCleanup.test.cjs && node tests/cardSearch.test.cjs && node tests/linkedCardMembers.test.cjs && node tests/verificationEmail.test.cjs && node tests/boardSortReorder.test.cjs && node tests/checklistItemTitles.test.cjs && node tests/swimlaneFilter.test.cjs && node tests/unit/resetPasswordEmail.test.js",
"test:unit:node": "node tests/filenameSanitizer.test.cjs && node tests/cardBoardConsistency.test.cjs && node tests/cardUrl.test.cjs && node tests/defaultSwimlane.test.cjs && node tests/cardMoveModifier.test.cjs && node tests/authenticationMethod.test.cjs && node tests/linkedCardTarget.test.cjs && node tests/staleWhileRevalidate.test.cjs && node tests/restArrayParam.test.cjs && node tests/minicardLabelHeight.test.cjs && node tests/dateUtils.normalizeDigits.test.cjs && node tests/reconcileBoardTeamMembers.test.cjs && node tests/rulesBoardSelector.test.cjs && node tests/notificationCleanup.test.cjs && node tests/cardSearch.test.cjs && node tests/linkedCardMembers.test.cjs && node tests/verificationEmail.test.cjs && node tests/boardSortReorder.test.cjs && node tests/checklistItemTitles.test.cjs && node tests/swimlaneFilter.test.cjs && node tests/popupOffset.test.cjs && node tests/unit/resetPasswordEmail.test.js",
"test:unit:all": "npm run test:unit:node && node tests/cards.stickers.test.js && node tests/stickers.test.js && node tests/stickers.packs.test.js && node tests/linkCardPopup.test.js && node tests/trelloCreator.import.test.js && node tests/trelloImport.maplink.test.js && node tests/trelloImport.zip.test.js && node tests/wekanCreator.import.test.js && node tests/wekanCreator.inconsistent.test.js && node tests/unit/oauth2-admin-groups.test.js",
"test:watch": "meteor test --driver-package meteortesting:mocha",
"test:e2e": "node tests/e2e/list-regressions.js",
+162
View File
@@ -0,0 +1,162 @@
'use strict';
// Plain-Node unit test (no DOM/Meteor) for the popup positioning geometry.
// Run: node tests/popupOffset.test.cjs
//
// Regression guard for #5667 ("Problem when scrolling in a map with date
// fields"): a date-picker opened low on a SCROLLED page used to be positioned
// from the opener's DOCUMENT offset mixed with the VIEWPORT height, so it landed
// partly outside the visible viewport (and, being position:absolute, scrolled
// away when the user tried to reach it). computePopupOffset must lay the popup
// out so it is fully within the visible viewport for any scroll position, while
// staying byte-identical to the old behaviour when the page is not scrolled.
const assert = require('assert');
const { computePopupOffset } = require('../client/lib/popupOffset');
let passed = 0;
function test(name, fn) {
fn();
passed += 1;
console.log(' ok -', name);
}
const VW = 1200;
const VH = 800;
const PAD = 10;
// The popup box in VIEWPORT coordinates (result.top is a DOCUMENT coordinate).
function viewportBox(result, scrollTop) {
const topVp = result.top - scrollTop;
return { topVp, bottomVp: topVp + (result.maxHeight || 0) };
}
function assertFullyVisible(result, scrollTop, label) {
const { topVp, bottomVp } = viewportBox(result, scrollTop);
assert.ok(topVp >= 0, `${label}: top ${topVp} must be >= 0 (in viewport)`);
assert.ok(
bottomVp <= VH + 0.001,
`${label}: bottom ${bottomVp} must be <= viewportHeight ${VH}`,
);
}
// --- POSITIVE: fully visible regardless of scroll / opener position ---------
test('#5667: low opener on a scrolled page stays fully within the viewport', () => {
const scrollTop = 1000;
// Opener is near the BOTTOM of the viewport (viewport y ~= 720), so its
// document top is 1000 + 720.
const result = computePopupOffset({
viewportWidth: VW,
viewportHeight: VH,
scrollTop,
opener: { top: scrollTop + 720, left: 300, height: 24 },
popupName: 'editCardDueDatePopup',
});
assertFullyVisible(result, scrollTop, 'low+scrolled');
// A low opener has more room above, so it opens upward (bottom is at/above the opener).
assert.ok(result.top - scrollTop < 720, 'should open above the low opener');
});
test('#5667: opener near the top of a scrolled page does not overflow the top', () => {
const scrollTop = 1000;
const result = computePopupOffset({
viewportWidth: VW,
viewportHeight: VH,
scrollTop,
opener: { top: scrollTop + 40, left: 300, height: 24 },
popupName: 'editCardDueDatePopup',
});
assertFullyVisible(result, scrollTop, 'top+scrolled');
});
test('opener in the upper half opens below and stays visible', () => {
const result = computePopupOffset({
viewportWidth: VW,
viewportHeight: VH,
scrollTop: 0,
opener: { top: 100, left: 300, height: 24 },
popupName: 'editCardDueDatePopup',
});
assert.strictEqual(result.top, 124, 'opens just below the opener (100+24)');
assertFullyVisible(result, 0, 'upper-half');
});
test('scrollLeft keeps the popup within the viewport horizontally', () => {
const result = computePopupOffset({
viewportWidth: VW,
viewportHeight: VH,
scrollTop: 0,
scrollLeft: 500,
opener: { top: 100, left: 500 + 1195, height: 24 }, // far right in viewport
popupName: 'editCardDueDatePopup',
});
const leftVp = result.left - 500;
const popupWidth = Math.min(380, VW * 0.55);
assert.ok(leftVp >= PAD, 'left within viewport');
assert.ok(leftVp + popupWidth <= VW - PAD + 0.001, 'right edge within viewport');
});
// --- Behaviour parity when NOT scrolled (no regression) ---------------------
test('unscrolled output equals the pre-fix behaviour (below case)', () => {
const r = computePopupOffset({
viewportWidth: VW, viewportHeight: VH, scrollTop: 0,
opener: { top: 200, left: 300, height: 24 }, popupName: 'x',
});
// Old code: top = openerBottom = 224; maxHeight = min(spaceBelow, 0.8*VH).
const spaceBelow = VH - 224 - PAD;
assert.strictEqual(r.top, 224);
assert.strictEqual(r.maxHeight, Math.min(spaceBelow, VH * 0.8));
});
test('special popups: cardDetails / no-opener / admin edit', () => {
const cd = computePopupOffset({ viewportWidth: VW, viewportHeight: VH, scrollTop: 0, popupName: 'cardDetailsPopup' });
assert.strictEqual(cd.top, 0);
assert.strictEqual(cd.maxHeight, VH);
const none = computePopupOffset({ viewportWidth: VW, viewportHeight: VH, scrollTop: 0, opener: null, popupName: 'x' });
assert.deepStrictEqual(none, { left: PAD, top: PAD, maxHeight: VH - PAD * 2 });
const mini = computePopupOffset({ viewportWidth: VW, viewportHeight: VH, isMiniScreen: true, popupName: 'x' });
assert.deepStrictEqual(mini, { left: 0, top: 0 });
});
// --- NEGATIVE / the actual bug: the OLD formula overflowed when scrolled -----
test('#5667 regression: the OLD document-coord formula placed it OFF-SCREEN', () => {
const scrollTop = 1000;
const opener = { top: scrollTop + 40, left: 300, height: 24 }; // near viewport top
// Reproduce the pre-fix math (document offset mixed with viewport height).
const openerTop = opener.top;
const openerBottom = opener.top + opener.height;
const spaceBelow = VH - openerBottom - PAD;
const spaceAbove = openerTop - PAD;
const preferBelow = spaceBelow >= spaceAbove;
let oldTop, oldMax;
if (preferBelow) {
oldMax = Math.max(0, Math.min(spaceBelow, VH * 0.8));
oldTop = openerBottom;
} else {
oldMax = Math.max(0, Math.min(spaceAbove, VH * 0.8));
oldTop = Math.max(PAD, openerTop - oldMax);
}
const oldTopVp = oldTop - scrollTop;
// The old popup's TOP edge is far above the visible viewport (negative) — the bug.
assert.ok(oldTopVp < 0, `old top ${oldTopVp} should be off-screen (the bug)`);
// The fixed function keeps it on-screen for the exact same inputs.
const fixed = computePopupOffset({
viewportWidth: VW, viewportHeight: VH, scrollTop, opener, popupName: 'editCardDueDatePopup',
});
assertFullyVisible(fixed, scrollTop, 'fixed');
});
test('maxHeight is never negative for an opener above the viewport', () => {
const scrollTop = 1000;
const result = computePopupOffset({
viewportWidth: VW, viewportHeight: VH, scrollTop,
opener: { top: scrollTop - 500, left: 300, height: 24 }, // scrolled past, above viewport
popupName: 'editCardDueDatePopup',
});
assert.ok(result.maxHeight >= 0, 'maxHeight must never be negative');
});
console.log(`\n${passed} tests passed`);