Skip to content

Codaxy/fix/iframe portal lookup dropdown position update - #1325

Merged
mstijak merged 5 commits into
masterfrom
codaxy/fix/iframe-portal-lookup-dropdown-position-update
Sep 9, 2026
Merged

Codaxy/fix/iframe portal lookup dropdown position update#1325
mstijak merged 5 commits into
masterfrom
codaxy/fix/iframe-portal-lookup-dropdown-position-update

Conversation

@milankovacevic-codaxy

Copy link
Copy Markdown
Contributor

LookupField (and other Dropdown-based fields: ColorField, DateTimeField, MonthField) mispositions its popup and fails to dismiss correctly when the field is rendered inside an <iframe> via a React portal (single React tree / JS realm, DOM output split across the top document and the iframe's own document; the same technique used by CSS-isolation helpers like react-frame-component).

Two independent bugs combine to produce this:

Popup renders in the wrong coordinate space. On a normal desktop pointer, Dropdown's popup is rendered inline (not portaled to document.body). When the field lives inside an iframe, that popup ends up as a DOM child inside the iframe's own document, so its position: fixed resolves against the iframe's viewport. But Dropdown.updateDropdownPosition always called getTopLevelBoundingClientRect(relatedElement), which unconditionally adds the iframe's own offset within the top document - a conversion that's only correct when the popup is actually portaled into the top document (e.g. Window, Tooltip, touch-friendly dropdowns). The result: the popup renders shifted by roughly the iframe's own left/top offset instead of appearing next to the field.

Dismiss-on-focus-out can't see across the iframe boundary. getActiveElement() read only the top document's document.activeElement, which the browser reports as the <iframe> element itself for any focus change inside it and never the actual focused element, and unchanged for every subsequent focus move within that iframe. FocusManager's polling loop and isSelfOrDescendant (el.contains(...), which is always false across documents) then misfire: opening the dropdown triggers an immediate false "focus left" dismissal (visible as an open/close flicker), and afterwards no further focus changes inside the iframe are ever detected, so the dropdown won't dismiss until focus returns to the top document.

Root cause:
Several places in the positioning/focus code implicitly assumed a single, global document - correct for the common case, but wrong once part of the widget tree renders inside a different document that shares the same JS realm.

Solution:
packages/cx/src/widgets/overlay/Dropdown.tsx

updateDropdownPosition: only convert relatedElement's rect into top-document coordinates when the popup element (el) and relatedElement actually live in different documents; otherwise use relatedElement.getBoundingClientRect() directly (they already share a coordinate space).

applyFixedPositioningPlacementStyles / applyAbsolutePositioningPlacementStyles: derive viewport width/height from el.ownerDocument instead of the global document, so available-room/flip-placement math and edge-anchored (right/bottom) styles are correct when the popup's containing viewport isn't the top document.

findOptimalPlacement: now takes the popup element so its placement scoring uses the same document-correct viewport.

getViewportRect (module helper): takes an optional doc parameter, defaulting to the global document - no behavior change for the non-iframe case.

packages/cx/src/util/getActiveElement.ts

getActiveElement() now recurses into a focused <iframe>'s own contentDocument (recursively, for nested iframes) to return the truly-focused element, instead of stopping at the <iframe> node. This is a shared low-level fix - every consumer (FocusManager, Overlay's focus-out handling, blur checks in ColorField/DateTimeField/MonthField/NumberField/TextArea/TextField/Grid/MenuItem) benefits with no call-site changes.

Both fixes are additive/conditional: for the standard (non-iframe) case, el.ownerDocument === relatedElement.ownerDocument and doc.activeElement is never an <iframe>, so the new code paths are no-ops and existing behavior is unchanged.

