3
Copyright 2011 Yahoo! Inc. All rights reserved.
4
Licensed under the BSD License.
5
http://yuilibrary.com/license/
7
YUI.add('controller', function(Y) {
10
The app framework provides simple MVC-like building blocks (models, model lists,
11
views, and controllers) for writing single-page JavaScript applications.
19
Provides URL-based routing using HTML5 `pushState()` or the location hash.
26
Provides URL-based routing using HTML5 `pushState()` or the location hash.
28
This makes it easy to wire up route handlers for different application states
29
while providing full back/forward navigation support and bookmarkable, shareable
38
var HistoryHash = Y.HistoryHash,
43
// Android versions lower than 3.0 are buggy and don't update
44
// window.location after a pushState() call, so we fall back to hash-based
47
// See http://code.google.com/p/android/issues/detail?id=17471
48
html5 = Y.HistoryBase.html5 && (!Y.UA.android || Y.UA.android >= 3),
50
location = win.location,
52
// We have to queue up pushState calls to avoid race conditions, since the
53
// popstate event doesn't actually provide any info on what URL it's
58
Fired when the controller is ready to begin dispatching to route handlers.
60
You shouldn't need to wait for this event unless you plan to implement some
61
kind of custom dispatching logic. It's used internally in order to avoid
62
dispatching to an initial route if a browser history change occurs first.
65
@param {Boolean} dispatched `true` if routes have already been dispatched
66
(most likely due to a history change).
71
function Controller() {
72
Controller.superclass.constructor.apply(this, arguments);
75
Y.Controller = Y.extend(Controller, Y.Base, {
76
// -- Public Properties ----------------------------------------------------
79
Whether or not this browser is capable of using HTML5 history.
81
This property is for informational purposes only. It's not configurable, and
82
changing it will have no effect.
90
Absolute root path from which all routes should be evaluated.
92
For example, if your controller is running on a page at
93
`http://example.com/myapp/` and you add a route with the path `/`, your
94
route will never execute, because the path will always be preceded by
95
`/myapp`. Setting `root` to `/myapp` would cause all routes to be evaluated
96
relative to that root URL, so the `/` route would then execute when the
97
user browses to `http://example.com/myapp/`.
99
This property may be overridden in a subclass, set after instantiation, or
100
passed as a config attribute when instantiating a `Y.Controller`-based
110
Array of route objects specifying routes to be created at instantiation
113
Each item in the array must be an object with the following properties:
115
* `path`: String or regex representing the path to match. See the docs for
116
the `route()` method for more details.
117
* `callback`: Function or a string representing the name of a function on
118
this controller instance that should be called when the route is
119
triggered. See the docs for the `route()` method for more details.
121
This property may be overridden in a subclass or passed as a config
122
attribute when instantiating a `Y.Controller`-based class, but setting it
123
after instantiation will have no effect (use the `route()` method instead).
125
If routes are passed at instantiation time, they will override any routes
126
set on the prototype.
134
// -- Protected Properties -------------------------------------------------
137
Whether or not `_dispatch()` has been called since this controller was
140
@property _dispatched
147
Whether or not we're currently in the process of dispatching to routes.
149
@property _dispatching
156
Whether or not the `ready` event has fired yet.
165
Regex used to match parameter placeholders in route paths.
169
1. Parameter prefix character. Either a `:` for subpath parameters that
170
should only match a single level of a path, or `*` for splat parameters
171
that should match any number of path levels.
174
@property _regexPathParam
178
_regexPathParam: /([:*])([\w-]+)/g,
181
Regex that matches and captures the query portion of a URL, minus the
182
preceding `?` character, and discarding the hash portion of the URL if any.
184
@property _regexUrlQuery
188
_regexUrlQuery: /\?([^#]*).*$/,
191
Regex that matches everything before the path portion of an HTTP or HTTPS
192
URL. This will be used to strip this part of the URL from a string when we
195
@property _regexUrlStrip
199
_regexUrlStrip: /^https?:\/\/[^\/]*/i,
201
// -- Lifecycle Methods ----------------------------------------------------
202
initializer: function (config) {
205
// Set config properties.
206
config || (config = {});
208
config.routes && (self.routes = config.routes);
209
Lang.isValue(config.root) && (self.root = config.root);
214
YArray.each(self.routes, function (route) {
215
self.route(route.path, route.callback);
218
// Set up a history instance or hashchange listener.
220
self._history = new Y.HistoryHTML5({force: true});
221
self._history.after('change', self._afterHistoryChange, self);
223
Y.on('hashchange', self._afterHistoryChange, win, self);
226
// Fire a 'ready' event once we're ready to route. We wait first for all
227
// subclass initializers to finish, then for window.onload, and then an
228
// additional 20ms to allow the browser to fire a useless initial
229
// `popstate` event if it wants to (and Chrome always wants to).
230
self.publish(EVT_READY, {
231
defaultFn : self._defReadyFn,
236
self.once('initializedChange', function () {
237
Y.once('load', function () {
238
setTimeout(function () {
239
self.fire(EVT_READY, {dispatched: !!self._dispatched});
245
destructor: function () {
247
this._history.detachAll();
249
Y.detach('hashchange', this._afterHistoryChange, win);
253
// -- Public Methods -------------------------------------------------------
256
Dispatches to the first route handler that matches the current URL, if any.
258
If `dispatch()` is called before the `ready` event has fired, it will
259
automatically wait for the `ready` event before dispatching. Otherwise it
260
will dispatch immediately.
265
dispatch: function () {
266
this.once(EVT_READY, function () {
269
if (html5 && this.upgrade()) {
272
this._dispatch(this._getPath());
280
Gets the current route path, relative to the `root` (if any).
283
@return {String} Current route path.
285
getPath: function () {
286
return this._getPath();
290
Returns `true` if this controller has at least one route that matches the
291
specified URL path, `false` otherwise.
294
@param {String} path URL path to match.
295
@return {Boolean} `true` if there's at least one matching route, `false`
298
hasRoute: function (path) {
299
return !!this.match(path).length;
303
Returns an array of route objects that match the specified URL path.
305
This method is called internally to determine which routes match the current
306
path whenever the URL changes. You may override it if you want to customize
307
the route matching logic, although this usually shouldn't be necessary.
309
Each returned route object has the following properties:
311
* `callback`: A function or a string representing the name of a function
312
this controller that should be executed when the route is triggered.
313
* `keys`: An array of strings representing the named parameters defined in
314
the route's path specification, if any.
315
* `path`: The route's path specification, which may be either a string or
317
* `regex`: A regular expression version of the route's path specification.
318
This regex is used to determine whether the route matches a given path.
321
controller.route('/foo', function () {});
322
controller.match('/foo');
323
// => [{callback: ..., keys: [], path: '/foo', regex: ...}]
326
@param {String} path URL path to match.
327
@return {Object[]} Array of route objects that match the specified path.
329
match: function (path) {
330
return YArray.filter(this._routes, function (route) {
331
return path.search(route.regex) > -1;
336
Removes the `root` URL from the from of _path_ (if it's there) and returns
337
the result. The returned path will always have a leading `/`.
340
@param {String} path URL path.
341
@return {String} Rootless path.
343
removeRoot: function (path) {
344
var root = this.root;
346
// Strip out the non-path part of the URL, if any (e.g.
347
// "http://foo.com"), so that we're left with just the path.
348
path = path.replace(this._regexUrlStrip, '');
350
if (root && path.indexOf(root) === 0) {
351
path = path.substring(root.length);
354
return path.charAt(0) === '/' ? path : '/' + path;
358
Replaces the current browser history entry with a new one, and dispatches to
359
the first matching route handler, if any.
361
Behind the scenes, this method uses HTML5 `pushState()` in browsers that
362
support it (or the location hash in older browsers and IE) to change the
365
The specified URL must share the same origin (i.e., protocol, host, and
366
port) as the current page, or an error will occur.
369
// Starting URL: http://example.com/
371
controller.replace('/path/');
372
// New URL: http://example.com/path/
374
controller.replace('/path?foo=bar');
375
// New URL: http://example.com/path?foo=bar
377
controller.replace('/');
378
// New URL: http://example.com/
381
@param {String} [url] URL to set. Should be a relative URL. If this
382
controller's `root` property is set, this URL must be relative to the
383
root URL. If no URL is specified, the page's current URL will be used.
387
replace: function (url) {
388
return this._queue(url, true);
392
Adds a route handler for the specified URL _path_.
394
The _path_ parameter may be either a string or a regular expression. If it's
395
a string, it may contain named parameters: `:param` will match any single
396
part of a URL path (not including `/` characters), and `*param` will match
397
any number of parts of a URL path (including `/` characters). These named
398
parameters will be made available as keys on the `req.params` object that's
399
passed to route handlers.
401
If the _path_ parameter is a regex, all pattern matches will be made
402
available as numbered keys on `req.params`, starting with `0` for the full
403
match, then `1` for the first subpattern match, and so on.
405
Here's a set of sample routes along with URL paths that they match:
407
* Route: `/photos/:tag/:page`
408
* URL: `/photos/kittens/1`, params: `{tag: 'kittens', page: '1'}`
409
* URL: `/photos/puppies/2`, params: `{tag: 'puppies', page: '2'}`
411
* Route: `/file/*path`
412
* URL: `/file/foo/bar/baz.txt`, params: `{path: 'foo/bar/baz.txt'}`
413
* URL: `/file/foo`, params: `{path: 'foo'}`
415
If multiple route handlers match a given URL, they will be executed in the
416
order they were added. The first route that was added will be the first to
420
controller.route('/photos/:tag/:page', function (req, next) {
421
Y.log('Current tag: ' + req.params.tag);
422
Y.log('Current page number: ' + req.params.page);
426
@param {String|RegExp} path Path to match. May be a string or a regular
428
@param {Function|String} callback Callback function to call whenever this
429
route is triggered. If specified as a string, the named function will be
430
called on this controller instance.
431
@param {Object} callback.req Request object containing information about
432
the request. It contains the following properties.
433
@param {Array|Object} callback.req.params Captured parameters matched by
434
the route path specification. If a string path was used and contained
435
named parameters, then this will be a key/value hash mapping parameter
436
names to their matched values. If a regex path was used, this will be
437
an array of subpattern matches starting at index 0 for the full match,
438
then 1 for the first subpattern match, and so on.
439
@param {String} callback.req.path The current URL path.
440
@param {Object} callback.req.query Query hash representing the URL query
441
string, if any. Parameter names are keys, and are mapped to parameter
443
@param {Function} callback.next Callback to pass control to the next
444
matching route. If you don't call this function, then no further route
445
handlers will be executed, even if there are more that match. If you do
446
call this function, then the next matching route handler (if any) will
447
be called, and will receive the same `req` object that was passed to
448
this route (so you can use the request object to pass data along to
452
route: function (path, callback) {
459
regex : this._getRegex(path, keys)
466
Saves a new browser history entry and dispatches to the first matching route
469
Behind the scenes, this method uses HTML5 `pushState()` in browsers that
470
support it (or the location hash in older browsers and IE) to change the
471
URL and create a history entry.
473
The specified URL must share the same origin (i.e., protocol, host, and
474
port) as the current page, or an error will occur.
477
// Starting URL: http://example.com/
479
controller.save('/path/');
480
// New URL: http://example.com/path/
482
controller.save('/path?foo=bar');
483
// New URL: http://example.com/path?foo=bar
485
controller.save('/');
486
// New URL: http://example.com/
489
@param {String} [url] URL to set. Should be a relative URL. If this
490
controller's `root` property is set, this URL must be relative to the
491
root URL. If no URL is specified, the page's current URL will be used.
495
save: function (url) {
496
return this._queue(url);
500
Upgrades a hash-based URL to an HTML5 URL if necessary. In non-HTML5
501
browsers, this method is a noop.
504
@return {Boolean} `true` if the URL was upgraded, `false` otherwise.
506
upgrade: html5 ? function () {
507
var hash = this._getHashPath();
509
if (hash && hash.charAt(0) === '/') {
510
// This is an HTML5 browser and we have a hash-based path in the
511
// URL, so we need to upgrade the URL to a non-hash URL. This
512
// will trigger a `history:change` event, which will in turn
513
// trigger a dispatch.
514
this.once(EVT_READY, function () {
522
} : function () { return false; },
524
// -- Protected Methods ----------------------------------------------------
527
Wrapper around `decodeURIComponent` that also converts `+` chars into
531
@param {String} string String to decode.
532
@return {String} Decoded string.
535
_decode: function (string) {
536
return decodeURIComponent(string.replace(/\+/g, ' '));
540
Shifts the topmost `_save()` call off the queue and executes it. Does
541
nothing if the queue is empty.
548
_dequeue: function () {
552
// If window.onload hasn't yet fired, wait until it has before
553
// dequeueing. This will ensure that we don't call pushState() before an
554
// initial popstate event has fired.
555
if (!YUI.Env.windowLoaded) {
556
Y.once('load', function () {
563
fn = saveQueue.shift();
564
return fn ? fn() : this;
568
Dispatches to the first route handler that matches the specified _path_.
570
If called before the `ready` event has fired, the dispatch will be aborted.
571
This ensures normalized behavior between Chrome (which fires a `popstate`
572
event on every pageview) and other browsers (which do not).
575
@param {String} path URL path.
579
_dispatch: function (path) {
581
routes = self.match(path),
584
self._dispatching = self._dispatched = true;
586
if (!routes || !routes.length) {
587
self._dispatching = false;
591
req = self._getRequest(path);
593
req.next = function (err) {
594
var callback, matches, route;
598
} else if ((route = routes.shift())) {
599
matches = route.regex.exec(path);
600
callback = typeof route.callback === 'string' ?
601
self[route.callback] : route.callback;
603
// Use named keys for parameter names if the route path contains
604
// named keys. Otherwise, use numerical match indices.
605
if (matches.length === route.keys.length + 1) {
606
req.params = YArray.hash(route.keys, matches.slice(1));
608
req.params = matches.concat();
611
callback.call(self, req, req.next);
617
self._dispatching = false;
618
return self._dequeue();
622
Gets the current path from the location hash, or an empty string if the
626
@return {String} Current hash path, or an empty string if the hash is empty.
629
_getHashPath: function () {
630
return HistoryHash.getHash().replace(this._regexUrlQuery, '');
634
Gets the current route path.
637
@return {String} Current route path.
640
_getPath: html5 ? function () {
641
return this.removeRoot(location.pathname);
643
return this._getHashPath() || this.removeRoot(location.pathname);
647
Gets the current route query string.
650
@return {String} Current route query string.
653
_getQuery: html5 ? function () {
654
return location.search.substring(1);
656
var hash = HistoryHash.getHash(),
657
matches = hash.match(this._regexUrlQuery);
659
return hash && matches ? matches[1] : location.search.substring(1);
663
Creates a regular expression from the given route specification. If _path_
664
is already a regex, it will be returned unmodified.
667
@param {String|RegExp} path Route path specification.
668
@param {Array} keys Array reference to which route parameter names will be
670
@return {RegExp} Route regex.
673
_getRegex: function (path, keys) {
674
if (path instanceof RegExp) {
678
path = path.replace(this._regexPathParam, function (match, operator, key) {
680
return operator === '*' ? '(.*?)' : '([^/]*)';
683
return new RegExp('^' + path + '$');
687
Gets a request object that can be passed to a route handler.
690
@param {String} path Current path being dispatched.
691
@return {Object} Request object.
694
_getRequest: function (path) {
697
query: this._parseQuery(this._getQuery())
702
Joins the `root` URL to the specified _url_, normalizing leading/trailing
706
controller.root = '/foo'
707
controller._joinURL('bar'); // => '/foo/bar'
708
controller._joinURL('/bar'); // => '/foo/bar'
710
controller.root = '/foo/'
711
controller._joinURL('bar'); // => '/foo/bar'
712
controller._joinURL('/bar'); // => '/foo/bar'
715
@param {String} url URL to append to the `root` URL.
716
@return {String} Joined URL.
719
_joinURL: function (url) {
720
var root = this.root;
722
if (url.charAt(0) === '/') {
723
url = url.substring(1);
726
return root && root.charAt(root.length - 1) === '/' ?
732
Parses a URL query string into a key/value hash. If `Y.QueryString.parse` is
733
available, this method will be an alias to that.
736
@param {String} query Query string to parse.
737
@return {Object} Hash of key/value pairs for query parameters.
740
_parseQuery: QS && QS.parse ? QS.parse : function (query) {
741
var decode = this._decode,
742
params = query.split('&'),
748
for (; i < len; ++i) {
749
param = params[i].split('=');
752
result[decode(param[0])] = decode(param[1] || '');
760
Queues up a `_save()` call to run after all previously-queued calls have
763
This is necessary because if we make multiple `_save()` calls before the
764
first call gets dispatched, then both calls will dispatch to the last call's
767
All arguments passed to `_queue()` will be passed on to `_save()` when the
768
queued function is executed.
775
_queue: function () {
776
var args = arguments,
779
saveQueue.push(function () {
781
if (Y.UA.ios && Y.UA.ios < 5) {
782
// iOS <5 has buggy HTML5 history support, and needs to be
784
self._save.apply(self, args);
786
// Wrapped in a timeout to ensure that _save() calls are
787
// always processed asynchronously. This ensures consistency
788
// between HTML5- and hash-based history.
789
setTimeout(function () {
790
self._save.apply(self, args);
794
self._dispatching = true; // otherwise we'll dequeue too quickly
795
self._save.apply(self, args);
801
return !this._dispatching ? this._dequeue() : this;
805
Saves a history entry using either `pushState()` or the location hash.
808
@param {String} [url] URL for the history entry.
809
@param {Boolean} [replace=false] If `true`, the current history entry will
810
be replaced instead of a new one being added.
814
_save: html5 ? function (url, replace) {
815
// Force _ready to true to ensure that the history change is handled
816
// even if _save is called before the `ready` event fires.
819
this._history[replace ? 'replace' : 'add'](null, {
820
url: typeof url === 'string' ? this._joinURL(url) : url
824
} : function (url, replace) {
827
if (typeof url === 'string' && url.charAt(0) !== '/') {
831
HistoryHash[replace ? 'replaceHash' : 'setHash'](url);
835
// -- Protected Event Handlers ---------------------------------------------
838
Handles `history:change` and `hashchange` events.
840
@method _afterHistoryChange
841
@param {EventFacade} e
844
_afterHistoryChange: function (e) {
848
self._dispatch(self._getPath());
852
// -- Default Event Handlers -----------------------------------------------
855
Default handler for the `ready` event.
858
@param {EventFacade} e
861
_defReadyFn: function (e) {
869
}, '3.4.1' ,{optional:['querystring-parse'], requires:['array-extras', 'base-build', 'history']});