├── .babelrc ├── .eslintrc ├── .gitignore ├── .npmignore ├── .prettierignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── example └── src │ ├── index.html │ ├── index.js │ ├── material_title_panel.js │ ├── responsive_example.html │ ├── responsive_example.js │ └── sidebar_content.js ├── package-lock.json ├── package.json ├── rollup.config.js ├── src └── sidebar.js └── webpack.config.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | ["@babel/env", { "loose": true, "modules": false }], 4 | "@babel/react" 5 | ], 6 | "plugins": [ 7 | "@babel/plugin-transform-runtime", 8 | "@babel/plugin-proposal-object-rest-spread", 9 | ["transform-react-remove-prop-types", { "mode": "unsafe-wrap" }] 10 | ] 11 | } 12 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es6": true 5 | }, 6 | "extends": ["airbnb", "prettier", "prettier/react"], 7 | "parser": "babel-eslint", 8 | "parserOptions": { 9 | "ecmaFeatures": { 10 | "jsx": true 11 | }, 12 | "sourceType": "module" 13 | }, 14 | "rules": { 15 | "react/destructuring-assignment": 0, 16 | "react/jsx-filename-extension": 0, 17 | "jsx-a11y/anchor-is-valid": 0, 18 | "no-plusplus": 0, 19 | "react/require-default-props": 0, 20 | "react/forbid-prop-types": 0, 21 | "react/no-access-state-in-setstate": 0 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | _dev/ 3 | node_modules/ 4 | example/compiled 5 | .DS_Store 6 | npm-debug.log 7 | dist 8 | .vscode -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | example 3 | node_modules 4 | src 5 | .babelrc 6 | .eslintrc 7 | .gitignore 8 | .nvmrc 9 | .travis.yml 10 | CHANGELOG.md 11 | package-lock.json 12 | rollup.config.js 13 | webpack.config.js -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | dist 2 | example/dist 3 | package-lock.json -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change log 2 | 3 | ## 3.0.0 4 | 5 | - prop-types is now dependency instead of peer dependency (@markusenglund) 6 | - PropTypes are removed in production to save on size (@markusenglund) 7 | - Now using babel in loose mode & babel-runtime to reduce bundle size further (@markusenglund) 8 | - Made the library available as ES Module in addition to CommonJS (@markusenglund) 9 | - Remove tab-index from overlay (@markusenglund) 10 | - Remove scroll bar when not needed by changing default content overflowY from "scroll" to "auto" 11 | - Added new id props to let users give custom id values to all elements (@rluiten) 12 | - Remove touch functionality in IOS since it doesn't work due to swipe-to-go-back native behaviour. 13 | - Remove box-shadow when the sidebar is not visible, so it's not visible at the edge of the screen. 14 | 15 | ## 2.3.2 16 | 17 | - prop-types is now a peer dependency (@Fallenstedt) 18 | 19 | ## 2.3.1 20 | 21 | - Modify content styles to have momentum scrolling (@Fallenstedt) 22 | - Update examples to eliminate depreciation warnings(@Fallenstedt) 23 | - Update readme's examples(@Fallenstedt) 24 | 25 | ## 2.3 26 | 27 | - Replace findDOMNode by ref callback (@BDav24) 28 | - Allow setting initial sidebar width (@BDav24) 29 | 30 | ## 2.2 31 | 32 | - Move from onTouchTap to onClick for React 15.2 compatibility (@factorize) 33 | - Fix accessibility issues (@cristian-sima) 34 | 35 | ## 2.1.4 36 | 37 | - Update included ES5 build with 2.1.3 changes 38 | 39 | ## 2.1.3 40 | 41 | - Added optional classNames (@sugarshin) 42 | 43 | ## 2.1.2 44 | 45 | - Fix server side rendering (@elliottsj) 46 | 47 | ## 2.1 48 | 49 | - Allow overriding embedded styles (@kulesa) 50 | 51 | ## 2.0.1 52 | 53 | - Allow adding className to sidebar using sidebarClassName prop (@lostdalek) 54 | 55 | ## 2.0.0 56 | 57 | - React 0.14 release 58 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Paulus Schoutsen 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # React Sidebar [![npm version](https://badge.fury.io/js/react-sidebar.svg)](http://badge.fury.io/js/react-sidebar) [![Build Status](https://travis-ci.org/balloob/react-sidebar.svg)](https://travis-ci.org/balloob/react-sidebar) 2 | 3 | React Sidebar is a sidebar component for React 0.14+. It offers the following features: 4 | 5 | - The sidebar can slide over the main content or dock next to it. 6 | - Touch enabled: swipe to open and close the sidebar like on a native mobile app. 7 | - Easy to combine with media queries to show the sidebar only when there's enough screen-width ([see example](http://balloob.github.io/react-sidebar/example/responsive_example.html)). 8 | - Works on both the left and right side. 9 | - Tiny size: <2.5kB gzipped 10 | - MIT license 11 | 12 | [See a demo here.](http://balloob.github.io/react-sidebar/example/) 13 | 14 | ## Touch specifics 15 | 16 | The touch interaction of the React Sidebar mimics the interactions that are supported by Android apps that implement the material design spec: 17 | 18 | - When the sidebar is closed, dragging from the left side of the screen will have the right side of the sidebar follow your finger. 19 | - When the sidebar is open, sliding your finger over the screen will only affect the sidebar once your finger is over the sidebar. 20 | - On release, it will call `onSetOpen` prop if the distance the sidebar was dragged is more than the `dragToggleDistance` prop. 21 | 22 | Note: The sidebar touch functionality doesn't work on IOS because of the "swipe-to-go-back" feature. Therefore the functionality has been disabled on IOS. 23 | 24 | ## Installation 25 | 26 | `npm install react-sidebar` 27 | 28 | If you use TypeScript, typings are [available on DefinitelyTyped](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/master/types/react-sidebar) and can be installed with: 29 | 30 | `npm install @types/react-sidebar` 31 | 32 | ## Getting started 33 | 34 | By design, React Sidebar does not keep track of whether it is open or not. This has to be done by the parent component. This allows the parent component to make changes to the sidebar and main content based on the open/docked state. An example could be to hide the "show menu" button from the main content when the sidebar is docked. 35 | 36 | Because React Sidebar can be toggled by dragging the sidebar into its open/closed position, you will have to pass in an `onSetOpen` method as a prop to allow the sidebar to control the open state in the parent. 37 | 38 | The minimum React component to integrate React Sidebar looks like this: 39 | 40 | ```jsx 41 | import React from "react"; 42 | import Sidebar from "react-sidebar"; 43 | 44 | class App extends React.Component { 45 | constructor(props) { 46 | super(props); 47 | this.state = { 48 | sidebarOpen: true 49 | }; 50 | this.onSetSidebarOpen = this.onSetSidebarOpen.bind(this); 51 | } 52 | 53 | onSetSidebarOpen(open) { 54 | this.setState({ sidebarOpen: open }); 55 | } 56 | 57 | render() { 58 | return ( 59 | Sidebar content} 61 | open={this.state.sidebarOpen} 62 | onSetOpen={this.onSetSidebarOpen} 63 | styles={{ sidebar: { background: "white" } }} 64 | > 65 | 68 | 69 | ); 70 | } 71 | } 72 | 73 | export default App; 74 | ``` 75 | 76 | ## Responsive sidebar 77 | 78 | A common use case for a sidebar is to show it automatically when there is enough screen width available. This can be achieved using media queries via [`window.matchMedia`][mdn-matchmedia]. This again has to be integrated into the parent component so you can use the information to make changes to the sidebar and main content. 79 | 80 | [mdn-matchmedia]: https://developer.mozilla.org/en-US/docs/Web/API/Window/matchMedia 81 | 82 | ```jsx 83 | import React from "react"; 84 | import Sidebar from "react-sidebar"; 85 | 86 | const mql = window.matchMedia(`(min-width: 800px)`); 87 | 88 | class App extends React.Component { 89 | constructor(props) { 90 | super(props); 91 | this.state = { 92 | sidebarDocked: mql.matches, 93 | sidebarOpen: false 94 | }; 95 | 96 | this.mediaQueryChanged = this.mediaQueryChanged.bind(this); 97 | this.onSetSidebarOpen = this.onSetSidebarOpen.bind(this); 98 | } 99 | 100 | componentWillMount() { 101 | mql.addListener(this.mediaQueryChanged); 102 | } 103 | 104 | componentWillUnmount() { 105 | mql.removeListener(this.mediaQueryChanged); 106 | } 107 | 108 | onSetSidebarOpen(open) { 109 | this.setState({ sidebarOpen: open }); 110 | } 111 | 112 | mediaQueryChanged() { 113 | this.setState({ sidebarDocked: mql.matches, sidebarOpen: false }); 114 | } 115 | 116 | render() { 117 | return ( 118 | Sidebar content} 120 | open={this.state.sidebarOpen} 121 | docked={this.state.sidebarDocked} 122 | onSetOpen={this.onSetSidebarOpen} 123 | > 124 | Main content 125 | 126 | ); 127 | } 128 | } 129 | 130 | export default App; 131 | ``` 132 | 133 | ## Supported props 134 | 135 | | Property name | Type | Default | Description | 136 | | ------------------ | ------------------------- | -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | 137 | | children | Anything React can render | n/a | The main content | 138 | | rootClassName | string | n/a | Add a custom class to the root component | 139 | | sidebarClassName | string | n/a | Add a custom class to the sidebar | 140 | | contentClassName | string | n/a | Add a custom class to the content | 141 | | overlayClassName | string | n/a | Add a custom class to the overlay | 142 | | defaultSidebarWidth | number | 0 | Width in pixles of the sidebar on render. Use this to stop the sidebar from poping in after intial render. (Overrides transitions) | 143 | | sidebar | Anything React can render | n/a | The sidebar content | 144 | | onSetOpen | function | n/a | Callback called when the sidebar wants to change the open prop. Happens after sliding the sidebar and when the overlay is clicked when the sidebar is open. | 145 | | docked | boolean | false | If the sidebar should be always visible | 146 | | open | boolean | false | If the sidebar should be open | 147 | | transitions | boolean | true | If transitions should be enabled | 148 | | touch | boolean | true | If touch gestures should be enabled | 149 | | touchHandleWidth | number | 20 | Width in pixels you can start dragging from the edge when the sidebar is closed. | 150 | | dragToggleDistance | number | 30 | Distance the sidebar has to be dragged before it will open/close after it is released. | 151 | | pullRight | boolean | false | Place the sidebar on the right | 152 | | shadow | boolean | true | Enable/Disable sidebar shadow | 153 | | styles | object | [See below](#styles) | Inline styles. These styles are merged with the defaults and applied to the respective elements. | 154 | | rootId | string | n/a | Add an id to the root component | 155 | | sidebarId | string | n/a | Add an id to the sidebar | 156 | | contentId | string | n/a | Add an id to the content. The driving use case for adding an element id to content was to allow react-scroll to scroll the content area of the site using react-sidebar. | 157 | | overlayId | string | n/a | Add an an id to the overlay | 158 | 159 | ## Styles 160 | 161 | Styles are passed as an object with 5 keys, `root`, `sidebar`, `content`, `overlay` and `dragHandle`, and merged to the following defaults: 162 | 163 | ```javascript 164 | { 165 | root: { 166 | position: "absolute", 167 | top: 0, 168 | left: 0, 169 | right: 0, 170 | bottom: 0, 171 | overflow: "hidden" 172 | }, 173 | sidebar: { 174 | zIndex: 2, 175 | position: "absolute", 176 | top: 0, 177 | bottom: 0, 178 | transition: "transform .3s ease-out", 179 | WebkitTransition: "-webkit-transform .3s ease-out", 180 | willChange: "transform", 181 | overflowY: "auto" 182 | }, 183 | content: { 184 | position: "absolute", 185 | top: 0, 186 | left: 0, 187 | right: 0, 188 | bottom: 0, 189 | overflowY: "auto", 190 | WebkitOverflowScrolling: "touch", 191 | transition: "left .3s ease-out, right .3s ease-out" 192 | }, 193 | overlay: { 194 | zIndex: 1, 195 | position: "fixed", 196 | top: 0, 197 | left: 0, 198 | right: 0, 199 | bottom: 0, 200 | opacity: 0, 201 | visibility: "hidden", 202 | transition: "opacity .3s ease-out, visibility .3s ease-out", 203 | backgroundColor: "rgba(0,0,0,.3)" 204 | }, 205 | dragHandle: { 206 | zIndex: 1, 207 | position: "fixed", 208 | top: 0, 209 | bottom: 0 210 | } 211 | }; 212 | ``` 213 | 214 | ## Acknowledgements 215 | 216 | My goal was to make a React Component that implements the [material design spec for navigation drawers](https://material.io/design/components/navigation-drawer.html). My initial attempt was to improve [hamburger-basement by arnemart](https://github.com/arnemart/hamburger-basement) but I quickly figured that I better start from scratch. Still, that project helped me a ton to get started. 217 | -------------------------------------------------------------------------------- /example/src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | React Sidebar 6 | 7 | 9 | 14 | 15 | 16 | 17 |
18 | 19 | 20 | -------------------------------------------------------------------------------- /example/src/index.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom"; 3 | 4 | import Sidebar from "../.."; 5 | import MaterialTitlePanel from "./material_title_panel"; 6 | import SidebarContent from "./sidebar_content"; 7 | 8 | const styles = { 9 | contentHeaderMenuLink: { 10 | textDecoration: "none", 11 | color: "white", 12 | padding: 8 13 | }, 14 | content: { 15 | padding: "16px" 16 | } 17 | }; 18 | 19 | class App extends React.Component { 20 | constructor(props) { 21 | super(props); 22 | 23 | this.state = { 24 | docked: false, 25 | open: false, 26 | transitions: true, 27 | touch: true, 28 | shadow: true, 29 | pullRight: false, 30 | touchHandleWidth: 20, 31 | dragToggleDistance: 30 32 | }; 33 | 34 | this.renderPropCheckbox = this.renderPropCheckbox.bind(this); 35 | this.renderPropNumber = this.renderPropNumber.bind(this); 36 | this.onSetOpen = this.onSetOpen.bind(this); 37 | this.menuButtonClick = this.menuButtonClick.bind(this); 38 | } 39 | 40 | onSetOpen(open) { 41 | this.setState({ open }); 42 | } 43 | 44 | menuButtonClick(ev) { 45 | ev.preventDefault(); 46 | this.onSetOpen(!this.state.open); 47 | } 48 | 49 | renderPropCheckbox(prop) { 50 | const toggleMethod = ev => { 51 | const newState = {}; 52 | newState[prop] = ev.target.checked; 53 | this.setState(newState); 54 | }; 55 | 56 | return ( 57 |

