From 4078229613b111906b44e946ad3f1a54f002f81b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?K=C3=A9vin=20Barfleur?= <39409397+kevinbarfleur@users.noreply.github.com> Date: Wed, 1 Jul 2026 05:04:01 +0200 Subject: [PATCH 1/2] feat(WW-4125): bindable position + auto vertical/horizontal opening direction --- src/wwElement.vue | 84 ++++++++++++++++++++++++++++++++++++++++++++++- ww-config.js | 34 +++++++++++++++++-- 2 files changed, 115 insertions(+), 3 deletions(-) diff --git a/src/wwElement.vue b/src/wwElement.vue index 1b91809..18a97c2 100644 --- a/src/wwElement.vue +++ b/src/wwElement.vue @@ -93,6 +93,8 @@ export default { }); const triggerBox = ref({}); + const resolvedPosition = ref(props.content.position); + const synchronizeTriggerBox = () => { if (!triggerElementRef?.value) return; const box = triggerElementRef.value.getBoundingClientRect(); @@ -104,6 +106,73 @@ export default { width: box.width, height: box.height, }; + computeResolvedPosition(); + }; + + const computeResolvedPosition = () => { + const position = props.content.position; + const box = triggerBox.value; + if (!box || box.width === undefined) { + resolvedPosition.value = position; + return; + } + + const frontWindow = wwLib.getFrontWindow(); + const viewportHeight = frontWindow.innerHeight; + const viewportWidth = frontWindow.innerWidth; + + const dropdownRect = dropdownElementRef?.value?.getBoundingClientRect(); + const dropdownHeight = dropdownRect?.height || box.height; + const dropdownWidth = dropdownRect?.width || box.width; + + const parseOffset = (value) => { + const parsed = parseFloat(value); + return Number.isFinite(parsed) ? parsed : 0; + }; + const offsetY = parseOffset(props.content.offsetY); + const offsetX = parseOffset(props.content.offsetX); + + if ( + props.content.autoVertical && + (position === "top" || position === "bottom") + ) { + const spaceBelow = viewportHeight - box.bottom - offsetY; + const spaceAbove = box.top - offsetY; + const fitsBelow = spaceBelow >= dropdownHeight; + const fitsAbove = spaceAbove >= dropdownHeight; + if (!fitsBelow && (fitsAbove || spaceAbove > spaceBelow)) { + resolvedPosition.value = "top"; + } else { + resolvedPosition.value = "bottom"; + } + return; + } + + if ( + props.content.autoHorizontal && + (position === "left" || position === "right") + ) { + const spaceRight = viewportWidth - box.right - offsetX; + const spaceLeft = box.left - offsetX; + const fitsRight = spaceRight >= dropdownWidth; + const fitsLeft = spaceLeft >= dropdownWidth; + if (position === "right") { + if (!fitsRight && (fitsLeft || spaceLeft > spaceRight)) { + resolvedPosition.value = "left"; + } else { + resolvedPosition.value = "right"; + } + } else { + if (!fitsLeft && (fitsRight || spaceRight > spaceLeft)) { + resolvedPosition.value = "right"; + } else { + resolvedPosition.value = "left"; + } + } + return; + } + + resolvedPosition.value = position; }; function onWindowClick(event) { @@ -189,6 +258,7 @@ export default { delayedIsClosed.value = false; nextTick(() => { delayedIsOpen.value = true; + nextTick(() => computeResolvedPosition()); }); } else { stopPositioningDropdown(); @@ -221,10 +291,22 @@ export default { } ); + watch( + () => [ + props.content.position, + props.content.autoVertical, + props.content.autoHorizontal, + ], + () => { + computeResolvedPosition(); + } + ); + return { appDiv, synchronizeTriggerBox, triggerBox, + resolvedPosition, isOpened, timeoutId, isEditing, @@ -237,7 +319,7 @@ export default { computed: { style() { const style = {}; - const position = this.content.position; + const position = this.resolvedPosition; const alignment = this.content.alignment; const offsetX = diff --git a/ww-config.js b/ww-config.js index f7fea51..0ca5d99 100644 --- a/ww-config.js +++ b/ww-config.js @@ -49,10 +49,40 @@ export default { classes: true, states: true, responsive: true, + /* wwEditor:start */ bindingValidation: { type: 'string', - tooltip: 'A string that defines the position of the dropdown. Possible values are top, right, bottom, and left.', - } + tooltip: 'A string that defines the position of the dropdown. Possible values are top, right, bottom, and left. Bind it to drive the opening direction from a formula (e.g. the trigger position on the page).', + }, + /* wwEditor:end */ + }, + autoVertical: { + type: "OnOff", + label: { + en: "Auto vertical", + }, + defaultValue: false, + bindable: true, + /* wwEditor:start */ + bindingValidation: { + type: "boolean", + tooltip: "A boolean that, when true, flips a top/bottom dropdown to the opposite side when it would overflow the viewport (bottom by default, flips to top near the bottom edge).", + }, + /* wwEditor:end */ + }, + autoHorizontal: { + type: "OnOff", + label: { + en: "Auto horizontal", + }, + defaultValue: false, + bindable: true, + /* wwEditor:start */ + bindingValidation: { + type: "boolean", + tooltip: "A boolean that, when true, flips a left/right dropdown to the opposite side when it would overflow the viewport (near the left or right edge).", + }, + /* wwEditor:end */ }, alignment: { label: { en: "Align" }, From fae350dbf1d137c29f2b441a692dfd2eb0e57df3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9o=20Coletta?= Date: Fri, 31 Jul 2026 10:48:52 +0200 Subject: [PATCH 2/2] fix(WW-4125): Handle rendered dropdown positioning --- src/wwElement.vue | 66 +++++++++++++++++++++++++++++++++++------------ 1 file changed, 50 insertions(+), 16 deletions(-) diff --git a/src/wwElement.vue b/src/wwElement.vue index 18a97c2..1390a78 100644 --- a/src/wwElement.vue +++ b/src/wwElement.vue @@ -121,16 +121,38 @@ export default { const viewportHeight = frontWindow.innerHeight; const viewportWidth = frontWindow.innerWidth; - const dropdownRect = dropdownElementRef?.value?.getBoundingClientRect(); - const dropdownHeight = dropdownRect?.height || box.height; - const dropdownWidth = dropdownRect?.width || box.width; + const dropdownElement = dropdownElementRef?.value; + if (!dropdownElement) { + resolvedPosition.value = position; + return; + } - const parseOffset = (value) => { - const parsed = parseFloat(value); - return Number.isFinite(parsed) ? parsed : 0; + const dropdownHeight = dropdownElement.offsetHeight || box.height; + const dropdownWidth = dropdownElement.offsetWidth || box.width; + const computedStyle = frontWindow.getComputedStyle(dropdownElement); + const renderedPosition = resolvedPosition.value; + + const getRenderedOffset = (property, reference) => { + const value = Number.parseFloat(computedStyle[property]); + return Number.isFinite(value) ? value - reference : 0; }; - const offsetY = parseOffset(props.content.offsetY); - const offsetX = parseOffset(props.content.offsetX); + + let offsetY = 0; + let offsetX = 0; + switch (renderedPosition) { + case "top": + offsetY = getRenderedOffset("bottom", viewportHeight - box.top); + break; + case "bottom": + offsetY = getRenderedOffset("top", box.bottom); + break; + case "left": + offsetX = getRenderedOffset("right", viewportWidth - box.left); + break; + case "right": + offsetX = getRenderedOffset("left", box.right); + break; + } if ( props.content.autoVertical && @@ -222,15 +244,17 @@ export default { scrollableParents.push(wwLib.getFrontWindow()); } + function observeDropdownSize() { + if (!resizeObserver || !dropdownElementRef.value) return; + resizeObserver.observe(dropdownElementRef.value); + } + function startPositioningDropdown() { synchronizeTriggerBox(); wwLib.getFrontDocument().addEventListener("click", onWindowClick); - resizeObserver = new ResizeObserver((entries) => { - const entry = entries[0]; - triggerBox.value.width = entry.contentRect.width; - triggerBox.value.height = entry.contentRect.height; - }); + resizeObserver = new ResizeObserver(synchronizeTriggerBox); resizeObserver.observe(triggerElementRef.value); + observeDropdownSize(); setScrollableParents(triggerElementRef.value); scrollableParents.forEach((p) => { p.addEventListener("scroll", synchronizeTriggerBox, { passive: true }); @@ -243,6 +267,7 @@ export default { function stopPositioningDropdown() { wwLib.getFrontDocument().removeEventListener("click", onWindowClick); resizeObserver?.disconnect(); + resizeObserver = null; scrollableParents.forEach((p) => { p.removeEventListener("scroll", synchronizeTriggerBox); wwLib @@ -258,7 +283,11 @@ export default { delayedIsClosed.value = false; nextTick(() => { delayedIsOpen.value = true; - nextTick(() => computeResolvedPosition()); + nextTick(() => { + if (!isDisplayed.value) return; + observeDropdownSize(); + computeResolvedPosition(); + }); }); } else { stopPositioningDropdown(); @@ -296,9 +325,14 @@ export default { props.content.position, props.content.autoVertical, props.content.autoHorizontal, + props.content.offsetX, + props.content.offsetY, ], - () => { - computeResolvedPosition(); + ([position], [previousPosition]) => { + if (position !== previousPosition) { + resolvedPosition.value = position; + } + nextTick(() => computeResolvedPosition()); } );