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";