58 | 67 |

68 | ); 69 | } 70 | 71 | renderPropNumber(prop) { 72 | const setMethod = ev => { 73 | const newState = {}; 74 | newState[prop] = parseInt(ev.target.value, 10); 75 | this.setState(newState); 76 | }; 77 | 78 | return ( 79 |

80 | {prop}{" "} 81 | 82 |

83 | ); 84 | } 85 | 86 | render() { 87 | const sidebar = ; 88 | 89 | const contentHeader = ( 90 | 91 | {!this.state.docked && ( 92 | 97 | = 98 | 99 | )} 100 | React Sidebar 101 | 102 | ); 103 | 104 | const sidebarProps = { 105 | sidebar, 106 | docked: this.state.docked, 107 | sidebarClassName: "custom-sidebar-class", 108 | contentId: "custom-sidebar-content-id", 109 | open: this.state.open, 110 | touch: this.state.touch, 111 | shadow: this.state.shadow, 112 | pullRight: this.state.pullRight, 113 | touchHandleWidth: this.state.touchHandleWidth, 114 | dragToggleDistance: this.state.dragToggleDistance, 115 | transitions: this.state.transitions, 116 | onSetOpen: this.onSetOpen 117 | }; 118 | 119 | return ( 120 | 121 | 122 |
123 |

124 | React Sidebar is a sidebar component for React. It offers the 125 | following features: 126 |

127 |
    128 |
  • Have the sidebar slide over main content
  • 129 |
  • Dock the sidebar next to the content
  • 130 |
  • Touch enabled: swipe to open and close the sidebar
  • 131 |
  • 132 | Easy to combine with media queries for auto-docking ( 133 | see example) 134 |
  • 135 |
  • 136 | Sidebar and content passed in as PORCs (Plain Old React 137 | Components) 138 |
  • 139 |
  • 140 | 141 | Source on GitHub 142 | {" "} 143 | (MIT license) 144 |
  • 145 |
  • Only dependency is React
  • 146 |
147 |

148 | 149 | Instructions how to get started. 150 | 151 |

152 |

153 | Current rendered sidebar properties: 154 |

155 | {[ 156 | "open", 157 | "docked", 158 | "transitions", 159 | "touch", 160 | "shadow", 161 | "pullRight" 162 | ].map(this.renderPropCheckbox)} 163 | {["touchHandleWidth", "dragToggleDistance"].map( 164 | this.renderPropNumber 165 | )} 166 |
167 |
168 |
169 | ); 170 | } 171 | } 172 | 173 | ReactDOM.render(, document.getElementById("example")); 174 | -------------------------------------------------------------------------------- /example/src/material_title_panel.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import PropTypes from "prop-types"; 3 | 4 | const styles = { 5 | root: { 6 | fontFamily: 7 | '"HelveticaNeue-Light", "Helvetica Neue Light", "Helvetica Neue", Helvetica, Arial, "Lucida Grande", sans-serif', 8 | fontWeight: 300 9 | }, 10 | header: { 11 | backgroundColor: "#03a9f4", 12 | color: "white", 13 | padding: "16px", 14 | fontSize: "1.5em" 15 | } 16 | }; 17 | 18 | const MaterialTitlePanel = props => { 19 | const rootStyle = props.style 20 | ? { ...styles.root, ...props.style } 21 | : styles.root; 22 | 23 | return ( 24 |
25 |
{props.title}
26 | {props.children} 27 |
28 | ); 29 | }; 30 | 31 | MaterialTitlePanel.propTypes = { 32 | style: PropTypes.object, 33 | title: PropTypes.oneOfType([PropTypes.string, PropTypes.object]), 34 | children: PropTypes.object 35 | }; 36 | 37 | export default MaterialTitlePanel; 38 | -------------------------------------------------------------------------------- /example/src/responsive_example.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Responsive Example - React Sidebar 6 | 7 | 9 | 10 | 11 | 12 |
13 | 14 | 15 | -------------------------------------------------------------------------------- /example/src/responsive_example.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom"; 3 | import Sidebar from "../.."; 4 | import MaterialTitlePanel from "./material_title_panel"; 5 | import SidebarContent from "./sidebar_content"; 6 | 7 | const styles = { 8 | contentHeaderMenuLink: { 9 | textDecoration: "none", 10 | color: "white", 11 | padding: 8 12 | }, 13 | content: { 14 | padding: "16px" 15 | } 16 | }; 17 | 18 | const mql = window.matchMedia(`(min-width: 800px)`); 19 | 20 | class App extends React.Component { 21 | constructor(props) { 22 | super(props); 23 | 24 | this.state = { 25 | docked: mql.matches, 26 | open: false 27 | }; 28 | 29 | this.mediaQueryChanged = this.mediaQueryChanged.bind(this); 30 | this.toggleOpen = this.toggleOpen.bind(this); 31 | this.onSetOpen = this.onSetOpen.bind(this); 32 | } 33 | 34 | componentWillMount() { 35 | mql.addListener(this.mediaQueryChanged); 36 | } 37 | 38 | componentWillUnmount() { 39 | mql.removeListener(this.mediaQueryChanged); 40 | } 41 | 42 | onSetOpen(open) { 43 | this.setState({ open }); 44 | } 45 | 46 | mediaQueryChanged() { 47 | this.setState({ 48 | docked: mql.matches, 49 | open: false 50 | }); 51 | } 52 | 53 | toggleOpen(ev) { 54 | this.setState({ open: !this.state.open }); 55 | 56 | if (ev) { 57 | ev.preventDefault(); 58 | } 59 | } 60 | 61 | render() { 62 | const sidebar = ; 63 | 64 | const contentHeader = ( 65 | 66 | {!this.state.docked && ( 67 | 72 | = 73 | 74 | )} 75 | Responsive React Sidebar 76 | 77 | ); 78 | 79 | const sidebarProps = { 80 | sidebar, 81 | docked: this.state.docked, 82 | open: this.state.open, 83 | onSetOpen: this.onSetOpen 84 | }; 85 | 86 | return ( 87 | 88 | 89 |
90 |

