Skip to content

Commit

Permalink
fix: render portal in shadow-root
Browse files Browse the repository at this point in the history
  • Loading branch information
mshatikhin committed May 21, 2024
1 parent 42b91de commit 6b7603d
Show file tree
Hide file tree
Showing 5 changed files with 118 additions and 82 deletions.
122 changes: 82 additions & 40 deletions packages/react-ui/internal/Popup/Popup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import { isInstanceWithAnchorElement } from '../../lib/InstanceWithAnchorElement
import { createPropsGetter } from '../../lib/createPropsGetter';
import { isInstanceOf } from '../../lib/isInstanceOf';
import { ThemeContext } from '../../lib/theming/ThemeContext';
import { RenderContainerElement, RenderLayerConsumer } from '../RenderLayer';
import {
getFullReactUIFlagsContext,
ReactUIFeatureFlags,
Expand Down Expand Up @@ -257,6 +258,7 @@ export class Popup extends React.Component<PopupProps, PopupState> {
public state: PopupState = { location: this.props.opened ? DUMMY_LOCATION : null };
private theme!: Theme;
private emotion!: Emotion;
private container!: RenderContainerElement;
public featureFlags!: ReactUIFeatureFlags;
private layoutEventsToken: Nullable<ReturnType<typeof LayoutEvents.addListener>>;
private locationUpdateId: Nullable<number> = null;
Expand Down Expand Up @@ -332,19 +334,26 @@ export class Popup extends React.Component<PopupProps, PopupState> {
{(flags) => {
this.featureFlags = getFullReactUIFlagsContext(flags);
return (
<EmotionConsumer>
{(emotion) => {
this.emotion = emotion;
<RenderLayerConsumer>
{(container) => {
this.container = container;
return (
<ThemeContext.Consumer>
{(theme) => {
this.theme = theme;
return this.renderMain();
<EmotionConsumer>
{(emotion) => {
this.emotion = emotion;
return (
<ThemeContext.Consumer>
{(theme) => {
this.theme = theme;
return this.renderMain();
}}
</ThemeContext.Consumer>
);
}}
</ThemeContext.Consumer>
</EmotionConsumer>
);
}}
</EmotionConsumer>
</RenderLayerConsumer>
);
}}
</ReactUIFeatureFlagsContext.Consumer>
Expand Down Expand Up @@ -757,39 +766,72 @@ export class Popup extends React.Component<PopupProps, PopupState> {
);
}

private getCoordinates(anchorRect: Rect, popupRect: Rect, positionName: string) {
const { margin: marginFromProps } = this.props;
const margin =
isNonNullable(marginFromProps) && !isNaN(marginFromProps)
? marginFromProps
: parseInt(this.theme.popupMargin) || 0;
const position = PopupHelper.getPositionObject(positionName);
const popupOffset = this.getProps().popupOffset + this.getPinnedPopupOffset(anchorRect, position);
private getCoordinates(anchorRect: Rect, popupRect: Rect, positionName: string): { top: number; left: number } {
const calcCoordinates = () => {
const { margin: marginFromProps } = this.props;
const margin =
isNonNullable(marginFromProps) && !isNaN(marginFromProps)
? marginFromProps
: parseInt(this.theme.popupMargin) || 0;
const position = PopupHelper.getPositionObject(positionName);
const popupOffset = this.getProps().popupOffset + this.getPinnedPopupOffset(anchorRect, position);

switch (position.direction) {
case 'top':
return {
top: anchorRect.top - popupRect.height - margin,
left: this.getHorizontalPosition(anchorRect, popupRect, position.align, popupOffset),
};
case 'bottom':
return {
top: anchorRect.top + anchorRect.height + margin,
left: this.getHorizontalPosition(anchorRect, popupRect, position.align, popupOffset),
};
case 'left':
return {
top: this.getVerticalPosition(anchorRect, popupRect, position.align, popupOffset),
left: anchorRect.left - popupRect.width - margin,
};
case 'right':
return {
top: this.getVerticalPosition(anchorRect, popupRect, position.align, popupOffset),
left: anchorRect.left + anchorRect.width + margin,
};
default:
throw new Error(`Unexpected direction '${position.direction}'`);
}
};

switch (position.direction) {
case 'top':
return {
top: anchorRect.top - popupRect.height - margin,
left: this.getHorizontalPosition(anchorRect, popupRect, position.align, popupOffset),
};
case 'bottom':
return {
top: anchorRect.top + anchorRect.height + margin,
left: this.getHorizontalPosition(anchorRect, popupRect, position.align, popupOffset),
};
case 'left':
return {
top: this.getVerticalPosition(anchorRect, popupRect, position.align, popupOffset),
left: anchorRect.left - popupRect.width - margin,
};
case 'right':
return {
top: this.getVerticalPosition(anchorRect, popupRect, position.align, popupOffset),
left: anchorRect.left + anchorRect.width + margin,
};
default:
throw new Error(`Unexpected direction '${position.direction}'`);
const containerCoordinates = this.getCoords(this.container?.firstElementChild);
const coordinates = calcCoordinates();

return this.container?.firstElementChild
? {
top: coordinates.top - containerCoordinates.top,
left: coordinates.left - containerCoordinates.left,
}
: coordinates;
}

private getCoords(element?: Element | null) {
if (!element) {
return { top: 0, left: 0 };
}

const box = element.getBoundingClientRect();
const body = globalObject.document?.body;
const docEl = globalObject.document?.documentElement;

const scrollTop = globalObject.scrollY || docEl?.scrollTop || body?.scrollTop || 0;
const scrollLeft = globalObject.scrollX || docEl?.scrollLeft || body?.scrollLeft || 0;

const clientTop = docEl?.clientTop || body?.clientTop || 0;
const clientLeft = docEl?.clientLeft || body?.clientLeft || 0;

const top = box.top + scrollTop - clientTop;
const left = box.left + scrollLeft - clientLeft;

return { top: Math.round(top), left: Math.round(left) };
}

private getPinOffset(align: string) {
Expand Down
28 changes: 19 additions & 9 deletions packages/react-ui/internal/RenderContainer/RenderContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { Nullable } from '../../typings/utility-types';
import { getRandomID } from '../../lib/utils';
import { Upgrade } from '../../lib/Upgrades';
import { callChildRef } from '../../lib/callChildRef/callChildRef';
import { RenderLayerConsumer, RenderContainerElement } from '../RenderLayer';

import { RenderInnerContainer } from './RenderInnerContainer';
import { RenderContainerProps } from './RenderContainerTypes';
Expand All @@ -20,7 +21,7 @@ export class RenderContainer extends React.Component<RenderContainerProps> {

public shouldComponentUpdate(nextProps: RenderContainerProps) {
if (!this.props.children && nextProps.children) {
this.mountContainer();
this.mountContainer(undefined);
}
if (this.props.children && !nextProps.children) {
this.unmountContainer();
Expand All @@ -33,28 +34,37 @@ export class RenderContainer extends React.Component<RenderContainerProps> {
}

public render() {
return <RenderLayerConsumer>{this.renderMain}</RenderLayerConsumer>;
}

private renderMain = (root: RenderContainerElement) => {
if (this.props.children) {
this.mountContainer();
this.mountContainer(root);
}

return <RenderInnerContainer {...this.props} domContainer={this.domContainer} rootId={this.rootId} />;
}
};

private createContainer(root: RenderContainerElement) {
const domContainer = root
? root.appendChild(root.ownerDocument.createElement('div'))
: globalObject.document?.createElement('div');

private createContainer() {
const domContainer = globalObject.document?.createElement('div');
if (domContainer) {
domContainer.setAttribute('class', Upgrade.getSpecificityClassName());
domContainer.setAttribute('data-rendered-container-id', `${this.rootId}`);
this.domContainer = domContainer;
}
}

private mountContainer() {
private mountContainer(root: RenderContainerElement) {
if (!this.domContainer) {
this.createContainer();
this.createContainer(root);
}
if (this.domContainer && this.domContainer.parentNode !== globalObject.document?.body) {
globalObject.document?.body.appendChild(this.domContainer);

const rootElement = root ?? globalObject.document?.body;
if (this.domContainer && this.domContainer.parentNode !== rootElement) {
rootElement?.appendChild(this.domContainer);

if (this.props.containerRef) {
callChildRef(this.props.containerRef, this.domContainer);
Expand Down
20 changes: 7 additions & 13 deletions packages/react-ui/internal/RenderLayer/RenderLayer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ export interface RenderLayerProps extends CommonProps {

type DefaultProps = Required<Pick<RenderLayerProps, 'active'>>;

const RenderLayerContext = createContext<Nullable<ShadowRoot | Element>>(null);
export type RenderContainerElement = Nullable<ShadowRoot | HTMLElement>;
const RenderLayerContext = createContext<RenderContainerElement>(null);
export const RenderLayerProvider = RenderLayerContext.Provider;
export const RenderLayerConsumer = RenderLayerContext.Consumer;

Expand Down Expand Up @@ -48,7 +49,6 @@ export class RenderLayer extends React.Component<RenderLayerProps> {
remove: () => void;
} | null = null;
private setRootNode!: TSetRootNode;
private container: Nullable<Element | ShadowRoot> = null;

public componentDidMount() {
if (this.getProps().active) {
Expand All @@ -74,16 +74,9 @@ export class RenderLayer extends React.Component<RenderLayerProps> {

public render() {
return (
<RenderLayerConsumer>
{(container) => {
this.container = container;
return (
<CommonWrapper rootNodeRef={this.setRootNode} {...this.props}>
{React.Children.only(this.props.children)}
</CommonWrapper>
);
}}
</RenderLayerConsumer>
<CommonWrapper rootNodeRef={this.setRootNode} {...this.props}>
{React.Children.only(this.props.children)}
</CommonWrapper>
);
}

Expand Down Expand Up @@ -135,7 +128,8 @@ export class RenderLayer extends React.Component<RenderLayerProps> {

if (
!node ||
(isInstanceOf(target, globalObject.Element) && containsTargetOrRenderContainer(target, this.container)(node))
(event.composed && event.composedPath().indexOf(node) > -1) ||
(isInstanceOf(target, globalObject.Element) && containsTargetOrRenderContainer(target)(node))
) {
return;
}
Expand Down
21 changes: 6 additions & 15 deletions packages/react-ui/lib/listenFocusOutside.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,6 @@ import ReactDOM from 'react-dom';
import debounce from 'lodash.debounce';
import { globalObject } from '@skbkontur/global-object';

import { Nullable } from '../typings/utility-types';

import { isInstanceOf } from './isInstanceOf';
import { isFirefox } from './client';

Expand Down Expand Up @@ -53,28 +51,23 @@ function handleNativeFocus(event: UIEvent) {
});
}

export function containsTargetOrRenderContainer(target: Element, renderContainer?: Nullable<Element | ShadowRoot>) {
export function containsTargetOrRenderContainer(target: Element) {
return (element: Element) => {
if (!element) {
return false;
}
if (element.contains(target)) {
return true;
}
const container = findRenderContainer(target, element, renderContainer);
const container = findRenderContainer(target, element);
return !!container && element.contains(container);
};
}

/**
* Searches RenderContainer placed in "rootNode" for "node"
*/
export function findRenderContainer(
node: Element,
rootNode: Element,
renderContainer?: Nullable<Element | ShadowRoot>,
container?: Element,
): Element | null {
export function findRenderContainer(node: Element, rootNode: Element, container?: Element): Element | null {
const currentNode = node.parentNode;
if (
!currentNode ||
Expand All @@ -89,18 +82,16 @@ export function findRenderContainer(

const newContainerId = currentNode.getAttribute('data-rendered-container-id');
if (newContainerId) {
const nextNode = (renderContainer ?? globalObject.document)?.querySelector(
`[data-render-container-id~="${newContainerId}"]`,
);
const nextNode = globalObject.document?.querySelector(`[data-render-container-id~="${newContainerId}"]`);

if (!nextNode) {
throw Error(`Origin node for render container was not found`);
}

return findRenderContainer(nextNode, rootNode, renderContainer, nextNode);
return findRenderContainer(nextNode, rootNode, nextNode);
}

return findRenderContainer(currentNode, rootNode, renderContainer, container);
return findRenderContainer(currentNode, rootNode, container);
}

export function listen(elements: Element[] | (() => Element[]), callback: (event: Event) => void) {
Expand Down
9 changes: 4 additions & 5 deletions packages/react-ui/lib/widgets/WidgetContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,19 @@ import React, { ReactNode, useState } from 'react';
import type { Emotion } from '@emotion/css/create-instance';

import { EmotionProvider, getEmotion } from '../theming/Emotion';
import { RenderLayerProvider } from '../../internal/RenderLayer';
import { Nullable } from '../../typings/utility-types';
import { RenderContainerElement, RenderLayerProvider } from '../../internal/RenderLayer';

interface Props {
root: Nullable<Element | ShadowRoot>;
root: RenderContainerElement;
children: ReactNode;
}

export const WidgetContainer = ({ root, children }: Props) => {
const [styles, setStyles] = useState<Emotion>();

function setRef(el: HTMLDivElement) {
if (!styles) {
setStyles(getEmotion(el));
if (!styles && el) {
setStyles(getEmotion(el, 'react-ui-widget'));
}
}

Expand Down

0 comments on commit 6b7603d

Please sign in to comment.