Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions litmus/features/dropdown/IFramePortal.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { createPortal } from "react-dom";
import { useEffect, useRef, useState } from "react";

function copyStyles(sourceDoc, targetDoc) {
Array.from(sourceDoc.styleSheets).forEach((styleSheet) => {
try {
if (styleSheet.href) {
let link = targetDoc.createElement("link");
link.rel = "stylesheet";
link.href = styleSheet.href;
targetDoc.head.appendChild(link);
} else if (styleSheet.ownerNode && styleSheet.ownerNode.textContent) {
// dev builds inject theme/widget CSS as inline <style> tags (style-loader),
// which have no .href, so clone the tag's contents instead
let style = targetDoc.createElement("style");
style.textContent = styleSheet.ownerNode.textContent;
targetDoc.head.appendChild(style);
}
} catch (err) {
console.warn("IFramePortal: could not copy stylesheet", err);
}
});
}

// Renders `children` inside a same-origin iframe via a React portal, while the
// <iframe> element itself stays a normal sibling in the outer document's DOM/layout.
// Single React tree, single JS realm - only the DOM output is split across two
// documents. This mirrors iframe-based style isolation helpers (e.g. react-frame-component).
export const IFramePortal = ({ children, style }) => {
let iframeRef = useRef(null);
let [containerEl, setContainerEl] = useState(null);

useEffect(() => {
let iframeEl = iframeRef.current;

function handleLoad() {
let doc = iframeEl.contentDocument;
doc.body.style.margin = "0";
let container = doc.createElement("div");
doc.body.appendChild(container);
copyStyles(document, doc);
setContainerEl(container);
}

if (iframeEl.contentDocument.readyState === "complete") handleLoad();
else iframeEl.addEventListener("load", handleLoad);

return () => iframeEl.removeEventListener("load", handleLoad);
}, []);

return (
<div>
<iframe ref={iframeRef} style={style} />
{containerEl && createPortal(children, containerEl)}
</div>
);
};
57 changes: 57 additions & 0 deletions litmus/features/dropdown/context-menu-iframe-focus.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { openContextMenu } from "cx/widgets";
import { Controller } from "cx/ui";
import { IFramePortal } from "./IFramePortal";

// Bug repro: ContextMenu has dismissOnFocusOut enabled by default. Its content
// below embeds a same-origin iframe (via IFramePortal) with a focusable text
// input. Right-click the box to open the menu, then click inside the
// iframe's input field - the menu should stay open, since focus never
// actually left the menu.
//
// Before the fix: the outer document's `document.activeElement` only ever
// reports the <iframe> element itself as focused, and Node.contains() cannot
// see across the document boundary into the iframe's own document, so
// FocusManager treated this as focus leaving the menu entirely and dismissed
// it immediately.

class PageController extends Controller {}

export default (
<cx>
<div
controller={PageController}
style="font-family: sans-serif; padding: 16px; display: flex; flexDirection: column;"
>
<p style="max-width: 480px; margin: 0 0 16px;">
Right-click the box below to open a ContextMenu, then click inside the
embedded iframe's text input. The menu should remain open while
that field has focus.
</p>
<div
onContextMenu={(e, instance) =>
openContextMenu(
e,
<cx>
<div style="padding: 8px;">
<div style="margin-bottom: 8px;">
Menu content with an embedded iframe:
</div>
<IFramePortal
style={{ width: "260px", height: "90px", border: "1px solid #999" }}
>
<div style="padding: 8px; font-family: sans-serif;">
<input type="text" placeholder="Focus me" style="width: 100%;" />
</div>
</IFramePortal>
</div>
</cx>,
instance,
)
}
style="padding: 40px; border: 1px dashed #999; width: 300px; text-align: center;"
>
Right Click Here
</div>
</div>
</cx>
);
100 changes: 100 additions & 0 deletions litmus/features/dropdown/lookup-inside-iframe-portal.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { Controller, LabelsTopLayout } from "cx/ui";
import { LookupField } from "cx/widgets";
import { IFramePortal } from "./IFramePortal";
import "./style.scss";

// Bug repro: a LookupField's dropdown is expected to open right next to the field.
// Here the field is rendered inside an <iframe> (via a React portal - single JS
// realm/React tree, DOM split across two documents), and the iframe itself is
// offset from the parent document's top-left corner. Opening the dropdown shows
// it positioned away from the field, roughly shifted by the iframe's own
// left/top offset within the parent document.