91 | This example will automatically dock the sidebar if the page width 92 | is above 800px (which is currently {this.state.docked.toString()} 93 | ). 94 |

95 |

96 | This functionality should live in the component that renders the 97 | sidebar. This way you're able to modify the sidebar and main 98 | content based on the responsiveness data. For example, the menu 99 | button in the header of the content is now{" "} 100 | {this.state.docked ? "hidden" : "shown"} because the sidebar is{" "} 101 | {!this.state.docked && "not"} visible. 102 |

103 |
104 |
105 |
106 | ); 107 | } 108 | } 109 | 110 | ReactDOM.render(, document.getElementById("example")); 111 | -------------------------------------------------------------------------------- /example/src/sidebar_content.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import PropTypes from "prop-types"; 3 | import MaterialTitlePanel from "./material_title_panel"; 4 | 5 | const styles = { 6 | sidebar: { 7 | width: 256, 8 | height: "100%" 9 | }, 10 | sidebarLink: { 11 | display: "block", 12 | padding: "16px 0px", 13 | color: "#757575", 14 | textDecoration: "none" 15 | }, 16 | divider: { 17 | margin: "8px 0", 18 | height: 1, 19 | backgroundColor: "#757575" 20 | }, 21 | content: { 22 | padding: "16px", 23 | height: "100%", 24 | backgroundColor: "white" 25 | } 26 | }; 27 | 28 | const SidebarContent = props => { 29 | const style = props.style 30 | ? { ...styles.sidebar, ...props.style } 31 | : styles.sidebar; 32 | 33 | const links = []; 34 | 35 | for (let ind = 0; ind < 10; ind++) { 36 | links.push( 37 | 38 | Mock menu item {ind} 39 | 40 | ); 41 | } 42 | 43 | return ( 44 | 45 |
46 | 47 | Home 48 | 49 | 50 | Responsive Example 51 | 52 |
53 | {links} 54 |
55 | 56 | ); 57 | }; 58 | 59 | SidebarContent.propTypes = { 60 | style: PropTypes.object 61 | }; 62 | 63 | export default SidebarContent; 64 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-sidebar", 3 | "version": "3.0.2", 4 | "author": "Paulus Schoutsen ", 5 | "description": "A sidebar component for React.", 6 | "main": "dist/react-sidebar.cjs.js", 7 | "module": "dist/react-sidebar.esm.js", 8 | "scripts": { 9 | "example:dev": "webpack-dev-server --mode development", 10 | "example:prod": "NODE_ENV=production webpack --mode production", 11 | "build": "rollup -c", 12 | "build:watch": "rollup -c --watch", 13 | "lint": "eslint src example/src", 14 | "format": "prettier --write '**/*.{js,json}'" 15 | }, 16 | "keywords": [ 17 | "react", 18 | "react-component", 19 | "sidebar", 20 | "drawer", 21 | "navigation" 22 | ], 23 | "repository": { 24 | "type": "git", 25 | "url": "git://github.com/balloob/react-sidebar.git" 26 | }, 27 | "dependencies": { 28 | "@babel/runtime": ">=7.0.0-beta.56", 29 | "prop-types": "^15.6.2" 30 | }, 31 | "peerDependencies": { 32 | "react": ">=16.4.2", 33 | "react-dom": ">=16.4.2" 34 | }, 35 | "devDependencies": { 36 | "@babel/cli": "^7.0.0-beta.56", 37 | "@babel/core": "^7.0.0-beta.56", 38 | "@babel/plugin-proposal-object-rest-spread": "^7.0.0-beta.56", 39 | "@babel/plugin-transform-runtime": "^7.0.0-beta.56", 40 | "@babel/preset-env": "^7.0.0-beta.56", 41 | "@babel/preset-react": "^7.0.0-beta.56", 42 | "babel-eslint": "^8.2.6", 43 | "babel-loader": "^8.0.0-beta.4", 44 | "babel-plugin-transform-react-remove-prop-types": "^0.4.14", 45 | "babel-preset-es2015": "^6.24.1", 46 | "eslint": "^4.19.1", 47 | "eslint-config-airbnb": "^17.0.0", 48 | "eslint-config-prettier": "^2.9.0", 49 | "eslint-plugin-import": "^2.13.0", 50 | "eslint-plugin-jsx-a11y": "^6.1.1", 51 | "eslint-plugin-react": "^7.10.0", 52 | "html-webpack-plugin": "^3.2.0", 53 | "prettier": "^1.14.0", 54 | "react": ">=16.4.2", 55 | "react-dom": ">=16.4.2", 56 | "rollup": "^0.63.5", 57 | "rollup-plugin-babel": "^4.0.0-beta.8", 58 | "webpack": "^4.16.5", 59 | "webpack-bundle-analyzer": "^2.13.1", 60 | "webpack-cli": "^3.1.0", 61 | "webpack-dev-server": "^3.1.5" 62 | }, 63 | "license": "MIT" 64 | } 65 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import babel from "rollup-plugin-babel"; 2 | import pkg from "./package.json"; 3 | 4 | const input = "./src/sidebar.js"; 5 | 6 | const external = id => !id.startsWith("/") && !id.startsWith("."); 7 | 8 | const getBabelOptions = () => ({ 9 | runtimeHelpers: true, 10 | plugins: ["@babel/transform-runtime"] 11 | }); 12 | 13 | export default [ 14 | { 15 | input, 16 | output: { file: pkg.main, format: "cjs" }, 17 | external, 18 | plugins: [babel(getBabelOptions())] 19 | }, 20 | 21 | { 22 | input, 23 | output: { file: pkg.module, format: "es" }, 24 | external, 25 | plugins: [babel(getBabelOptions())] 26 | } 27 | ]; 28 | -------------------------------------------------------------------------------- /src/sidebar.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from "react"; 2 | import PropTypes from "prop-types"; 3 | 4 | const CANCEL_DISTANCE_ON_SCROLL = 20; 5 | 6 | const defaultStyles = { 7 | root: { 8 | position: "absolute", 9 | top: 0, 10 | left: 0, 11 | right: 0, 12 | bottom: 0, 13 | overflow: "hidden" 14 | }, 15 | sidebar: { 16 | zIndex: 2, 17 | position: "absolute", 18 | top: 0, 19 | bottom: 0, 20 | transition: "transform .3s ease-out", 21 | WebkitTransition: "-webkit-transform .3s ease-out", 22 | willChange: "transform", 23 | overflowY: "auto" 24 | }, 25 | content: { 26 | position: "absolute", 27 | top: 0, 28 | left: 0, 29 | right: 0, 30 | bottom: 0, 31 | overflowY: "auto", 32 | WebkitOverflowScrolling: "touch", 33 | transition: "left .3s ease-out, right .3s ease-out" 34 | }, 35 | overlay: { 36 | zIndex: 1, 37 | position: "fixed", 38 | top: 0, 39 | left: 0, 40 | right: 0, 41 | bottom: 0, 42 | opacity: 0, 43 | visibility: "hidden", 44 | transition: "opacity .3s ease-out, visibility .3s ease-out", 45 | backgroundColor: "rgba(0,0,0,.3)" 46 | }, 47 | dragHandle: { 48 | zIndex: 1, 49 | position: "fixed", 50 | top: 0, 51 | bottom: 0 52 | } 53 | }; 54 | 55 | class Sidebar extends Component { 56 | constructor(props) { 57 | super(props); 58 | 59 | this.state = { 60 | // the detected width of the sidebar in pixels 61 | sidebarWidth: props.defaultSidebarWidth, 62 | 63 | // keep track of touching params 64 | touchIdentifier: null, 65 | touchStartX: null, 66 | touchCurrentX: null, 67 | 68 | // if touch is supported by the browser 69 | dragSupported: false 70 | }; 71 | 72 | this.overlayClicked = this.overlayClicked.bind(this); 73 | this.onTouchStart = this.onTouchStart.bind(this); 74 | this.onTouchMove = this.onTouchMove.bind(this); 75 | this.onTouchEnd = this.onTouchEnd.bind(this); 76 | this.onScroll = this.onScroll.bind(this); 77 | this.saveSidebarRef = this.saveSidebarRef.bind(this); 78 | } 79 | 80 | componentDidMount() { 81 | const isIos = /iPad|iPhone|iPod/.test(navigator ? navigator.userAgent : ""); 82 | this.setState({ 83 | dragSupported: 84 | typeof window === "object" && "ontouchstart" in window && !isIos 85 | }); 86 | this.saveSidebarWidth(); 87 | } 88 | 89 | componentDidUpdate() { 90 | // filter out the updates when we're touching 91 | if (!this.isTouching()) { 92 | this.saveSidebarWidth(); 93 | } 94 | } 95 | 96 | onTouchStart(ev) { 97 | // filter out if a user starts swiping with a second finger 98 | if (!this.isTouching()) { 99 | const touch = ev.targetTouches[0]; 100 | this.setState({ 101 | touchIdentifier: touch.identifier, 102 | touchStartX: touch.clientX, 103 | touchCurrentX: touch.clientX 104 | }); 105 | } 106 | } 107 | 108 | onTouchMove(ev) { 109 | if (this.isTouching()) { 110 | for (let ind = 0; ind < ev.targetTouches.length; ind++) { 111 | // we only care about the finger that we are tracking 112 | if (ev.targetTouches[ind].identifier === this.state.touchIdentifier) { 113 | this.setState({ 114 | touchCurrentX: ev.targetTouches[ind].clientX 115 | }); 116 | break; 117 | } 118 | } 119 | } 120 | } 121 | 122 | onTouchEnd() { 123 | if (this.isTouching()) { 124 | // trigger a change to open if sidebar has been dragged beyond dragToggleDistance 125 | const touchWidth = this.touchSidebarWidth(); 126 | 127 | if ( 128 | (this.props.open && 129 | touchWidth < 130 | this.state.sidebarWidth - this.props.dragToggleDistance) || 131 | (!this.props.open && touchWidth > this.props.dragToggleDistance) 132 | ) { 133 | this.props.onSetOpen(!this.props.open); 134 | } 135 | 136 | this.setState({ 137 | touchIdentifier: null, 138 | touchStartX: null, 139 | touchCurrentX: null 140 | }); 141 | } 142 | } 143 | 144 | // This logic helps us prevents the user from sliding the sidebar horizontally 145 | // while scrolling the sidebar vertically. When a scroll event comes in, we're 146 | // cancelling the ongoing gesture if it did not move horizontally much. 147 | onScroll() { 148 | if (this.isTouching() && this.inCancelDistanceOnScroll()) { 149 | this.setState({ 150 | touchIdentifier: null, 151 | touchStartX: null, 152 | touchCurrentX: null 153 | }); 154 | } 155 | } 156 | 157 | // True if the on going gesture X distance is less than the cancel distance 158 | inCancelDistanceOnScroll() { 159 | let cancelDistanceOnScroll; 160 | 161 | if (this.props.pullRight) { 162 | cancelDistanceOnScroll = 163 | Math.abs(this.state.touchCurrentX - this.state.touchStartX) < 164 | CANCEL_DISTANCE_ON_SCROLL; 165 | } else { 166 | cancelDistanceOnScroll = 167 | Math.abs(this.state.touchStartX - this.state.touchCurrentX) < 168 | CANCEL_DISTANCE_ON_SCROLL; 169 | } 170 | return cancelDistanceOnScroll; 171 | } 172 | 173 | isTouching() { 174 | return this.state.touchIdentifier !== null; 175 | } 176 | 177 | overlayClicked() { 178 | if (this.props.open) { 179 | this.props.onSetOpen(false); 180 | } 181 | } 182 | 183 | saveSidebarWidth() { 184 | const width = this.sidebar.offsetWidth; 185 | 186 | if (width !== this.state.sidebarWidth) { 187 | this.setState({ sidebarWidth: width }); 188 | } 189 | } 190 | 191 | saveSidebarRef(node) { 192 | this.sidebar = node; 193 | } 194 | 195 | // calculate the sidebarWidth based on current touch info 196 | touchSidebarWidth() { 197 | // if the sidebar is open and start point of drag is inside the sidebar 198 | // we will only drag the distance they moved their finger 199 | // otherwise we will move the sidebar to be below the finger. 200 | if (this.props.pullRight) { 201 | if ( 202 | this.props.open && 203 | window.innerWidth - this.state.touchStartX < this.state.sidebarWidth 204 | ) { 205 | if (this.state.touchCurrentX > this.state.touchStartX) { 206 | return ( 207 | this.state.sidebarWidth + 208 | this.state.touchStartX - 209 | this.state.touchCurrentX 210 | ); 211 | } 212 | return this.state.sidebarWidth; 213 | } 214 | return Math.min( 215 | window.innerWidth - this.state.touchCurrentX, 216 | this.state.sidebarWidth 217 | ); 218 | } 219 | 220 | if (this.props.open && this.state.touchStartX < this.state.sidebarWidth) { 221 | if (this.state.touchCurrentX > this.state.touchStartX) { 222 | return this.state.sidebarWidth; 223 | } 224 | return ( 225 | this.state.sidebarWidth - 226 | this.state.touchStartX + 227 | this.state.touchCurrentX 228 | ); 229 | } 230 | return Math.min(this.state.touchCurrentX, this.state.sidebarWidth); 231 | } 232 | 233 | render() { 234 | const sidebarStyle = { 235 | ...defaultStyles.sidebar, 236 | ...this.props.styles.sidebar 237 | }; 238 | const contentStyle = { 239 | ...defaultStyles.content, 240 | ...this.props.styles.content 241 | }; 242 | const overlayStyle = { 243 | ...defaultStyles.overlay, 244 | ...this.props.styles.overlay 245 | }; 246 | const useTouch = this.state.dragSupported && this.props.touch; 247 | const isTouching = this.isTouching(); 248 | const rootProps = { 249 | className: this.props.rootClassName, 250 | style: { ...defaultStyles.root, ...this.props.styles.root }, 251 | role: "navigation", 252 | id: this.props.rootId 253 | }; 254 | let dragHandle; 255 | 256 | const hasBoxShadow = 257 | this.props.shadow && (isTouching || this.props.open || this.props.docked); 258 | // sidebarStyle right/left 259 | if (this.props.pullRight) { 260 | sidebarStyle.right = 0; 261 | sidebarStyle.transform = "translateX(100%)"; 262 | sidebarStyle.WebkitTransform = "translateX(100%)"; 263 | if (hasBoxShadow) { 264 | sidebarStyle.boxShadow = "-2px 2px 4px rgba(0, 0, 0, 0.15)"; 265 | } 266 | } else { 267 | sidebarStyle.left = 0; 268 | sidebarStyle.transform = "translateX(-100%)"; 269 | sidebarStyle.WebkitTransform = "translateX(-100%)"; 270 | if (hasBoxShadow) { 271 | sidebarStyle.boxShadow = "2px 2px 4px rgba(0, 0, 0, 0.15)"; 272 | } 273 | } 274 | 275 | if (isTouching) { 276 | const percentage = this.touchSidebarWidth() / this.state.sidebarWidth; 277 | 278 | // slide open to what we dragged 279 | if (this.props.pullRight) { 280 | sidebarStyle.transform = `translateX(${(1 - percentage) * 100}%)`; 281 | sidebarStyle.WebkitTransform = `translateX(${(1 - percentage) * 100}%)`; 282 | } else { 283 | sidebarStyle.transform = `translateX(-${(1 - percentage) * 100}%)`; 284 | sidebarStyle.WebkitTransform = `translateX(-${(1 - percentage) * 285 | 100}%)`; 286 | } 287 | 288 | // fade overlay to match distance of drag 289 | overlayStyle.opacity = percentage; 290 | overlayStyle.visibility = "visible"; 291 | } else if (this.props.docked) { 292 | // show sidebar 293 | if (this.state.sidebarWidth !== 0) { 294 | sidebarStyle.transform = `translateX(0%)`; 295 | sidebarStyle.WebkitTransform = `translateX(0%)`; 296 | } 297 | 298 | // make space on the left/right side of the content for the sidebar 299 | if (this.props.pullRight) { 300 | contentStyle.right = `${this.state.sidebarWidth}px`; 301 | } else { 302 | contentStyle.left = `${this.state.sidebarWidth}px`; 303 | } 304 | } else if (this.props.open) { 305 | // slide open sidebar 306 | sidebarStyle.transform = `translateX(0%)`; 307 | sidebarStyle.WebkitTransform = `translateX(0%)`; 308 | 309 | // show overlay 310 | overlayStyle.opacity = 1; 311 | overlayStyle.visibility = "visible"; 312 | } 313 | 314 | if (isTouching || !this.props.transitions) { 315 | sidebarStyle.transition = "none"; 316 | sidebarStyle.WebkitTransition = "none"; 317 | contentStyle.transition = "none"; 318 | overlayStyle.transition = "none"; 319 | } 320 | 321 | if (useTouch) { 322 | if (this.props.open) { 323 | rootProps.onTouchStart = this.onTouchStart; 324 | rootProps.onTouchMove = this.onTouchMove; 325 | rootProps.onTouchEnd = this.onTouchEnd; 326 | rootProps.onTouchCancel = this.onTouchEnd; 327 | rootProps.onScroll = this.onScroll; 328 | } else { 329 | const dragHandleStyle = { 330 | ...defaultStyles.dragHandle, 331 | ...this.props.styles.dragHandle 332 | }; 333 | dragHandleStyle.width = this.props.touchHandleWidth; 334 | 335 | // dragHandleStyle right/left 336 | if (this.props.pullRight) { 337 | dragHandleStyle.right = 0; 338 | } else { 339 | dragHandleStyle.left = 0; 340 | } 341 | 342 | dragHandle = ( 343 |
350 | ); 351 | } 352 | } 353 | 354 | return ( 355 |
356 |
362 | {this.props.sidebar} 363 |
364 | {/* eslint-disable */} 365 |
371 | {/* eslint-enable */} 372 |
377 | {dragHandle} 378 | {this.props.children} 379 |
380 |
381 | ); 382 | } 383 | } 384 | 385 | Sidebar.propTypes = { 386 | // main content to render 387 | children: PropTypes.node.isRequired, 388 | 389 | // styles 390 | styles: PropTypes.shape({ 391 | root: PropTypes.object, 392 | sidebar: PropTypes.object, 393 | content: PropTypes.object, 394 | overlay: PropTypes.object, 395 | dragHandle: PropTypes.object 396 | }), 397 | 398 | // root component optional class 399 | rootClassName: PropTypes.string, 400 | 401 | // sidebar optional class 402 | sidebarClassName: PropTypes.string, 403 | 404 | // content optional class 405 | contentClassName: PropTypes.string, 406 | 407 | // overlay optional class 408 | overlayClassName: PropTypes.string, 409 | 410 | // sidebar content to render 411 | sidebar: PropTypes.node.isRequired, 412 | 413 | // boolean if sidebar should be docked 414 | docked: PropTypes.bool, 415 | 416 | // boolean if sidebar should slide open 417 | open: PropTypes.bool, 418 | 419 | // boolean if transitions should be disabled 420 | transitions: PropTypes.bool, 421 | 422 | // boolean if touch gestures are enabled 423 | touch: PropTypes.bool, 424 | 425 | // max distance from the edge we can start touching 426 | touchHandleWidth: PropTypes.number, 427 | 428 | // Place the sidebar on the right 429 | pullRight: PropTypes.bool, 430 | 431 | // Enable/Disable sidebar shadow 432 | shadow: PropTypes.bool, 433 | 434 | // distance we have to drag the sidebar to toggle open state 435 | dragToggleDistance: PropTypes.number, 436 | 437 | // callback called when the overlay is clicked 438 | onSetOpen: PropTypes.func, 439 | 440 | // Initial sidebar width when page loads 441 | defaultSidebarWidth: PropTypes.number, 442 | 443 | // root component optional id 444 | rootId: PropTypes.string, 445 | 446 | // sidebar optional id 447 | sidebarId: PropTypes.string, 448 | 449 | // content optional id 450 | contentId: PropTypes.string, 451 | 452 | // overlay optional id 453 | overlayId: PropTypes.string 454 | }; 455 | 456 | Sidebar.defaultProps = { 457 | docked: false, 458 | open: false, 459 | transitions: true, 460 | touch: true, 461 | touchHandleWidth: 20, 462 | pullRight: false, 463 | shadow: true, 464 | dragToggleDistance: 30, 465 | onSetOpen: () => {}, 466 | styles: {}, 467 | defaultSidebarWidth: 0 468 | }; 469 | 470 | export default Sidebar; 471 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | const HtmlWebpackPlugin = require("html-webpack-plugin"); 3 | const { BundleAnalyzerPlugin } = require("webpack-bundle-analyzer"); 4 | 5 | const isProd = process.env.NODE_ENV === "production"; 6 | 7 | module.exports = { 8 | entry: { 9 | index: "./example/src/index", 10 | responsive_example: "./example/src/responsive_example" 11 | }, 12 | output: { 13 | filename: "[name].js", 14 | path: path.join(__dirname, "example/dist") 15 | }, 16 | module: { 17 | rules: [ 18 | { 19 | test: /\.js$/, 20 | use: "babel-loader", 21 | exclude: /node_modules/ 22 | } 23 | ] 24 | }, 25 | plugins: [ 26 | new BundleAnalyzerPlugin({ 27 | analyzerMode: isProd ? "static" : "disabled" 28 | }), 29 | new HtmlWebpackPlugin({ 30 | template: path.join(__dirname, "example/src/index.html"), 31 | filename: path.join(__dirname, "example/dist/index.html"), 32 | chunks: ["index"] 33 | }), 34 | new HtmlWebpackPlugin({ 35 | template: path.join(__dirname, "example/src/responsive_example.html"), 36 | filename: path.join(__dirname, "example/dist/responsive_example.html"), 37 | chunks: ["responsive_example"] 38 | }) 39 | ], 40 | devServer: { 41 | contentBase: "./example", 42 | host: "0.0.0.0" 43 | } 44 | }; 45 | --------------------------------------------------------------------------------