diff --git a/Valour/Client/ContextMenu/ContextMenuRoot.razor.js b/Valour/Client/ContextMenu/ContextMenuRoot.razor.js index e6d1aa3e8..9159c67aa 100644 --- a/Valour/Client/ContextMenu/ContextMenuRoot.razor.js +++ b/Valour/Client/ContextMenu/ContextMenuRoot.razor.js @@ -21,6 +21,26 @@ export function clearMenu() { currentMenu = null; } +// Margin kept between a repositioned submenu and the viewport edge. +const SUBMENU_EDGE_MARGIN = 10; + +export function computeSubmenuTranslate(boundingBox, windowWidth, windowHeight, margin = SUBMENU_EDGE_MARGIN){ + let translateX = 0; + let translateY = 0; + + if (boundingBox.right > windowWidth){ + translateX = windowWidth - boundingBox.right - margin; + } + + if (boundingBox.top < 0){ + translateY = Math.abs(boundingBox.top) + margin; + } else if (boundingBox.bottom > windowHeight){ + translateY = windowHeight - boundingBox.bottom - margin; + } + + return { translateX, translateY }; +} + export function reposition(){ if (!currentMenu) @@ -70,16 +90,7 @@ export function reposition(){ submenu.style.transform = ''; const boundingBox = submenu.getBoundingClientRect(); - let translateX = 0; - let translateY = 0; - - if (boundingBox.right > windowWidth){ - translateX = windowWidth - boundingBox.right - 10; - } - - if (boundingBox.top < 0){ - translateY = Math.abs(boundingBox.top) + 10; - } + const { translateX, translateY } = computeSubmenuTranslate(boundingBox, windowWidth, windowHeight); if (translateX !== 0 || translateY !== 0){ submenu.style.transform = `translate(${translateX}px, ${translateY}px)`; diff --git a/Valour/Tests/Js/context-menu-reposition.test.mjs b/Valour/Tests/Js/context-menu-reposition.test.mjs new file mode 100644 index 000000000..8867f5adf --- /dev/null +++ b/Valour/Tests/Js/context-menu-reposition.test.mjs @@ -0,0 +1,45 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; + +// computeSubmenuTranslate must keep submenus fully inside the viewport on all four edges. +// Submenus anchor via `bottom: -50%` off their parent button, so one +// near the bottom edge can open a submenu that extends past the viewport bottom. +const checks = []; +const check = (name, actual, expected) => checks.push([name, actual, expected]); + +const { computeSubmenuTranslate } = await import('../../Client/ContextMenu/ContextMenuRoot.razor.js'); + +const windowWidth = 1280; +const windowHeight = 720; + +check('1. submenu fully in bounds gets no correction', + computeSubmenuTranslate({ right: 500, top: 100, bottom: 300 }, windowWidth, windowHeight), + { translateX: 0, translateY: 0 }); + +check('2. submenu overflowing the right edge is pulled left by the overflow plus margin', + computeSubmenuTranslate({ right: 1300, top: 100, bottom: 300 }, windowWidth, windowHeight), + { translateX: -30, translateY: 0 }); + +check('3. submenu overflowing the top edge is pushed down by the overflow plus margin', + computeSubmenuTranslate({ right: 500, top: -15, bottom: 300 }, windowWidth, windowHeight), + { translateX: 0, translateY: 25 }); + +check('4. submenu overflowing the bottom edge is pulled up by the overflow plus margin', + computeSubmenuTranslate({ right: 500, top: 650, bottom: 760 }, windowWidth, windowHeight), + { translateX: 0, translateY: -50 }); + +check('5. right and bottom overflow are corrected independently at once', + computeSubmenuTranslate({ right: 1320, top: 650, bottom: 780 }, windowWidth, windowHeight), + { translateX: -50, translateY: -70 }); + +check('6. top-edge overflow takes precedence over bottom-edge overflow', + computeSubmenuTranslate({ right: 500, top: -5, bottom: 900 }, windowWidth, windowHeight), + { translateX: 0, translateY: 15 }); + +check('7. custom margin is honored', + computeSubmenuTranslate({ right: 500, top: 100, bottom: 740 }, windowWidth, windowHeight, 0), + { translateX: 0, translateY: -20 }); + +for (const [name, actual, expected] of checks) { + test(name, () => assert.deepEqual(actual, expected)); +}