From e413ec2e05103033e0db7e1e33f7c0a4e988fae6 Mon Sep 17 00:00:00 2001 From: Gytis Vinclovas Date: Fri, 12 Oct 2018 12:22:28 +0300 Subject: [PATCH] [onDeviceUI] Add ability to render addons in React Native (#4381) * Reducing the size of the ui. * Addon scrollview with addon names * Updated style, shows addons list with loaded addons. * Updated status bar, so content would not be drawn below it on android. * Added some addons to example. * Moved out of state * Fixed event on android. * Temporary disabled yellow box. * Adding two options for storybook ui: isUIOpen - whether to initially show top bar - default true. isStoryMenuOpen - should show story menu open by default - default false. * Creating rn-addons.js file inside generators. * Removed separate addon store. * Using active prop. * Rewrote addons wrapper without using modal. We cannot use modal since addons expect to be rendered all the time. So now we display the wrapper as position absolute and make it 0 0 size when it is not visible. * Fixing yellow box warnings. * Addon window by default is scrollable. * Removed yellow box disabling, cleared up example index. * Reverted accidentally deleted line. * Updated dependencies. * Dependency bump * Updated UI. Removed modal, displaying menus as panels appearing from sides. * Clearing up the style a little bit. * Temporary disabling some examples * Updating the readmes. * Updating the readme. * Added notes addon that support rn * Updated addon documentation regarding setTimeout. * Fixed proptypes issue, properly set initial tab from the props. * Reduced the amount of rerenders, if you use onDeviceUI it only listens to story change events once instead of twice. * Adding more addons. * OnDeviceUI is now set to true by default. * Updated the notes addon so it renders markdown properly. * Fixes endless cycle when initially rendering when both onDeviceUI and server are enabled. * Selection prop is not required ( it is not set when not using onDeviceUI). * Renamed rn-notes to ondevice-notes addon. * Added option to use channels as async. * Using async channel if onDeviceUI is set to true. * Updated notes documentation/tests. * Adding backgrounds addon. * Updated notes readme. * Adding ondevice knobs addon. * Updated example * Reverted accidental merge issue. * Updating documentation. * Fixed knobs entry file. * Updating documentation. * Updating documentation. * Removed packager completely. * Updated cli. * Added missing dependency. * Websocket doesn't throw red screen on connection fail anymore. * Takes children from props instead out of state.. * Fixed bug where selecting story didn't actually select it. * Removed ondeviceUI in example. * If it fails to connect it selects initial story. * Knobs are properly reset on change. * Proper import in example * Reverts to localhost if no host is defined. * Ui doesnt jump when hiding bottom bar, should handle keyboard correctly on ios. * Updated background addon to unregister on unmount. * Properly handles animations on android. * Creates channel as soon as getStorybookUI is called instead of during the render. * Displays message if no addons are loaded. * setOptions called without timeout. * Sets initial story if connection to websocket server fail. * Updating style. * Removed margin bottom. * Added swiping on the nav bar, touching preview maximizes it. * Fixed keyboard aware view. * Fixed background panel so it doesn't lose color immediately. * Uses preview width for panels. * Moving class inside preview. * All react native installations receive same template. * Removed react_native fixture. * Fixing lint. * Lint fix. * Improving performance dramatically. * Adding on device addons. * Reverted back fixtures change. * Reverting file change * Updating readmes. * Updated readme about server. * Reverted yarn.lock * Fixing propTypes. * Splitting out onDeviceUI/index to smaller components. * Splitting up onDeviceUI. * Removed unused dependency. * Properly uses whole screen for preview. * Updated visibility button. * Few code review fixes Updated readme, Renamed handlers. * Fixed where width is taken from. --- ADDONS_SUPPORT.md | 40 +-- app/react-native/docs/addons.md | 50 ++++ app/react-native/docs/manual-setup.md | 55 ++-- app/react-native/docs/server.md | 24 ++ app/react-native/package.json | 4 +- app/react-native/readme.md | 8 +- app/react-native/src/bin/storybook-start.js | 6 +- ...absolute-positioned-keyboard-aware-view.js | 89 +++++++ .../components/OnDeviceUI/addons/index.js | 47 ++++ .../components/OnDeviceUI/addons/list.js | 50 ++++ .../components/OnDeviceUI/addons/tab.js | 27 ++ .../components/OnDeviceUI/addons/wrapper.js | 45 ++++ .../components/OnDeviceUI/animation.js | 77 ++++++ .../preview/components/OnDeviceUI/index.js | 246 +++++++++--------- .../components/OnDeviceUI/menu_close.png | Bin 1110 -> 0 bytes .../components/OnDeviceUI/menu_open.png | Bin 4744 -> 0 bytes .../components/OnDeviceUI/navigation/bar.js | 42 +++ .../OnDeviceUI/navigation/button.js | 57 ++++ .../OnDeviceUI/navigation/consts.js | 3 + .../components/OnDeviceUI/navigation/index.js | 71 +++++ .../navigation/visibility-button.js | 25 ++ .../preview/components/OnDeviceUI/panel.js | 25 ++ .../preview/components/OnDeviceUI/style.js | 58 +++-- .../preview/components/StoryListView/index.js | 14 +- .../src/preview/components/StoryView/index.js | 57 +++- app/react-native/src/preview/index.js | 136 +++++++--- examples/crna-kitchen-sink/app.json | 7 +- examples/crna-kitchen-sink/package.json | 6 +- .../crna-kitchen-sink/storybook/addons.js | 1 + examples/crna-kitchen-sink/storybook/index.js | 28 +- .../crna-kitchen-sink/storybook/rn-addons.js | 3 + .../storybook/stories/CenterView/style.js | 1 - .../storybook/stories/Knobs/index.js | 2 +- .../storybook/stories/index.js | 31 ++- 34 files changed, 1047 insertions(+), 288 deletions(-) create mode 100644 app/react-native/docs/addons.md create mode 100644 app/react-native/docs/server.md create mode 100644 app/react-native/src/preview/components/OnDeviceUI/absolute-positioned-keyboard-aware-view.js create mode 100644 app/react-native/src/preview/components/OnDeviceUI/addons/index.js create mode 100644 app/react-native/src/preview/components/OnDeviceUI/addons/list.js create mode 100644 app/react-native/src/preview/components/OnDeviceUI/addons/tab.js create mode 100644 app/react-native/src/preview/components/OnDeviceUI/addons/wrapper.js create mode 100644 app/react-native/src/preview/components/OnDeviceUI/animation.js delete mode 100644 app/react-native/src/preview/components/OnDeviceUI/menu_close.png delete mode 100644 app/react-native/src/preview/components/OnDeviceUI/menu_open.png create mode 100644 app/react-native/src/preview/components/OnDeviceUI/navigation/bar.js create mode 100644 app/react-native/src/preview/components/OnDeviceUI/navigation/button.js create mode 100644 app/react-native/src/preview/components/OnDeviceUI/navigation/consts.js create mode 100644 app/react-native/src/preview/components/OnDeviceUI/navigation/index.js create mode 100644 app/react-native/src/preview/components/OnDeviceUI/navigation/visibility-button.js create mode 100644 app/react-native/src/preview/components/OnDeviceUI/panel.js create mode 100644 examples/crna-kitchen-sink/storybook/rn-addons.js diff --git a/ADDONS_SUPPORT.md b/ADDONS_SUPPORT.md index a9de7742d5ec..e367da5b116c 100644 --- a/ADDONS_SUPPORT.md +++ b/ADDONS_SUPPORT.md @@ -1,19 +1,21 @@ -## Addon / Framework Support Table - -| | [React](app/react)|[React Native](app/react-native)|[Vue](app/vue)|[Angular](app/angular)| [Polymer](app/polymer)| [Mithril](app/mithril)| [HTML](app/html)| [Marko](app/marko)| [Svelte](app/svelte)| [Riot](app/riot)| [Ember](app/ember)| -| ----------- |:-------:|:-------:|:-------:|:-------:|:-------:|:-------:|:-------:|:-------:|:-------:|:-------:|:-------:| -|[a11y](addons/a11y) |+| |+|+|+|+|+|+| | |+| -|[actions](addons/actions) |+|+|+|+|+|+|+|+|+|+|+| -|[backgrounds](addons/backgrounds)|+| |+|+|+|+|+|+|+|+|+| -|[centered](addons/centered) |+| |+|+| |+|+| |+| |+| -|[events](addons/events) |+| |+|+|+|+|+|+| | |+| -|[graphql](addons/graphql) |+| | | | | | | | | | | -|[info](addons/info) |+| | | | | | | | | | | -|[jest](addons/jest) |+| | |+| | |+| | | | | -|[knobs](addons/knobs) |+|+|+|+|+|+|+|+|+|+|+| -|[links](addons/links) |+|+|+|+|+|+|+| |+|+|+| -|[notes](addons/notes) |+| |+|+|+|+|+| |+|+|+| -|[options](addons/options) |+|+|+|+|+|+|+| |+|+|+| -|[storyshots](addons/storyshots) |+|+|+|+| | |+| |+|+| | -|[storysource](addons/storysource)|+| |+|+|+|+|+|+|+|+|+| -|[viewport](addons/viewport) |+| |+|+|+|+|+|+|+|+|+| +## Addon / Framework Support Table + +| | [React](app/react)|[React Native](app/react-native)|[Vue](app/vue)|[Angular](app/angular)| [Polymer](app/polymer)| [Mithril](app/mithril)| [HTML](app/html)| [Marko](app/marko)| [Svelte](app/svelte)| [Riot](app/riot)| [Ember](app/ember)| +| ----------- |:-------:|:-------:|:-------:|:-------:|:-------:|:-------:|:-------:|:-------:|:-------:|:-------:|:-------:| +|[a11y](addons/a11y) |+| |+|+|+|+|+|+| | |+| +|[actions](addons/actions) |+|+|+|+|+|+|+|+|+|+|+| +|[backgrounds](addons/backgrounds)|+|*|+|+|+|+|+|+|+|+|+| +|[centered](addons/centered) |+| |+|+| |+|+| |+| |+| +|[events](addons/events) |+| |+|+|+|+|+|+| | |+| +|[graphql](addons/graphql) |+| | | | | | | | | | | +|[info](addons/info) |+| | | | | | | | | | | +|[jest](addons/jest) |+| | |+| | |+| | | | | +|[knobs](addons/knobs) |+|+*|+|+|+|+|+|+|+|+|+| +|[links](addons/links) |+|+|+|+|+|+|+| |+|+|+| +|[notes](addons/notes) |+|+*|+|+|+|+|+| |+|+|+| +|[options](addons/options) |+|+|+|+|+|+|+| |+|+|+| +|[storyshots](addons/storyshots) |+|+|+|+| | |+| |+|+| | +|[storysource](addons/storysource)|+| |+|+|+|+|+|+|+|+|+| +|[viewport](addons/viewport) |+| |+|+|+|+|+|+|+|+|+| + +`*` - React Native on device addon (addons/onDevice-\) diff --git a/app/react-native/docs/addons.md b/app/react-native/docs/addons.md new file mode 100644 index 000000000000..1ede5633ad73 --- /dev/null +++ b/app/react-native/docs/addons.md @@ -0,0 +1,50 @@ +# Addons + +Storybook supports addons. You can read more about them [here](https://storybook.js.org/addons/introduction/) + +There is one big difference in React Native is that it has two types of addons: Addons that work in the browser +and addons that work on the app itself (on device addons). + +## Browser addons +Browser addons are default addons to storybook. You create a file called addons.js inside storybook and it is +automatically added inside your browser. + +## On device addons +On device addons are addons that are displayed in your app in addons panel. +To use them you have to create a file called `rn-addons.js` next to your storybook entry. +Because React Native does not dynamically resolve imports, you also have to manually import them. +Example: +**storybook/index.js** +``` +import { getStorybookUI, configure } from '@storybook/react-native'; +import './rn-addons'; +// import stories +configure(() => { + require($PATH_TO_STORIES); +}, module); + +const StorybookUI = getStorybookUI(); +export default StorybookUI; + +**storybook/rn-addons.js** +``` +import '@storybook/addon-ondevice-knobs/register'; +import '@storybook/addon-ondevice-notes/register'; +... +``` + +This step is done automatically when you install Storybook for the first time and also described in [Manual Setup](https://github.com/storybooks/storybook/blob/master/app/react-native/docs/manual-setup.md) + +## Compatibility +Addon compatibilty can be found [here](https://github.com/storybooks/storybook/blob/master/ADDONS_SUPPORT.md) + +## Performance of on device addons +Because on device addons are inside the app, they are also rerendered on every change. This can reduce performance a lot. + +## Writing the on device addons +On device addons use same addon store and api as web addons. The only difference in api is that you don't have `api` prop +and have to rely on channel for everything. + +The main difference between browser and app addons is that the render has to be supported by React Native (View, Text). +For more info about writing addons read [writing addons](https://storybook.js.org/addons/writing-addons/) section in +storybook documentation. diff --git a/app/react-native/docs/manual-setup.md b/app/react-native/docs/manual-setup.md index 4ad7912a929b..321f23248c4d 100644 --- a/app/react-native/docs/manual-setup.md +++ b/app/react-native/docs/manual-setup.md @@ -6,32 +6,38 @@ First, install the `@storybook/react-native` module npm install @storybook/react-native ``` -Create a new directory called `storybook` in your project root and create an entry file (index.ios.js or index.android.js) as given below. (Don't forget to replace "MyApplicationName" with your app name). +Create a new directory called `storybook` in your project root and create an entry file (index.js) as given below. +(Don't forget to replace "MyApplicationName" with your app name). +**storybook/index.js** ```js import { AppRegistry } from 'react-native'; import { getStorybookUI, configure } from '@storybook/react-native'; -import './addons'; +import './rn-addons'; -// import your stories -configure(function() { +// import stories +configure(() => { + // eslint-disable-next-line global-require require('./stories'); }, module); -const StorybookUI = getStorybookUI({ - port: 7007, - host: 'localhost', -}); -AppRegistry.registerComponent('MyApplicationName', () => StorybookUI); +const StorybookUIRoot = getStorybookUI(); + +AppRegistry.registerComponent('MyApplicationName', () => StorybookUIRoot); +export default StorybookUIRoot; ``` -Create a file named `addons.js` file in `storybook` directory to use addons. Here is a list of default addons: +Create a file called `rn-addons.js` +In this file you can import on device addons. -```js -import '@storybook/addon-actions'; -import '@storybook/addon-links'; +**storybook/rn-addons.js** +``` +import '@storybook/addon-ondevice-knobs/register'; +import '@storybook/addon-ondevice-notes/register'; +... ``` + Then write your first story in the `stories` directory like this: ```js @@ -58,12 +64,31 @@ storiesOf('CenteredView') )); ``` -Then add following NPM script into your `package.json` file: +Finally replace your app entry with +```js +import './storybook'; +``` +If you cannot replace your entry point just make sure that the component exported from `./storybook` is displayed +somewhere in your app. `StorybookUI` is simply a RN `View` component that can be embedded anywhere in your +RN application, e.g. on a tab or within an admin screen. + +## Server support + +If you want to support having a storybook server running add following NPM script into your `package.json` file: ```json { "scripts": { - "storybook": "storybook start -p 7007" + "storybook": "storybook start" } } ``` + +If you want to have addons inside browser, create a file named `addons.js` file in `storybook`. Here is a list of default addons: + +**storybook/addons.js** +```js +import '@storybook/addon-actions'; +import '@storybook/addon-links'; +``` + diff --git a/app/react-native/docs/server.md b/app/react-native/docs/server.md new file mode 100644 index 000000000000..fcfe2abe7630 --- /dev/null +++ b/app/react-native/docs/server.md @@ -0,0 +1,24 @@ +# Storybook server +The default usage of React Native Storybook till version 4 involved starting Storybook server. +Starting from v4 we do not expect user to start the server since in most cases it is not really necessary. + +In case you still want to run Storybook server simply call `npm run storybook` or `npx storybook start`. + +## Benefits of storybook server + +* ### Websockets connection +The main benefit you get from running storybook server is that your app will be listening for websockets connection. +That means that you can create your own tools that integrate with your storybook app. + +* ### IDE Plugins +Having server running allows you to control your storybook view from inside web page or your ide. + +There is a plugin for [JetBrains IDEs](https://plugins.jetbrains.com/plugin/9910-storybook) and there is one +for [VS Code](https://github.com/orta/vscode-react-native-storybooks). + + +* ### Web addons +There are Storybook addons that work with React Native but do not have on device implementations. + + + diff --git a/app/react-native/package.json b/app/react-native/package.json index c281601c4462..d3326ff164c4 100644 --- a/app/react-native/package.json +++ b/app/react-native/package.json @@ -29,6 +29,7 @@ }, "dependencies": { "@storybook/addons": "4.0.0-alpha.24", + "@storybook/channels": "4.0.0-alpha.24", "@storybook/channel-websocket": "4.0.0-alpha.24", "@storybook/core": "4.0.0-alpha.24", "@storybook/core-events": "4.0.0-alpha.24", @@ -57,8 +58,7 @@ "prop-types": "^15.6.2", "raw-loader": "^0.5.1", "react-dev-utils": "6.0.4", - "react-native-compat": "^1.0.0", - "react-native-iphone-x-helper": "^1.2.0", + "react-native-swipe-gestures": "^1.0.2", "shelljs": "^0.8.2", "universal-dotenv": "^1.9.1", "url-parse": "^1.4.3", diff --git a/app/react-native/readme.md b/app/react-native/readme.md index 5c329aeb9686..b30bcb7547fb 100644 --- a/app/react-native/readme.md +++ b/app/react-native/readme.md @@ -104,8 +104,8 @@ You can pass these parameters to getStorybookUI call in your storybook entry poi ``` { - onDeviceUI: Boolean (false) - -- display stories list on the device + onDeviceUI: Boolean (true) + -- display navigator and addons on the device disableWebsockets: Boolean (false) -- allows to display stories without running storybook server. Should be used with onDeviceUI secured: Boolean (false) @@ -116,6 +116,10 @@ You can pass these parameters to getStorybookUI call in your storybook entry poi -- port to use query: String ("") -- additional query string to pass to websockets + isUIHidden: Boolean (false) + -- should the ui be closed initialy. + tabOpen: Number (0) + -- which tab should be open. -1 Navigator, 0 Preview, 1 Addons } ``` diff --git a/app/react-native/src/bin/storybook-start.js b/app/react-native/src/bin/storybook-start.js index fb660525e743..ebdae9425db4 100644 --- a/app/react-native/src/bin/storybook-start.js +++ b/app/react-native/src/bin/storybook-start.js @@ -5,8 +5,8 @@ import program from 'commander'; import Server from '../server'; program - .option('-h, --host ', 'host to listen on') - .option('-p, --port ', 'port to listen on') + .option('-h, --host ', 'host to listen on', 'localhost') + .option('-p, --port ', 'port to listen on', 7007) .option('-s, --secured', 'whether server is running on https') .option('-c, --config-dir [dir-name]', 'storybook config directory') .option('-e, --environment [environment]', 'DEVELOPMENT/PRODUCTION environment for webpack') @@ -33,7 +33,7 @@ server.listen(...listenAddr, err => { if (err) { throw err; } - const address = `http://${program.host || 'localhost'}:${program.port}/`; + const address = `http://${program.host}:${program.port}/`; console.info(`\nReact Native Storybook started on => ${address}\n`); if (program.smokeTest) { process.exit(0); diff --git a/app/react-native/src/preview/components/OnDeviceUI/absolute-positioned-keyboard-aware-view.js b/app/react-native/src/preview/components/OnDeviceUI/absolute-positioned-keyboard-aware-view.js new file mode 100644 index 000000000000..454926269351 --- /dev/null +++ b/app/react-native/src/preview/components/OnDeviceUI/absolute-positioned-keyboard-aware-view.js @@ -0,0 +1,89 @@ +import React, { PureComponent } from 'react'; +import PropTypes from 'prop-types'; +import { Platform, Keyboard, Dimensions, View } from 'react-native'; + +import style from './style'; + +// Android changes screen size when keyboard opens. +// To avoid issues we use absolute positioned element with predefined screen size +export default class AbsolutePositionedKeyboardAwareView extends PureComponent { + componentWillMount() { + this.keyboardDidShowListener = Keyboard.addListener( + 'keyboardDidShow', + this.keyboardDidShowHandler + ); + this.keyboardDidHideListener = Keyboard.addListener( + 'keyboardDidHide', + this.keyboardDidHideHandler + ); + Dimensions.addEventListener('change', this.removeKeyboardOnOrientationChange); + } + + componentWillUnmount() { + this.keyboardDidShowListener.remove(); + this.keyboardDidHideListener.remove(); + Dimensions.removeEventListener('change', this.removeKeyboardOnOrientationChange); + } + + keyboardDidShowHandler = e => { + if (Platform.OS === 'android') { + const { previewWidth } = this.props; + // There is bug in RN android that keyboardDidShow event is called simply when you go from portrait to landscape. + // To make sure that this is keyboard event we check screen width + if (previewWidth === e.endCoordinates.width) { + this.keyboardOpen = true; + } + } + }; + + // When rotating screen from portrait to landscape with keyboard open on android it calls keyboardDidShow, but doesn't call + // keyboardDidHide. To avoid issues we set keyboardOpen to false immediately on keyboardChange. + removeKeyboardOnOrientationChange = () => { + if (Platform.OS === 'android') { + this.keyboardOpen = false; + } + }; + + keyboardDidHideHandler = () => { + if (this.keyboardOpen) { + this.keyboardOpen = false; + } + }; + + onLayoutHandler = ({ nativeEvent }) => { + if (!this.keyboardOpen) { + const { width, height } = nativeEvent.layout; + const { onLayout } = this.props; + + onLayout({ + previewHeight: height, + previewWidth: width, + }); + } + }; + + render() { + const { children, previewWidth, previewHeight } = this.props; + + return ( + + + {children} + + + ); + } +} + +AbsolutePositionedKeyboardAwareView.propTypes = { + children: PropTypes.node.isRequired, + previewWidth: PropTypes.number.isRequired, + previewHeight: PropTypes.number.isRequired, + onLayout: PropTypes.func.isRequired, +}; diff --git a/app/react-native/src/preview/components/OnDeviceUI/addons/index.js b/app/react-native/src/preview/components/OnDeviceUI/addons/index.js new file mode 100644 index 000000000000..57b46d500a6d --- /dev/null +++ b/app/react-native/src/preview/components/OnDeviceUI/addons/index.js @@ -0,0 +1,47 @@ +import React, { PureComponent } from 'react'; +import { View, Text } from 'react-native'; +import addons from '@storybook/addons'; + +import AddonsList from './list'; +import AddonWrapper from './wrapper'; +import style from '../style'; + +export default class Addons extends PureComponent { + constructor() { + super(); + + addons.loadAddons({}); + this.panels = addons.getPanels(); + + this.state = { + addonSelected: Object.keys(this.panels)[0] || null, + }; + } + + onPressAddon = addonSelected => { + this.setState({ addonSelected }); + }; + + render() { + const { addonSelected } = this.state; + + if (Object.keys(this.panels).length === 0) { + return ( + + No onDevice addons loaded. + + ); + } + + return ( + + + + + ); + } +} diff --git a/app/react-native/src/preview/components/OnDeviceUI/addons/list.js b/app/react-native/src/preview/components/OnDeviceUI/addons/list.js new file mode 100644 index 000000000000..3e55ed82de46 --- /dev/null +++ b/app/react-native/src/preview/components/OnDeviceUI/addons/list.js @@ -0,0 +1,50 @@ +import React, { PureComponent } from 'react'; +import PropTypes from 'prop-types'; +import { View, ScrollView, StyleSheet } from 'react-native'; + +import Button from '../navigation/button'; + +const style = StyleSheet.create({ + list: { + flexDirection: 'row', + backgroundColor: 'white', + borderBottomWidth: 1, + borderBottomColor: '#e6e6e6', + }, +}); + +export default class AddonList extends PureComponent { + renderTab = (id, title) => { + const { addonSelected, onPressAddon } = this.props; + + return ( + + ); + }; + + render() { + const { panels } = this.props; + const addonKeys = Object.keys(panels); + + return ( + + + {addonKeys.map(id => this.renderTab(id, panels[id].title))} + + + ); + } +} + +AddonList.propTypes = { + panels: PropTypes.objectOf( + PropTypes.shape({ + title: PropTypes.string.isRequired, + render: PropTypes.func.isRequired, + }).isRequired + ).isRequired, + onPressAddon: PropTypes.func.isRequired, + addonSelected: PropTypes.string.isRequired, +}; diff --git a/app/react-native/src/preview/components/OnDeviceUI/addons/tab.js b/app/react-native/src/preview/components/OnDeviceUI/addons/tab.js new file mode 100644 index 000000000000..3d3a9d94d68e --- /dev/null +++ b/app/react-native/src/preview/components/OnDeviceUI/addons/tab.js @@ -0,0 +1,27 @@ +import React, { PureComponent } from 'react'; +import PropTypes from 'prop-types'; +import { TouchableOpacity, Text } from 'react-native'; + +import style from '../style'; + +export default class Tab extends PureComponent { + onPressHandler = () => { + const { onPress, id } = this.props; + onPress(id); + }; + + render() { + const { title } = this.props; + return ( + + {title} + + ); + } +} + +Tab.propTypes = { + onPress: PropTypes.func.isRequired, + id: PropTypes.string.isRequired, + title: PropTypes.string.isRequired, +}; diff --git a/app/react-native/src/preview/components/OnDeviceUI/addons/wrapper.js b/app/react-native/src/preview/components/OnDeviceUI/addons/wrapper.js new file mode 100644 index 000000000000..9874e510bdca --- /dev/null +++ b/app/react-native/src/preview/components/OnDeviceUI/addons/wrapper.js @@ -0,0 +1,45 @@ +import React, { PureComponent } from 'react'; +import PropTypes from 'prop-types'; +import { View, ScrollView, KeyboardAvoidingView, Platform } from 'react-native'; + +import style from '../style'; + +export default class Wrapper extends PureComponent { + render() { + const { panels, addonSelected } = this.props; + + const keyboardVerticalOffset = Platform.OS === 'ios' ? 60 : 0; + + const addonKeys = Object.keys(panels); + + return addonKeys.map(id => { + const selected = addonSelected === id; + + return ( + + + {panels[id].render({ active: selected })} + + + ); + }); + } +} + +Wrapper.propTypes = { + panels: PropTypes.objectOf( + PropTypes.shape({ + title: PropTypes.string.isRequired, + render: PropTypes.func.isRequired, + }).isRequired + ).isRequired, + addonSelected: PropTypes.string, +}; + +Wrapper.defaultProps = { + addonSelected: '', +}; diff --git a/app/react-native/src/preview/components/OnDeviceUI/animation.js b/app/react-native/src/preview/components/OnDeviceUI/animation.js new file mode 100644 index 000000000000..625fd78a6674 --- /dev/null +++ b/app/react-native/src/preview/components/OnDeviceUI/animation.js @@ -0,0 +1,77 @@ +import { NAVIGATOR, PREVIEW, ADDONS } from './navigation/consts'; + +const PREVIEW_SCALE = 0.3; + +const panelWidth = width => width * (1 - PREVIEW_SCALE - 0.05); + +export function getNavigatorPanelPosition(animatedValue, previewWidth) { + return [ + { + transform: [ + { + translateX: animatedValue.interpolate({ + inputRange: [NAVIGATOR, PREVIEW], + outputRange: [0, -panelWidth(previewWidth)], + }), + }, + ], + width: panelWidth(previewWidth), + }, + ]; +} + +export function getAddonPanelPosition(animatedValue, previewWidth) { + return [ + { + transform: [ + { + translateX: animatedValue.interpolate({ + inputRange: [PREVIEW, ADDONS], + outputRange: [previewWidth, previewWidth - panelWidth(previewWidth)], + }), + }, + ], + width: panelWidth(previewWidth), + }, + ]; +} + +export function getPreviewPosition( + animatedValue, + previewWidth, + previewHeight, + slideBetweenAnimation +) { + const translateX = previewWidth / 2 - (previewWidth * PREVIEW_SCALE) / 2 - 6; + const translateY = -(previewHeight / 2 - (previewHeight * PREVIEW_SCALE) / 2 - 12); + + return { + transform: [ + { + translateX: animatedValue.interpolate({ + inputRange: [NAVIGATOR, PREVIEW, ADDONS], + outputRange: [translateX, 0, -translateX], + }), + }, + { + translateY: animatedValue.interpolate({ + inputRange: [NAVIGATOR, PREVIEW, ADDONS], + outputRange: [translateY, slideBetweenAnimation ? translateY : 0, translateY], + }), + }, + ], + }; +} + +export function getPreviewScale(animatedValue, slideBetweenAnimation) { + return { + transform: [ + { + scale: animatedValue.interpolate({ + inputRange: [NAVIGATOR, PREVIEW, ADDONS], + outputRange: [PREVIEW_SCALE, slideBetweenAnimation ? PREVIEW_SCALE : 1, PREVIEW_SCALE], + }), + }, + ], + }; +} diff --git a/app/react-native/src/preview/components/OnDeviceUI/index.js b/app/react-native/src/preview/components/OnDeviceUI/index.js index aab08cb88d2c..b249643adfd5 100644 --- a/app/react-native/src/preview/components/OnDeviceUI/index.js +++ b/app/react-native/src/preview/components/OnDeviceUI/index.js @@ -1,167 +1,155 @@ -import React, { Component } from 'react'; +import React, { PureComponent } from 'react'; import PropTypes from 'prop-types'; -import { ifIphoneX } from 'react-native-iphone-x-helper'; - -import { Dimensions, View, TouchableWithoutFeedback, Image, Text } from 'react-native'; +import { SafeAreaView, Animated, TouchableOpacity } from 'react-native'; import Events from '@storybook/core-events'; -import style from './style'; + import StoryListView from '../StoryListView'; import StoryView from '../StoryView'; +import Addons from './addons'; +import Panel from './panel'; +import Navigation from './navigation'; +import AbsolutePositionedKeyboardAwareView from './absolute-positioned-keyboard-aware-view'; -/** - * Returns true if the screen is in portrait mode - */ -const isDeviceInPortrait = () => { - const dim = Dimensions.get('screen'); - return dim.height >= dim.width; -}; +import { PREVIEW } from './navigation/consts'; + +import { + getPreviewPosition, + getPreviewScale, + getAddonPanelPosition, + getNavigatorPanelPosition, +} from './animation'; + +import style from './style'; -const openMenuImage = require('./menu_open.png'); -const closeMenuImage = require('./menu_close.png'); +const ANIMATION_DURATION = 300; -const DRAWER_WIDTH = 250; +export default class OnDeviceUI extends PureComponent { + constructor(props) { + super(props); -export default class OnDeviceUI extends Component { - constructor(...args) { - super(...args); + const tabOpen = props.tabOpen || PREVIEW; this.state = { - isMenuOpen: false, - selectedKind: null, - selectedStory: null, - isPortrait: isDeviceInPortrait(), + tabOpen, + slideBetweenAnimation: false, + selection: props.initialStory || {}, + storyFn: props.initialStory ? props.initialStory.storyFn : null, + previewWidth: 0, + previewHeight: 0, }; + + this.animatedValue = new Animated.Value(tabOpen); + this.forceRender = this.forceUpdate.bind(this); } - componentDidMount() { + componentWillMount() { const { events } = this.props; - - Dimensions.addEventListener('change', this.handleDeviceRotation); events.on(Events.SELECT_STORY, this.handleStoryChange); + events.on(Events.FORCE_RE_RENDER, this.forceRender); } componentWillUnmount() { const { events } = this.props; - - Dimensions.removeEventListener('change', this.handleDeviceRotation); events.removeListener(Events.SELECT_STORY, this.handleStoryChange); + events.removeListener(Events.FORCE_RE_RENDER, this.forceRender); } - handleDeviceRotation = () => { - this.setState({ - isPortrait: isDeviceInPortrait(), - }); + onLayout = ({ previewWidth, previewHeight }) => { + this.setState({ previewWidth, previewHeight }); + }; + + handleOpenPreview = () => { + this.handleToggleTab(PREVIEW); }; handleStoryChange = selection => { - const { kind, story } = selection; this.setState({ - selectedKind: kind, - selectedStory: story, + selection: { + kind: selection.kind, + story: selection.story, + }, + storyFn: selection.storyFn, }); }; - handleToggleMenu = () => { - const { isMenuOpen } = this.state; + handleToggleTab = newTabOpen => { + const { tabOpen } = this.state; + + if (newTabOpen === tabOpen) { + return; + } + + Animated.timing(this.animatedValue, { + toValue: newTabOpen, + duration: ANIMATION_DURATION, + useNativeDriver: true, + }).start(); this.setState({ - isMenuOpen: !isMenuOpen, + tabOpen: newTabOpen, + // True if swiping between navigator and addons + slideBetweenAnimation: tabOpen + newTabOpen === PREVIEW, }); }; render() { - const { stories, events, url } = this.props; - const { isPortrait, isMenuOpen, selectedKind, selectedStory } = this.state; - - const iPhoneXStyles = ifIphoneX( - isPortrait - ? { - marginVertical: 30, - } - : { - marginHorizontal: 30, - }, - {} - ); - - const menuStyles = [ - style.menuContainer, - { - transform: [ - { - translateX: isMenuOpen ? 0 : -DRAWER_WIDTH - 30, - }, - ], - }, - iPhoneXStyles, + const { stories, events, url, isUIHidden } = this.props; + const { + tabOpen, + slideBetweenAnimation, + selection, + storyFn, + previewWidth, + previewHeight, + } = this.state; + + const previewWrapperStyles = [ + style.flex, + getPreviewPosition(this.animatedValue, previewWidth, previewHeight, slideBetweenAnimation), ]; - const headerStyles = [ - style.headerContainer, - { - opacity: +!isMenuOpen, - }, + const previewStyles = [ + style.flex, + tabOpen !== 0 && style.previewMinimized, + getPreviewScale(this.animatedValue, slideBetweenAnimation), ]; - const previewContainerStyles = [style.previewContainer, iPhoneXStyles]; - - const previewWrapperStyles = [style.previewWrapper, iPhoneXStyles]; - - /* - Checks if import is a base64 encoded string uri. - If using haul as bundler, some projects are set up to include small files as base64 strings. - */ - let openIcon = openMenuImage; - if (typeof openIcon === 'string') { - openIcon = { uri: openMenuImage }; - } - let closeIcon = closeMenuImage; - if (typeof closeIcon === 'string') { - closeIcon = { uri: closeMenuImage }; - } - return ( - - - - - - - - - - {selectedKind} {selectedStory} - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + ); } } @@ -179,8 +167,18 @@ OnDeviceUI.propTypes = { removeListener: PropTypes.func.isRequired, }).isRequired, url: PropTypes.string, + tabOpen: PropTypes.number, + isUIHidden: PropTypes.bool, + initialStory: PropTypes.shape({ + story: PropTypes.string.isRequired, + kind: PropTypes.string.isRequired, + storyFn: PropTypes.func.isRequired, + }), }; OnDeviceUI.defaultProps = { url: '', + tabOpen: 0, + isUIHidden: false, + initialStory: null, }; diff --git a/app/react-native/src/preview/components/OnDeviceUI/menu_close.png b/app/react-native/src/preview/components/OnDeviceUI/menu_close.png deleted file mode 100644 index 557b3e21aebd211c2afab5972cca2a8c342f78b9..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1110 zcmeAS@N?(olHy`uVBq!ia0vp^4Is?H1|$#LC7uRSEX7WqAsj$Z!;#Vf4nJ za0`PlBg3pY5)2H?KRjI=Ln`LHy%U)y>?qRu@cvD2k!PkZk{%)sHUNy}ZHc4qF`a<7uTlYf^75c8^_uTl#_DGTUvp*!Eqp zbBUPW@Kr{-^qJn@b+=hJOio>1d}rBxf&YA)CnoNXd&6`;^3AThr#9So(=ORvyOL$P zu2fxwy2VeOt>+&(^K4}~-6ns{_I0nhWy(*-wveWXhy9J|$Ex#7{N^OK1vJe7a=&l8 z{r`T)$?~fa%vWQYZU9B%mkFDA1U5){Pf+?}_~CL0&KCS52th*g2Z|?K@Fx~Zsd)noa4ac73 z#H()L{I9$r%QPVTwU3ij5vR_f{RckwXGrsGX9;^)J?HNv#c2)?;}b)hd{;L<+tGa} z-=sxIbccuKA<3K~b^fMP9UkhhXakC><_6^fMO0LQTHM2efJ~(@)f-Vs{ap5j z=T&dGJ)Hd^f~htCOfXPJP0nk<2jkmH@th+2ge`&Wxf^vF50#%<#lm&puvu+ z&>!#S+v{Iyy~o7nBhSR{6VJ@9Q~pEC(Jk&!IH%DEv3*_%C$fKNH8Q?`*#7jbU|Fb! zYRy!I#!En1B`s4Rld1cIK2MypYD7cm4{4syH;=80Y?EKX;`fBZK7iBcv}9et;ln!J zPZa++s2V)e1d)#nfuzTo;vbD(6FfK{va8x&+s2{V3e+@libUN4A)S>^J}~q1Z`KLo zeaNom=x+*=lD!I&+OonDsCPyp2T`) zybp{I^Vx6EIGFxm*HwpHw*%TD|Mu&6eYlh}OYD2#4__Ux4cTUAWzGlw*t|sVVDj$3 zwBHSf=BuZ2`td%`Fkz}&{NeVJUsZn&7j>vHaO@JQF`t{zHY1;LYn%KLJ;!g` z85+bj{@5i=doc0iJ!xBqhu=k4vshn~&NpK63;0p18_i_S?#N)?sIT#7pO|jr>ctNd zy6aSbaIATLV}gTe~ HDWM4fg!=4x diff --git a/app/react-native/src/preview/components/OnDeviceUI/menu_open.png b/app/react-native/src/preview/components/OnDeviceUI/menu_open.png deleted file mode 100644 index 64a204a093d342e13c864811f71d5dbe1e3bf273..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4744 zcmdUyc~sNaw#QEh5>$xK$9)b!pw_EguLV&=FafcmL~8-jkT3;;${-1l0%4FLsa9wm zLM;^s5PYQ;OdvrJ#V|#wB1OQ6KjtB*2nGm~VTgp>^NU^U-L>AI?~ktcTPrL1{m$9@ z?6ZISeD*g#9PuCJ+pY}b{+{4ey69rRlIs_OOT zPz=q{SAxN>*0Cq^jAiO1#h|1>(FNwpRxLO zJbHBg^8WPO>rWnBQ@Syk?K+))5nEq96VNg9d^qdt{_hI5kvk?HibiWE(3t=KUOc30 zJCU)hrC21eDX|gQ6t@(`*DOU3SMCG`YH!t7s(lp?PD{G!&Q#~4&UVGDWxF_Fp3Px$ z+?yV+0vSwC4wnGbVH zecp-gdL+`Ctj`>(lwl^M&B&^!732-lK+Z51y&>bw(+S8`L0yrDd9NObcKmP2 zUE*zZAanYjRAP=9E3OUf+diu2S!KEXE$KE zj>NZ&&>Ac|t*Icb$}Iy(S0~<)6sxC3essv0N;yt$I-K^!5-6_9@>7<%&z((vx{>GU z&D}}V1NR#rBCSF)IfG^2vp?pvs-U{ZlGi+)!uir9^$`31Mjokr9$T8!Qnbf`cwBfr z9(ah-Zg{HoO0Un-hADOH2uEYbLGR@!(5OQALw}@Q8(wOHY(nGxNB1$hBk`AaU6^_H z8ZOv1unZY0*cg$Ak+;h*p!9QxOZcKB10K^22c+i%9Ei8xRbttp6$x?rp!h|ye;|J6 zb0ZR0? z9RYx?@$DnL^#(k;&cp*XIl&WCNn|pRt{@ck+wWkc!)c5s&|o$bT_ZUuj)wt&8a<#h z944c28FQB{!G$w*L26FwqR7&@jL*bh3kLHpx*H2i=sy{>6z4_q*8rb$<^Jup!J(a) zM3K5dwi<5Mb7$g|c((yhFrnK|5+iPJvmHn|5eGo=GY2y6Rlu-P>yyfWn>RlIYdDi% zloSsHOT%!Jj(;dE2$ShB`SIQ^k!L*rt|#@HJkDtuQla(NdY^iO&sg>Eo?t0gtd*J=f5)ju{t4k1_2 zsf77`P0iXZ%}#)#5i1rI188B_v!hklWverGj1qVy@zep4$dUk$N4*AV5Crk@yMOLV z0D?5{zK)pfFFXLSt9l@L*tk~fmx;NRy+PiCRK;2W-tN>|HH*WDd)9LoPX^JmRfzW4 z#xvk~Ft;gETeqGN??Q@{|Nq3?@FS4$t|Qu@47 z%Or8^l^|!RQK3{Y-Dkb>6C$zakXua`;z3=M=|_ z6U=d|7*%me4Yx72FT?_xV6C7AX+xBXyNb(->%k#gOYdN%8A5*{({D{0a12Nmd=!54 zGdR=n^c_p=ZVKtJhYk~#s7u%#n6SKT!r6)k%dyh;$W0`lQiw;{Ttn&ePQBahfTno{ zloqJ#6|KTKqbQ8LbGjAD_YR7Dc(^paodX=})T4^?pI)jcin;r9pREm-@QJu6QQD6X zIa8uy3-O*k#J6mM3sG)6R#Z#T9N@f`uK|hm%aKdS7G%yZt8iKSkRc;XrBY-=M{L7^ z2w!0!fiO*N4+fpSsfChwcmS%62TG#Cr#CQWNr2>+w&1^r@mbqJ9SEPckB5(f1W}d* z49IwowHD`a*>TUrb~At-+jPza_>gJ$oGj++WgBpIu*zrrEmS@B5cPcNVI%~}Bxh82 zT^u$c`2|j&n8GpoZoZ1S8Y!{twX?va2-F2A8{At;(gK}{#FN6V%|EpXErahqBfF&G)eE7*$)!%eB1bdv(M@^^=hwZ!mFkKIA(~M0fuX zl><4ES7fsu#!HsvDIUvK+qdGS87RgVY>I3w30^Zs-~k^c-osNm3I*5^A@<%%WElGf z0?$rZ198GQ*WWW8a|>lyWdsVT&MP{$+7NVX?b0Fgf{u#D-08@44CDOp1`Oj|%O^HJ z)v>wU4fos@jG5|8x}k{$Hl?C_H*7o9k-aL>WsKbzFn58A^X=`7fcWB{6h=wEQis*q zODJO}O&M@TeMc?Cd7+NVe}Y(v_L1YC1{!p+IPW1c(&xaUo2nd5?Y=NtE8pl^Fiw6w zqIJX&_*}L!t)A~N$L>Wm=mG~U))9`tbj<0Xyzp*>cjFCyuQefAFqaAORWQ=1c>O7V zOoWDZ4zKvR5uU^IGVBpfL{l5Ex`fV>KT+g%Xz>nB&~!k*l8G{WQ&7}TPwL)vV~U6+ zLpIW6v?=d8Et1^&!GH(BS>4!#EGg8mAWJVPi!Ig@?jkG_s4ze5M4|77)pP%mD}q?B z?JwL8dEB(C@dQ#uh8|XT2BnrRuU(o8wWI6xMtGud1?=ylq94Pxs<5Sq3#E`ky9k3y zkgv3M&=&556f@&9liVjSp^&!i_1C0j-TWAB{}CMF>{BHm73ky0$~v`=s!6ut6Q~1A zqKcMur{Y|)|5@m{7JB|RTWmG{$4vm%s+!o3w@6?lQ9B8z=4%I9^Fh)Iym&#JXOZg!m$-tjPwW zX-0ZdQtYiEyAK})HP&GZRl){ghA<3r@2>@NaVDVvOO^L3rWLNp6C|1%=3Pj&qBak@)lFJQIds)!an2#vJtVX|AWdf!SM(PSuh|JiTho4;du5Hg_1gnIOMa$NLI^i z_pccFJ;kh|4S|9el=|iglp^J+uI)a_Ikq4-P;*KZBuLN+=6{1SJd<)mbDHKG)wsfi zYIU(0BVWhfr;;g-!J@^Lyq8HON)SLvtx?ax@^SplIiRmSW`8NQeac|b%5Sp`)UO3} zU+2Kol(=VueuW!(Z-jNlOUV_nuq{*%ey4b<_*s!$(s&uw#<@NkcU3-G+#6DalG~>_ zanJmHGcfYs6(odfs#6OILbDqwhSxNB>6CTdqBRt<7ns86oeQRLRuv5XumiN#lF#;Z23;d5`??KkC?}8Ct&EU7+qA`MxBy z8{=4__TBS-r8KlV=;kra@wkJ{6~byS4dh0+CueZv`x{|6 z`9|MGOlQhk%2ibM8SnDr)kC*-} z3_@fdjjvOqFLc~((s4Jy3B~%@PcjUIYu#?$O||YOOouzO+F7WabBX8xYd&KlG|^~s zZ>-Vhp!ACPlnf%C!y&S<+ikQYArIw;l%cti5S*7T=9?sY(; zdzH~5Q{@B*3q%83NT-a%+W$X$H}*nV3S!THOUd^s;i{sTC!2rO<% z(oC?+$H1#;cU~2`ve)#`X5U4oh`0P!%+EKFf3SkBOExU#YuCj1IEQVnc6eb zqPUE<-22!gO?Q8H)#+S@86-z7=>}Du6Ye~RgrZ z-qTYS>K;uu;Q6(9Srq39(h7&d0dVUH>Z7KHz{BX1K?1ngG!+dY8zcZWE4e6SDu3Fm z&eazkn4zd-*$K;>Tq>9GPPE2ax6TLknc6H>1d`!cILA}94apFgG`s_)gMqBwqjk3% z`^UMJ?J7e#!{gNTr^@i8E4F}j)k;_;6x++zYWTlzHU|Fmz&;9{M&ALQsy^&j?QJ?1 zK495fHV*&HcPhrRx2+6p-(i$XyXk$iJ?3Povf(pOZHM#Q<60-~?up5+t2$kMy7F{< jfazZg=Kt&JtZeNn%C#AP<9(uEP+x}*c + + + + + ); + } +} + +Bar.propTypes = { + onPress: PropTypes.func.isRequired, + index: PropTypes.number.isRequired, +}; diff --git a/app/react-native/src/preview/components/OnDeviceUI/navigation/button.js b/app/react-native/src/preview/components/OnDeviceUI/navigation/button.js new file mode 100644 index 000000000000..94e1c343f88c --- /dev/null +++ b/app/react-native/src/preview/components/OnDeviceUI/navigation/button.js @@ -0,0 +1,57 @@ +import React, { PureComponent } from 'react'; +import PropTypes from 'prop-types'; +import { View, StyleSheet, Text, TouchableOpacity } from 'react-native'; + +const style = StyleSheet.create({ + text: { + color: '#999999', + paddingHorizontal: 8, + paddingVertical: 10, + fontSize: 11, + }, + underline: { + height: 3, + backgroundColor: 'transparent', + }, +}); + +export default class Button extends PureComponent { + onPress = () => { + const { onPress, id } = this.props; + onPress(id); + }; + + render() { + const { active, children } = this.props; + + return ( + + + {children.toUpperCase()} + + + + ); + } +} + +Button.propTypes = { + onPress: PropTypes.func.isRequired, + active: PropTypes.bool.isRequired, + id: PropTypes.oneOfType([PropTypes.number, PropTypes.string]).isRequired, + children: PropTypes.node.isRequired, +}; diff --git a/app/react-native/src/preview/components/OnDeviceUI/navigation/consts.js b/app/react-native/src/preview/components/OnDeviceUI/navigation/consts.js new file mode 100644 index 000000000000..7c8f4bcbafeb --- /dev/null +++ b/app/react-native/src/preview/components/OnDeviceUI/navigation/consts.js @@ -0,0 +1,3 @@ +export const NAVIGATOR = -1; +export const PREVIEW = 0; +export const ADDONS = 1; diff --git a/app/react-native/src/preview/components/OnDeviceUI/navigation/index.js b/app/react-native/src/preview/components/OnDeviceUI/navigation/index.js new file mode 100644 index 000000000000..a82d4147c6f3 --- /dev/null +++ b/app/react-native/src/preview/components/OnDeviceUI/navigation/index.js @@ -0,0 +1,71 @@ +import React, { PureComponent } from 'react'; +import { View } from 'react-native'; +import GestureRecognizer from 'react-native-swipe-gestures'; +import PropTypes from 'prop-types'; + +import Bar from './bar'; +import VisibilityButton from './visibility-button'; + +const SWIPE_CONFIG = { + velocityThreshold: 0.2, + directionalOffsetThreshold: 80, +}; + +export default class Navigation extends PureComponent { + constructor(props) { + super(props); + this.state = { + isUIVisible: props.initialUiVisible, + }; + } + + handleToggleUI = () => { + const { isUIVisible } = this.state; + + this.setState({ isUIVisible: !isUIVisible }); + }; + + handleSwipeLeft = () => { + const { tabOpen, onChangeTab } = this.props; + if (tabOpen < 1) { + onChangeTab(tabOpen + 1); + } + }; + + handleSwipeRight = () => { + const { tabOpen, onChangeTab } = this.props; + if (tabOpen > -1) { + onChangeTab(tabOpen - 1); + } + }; + + render() { + const { tabOpen, onChangeTab } = this.props; + const { isUIVisible } = this.state; + + return ( + + {isUIVisible && ( + + + + )} + + + ); + } +} + +Navigation.propTypes = { + initialUiVisible: PropTypes.bool, + tabOpen: PropTypes.number.isRequired, + onChangeTab: PropTypes.func.isRequired, +}; + +Navigation.defaultProps = { + initialUiVisible: true, +}; diff --git a/app/react-native/src/preview/components/OnDeviceUI/navigation/visibility-button.js b/app/react-native/src/preview/components/OnDeviceUI/navigation/visibility-button.js new file mode 100644 index 000000000000..1719b18e71df --- /dev/null +++ b/app/react-native/src/preview/components/OnDeviceUI/navigation/visibility-button.js @@ -0,0 +1,25 @@ +import React, { PureComponent } from 'react'; +import { Text, TouchableOpacity } from 'react-native'; +import PropTypes from 'prop-types'; +import style from '../style'; + +export default class VisibilityButton extends PureComponent { + render() { + const { onPress } = this.props; + return ( + + + + ); + } +} + +VisibilityButton.propTypes = { + onPress: PropTypes.func.isRequired, +}; diff --git a/app/react-native/src/preview/components/OnDeviceUI/panel.js b/app/react-native/src/preview/components/OnDeviceUI/panel.js new file mode 100644 index 000000000000..3723076c8ef6 --- /dev/null +++ b/app/react-native/src/preview/components/OnDeviceUI/panel.js @@ -0,0 +1,25 @@ +import React, { PureComponent } from 'react'; +import { StyleSheet, Animated } from 'react-native'; +import PropTypes from 'prop-types'; + +const style = StyleSheet.create({ + panel: { + ...StyleSheet.absoluteFillObject, + borderWidth: 1, + borderTopWidth: 1, + borderBottomWidth: 0, + borderColor: '#e6e6e6', + }, +}); + +export default class Panel extends PureComponent { + render() { + const { children, style: propsStyle } = this.props; + return {children}; + } +} + +Panel.propTypes = { + style: PropTypes.arrayOf(PropTypes.object).isRequired, + children: PropTypes.node.isRequired, +}; diff --git a/app/react-native/src/preview/components/OnDeviceUI/style.js b/app/react-native/src/preview/components/OnDeviceUI/style.js index ad4db0ba484e..f130d7f9a935 100644 --- a/app/react-native/src/preview/components/OnDeviceUI/style.js +++ b/app/react-native/src/preview/components/OnDeviceUI/style.js @@ -1,42 +1,46 @@ -import { StyleSheet } from 'react-native-compat'; - export default { main: { flex: 1, - flexDirection: 'row', - backgroundColor: 'white', - }, - icon: { - width: 30, - height: 30, - }, - headerContainer: { - flexDirection: 'row', - alignItems: 'center', - margin: 10, }, headerText: { marginLeft: 10, fontSize: 20, color: 'black', }, - menuContainer: { - ...StyleSheet.absoluteFillObject, - right: null, - paddingHorizontal: 10, - paddingBottom: 10, - backgroundColor: 'white', + text: { + fontSize: 18, }, - previewContainer: { - flex: 1, + center: { + alignItems: 'center', + justifyContent: 'center', }, - previewWrapper: { - flex: 1, + hideButtonText: { + fontSize: 14, + color: '#999999', + }, + hideButton: { + backgroundColor: 'transparent', + position: 'absolute', + right: 8, + bottom: 12, }, - closeButton: { - marginVertical: 10, + previewMinimized: { + borderWidth: 1, + borderColor: '#b3b3b3', }, - preview: { - ...StyleSheet.absoluteFillObject, + tab: { + marginRight: 15, + }, + addonList: { + flexDirection: 'row', + }, + invisible: { + height: 0, + width: 0, + opacity: 0, + position: 'absolute', + }, + flex: { + flex: 1, }, }; diff --git a/app/react-native/src/preview/components/StoryListView/index.js b/app/react-native/src/preview/components/StoryListView/index.js index bbf4acb1ebe9..a53522b648ec 100644 --- a/app/react-native/src/preview/components/StoryListView/index.js +++ b/app/react-native/src/preview/components/StoryListView/index.js @@ -43,25 +43,16 @@ export default class StoryListView extends Component { }; this.storyAddedHandler = this.handleStoryAdded.bind(this); - this.changeStoryHandler = this.changeStory.bind(this); props.stories.on(Events.STORY_ADDED, this.storyAddedHandler); } componentDidMount() { - const { stories } = this.props; - this.handleStoryAdded(); - const dump = stories.dumpStoryBook(); - const nonEmptyKind = dump.find(kind => kind.stories.length > 0); - if (nonEmptyKind) { - this.changeStory(nonEmptyKind.kind, nonEmptyKind.stories[0]); - } } componentWillUnmount() { const { stories } = this.props; - stories.removeListener(Events.STORY_ADDED, this.storyAddedHandler); } @@ -93,13 +84,13 @@ export default class StoryListView extends Component { } render() { - const { width, selectedKind, selectedStory } = this.props; + const { selectedKind, selectedStory } = this.props; const { data } = this.state; return ( ( { - this.setState({ storyFn, selection }); + selectStory = selection => { + this.setState({ storyFn: selection.storyFn, selection }); }; renderHelp = () => { @@ -44,8 +47,29 @@ export default class StoryView extends Component { ); }; + renderOnDeviceUIHelp = () => ( + + Please open navigator and select a story to preview. + + ); + render() { - const { storyFn, selection } = this.state; + const { listenToEvents } = this.props; + + if (listenToEvents) { + const { storyFn, selection } = this.state; + const { kind, story } = selection; + + return storyFn ? ( + + {storyFn()} + + ) : ( + this.renderHelp() + ); + } + + const { storyFn, selection } = this.props; const { kind, story } = selection; return storyFn ? ( @@ -53,12 +77,18 @@ export default class StoryView extends Component { {storyFn()} ) : ( - this.renderHelp() + this.renderOnDeviceUIHelp() ); } } StoryView.propTypes = { + listenToEvents: PropTypes.bool, + storyFn: PropTypes.func, + selection: PropTypes.shape({ + kind: PropTypes.string, + story: PropTypes.string, + }), events: PropTypes.shape({ on: PropTypes.func.isRequired, removeListener: PropTypes.func.isRequired, @@ -68,4 +98,7 @@ StoryView.propTypes = { StoryView.defaultProps = { url: '', + listenToEvents: false, + selection: {}, + storyFn: null, }; diff --git a/app/react-native/src/preview/index.js b/app/react-native/src/preview/index.js index 5ca36c4e7f01..47d060502a12 100644 --- a/app/react-native/src/preview/index.js +++ b/app/react-native/src/preview/index.js @@ -6,8 +6,8 @@ import parse from 'url-parse'; import addons from '@storybook/addons'; import Events from '@storybook/core-events'; +import Channel from '@storybook/channels'; import createChannel from '@storybook/channel-websocket'; -import { EventEmitter } from 'events'; import { StoryStore, ClientApi } from '@storybook/core/client'; import OnDeviceUI from './components/OnDeviceUI'; import StoryView from './components/StoryView'; @@ -40,50 +40,85 @@ export default class Preview { } getStorybookUI(params = {}) { - return () => { - let webUrl = null; - let channel = null; - - try { - channel = addons.getChannel(); - } catch (e) { - // getChannel throws if the channel is not defined, - // which is fine in this case (we will define it below) - } + let webUrl = null; + let channel = null; - if (!channel || params.resetStorybook) { - if (params.onDeviceUI && params.disableWebsockets) { - channel = new EventEmitter(); - } else { - const host = params.host || parse(NativeModules.SourceCode.scriptURL).hostname; - const port = params.port !== false ? `:${params.port || 7007}` : ''; - - const query = params.query || ''; - const { secured } = params; - const websocketType = secured ? 'wss' : 'ws'; - const httpType = secured ? 'https' : 'http'; - - const url = `${websocketType}://${host}${port}/${query}`; - webUrl = `${httpType}://${host}${port}`; - channel = createChannel({ url }); - } + const onDeviceUI = params.onDeviceUI !== false; + + // should the initial story be sent to storybookUI + // set to true if using disableWebsockets or if connection to WebsocketServer fails. + let setInitialStory = false; - addons.setChannel(channel); + try { + channel = addons.getChannel(); + } catch (e) { + // getChannel throws if the channel is not defined, + // which is fine in this case (we will define it below) + } - channel.emit(Events.CHANNEL_CREATED); + if (!channel || params.resetStorybook) { + if (onDeviceUI && params.disableWebsockets) { + channel = new Channel({ async: true }); + } else { + const host = + params.host || parse(NativeModules.SourceCode.scriptURL).hostname || 'localhost'; + const port = params.port !== false ? `:${params.port || 7007}` : ''; + + const query = params.query || ''; + const { secured } = params; + const websocketType = secured ? 'wss' : 'ws'; + const httpType = secured ? 'https' : 'http'; + + const url = `${websocketType}://${host}${port}/${query}`; + webUrl = `${httpType}://${host}${port}`; + channel = createChannel({ + url, + async: onDeviceUI, + onError: () => { + this._setInitialStory(); + + setInitialStory = true; + }, + }); } - channel.on(Events.GET_STORIES, () => this._sendSetStories()); - channel.on(Events.SET_CURRENT_STORY, d => this._selectStory(d)); - this._sendSetStories(); + addons.setChannel(channel); + + channel.emit(Events.CHANNEL_CREATED); + } + + channel.on(Events.GET_STORIES, () => this._sendSetStories()); + channel.on(Events.SET_CURRENT_STORY, d => this._selectStory(d)); + this._sendSetStories(); + + // If the app is started with server running, set the story as the one selected in the browser + if (webUrl) { this._sendGetCurrentStory(); + } else { + setInitialStory = true; + } + + const preview = this; + + // react-native hot module loader must take in a Class - https://github.com/facebook/react-native/issues/10991 + // eslint-disable-next-line react/prefer-stateless-function + return class StorybookRoot extends React.PureComponent { + render() { + if (onDeviceUI) { + return ( + + ); + } - // finally return the preview component - return params.onDeviceUI ? ( - - ) : ( - - ); + return ; + } }; } @@ -98,10 +133,31 @@ export default class Preview { channel.emit(Events.GET_CURRENT_STORY); } - _selectStory(selection) { + _setInitialStory = () => { + const story = this._getInitialStory(); + if (story) { + this._selectStory(story); + } + }; + + _getInitialStory = () => { + const dump = this._stories.dumpStoryBook(); + const nonEmptyKind = dump.find(kind => kind.stories.length > 0); + if (nonEmptyKind) { + return this._getStory({ kind: nonEmptyKind.kind, story: nonEmptyKind.stories[0] }); + } + + return null; + }; + + _getStory(selection) { const { kind, story } = selection; const storyFn = this._stories.getStoryWithContext(kind, story); + return { ...selection, storyFn }; + } + + _selectStory(selection) { const channel = addons.getChannel(); - channel.emit(Events.SELECT_STORY, selection, storyFn); + channel.emit(Events.SELECT_STORY, this._getStory(selection)); } } diff --git a/examples/crna-kitchen-sink/app.json b/examples/crna-kitchen-sink/app.json index 7acefb4d1c1e..b99f83a818b5 100644 --- a/examples/crna-kitchen-sink/app.json +++ b/examples/crna-kitchen-sink/app.json @@ -1,5 +1,10 @@ { "expo": { - "sdkVersion": "24.0.0" + "sdkVersion": "24.0.0", + "androidStatusBarColor": "#C2185B", + "androidStatusBar": { + "barStyle": "light-content", + "backgroundColor": "#C2185B" + } } } diff --git a/examples/crna-kitchen-sink/package.json b/examples/crna-kitchen-sink/package.json index c527b840b4a1..d125353611d1 100644 --- a/examples/crna-kitchen-sink/package.json +++ b/examples/crna-kitchen-sink/package.json @@ -8,7 +8,7 @@ "eject": "react-native-scripts eject", "ios": "react-native-scripts ios", "start": "react-native-scripts start", - "storybook": "storybook start -p 7007", + "storybook": "storybook start", "test": "node node_modules/jest/bin/jest.js --watch" }, "jest": { @@ -35,6 +35,10 @@ "@storybook/core-events": "file:../../packs/storybook-core-events.tgz", "@storybook/node-logger": "file:../../packs/storybook-node-logger.tgz", "@storybook/react-native": "file:../../packs/storybook-react-native.tgz", + "@storybook/addon-notes": "file:../../packs/storybook-addon-notes.tgz", + "@storybook/addon-ondevice-backgrounds": "file:../../packs/storybook-addon-ondevice-backgrounds.tgz", + "@storybook/addon-ondevice-knobs": "file:../../packs/storybook-addon-ondevice-knobs.tgz", + "@storybook/addon-ondevice-notes": "file:../../packs/storybook-addon-ondevice-notes.tgz", "@storybook/ui": "file:../../packs/storybook-ui.tgz", "jest-expo": "^24.0.0", "react-dom": "^16.2.0", diff --git a/examples/crna-kitchen-sink/storybook/addons.js b/examples/crna-kitchen-sink/storybook/addons.js index 02d363f13be9..8741fd55b89f 100644 --- a/examples/crna-kitchen-sink/storybook/addons.js +++ b/examples/crna-kitchen-sink/storybook/addons.js @@ -2,3 +2,4 @@ import '@storybook/addon-actions/register'; import '@storybook/addon-links/register'; import '@storybook/addon-options/register'; import '@storybook/addon-knobs/register'; +import '@storybook/addon-notes/register'; diff --git a/examples/crna-kitchen-sink/storybook/index.js b/examples/crna-kitchen-sink/storybook/index.js index 812f1aad29c7..4bf516784a56 100644 --- a/examples/crna-kitchen-sink/storybook/index.js +++ b/examples/crna-kitchen-sink/storybook/index.js @@ -1,32 +1,10 @@ -import { AppRegistry } from 'react-native'; -import React, { Component } from 'react'; import { getStorybookUI, configure } from '@storybook/react-native'; -import { setOptions } from '@storybook/addon-options'; +import './rn-addons'; -// import stories configure(() => { // eslint-disable-next-line global-require require('./stories'); }, module); -const StorybookUIRoot = getStorybookUI({ port: 7007, onDeviceUI: true }); - -setTimeout( - () => - setOptions({ - name: 'CRNA React Native App', - onDeviceUI: true, - }), - 100 -); - -// react-native hot module loader must take in a Class - https://github.com/facebook/react-native/issues/10991 -// eslint-disable-next-line react/prefer-stateless-function -class StorybookUIHMRRoot extends Component { - render() { - return ; - } -} - -AppRegistry.registerComponent('crna-kitchen-sink', () => StorybookUIHMRRoot); -export default StorybookUIHMRRoot; +const StorybookUIRoot = getStorybookUI(); +export default StorybookUIRoot; diff --git a/examples/crna-kitchen-sink/storybook/rn-addons.js b/examples/crna-kitchen-sink/storybook/rn-addons.js new file mode 100644 index 000000000000..9617c17a5d89 --- /dev/null +++ b/examples/crna-kitchen-sink/storybook/rn-addons.js @@ -0,0 +1,3 @@ +require('@storybook/addon-ondevice-knobs/register'); +require('@storybook/addon-ondevice-notes/register'); +require('@storybook/addon-ondevice-backgrounds/register'); diff --git a/examples/crna-kitchen-sink/storybook/stories/CenterView/style.js b/examples/crna-kitchen-sink/storybook/stories/CenterView/style.js index ff347fd9841f..bc7b2bbafc86 100644 --- a/examples/crna-kitchen-sink/storybook/stories/CenterView/style.js +++ b/examples/crna-kitchen-sink/storybook/stories/CenterView/style.js @@ -3,6 +3,5 @@ export default { flex: 1, justifyContent: 'center', alignItems: 'center', - backgroundColor: '#F5FCFF', }, }; diff --git a/examples/crna-kitchen-sink/storybook/stories/Knobs/index.js b/examples/crna-kitchen-sink/storybook/stories/Knobs/index.js index 6c65a02cc46d..e3d008f33a20 100644 --- a/examples/crna-kitchen-sink/storybook/stories/Knobs/index.js +++ b/examples/crna-kitchen-sink/storybook/stories/Knobs/index.js @@ -11,7 +11,7 @@ import { array, date, object, -} from '@storybook/addon-knobs/react'; +} from '@storybook/addon-knobs'; export default () => { const name = text('Name', 'Storyteller'); diff --git a/examples/crna-kitchen-sink/storybook/stories/index.js b/examples/crna-kitchen-sink/storybook/stories/index.js index a9720029b43c..f23789318965 100644 --- a/examples/crna-kitchen-sink/storybook/stories/index.js +++ b/examples/crna-kitchen-sink/storybook/stories/index.js @@ -1,19 +1,44 @@ import React from 'react'; import { Text } from 'react-native'; -import { storiesOf, addParameters } from '@storybook/react-native'; +import { storiesOf, addDecorator, addParameters } from '@storybook/react-native'; import { action } from '@storybook/addon-actions'; import { linkTo } from '@storybook/addon-links'; import { withKnobs } from '@storybook/addon-knobs'; - +import { withNotes } from '@storybook/addon-ondevice-notes'; +import { withBackgrounds } from '@storybook/addon-ondevice-backgrounds'; import knobsWrapper from './Knobs'; import Button from './Button'; import CenterView from './CenterView'; import Welcome from './Welcome'; -storiesOf('Welcome', module).add('to Storybook', () => ); +addDecorator(withNotes); +addDecorator( + withBackgrounds([ + { name: 'twitter', value: '#6cff5d', default: true }, + { name: 'facebook', value: '#3b5998' }, + ]) +); + +storiesOf('Welcome', module).add('to Storybook', () => , { + notes: ` +# Markdown!\n +* List Item +* [List Item with Link](https://twitter.com/Charles_Mangwa) +`, +}); storiesOf('Button', module) + .addParameters({ + backgrounds: [ + { name: 'red', value: '#F44336' }, + { name: 'blue', value: '#2196F3', default: true }, + ], + notes: ` +# Custom note\n +_This component doesn't look right_ +`, + }) .addDecorator(getStory => {getStory()}) .add('with text', () => (