├── .npmrc ├── .eslintignore ├── register.js ├── .npmignore ├── .gitignore ├── docs └── demo.png ├── src ├── index.js ├── preset.js ├── constants.js ├── components │ ├── RTLPanel │ │ ├── styles.js │ │ ├── index.test.js │ │ ├── index.js │ │ └── __snapshots__ │ │ │ └── index.test.js.snap │ └── RTLToggle │ │ ├── index.test.js │ │ ├── index.js │ │ ├── styles.js │ │ └── __snapshots__ │ │ └── index.test.js.snap ├── utils.js ├── preview.js ├── utils.test.js ├── manager.js ├── containers │ └── RTLPanel │ │ ├── index.js │ │ ├── index.test.js │ │ └── __snapshots__ │ │ └── index.test.js.snap └── manager.test.js ├── .babelrc ├── .storybook ├── preview.js ├── main.js └── stories.js ├── config └── jest │ └── config.json ├── preset.js ├── .eslintrc ├── .travis.yml ├── LICENSE ├── README.md └── package.json /.npmrc: -------------------------------------------------------------------------------- 1 | legacy-peer-deps=true 2 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | /coverage/ 2 | /dist/ 3 | -------------------------------------------------------------------------------- /register.js: -------------------------------------------------------------------------------- 1 | require('./dist').register() 2 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | /docs/ 2 | /src/**/*.test.js 3 | /.* 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /coverage/ 2 | /dist/ 3 | /node_modules/ 4 | .vscode -------------------------------------------------------------------------------- /docs/demo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/unindented/storybook-addon-rtl/HEAD/docs/demo.png -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | export { register } from './manager' 2 | export { initialize as initializeRTL } from './preview' 3 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "sourceType": "unambiguous", 3 | "presets": [ 4 | "@babel/preset-env", 5 | "@babel/preset-react" 6 | ] 7 | } 8 | -------------------------------------------------------------------------------- /src/preset.js: -------------------------------------------------------------------------------- 1 | function managerEntries (entry = []) { 2 | return [...entry, require.resolve('./manager')] 3 | } 4 | 5 | module.exports = { managerEntries } 6 | -------------------------------------------------------------------------------- /.storybook/preview.js: -------------------------------------------------------------------------------- 1 | import { initializeRTL } from '../src' 2 | 3 | initializeRTL() 4 | 5 | export const parameters = { 6 | actions: { argTypesRegex: '^on[A-Z].*' } 7 | } 8 | -------------------------------------------------------------------------------- /config/jest/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "rootDir": "../../", 3 | "testEnvironment": "jsdom", 4 | "coverageReporters": [ 5 | "text", 6 | "text-summary", 7 | "html" 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /preset.js: -------------------------------------------------------------------------------- 1 | // This file is required for the Storybook Addon Catalog. 2 | function managerEntries (entry = []) { 3 | return [...entry, require.resolve('./register')] 4 | } 5 | 6 | module.exports = { managerEntries } 7 | -------------------------------------------------------------------------------- /src/constants.js: -------------------------------------------------------------------------------- 1 | export const ADDON_ID = 'storybook/rtl' 2 | export const PANEL_ID = `${ADDON_ID}/rtl-panel` 3 | export const INITIALIZE_EVENT_ID = `${ADDON_ID}/rtl-initialize` 4 | export const UPDATE_EVENT_ID = `${ADDON_ID}/rtl-update` 5 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "standard", 4 | "plugin:react/recommended" 5 | ], 6 | "env": { 7 | "jest": true 8 | }, 9 | "settings": { 10 | "react": { 11 | "version": "detect" 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/components/RTLPanel/styles.js: -------------------------------------------------------------------------------- 1 | import styled from 'styled-components' 2 | 3 | export const Panel = styled.div` 4 | align-items: flex-start; 5 | color: #444; 6 | display: flex; 7 | flex-direction: column; 8 | padding: 10px 15px; 9 | ` 10 | -------------------------------------------------------------------------------- /.storybook/main.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | stories: [ 3 | './stories.js' 4 | ], 5 | addons: [ 6 | '@storybook/addon-actions', 7 | '../register' 8 | ], 9 | features: { 10 | storyStoreV7: true 11 | }, 12 | framework: '@storybook/react-webpack5', 13 | core: { 14 | builder: "@storybook/builder-webpack5" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | export function getDefaultTextDirection (api) { 2 | const queryParam = api.getQueryParam('direction') 3 | const htmlDirection = window.getComputedStyle(document.documentElement).direction.toLowerCase() 4 | return queryParam || htmlDirection || 'ltr' 5 | } 6 | 7 | export function setTextDirection (direction) { 8 | document.documentElement.dir = direction 9 | } 10 | -------------------------------------------------------------------------------- /src/components/RTLPanel/index.test.js: -------------------------------------------------------------------------------- 1 | import { render } from '@testing-library/react' 2 | import React from 'react' 3 | import RTLPanel from '.' 4 | 5 | describe('RTLPanel', () => { 6 | let spy 7 | let wrapper 8 | 9 | beforeEach(() => { 10 | spy = jest.fn() 11 | wrapper = render() 12 | }) 13 | 14 | it('renders', () => { 15 | expect(wrapper.container.firstChild).toMatchSnapshot() 16 | }) 17 | }) 18 | -------------------------------------------------------------------------------- /src/preview.js: -------------------------------------------------------------------------------- 1 | import { addons } from '@storybook/addons' 2 | import { setTextDirection } from './utils' 3 | import { INITIALIZE_EVENT_ID, UPDATE_EVENT_ID } from './constants' 4 | 5 | function handleUpdateEvent ({ direction }) { 6 | setTextDirection(direction) 7 | } 8 | 9 | export function initialize (options = {}) { 10 | const channel = addons.getChannel() 11 | channel.on(UPDATE_EVENT_ID, handleUpdateEvent) 12 | channel.emit(INITIALIZE_EVENT_ID) 13 | } 14 | -------------------------------------------------------------------------------- /.storybook/stories.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { action } from '@storybook/addon-actions' 3 | 4 | import RTLToggle from '../src/components/RTLToggle' 5 | 6 | export default { 7 | title: 'RTLToggle', 8 | component: RTLToggle 9 | } 10 | const Template = (args) => 11 | export const basic = Template.bind({}) 12 | 13 | export const unchecked = Template.bind({}) 14 | export const checked = Template.bind({}) 15 | checked.args = { checked: true } 16 | export const rtlParameter = Template.bind({}) 17 | rtlParameter.parameters = { 18 | direction: 'rtl' 19 | } 20 | -------------------------------------------------------------------------------- /src/components/RTLPanel/index.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import PropTypes from 'prop-types' 3 | import RTLToggle from '../RTLToggle' 4 | import { Panel } from './styles' 5 | 6 | export default class RTLPanel extends Component { 7 | render () { 8 | const { checked, onChange } = this.props 9 | 10 | return ( 11 | 12 | 16 | 17 | ) 18 | } 19 | } 20 | 21 | RTLPanel.propTypes = { 22 | checked: PropTypes.bool, 23 | onChange: PropTypes.func.isRequired 24 | } 25 | 26 | RTLPanel.defaultProps = { 27 | checked: false 28 | } 29 | -------------------------------------------------------------------------------- /src/components/RTLPanel/__snapshots__/index.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`RTLPanel renders 1`] = ` 4 |
7 | 10 | 26 | 32 | Right-to-left 33 | 34 | 35 |
36 | `; 37 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - '12' 4 | 5 | deploy: 6 | provider: npm 7 | email: unindented@gmail.com 8 | api_key: 9 | secure: kG3HT+AOB3o1sA+oT0uwyPPcwZWxOB7yFbqzrTzOX0DpPnhidHdAlAbWmh90tttuvLuKXszAt77ghURsr8X3S7pp1UeDl2iQ2dkpWvntE/tPifJ+0/e+3bjrwemf0+Eu9QYO2ELub0msmSv5SZyDUw7aJb/FKWPh40Ynb95Teub07D3iUyVM44RA3unvztejL4vsap6S5/59cc3cZbX8K10NMQQBgYJJloFtIjjOHtp7K7wQaevNTCapWP1Zv1O1+95H6EMfCCFCpoWejhJl8bjLRPZ0PUnbqIqwKQsPBETbdPbEnrvbFIizamOL0aSRwY+Sd67R2oqSfUPzJtGQvjAF2RYLpar82YS2uNnTAFI9x7m50rqaOAG50u0zcaFs6xZcUZJXRhX3BSd/sSxyZkTWUOuDpoQnjMxZ/2GVOHEujIieY0dilQjW+kpncWJO0ztprvW7p62oZiETYEcmfW+ztBJ+XEYPlyqaYuQCMmkNQZjg7o75rh5wjy7kgKEFHYlk9Vw/aEvn+zKt4St0GEJVgAJ7OdIOx9PUx/xWi5V/4EvT0kqWYr1Q/4ZuuqBoU3WRAZ4DTSkXLg00581gVf/qtsZFb+xh9pL7tWENURqdPicRLLRuPMTNiC+P5UzdFpXVgJKf19Y64stNjfu3Arp9tWBpWLk1RNb5CZugRAM= 10 | on: 11 | tags: true 12 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2017 Daniel Perez Alvarez 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | this software and associated documentation files (the "Software"), to deal in 5 | the Software without restriction, including without limitation the rights to 6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 7 | of the Software, and to permit persons to whom the Software is furnished to do 8 | so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /src/components/RTLToggle/index.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { fireEvent, render, act } from '@testing-library/react' 3 | import RTLToggle from '.' 4 | 5 | describe('RTLToggle', () => { 6 | let spy 7 | let wrapper 8 | 9 | beforeEach(() => { 10 | spy = jest.fn() 11 | }) 12 | 13 | describe('default', () => { 14 | beforeEach(() => { 15 | wrapper = render() 16 | }) 17 | 18 | it('renders', () => { 19 | expect(wrapper.container.firstChild).toMatchSnapshot() 20 | }) 21 | 22 | it('invokes `onChange` when changed', () => { 23 | act(() => { 24 | fireEvent.click(wrapper.getByTestId('rtl-toggle-input')) 25 | }) 26 | expect(spy).toHaveBeenCalledWith(true) 27 | }) 28 | }) 29 | 30 | describe('unchecked', () => { 31 | beforeEach(() => { 32 | wrapper = render() 33 | }) 34 | 35 | it('renders', () => { 36 | expect(wrapper.container.firstChild).toMatchSnapshot() 37 | }) 38 | }) 39 | 40 | describe('checked', () => { 41 | beforeEach(() => { 42 | wrapper = render() 43 | }) 44 | 45 | it('renders', () => { 46 | expect(wrapper.container.firstChild).toMatchSnapshot() 47 | }) 48 | }) 49 | }) 50 | -------------------------------------------------------------------------------- /src/utils.test.js: -------------------------------------------------------------------------------- 1 | import { getDefaultTextDirection, setTextDirection } from './utils' 2 | 3 | describe('utils', () => { 4 | describe('.getDefaultTextDirection', () => { 5 | it('returns the text direction from the query params', () => { 6 | const api = { 7 | getQueryParam: jest.fn().mockReturnValue('rtl') 8 | } 9 | jest.spyOn(window, 'getComputedStyle').mockReturnValue({ direction: '' }) 10 | expect(getDefaultTextDirection(api)).toBe('rtl') 11 | }) 12 | 13 | it('returns the text direction from the html tag', () => { 14 | const api = { 15 | getQueryParam: jest.fn().mockReturnValue(undefined) 16 | } 17 | jest.spyOn(window, 'getComputedStyle').mockReturnValue({ direction: 'rtl' }) 18 | expect(getDefaultTextDirection(api)).toBe('rtl') 19 | }) 20 | 21 | it('returns `ltr` as a fallback', () => { 22 | const api = { 23 | getQueryParam: jest.fn().mockReturnValue(undefined) 24 | } 25 | jest.spyOn(window, 'getComputedStyle').mockReturnValue({ direction: '' }) 26 | expect(getDefaultTextDirection(api)).toBe('ltr') 27 | }) 28 | }) 29 | 30 | describe('.setTextDirection', () => { 31 | it('sets the direction of the html tag', () => { 32 | setTextDirection('rtl') 33 | expect(document.documentElement.dir).toBe('rtl') 34 | }) 35 | }) 36 | }) 37 | -------------------------------------------------------------------------------- /src/components/RTLToggle/index.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import PropTypes from 'prop-types' 3 | import { 4 | Toggle, 5 | ToggleInput, 6 | ToggleKnob, 7 | ToggleLabel, 8 | ToggleMessage 9 | } from './styles' 10 | 11 | export default class RTLToggle extends Component { 12 | constructor (props) { 13 | super(props) 14 | 15 | this.handleChange = this.handleChange.bind(this) 16 | } 17 | 18 | handleChange (evt) { 19 | const checked = evt.target.checked 20 | this.props.onChange(checked) 21 | } 22 | 23 | render () { 24 | const { checked } = this.props 25 | 26 | return ( 27 | 28 | 31 | 38 | 41 | {'Enable right-to-left'} 42 | 43 | 44 | 49 | {checked ? 'Right-to-left' : 'Left-to-right'} 50 | 51 | 52 | ) 53 | } 54 | } 55 | 56 | RTLToggle.propTypes = { 57 | checked: PropTypes.bool, 58 | onChange: PropTypes.func.isRequired 59 | } 60 | 61 | RTLToggle.defaultProps = { 62 | checked: false 63 | } 64 | -------------------------------------------------------------------------------- /src/components/RTLToggle/styles.js: -------------------------------------------------------------------------------- 1 | import styled, { css } from 'styled-components' 2 | 3 | const height = 25 4 | const width = 50 5 | const gap = 2 6 | const hue = 208 7 | 8 | export const Toggle = styled.span` 9 | align-items: center; 10 | display: flex; 11 | font-family: -apple-system, ".SFNSText-Regular", "San Francisco", Roboto, "Segoe UI", "Helvetica Neue", "Lucida Grande", sans-serif; 12 | font-size: 13px; 13 | white-space: nowrap; 14 | ` 15 | 16 | export const ToggleLabel = styled.label` 17 | background-color: hsl(${hue}, 0%, 51%); 18 | border-radius: ${height}px; 19 | cursor: pointer; 20 | display: block; 21 | height: ${height}px; 22 | position: relative; 23 | transition: background-color 0.3s ease-in-out; 24 | width: ${width}px; 25 | 26 | ${props => props.checked && css` 27 | background-color: hsl(${hue}, 79%, 51%); 28 | `} 29 | ` 30 | 31 | export const ToggleInput = styled.input` 32 | opacity: 0; 33 | position: absolute; 34 | z-index: -1; 35 | ` 36 | 37 | export const ToggleKnob = styled.a` 38 | background: hsl(${hue}, 100%, 100%); 39 | border-radius: ${height - 2 * gap}px; 40 | height: ${height - 2 * gap}px; 41 | left: ${gap}px; 42 | overflow: hidden; 43 | position: absolute; 44 | text-indent: -9999px; 45 | top: ${gap}px; 46 | transition: left 0.3s ease-in-out, transform 0.3s ease-in-out, width 0.3s ease-in-out; 47 | width: ${height - 2 * gap}px; 48 | 49 | ${ToggleInput}:active + & { 50 | width: ${width * 0.75 - 4 * gap}px; 51 | } 52 | 53 | ${ToggleInput}:focus + & { 54 | outline: #444 dotted 1px; 55 | outline: -webkit-focus-ring-color auto 5px; 56 | } 57 | 58 | ${props => props.checked && css` 59 | left: calc(100% - ${gap}px); 60 | transform: translateX(-100%); 61 | `} 62 | ` 63 | 64 | export const ToggleMessage = styled.span` 65 | margin: 0 15px; 66 | ` 67 | -------------------------------------------------------------------------------- /src/components/RTLToggle/__snapshots__/index.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`RTLToggle checked renders 1`] = ` 4 | 7 | 23 | 29 | Right-to-left 30 | 31 | 32 | `; 33 | 34 | exports[`RTLToggle default renders 1`] = ` 35 | 38 | 53 | 59 | Left-to-right 60 | 61 | 62 | `; 63 | 64 | exports[`RTLToggle unchecked renders 1`] = ` 65 | 68 | 83 | 89 | Left-to-right 90 | 91 | 92 | `; 93 | -------------------------------------------------------------------------------- /src/manager.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { addons } from '@storybook/addons' 3 | import { STORY_RENDERED } from '@storybook/core-events' 4 | import RTLPanel from './containers/RTLPanel' 5 | import { ADDON_ID, PANEL_ID, UPDATE_EVENT_ID } from './constants' 6 | import { getDefaultTextDirection } from './utils' 7 | 8 | export function register () { 9 | addons.register(ADDON_ID, (api) => { 10 | const channel = addons.getChannel() 11 | setDirectionOnStoryChange(api) 12 | 13 | addons.addPanel(PANEL_ID, { 14 | title: 'RTL', 15 | render: ({ active, key }) => { /* eslint-disable-line react/prop-types, react/display-name */ 16 | if (!active) { 17 | return null 18 | } 19 | 20 | return () 21 | } 22 | }) 23 | }) 24 | } 25 | 26 | export function setDirectionOnStoryChange (api) { 27 | const channel = addons.getChannel() 28 | 29 | // Keep track of the most recent value that was a result of user interaction 30 | // so we can return to this value whenever a story is opened that does not have a parameter 31 | let lastUserInteractionValue 32 | // Whenever a story is rendered, update the state to represent the parameter value of the story. 33 | // We do this here and not in the panel component because we want the parameter to be respected 34 | // even if the panel is never opened 35 | channel.on(STORY_RENDERED, (_) => { 36 | const lastUpdate = channel.last(UPDATE_EVENT_ID)?.[0] 37 | lastUserInteractionValue = lastUpdate?.userInteraction ? lastUpdate.direction : lastUserInteractionValue 38 | 39 | const paramValue = api.getCurrentParameter('direction') 40 | let newDirection 41 | if (paramValue) { 42 | newDirection = paramValue 43 | } else if (lastUserInteractionValue) { 44 | newDirection = lastUserInteractionValue 45 | } else { 46 | newDirection = getDefaultTextDirection(api) 47 | } 48 | channel.emit(UPDATE_EVENT_ID, { direction: newDirection, userInteraction: false }) 49 | }) 50 | } 51 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Storybook Addon RTL [![Version](https://img.shields.io/npm/v/storybook-addon-rtl.svg)](https://www.npmjs.com/package/storybook-addon-rtl) [![Build Status](https://img.shields.io/travis/unindented/storybook-addon-rtl.svg)](https://travis-ci.org/unindented/storybook-addon-rtl) 2 | 3 | Storybook Addon RTL allows you to switch to right-to-left flow in your stories in [Storybook](https://storybook.js.org). 4 | 5 | This addon has been tested with Storybook for React, Vue and Angular. It should also work in other frameworks. 6 | 7 | ![Storybook Addon RTL Demo](docs/demo.png) 8 | 9 | 10 | ### Getting Started 11 | 12 | ```sh 13 | npm i --save-dev storybook-addon-rtl 14 | ``` 15 | 16 | Add the addon to the addons array in `.storybook/main.js` 17 | 18 | ```js 19 | module.exports = { 20 | /// other storybook configuration 21 | addons: [ 22 | // other addons here 23 | "storybook-addon-rtl", 24 | ], 25 | }; 26 | ``` 27 | 28 | Add the following to `preview.js`: 29 | 30 | ```js 31 | import { initializeRTL } from 'storybook-addon-rtl'; 32 | 33 | initializeRTL(); 34 | ``` 35 | 36 | Then write your stories normally: 37 | 38 | ```js 39 | import React from 'react' 40 | import MyComponent from './MyComponent' 41 | 42 | export default { 43 | title: 'My Component', 44 | component: MyComponent 45 | } 46 | const Template = (args) => 47 | export const default = Template.bind({}) 48 | 49 | // Optionally include direction as story parameter 50 | rtlParameter.parameters = { 51 | direction: 'rtl' 52 | } 53 | ``` 54 | 55 | 56 | ## Meta 57 | 58 | * Code: `git clone https://github.com/unindented/storybook-addon-rtl.git` 59 | * Home: 60 | 61 | 62 | ## Contributors 63 | 64 | * Daniel Perez Alvarez ([unindented@gmail.com](mailto:unindented@gmail.com)) 65 | * Benjamin Kindle ([benjaminkindle@yahoo.com](mailto:benjaminkindle@yahoo.com)) 66 | 67 | 68 | ## License 69 | 70 | Copyright (c) 2017 Daniel Perez Alvarez ([unindented.org](https://unindented.org/)). This is free software, and may be redistributed under the terms specified in the LICENSE file. 71 | -------------------------------------------------------------------------------- /src/containers/RTLPanel/index.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import PropTypes from 'prop-types' 3 | import RTLPanelComponent from '../../components/RTLPanel' 4 | import { getDefaultTextDirection } from '../../utils' 5 | import { INITIALIZE_EVENT_ID, UPDATE_EVENT_ID } from '../../constants' 6 | 7 | export default class RTLPanel extends Component { 8 | constructor (props) { 9 | super(props) 10 | this.emitCurrentState = this.emitCurrentState.bind(this) 11 | this.handlePanelChange = this.handlePanelChange.bind(this) 12 | this.handleUpdate = this.handleUpdate.bind(this) 13 | 14 | // Get the direction most recently emitted before the panel was opened. 15 | const lastDirection = this.props.channel.last(UPDATE_EVENT_ID)?.[0].direction 16 | const defaultDirection = lastDirection || getDefaultTextDirection(props.api) 17 | this.state = { direction: defaultDirection } 18 | this.props.channel.emit(UPDATE_EVENT_ID, { direction: defaultDirection, userInteraction: false }) 19 | } 20 | 21 | componentDidMount () { 22 | this.props.channel.on(INITIALIZE_EVENT_ID, this.emitCurrentState) 23 | // Update events can be emitted either by this component (when the toggle value changes) 24 | // or by the register code, which emits an event when a new story is rendered that has a parameter value. 25 | this.props.channel.on(UPDATE_EVENT_ID, this.handleUpdate) 26 | } 27 | 28 | handleUpdate (update) { 29 | this.setState({ direction: update.direction }) 30 | } 31 | 32 | componentWillUnmount () { 33 | this.props.channel.removeListener(INITIALIZE_EVENT_ID, this.emitCurrentState) 34 | this.props.channel.removeListener(UPDATE_EVENT_ID, this.handleUpdate) 35 | } 36 | 37 | emitCurrentState () { 38 | this.props.channel.emit(UPDATE_EVENT_ID, this.state) 39 | } 40 | 41 | handlePanelChange (checked) { 42 | this.props.channel.emit(UPDATE_EVENT_ID, { direction: checked ? 'rtl' : 'ltr', userInteraction: true }) 43 | } 44 | 45 | render () { 46 | const { direction } = this.state 47 | 48 | return ( 49 | 53 | ) 54 | } 55 | } 56 | 57 | RTLPanel.propTypes = { 58 | api: PropTypes.object.isRequired, 59 | channel: PropTypes.object.isRequired 60 | } 61 | -------------------------------------------------------------------------------- /src/containers/RTLPanel/index.test.js: -------------------------------------------------------------------------------- 1 | import { render, act } from '@testing-library/react' 2 | import React from 'react' 3 | import RTLPanel from '.' 4 | import { UPDATE_EVENT_ID } from '../../constants' 5 | 6 | describe('RTLPanel', () => { 7 | let api 8 | let channel 9 | let wrapper 10 | 11 | beforeEach(() => { 12 | api = { 13 | getQueryParam: jest.fn() 14 | } 15 | channel = { 16 | on: jest.fn(), 17 | last: jest.fn(), 18 | removeListener: jest.fn(), 19 | emit: jest.fn() 20 | } 21 | }) 22 | 23 | describe('without query parameter', () => { 24 | beforeEach(() => { 25 | api.getQueryParam.mockReturnValue(undefined) 26 | wrapper = render() 27 | }) 28 | 29 | it('renders', () => { 30 | expect(wrapper.container.firstChild).toMatchSnapshot() 31 | }) 32 | }) 33 | 34 | describe('with query parameter', () => { 35 | beforeEach(() => { 36 | api.getQueryParam.mockReturnValue('rtl') 37 | wrapper = render() 38 | }) 39 | 40 | it('renders', () => { 41 | expect(wrapper.container.firstChild).toMatchSnapshot() 42 | }) 43 | }) 44 | 45 | describe('with direction set while panel closed', () => { 46 | beforeEach(() => { 47 | channel.last.mockReturnValue([{ direction: 'rtl' }]) 48 | wrapper = render() 49 | }) 50 | 51 | it('renders', () => { 52 | expect(wrapper.container.firstChild).toMatchSnapshot() 53 | }) 54 | }) 55 | 56 | // Simulate a story param by emitting an event after the component is created 57 | describe('with story param', () => { 58 | let updateEventHandler = () => { } 59 | 60 | beforeEach(() => { 61 | api.getQueryParam.mockReturnValue('rtl') 62 | channel.on.mockImplementation((eventID, handler) => { 63 | if (eventID === UPDATE_EVENT_ID) { 64 | updateEventHandler = handler 65 | } 66 | }) 67 | wrapper = render() 68 | }) 69 | 70 | it('renders', () => { 71 | act(() => { 72 | updateEventHandler({ direction: 'rtl' }) 73 | }) 74 | 75 | expect(wrapper.container.firstChild).toMatchSnapshot() 76 | }) 77 | 78 | it('responds to changes', () => { 79 | act(() => { 80 | updateEventHandler({ direction: 'rtl' }) 81 | }) 82 | expect(wrapper.container.firstChild).toMatchSnapshot() 83 | 84 | act(() => { 85 | updateEventHandler({ direction: 'ltr' }) 86 | }) 87 | expect(wrapper.container.firstChild).toMatchSnapshot() 88 | }) 89 | }) 90 | }) 91 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "storybook-addon-rtl", 3 | "description": "Right-to-left addon for Storybook.", 4 | "author": { 5 | "name": "unindented" 6 | }, 7 | "version": "0.4.4", 8 | "license": "MIT", 9 | "main": "dist/index.js", 10 | "repository": { 11 | "type": "git", 12 | "url": "https://github.com/unindented/storybook-addon-rtl.git" 13 | }, 14 | "files": [ 15 | "dist", 16 | "register.js", 17 | "preset.js" 18 | ], 19 | "keywords": [ 20 | "storybook", 21 | "storybook-addon", 22 | "rtl", 23 | "directionality", 24 | "right-to-left", 25 | "ltr", 26 | "internationalization", 27 | "i18n" 28 | ], 29 | "storybook": { 30 | "displayName": "Storybook Addon RTL", 31 | "supportedFrameworks": [ 32 | "react", 33 | "vue", 34 | "angular", 35 | "web-components" 36 | ] 37 | }, 38 | "scripts": { 39 | "clean:coverage": "rimraf coverage", 40 | "clean:dist": "rimraf dist", 41 | "clean": "run-p clean:*", 42 | "test:lint": "eslint . .storybook", 43 | "test:unit": "jest src --coverage --config config/jest/config.json", 44 | "test": "run-s test:*", 45 | "build": "babel src -d dist", 46 | "watch": "babel src --out-dir dist --watch", 47 | "build-storybook": "storybook build", 48 | "deploy-storybook": "storybook-to-ghpages", 49 | "storybook": "storybook dev", 50 | "prepublishOnly": "run-s clean build" 51 | }, 52 | "dependencies": { 53 | "@storybook/components": "^7.0.0-alpha.48", 54 | "@storybook/core-events": "^7.0.0-alpha.48", 55 | "prop-types": "^15.8.1", 56 | "styled-components": "^5.3.5" 57 | }, 58 | "devDependencies": { 59 | "@babel/cli": "^7.17.10", 60 | "@babel/core": "^7.17.10", 61 | "@babel/preset-env": "^7.17.10", 62 | "@babel/preset-react": "^7.17.12", 63 | "@storybook/addon-actions": "~7.0.0-alpha.48", 64 | "@storybook/addons": "~7.0.0-alpha.48", 65 | "@storybook/builder-webpack5": "~7.0.0-alpha.48", 66 | "@storybook/react": "~7.0.0-alpha.48", 67 | "@storybook/react-webpack5": "^7.0.0-alpha.48", 68 | "@storybook/storybook-deployer": "^2.8.16", 69 | "@testing-library/react": "^13.3.0", 70 | "babel-core": "^7.0.0-bridge.0", 71 | "babel-jest": "^28.1.0", 72 | "eslint": "^8.16.0", 73 | "eslint-config-standard": "^17.0.0", 74 | "eslint-config-standard-react": "^11.0.1", 75 | "eslint-plugin-import": "^2.26.0", 76 | "eslint-plugin-node": "^11.1.0", 77 | "eslint-plugin-promise": "^6.0.0", 78 | "eslint-plugin-react": "^7.30.0", 79 | "jest": "^28.1.0", 80 | "jest-environment-jsdom": "^28.1.0", 81 | "npm-run-all": "^4.1.5", 82 | "react": "^18.1.0", 83 | "react-dom": "^18.1.0", 84 | "react-test-renderer": "^18.1.0", 85 | "rimraf": "^3.0.2", 86 | "storybook": "^7.0.0-alpha.48" 87 | }, 88 | "peerDependencies": { 89 | "@storybook/addons": ">=3.1.6 < 8.0.0", 90 | "react": "*", 91 | "react-dom": "*" 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/manager.test.js: -------------------------------------------------------------------------------- 1 | import { addons, mockChannel } from '@storybook/addons' 2 | import { STORY_RENDERED } from '@storybook/core-events' 3 | import { UPDATE_EVENT_ID } from './constants' 4 | import { setDirectionOnStoryChange } from './manager' 5 | import * as Utils from './utils' 6 | 7 | describe('setDirectionByParameters', () => { 8 | const updateHandler = jest.fn() 9 | const api = { 10 | getCurrentParameter: jest.fn(), 11 | getQueryParam: jest.fn() 12 | } 13 | let channel 14 | 15 | beforeEach(() => { 16 | updateHandler.mockClear() 17 | 18 | addons.setChannel(mockChannel()) 19 | channel = addons.getChannel() 20 | channel.on(UPDATE_EVENT_ID, updateHandler) 21 | channel.emit(UPDATE_EVENT_ID, { direction: 'ltr' }) 22 | setDirectionOnStoryChange(api) 23 | }) 24 | 25 | afterEach(() => { 26 | api.getCurrentParameter = jest.fn() 27 | }) 28 | 29 | it('emits story parameter when story is rendered', () => { 30 | api.getCurrentParameter.mockReturnValue('rtl') 31 | 32 | channel.emit(STORY_RENDERED) 33 | expect(updateHandler).toHaveBeenLastCalledWith({ direction: 'rtl', userInteraction: false }) 34 | }) 35 | 36 | it('works when panel has never been opened', () => { 37 | // cause channel to return undefined as if no events have ever been fired 38 | channel.emit(UPDATE_EVENT_ID, undefined) 39 | const defaultDirSpy = jest.spyOn(Utils, 'getDefaultTextDirection').mockReturnValue('defaultRTL') 40 | 41 | channel.emit(STORY_RENDERED) 42 | 43 | expect(defaultDirSpy).toHaveBeenCalled() 44 | expect(updateHandler).toHaveBeenLastCalledWith({ direction: 'defaultRTL', userInteraction: false }) 45 | 46 | defaultDirSpy.mockRestore() 47 | }) 48 | 49 | it('emits last user interaction when story is rendered without parameters', () => { 50 | api.getCurrentParameter.mockReturnValue(undefined) 51 | channel.emit(UPDATE_EVENT_ID, { direction: 'ltr', userInteraction: true }) 52 | 53 | channel.emit(STORY_RENDERED) 54 | expect(updateHandler).toHaveBeenLastCalledWith({ direction: 'ltr', userInteraction: false }) 55 | }) 56 | 57 | it('emits story parameter even if there was previous user interaction', () => { 58 | channel.emit(UPDATE_EVENT_ID, { direction: 'ltr', userInteraction: true }) 59 | api.getCurrentParameter.mockReturnValue('rtl') 60 | 61 | channel.emit(STORY_RENDERED) 62 | expect(updateHandler).toHaveBeenLastCalledWith({ direction: 'rtl', userInteraction: false }) 63 | }) 64 | 65 | it('emits last user interaction when switching from parameter story to non-parameter story', () => { 66 | channel.emit(UPDATE_EVENT_ID, { direction: 'ltr', userInteraction: true }) 67 | api.getCurrentParameter.mockReturnValue('rtl') 68 | channel.emit(STORY_RENDERED) 69 | 70 | api.getCurrentParameter.mockReturnValue(undefined) 71 | channel.emit(STORY_RENDERED) 72 | 73 | expect(updateHandler).toHaveBeenLastCalledWith({ direction: 'ltr', userInteraction: false }) 74 | }) 75 | 76 | it('emits default direction when switching from parameter story to non-parameter story', () => { 77 | api.getCurrentParameter.mockReturnValue('rtl') 78 | channel.emit(STORY_RENDERED) 79 | expect(updateHandler).toHaveBeenLastCalledWith({ direction: 'rtl', userInteraction: false }) 80 | 81 | api.getCurrentParameter.mockReturnValue(undefined) 82 | channel.emit(STORY_RENDERED) 83 | 84 | expect(updateHandler).toHaveBeenLastCalledWith({ direction: 'ltr', userInteraction: false }) 85 | }) 86 | }) 87 | -------------------------------------------------------------------------------- /src/containers/RTLPanel/__snapshots__/index.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`RTLPanel with direction set while panel closed renders 1`] = ` 4 |
7 | 10 | 26 | 32 | Right-to-left 33 | 34 | 35 |
36 | `; 37 | 38 | exports[`RTLPanel with query parameter renders 1`] = ` 39 |
42 | 45 | 61 | 67 | Right-to-left 68 | 69 | 70 |
71 | `; 72 | 73 | exports[`RTLPanel with story param renders 1`] = ` 74 |
77 | 80 | 96 | 102 | Right-to-left 103 | 104 | 105 |
106 | `; 107 | 108 | exports[`RTLPanel with story param responds to changes 1`] = ` 109 |
112 | 115 | 131 | 137 | Right-to-left 138 | 139 | 140 |
141 | `; 142 | 143 | exports[`RTLPanel with story param responds to changes 2`] = ` 144 |
147 | 150 | 166 | 172 | Left-to-right 173 | 174 | 175 |
176 | `; 177 | 178 | exports[`RTLPanel without query parameter renders 1`] = ` 179 |
182 | 185 | 200 | 206 | Left-to-right 207 | 208 | 209 |
210 | `; 211 | --------------------------------------------------------------------------------