├── .babelrc
├── .editorconfig
├── .eslintrc
├── .gitignore
├── .npmignore
├── .watchmanconfig
├── LICENSE.md
├── README.md
├── lib
├── attachHistoryModifiers.js
├── createOrchestrator.js
├── index.js
└── navigation.js
├── package.json
└── src
├── attachHistoryModifiers.js
├── createOrchestrator.js
├── index.js
└── navigation.js
/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "presets": ["react-native"],
3 | "plugins": ["add-module-exports"]
4 | }
5 |
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | # EditorConfig helps developers define and maintain
2 | # consistent coding styles between different editors and IDEs.
3 |
4 | root = true
5 |
6 | [*]
7 | end_of_line = lf
8 | charset = utf-8
9 | trim_trailing_whitespace = true
10 | insert_final_newline = true
11 | indent_style = space
12 | indent_size = 2
13 |
--------------------------------------------------------------------------------
/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "extends": "airbnb",
3 |
4 | "parser": "babel-eslint",
5 |
6 | "plugins": [
7 | "babel"
8 | ],
9 |
10 | "rules": {
11 | "semi": [2, "never"],
12 | "no-trailing-spaces": [2, { "skipBlankLines": true }],
13 | "default-case": 0,
14 | "no-unused-expressions": [2, {
15 | "allowShortCircuit": true,
16 | "allowTernary": true
17 | }],
18 | "no-shadow": 0,
19 | "new-cap": 0,
20 | "no-loop-func": 0,
21 | "prefer-template": 0,
22 | "no-param-reassign": 0,
23 | "func-names": 0,
24 | "consistent-return": 0,
25 | "no-underscore-dangle": 0,
26 | "import/no-unresolved": 0,
27 |
28 | "react/jsx-closing-bracket-location": [1, "after-props"],
29 | },
30 |
31 | "ecmaFeatures": {
32 | "experimentalObjectRestSpread": true
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | *.log
3 | lib
4 |
--------------------------------------------------------------------------------
/.npmignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | *.log
3 |
--------------------------------------------------------------------------------
/.watchmanconfig:
--------------------------------------------------------------------------------
1 | {
2 | "ignore_dirs": [
3 | ".git",
4 | "node_modules"
5 | ]
6 | }
7 |
--------------------------------------------------------------------------------
/LICENSE.md:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Permission is hereby granted, free of charge, to any person obtaining a copy
4 | of this software and associated documentation files (the "Software"), to deal
5 | in the Software without restriction, including without limitation the rights
6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
7 | copies of the Software, and to permit persons to whom the Software is
8 | furnished to do so, subject to the following conditions:
9 |
10 | The above copyright notice and this permission notice shall be included in all
11 | copies or substantial portions of the Software.
12 |
13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
19 | SOFTWARE.
20 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | React stack nav
2 | ==========
3 | Simple universal navigation for React Native and React
4 |
5 | - **Universal** Works in iOS, Android, and Web
6 | - **Familiar API** Has the same API as the web's [History API](https://developer.mozilla.org/en-US/docs/Web/API/History_API)
7 | - **Back/forward button support** Automatic support for Android back button and back/forward buttons on web
8 | - **Deep linking support** Automatic support for deep linking
9 | - **Server-side rendering** Simple as pre-loading redux state with the requested url
10 | - **Composable and declarative** Uses React's component tree to compose and handle routes
11 | - **Use any navigation paradigm** Abstract enough to let you build the navigation with any UI components you want
12 | - **Easy to understand** You can read the source! Only ~300 lines of code
13 |
14 | ### Examples
15 |
16 | iOS | Android | Web
17 | :---:|:---:|:---:
18 | | **[Drawer example](https://github.com/tuckerconnelly/react-stack-nav-examples/tree/master/Drawer)** |
19 |  |  | 
20 | | **[Bottom nav with stacks example](https://github.com/tuckerconnelly/react-stack-nav-examples/tree/master/BottomNavWithStacks)** |
21 |  |  | 
22 |
23 | The examples repo is [over here](https://github.com/tuckerconnelly/react-stack-nav-examples).
24 |
25 | You can also check out a real-world example in the [Carbon UI Docs source](https://github.com/tuckerconnelly/carbon-ui-docs).
26 |
27 | ### What it looks like
28 |
29 | A navigation component (drawer, tabs, anything):
30 | ```js
31 | import React from 'react'
32 | import { Button, View } from 'react-native'
33 | import { connect } from 'react-redux'
34 | import { pushState } from 'react-stack-nav'
35 |
36 | const Navigation = ({ pushState }) =>
37 |
38 |
39 |
40 |
41 |
42 | const mapDispatchToProps = { pushState }
43 |
44 | export default connect(null, mapDispatchToProps)(Navigation)
45 | ```
46 |
47 | A component receives and handles the first fragment of the url:
48 | ```js
49 | import React from 'react'
50 | import { Text, View } from 'react-native'
51 | import { createOrchestrator } from 'react-stack-nav'
52 |
53 | const Index = ({ routeFragment }) =>
54 |
55 | {routeFragment === '' && Home}
56 | {routeFragment === 'myRoute' && My route}
57 |
58 |
59 | export default createOrchestrator()(Index)
60 | ```
61 |
62 | ### Installation
63 |
64 | ```
65 | npm -S i react-stack-nav
66 | ```
67 |
68 | Then match your redux setup to this:
69 |
70 | ```js
71 | import { createStore, applyMiddleware, compose, combineReducers } from 'redux'
72 | import thunk from 'redux-thunk'
73 | import { navigation, attachHistoryModifiers } from 'react-stack-nav'
74 | import { BackAndroid, Linking } from 'react-native'
75 |
76 | import app from './modules/duck'
77 |
78 | const rootReducer = combineReducers({ navigation })
79 |
80 | export default (initialState = {}) =>
81 | createStore(
82 | rootReducer,
83 | initialState,
84 | compose(
85 | applyMiddleware(thunk),
86 | attachHistoryModifiers({ BackAndroid, Linking }),
87 | ),
88 | )
89 | ```
90 |
91 | If you want deep linking to work, make sure you set it up per instructions [here](https://facebook.github.io/react-native/docs/linking.html).
92 |
93 | ### Principles
94 |
95 | - Treat the redux store as a single-source-of-truth for routing
96 | - Use URLs and the History API, even in React Native
97 | - Treat the URL as a stack, and "pop" off fragments of the URL to orchestrate transitions between URLs
98 | - Leave you in control, favor patterns over frameworks
99 |
100 | ### Usage
101 |
102 | At the core of react-stack-nav is the `navigation` reducer, whose state looks like this:
103 |
104 | ```js
105 | {
106 | index: 0, // The index of the current history object
107 | history: [{ statObj, title, url }], // An ordered list of history objects from pushState
108 | }
109 | ```
110 |
111 | If you want to navigate to a new url/page, use the `pushState` action creator in one of your components (it's the same as [history.pushState](https://developer.mozilla.org/en-US/docs/Web/API/History_API)!):
112 |
113 | ```js
114 | import { pushState } from 'react-stack-nav'
115 |
116 | class MyComponent {
117 | _handleClick = () => this.props.pushState({ yo: 'dawg' }, 'New Route', '/newRoute')
118 | }
119 | ```
120 |
121 | That'll push a new object onto `state.navigation.history`, so your new navigation reducer state might look like this:
122 |
123 | ```js
124 | {
125 | index: 1,
126 | history: [
127 | { stateObj: {}, title: '', url: '' },
128 | { stateObj: {}, title: 'New Route', '/newRoute' }
129 | ]
130 | }
131 | ```
132 |
133 | Now say you clicked back in the browser (or hit the android back button), your state would automatically update to:
134 |
135 | ```js
136 | {
137 | index: 0,
138 | history: [/* same as above */]
139 | }
140 | ```
141 |
142 | With `index: 0` to say, hey, we're on the first history entry.
143 |
144 | ---
145 |
146 | If you want to use the history object, to ya know, render stuff, you can do so directly:
147 |
148 | ```js
149 |
150 | const MyComponent = ({ url, title }) =>
151 |
152 | {title}
153 | {url === '/users' && }
154 | {url === '/pictures' && }
155 |
156 |
157 | const mapStateToProps = ({ navigation }) => ({
158 | url: navigation.history[navigation.index].url,
159 | title: navigation.history[navigation.index].url,
160 | })
161 |
162 | connect(mapStateToProps)(MyComponent)
163 | ```
164 |
165 | You could handle all your routing like that, but...wait a minute...if you turn a url sideways, it kinda looks like a stack yeah?
166 |
167 | ```
168 | /users/1/profile
169 |
170 | users
171 | -------
172 | 1
173 | -------
174 | profile
175 | ```
176 |
177 | `react-stack-nav` runs with this idea and introduces the idea of an _orchestrator_. Orchestrators pops off an element in the stack and declaratively handles transitions when it changes:
178 |
179 | ```
180 | users -------> Popped off and handled by first orchestrator in component tree
181 | -------
182 | 1 -------> Popped off and handled by second orchestrator in component tree
183 | -------
184 | profile -------> Popped off and handled by third orchestrator in component tree
185 | ```
186 |
187 | The first orchestrator found in the component tree would handle changes to the top of the stack:
188 |
189 |
190 | ```js
191 | import { createOrchestrator } from 'react-stack-nav'
192 |
193 | const Index = ({ routeFragment }) => {
194 |
195 | {routeFragment === 'users' && }
196 | {routeFragment === 'pictures' && }
197 |
198 | }
199 |
200 | export default createOrchestrator()(Index)
201 | ```
202 |
203 | The next orchestrators found in the tree would handle the next layer of the url stack, so `UsersIndex` might look like this:
204 |
205 | ```js
206 | class UsersIndex extends Component {
207 | async componentWillReceiveProps(next) {
208 | const { routeFragment } = this.props
209 | if (routeFragment !== next.routeFragment) return
210 |
211 | this.setState({ user: await fetchUserData(routeFragment) })
212 | }
213 |
214 | render() {
215 |
216 | {/* Use this.state.user info */}
217 |
218 | }
219 | }
220 |
221 | export default createOrchestrator('users')(UsersIndex)
222 | ```
223 |
224 | `createOrchestrator()` accepts a string or a regular expression for that particular layer of that stack. If it doesn't match the current layer of the url, `props.routeFragment` will be undefined.
225 |
226 | You could create orchestrators _ad inifitum_ all the way down the component tree to handle as much of the URL stack as you want:
227 |
228 | ```js
229 | const UserIndex = ({ routeFragment }) => {
230 |
231 | {routeFragment === 'profile' && }
232 | {routeFragment === 'settings' && }
233 |
234 | }
235 |
236 | export default createOrchestrator(/\d+/)(UserIndex)
237 | ```
238 |
239 | ```js
240 | const ProfileIndex = ({ routeFragment }) => {
241 |
242 | {/* Would handle /users/${userId}/profile/posts */}
243 | {routeFragment === 'posts' && }
244 | {/* Would handle /users/${userId}/profile/pictures */}
245 | {routeFragment === 'pictures' && }
246 |
247 | }
248 |
249 | export default createOrchestrator('profile')(ProfileIndex)
250 |
251 | ```
252 |
253 | ### API
254 |
255 | - `attachHistoryModifiers` - Store enhancer for redux, handles android back-button presses, browser back/forward buttons, and deep linking
256 | - `createOrchestrator(fragment: string | regexp)` -- Higher-order-component that creates an orchestrator
257 | - `navigation` -- `navigation` reducer for setting up your store
258 |
259 | #### Action creators
260 |
261 | These are the same as the [History API](https://developer.mozilla.org/en-US/docs/Web/API/History_API) :
262 |
263 | - `pushState(stateObj: object, title: string, url: string)`: Pushes a new state onto the history array
264 | - `replaceState(stateObj: object, title: string, url: string)`: Replaces the current state on the history array
265 | - `back(reduxOnly: bool)`: Moves the `navigation.index` back one. If `reduxOnly` is true, it won't change the browser `history` state (this is mostly for internal use)
266 | - `forward(reduxOnly: bool)`: Moves the `navigation.index` forward one, reduxOnly is the same as for `back()`
267 | - `go(numberOfEntries: int)`: Moves the `navigation.index` forward/backward by the passed number (`go(1)` is the same as `forward()`, for example).
268 | - `replaceTop(stateObj: object, title: string, toUrl: string)`: Like `replaceState`, but appends the `toUrl` to the current url instead of replacing it outright. Primarily used for index redirects.
269 | - `pushTop(stateObj: object, title: string, toUrl: string)`: Like `pushState`, but appends the `toUrl` to the current url instead of replacing it outright. Primarily used for card stacks.
270 |
271 | ### Connect
272 |
273 | Follow the creator on Twitter, [@TuckerConnelly](https://twitter.com/TuckerConnelly)
274 |
275 | ### License
276 | MIT
277 |
--------------------------------------------------------------------------------
/lib/attachHistoryModifiers.js:
--------------------------------------------------------------------------------
1 | Object.defineProperty(exports,"__esModule",{value:true});exports.default=
2 |
3 |
4 |
5 |
6 |
7 |
8 | attachHistoryModifiers;var _urlParse=require('url-parse');var _urlParse2=_interopRequireDefault(_urlParse);var _navigation=require('./navigation');function _interopRequireDefault(obj){return obj&&obj.__esModule?obj:{default:obj};} // HACK global.__BUNDLE_START_TIME__ is only present in React Native
9 | var __WEB__=!global.__BUNDLE_START_TIME__&&window.location.pathname;function attachHistoryModifiers(){var _ref=arguments.length<=0||arguments[0]===undefined?{}:arguments[0];var BackAndroid=_ref.BackAndroid;var Linking=_ref.Linking;return function(createStore){return function(reducer,preloadedState,enhancer){
10 | var store=createStore(reducer,preloadedState,enhancer);var
11 | dispatch=store.dispatch;var getState=store.getState;
12 |
13 | if(__WEB__){
14 | window.onpopstate=function(_ref2){var state=_ref2.state;
15 | var newIndex=getState().navigation.history.
16 | map(function(s){return s.stateObj.index;}).
17 | indexOf(state.index);
18 | var lastIndex=getState().navigation.index;
19 |
20 | if(newIndex<=lastIndex)dispatch((0,_navigation.back)(true));
21 | if(newIndex>lastIndex)dispatch((0,_navigation.forward)(true));};}
22 |
23 |
24 | if(BackAndroid){
25 | BackAndroid.addEventListener('hardwareBackPress',function(){var
26 | index=store.getState().navigation.index;
27 | if(index===0)return false;
28 | dispatch((0,_navigation.back)());
29 | return true;});}
30 |
31 |
32 | if(Linking){
33 | Linking.addEventListener('url',function(_ref3){var url=_ref3.url;var _ref4=
34 | new _urlParse2.default(url);var pathname=_ref4.pathname;var query=_ref4.query;var hash=_ref4.hash;
35 | dispatch((0,_navigation.replaceState)(0,0,''+pathname+query+hash));});}
36 |
37 |
38 |
39 | return store;};};}module.exports=exports['default'];
--------------------------------------------------------------------------------
/lib/createOrchestrator.js:
--------------------------------------------------------------------------------
1 | Object.defineProperty(exports,"__esModule",{value:true});var _extends=Object.assign||function(target){for(var i=1;i at the top '+'of your app.');(0,_invariant2.default)(context.store.getState().navigation,'Couldn\'t find the navigation reducer on the store. '+'Make sure you have react-stack-nav\'s reducer on '+'your root reducer.');return _this2;}_createClass(Orchestrator,[{key:'getChildContext',value:function getChildContext(){return {lastOrchestratorId:this._orchestratorId,orchestratorPath:this._orchestratorPath};}},{key:'componentWillMount',value:function componentWillMount(){var lastOrchestratorId=this.context.lastOrchestratorId;this._orchestratorId=lastOrchestratorId!==undefined?lastOrchestratorId+1:0;this._orchestratorPath=this.context.orchestratorPath?[].concat(_toConsumableArray(this.context.orchestratorPath),[fragment]):[];this._updateUrlStack();this._unsubscribeFromStore=this.context.store.subscribe(this._updateUrlStack);}},{key:'componentWillUnmount',value:function componentWillUnmount(){this._unsubscribeFromStore();}},{key:'render',value:function render()
73 |
74 |
75 |
76 |
77 |
78 |
79 |
80 |
81 |
82 |
83 |
84 |
85 |
86 |
87 |
88 |
89 |
90 |
91 |
92 |
93 |
94 | {
95 | return (
96 | _react2.default.createElement(ComposedComponent,_extends({},
97 | this.props,{
98 | routeFragment:this._routeFragment,__source:{fileName:_jsxFileName,lineNumber:96}})));}},{key:'_routeFragment',get:function get(){var _this3=this;var urlMatchesOrchestratorPath=this._orchestratorPath.reduce(function(prev,curr,i){if(!prev)return false;if(!_this3.state.urlStack[i])return false; // Handle regex orchestrators
99 | if(_this3._orchestratorPath[i] instanceof RegExp){return _this3.state.urlStack[i].match(_this3._orchestratorPath[i]);} // Handle string orchestrators
100 | return _this3.state.urlStack[i]===_this3._orchestratorPath[i];},true);if(!urlMatchesOrchestratorPath)return undefined;return this.state.urlStack[this._orchestratorId]||'';}}]);return Orchestrator;}(_react.Component);
101 |
102 |
103 | Orchestrator.displayName='Orchestrator('+(
104 | _react.Component.displayName||_react.Component.name||'Component')+')';
105 |
106 | Orchestrator.contextTypes=_extends({},
107 | Orchestrator.contextTypes,{
108 | store:_react.PropTypes.object,
109 | lastOrchestratorId:_react.PropTypes.number,
110 | orchestratorPath:_react.PropTypes.array});
111 |
112 |
113 | Orchestrator.childContextTypes=_extends({},
114 | Orchestrator.childContextTypes,{
115 | lastOrchestratorId:_react.PropTypes.number,
116 | orchestratorPath:_react.PropTypes.array});
117 |
118 |
119 | return Orchestrator;};};module.exports=exports['default'];
--------------------------------------------------------------------------------
/lib/index.js:
--------------------------------------------------------------------------------
1 | Object.defineProperty(exports,"__esModule",{value:true});var _attachHistoryModifiers=require('./attachHistoryModifiers');Object.defineProperty(exports,'attachHistoryModifiers',{enumerable:true,get:function get(){return _interopRequireDefault(_attachHistoryModifiers).default;}});var _createOrchestrator=require('./createOrchestrator');Object.defineProperty(exports,'createOrchestrator',{enumerable:true,get:function get(){return _interopRequireDefault(_createOrchestrator).
2 | default;}});var _navigation=require('./navigation');
3 | Object.keys(_navigation).forEach(function(key){if(key==="default")return;Object.defineProperty(exports,key,{enumerable:true,get:function get(){return _navigation[key];}});});Object.defineProperty(exports,'navigation',{enumerable:true,get:function get(){return _interopRequireDefault(_navigation).
4 | default;}});function _interopRequireDefault(obj){return obj&&obj.__esModule?obj:{default:obj};}
--------------------------------------------------------------------------------
/lib/navigation.js:
--------------------------------------------------------------------------------
1 | Object.defineProperty(exports,"__esModule",{value:true});var _extends=Object.assign||function(target){for(var i=1;i",
25 | "license": "MIT",
26 | "devDependencies": {
27 | "babel-cli": "^6.14.0",
28 | "babel-core": "^6.14.0",
29 | "babel-eslint": "^6.1.2",
30 | "babel-plugin-add-module-exports": "^0.2.1",
31 | "babel-preset-react-native": "^1.9.0",
32 | "eslint": "^3.4.0",
33 | "eslint-config-airbnb": "^10.0.1",
34 | "eslint-plugin-babel": "^3.3.0",
35 | "eslint-plugin-import": "^1.14.0",
36 | "eslint-plugin-jsx-a11y": "^2.2.1",
37 | "eslint-plugin-react": "^6.2.0"
38 | },
39 | "peerDependencies": {
40 | "react": ">=15.3.2",
41 | "redux": ">=3.0.0"
42 | },
43 | "dependencies": {
44 | "url-parse": "^1.1.7"
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/src/attachHistoryModifiers.js:
--------------------------------------------------------------------------------
1 | import URL from 'url-parse'
2 |
3 | // HACK global.__BUNDLE_START_TIME__ is only present in React Native
4 | const __WEB__ = !global.__BUNDLE_START_TIME__ && window.location.pathname
5 |
6 | import { replaceState, back, forward } from './navigation'
7 |
8 | export default function attachHistoryModifiers({ BackAndroid, Linking } = {}) {
9 | return createStore => (reducer, preloadedState, enhancer) => {
10 | const store = createStore(reducer, preloadedState, enhancer)
11 | const { dispatch, getState } = store
12 |
13 | if (__WEB__) {
14 | window.onpopstate = ({ state }) => {
15 | const newIndex = getState().navigation.history
16 | .map(s => s.stateObj.index)
17 | .indexOf(state.index)
18 | const lastIndex = getState().navigation.index
19 |
20 | if (newIndex <= lastIndex) dispatch(back(true))
21 | if (newIndex > lastIndex) dispatch(forward(true))
22 | }
23 | }
24 | if (BackAndroid) {
25 | BackAndroid.addEventListener('hardwareBackPress', () => {
26 | const { index } = store.getState().navigation
27 | if (index === 0) return false
28 | dispatch(back())
29 | return true
30 | })
31 | }
32 | if (Linking) {
33 | Linking.addEventListener('url', ({ url }) => {
34 | const { pathname, query, hash } = new URL(url)
35 | dispatch(replaceState(0, 0, `${pathname}${query}${hash}`))
36 | })
37 | }
38 |
39 | return store
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/src/createOrchestrator.js:
--------------------------------------------------------------------------------
1 | import React, { Component, PropTypes } from 'react'
2 | import invariant from 'invariant'
3 |
4 | function makeStackFromPathname(pathname) {
5 | const pathArray = pathname.split('/')
6 | pathArray.shift() // Remove first blank ""
7 | return pathArray
8 | }
9 |
10 | export default fragment => component => {
11 | let ComposedComponent = component
12 |
13 | // Handle stateless components
14 | if (!ComposedComponent.render && !ComposedComponent.prototype.render) {
15 | ComposedComponent = class extends Component {
16 | render() {
17 | return component(this.props, this.context)
18 | }
19 | }
20 | }
21 |
22 | class Orchestrator extends Component { // eslint-disable-line react/no-multi-comp
23 | constructor(props, context) {
24 | super(props, context)
25 | invariant(context.store,
26 | 'Couldn\'t find the store on the context. ' +
27 | 'Make sure you have a redux at the top ' +
28 | 'of your app.')
29 |
30 | invariant(context.store.getState().navigation,
31 | 'Couldn\'t find the navigation reducer on the store. ' +
32 | 'Make sure you have react-stack-nav\'s reducer on ' +
33 | 'your root reducer.'
34 | )
35 | }
36 |
37 | state = { urlStack: [] }
38 |
39 | getChildContext() {
40 | return {
41 | lastOrchestratorId: this._orchestratorId,
42 | orchestratorPath: this._orchestratorPath,
43 | }
44 | }
45 |
46 | componentWillMount() {
47 | const { lastOrchestratorId } = this.context
48 |
49 | this._orchestratorId = lastOrchestratorId !== undefined ?
50 | lastOrchestratorId + 1 :
51 | 0
52 | this._orchestratorPath = this.context.orchestratorPath ?
53 | [...this.context.orchestratorPath, fragment] :
54 | []
55 |
56 | this._updateUrlStack()
57 | this._unsubscribeFromStore = this.context.store.subscribe(this._updateUrlStack)
58 | }
59 |
60 | componentWillUnmount() {
61 | this._unsubscribeFromStore()
62 | }
63 |
64 | _unsubscribeFromStore = null
65 |
66 | _updateUrlStack = () => {
67 | const state = this.context.store.getState()
68 | const index = state.navigation.index
69 | // Default to redux store for stack if this is the first
70 | // route and the navStack hasn't been set yet
71 | this.setState({
72 | urlStack: makeStackFromPathname(state.navigation.history[index].url),
73 | })
74 | }
75 |
76 | get _routeFragment() {
77 | const urlMatchesOrchestratorPath = this._orchestratorPath.reduce((prev, curr, i) => {
78 | if (!prev) return false
79 |
80 | if (!this.state.urlStack[i]) return false
81 | // Handle regex orchestrators
82 | if (this._orchestratorPath[i] instanceof RegExp) {
83 | return this.state.urlStack[i].match(this._orchestratorPath[i])
84 | }
85 | // Handle string orchestrators
86 | return this.state.urlStack[i] === this._orchestratorPath[i]
87 | }, true)
88 |
89 | if (!urlMatchesOrchestratorPath) return undefined
90 |
91 | return this.state.urlStack[this._orchestratorId] || ''
92 | }
93 |
94 | render() {
95 | return (
96 |
99 | )
100 | }
101 | }
102 |
103 | Orchestrator.displayName =
104 | `Orchestrator(${Component.displayName || Component.name || 'Component'})`
105 |
106 | Orchestrator.contextTypes = {
107 | ...Orchestrator.contextTypes,
108 | store: PropTypes.object,
109 | lastOrchestratorId: PropTypes.number,
110 | orchestratorPath: PropTypes.array,
111 | }
112 |
113 | Orchestrator.childContextTypes = {
114 | ...Orchestrator.childContextTypes,
115 | lastOrchestratorId: PropTypes.number,
116 | orchestratorPath: PropTypes.array,
117 | }
118 |
119 | return Orchestrator
120 | }
121 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | export { default as attachHistoryModifiers } from './attachHistoryModifiers'
2 | export { default as createOrchestrator } from './createOrchestrator'
3 | export * from './navigation'
4 | export { default as navigation } from './navigation'
5 |
--------------------------------------------------------------------------------
/src/navigation.js:
--------------------------------------------------------------------------------
1 | // HACK global.__BUNDLE_START_TIME__ is only present in React Native
2 | const __WEB__ = !global.__BUNDLE_START_TIME__ && window.location.pathname
3 |
4 | const removeTrailingSlashFromUrl = url => {
5 | const urlParts = url.split('?')
6 | urlParts[0] = urlParts[0].replace(/\/$/, '')
7 | return urlParts.join('?')
8 | }
9 |
10 | export const pushState = (stateObj, title, url) => ({
11 | type: 'HISTORY_PUSH_STATE',
12 | payload: { stateObj: stateObj || {}, title: title || '', url: removeTrailingSlashFromUrl(url) },
13 | })
14 | export const replaceState = (stateObj, title, url) => ({
15 | type: 'HISTORY_REPLACE_STATE',
16 | payload: { stateObj: stateObj || {}, title: title || '', url: removeTrailingSlashFromUrl(url) },
17 | })
18 | export const back = fromPopState => {
19 | if (__WEB__ && !fromPopState) {
20 | window.history.back()
21 | return { type: 'NULL' }
22 | }
23 | return { type: 'HISTORY_BACK' }
24 | }
25 | export const forward = fromPopState => {
26 | if (__WEB__ && !fromPopState) {
27 | window.history.forward()
28 | return { type: 'NULL' }
29 | }
30 | return { type: 'HISTORY_FORWARD' }
31 | }
32 | export const go = numberOfEntries => ({
33 | type: 'HISTORY_GO',
34 | payload: { numberOfEntries },
35 | })
36 | export const replaceTop = (stateObj, title, url) => ({
37 | type: 'HISTORY_REPLACE_TOP',
38 | payload: { stateObj: stateObj || {}, title: title || '', url: removeTrailingSlashFromUrl(url) },
39 | })
40 | export const pushTop = (stateObj, title, url) => ({
41 | type: 'HISTORY_PUSH_TOP',
42 | payload: { stateObj: stateObj || {}, title: title || '', url: removeTrailingSlashFromUrl(url) },
43 | })
44 |
45 | const initialState = {
46 | index: 0,
47 | history: [{ stateObj: { index: 0 }, title: '', url: '' }],
48 | }
49 |
50 | if (__WEB__) {
51 | initialState.history[0].url = removeTrailingSlashFromUrl(window.location.pathname)
52 | window.history.replaceState(
53 | initialState.history[0].stateObj,
54 | initialState.history[0].title,
55 | initialState.history[0].url
56 | )
57 | }
58 |
59 | export default (state = initialState, action) => {
60 | switch (action.type) {
61 | case 'HISTORY_PUSH_STATE': {
62 | const { stateObj, title, url } = action.payload
63 |
64 | if (url === state.history[state.index].url) return state
65 |
66 | const stateObjWithIndex = { ...stateObj, index: state.index + 1 }
67 |
68 | if (__WEB__) window.history.pushState(stateObjWithIndex, title, url.length ? url : '/')
69 | return {
70 | index: state.index + 1,
71 | history: state.history
72 | .slice(0, state.index + 1)
73 | .concat([{ stateObj: stateObjWithIndex, title, url }]),
74 | }
75 | }
76 | case 'HISTORY_REPLACE_STATE': {
77 | const { stateObj, title, url } = action.payload
78 |
79 | if (url === state.history[state.index].url) return state
80 |
81 | const stateObjWithIndex = { ...stateObj, index: state.index }
82 |
83 | if (__WEB__) window.history.replaceState(stateObjWithIndex, title, url.length ? url : '/')
84 | return {
85 | index: state.index,
86 | history: state.history
87 | .slice(0, state.index)
88 | .concat([{ stateObj: stateObjWithIndex, title, url }]),
89 | }
90 | }
91 | case 'HISTORY_REPLACE_TOP': {
92 | const { stateObj, title, url } = action.payload
93 |
94 | const stateObjWithIndex = { ...stateObj, index: state.index }
95 |
96 | const newUrl = state.history[state.index].url + '/' + url
97 |
98 | if (__WEB__) window.history.replaceState(stateObjWithIndex, title, newUrl)
99 | return {
100 | index: state.index,
101 | history: state.history
102 | .slice(0, state.index)
103 | .concat([{ stateObj: stateObjWithIndex, title, url: newUrl }]),
104 | }
105 | }
106 | case 'HISTORY_PUSH_TOP': {
107 | const { stateObj, title, url } = action.payload
108 |
109 | const stateObjWithIndex = { ...stateObj, index: state.index + 1 }
110 |
111 | const newUrl = state.history[state.index].url + '/' + url
112 |
113 | if (__WEB__) window.history.pushState(stateObjWithIndex, title, newUrl)
114 | return {
115 | index: state.index + 1,
116 | history: state.history
117 | .slice(0, state.index + 1)
118 | .concat([{ stateObj: stateObjWithIndex, title, url: newUrl }]),
119 | }
120 | }
121 | case 'HISTORY_BACK':
122 | if (state.index === 0) return state
123 | return { ...state, index: state.index - 1 }
124 |
125 | case 'HISTORY_FORWARD':
126 | if (state.index === state.history.length - 1) return state
127 | return { ...state, index: state.index + 1 }
128 |
129 | case 'HISTORY_GO': {
130 | const targetIndex = state.index + action.payload.numberOfEntries
131 |
132 | if (!state.history[targetIndex]) {
133 | console.warn('Tried to `go()` to a non-existant index!') // eslint-disable-line no-console, max-len
134 | return state
135 | }
136 | return { ...state, index: targetIndex }
137 | }
138 |
139 | default: return state
140 | }
141 | }
142 |
--------------------------------------------------------------------------------