Test plan
Added litmus repros under litmus/features/dropdown/ for manual verification (and for comparing against a genuinely separate iframe document, where this bug doesn't apply):
lookup-inside-iframe-portal.js + IFramePortal.js - reproduces both bugs (portal-based iframe embedding).
lookup-inside-real-iframe.js - control case: a LookupField in a truly separate iframe document/window, for comparison.

Manual checks:
Open the dropdown for the iframe-portal LookupField - it opens directly next to the field (no offset), matching the non-iframe field on the same page.
No open/close flicker on first click.
Clicking other content inside the same iframe dismisses the dropdown.
Clicking outside the iframe still dismisses the dropdown (regression check).
Existing non-iframe dropdown/overlay behavior (Window, Tooltip, context menus, LookupField/ColorField/DateTimeField/MonthField) is unaffected.

@mstijak

mstijak commented Sep 7, 2026

Copy link
Copy Markdown
Member

Review

Nine findings. Correctness issues first, then quality.

Correctness

1. Iframe-aware getActiveElement dismisses overlays that contain an iframepackages/cx/src/util/getActiveElement.ts:9

Drilling into the focused iframe's document returns an inner element, but isSelfOrDescendant uses Node.contains, which never crosses document boundaries. Any top-document overlay with dismissOnFocusOut (ContextMenu has it on by default, or a Window/Dropdown with it enabled) that embeds a same-origin iframe now receives a spurious focus-out from FocusManager.nudge() and dismisses when the user clicks inside the iframe. Menu.onFocusOut clears the cursor the same way. Before this PR the <iframe> element itself was returned and counted as contained, so the overlay stayed open.

Fix: make containment frame-aware (walk node.ownerDocument.defaultView.frameElement up to el.ownerDocument in isSelfOrDescendant/FocusManager), or return the <iframe> element unless the caller's el is inside that iframe.

2. Dropdown inside an iframe never repositions on the iframe's own scroll or resizepackages/cx/src/widgets/overlay/Dropdown.tsx:207

The new same-document branch positions fixed against the iframe viewport, but overlayDidMount still subscribes scroll only on the top window plus relatedElement's parentElement chain, and ResizeManager only binds the top window's resize. Document-level scroll fires on the iframe Document/Window, not on <html> where the parent walk stops, so wheel-scrolling inside the iframe leaves the dropdown pinned while the field scrolls away, and closeOnScrollDistance never fires. Resizing the <iframe> element has the same problem.

Fix: push relatedElement.ownerDocument.defaultView (and ancestor frame windows) into scrollableParents and subscribe resize on it.

3. Viewport size for an iframe uses the <html> offset box, not the real viewportDropdown.tsx:984

getViewportRect and the fixed-placement math use doc.documentElement.offsetWidth/offsetHeight, but position: fixed bottom/right resolve against the actual iframe viewport. The two agree only when the iframe's CSS sets html { height: 100% }, which the cx theme emits only behind $cx-include-global-rules (default false). The litmus repro masks this because copyStyles clones every outer stylesheet including the litmus html height rule.

Example: style-isolated iframe 600px tall, content ~150px. viewport.bottom = 150, so findOptimalPlacement scores "down" as no-room and picks "up"; style.bottom = 150 - rel.top + offset then resolves from the 600px viewport bottom and the dropdown renders ~450px above the field.

Fix: use doc.documentElement.clientWidth/clientHeight or doc.defaultView.innerWidth/innerHeight.

4. getActiveElement can now return null despite its Element return typegetActiveElement.ts:12

if (frameDoc) return getActiveElement(frameDoc) drops the old non-null guarantee: when the focused iframe's document has neither activeElement nor body (after document.open(), mid-navigation), the function returns null. Grid cell-edit cursor moves call unfocusElement(null, false)activeElement is nullclosestParent(null) is falsy → (activeElement as HTMLElement).blur() in FocusManager throws. The doc?. optional chaining on line 4 is dead (doc was just assigned) and hides the real nullability.

Fix: const inner = getActiveElement(frameDoc); return inner ?? active; and write the signature as getActiveElement(doc: Document = document) with plain doc.activeElement ?? doc.body.

5. trackMouse dropdowns inside an iframe mix coordinate spacesDropdown.tsx:308

The same-document branch produces iframe-local parentBounds, but instance.mousePosition comes from getCursorPos, which adds the parent-frame offset and is in top-document coordinates. A Tooltip with trackMouse (or any Dropdown with trackMouseX/Y) rendered inline inside an IFramePortal offset 300px from the page edge has parentBounds.left replaced by a top-document x while top and the viewport are iframe-local, so it renders 300px off on the tracked axis. Before the PR both were top-document and consistently wrong by the iframe offset; now only the tracked axis is wrong.

Fix: subtract getParentFrameBoundingClientRect(relatedElement) from mousePosition in the same-document branch, or capture mousePosition in the dropdown's document space.

6. Two disagreeing definitions of "the focused element"packages/cx/src/util/DOM.ts:54

isFocused(), isFocusedDeep(), getFocusedElement() (DOM.ts 54/58/79) and MenuItem.tsx:388 still read the outer document.activeElement, while getActiveElement is now iframe-aware. Inside the PR's IFramePortal, MenuItem.onMouseEnter uses el.contains(getActiveElement()) (iframe-aware) while onMouseDown uses isFocusedDeep(this.el) and onKeyDown uses isFocused(this.el), both unconditionally false there because the outer activeElement is the <iframe>. Mousedown always steals focus from an already-focused submenu child and never preventDefaults; Esc on an already-focused item is always swallowed by stopPropagation so an enclosing Window/Dropdown never dismisses. GridCellEditor.tsx:53 and Menu.tsx:326 re-focus the first child the same way.

Fix: make the DOM.ts helpers delegate to getActiveElement().

Quality

7. Litmus labels are swapped and the prose is wronglitmus/features/dropdown/lookup-inside-iframe-portal.js:62

The field labelled "IFrame Portal Lookup (inline)" is the one configured with dropdownOptions={{ inline: false }}, while the actually inline-rendered field (line 72, LookupField defaults to inline: !isTouchDevice()) has the plain label. The paragraph at lines 44-47 claims IFramePortal blocks outer stylesheets, but copyStyles (IFramePortal.js:41) copies every outer stylesheet including style.scss's red rule. Someone verifying the fix on the "(inline)" field sees the portaled case, which never had the bug. Swap the label suffixes and correct the prose.

8. Owning document resolved three different waysDropdown.tsx:762

el.ownerDocument || document at 425 and 553, el?.ownerDocument at 769 via a newly optional el? 5th param on findOptimalPlacement relying on getViewportRect's = document default. Element.ownerDocument is never null, so the fallbacks are dead, and the optional parameter is always passed by its single caller (line 344). Omitting it silently scores placements against the top-level viewport, which is exactly the bug this PR fixes. Resolve const doc = el.ownerDocument once in updateDropdownPosition and pass it down through required parameters.

9. Whole-file reindent mixed into the fix commitDropdown.tsx

Commit 9c5ed96 reindents the file from 2-space to the .editorconfig 3-space style (raw diff 895+/884-, whitespace-insensitive 31+/20-) in the same commit as the logic fix. The GitHub diff shows every line changed so the ~50-line fix is unreviewable without ?w=1, git blame on all ~980 lines now points at the bug-fix commit, and any other branch touching Dropdown.tsx conflicts on every line. The reformat is fine, but land it as a separate style: commit before the fix.

- Implemented context menu handling for focusable elements within iframes to prevent premature dismissal.
- Updated dropdown positioning logic to account for elements residing in different documents (iframes).
- Improved active element retrieval across document boundaries to ensure accurate focus management.
@mstijak
mstijak merged commit 62c27a8 into master Sep 9, 2026
2 checks passed
@mstijak
mstijak deleted the codaxy/fix/iframe-portal-lookup-dropdown-position-update branch September 9, 2026 07:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants