diff --git a/package-lock.json b/package-lock.json index 6f82b9b97..7f6f5a53f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20407,6 +20407,11 @@ } } }, + "simulant": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/simulant/-/simulant-0.2.2.tgz", + "integrity": "sha1-8bzlJxK2p6DaON392n6DsgsdoB4=" + }, "sinon": { "version": "6.1.3", "resolved": "https://registry.npmjs.org/sinon/-/sinon-6.1.3.tgz", diff --git a/package.json b/package.json index 40dc922bc..9047ce6a4 100644 --- a/package.json +++ b/package.json @@ -150,6 +150,7 @@ "relay-runtime": "github:coralproject/patched#relay-runtime", "relay-test-utils": "github:coralproject/patched#relay-test-utils", "sane": "^2.5.2", + "simulant": "^0.2.2", "sinon": "^6.1.3", "style-loader": "^0.21.0", "ts-jest": "^23.0.0", diff --git a/src/core/client/stream/components/Comment/Comment.tsx b/src/core/client/stream/components/Comment/Comment.tsx index 8f8553fce..10a6c9659 100644 --- a/src/core/client/stream/components/Comment/Comment.tsx +++ b/src/core/client/stream/components/Comment/Comment.tsx @@ -2,7 +2,7 @@ import React from "react"; import { StatelessComponent } from "react"; import { Typography } from "talk-ui/components"; -import PermalinkContainer from "../Permalink/PermalinkContainer"; +import PermalinkContainer from "../../containers/PermalinkContainer"; import Timestamp from "./Timestamp"; import TopBar from "./TopBar"; import Username from "./Username"; diff --git a/src/core/client/stream/components/Permalink/PermalinkPopover.tsx b/src/core/client/stream/components/Permalink/PermalinkPopover.tsx index bb83aeb74..61a2d3ba4 100644 --- a/src/core/client/stream/components/Permalink/PermalinkPopover.tsx +++ b/src/core/client/stream/components/Permalink/PermalinkPopover.tsx @@ -2,14 +2,14 @@ import { Localized } from "fluent-react/compat"; import React, { CSSProperties } from "react"; import CopyToClipboard from "react-copy-to-clipboard"; import { RefHandler } from "react-popper"; -import { Button, Flex, TextField } from "talk-ui/components"; +import { Button, ClickOutside, Flex, TextField } from "talk-ui/components"; import * as styles from "./PermalinkPopover.css"; interface InnerProps { permalinkUrl: string; style?: CSSProperties; forwardRef?: RefHandler; - toggleVisibility?: () => any; + toggleVisibility: () => void; } interface State { @@ -35,25 +35,27 @@ class PermalinkPopover extends React.Component { }; public render() { - const { permalinkUrl } = this.props; + const { permalinkUrl, toggleVisibility } = this.props; const { copied } = this.state; return ( - - - - - - + + + + + + + + ); } } diff --git a/src/core/client/stream/components/PermalinkView.tsx b/src/core/client/stream/components/Permalink/PermalinkView.tsx similarity index 88% rename from src/core/client/stream/components/PermalinkView.tsx rename to src/core/client/stream/components/Permalink/PermalinkView.tsx index 889c8c28a..7e4a29177 100644 --- a/src/core/client/stream/components/PermalinkView.tsx +++ b/src/core/client/stream/components/Permalink/PermalinkView.tsx @@ -2,7 +2,7 @@ import * as React from "react"; import { StatelessComponent } from "react"; import { Flex } from "talk-ui/components"; -import CommentContainer from "../containers/CommentContainer"; +import CommentContainer from "../../containers/CommentContainer"; export interface InnerProps { comment: {} | null; diff --git a/src/core/client/stream/components/Permalink/PermalinkContainer.tsx b/src/core/client/stream/containers/PermalinkContainer.tsx similarity index 91% rename from src/core/client/stream/components/Permalink/PermalinkContainer.tsx rename to src/core/client/stream/containers/PermalinkContainer.tsx index 4364a8e0b..46a646600 100644 --- a/src/core/client/stream/components/Permalink/PermalinkContainer.tsx +++ b/src/core/client/stream/containers/PermalinkContainer.tsx @@ -3,7 +3,7 @@ import { graphql } from "react-relay"; import { withLocalStateContainer } from "talk-framework/lib/relay"; import { AppQueryLocal as Local } from "talk-stream/__generated__/AppQueryLocal.graphql"; -import Permalink from "./Permalink"; +import Permalink from "../components/Permalink/Permalink"; interface InnerProps { local: Local; diff --git a/src/core/client/stream/containers/PermalinkViewContainer.tsx b/src/core/client/stream/containers/PermalinkViewContainer.tsx index 6e0545383..a2c6216f8 100644 --- a/src/core/client/stream/containers/PermalinkViewContainer.tsx +++ b/src/core/client/stream/containers/PermalinkViewContainer.tsx @@ -3,7 +3,7 @@ import { graphql } from "react-relay"; import { withFragmentContainer } from "talk-framework/lib/relay"; import { PropTypesOf } from "talk-framework/types"; import { PermalinkViewContainerQuery as Data } from "talk-stream/__generated__/PermalinkViewContainerQuery.graphql"; -import PermalinkView from "../components/PermalinkView"; +import PermalinkView from "../components/Permalink/PermalinkView"; interface InnerProps { data: Data; @@ -18,12 +18,7 @@ const enhanced = withFragmentContainer<{ data: Data }>({ fragment PermalinkViewContainerQuery on Query @argumentDefinitions(commentID: { type: "ID!" }) { comment(id: $commentID) { - id - author { - username - } - body - createdAt + ...CommentContainer } } `, diff --git a/src/core/client/stream/queries/AppQuery.tsx b/src/core/client/stream/queries/AppQuery.tsx index 1cb252899..07601ed1d 100644 --- a/src/core/client/stream/queries/AppQuery.tsx +++ b/src/core/client/stream/queries/AppQuery.tsx @@ -20,9 +20,25 @@ interface InnerProps { local: Local; } +interface WrappedProps { + data: any; +} + // TODO (bc) refactor this into another component. break down the needs of each component. // (careful porting QueryRenderer into another stateless component) +export const renderWrapper = ( + WrappedComponent: React.ComponentType +) => ({ error, props }: ReadyState) => { + if (error) { + return
{error.message}
; + } + if (props) { + return ; + } + return
Loading
; +}; + const AppQuery: StatelessComponent = ({ local: { commentID, assetID }, }) => { @@ -37,15 +53,7 @@ const AppQuery: StatelessComponent = ({ variables={{ commentID, }} - render={({ error, props }: ReadyState) => { - if (error) { - return
{error.message}
; - } - if (props) { - return ; - } - return
Loading
; - }} + render={renderWrapper(PermalinkViewContainer)} /> ); } @@ -60,15 +68,7 @@ const AppQuery: StatelessComponent = ({ variables={{ assetID, }} - render={({ error, props }: ReadyState) => { - if (error) { - return
{error.message}
; - } - if (props) { - return ; - } - return
Loading
; - }} + render={renderWrapper(AppContainer)} /> ); }; diff --git a/src/core/client/ui/components/Attachment/Attachment.css b/src/core/client/ui/components/Attachment/Attachment.css deleted file mode 100644 index c3a2af639..000000000 --- a/src/core/client/ui/components/Attachment/Attachment.css +++ /dev/null @@ -1,2 +0,0 @@ -.root { -} diff --git a/src/core/client/ui/components/Attachment/Attachment.tsx b/src/core/client/ui/components/Attachment/Attachment.tsx deleted file mode 100644 index 549787707..000000000 --- a/src/core/client/ui/components/Attachment/Attachment.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import React, { CSSProperties } from "react"; -import { Manager, Popper, Reference, RefHandler } from "react-popper"; - -interface RenderProps { - ref: RefHandler; - style?: CSSProperties; -} - -interface InnerProps { - body: React.ReactElement | null; - children: (props: RenderProps) => React.ReactElement; - className?: string; - placement?: - | "auto-start" - | "auto" - | "auto-end" - | "top-start" - | "top" - | "top-end" - | "right-start" - | "right" - | "right-end" - | "bottom-end" - | "bottom" - | "bottom-start" - | "left-end" - | "left" - | "left-start"; -} - -interface Props { - ref: any; - style: CSSProperties; -} - -class Attachment extends React.Component { - public render() { - const { children, body, placement = "top" } = this.props; - return ( - - {(props: Props) => children({ ref: props.ref })} - - {(props: Props) => - body - ? React.cloneElement(body, { - innerRef: props.ref, - style: props.style, - }) - : null - } - - - ); - } -} - -export default Attachment; diff --git a/src/core/client/ui/components/Attachment/index.ts b/src/core/client/ui/components/Attachment/index.ts deleted file mode 100644 index b3b8d5f4a..000000000 --- a/src/core/client/ui/components/Attachment/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from "./Attachment"; -export { default } from "./Attachment"; diff --git a/src/core/client/ui/components/ClickOutside/ClickOutside.mdx b/src/core/client/ui/components/ClickOutside/ClickOutside.mdx new file mode 100644 index 000000000..0d8126b5d --- /dev/null +++ b/src/core/client/ui/components/ClickOutside/ClickOutside.mdx @@ -0,0 +1,27 @@ +--- +name: ClickOutside +menu: UI Kit +--- + +import { Playground, PropsTable } from 'docz' +import ClickOutside from './ClickOutside' +import Button from '../Button/Button' + +# ClickOutside + +A Component to handle click events outside the children components. + +## Basic usage +Wrap a Component with `` and pass a function to `onClickOutside`. This function will trigger when the user +clicks outside the component. + +### Test +Click the blue background. It should trigger an alert. Nothing should happen if you click the button. + + +
+ alert('You clicked outside!')}> + + +
+
diff --git a/src/core/client/ui/components/ClickOutside/ClickOutside.spec.tsx b/src/core/client/ui/components/ClickOutside/ClickOutside.spec.tsx new file mode 100644 index 000000000..fd5ba04b8 --- /dev/null +++ b/src/core/client/ui/components/ClickOutside/ClickOutside.spec.tsx @@ -0,0 +1,62 @@ +import { mount } from "enzyme"; +import React from "react"; +import simulant from "simulant"; +import sinon from "sinon"; + +import ClickOutside from "./ClickOutside"; + +let container: HTMLElement; + +beforeAll(() => { + container = document.createElement("div"); + document.body.appendChild(container); +}); + +afterAll(() => { + document.body.removeChild(container); +}); + +it("should render correctly", () => { + const noop = () => null; + const wrapper = mount( + + Hello World! + + ); + expect(wrapper.html()).toMatchSnapshot(); + wrapper.unmount(); +}); + +it("should detect click outside", () => { + const onClickOutside = sinon.spy(); + const wrapper = mount( + + Hello World! + , + { + attachTo: container, + } + ); + simulant.fire(container, "click"); + + expect(onClickOutside.calledOnce).toEqual(true); + wrapper.unmount(); +}); + +it("should ignore click inside", () => { + const onClickOutside = sinon.spy(); + const wrapper = mount( + + + , + { + attachTo: container, + } + ); + + const target = document.getElementById("click-outside-test-button")!; + simulant.fire(target, "click"); + + expect(onClickOutside.calledOnce).toEqual(false); + wrapper.unmount(); +}); diff --git a/src/core/client/ui/components/ClickOutside/ClickOutside.tsx b/src/core/client/ui/components/ClickOutside/ClickOutside.tsx new file mode 100644 index 000000000..a113c9776 --- /dev/null +++ b/src/core/client/ui/components/ClickOutside/ClickOutside.tsx @@ -0,0 +1,34 @@ +import React from "react"; +import { findDOMNode } from "react-dom"; + +export interface ClickOutsideProps { + onClickOutside: () => void; + children: React.ReactNode; +} + +export class ClickOutside extends React.Component { + public domNode: Element | null = null; + + public handleClick = (e: MouseEvent) => { + const { onClickOutside } = this.props; + if (!e || !this.domNode!.contains(e.target as HTMLInputElement)) { + // tslint:disable-next-line:no-unused-expression + onClickOutside && onClickOutside(); + } + }; + + public componentDidMount() { + this.domNode = findDOMNode(this) as Element; + document.addEventListener("click", this.handleClick, true); + } + + public componentWillUnmount() { + document.removeEventListener("click", this.handleClick, true); + } + + public render() { + return this.props.children; + } +} + +export default ClickOutside; diff --git a/src/core/client/ui/components/ClickOutside/__snapshots__/ClickOutside.spec.tsx.snap b/src/core/client/ui/components/ClickOutside/__snapshots__/ClickOutside.spec.tsx.snap new file mode 100644 index 000000000..883a02a51 --- /dev/null +++ b/src/core/client/ui/components/ClickOutside/__snapshots__/ClickOutside.spec.tsx.snap @@ -0,0 +1,3 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`should render correctly 1`] = `"Hello World!"`; diff --git a/src/core/client/ui/components/ClickOutside/index.ts b/src/core/client/ui/components/ClickOutside/index.ts new file mode 100644 index 000000000..f3f59adad --- /dev/null +++ b/src/core/client/ui/components/ClickOutside/index.ts @@ -0,0 +1 @@ +export { default, ClickOutside } from "./ClickOutside"; diff --git a/src/core/client/ui/components/Popover/Popover.tsx b/src/core/client/ui/components/Popover/Popover.tsx index e318810d1..11d37ddd3 100644 --- a/src/core/client/ui/components/Popover/Popover.tsx +++ b/src/core/client/ui/components/Popover/Popover.tsx @@ -42,7 +42,7 @@ interface Props { } interface RenderProps { - toggleVisibility?: () => void; + toggleVisibility: () => void; forwardRef?: RefHandler; } @@ -115,8 +115,6 @@ class Popover extends React.Component { aria-hidden={!visible} > {description} - - {/* */}
{ forwardRef: props.ref, })}
- {/*
*/} ) } diff --git a/src/core/client/ui/components/index.ts b/src/core/client/ui/components/index.ts index 2a52122ec..0b2032cb5 100644 --- a/src/core/client/ui/components/index.ts +++ b/src/core/client/ui/components/index.ts @@ -8,3 +8,4 @@ export { default as UIContext, UIContextProps } from "./UIContext"; export { default as Flex } from "./Flex"; export { default as MatchMedia } from "./MatchMedia"; export { default as TrapFocus } from "./TrapFocus"; +export { default as ClickOutside } from "./ClickOutside"; diff --git a/src/types/simulant.d.ts b/src/types/simulant.d.ts new file mode 100644 index 000000000..0537e0911 --- /dev/null +++ b/src/types/simulant.d.ts @@ -0,0 +1,16 @@ +declare module "simulant" { + type SimulantEvent = {}; + + interface Simulant { + (event: string, extendedParams: Record): SimulantEvent; + fire( + target: HTMLElement, + event: string | SimulantEvent, + extendedParams?: Record + ): void; + polyfill(): void; + } + + const simulant: Simulant; + export default simulant; +}