diff --git a/modules/Media.js b/modules/Media.js index da71686..058460b 100644 --- a/modules/Media.js +++ b/modules/Media.js @@ -3,31 +3,42 @@ import PropTypes from "prop-types"; import invariant from "invariant"; import json2mq from "json2mq"; +const queryType = PropTypes.oneOfType([ + PropTypes.string, + PropTypes.object, + PropTypes.arrayOf(PropTypes.object.isRequired) +]); + /** * Conditionally renders based on whether or not a media query matches. */ class Media extends React.Component { static propTypes = { - defaultMatches: PropTypes.bool, - query: PropTypes.oneOfType([ - PropTypes.string, - PropTypes.object, - PropTypes.arrayOf(PropTypes.object.isRequired) - ]).isRequired, + defaultMatches: PropTypes.objectOf(PropTypes.bool), + queries: PropTypes.objectOf(queryType).isRequired, render: PropTypes.func, children: PropTypes.oneOfType([PropTypes.node, PropTypes.func]), targetWindow: PropTypes.object }; - static defaultProps = { - defaultMatches: true - }; + queries = []; state = { - matches: this.props.defaultMatches + matches: + this.props.defaultMatches || + Object.keys(this.props.queries).reduce( + (acc, key) => ({ ...acc, [key]: true }), + {} + ) }; - updateMatches = () => this.setState({ matches: this.mediaQueryList.matches }); + updateMatches = () => { + const newMatches = this.queries.reduce( + (acc, { name, mqList }) => ({ ...acc, [name]: mqList.matches }), + {} + ); + this.setState({ matches: newMatches }); + }; componentWillMount() { if (typeof window !== "object") return; @@ -39,29 +50,50 @@ class Media extends React.Component { " does not support `matchMedia`." ); - let { query } = this.props; - if (typeof query !== "string") query = json2mq(query); + const { queries } = this.props; + + this.queries = Object.keys(queries).map(name => { + const query = queries[name]; + const qs = typeof query !== "string" ? json2mq(query) : query; + const mqList = targetWindow.matchMedia(qs); + + mqList.addListener(this.updateMatches); + + return { name, qs, mqList }; + }); - this.mediaQueryList = targetWindow.matchMedia(query); - this.mediaQueryList.addListener(this.updateMatches); this.updateMatches(); } componentWillUnmount() { - this.mediaQueryList.removeListener(this.updateMatches); + this.queries.forEach(({ mqList }) => + mqList.removeListener(this.updateMatches) + ); } render() { const { children, render } = this.props; const { matches } = this.state; + const isAnyMatches = Object.keys(matches).some(key => matches[key]); + return render - ? matches ? render() : null + ? isAnyMatches + ? render(matches) + : null : children ? typeof children === "function" ? children(matches) - : !Array.isArray(children) || children.length // Preact defaults to empty children array - ? matches ? React.Children.only(children) : null + : // Preact defaults to empty children array + !Array.isArray(children) || children.length + ? isAnyMatches + ? // We have to check whether child is a composite component or not to decide should we + // provide `matches` as a prop or not + React.Children.only(children) && + typeof React.Children.only(children).type === "string" + ? React.Children.only(children) + : React.cloneElement(React.Children.only(children), { matches }) + : null : null : null; } diff --git a/modules/__tests__/Media-ssr-test.js b/modules/__tests__/Media-ssr-test.js new file mode 100644 index 0000000..170d897 --- /dev/null +++ b/modules/__tests__/Media-ssr-test.js @@ -0,0 +1,57 @@ +/** @jest-environment node */ + +import React from "react"; +import ReactDOMServer from "react-dom/server"; +import Media from "../Media"; + +describe("A in server environment", () => { + const queries = { + sm: "(max-width: 1000px)", + lg: "(max-width: 2000px)", + xl: "(max-width: 3000px)" + }; + + describe("when no default matches prop provided", () => { + it("should render its children as if all queries are matching", () => { + const element = ( + + {matches => + matches.sm && + matches.lg && + matches.xl && All matches, render! + } + + ); + + const result = ReactDOMServer.renderToStaticMarkup(element); + + expect(result).toBe("All matches, render!"); + }); + }); + + describe("when default matches prop provided", () => { + const defaultMatches = { + sm: true, + lg: false, + xl: false + }; + + it("should render its children according to the provided defaultMatches", () => { + const element = ( + + {matches => ( +
+ {matches.sm && small} + {matches.lg && large} + {matches.xl && extra large} +
+ )} +
+ ); + + const result = ReactDOMServer.renderToStaticMarkup(element); + + expect(result).toBe("
small
"); + }); + }); +}); diff --git a/modules/__tests__/Media-test.js b/modules/__tests__/Media-test.js index 49678d6..cff7a12 100644 --- a/modules/__tests__/Media-test.js +++ b/modules/__tests__/Media-test.js @@ -1,16 +1,19 @@ import React from "react"; import ReactDOM from "react-dom"; -import ReactDOMServer from "react-dom/server"; import Media from "../Media"; -const createMockMediaMatcher = matches => () => ({ - matches, +const createMockMediaMatcher = matchesOrMapOfMatches => qs => ({ + matches: + typeof matchesOrMapOfMatches === "object" + ? matchesOrMapOfMatches[qs] + : matchesOrMapOfMatches, addListener: () => {}, removeListener: () => {} }); -describe("A ", () => { +describe("A in browser environment", () => { let originalMatchMedia; + beforeEach(() => { originalMatchMedia = window.matchMedia; }); @@ -20,49 +23,81 @@ describe("A ", () => { }); let node; + beforeEach(() => { node = document.createElement("div"); }); + const queries = { + sm: "(max-width: 1000px)", + lg: "(max-width: 2000px)" + }; + describe("with a query that matches", () => { beforeEach(() => { window.matchMedia = createMockMediaMatcher(true); }); - describe("and a children element", () => { - it("renders its child", () => { + describe("and a child DOM element", () => { + it("should render child", () => { + const element = ( + +
fully matched
+
+ ); + + ReactDOM.render(element, node, () => { + expect(node.firstChild.innerHTML).toMatch("fully matched"); + }); + }); + }); + + describe("and a child component", () => { + it("should render child and provide matches as a prop", () => { + const Component = props => + props.matches.sm && props.matches.lg && fully matched; + const element = ( - -
hello
+ + ); ReactDOM.render(element, node, () => { - expect(node.firstChild.innerHTML).toMatch(/hello/); + expect(node.firstChild.innerHTML).toMatch("fully matched"); }); }); }); describe("and a children function", () => { - it("renders its child", () => { + it("should render its children function call result", () => { const element = ( - - {matches => (matches ?
hello
:
goodbye
)} + + {matches => + matches.sm && matches.lg && children as a function + } ); ReactDOM.render(element, node, () => { - expect(node.firstChild.innerHTML).toMatch(/hello/); + expect(node.firstChild.innerHTML).toMatch("children as a function"); }); }); }); - describe("and a render function", () => { - it("renders its child", () => { - const element =
hello
} />; + describe("and a render prop", () => { + it("should render `render` prop call result", () => { + const element = ( + + matches.sm && matches.lg && render prop + } + /> + ); ReactDOM.render(element, node, () => { - expect(node.firstChild.innerHTML).toMatch(/hello/); + expect(node.firstChild.innerHTML).toMatch("render prop"); }); }); }); @@ -73,56 +108,138 @@ describe("A ", () => { window.matchMedia = createMockMediaMatcher(false); }); - describe("and a children element", () => { - it("renders its child", () => { + describe("and a child DOM element", () => { + it("should not render anything", () => { + const element = ( + +
I am not rendered
+
+ ); + + ReactDOM.render(element, node, () => { + expect(node.innerHTML).not.toMatch("I am not rendered"); + }); + }); + }); + + describe("and a child component", () => { + it("should not render anything", () => { + const Component = () => I'm not rendered; + + const element = ( + + + + ); + + ReactDOM.render(element, node, () => { + expect(node.innerHTML).not.toMatch("I'm not rendered"); + }); + }); + }); + + describe("and a children function", () => { + it("should render children function call result", () => { + const element = ( + + {matches => + !matches.sm && !matches.lg && no matches at all + } + + ); + + ReactDOM.render(element, node, () => { + expect(node.firstChild.innerHTML).toMatch("no matches at all"); + }); + }); + }); + + describe("and a render prop", () => { + it("should not call render prop at all", () => { + const render = jest.fn(); + + const element = ; + + ReactDOM.render(element, node, () => { + expect(render).not.toBeCalled(); + }); + }); + }); + }); + + describe("with a query that partially match", () => { + const queries = { + sm: "(max-width: 1000px)", + lg: "(max-width: 2000px)" + }; + + const matches = { + "(max-width: 1000px)": true, + "(max-width: 2000px)": false + }; + + beforeEach(() => { + window.matchMedia = createMockMediaMatcher(matches); + }); + + describe("and a child component", () => { + it("should render child and provide matches as a prop", () => { + const Component = props => + props.matches.sm && + !props.matches.lg && partially matched; + const element = ( - -
hello
+ + ); ReactDOM.render(element, node, () => { - expect(node.firstChild.innerHTML || "").not.toMatch(/hello/); + expect(node.firstChild.innerHTML).toMatch("partially matched"); }); }); }); describe("and a children function", () => { - it("renders its child", () => { + it("should render children function call result", () => { const element = ( - - {matches => (matches ?
hello
:
goodbye
)} + + {matches => + matches.sm && + !matches.lg && yep, something definetly matched + } ); ReactDOM.render(element, node, () => { - expect(node.firstChild.innerHTML).toMatch(/goodbye/); + expect(node.firstChild.innerHTML).toMatch( + "yep, something definetly matched" + ); }); }); }); - describe("and a render function", () => { - it("does not render", () => { - let renderWasCalled = false; + describe("and a render prop", () => { + it("should render `render` prop call result", () => { const element = ( { - renderWasCalled = true; - return
hello
; - }} + queries={queries} + render={matches => + matches.sm && !matches.lg && please render me + } /> ); ReactDOM.render(element, node, () => { - expect(node.firstChild.innerHTML || "").not.toMatch(/hello/); - expect(renderWasCalled).toBe(false); + expect(node.firstChild.innerHTML).toMatch("please render me"); }); }); }); }); describe("when a custom targetWindow prop is passed", () => { + const queries = { matches: { maxWidth: 320 } }; + beforeEach(() => { window.matchMedia = createMockMediaMatcher(true); }); @@ -133,8 +250,8 @@ describe("A ", () => { }; const element = ( - - {matches => (matches ?
hello
:
goodbye
)} + + {({ matches }) => (matches ?
hello
:
goodbye
)}
); @@ -148,8 +265,8 @@ describe("A ", () => { const notAWindow = {}; const element = ( - - {matches => (matches ?
hello
:
goodbye
)} + + {({ matches }) => (matches ?
hello
:
goodbye
)}
); @@ -159,20 +276,4 @@ describe("A ", () => { }); }); }); - - describe("rendered on the server", () => { - beforeEach(() => { - window.matchMedia = createMockMediaMatcher(true); - }); - - it("renders its child", () => { - const markup = ReactDOMServer.renderToStaticMarkup( - -
hello
-
- ); - - expect(markup).toMatch(/hello/); - }); - }); });