class PageController extends Controller {
onInit() {
this.store.init("city", 1);
this.store.init("cities", [
{ id: 1, text: "New York" },
{ id: 2, text: "London" },
{ id: 3, text: "Paris" },
{ id: 4, text: "Berlin" },
{ id: 5, text: "Tokyo" },
{ id: 6, text: "Sydney" },
{ id: 7, text: "Toronto" },
{ id: 8, text: "Madrid" },
]);
}
}

export default (
<cx>
<div
style="font-family: sans-serif; display: flex; flexDirection: column;"
controller={PageController}
>
<p style="max-width: 420px; margin: 0 0 16px;">
Bug repro: The LookupField below is portaled inside an iframe that is
offset from the top-left corner of the parent document. Open the
dropdown — it should appear directly above or below the field, but
instead it is positioned incorrectly elsewhere on the page.
</p>
<p style="max-width: 420px; margin: 0 0 16px;">
Setting inline=false on the inner lookup would fix the dropdown
positioning, but it would prevent the dropdown from being isolated from
the parent document's stylesheets. Isolating the stylesheets is the
purpose of IFramePortal, so this is not a suitable workaround.
</p>

<p style="max-width: 420px; margin: 0 0 16px;">
IFramePortal copies every outer stylesheet into the iframe document (see
copyStyles), so both rules below - the red base rule and the more
specific blue ".inner-lookup" override - are present inside the iframe.
The lookup rendered inline (actually inside the iframe DOM, nested under
".inner-lookup") matches the blue override. The lookup with
dropdownOptions inline false portals its dropdown out to the parent
document's body instead, outside the ".inner-lookup" ancestor, so it
only matches the red base rule and is not isolated from the parent
document at all.
</p>
<LabelsTopLayout>
<LookupField
label="Parent Document Lookup"
value-bind="city"
options-bind="cities"
placeholder="Select a city..."
/>
</LabelsTopLayout>

<IFramePortal
style={{
width: "1000px",
height: "600px",
marginLeft: "300px",
border: "2px solid #c0392b",
}}
>
<div style="padding: 40px;">
<LabelsTopLayout>
<LookupField
class="inner-lookup"
label="IFrame Portal Lookup"
value-bind="city"
options-bind="cities"
placeholder="Select a city..."
dropdownOptions={{
inline: false,
}}
/>
<LookupField
class="inner-lookup"
label="IFrame Portal Lookup (inline)"
value-bind="city"
options-bind="cities"
placeholder="Select a city..."
/>
</LabelsTopLayout>
</div>
</IFramePortal>
</div>
</cx>
);
78 changes: 78 additions & 0 deletions litmus/features/dropdown/lookup-inside-real-iframe.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { Controller, LabelsTopLayout } from "cx/ui";
import { LookupField } from "cx/widgets";

const isEmbedded = window.self !== window.top;

class PageController extends Controller {
onInit() {
this.store.init("city", 1);
this.store.init("cities", [
{ id: 1, text: "New York" },
{ id: 2, text: "London" },
{ id: 3, text: "Paris" },
{ id: 4, text: "Berlin" },
{ id: 5, text: "Tokyo" },
{ id: 6, text: "Sydney" },
{ id: 7, text: "Toronto" },
{ id: 8, text: "Madrid" },
]);
}
}

const LookupInFrame = (
<cx>
<div
style="padding: 40px; font-family: sans-serif;"
controller={PageController}
>
<LabelsTopLayout>
<LookupField
label="Real IFrame Lookup"
value-bind="city"
options-bind="cities"
placeholder="Select a city..."
/>
</LabelsTopLayout>
</div>
</cx>
);

