├── .babelrc ├── .eslintignore ├── .eslintrc ├── .gitignore ├── .npmignore ├── .npmrc ├── .travis.yml ├── CHANGELOG.md ├── LICENSE ├── README.md ├── jest.config.js ├── package.json ├── src ├── AutoDirectionProvider.jsx ├── DirectionProvider.jsx ├── constants.js ├── proptypes │ ├── brcast.js │ └── direction.js └── withDirection.jsx └── tests ├── .eslintrc ├── AutoDirectionProvider_test.jsx ├── DirectionProvider_test.jsx ├── _helpers.jsx ├── mocks └── brcast_mock.js └── withDirection_test.jsx /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["airbnb"], 3 | "plugins": [ 4 | ["transform-replace-object-assign", { "moduleSpecifier": "object.assign" }], 5 | ], 6 | } 7 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "extends": "airbnb", 4 | "env": { 5 | "browser": true, 6 | "node": true 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (http://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # Typescript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | 60 | # only apps should have lock files 61 | npm-shrinkwrap.json 62 | package-lock.json 63 | yarn.lock 64 | 65 | # babel output 66 | dist 67 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (http://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # Typescript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | 60 | # only apps should have lock files 61 | npm-shrinkwrap.json 62 | package-lock.json 63 | yarn.lock 64 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | package-lock=false 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "16" 4 | - "14" 5 | - "12" 6 | - "10" 7 | - "8" 8 | - "6" 9 | - "4" 10 | before_install: 11 | - 'nvm install-latest-npm' 12 | install: 13 | - 'if [ "${TRAVIS_NODE_VERSION}" = "0.6" ] || [ "${TRAVIS_NODE_VERSION}" = "0.9" ]; then nvm install --latest-npm 0.8 && npm install && nvm use "${TRAVIS_NODE_VERSION}"; else npm install; fi;' 14 | script: 15 | - 'if [ -n "${LINT-}" ]; then npm run lint; fi' 16 | - 'if [ "${TEST-}" = true ]; then npm run test:only; fi' 17 | env: 18 | global: 19 | - TEST=true 20 | matrix: 21 | - REACT=0.14 22 | - REACT=15.4 23 | - REACT=15 24 | - REACT=16 25 | sudo: false 26 | matrix: 27 | fast_finish: true 28 | include: 29 | env: LINT=true TEST=false 30 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | ## v1.4.0 4 | - [New] `DirectionProvider`: add `lang` prop 5 | - [Deps] update `airbnb-prop-types`, `direction`, `hoist-non-react-statics`, `object.assign`, `object.values`, `prop-types` 6 | - [Dev Deps] update `babel-preset-airbnb`, `casual`, `chai, `enzyme`, `enzyme-adapter-react-helper`, `eslint`, `eslint-config-airbnb`, `eslint-plugin-import`, `eslint-plugin-jsx-a11y`, `eslint-plugin-react`, `rimraf`, `safe-publish-latest`, `sinon-chai`, `sinon-sandbox`, `webpack` 7 | 8 | ## v1.3.1 9 | - [Deps] Update hoist-non-react-statics to 3.3.0 10 | - [Deps] update `airbnb-prop-types`, `deepmerge`, `direction`, `hoist-non-react-statics`, `prop-types` 11 | - [Dev Deps] update `babel-plugin-transform-replace-object-assign`, `babel-preset-airbnb`, `chai-enzyme`, `enzyme-adapter-react-helper`, `eslint`, `eslint-config-airbnb`, `eslint-plugin-import`, `eslint-plugin-jsx-a11y`, `eslint-plugin-react`, `sinon`, `sinon-chai`, `sinon-sandbox` 12 | 13 | ## v1.3.0 14 | - [New] Add `AutoDirectionProvider` component for automatically detected direction. 15 | 16 | ## v1.2.0 17 | - [New] Add `inline` option; if specified, will render a `span` instead of a `div`. 18 | 19 | ## v1.1.0 20 | - [New] increase peerDep/devDep range for react and bump enzyme (#2) 21 | - [Deps] update `hoist-non-react-statics`, `prop-types` 22 | - [Dev Deps] update `babel-jest`, `chai`, `eslint`, `eslint-config-airbnb`, `eslint-plugin-jsx-a11y`, `eslint-plugin-react`, `jest`, `rimraf`, `sinon`, `sinon-chai`, `webpack` 23 | 24 | ## v1.0.1 25 | - [Fix] allow down to React v15.0 26 | - [Deps] update `airbnb-prop-types`, `deepmerge`, `hoist-non-react-statics` 27 | - [Dev Deps] update `babel-cli`, `casual`, `eslint`, `eslint-plugin-jsx-a11y`, `eslint-plugin-react`, `sinon-chai`, `sinon`, `webpack`; remove `eslint-config-airbnb-base` 28 | - [Dev Deps] add `babel-jest`, which needs to be explicitly a top-level dev dep for npm 2 29 | - [Tests] test on node 4, but not node 7 30 | 31 | ## v1.0.0 32 | - Initial release 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Airbnb 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # react-with-direction [![Version Badge][npm-version-svg]][package-url] 2 | 3 | [![Build Status][travis-svg]][travis-url] 4 | [![dependency status][deps-svg]][deps-url] 5 | [![dev dependency status][dev-deps-svg]][dev-deps-url] 6 | [![License][license-image]][license-url] 7 | [![Downloads][downloads-image]][downloads-url] 8 | 9 | [![npm badge][npm-badge-png]][package-url] 10 | 11 | Components to support both right-to-left (RTL) and left-to-right (LTR) layouts in React. 12 | 13 | Supporting RTL or switching between different directions can be tricky. Most browsers have [built-in support](https://www.w3.org/International/questions/qa-html-dir) for displaying markup like paragraphs, lists, and tables. But what about interactive or complex custom UI components? In a right-to-left layout, a photo carousel should advance in the opposite direction, and the primary tab in a navigation control should the rightmost, for example. 14 | 15 | This package provides components to simplify that effort. 16 | 17 | ## withDirection 18 | 19 | Use `withDirection` when your component needs to change based on the layout direction. `withDirection` is an HOC that consumes the direction from React context and passes it as a `direction` prop to the wrapped component. The wrapped component can then pivot its logic to accommodate each direction. 20 | 21 | Usage example: 22 | 23 | ```js 24 | import withDirection, { withDirectionPropTypes, DIRECTIONS } from 'react-with-direction'; 25 | 26 | function ForwardsLabel({ direction }) { 27 | return ( 28 |
29 | Forwards 30 | {direction === DIRECTIONS.LTR && } 31 | {direction === DIRECTIONS.RTL && } 32 |
33 | ); 34 | } 35 | ForwardsLabel.propTypes = { 36 | ...withDirectionPropTypes, 37 | }; 38 | 39 | export default withDirection(ForwardsLabel); 40 | ``` 41 | 42 | ## DirectionProvider 43 | 44 | Use `DirectionProvider` at the top of your app to set the direction context, which can then be consumed by components using `withDirection`. 45 | 46 | You should set the `direction` prop based on the language of the content being rendered; for example, `DIRECTIONS.RTL` (right-to-left) for Arabic or Hebrew, or `DIRECTIONS.LTR` (left-to-right) for English or most other languages. 47 | 48 | `DirectionProvider` components can also be nested, so that the direction can be overridden for certain branches of the React tree. 49 | 50 | `DirectionProvider` will render its children inside of a `
` element with a `dir` attribute set to match the `direction` prop, for example: `
`. This maintains consistency when being rendered in a browser. To render inside of a `` instead of a div, set the `inline` prop to `true`. 51 | 52 | Usage example: 53 | 54 | ```js 55 | import DirectionProvider, { DIRECTIONS } from 'react-with-direction/dist/DirectionProvider'; 56 | ``` 57 | 58 | ```jsx 59 | 60 |
61 | 62 |
63 |
64 | ``` 65 | 66 | To set the `lang` attribute on the wrapping element, provide the `lang` prop to `DirectionProvider`. 67 | 68 | Usage example: 69 | 70 | ```jsx 71 | import DirectionProvider, { DIRECTIONS } from 'react-with-direction/dist/DirectionProvider'; 72 | 73 | 74 |
75 | 76 |
77 |
78 | ``` 79 | 80 | Note that `lang` and `direction` are independent – `lang` only sets the attribute on the wrapping element. 81 | 82 | ## AutoDirectionProvider 83 | 84 | Use `AutoDirectionProvider` around, for example, user-generated content where the text direction is unknown or may change. This renders a `DirectionProvider` with the `direction` prop automatically set based on the `text` prop provided. 85 | 86 | Direction will be determined based on the first strong LTR/RTL character in the `text` string. Strings with no strong direction (e.g., numbers) will inherit the direction from its nearest `DirectionProvider` ancestor or default to LTR. 87 | 88 | Usage example: 89 | 90 | ```js 91 | import AutoDirectionProvider from 'react-with-direction/dist/AutoDirectionProvider'; 92 | ``` 93 | 94 | ```js 95 | 96 | 97 | {userGeneratedContent} 98 | 99 | 100 | ``` 101 | 102 | `AutoDirectionProvider` also supports the `lang` prop in the same way as `DirectionProvider` does. 103 | 104 | [package-url]: https://npmjs.org/package/react-with-direction 105 | [npm-version-svg]: http://versionbadg.es/airbnb/react-with-direction.svg 106 | [travis-svg]: https://travis-ci.org/airbnb/react-with-direction.svg 107 | [travis-url]: https://travis-ci.org/airbnb/react-with-direction 108 | [deps-svg]: https://david-dm.org/airbnb/react-with-direction.svg 109 | [deps-url]: https://david-dm.org/airbnb/react-with-direction 110 | [dev-deps-svg]: https://david-dm.org/airbnb/react-with-direction/dev-status.svg 111 | [dev-deps-url]: https://david-dm.org/airbnb/react-with-direction#info=devDependencies 112 | [npm-badge-png]: https://nodei.co/npm/react-with-direction.png?downloads=true&stars=true 113 | [license-image]: http://img.shields.io/npm/l/react-with-direction.svg 114 | [license-url]: LICENSE 115 | [downloads-image]: http://img.shields.io/npm/dm/react-with-direction.svg 116 | [downloads-url]: http://npm-stat.com/charts.html?package=react-with-direction 117 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | roots: [ 3 | './tests', 4 | ], 5 | setupTestFrameworkScriptFile: './tests/_helpers.jsx', 6 | testEnvironment: 'node', 7 | testRegex: '.*(\\.|/|_)(test)\\.jsx?$', 8 | }; 9 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-with-direction", 3 | "version": "1.4.0", 4 | "description": "Components to provide and consume RTL or LTR direction in React", 5 | "author": "Yaniv Zimet ", 6 | "repository": "airbnb/react-with-direction", 7 | "homepage": "https://github.com/airbnb/react-with-direction#readme", 8 | "bugs": "https://github.com/airbnb/react-with-direction/issues", 9 | "main": "dist/withDirection", 10 | "license": "MIT", 11 | "scripts": { 12 | "prebuild": "npm run clean", 13 | "build": "babel src --out-dir dist", 14 | "lint": "eslint .", 15 | "pretest": "npm run lint", 16 | "pretest:only": "npm run react", 17 | "test:only": "jest", 18 | "test": "npm run test:only", 19 | "test:watch": "npm run test:only -- --watchAll", 20 | "clean": "rimraf dist", 21 | "react": "enzyme-adapter-react-install 16", 22 | "prepublish": "safe-publish-latest && npm run build" 23 | }, 24 | "dependencies": { 25 | "airbnb-prop-types": "^2.16.0", 26 | "brcast": "^2.0.2", 27 | "deepmerge": "^1.5.2", 28 | "direction": "^1.0.4", 29 | "hoist-non-react-statics": "^3.3.2", 30 | "object.assign": "^4.1.2", 31 | "object.values": "^1.1.5", 32 | "prop-types": "^15.7.2" 33 | }, 34 | "devDependencies": { 35 | "babel-cli": "^6.26.0", 36 | "babel-jest": "^21.2.0", 37 | "babel-plugin-transform-replace-object-assign": "^1.0.0", 38 | "babel-preset-airbnb": "^2.6.0", 39 | "casual": "^1.6.2", 40 | "chai": "^4.3.4", 41 | "chai-enzyme": "^1.0.0-beta.1", 42 | "cheerio": "=1.0.0-rc.3", 43 | "enzyme": "^3.11.0", 44 | "enzyme-adapter-react-helper": "^1.3.9", 45 | "eslint": "^7.32.0", 46 | "eslint-config-airbnb": "^18.2.1", 47 | "eslint-plugin-import": "^2.24.2", 48 | "eslint-plugin-jsx-a11y": "^6.4.1", 49 | "eslint-plugin-react": "^7.26.1", 50 | "jest": "^21.2.1", 51 | "react": "^0.14 || ^15 || ^16", 52 | "react-dom": "^0.14 || ^15 || ^16", 53 | "rimraf": "^2.7.1", 54 | "safe-publish-latest": "^1.1.4", 55 | "sinon": "^4.5.0", 56 | "sinon-chai": "^3.7.0", 57 | "sinon-sandbox": "^2.0.6", 58 | "webpack": "^3.12.0" 59 | }, 60 | "peerDependencies": { 61 | "react": "^0.14 || ^15 || ^16", 62 | "react-dom": "^0.14 || ^15 || ^16" 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/AutoDirectionProvider.jsx: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | import React from 'react'; 3 | import { forbidExtraProps } from 'airbnb-prop-types'; 4 | import getDirection from 'direction'; 5 | import directionPropType from './proptypes/direction'; 6 | import DirectionProvider from './DirectionProvider'; 7 | import withDirection from './withDirection'; 8 | 9 | const propTypes = forbidExtraProps({ 10 | children: PropTypes.node.isRequired, 11 | direction: directionPropType.isRequired, 12 | inline: PropTypes.bool, 13 | text: PropTypes.string.isRequired, 14 | lang: PropTypes.string, 15 | }); 16 | 17 | const defaultProps = { 18 | inline: false, 19 | }; 20 | 21 | function AutoDirectionProvider({ 22 | children, 23 | direction, 24 | inline, 25 | text, 26 | lang, 27 | }) { 28 | const textDirection = getDirection(text); 29 | const dir = textDirection === 'neutral' ? direction : textDirection; 30 | 31 | return ( 32 | 37 | {React.Children.only(children)} 38 | 39 | ); 40 | } 41 | 42 | AutoDirectionProvider.propTypes = propTypes; 43 | AutoDirectionProvider.defaultProps = defaultProps; 44 | 45 | export default withDirection(AutoDirectionProvider); 46 | -------------------------------------------------------------------------------- /src/DirectionProvider.jsx: -------------------------------------------------------------------------------- 1 | // This component provides a string to React context that is consumed by the 2 | // withDirection higher-order component. We can use this to give access to 3 | // the current layout direction for components to use. 4 | 5 | import PropTypes from 'prop-types'; 6 | import React from 'react'; 7 | import { forbidExtraProps } from 'airbnb-prop-types'; 8 | import brcast from 'brcast'; 9 | import brcastShape from './proptypes/brcast'; 10 | import directionPropType from './proptypes/direction'; 11 | import { DIRECTIONS, CHANNEL } from './constants'; 12 | 13 | const propTypes = forbidExtraProps({ 14 | children: PropTypes.node.isRequired, 15 | direction: directionPropType.isRequired, 16 | inline: PropTypes.bool, 17 | lang: PropTypes.string, 18 | }); 19 | 20 | const defaultProps = { 21 | inline: false, 22 | }; 23 | 24 | const childContextTypes = { 25 | [CHANNEL]: brcastShape, 26 | }; 27 | 28 | export { DIRECTIONS }; 29 | 30 | export default class DirectionProvider extends React.Component { 31 | constructor(props) { 32 | super(props); 33 | this.broadcast = brcast(props.direction); 34 | } 35 | 36 | getChildContext() { 37 | return { 38 | [CHANNEL]: this.broadcast, 39 | }; 40 | } 41 | 42 | componentWillReceiveProps(nextProps) { 43 | if (this.props.direction !== nextProps.direction) { 44 | this.broadcast.setState(nextProps.direction); 45 | } 46 | } 47 | 48 | render() { 49 | const { children, direction, inline, lang } = this.props; 50 | const Tag = inline ? 'span' : 'div'; 51 | return ( 52 | 53 | {React.Children.only(children)} 54 | 55 | ); 56 | } 57 | } 58 | 59 | DirectionProvider.propTypes = propTypes; 60 | DirectionProvider.defaultProps = defaultProps; 61 | DirectionProvider.childContextTypes = childContextTypes; 62 | -------------------------------------------------------------------------------- /src/constants.js: -------------------------------------------------------------------------------- 1 | export const CHANNEL = '__direction__'; 2 | 3 | export const DIRECTIONS = { 4 | LTR: 'ltr', 5 | RTL: 'rtl', 6 | }; 7 | -------------------------------------------------------------------------------- /src/proptypes/brcast.js: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | 3 | export default PropTypes.shape({ 4 | getState: PropTypes.func, 5 | setState: PropTypes.func, 6 | subscribe: PropTypes.func, 7 | }); 8 | -------------------------------------------------------------------------------- /src/proptypes/direction.js: -------------------------------------------------------------------------------- 1 | import values from 'object.values'; 2 | import PropTypes from 'prop-types'; 3 | 4 | import { DIRECTIONS } from '../constants'; 5 | 6 | export default PropTypes.oneOf(values(DIRECTIONS)); 7 | -------------------------------------------------------------------------------- /src/withDirection.jsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable react/forbid-foreign-prop-types */ 2 | // This higher-order component consumes a string from React context that is 3 | // provided by the DirectionProvider component. 4 | // We can use this to conditionally switch layout/direction for right-to-left layouts. 5 | 6 | import React from 'react'; 7 | import hoistNonReactStatics from 'hoist-non-react-statics'; 8 | import deepmerge from 'deepmerge'; 9 | import getComponentName from 'airbnb-prop-types/build/helpers/getComponentName'; 10 | import { CHANNEL, DIRECTIONS } from './constants'; 11 | import brcastShape from './proptypes/brcast'; 12 | import directionPropType from './proptypes/direction'; 13 | 14 | const contextTypes = { 15 | [CHANNEL]: brcastShape, 16 | }; 17 | 18 | export { DIRECTIONS }; 19 | 20 | // set a default direction so that a component wrapped with this HOC can be 21 | // used even without a DirectionProvider ancestor in its react tree. 22 | const defaultDirection = DIRECTIONS.LTR; 23 | 24 | // export for convenience, in order for components to spread these onto their propTypes 25 | export const withDirectionPropTypes = { 26 | direction: directionPropType.isRequired, 27 | }; 28 | 29 | export default function withDirection(WrappedComponent) { 30 | class WithDirection extends React.Component { 31 | constructor(props, context) { 32 | super(props, context); 33 | this.state = { 34 | direction: context[CHANNEL] ? context[CHANNEL].getState() : defaultDirection, 35 | }; 36 | } 37 | 38 | componentDidMount() { 39 | if (this.context[CHANNEL]) { 40 | // subscribe to future direction changes 41 | this.channelUnsubscribe = this.context[CHANNEL].subscribe((direction) => { 42 | this.setState({ direction }); 43 | }); 44 | } 45 | } 46 | 47 | componentWillUnmount() { 48 | if (this.channelUnsubscribe) { 49 | this.channelUnsubscribe(); 50 | } 51 | } 52 | 53 | render() { 54 | const { direction } = this.state; 55 | 56 | return ( 57 | 61 | ); 62 | } 63 | } 64 | 65 | const wrappedComponentName = getComponentName(WrappedComponent) || 'Component'; 66 | 67 | WithDirection.WrappedComponent = WrappedComponent; 68 | WithDirection.contextTypes = contextTypes; 69 | WithDirection.displayName = `withDirection(${wrappedComponentName})`; 70 | if (WrappedComponent.propTypes) { 71 | WithDirection.propTypes = deepmerge({}, WrappedComponent.propTypes); 72 | delete WithDirection.propTypes.direction; 73 | } 74 | if (WrappedComponent.defaultProps) { 75 | WithDirection.defaultProps = deepmerge({}, WrappedComponent.defaultProps); 76 | } 77 | 78 | return hoistNonReactStatics(WithDirection, WrappedComponent); 79 | } 80 | -------------------------------------------------------------------------------- /tests/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "jest": true 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /tests/AutoDirectionProvider_test.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { expect } from 'chai'; 3 | import { shallow } from 'enzyme'; 4 | 5 | import AutoDirectionProvider from '../src/AutoDirectionProvider'; 6 | import DirectionProvider from '../src/DirectionProvider'; 7 | import { DIRECTIONS, CHANNEL } from '../src/constants'; 8 | import mockBrcast from './mocks/brcast_mock'; 9 | 10 | describe('', () => { 11 | it('renders a DirectionProvider', () => { 12 | const wrapper = shallow(( 13 | 14 |
15 | 16 | )).dive(); 17 | 18 | expect(wrapper).to.have.exactly(1).descendants(DirectionProvider); 19 | }); 20 | 21 | describe('direction prop', () => { 22 | it('is LTR correct for LTR strings', () => { 23 | const wrapper = shallow(( 24 | 25 |
26 | 27 | )).dive(); 28 | 29 | expect(wrapper.find(DirectionProvider)).to.have.prop('direction', DIRECTIONS.LTR); 30 | }); 31 | 32 | it('is RTL correct for RTL strings', () => { 33 | const wrapper = shallow(( 34 | 35 |
36 | 37 | )).dive(); 38 | 39 | expect(wrapper.find(DirectionProvider)).to.have.prop('direction', DIRECTIONS.RTL); 40 | }); 41 | 42 | it('is inherited from context for neutral strings', () => { 43 | const wrapper = shallow( 44 | ( 45 | 46 |
47 | 48 | ), { 49 | context: { 50 | [CHANNEL]: mockBrcast({ 51 | data: DIRECTIONS.RTL, 52 | }), 53 | }, 54 | }, 55 | ).dive(); 56 | 57 | expect(wrapper.find(DirectionProvider)).to.have.prop('direction', DIRECTIONS.RTL); 58 | }); 59 | }); 60 | 61 | it('renders its children', () => { 62 | const children =
Foo
; 63 | 64 | const wrapper = shallow(( 65 | 66 | {children} 67 | 68 | )).dive(); 69 | 70 | expect(wrapper).to.contain(children); 71 | }); 72 | 73 | it('passes the inline prop to DirectionProvider', () => { 74 | let wrapper = shallow(( 75 | 76 |
77 | 78 | )).dive(); 79 | 80 | expect(wrapper.find(DirectionProvider)).to.have.prop('inline', false); 81 | 82 | wrapper = shallow(( 83 | 84 |
85 | 86 | )).dive(); 87 | 88 | expect(wrapper.find(DirectionProvider)).to.have.prop('inline', true); 89 | }); 90 | 91 | it('passes the lang prop to DirectionProvider', () => { 92 | const wrapper = shallow(( 93 | 94 |
95 | 96 | )).dive(); 97 | 98 | expect(wrapper.find(DirectionProvider)).to.have.prop('lang', 'en'); 99 | }); 100 | }); 101 | -------------------------------------------------------------------------------- /tests/DirectionProvider_test.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { expect } from 'chai'; 3 | import { shallow } from 'enzyme'; 4 | import sinon from 'sinon-sandbox'; 5 | 6 | import DirectionProvider from '../src/DirectionProvider'; 7 | import { DIRECTIONS } from '../src/constants'; 8 | 9 | describe('', () => { 10 | let children; 11 | beforeEach(() => { 12 | children =
Foo
; 13 | }); 14 | 15 | it('renders its children', () => { 16 | const wrapper = shallow( 17 | {children}, 18 | ); 19 | expect(wrapper.contains(children)).to.eq(true); 20 | }); 21 | 22 | it('renders a wrapping div with a dir attribute', () => { 23 | const direction = DIRECTIONS.RTL; 24 | const wrapper = shallow( 25 | {children}, 26 | ); 27 | expect(wrapper).to.have.type('div'); 28 | expect(wrapper).to.have.prop('dir', direction); 29 | }); 30 | 31 | it('renders a wrapping span when the inline prop is true', () => { 32 | const direction = DIRECTIONS.RTL; 33 | const wrapper = shallow( 34 | {children}, 35 | ); 36 | expect(wrapper).to.have.type('span'); 37 | expect(wrapper).to.have.prop('dir', direction); 38 | }); 39 | 40 | it('renders a lang attribute when the lang prop is set', () => { 41 | const direction = DIRECTIONS.RTL; 42 | const wrapper = shallow( 43 | {children}, 44 | ); 45 | expect(wrapper).to.have.prop('lang', 'ar'); 46 | }); 47 | 48 | it('broadcasts the direction when the direction prop changes', () => { 49 | const direction = DIRECTIONS.LTR; 50 | const nextDirection = DIRECTIONS.RTL; 51 | const wrapper = shallow( 52 | {children}, 53 | ); 54 | const broadcast = wrapper.instance().broadcast; 55 | const broadcastSpy = sinon.spy(broadcast, 'setState'); 56 | wrapper.setProps({ direction: nextDirection }); 57 | expect(broadcastSpy).to.have.callCount(1); 58 | }); 59 | 60 | it('does not broadcast the direction when the direction prop stays the same', () => { 61 | const direction = DIRECTIONS.LTR; 62 | const nextDirection = DIRECTIONS.LTR; 63 | const wrapper = shallow( 64 | {children}, 65 | ); 66 | const broadcast = wrapper.instance().broadcast; 67 | const broadcastSpy = sinon.spy(broadcast, 'setState'); 68 | wrapper.setProps({ direction: nextDirection }); 69 | expect(broadcastSpy).to.have.callCount(0); 70 | }); 71 | }); 72 | -------------------------------------------------------------------------------- /tests/_helpers.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import chai from 'chai'; 3 | import sinonChai from 'sinon-chai'; 4 | import sinon from 'sinon-sandbox'; 5 | import chaiEnzyme from 'chai-enzyme'; 6 | 7 | import configure from 'enzyme-adapter-react-helper'; 8 | 9 | configure({ disableLifecycleMethods: true }); 10 | 11 | chai.use(sinonChai); 12 | chai.use(chaiEnzyme()); 13 | 14 | afterEach(() => { 15 | sinon.restore(); 16 | }); 17 | -------------------------------------------------------------------------------- /tests/mocks/brcast_mock.js: -------------------------------------------------------------------------------- 1 | import casual from 'casual'; 2 | import sinon from 'sinon-sandbox'; 3 | 4 | export default (overrides = {}) => { 5 | const brcast = { 6 | data: casual.string, 7 | setState: sinon.stub(), 8 | unsubscribe: sinon.stub(), 9 | ...overrides, 10 | }; 11 | 12 | brcast.getState = sinon.stub().returns(brcast.data); 13 | brcast.subscribe = sinon.stub().returns(brcast.unsubscribe); 14 | 15 | return { 16 | ...brcast, 17 | ...overrides, 18 | }; 19 | }; 20 | -------------------------------------------------------------------------------- /tests/withDirection_test.jsx: -------------------------------------------------------------------------------- 1 | import PropTypes from 'prop-types'; 2 | import React from 'react'; 3 | import { expect } from 'chai'; 4 | import { shallow, render } from 'enzyme'; 5 | import sinon from 'sinon-sandbox'; 6 | 7 | import DirectionProvider from '../src/DirectionProvider'; 8 | import withDirection, { withDirectionPropTypes } from '../src/withDirection'; 9 | import { DIRECTIONS, CHANNEL } from '../src/constants'; 10 | import mockBrcast from './mocks/brcast_mock'; 11 | 12 | function getWrappedComponent(expectedDirection) { 13 | function MyComponent({ animal, direction }) { 14 | expect(direction).to.equal(expectedDirection); 15 | return ( 16 |
{`My direction is ${direction} and my animal is ${animal}.`}
17 | ); 18 | } 19 | MyComponent.propTypes = { 20 | ...withDirectionPropTypes, 21 | animal: PropTypes.string, 22 | }; 23 | MyComponent.defaultProps = { 24 | animal: 'dog', 25 | }; 26 | 27 | return withDirection(MyComponent); 28 | } 29 | 30 | describe('withDirection()', () => { 31 | it('has a wrapped displayName', () => { 32 | const Wrapped = getWrappedComponent(DIRECTIONS.LTR); 33 | expect(Wrapped.displayName).to.equal('withDirection(MyComponent)'); 34 | }); 35 | 36 | it('defaults direction to LTR', () => { 37 | const Wrapped = getWrappedComponent(DIRECTIONS.LTR); 38 | render(); 39 | }); 40 | 41 | it('passes direction from context to the wrapped component', () => { 42 | const Wrapped = getWrappedComponent(DIRECTIONS.RTL); 43 | render( 44 | 45 |
46 | 47 |
48 |
, 49 | ); 50 | }); 51 | 52 | describe('lifecycle methods', () => { 53 | let brcast; 54 | beforeEach(() => { 55 | const unsubscribe = sinon.stub(); 56 | brcast = mockBrcast({ 57 | data: DIRECTIONS.LTR, 58 | subscribe: sinon.stub().yields(DIRECTIONS.RTL).returns(unsubscribe), 59 | unsubscribe, 60 | }); 61 | }); 62 | 63 | describe('with a brcast context', () => { 64 | let context; 65 | beforeEach(() => { 66 | context = { 67 | [CHANNEL]: brcast, 68 | }; 69 | }); 70 | 71 | it('sets state with a new direction when the context changes', () => { 72 | const Wrapped = getWrappedComponent(DIRECTIONS.LTR); 73 | const wrapper = shallow(, { context }); 74 | expect(wrapper).to.have.prop('direction', DIRECTIONS.LTR); 75 | 76 | wrapper.instance().componentDidMount(); 77 | wrapper.update(); 78 | expect(wrapper).to.have.prop('direction', DIRECTIONS.RTL); 79 | }); 80 | 81 | it('calls brcast subscribe when the component mounts', () => { 82 | const Wrapped = getWrappedComponent(DIRECTIONS.LTR); 83 | const wrapper = shallow(, { context }); 84 | 85 | expect(brcast.subscribe).to.have.callCount(0); 86 | wrapper.instance().componentDidMount(); 87 | expect(brcast.subscribe).to.have.callCount(1); 88 | }); 89 | 90 | it('calls brcast unsubscribe when the component unmounts', () => { 91 | const Wrapped = getWrappedComponent(DIRECTIONS.LTR); 92 | const wrapper = shallow(, { context }); 93 | wrapper.instance().componentDidMount(); 94 | 95 | expect(brcast.unsubscribe).to.have.callCount(0); 96 | wrapper.instance().componentWillUnmount(); 97 | expect(brcast.unsubscribe).to.have.callCount(1); 98 | }); 99 | }); 100 | 101 | describe('without a brcast context', () => { 102 | let context; 103 | beforeEach(() => { 104 | context = { 105 | [CHANNEL]: null, 106 | }; 107 | }); 108 | 109 | it('does not call brcast subscribe when the component mounts', () => { 110 | const Wrapped = getWrappedComponent(DIRECTIONS.LTR); 111 | const wrapper = shallow(, { context }); 112 | 113 | wrapper.instance().componentDidMount(); 114 | expect(brcast.subscribe).to.have.callCount(0); 115 | }); 116 | 117 | it('does not call brcast unsubscribe when the component unmounts', () => { 118 | const Wrapped = getWrappedComponent(DIRECTIONS.LTR); 119 | const wrapper = shallow(, { context }); 120 | wrapper.instance().componentDidMount(); 121 | 122 | wrapper.instance().componentWillUnmount(); 123 | expect(brcast.unsubscribe).to.have.callCount(0); 124 | }); 125 | }); 126 | }); 127 | }); 128 | --------------------------------------------------------------------------------