Merge branch 'ui-components' of github.com:coralproject/talk into ui-components

This commit is contained in:
Belen Curcio
2018-08-03 09:25:11 -03:00
159 changed files with 6379 additions and 1540 deletions
+55
View File
@@ -0,0 +1,55 @@
import sinon from "sinon";
import { Decorator } from "./decorators";
import PymControl from "./PymControl";
describe("PymControl", () => {
const container: HTMLElement = document.createElement("div");
const cleanupDecorator = sinon.mock().once();
const withMockDecorator: Decorator = sinon
.mock()
.once()
.withArgs(sinon.match.object)
.returns(cleanupDecorator);
let control: PymControl;
beforeAll(() => {
container.id = "pymcontrol-test-id";
document.body.appendChild(container);
});
afterAll(() => {
document.body.removeChild(container);
});
it("should create iframe", () => {
control = new PymControl({
decorators: [withMockDecorator],
id: container.id,
url: "http://coralproject.net",
title: "iFrame title",
});
expect(container.innerHTML).toMatchSnapshot();
});
it("should send message", done => {
const messages: MessageEvent[] = [];
const messageRecorder = (e: MessageEvent) => messages.push(e);
const contentWindow = (container.firstChild as HTMLIFrameElement)
.contentWindow!;
contentWindow.addEventListener("message", messageRecorder, false);
control.sendMessage("test", "hello world");
setTimeout(() => {
contentWindow.removeEventListener("message", messageRecorder, false);
expect(messages).toHaveLength(1);
expect(messages[0].data).toMatchSnapshot();
done();
});
});
it("should remove iframe", () => {
control.remove();
expect(container.innerHTML).toBe("");
});
it("should cleanup decorators", () => {
cleanupDecorator.verify();
});
});
+41
View File
@@ -0,0 +1,41 @@
import pym from "pym.js";
import { CleanupCallback, Decorator } from "./decorators";
interface PymControlConfig {
id: string;
url: string;
title: string;
decorators?: ReadonlyArray<Decorator>;
}
export default class PymControl {
private pym: pym.Parent;
private cleanups: CleanupCallback[];
constructor(config: PymControlConfig) {
const decorators = config.decorators || [];
this.pym = new pym.Parent(config.id, config.url, {
title: config.title,
id: `${config.id}_iframe`,
name: `${config.id}_iframe`,
});
this.cleanups = decorators
.map(enhance => enhance(this.pym))
.filter(cb => cb) as CleanupCallback[];
}
public sendMessage(id: string, raw?: string) {
this.pym.sendMessage(id, raw || "");
}
public remove() {
this.cleanups.forEach(cb => cb());
this.cleanups = [];
// Remove the pym parent.
this.pym.remove();
}
}
+70
View File
@@ -0,0 +1,70 @@
import sinon from "sinon";
import { createStreamInterface } from "./Stream";
it("should call eventEmitter.on", () => {
const control = {};
const cb = () => "";
const eventEmitter = {
on: sinon
.mock()
.once()
.withArgs("eventName", cb),
};
const stream = createStreamInterface(control as any, eventEmitter as any);
stream.on("eventName", cb);
eventEmitter.on.verify();
});
it("should call eventEmitter.off", () => {
const control = {};
const cb = () => "";
const eventEmitter = {
off: sinon
.mock()
.once()
.withArgs("eventName", cb),
};
const stream = createStreamInterface(control as any, eventEmitter as any);
stream.off("eventName", cb);
eventEmitter.off.verify();
});
it("should call control.login", () => {
const control = {
sendMessage: sinon
.mock()
.once()
.withArgs("login", "token"),
};
const eventEmitter = {};
const stream = createStreamInterface(control as any, eventEmitter as any);
stream.login("token");
control.sendMessage.verify();
});
it("should call control.logout", () => {
const control = {
sendMessage: sinon
.mock()
.once()
.withArgs("logout"),
};
const eventEmitter = {};
const stream = createStreamInterface(control as any, eventEmitter as any);
stream.logout();
control.sendMessage.verify();
});
it("should call control.remove", () => {
const control = {
remove: sinon
.mock()
.once()
.withArgs(),
};
const eventEmitter = {};
const stream = createStreamInterface(control as any, eventEmitter as any);
stream.remove();
control.remove.verify();
});
+83
View File
@@ -0,0 +1,83 @@
import { EventEmitter2 } from "eventemitter2";
import qs from "query-string";
import {
Decorator,
withAutoHeight,
withClickEvent,
withCommentID,
withEventEmitter,
withIOSSafariWidthWorkaround,
} from "./decorators";
import PymControl from "./PymControl";
import { ensureEndSlash } from "./utils";
interface CreatePymControlConfig {
assetID?: string;
assetURL?: string;
title?: string;
eventEmitter: EventEmitter2;
id: string;
rootURL: string;
}
export function createPymControl(config: CreatePymControlConfig) {
const streamDecorators: ReadonlyArray<Decorator> = [
withIOSSafariWidthWorkaround,
withAutoHeight,
withClickEvent,
withCommentID,
withEventEmitter(config.eventEmitter),
];
const query = qs.stringify({
assetID: config.assetID,
assetURL: config.assetURL,
});
const url = `${ensureEndSlash(config.rootURL)}stream.html?${query}`;
return new PymControl({
id: config.id,
title: config.title || "Talk Embed Stream",
decorators: streamDecorators,
url,
});
}
type EventCallback = (data: any) => void;
export function createStreamInterface(
control: PymControl,
eventEmitter: EventEmitter2
) {
return {
on(eventName: string, callback: EventCallback) {
return eventEmitter.on(eventName, callback);
},
off(eventName: string, callback: EventCallback) {
return eventEmitter.off(eventName, callback);
},
login(token: string) {
control.sendMessage("login", token);
},
logout() {
control.sendMessage("logout");
},
remove() {
return control.remove();
},
};
}
export type StreamInterface = ReturnType<typeof createStreamInterface>;
export interface CreateConfig {
assetID?: string;
assetURL?: string;
title?: string;
eventEmitter: EventEmitter2;
id: string;
rootURL: string;
}
export default function create(config: CreateConfig) {
return createStreamInterface(createPymControl(config), config.eventEmitter);
}
@@ -0,0 +1,5 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`PymControl should create iframe 1`] = `"<iframe src=\\"http://coralproject.net/?initialWidth=0&amp;childId=pymcontrol-test-id&amp;parentTitle=&amp;parentUrl=http%3A%2F%2Flocalhost%2F\\" width=\\"100%\\" scrolling=\\"no\\" marginheight=\\"0\\" frameborder=\\"0\\" title=\\"iFrame title\\" id=\\"pymcontrol-test-id_iframe\\" name=\\"pymcontrol-test-id_iframe\\"></iframe>"`;
exports[`PymControl should send message 1`] = `"pymxPYMxpymcontrol-test-idxPYMxtestxPYMxhello world"`;
@@ -0,0 +1,3 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Basic integration test should render iframe 1`] = `"<iframe src=\\"http://localhost/stream.html?&amp;initialWidth=0&amp;childId=basic-integration-test-id&amp;parentTitle=&amp;parentUrl=http%3A%2F%2Flocalhost%2F\\" width=\\"100%\\" scrolling=\\"no\\" marginheight=\\"0\\" frameborder=\\"0\\" title=\\"Talk Embed Stream\\" id=\\"basic-integration-test-id_iframe\\" name=\\"basic-integration-test-id_iframe\\" style=\\"width: 1px; min-width: 100%;\\"></iframe>"`;
+11
View File
@@ -0,0 +1,11 @@
import pym from "pym.js";
export type CleanupCallback = () => void;
export type Decorator = (pym: pym.Parent) => CleanupCallback | void;
export { default as withAutoHeight } from "./withAutoHeight";
export { default as withClickEvent } from "./withClickEvent";
export { default as withCommentID } from "./withCommentID";
export { default as withEventEmitter } from "./withEventEmitter";
export {
default as withIOSSafariWidthWorkaround,
} from "./withIOSSafariWidthWorkaround";
@@ -0,0 +1,16 @@
import withAutoHeight from "./withAutoHeight";
it("should set height", () => {
const fakePym = {
onMessage: (type: string, callback: (height: string) => void) => {
expect(type).toBe("height");
callback("100");
},
el: document.createElement("div"),
};
fakePym.el.innerHTML = "<span>Hello World </span>";
withAutoHeight(fakePym as any);
expect(fakePym.el.innerHTML).toBe(
'<span style="height: 100px;">Hello World </span>'
);
});
@@ -0,0 +1,14 @@
import { Decorator } from "./";
const withAutoHeight: Decorator = pym => {
// Resize parent iframe height when child height changes
let cachedHeight: string;
pym.onMessage("height", (height: string) => {
if (height !== cachedHeight) {
(pym.el.firstChild! as HTMLElement).style.height = `${height}px`;
cachedHeight = height;
}
});
};
export default withAutoHeight;
@@ -0,0 +1,19 @@
import simulant from "simulant";
import sinon from "sinon";
import { CleanupCallback } from ".";
import withClickEvent from "./withClickEvent";
it("should send click events", () => {
const pymMock = {
sendMessage: sinon
.mock()
.once()
.withArgs("click", ""),
};
const cleanup = withClickEvent(pymMock as any) as CleanupCallback;
simulant.fire(document.body, "click");
cleanup();
simulant.fire(document.body, "click");
pymMock.sendMessage.verify();
});
@@ -0,0 +1,16 @@
import { Decorator } from "./";
const withClickEvent: Decorator = pym => {
const handleClick = () => pym.sendMessage("click", "");
// If the user clicks outside the embed, then tell the embed.
document.addEventListener("click", handleClick, true);
// Return cleanup callback.
return () => {
// Remove the event listeners.
document.removeEventListener("click", handleClick, true);
};
};
export default withClickEvent;
@@ -0,0 +1,36 @@
import withCommentID from "./withCommentID";
it("should add commentID", () => {
const previousLocation = location.toString();
const previousState = window.history.state;
const fakePym = {
onMessage: (type: string, callback: (id: string) => void) => {
if (type === "view-comment") {
callback("comment-id");
}
},
};
withCommentID(fakePym as any);
expect(location.toString()).toBe("http://localhost/?commentId=comment-id");
window.history.replaceState(previousState, document.title, previousLocation);
});
it("should remove commentID", () => {
const previousLocation = location.toString();
const previousState = window.history.state;
window.history.replaceState(
previousState,
document.title,
"http://localhost/?commentId=comment-id"
);
const fakePym = {
onMessage: (type: string, callback: () => void) => {
if (type === "view-all-comments") {
callback();
}
},
};
withCommentID(fakePym as any);
expect(location.toString()).toBe("http://localhost/");
window.history.replaceState(previousState, document.title, previousLocation);
});
@@ -0,0 +1,36 @@
import qs from "query-string";
import { buildURL } from "../utils";
import { Decorator } from "./";
const withCommentID: Decorator = pym => {
// Remove the comment id from the query.
pym.onMessage("view-all-comments", () => {
const search = qs.stringify({
...qs.parse(location.search),
commentId: undefined,
});
// Remove the commentId url param.
const url = buildURL({ search });
// Change the url.
window.history.replaceState({}, document.title, url);
});
// Add the permalink comment id to the query.
pym.onMessage("view-comment", (id: string) => {
const search = qs.stringify({
...qs.parse(location.search),
commentId: id,
});
// Remove the commentId url param.
const url = buildURL({ search });
// Change the url.
window.history.replaceState({}, document.title, url);
});
};
export default withCommentID;
@@ -0,0 +1,21 @@
import sinon from "sinon";
import withEventEmitter from "./withEventEmitter";
it("should emit events from pym to eventEmitter", () => {
const eventEmitterMock = {
emit: sinon
.mock()
.once()
.withArgs("eventName", "value"),
};
const fakePym = {
onMessage: (type: string, callback: (raw: string) => void) => {
expect(type).toBe("event");
callback(JSON.stringify({ eventName: "eventName", value: "value" }));
},
el: document.createElement("div"),
};
withEventEmitter(eventEmitterMock as any)(fakePym as any);
eventEmitterMock.emit.verify();
});
@@ -0,0 +1,13 @@
import { EventEmitter2 } from "eventemitter2";
import { Decorator } from "./";
const withEventEmitter = (eventEmitter: EventEmitter2): Decorator => pym => {
// Pass events from iframe to the event emitter.
pym.onMessage("event", (raw: string) => {
const { eventName, value } = JSON.parse(raw);
eventEmitter.emit(eventName, value);
});
};
export default withEventEmitter;
@@ -0,0 +1,12 @@
import withIOSSafariWidthWorkaround from "./withIOSSafariWidthWorkaround";
it("should set width workaround", () => {
const fakePym = {
el: document.createElement("div"),
};
fakePym.el.innerHTML = "<span>Hello World</span>";
withIOSSafariWidthWorkaround(fakePym as any);
expect(fakePym.el.innerHTML).toBe(
'<span style="width: 1px; min-width: 100%;">Hello World</span>'
);
});
@@ -0,0 +1,9 @@
import { Decorator } from "./";
const withIOSSafariWidthWorkaround: Decorator = pym => {
// Workaround: IOS Safari ignores `width` but respects `min-width` value.
(pym.el.firstChild! as HTMLElement).style.width = "1px";
(pym.el.firstChild! as HTMLElement).style.minWidth = "100%";
};
export default withIOSSafariWidthWorkaround;
+19
View File
@@ -0,0 +1,19 @@
<!DOCTYPE html>
<html>
<head>
<title>Talk 5.0 Embed Stream</title>
<meta charset="utf-8">
<meta http-equiv="Content-type" content="text/html; charset=utf-8" />
<meta name="viewport" content="width=device-width, user-scalable=no">
</head>
<body>
<h1 style="text-align: center" }>Talk 5.0 Embed Stream</h1>
<div id="coralStreamEmbed"></div>
<script>
window.TalkEmbed = Talk.render(document.getElementById('coralStreamEmbed'));
</script>
</body>
</html>
+23
View File
@@ -0,0 +1,23 @@
import * as Talk from "./";
describe("Basic integration test", () => {
const container: HTMLElement = document.createElement("div");
let streamInterface: ReturnType<typeof Talk.render>;
beforeAll(() => {
container.id = "basic-integration-test-id";
document.body.appendChild(container);
});
afterAll(() => {
document.body.removeChild(container);
});
it("should render iframe", () => {
streamInterface = Talk.render({
id: "basic-integration-test-id",
});
expect(container.innerHTML).toMatchSnapshot();
});
it("should remove iframe", () => {
streamInterface.remove();
expect(container.innerHTML).toBe("");
});
});
+30
View File
@@ -0,0 +1,30 @@
import { EventEmitter2 } from "eventemitter2";
import qs from "query-string";
import createStreamInterface from "./Stream";
export interface Config {
assetID?: string;
assetURL?: string;
rootURL?: string;
id?: string;
events?: (eventEmitter: EventEmitter2) => void;
}
export function render(config: Config = {}) {
// Parse query params
const query = qs.parse(location.search);
const eventEmitter = new EventEmitter2({ wildcard: true });
if (config.events) {
config.events(eventEmitter);
}
return createStreamInterface({
assetID: config.assetID || query.assetID,
assetURL: config.assetURL || query.assetURL,
id: config.id || "talk-embed-stream",
rootURL: config.rootURL || location.origin,
eventEmitter,
});
}
+14
View File
@@ -0,0 +1,14 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"lib": ["dom", "es5"],
"types": ["jest"],
"paths": {}
},
"include": [
"./**/*",
"../../../types/pym.d.ts",
"../../../types/simulant.d.ts"
],
"exclude": ["node_modules"]
}
@@ -0,0 +1,18 @@
import buildURL from "./buildURL";
it("should default to window.location", () => {
const url = buildURL();
expect(url).toBe("http://localhost/");
});
it("should build from parameters", () => {
const url = buildURL({
protocol: "https",
hostname: "hostname",
port: "8080",
pathname: "/pathname",
search: "search",
hash: "#hash",
});
expect(url).toBe("https//hostname:8080/pathname?search#hash");
});
+17
View File
@@ -0,0 +1,17 @@
export default function buildURL({
protocol = window.location.protocol,
hostname = window.location.hostname,
port = window.location.port,
pathname = window.location.pathname,
search = window.location.search,
hash = window.location.hash,
} = {}) {
if (search && search[0] !== "?") {
search = `?${search}`;
} else if (search === "?") {
search = "";
}
return `${protocol}//${hostname}${
port ? `:${port}` : ""
}${pathname}${search}${hash}`;
}
@@ -0,0 +1,11 @@
import ensureEndSlash from "./ensureEndSlash";
it("should add slash to the end", () => {
const path = ensureEndSlash("/test");
expect(path).toBe("/test/");
});
it("should not add slash to the end if it's already there", () => {
const path = ensureEndSlash("/test/");
expect(path).toBe("/test/");
});
@@ -0,0 +1,3 @@
export default function ensureEndSlash(p: string) {
return p.match(/\/$/) ? p : `${p}/`;
}
+2
View File
@@ -0,0 +1,2 @@
export { default as buildURL } from "./buildURL";
export { default as ensureEndSlash } from "./ensureEndSlash";
@@ -1,19 +1,31 @@
import { LocalizationProvider } from "fluent-react/compat";
import { MessageContext } from "fluent/compat";
import { Child as PymChild } from "pym.js";
import React, { StatelessComponent } from "react";
import { Formatter } from "react-timeago";
import { Environment } from "relay-runtime";
import { UIContext } from "talk-ui/components";
import { ClickFarAwayRegister } from "talk-ui/components/ClickOutside";
export interface TalkContext {
// relayEnvironment for our relay framework.
/** relayEnvironment for our relay framework. */
relayEnvironment: Environment;
// localMessages for our i18n framework.
/** localMessages for our i18n framework. */
localeMessages: MessageContext[];
// formatter for timeago.
/** formatter for timeago. */
timeagoFormatter?: Formatter;
/**
* A way to listen for clicks that are e.g. outside of the
* current frame for `ClickOutside`
*/
registerClickFarAway?: ClickFarAwayRegister;
/** A pym child that interacts with the pym parent. */
pym?: PymChild;
}
const { Provider, Consumer } = React.createContext<TalkContext>({} as any);
@@ -32,7 +44,12 @@ export const TalkContextProvider: StatelessComponent<{
}> = ({ value, children }) => (
<Provider value={value}>
<LocalizationProvider messages={value.localeMessages}>
<UIContext.Provider value={{ timeagoFormatter: value.timeagoFormatter }}>
<UIContext.Provider
value={{
timeagoFormatter: value.timeagoFormatter,
registerClickFarAway: value.registerClickFarAway,
}}
>
{children}
</UIContext.Provider>
</LocalizationProvider>
@@ -1,22 +1,32 @@
import { EventEmitter2 } from "eventemitter2";
import { Localized } from "fluent-react/compat";
import { noop } from "lodash";
import { Child as PymChild } from "pym.js";
import React from "react";
import { Formatter } from "react-timeago";
import { Environment, Network, RecordSource, Store } from "relay-runtime";
import { ClickFarAwayRegister } from "talk-ui/components/ClickOutside";
import { generateMessages, LocalesData, negotiateLanguages } from "../i18n";
import { fetchQuery } from "../network";
import { TalkContext } from "./TalkContext";
interface CreateContextArguments {
// Locales that the user accepts, usually `navigator.languages`.
/** Locales that the user accepts, usually `navigator.languages`. */
userLocales: ReadonlyArray<string>;
// Locales data that is returned by our `locales-loader`.
/** Locales data that is returned by our `locales-loader`. */
localesData: LocalesData;
// Init will be called after the context has been created.
/** Init will be called after the context has been created. */
init?: ((context: TalkContext) => void | Promise<void>);
/** A pym child that interacts with the pym parent. */
pym?: PymChild;
/** Supports emitting and listening to events. */
eventEmitter?: EventEmitter2;
}
/**
@@ -47,6 +57,8 @@ export default async function createContext({
init = noop,
userLocales,
localesData,
pym,
eventEmitter = new EventEmitter2({ wildcard: true }),
}: CreateContextArguments): Promise<TalkContext> {
// Initialize Relay.
const relayEnvironment = new Environment({
@@ -54,6 +66,21 @@ export default async function createContext({
store: new Store(new RecordSource()),
});
// Listen for outside clicks.
let registerClickFarAway: ClickFarAwayRegister | undefined;
if (pym) {
registerClickFarAway = cb => {
pym.onMessage("click", cb);
// Return unlisten callback.
return () => {
const index = pym.messageHandlers.click.indexOf(cb);
if (index > -1) {
pym.messageHandlers.click.splice(index, 1);
}
};
};
}
// Initialize i18n.
const locales = negotiateLanguages(userLocales, localesData);
@@ -69,6 +96,9 @@ export default async function createContext({
relayEnvironment,
localeMessages,
timeagoFormatter,
pym,
eventEmitter,
registerClickFarAway,
};
// Run custom initializations.
@@ -9,7 +9,7 @@ import Username from "./Username";
export interface CommentProps {
author: {
username: string;
username: string | null;
} | null;
body: string | null;
createdAt: string;
@@ -19,7 +19,8 @@ const Comment: StatelessComponent<CommentProps> = props => {
return (
<div role="article">
<TopBar>
{props.author && <Username>{props.author.username}</Username>}
{props.author &&
props.author.username && <Username>{props.author.username}</Username>}
<Timestamp>{props.createdAt}</Timestamp>
</TopBar>
<Typography>{props.body}</Typography>
@@ -19,3 +19,18 @@ it("renders username and body", () => {
const wrapper = shallow(<CommentContainer {...props} />);
expect(wrapper).toMatchSnapshot();
});
it("renders body only", () => {
const props: PropTypesOf<typeof CommentContainer> = {
data: {
author: {
username: null,
},
body: "Woof",
createdAt: "1995-12-17T03:24:00.000Z",
},
};
const wrapper = shallow(<CommentContainer {...props} />);
expect(wrapper).toMatchSnapshot();
});
@@ -1,5 +1,17 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`renders body only 1`] = `
<Comment
author={
Object {
"username": null,
}
}
body="Woof"
createdAt="1995-12-17T03:24:00.000Z"
/>
`;
exports[`renders username and body 1`] = `
<Comment
author={
+3 -3
View File
@@ -1,15 +1,15 @@
<!DOCTYPE html>
<html prefix="og: http://ogp.me/ns#">
<html>
<head>
<title>Relay Experiments</title>
<title>Talk - Stream</title>
<meta charset="utf-8">
<meta http-equiv="Content-type" content="text/html; charset=utf-8" />
<meta name="viewport" content="width=device-width, user-scalable=no">
</head>
<body>
<div id="app" aria-role="application" onclick="void(0)"></div>
<div id="app"></div>
</body>
</html>
+2
View File
@@ -1,3 +1,4 @@
import pym from "pym.js";
import React from "react";
import { StatelessComponent } from "react";
import ReactDOM from "react-dom";
@@ -23,6 +24,7 @@ async function main() {
init,
localesData,
userLocales: navigator.languages,
pym: new pym.Child({ polling: 100 }),
});
const Index: StatelessComponent = () => (
+1 -1
View File
@@ -16,5 +16,5 @@
}
},
"include": ["./**/*", "../../types/**/*.d.ts"],
"exclude": ["node_modules"]
"exclude": ["node_modules", "./embed"]
}
@@ -3,7 +3,14 @@ import React from "react";
import simulant from "simulant";
import sinon from "sinon";
import ClickOutside from "./ClickOutside";
import UIContext from "../UIContext";
import {
ClickFarAwayCallback,
ClickFarAwayRegister,
ClickOutside,
default as ClickOutsideWithContext,
} from "./ClickOutside";
let container: HTMLElement;
@@ -60,3 +67,57 @@ it("should ignore click inside", () => {
expect(onClickOutside.calledOnce).toEqual(false);
wrapper.unmount();
});
it("should detect click far away", () => {
let emitFarAwayClick: ClickFarAwayCallback = Function;
const unlisten = sinon.spy();
const registerClickFarAway: ClickFarAwayRegister = cb => {
emitFarAwayClick = cb;
return unlisten;
};
const onClickOutside = sinon.spy();
const wrapper = mount(
<ClickOutside
onClickOutside={onClickOutside}
registerClickFarAway={registerClickFarAway}
>
<button id="click-outside-test-button">Push Me</button>
</ClickOutside>,
{
attachTo: container,
}
);
expect(onClickOutside.calledOnce).toEqual(false);
emitFarAwayClick();
expect(onClickOutside.calledOnce).toEqual(true);
expect(unlisten.calledOnce).toEqual(false);
wrapper.unmount();
expect(unlisten.calledOnce).toEqual(true);
});
it("should get registerClickFarAway from context", () => {
const registerClickFarAway: ClickFarAwayRegister = sinon.spy();
const onClickOutside = sinon.spy();
const context: any = {
registerClickFarAway,
};
const wrapper = mount(
<UIContext.Provider value={context}>
<ClickOutsideWithContext
onClickOutside={onClickOutside}
registerClickFarAway={registerClickFarAway}
>
<button id="click-outside-test-button">Push Me</button>
</ClickOutsideWithContext>
</UIContext.Provider>,
{
attachTo: container,
}
);
expect(wrapper.find(ClickOutside).prop("registerClickFarAway")).toEqual(
registerClickFarAway
);
wrapper.unmount();
});
@@ -1,13 +1,30 @@
import React from "react";
import React, { StatelessComponent } from "react";
import { findDOMNode } from "react-dom";
import UIContext from "../UIContext";
export type ClickFarAwayCallback = () => void;
export type ClickFarAwayUnlistenCallback = () => void;
export type ClickFarAwayRegister = (
callback: ClickFarAwayCallback
) => ClickFarAwayUnlistenCallback;
interface Props {
onClickOutside: () => void;
/**
* A way to listen for clicks that are e.g. outside of the
* current frame for `ClickOutside`
*/
registerClickFarAway?: ClickFarAwayRegister;
children: React.ReactNode;
}
class ClickOutside extends React.Component<Props> {
export class ClickOutside extends React.Component<Props> {
public domNode: Element | null = null;
private unlisten?: ClickFarAwayUnlistenCallback;
public handleClick = (e: MouseEvent) => {
const { onClickOutside } = this.props;
@@ -17,17 +34,43 @@ class ClickOutside extends React.Component<Props> {
}
};
public handleClickFarAway = () => {
const { onClickOutside } = this.props;
// tslint:disable-next-line:no-unused-expression
onClickOutside && onClickOutside();
};
public componentDidMount() {
this.domNode = findDOMNode(this) as Element;
document.addEventListener("click", this.handleClick, true);
// Listen to far away clicks.
if (this.props.registerClickFarAway) {
this.unlisten = this.props.registerClickFarAway(this.handleClickFarAway);
}
}
public componentWillUnmount() {
document.removeEventListener("click", this.handleClick, true);
// Unlisten to far away clicks.
if (this.unlisten) {
this.unlisten();
this.unlisten = undefined;
}
}
public render() {
return this.props.children;
}
}
export default ClickOutside;
const ClickOutsideWithContext: StatelessComponent<Props> = props => (
<UIContext.Consumer>
{({ registerClickFarAway }) => (
<ClickOutside {...props} registerClickFarAway={registerClickFarAway} />
)}
</UIContext.Consumer>
);
export default ClickOutsideWithContext;
@@ -0,0 +1 @@
export { default as ClickOutside, ClickFarAwayRegister } from "./ClickOutside";
@@ -1,9 +1,11 @@
import { shallow } from "enzyme";
import { mount, shallow } from "enzyme";
import React from "react";
import { MediaQueryMatchers } from "react-responsive";
import { PropTypesOf } from "talk-ui/types";
import { MatchMedia } from "./MatchMedia";
import UIContext from "../UIContext";
import { default as MatchMediaWithContext, MatchMedia } from "./MatchMedia";
it("renders correctly", () => {
const props: PropTypesOf<typeof MatchMedia> = {
@@ -25,3 +27,20 @@ it("map new speech prop to older aural prop", () => {
const wrapper = shallow(<MatchMedia {...props} />);
expect(wrapper).toMatchSnapshot();
});
it("should get mediaQueryValues from context", () => {
const mediaQueryValues: Partial<MediaQueryMatchers> = {
width: 100,
};
const context: any = {
mediaQueryValues,
};
const wrapper = mount(
<UIContext.Provider value={context}>
<MatchMediaWithContext maxWidth="xs">
<span>Hello World</span>
</MatchMediaWithContext>
</UIContext.Provider>
);
expect(wrapper.find(MatchMedia).prop("values")).toEqual(mediaQueryValues);
});
@@ -2,9 +2,18 @@ import React from "react";
import { MediaQueryMatchers } from "react-responsive";
import { Formatter } from "react-timeago";
import { ClickFarAwayRegister } from "../ClickOutside";
export interface UIContextProps {
/** Allows to integrate translated strings into `RelativeTime` Component */
timeagoFormatter?: Formatter | null;
/** Allows testing `MatchMedia` by setting media query values */
mediaQueryValues?: Partial<MediaQueryMatchers>;
/**
* A way to listen for clicks that are e.g. outside of the
* current frame for `ClickOutside`
*/
registerClickFarAway?: ClickFarAwayRegister;
}
const UIContext = React.createContext<UIContextProps>({} as any);