diff --git a/package-lock.json b/package-lock.json
index 1f2de4b23..3e1c9fd7c 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1925,6 +1925,15 @@
"@types/passport": "*"
}
},
+ "@types/prop-types": {
+ "version": "15.5.4",
+ "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.5.4.tgz",
+ "integrity": "sha512-RnC6YeQDmDas7DToCbRWNntB9XpIR+sqg1zUqcCUxOJTBwGeSAPfTQaXqzyNND82FIBNY67r17FedDyaKRcHBQ==",
+ "dev": true,
+ "requires": {
+ "@types/react": "*"
+ }
+ },
"@types/query-string": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/@types/query-string/-/query-string-6.1.0.tgz",
@@ -3001,6 +3010,41 @@
}
}
},
+ "awesome-typescript-loader": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/awesome-typescript-loader/-/awesome-typescript-loader-4.0.1.tgz",
+ "integrity": "sha512-lx6JJXtuNXyrK1DBx/+w6fbnbcfavMz0a2zszZ89yQ/NAAUE6GzIAtfQ2mkD96piClCoxLpbNpTkVi/H8FJn7w==",
+ "dev": true,
+ "requires": {
+ "chalk": "^2.3.1",
+ "enhanced-resolve": "3.3.0",
+ "loader-utils": "^1.1.0",
+ "lodash": "^4.17.4",
+ "micromatch": "^3.0.3",
+ "mkdirp": "^0.5.1",
+ "source-map-support": "^0.5.3"
+ },
+ "dependencies": {
+ "enhanced-resolve": {
+ "version": "3.3.0",
+ "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-3.3.0.tgz",
+ "integrity": "sha512-2qbxE7ek3YxPJ1ML6V+satHkzHpJQKWkRHmRx6mfAoW59yP8YH8BFplbegSP+u2hBd6B6KCOpvJQ3dZAP+hkpg==",
+ "dev": true,
+ "requires": {
+ "graceful-fs": "^4.1.2",
+ "memory-fs": "^0.4.0",
+ "object-assign": "^4.0.1",
+ "tapable": "^0.2.5"
+ }
+ },
+ "tapable": {
+ "version": "0.2.8",
+ "resolved": "https://registry.npmjs.org/tapable/-/tapable-0.2.8.tgz",
+ "integrity": "sha1-mTcqXJmb8t8WCvwNdL7U9HlIzSI=",
+ "dev": true
+ }
+ }
+ },
"aws-sign2": {
"version": "0.7.0",
"resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz",
@@ -18994,6 +19038,12 @@
"integrity": "sha1-635iZ1SN3t+4mcG5Dlc3RVnN234=",
"dev": true
},
+ "ramda": {
+ "version": "0.25.0",
+ "resolved": "https://registry.npmjs.org/ramda/-/ramda-0.25.0.tgz",
+ "integrity": "sha512-GXpfrYVPwx3K7RQ6aYT8KPS8XViSXUVJT1ONhoKPE9VAleW42YE+U+8VEyGWt41EnEQW7gwecYJriTI0pKoecQ==",
+ "dev": true
+ },
"randexp": {
"version": "0.4.6",
"resolved": "https://registry.npmjs.org/randexp/-/randexp-0.4.6.tgz",
@@ -19626,6 +19676,21 @@
"integrity": "sha512-MKucv9nU65BOPqIrClAFxqvpGCC4RdRpqp0P1YIb7C3yT6TQVdcoOlr0k4TDHvLQhbkwd3nbTxiDQMa3iDlZxg==",
"dev": true
},
+ "react-with-state-props": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/react-with-state-props/-/react-with-state-props-2.0.4.tgz",
+ "integrity": "sha512-tG2Rn/KXPXKy6RBhPeGMgR3JeSd11feNvQMR9ri2V6s8Tu8Q6Yz3pRD8xTOB+IzkEM2Vhf+iau7w2lki7hyHOQ==",
+ "dev": true,
+ "requires": {
+ "@types/prop-types": "^15.5.2",
+ "@types/react": "^16.0.40",
+ "awesome-typescript-loader": "^4.0.1",
+ "prop-types": "^15.6.1",
+ "ramda": "^0.25.0",
+ "source-map-loader": "^0.2.3",
+ "typescript": "^2.7.2"
+ }
+ },
"read-cache": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
@@ -21176,6 +21241,43 @@
"integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w=",
"dev": true
},
+ "source-map-loader": {
+ "version": "0.2.3",
+ "resolved": "https://registry.npmjs.org/source-map-loader/-/source-map-loader-0.2.3.tgz",
+ "integrity": "sha512-MYbFX9DYxmTQFfy2v8FC1XZwpwHKYxg3SK8Wb7VPBKuhDjz8gi9re2819MsG4p49HDyiOSUKlmZ+nQBArW5CGw==",
+ "dev": true,
+ "requires": {
+ "async": "^2.5.0",
+ "loader-utils": "~0.2.2",
+ "source-map": "~0.6.1"
+ },
+ "dependencies": {
+ "json5": {
+ "version": "0.5.1",
+ "resolved": "https://registry.npmjs.org/json5/-/json5-0.5.1.tgz",
+ "integrity": "sha1-Hq3nrMASA0rYTiOWdn6tn6VJWCE=",
+ "dev": true
+ },
+ "loader-utils": {
+ "version": "0.2.17",
+ "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-0.2.17.tgz",
+ "integrity": "sha1-+G5jdNQyBabmxg6RlvF8Apm/s0g=",
+ "dev": true,
+ "requires": {
+ "big.js": "^3.1.3",
+ "emojis-list": "^2.0.0",
+ "json5": "^0.5.0",
+ "object-assign": "^4.0.1"
+ }
+ },
+ "source-map": {
+ "version": "0.6.1",
+ "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
+ "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
+ "dev": true
+ }
+ }
+ },
"source-map-resolve": {
"version": "0.5.2",
"resolved": "https://registry.npmjs.org/source-map-resolve/-/source-map-resolve-0.5.2.tgz",
diff --git a/package.json b/package.json
index 9983e9e96..07f82470f 100644
--- a/package.json
+++ b/package.json
@@ -179,6 +179,7 @@
"react-responsive": "^4.1.0",
"react-test-renderer": "^16.4.1",
"react-timeago": "^4.1.9",
+ "react-with-state-props": "^2.0.4",
"recompose": "^0.27.1",
"relay-compiler": "github:coralproject/patched#relay-compiler",
"relay-compiler-language-typescript": "github:coralproject/patched#relay-compiler-language-typescript",
diff --git a/src/core/client/ui/components/Popup/Popup.mdx b/src/core/client/ui/components/Popup/Popup.mdx
new file mode 100644
index 000000000..d17d58347
--- /dev/null
+++ b/src/core/client/ui/components/Popup/Popup.mdx
@@ -0,0 +1,44 @@
+---
+name: Popup
+menu: UI Kit
+---
+
+import { Playground } from "docz"
+import Popup from "./Popup"
+import Button from "../Button"
+import Flex from "../Flex"
+import Container from "react-with-state-props"
+
+# Popup
+
+Declaratively control a popup.
+
+## Basic usage
+
+
+ (
+
+ props.setFocus(true)}
+ onBlur={() => props.setFocus(false)}
+ onClose={() => props.setOpen(false)}
+ />
+
+
+
+
+ )}/>
+
diff --git a/src/core/client/ui/components/Popup/Popup.ts b/src/core/client/ui/components/Popup/Popup.ts
new file mode 100644
index 000000000..a39c985f9
--- /dev/null
+++ b/src/core/client/ui/components/Popup/Popup.ts
@@ -0,0 +1,174 @@
+import { Component } from "react";
+
+interface PopupProps {
+ open?: boolean;
+ focus?: boolean;
+ onFocus?: (e: FocusEvent) => void;
+ onBlur?: (e: FocusEvent) => void;
+ onLoad?: (e: Event) => void;
+ onUnload?: (e: Event) => void;
+ onClose?: () => void;
+ href: string;
+ features?: string;
+ title?: string;
+}
+
+export default class Popup extends Component {
+ private ref: Window | null = null;
+ private detectCloseInterval: any = null;
+ private resetCallbackInterval: any = null;
+
+ constructor(props: PopupProps) {
+ super(props);
+
+ if (props.open) {
+ this.openWindow(props);
+ }
+ }
+
+ private openWindow(props = this.props) {
+ this.ref = window.open(props.href, props.title, props.features);
+
+ this.setCallbacks();
+
+ // For some reasons IE needs a timeout before setting the callbacks...
+ setTimeout(() => this.setCallbacks(), 1000);
+ }
+
+ private setCallbacks() {
+ this.ref!.onload = e => {
+ if (this.detectCloseInterval) {
+ clearInterval(this.detectCloseInterval);
+ }
+ this.onLoad(e);
+ };
+
+ this.ref!.onfocus = e => {
+ this.onFocus(e);
+ };
+
+ this.ref!.onblur = e => {
+ this.onBlur(e);
+ };
+
+ // Use `onunload` instead of `onbeforeunload` which is not supported in iOS
+ // Safari.
+ this.ref!.onunload = e => {
+ this.onUnload(e);
+
+ if (this.resetCallbackInterval) {
+ clearInterval(this.resetCallbackInterval);
+ }
+
+ this.resetCallbackInterval = setInterval(() => {
+ try {
+ if (this.ref && this.ref.onload === null) {
+ if (this.resetCallbackInterval) {
+ clearInterval(this.resetCallbackInterval);
+ }
+ this.resetCallbackInterval = null;
+ this.setCallbacks();
+ }
+ } catch (err) {
+ // We could be getting a security exception here if the login page
+ // gets redirected to another domain to authenticate.
+ }
+ }, 50);
+
+ if (this.detectCloseInterval) {
+ clearInterval(this.detectCloseInterval);
+ }
+
+ this.detectCloseInterval = setInterval(() => {
+ if (!this.ref || this.ref.closed) {
+ if (this.detectCloseInterval) {
+ clearInterval(this.detectCloseInterval);
+ }
+ this.detectCloseInterval = null;
+ this.onClose();
+ }
+ }, 50);
+ };
+ }
+
+ private closeWindow() {
+ if (this.ref) {
+ if (!this.ref.closed) {
+ this.ref.close();
+ }
+ this.ref = null;
+ }
+ }
+
+ private focusWindow() {
+ if (this.ref && !this.ref.closed) {
+ this.ref.focus();
+ }
+ }
+
+ private blurWindow() {
+ if (this.ref && !this.ref.closed) {
+ this.ref.blur();
+ }
+ }
+
+ private onLoad = (e: Event) => {
+ if (this.props.onLoad) {
+ this.props.onLoad(e);
+ }
+ };
+
+ private onUnload = (e: Event) => {
+ if (this.props.onUnload) {
+ this.props.onUnload(e);
+ }
+ };
+
+ private onClose = () => {
+ if (this.props.onClose) {
+ this.props.onClose();
+ }
+ };
+
+ private onFocus = (e: FocusEvent) => {
+ if (this.props.onFocus) {
+ this.props.onFocus(e);
+ }
+ };
+
+ private onBlur = (e: FocusEvent) => {
+ if (this.props.onBlur) {
+ this.props.onBlur(e);
+ }
+ };
+
+ public componentWillReceiveProps(nextProps: PopupProps) {
+ if (nextProps.open && !this.ref) {
+ this.openWindow(nextProps);
+ }
+
+ if (this.props.open && !nextProps.open) {
+ this.closeWindow();
+ }
+
+ if (!this.props.focus && nextProps.focus) {
+ this.focusWindow();
+ }
+
+ if (this.props.focus && !nextProps.focus) {
+ this.blurWindow();
+ }
+
+ if (this.props.href !== nextProps.href) {
+ this.ref!.location.href = nextProps.href;
+ }
+ }
+
+ public componentWillUnmount() {
+ this.closeWindow();
+ }
+
+ public render() {
+ return null;
+ }
+}
diff --git a/src/core/client/ui/components/Popup/index.ts b/src/core/client/ui/components/Popup/index.ts
new file mode 100644
index 000000000..dc085ac15
--- /dev/null
+++ b/src/core/client/ui/components/Popup/index.ts
@@ -0,0 +1 @@
+export { default } from "./Popup";
diff --git a/src/core/client/ui/components/index.ts b/src/core/client/ui/components/index.ts
index ae57fd4e4..f7366ddc1 100644
--- a/src/core/client/ui/components/index.ts
+++ b/src/core/client/ui/components/index.ts
@@ -10,3 +10,4 @@ export { default as Flex } from "./Flex";
export { default as MatchMedia } from "./MatchMedia";
export { default as TrapFocus } from "./TrapFocus";
export { default as ClickOutside } from "./ClickOutside";
+export { default as Popup } from "./Popup";