Skip to content
This repository was archived by the owner on Nov 1, 2019. It is now read-only.
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
e13eed4
added test for scrolling
danielstro Oct 18, 2017
09439bd
initial solution
danielstro Oct 18, 2017
df07b22
Merge branch 'master' of github.com:wix/stylable-components into dani…
danielstro Oct 18, 2017
0612661
renamed callback
danielstro Oct 19, 2017
c78a9e1
initial collision solution
danielstro Oct 23, 2017
c9e8347
removed changes from popup demo
danielstro Oct 24, 2017
1f4902c
removed clientHeight
danielstro Oct 24, 2017
c08fc0a
fixed some imports
danielstro Oct 24, 2017
d4efd18
changed scrolling values in tests
danielstro Oct 24, 2017
7f8ea23
changed overflow to auto for safari
danielstro Oct 24, 2017
8b6b389
added webkit overflow touch for mobile
danielstro Oct 24, 2017
bf5b67e
fixed lint error
danielstro Oct 24, 2017
1fc90d2
fixed wrong style name in tests
danielstro Oct 24, 2017
d332285
found the problem?
danielstro Oct 25, 2017
ca210c2
added missing semicolon
danielstro Oct 25, 2017
1a0d940
changed tests values to better reflect test
danielstro Oct 25, 2017
834c0d6
some small changes and dealing with points
danielstro Oct 25, 2017
daf326c
merge master
danielstro Oct 25, 2017
1173b3e
some small fixes
danielstro Oct 26, 2017
c1bc778
removed point and document if from scroll event
danielstro Oct 29, 2017
67587b8
some small PR changes
danielstro Oct 29, 2017
3e194e5
removed debugger
danielstro Oct 29, 2017
dba5ea0
PR fixes
danielstro Nov 1, 2017
22c9a01
added some more tests for out of bounds
danielstro Nov 1, 2017
bd24fe1
Merge branch 'master' of github.com:wix/stylable-components into dani…
danielstro Nov 2, 2017
d579598
added check that popup is in the correct position for out of bounds test
danielstro Nov 2, 2017
ea50567
changed portal to use a callback
danielstro Nov 5, 2017
932f642
fixed imports
danielstro Nov 5, 2017
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
8 changes: 6 additions & 2 deletions demo/components/popup-demo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,12 +38,12 @@ export class PopupDemo extends React.Component<{}, DemoState> {
{value: 'top', labelText: 'top'},
{value: 'center', labelText: 'center'},
{value: 'bottom', labelText: 'bottom'}
];
];
const hPos: RadioGroupDataSchemaProps[] = [
{value: 'left', labelText: 'left'},
{value: 'center', labelText: 'center'},
{value: 'right', labelText: 'right'}
];
];
return (
<div>
<button
Expand All @@ -60,6 +60,7 @@ export class PopupDemo extends React.Component<{}, DemoState> {
anchorPosition={anchorPos}
open={this.state.isOpen}
ref={popup => this.popup = popup}
onExitBounds={this.onExitBounds}
>
<div style={{color: 'white', backgroundColor: 'black'}}>Hello!</div>
</Popup>
Expand Down Expand Up @@ -113,4 +114,7 @@ export class PopupDemo extends React.Component<{}, DemoState> {
this.setState({aHorizontal: e.value});
}

private onExitBounds = () => {
this.setState({isOpen: false});
}
}
45 changes: 44 additions & 1 deletion src/components/popup/popup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import * as React from 'react';
import {properties} from 'wix-react-tools';
import {Point} from '../../types';
import {StylableProps} from '../../types/props';
import {noop} from '../../utils';
import {Portal} from '../portal';

export type PopupVerticalPosition = 'top' | 'center' | 'bottom';
Expand All @@ -17,6 +18,7 @@ export interface PopupProps extends StylableProps {
anchorPosition?: PopupPositionPoint;
popupPosition?: PopupPositionPoint;
syncWidth?: boolean;
onExitBounds?: () => void;
children?: React.ReactNode;
}

Expand All @@ -30,17 +32,21 @@ export class Popup extends React.Component<PopupCompProps> {
open: false,
anchorPosition: {vertical: 'bottom', horizontal: 'left'},
popupPosition: {vertical: 'top', horizontal: 'left'},
syncWidth: true
syncWidth: true,
onExitBounds: noop
};

private portal: Portal | null;
private isExitingBounds = false;
private portalRect: ClientRect | null = null;

public render() {
if (this.props.anchor && this.props.open) {
return (
<Portal
style={this.createStyle()}
ref={portal => this.portal = portal}
onLayout={this.onPortalLayout}
>
{this.props.children}
</Portal>);
Expand All @@ -49,10 +55,32 @@ export class Popup extends React.Component<PopupCompProps> {
return null;
}

public componentDidMount() {
window.addEventListener('scroll', this.onScroll, true);
}

public componentWillUnmount() {
window.removeEventListener('scroll', this.onScroll);
}

public componentDidUpdate() {
if (this.isExitingBounds) {
this.props.onExitBounds!();
}
this.isExitingBounds = false;
}

public getPortal(): Portal | null {
return this.portal;
}

private onScroll = (e: Event) => {
if (this.props.anchor && this.props.open
&& !isPoint(this.props.anchor) && (e.target as Node).contains(this.props.anchor)) {
this.forceUpdate();
}
}

private createStyle(): React.CSSProperties {
if (!this.props.anchor) {
return {};
Expand Down Expand Up @@ -91,8 +119,16 @@ export class Popup extends React.Component<PopupCompProps> {
break;
}

if (this.portalRect) {
this.isExitingBounds = isFullyContainedWithinWindow(newStyle.top,
newStyle.left, this.portalRect.height, this.portalRect.width);
}
return newStyle;
}

private onPortalLayout = (rect: ClientRect | null) => {
this.portalRect = rect;
}
}

function getVerticalReference(rect: ClientRect, anchorPosition: PopupVerticalPosition): number {
Expand All @@ -119,3 +155,10 @@ function addTransform(style: React.CSSProperties, transformation: string) {
function isPoint(elem: Element | Point): elem is Point {
return elem.hasOwnProperty('x') && elem.hasOwnProperty('y');
}

function isFullyContainedWithinWindow(top: number, left: number, height: number, width: number): boolean {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

please have tests for this function

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added tests

return top < window.pageYOffset ||
left < window.pageXOffset ||
top + height - window.pageYOffset > window.innerHeight ||
left + width - window.pageXOffset > window.innerWidth;
}
16 changes: 14 additions & 2 deletions src/components/portal/portal.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,22 @@
import * as React from 'react';
import * as ReactDOM from 'react-dom';
import {globalId} from 'wix-react-tools';
import {noop} from '../../utils';

export interface PortalProps extends React.HTMLAttributes<HTMLDivElement> {
children: React.ReactNode;
onLayout?(layout: ClientRect | null): void;
}

export class Portal extends React.PureComponent<PortalProps> {
public static defaultProps: Partial<PortalProps> = {
onLayout: noop
};
private container: HTMLDivElement | null;
private portalContent: React.ReactElement<React.HTMLAttributes<HTMLDivElement>>;

public render() {
const {children, ...rest} = this.props;
const {children, onLayout, ...rest} = this.props;
const uniqueId = globalId.getRootId(this);
this.portalContent = (
<div {...rest} data-automation-id={uniqueId}>{children}</div>
Expand All @@ -36,10 +41,17 @@ export class Portal extends React.PureComponent<PortalProps> {
}

private renderPortal() {
ReactDOM.unstable_renderSubtreeIntoContainer(this, this.portalContent, this.getContainer());
ReactDOM.unstable_renderSubtreeIntoContainer(this, this.portalContent, this.getContainer(), this.onRender);
}

private getContainer() {
return this.container = this.container || document.body.appendChild(document.createElement('div'));
}

private onRender = () => {
if (this.props.onLayout !== noop) {
const portalContent = this.container && this.container.firstElementChild;
this.props.onLayout!(portalContent ? portalContent.getBoundingClientRect() : null);
}
}
}
176 changes: 175 additions & 1 deletion test/components/popup.spec.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import React = require('react');
import ReactDOM = require('react-dom');
import {ClientRenderer, DriverBase, expect, waitFor} from 'test-drive-react';
import {ClientRenderer, DriverBase, expect, sinon, waitFor} from 'test-drive-react';
import {PopupDemo} from '../../demo/components/popup-demo';
import {Popup, PopupPositionPoint} from '../../src/components/';
import {PopupTestDriver} from '../../test-kit/components/popup-driver';
Expand Down Expand Up @@ -126,6 +126,9 @@ describe('<Popup />', () => {

after(() => {
document.body.removeChild(scroll);
});

afterEach(() => {
document.body.scrollTop = 0;
document.body.scrollLeft = 0;
});
Expand Down Expand Up @@ -156,6 +159,177 @@ describe('<Popup />', () => {
expect([div, popup.root]).to.be.inVerticalSequence();
});
});

it('listens to internal scrolling and adjusts the popup location accordingly', async () => {
let anchorDiv: HTMLDivElement;
let scrollDiv: HTMLDivElement;
const {waitForDom} = clientRenderer.render(
<div>
<div
ref={(elem: HTMLDivElement) => scrollDiv = elem}
style={{height: '100px', overflow: 'auto', WebkitOverflowScrolling: 'touch'}}
>
<div style={{height: '300px'}}>Filler</div>
<div ref={(elem: HTMLDivElement) => anchorDiv = elem}>Anchor</div>
</div>
</div>
);

await waitForDom(() => expect(anchorDiv).to.be.present());

const {driver: popup} = clientRenderer.render(
<Popup
anchor={anchorDiv!}
open
>
<span data-automation-id="SPAN">Popup Body</span>
</Popup>).withDriver(PopupTestDriver);

scrollDiv!.scrollTop = 200;

return waitForDom(() => {
expect([anchorDiv, popup.root]).to.be.inVerticalSequence();
});
});

it('calls onExitBounds when the popup leaves the viewport from top', async () => {
let anchorDiv: HTMLDivElement;
let scrollDiv: HTMLDivElement;
const onExitBounds = sinon.spy();
const {waitForDom} = clientRenderer.render(
<div
ref={(elem: HTMLDivElement) => scrollDiv = elem}
style={{height: '1000px', overflow: 'auto', WebkitOverflowScrolling: 'touch'}}
>
<div ref={(elem: HTMLDivElement) => anchorDiv = elem} style={{height: '50px'}}>Anchor</div>
<div style={{height: '5000px'}}/>
</div>
);

await waitForDom(() => expect(anchorDiv).to.be.present());

const {driver: popup} = clientRenderer.render(
<Popup
anchor={anchorDiv!}
open
onExitBounds={onExitBounds}
>
<div style={{height: '50px'}}>Body</div>
</Popup>).withDriver(PopupTestDriver);

await waitForDom(() => {
expect(popup.root).to.be.present();
expect(popup.root.getBoundingClientRect().top).to.equal(50);
});
expect(onExitBounds).to.not.have.been.called;
scrollDiv!.scrollTop = 51;

return waitFor(() => {
expect(onExitBounds).to.have.been.calledOnce;
});
});

it('calls onExitBounds when the popup leaves the viewport from bottom', async () => {
let anchorDiv: HTMLDivElement;
let scrollDiv: HTMLDivElement;
const onExitBounds = sinon.spy();
const {waitForDom} = clientRenderer.render(
<div
ref={(elem: HTMLDivElement) => scrollDiv = elem}
style={{height: '1000px', overflow: 'auto', WebkitOverflowScrolling: 'touch'}}
>
<div style={{height: '3000px'}}/>
<div ref={(elem: HTMLDivElement) => anchorDiv = elem} style={{height: '50px'}}>Anchor</div>
<div style={{height: '100px'}}/>
</div>
);
scrollDiv!.scrollTop = 3100;
await waitForDom(() => expect(anchorDiv).to.be.present());

const {driver: popup} = clientRenderer.render(
<Popup
anchor={anchorDiv!}
open
onExitBounds={onExitBounds}
>
<div style={{height: '50px'}}>Body</div>
</Popup>).withDriver(PopupTestDriver);

await waitForDom(() => expect(popup.root).to.be.present());
expect(onExitBounds).to.not.have.been.called;
scrollDiv!.scrollTop = 2000;
return waitFor(() => {
expect(onExitBounds).to.have.been.calledOnce;
});
});

it('calls onExitBounds when the popup leaves the viewport from left', async () => {
let anchorDiv: HTMLDivElement;
let scrollDiv: HTMLDivElement;
const onExitBounds = sinon.spy();
const {waitForDom} = clientRenderer.render(
<div
ref={(elem: HTMLDivElement) => scrollDiv = elem}
style={{width: '100%', overflow: 'auto', WebkitOverflowScrolling: 'touch'}}
>
<div ref={(elem: HTMLDivElement) => anchorDiv = elem} style={{width: '50px'}}>Anchor</div>
<div style={{width: '5000px', height: '1px'}}/>
</div>
);

await waitForDom(() => expect(anchorDiv).to.be.present());

const {driver: popup} = clientRenderer.render(
<Popup
anchor={anchorDiv!}
open
onExitBounds={onExitBounds}
>
<div style={{width: '50px'}}>Body</div>
</Popup>).withDriver(PopupTestDriver);

await waitForDom(() => expect(popup.root).to.be.present());
expect(onExitBounds).to.not.have.been.called;
scrollDiv!.scrollLeft = 1;

return waitFor(() => {
expect(onExitBounds).to.have.been.calledOnce;
});
});

it('calls onExitBounds when the popup leaves the viewport from right', async () => {
let anchorDiv: HTMLDivElement;
let scrollDiv: HTMLDivElement;
const onExitBounds = sinon.spy();
const {waitForDom} = clientRenderer.render(
<div
ref={(elem: HTMLDivElement) => scrollDiv = elem}
style={{width: '100%', overflow: 'auto', WebkitOverflowScrolling: 'touch'}}
>
<div ref={(elem: HTMLDivElement) => anchorDiv = elem} style={{width: '200%'}}>Anchor</div>
</div>
);

await waitForDom(() => expect(anchorDiv).to.be.present());
scrollDiv!.scrollLeft = 50;
const {driver: popup} = clientRenderer.render(
<Popup
anchor={anchorDiv!}
anchorPosition={{vertical: 'bottom', horizontal: 'center'}}
open
onExitBounds={onExitBounds}
>
<div style={{width: '50px'}}>Body</div>
</Popup>).withDriver(PopupTestDriver);

await waitForDom(() => expect(popup.root).to.be.present());
expect(onExitBounds).to.not.have.been.called;
scrollDiv!.scrollLeft = 0;

return waitFor(() => {
expect(onExitBounds).to.have.been.calledOnce;
});
});
});

describe('Layout tests', () => {
Expand Down
Loading