const HostPage = (
<cx>
<div
style="font-family: sans-serif; display: flex; flexDirection: column;"
controller={PageController}
>
<p style="max-width: 420px; margin: 0 0 16px;">
Bug repro (real iframe): the LookupField in the framed page below runs
as a fully independent app in its own iframe document/window (a genuine
navigation via iframe src, not a React portal). Open its dropdown and
check whether it's positioned correctly relative to the field.
</p>
<p style="max-width: 420px; margin: 0 0 16px;">
This also doubles as a repro for a related viewport-size bug: the
framed field has very little content compared to the iframe's 600px
height, and the framed page doesn't set html/body height to 100%
(the default). If the dropdown ever measures the fixed-position
viewport off the framed document's content height instead of its
actual (much taller) viewport, it will think there's no room below
the field and open upward instead of downward.
</p>
<LabelsTopLayout>
<LookupField
label="Parent Document Lookup"
value-bind="city"
options-bind="cities"
placeholder="Select a city..."
/>
</LabelsTopLayout>

<iframe
src={window.location.href}
style="width: 1000px; height: 600px; margin-left: 300px; border: 2px solid #2980b9;"
/>
</div>
</cx>
);

export default isEmbedded ? LookupInFrame : HostPage;
9 changes: 9 additions & 0 deletions litmus/features/dropdown/style.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
.cxe-lookupfield-dropdown {
background: red;
}

.inner-lookup {
.cxe-lookupfield-dropdown {
background: blue;
}
}
5 changes: 4 additions & 1 deletion litmus/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,10 @@ import "./index.scss";
// import Demo from "./features/charts/PointReducer";
// import Demo from "./features/charts/line-graph/LineGraph";
// import Demo from "./features/charts/line-graph/SmoothingOvershoot";
import Demo from "./features/window/resize-cursor";
//import Demo from "./features/window/resize-cursor";
import Demo from "./features/dropdown/lookup-inside-iframe-portal";
// import Demo from "./features/dropdown/lookup-inside-real-iframe";
// import Demo from "./features/dropdown/context-menu-iframe-focus";
// import Demo from "./bugs/GridDefaultSortFieldClearableSortIssue";
// import Demo from "./bugs/GridFixedColumnsFixedHeaderColumnsPosition";
// import Demo from "./bugs/GridOnFetchRecords";
Expand Down
19 changes: 15 additions & 4 deletions packages/cx/src/util/DOM.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { isNumber } from "../util/isNumber";
import { getActiveElement } from "./getActiveElement";

type ElementFilter = (el: Element, condition: (el: Element) => boolean) => Element | null;

Expand Down Expand Up @@ -51,11 +52,11 @@ export function closestParent(el: Element, condition: (el: any) => boolean): HTM
}

export function isFocused(el: Element): boolean {
return document.activeElement == el;
return getActiveElement() == el;
}

export function isFocusedDeep(el: Element): boolean {
return document.activeElement == el || (!!document.activeElement && el.contains(document.activeElement));
return isSelfOrDescendant(el, getActiveElement());
}

const focusableWithoutTabIndex = ["INPUT", "SELECT", "TEXTAREA", "A", "BUTTON"];
Expand All @@ -76,13 +77,23 @@ export function isFocusable(el: Element): el is HTMLElement {
* @returns {Element}
*/
export function getFocusedElement(): Element | null {
return document.activeElement;
return getActiveElement();
}

export function isDescendant(el: Element, descEl: Element): boolean {
return el.contains(descEl);
}

//`descEl` may live in a different document than `el` (e.g. focus is inside a same-origin
//iframe) - Node.contains never crosses document boundaries, so walk out through each
//frame's <iframe> element in its parent document until we either cross into el's document
//or run out of ancestor frames.
export function isSelfOrDescendant(el: Element, descEl: Element): boolean {
return el == descEl || el.contains(descEl);
let node: Element | null | undefined = descEl;
while (node) {
if (el == node || el.contains(node)) return true;
let win: Window | null = node.ownerDocument?.defaultView;
node = win?.frameElement;
}
return false;
}
18 changes: 16 additions & 2 deletions packages/cx/src/util/getActiveElement.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,18 @@
//IE sometimes returns null while other browsers always return document.body.
export function getActiveElement(): Element {
return document.activeElement ?? document.body;
export function getActiveElement(doc: Document = document): Element {
let active = doc.activeElement ?? doc.body;

//when focus is inside a same-origin iframe, `doc.activeElement` only reports the
//<iframe> element itself - drill into its own document to find the element that's
//actually focused there (recursively, in case of nested iframes)
if (active && active.tagName === "IFRAME") {
const iframe = active as HTMLIFrameElement;
let frameDoc = iframe.contentDocument;
if (frameDoc) {
const inner = getActiveElement(frameDoc);
return inner ?? active;
}
}

return active;
}
Loading
Loading