├── .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 |
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 [](https://www.npmjs.com/package/storybook-addon-rtl) [](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 | 
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 |
36 | `;
37 |
38 | exports[`RTLPanel with query parameter renders 1`] = `
39 |
71 | `;
72 |
73 | exports[`RTLPanel with story param renders 1`] = `
74 |
106 | `;
107 |
108 | exports[`RTLPanel with story param responds to changes 1`] = `
109 |
141 | `;
142 |
143 | exports[`RTLPanel with story param responds to changes 2`] = `
144 |
176 | `;
177 |
178 | exports[`RTLPanel without query parameter renders 1`] = `
179 |
210 | `;
211 |
--------------------------------------------------------------------------------