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) {
424
@param {String|RegExp} path Path to match. May be a string or a regular
426
@param {Function|String} callback Callback function to call whenever this
427
route is triggered. If specified as a string, the named function will be
428
called on this controller instance.
429
@param {Object} callback.req Request object containing information about
430
the request. It contains the following properties.
431
@param {Array|Object} callback.req.params Captured parameters matched by
432
the route path specification. If a string path was used and contained
433
named parameters, then this will be a key/value hash mapping parameter
434
names to their matched values. If a regex path was used, this will be
435
an array of subpattern matches starting at index 0 for the full match,
436
then 1 for the first subpattern match, and so on.
437
@param {String} callback.req.path The current URL path.
438
@param {Object} callback.req.query Query hash representing the URL query
439
string, if any. Parameter names are keys, and are mapped to parameter
441
@param {Function} callback.next Callback to pass control to the next
442
matching route. If you don't call this function, then no further route
443
handlers will be executed, even if there are more that match. If you do
444
call this function, then the next matching route handler (if any) will
445
be called, and will receive the same `req` object that was passed to
446
this route (so you can use the request object to pass data along to
450
route: function (path, callback) {
457
regex : this._getRegex(path, keys)
464
Saves a new browser history entry and dispatches to the first matching route
467
Behind the scenes, this method uses HTML5 `pushState()` in browsers that
468
support it (or the location hash in older browsers and IE) to change the
469
URL and create a history entry.
471
The specified URL must share the same origin (i.e., protocol, host, and
472
port) as the current page, or an error will occur.
475
// Starting URL: http://example.com/
477
controller.save('/path/');
478
// New URL: http://example.com/path/
480
controller.save('/path?foo=bar');
481
// New URL: http://example.com/path?foo=bar
483
controller.save('/');
484
// New URL: http://example.com/
487
@param {String} [url] URL to set. Should be a relative URL. If this
488
controller's `root` property is set, this URL must be relative to the
489
root URL. If no URL is specified, the page's current URL will be used.
493
save: function (url) {
494
return this._queue(url);
498
Upgrades a hash-based URL to an HTML5 URL if necessary. In non-HTML5
499
browsers, this method is a noop.
502
@return {Boolean} `true` if the URL was upgraded, `false` otherwise.
504
upgrade: html5 ? function () {
505
var hash = this._getHashPath();
507
if (hash && hash.charAt(0) === '/') {
508
// This is an HTML5 browser and we have a hash-based path in the
509
// URL, so we need to upgrade the URL to a non-hash URL. This
510
// will trigger a `history:change` event, which will in turn
511
// trigger a dispatch.
512
this.once(EVT_READY, function () {
520
} : function () { return false; },
522
// -- Protected Methods ----------------------------------------------------
525
Wrapper around `decodeURIComponent` that also converts `+` chars into
529
@param {String} string String to decode.
530
@return {String} Decoded string.
533
_decode: function (string) {
534
return decodeURIComponent(string.replace(/\+/g, ' '));
538
Shifts the topmost `_save()` call off the queue and executes it. Does
539
nothing if the queue is empty.
546
_dequeue: function () {
550
// If window.onload hasn't yet fired, wait until it has before
551
// dequeueing. This will ensure that we don't call pushState() before an
552
// initial popstate event has fired.
553
if (!YUI.Env.windowLoaded) {
554
Y.once('load', function () {
561
fn = saveQueue.shift();
562
return fn ? fn() : this;
566
Dispatches to the first route handler that matches the specified _path_.
568
If called before the `ready` event has fired, the dispatch will be aborted.
569
This ensures normalized behavior between Chrome (which fires a `popstate`
570
event on every pageview) and other browsers (which do not).
573
@param {String} path URL path.
577
_dispatch: function (path) {
579
routes = self.match(path),
582
self._dispatching = self._dispatched = true;
584
if (!routes || !routes.length) {
585
self._dispatching = false;
589
req = self._getRequest(path);
591
req.next = function (err) {
592
var callback, matches, route;
596
} else if ((route = routes.shift())) {
597
matches = route.regex.exec(path);
598
callback = typeof route.callback === 'string' ?
599
self[route.callback] : route.callback;
601
// Use named keys for parameter names if the route path contains
602
// named keys. Otherwise, use numerical match indices.
603
if (matches.length === route.keys.length + 1) {
604
req.params = YArray.hash(route.keys, matches.slice(1));
606
req.params = matches.concat();
609
callback.call(self, req, req.next);
615
self._dispatching = false;
616
return self._dequeue();
620
Gets the current path from the location hash, or an empty string if the
624
@return {String} Current hash path, or an empty string if the hash is empty.
627
_getHashPath: function () {
628
return HistoryHash.getHash().replace(this._regexUrlQuery, '');
632
Gets the current route path.
635
@return {String} Current route path.
638
_getPath: html5 ? function () {
639
return this.removeRoot(location.pathname);
641
return this._getHashPath() || this.removeRoot(location.pathname);
645
Gets the current route query string.
648
@return {String} Current route query string.
651
_getQuery: html5 ? function () {
652
return location.search.substring(1);
654
var hash = HistoryHash.getHash(),
655
matches = hash.match(this._regexUrlQuery);
657
return hash && matches ? matches[1] : location.search.substring(1);
661
Creates a regular expression from the given route specification. If _path_
662
is already a regex, it will be returned unmodified.
665
@param {String|RegExp} path Route path specification.
666
@param {Array} keys Array reference to which route parameter names will be
668
@return {RegExp} Route regex.
671
_getRegex: function (path, keys) {
672
if (path instanceof RegExp) {
676
path = path.replace(this._regexPathParam, function (match, operator, key) {
678
return operator === '*' ? '(.*?)' : '([^/]*)';
681
return new RegExp('^' + path + '$');
685
Gets a request object that can be passed to a route handler.
688
@param {String} path Current path being dispatched.
689
@return {Object} Request object.
692
_getRequest: function (path) {
695
query: this._parseQuery(this._getQuery())
700
Joins the `root` URL to the specified _url_, normalizing leading/trailing
704
controller.root = '/foo'
705
controller._joinURL('bar'); // => '/foo/bar'
706
controller._joinURL('/bar'); // => '/foo/bar'
708
controller.root = '/foo/'
709
controller._joinURL('bar'); // => '/foo/bar'
710
controller._joinURL('/bar'); // => '/foo/bar'
713
@param {String} url URL to append to the `root` URL.
714
@return {String} Joined URL.
717
_joinURL: function (url) {
718
var root = this.root;
720
if (url.charAt(0) === '/') {
721
url = url.substring(1);
724
return root && root.charAt(root.length - 1) === '/' ?
730
Parses a URL query string into a key/value hash. If `Y.QueryString.parse` is
731
available, this method will be an alias to that.
734
@param {String} query Query string to parse.
735
@return {Object} Hash of key/value pairs for query parameters.
738
_parseQuery: QS && QS.parse ? QS.parse : function (query) {
739
var decode = this._decode,
740
params = query.split('&'),
746
for (; i < len; ++i) {
747
param = params[i].split('=');
750
result[decode(param[0])] = decode(param[1] || '');
758
Queues up a `_save()` call to run after all previously-queued calls have
761
This is necessary because if we make multiple `_save()` calls before the
762
first call gets dispatched, then both calls will dispatch to the last call's
765
All arguments passed to `_queue()` will be passed on to `_save()` when the
766
queued function is executed.
773
_queue: function () {
774
var args = arguments,
777
saveQueue.push(function () {
779
if (Y.UA.ios && Y.UA.ios < 5) {
780
// iOS <5 has buggy HTML5 history support, and needs to be
782
self._save.apply(self, args);
784
// Wrapped in a timeout to ensure that _save() calls are
785
// always processed asynchronously. This ensures consistency
786
// between HTML5- and hash-based history.
787
setTimeout(function () {
788
self._save.apply(self, args);
792
self._dispatching = true; // otherwise we'll dequeue too quickly
793
self._save.apply(self, args);
799
return !this._dispatching ? this._dequeue() : this;
803
Saves a history entry using either `pushState()` or the location hash.
806
@param {String} [url] URL for the history entry.
807
@param {Boolean} [replace=false] If `true`, the current history entry will
808
be replaced instead of a new one being added.
812
_save: html5 ? function (url, replace) {
813
// Force _ready to true to ensure that the history change is handled
814
// even if _save is called before the `ready` event fires.
817
this._history[replace ? 'replace' : 'add'](null, {
818
url: typeof url === 'string' ? this._joinURL(url) : url
822
} : function (url, replace) {
825
if (typeof url === 'string' && url.charAt(0) !== '/') {
829
HistoryHash[replace ? 'replaceHash' : 'setHash'](url);
833
// -- Protected Event Handlers ---------------------------------------------
836
Handles `history:change` and `hashchange` events.
838
@method _afterHistoryChange
839
@param {EventFacade} e
842
_afterHistoryChange: function (e) {
846
self._dispatch(self._getPath());
850
// -- Default Event Handlers -----------------------------------------------
853
Default handler for the `ready` event.
856
@param {EventFacade} e
859
_defReadyFn: function (e) {
867
}, '3.4.1' ,{optional:['querystring-parse'], requires:['array-extras', 'base-build', 'history']});