Dynamic persistent tabbed content for Angular.JS.
Useful for single-page interactive web apps which allow the user to organise content by tabs, e.g. a text editor.
Tabs...
-
each have a unique content element which is shown only while the tab is in 'focus'.
-
are 'typed'. A tab type specifies the template and (optionally) controller used to compile the tab's content element. By default, tabs get new scopes inheriting from their parent scope.
-
are arranged by 'areas'. A tab area represents a flat array of tabs, of which only one can be focused at a time.
-
can be moved to different areas, or to a different index within the same area.
-
can be parameterised by providing an
options
object when they are created. -
provide a simple events system for communicating with their instantiators.
-
are optionally persisted on a per-area basis.
You'll need to run npm install
and then sudo npm install grunt-cli -g
if you don't have grunt installed already.
Then it's just grunt
, or grunt && grunt watch
if developing.
Load tabangular.js
or tabangular.min.js
into your page
<script type="text/javascript" src="path/to/tabangular.js"></script>
Include 'tabangular'
in your module dependencies
var textEditor = angular.module('textEditor', ['tabangular']);
Register named tab types at config time.
textEditor.config(function (TabsProvider) {
TabsProvider.registerTabType("editorTab", {
// you can supply a url from which to fetch the template
templateUrl: 'templates/editor.html',
// controllers are resolved as usual
controller: 'EditorCtrl'
});
TabsProvider.registerTabType("configTab", {
// templateUrl looks in the $templateCache so you can supply the id of a
// text/ng-template script element
templateUrl: 'config-template',
controller: function ($scope, Tab) { ... }
});
TabsProvider.registerTabType("hello", {
// or just a string
template: '<div>Hello world!</div>',
// set scope to false to avoid creating a new scope for the tab
scope: false
// don't supply a controller if the tab doesn't need a controller
});
});
Additionally, or alternatively, you can automatically resolve named tab types by providing a 'fetcher' function.
textEditor.config(function (TabsProvider) {
TabsProvider.typeFetcherFactory(function($http) {
return function (deferred, typeID) {
// deferred is a $q Deferred object which must be resolved with the tab type
// typeID is the string ID of the tab type to resolve
var templateURL = "tabs/" + typeID + "/template.html";
// in this example, we load the controller from the server.
var controllerURL = "tabs/" + typeID + "/controller.js";
$http.get(controllerURL).success(function (result) {
var ctrl = eval("(" + result.data + ")");
// now the tab type can be resolved
deferred.resolve({
templateURL: templateURL,
controller: ctrl
});
});
};
});
});
Create a tab area with the Tabs
service
textEditor.controller('WindowCtrl', function (Tabs) {
$scope.docs = Tabs.newArea();
Then define functions and stuff for manipulating docs.
$scope.openDocument = function (filename) {
$scope.docs.open('editorTab', {filename: filename});
};
$scope.newDocument = function () {
$scope.docs.open('editorTab', {filename: 'untitled'})
};
$scope.configTab = null;
$scope.config = function () {
if ($scope.configTab) {
// we only want one config tab open at a time
$scope.configTab.focus();
} else {
$scope.configTab = $scope.docs.open('configTab'); // options are optional
$scope.configTab.on("closed", function () {
delete $scope.configTab;
});
}
}
});
It is up to you to specify the HTML/css for the tabs themselves, along with the dom node in which the content elements should be placed.
<body controller='WindowCtrl'>
<!-- tabs go here -->
<ul class='tabs-list'>
<li ng-repeat='tab in docs.list()' ng-class="{active: tab.focused}">
<a href="" ng-click="tab.focus()">{{tab.options.filename}}</a>
<span ng-show="tab.loading">...</span>
<a href="" ng-click="tab.close()">×</a>
</li>
<li class='new-document'>
<a href="" ng-click="newDocument()">new +</a>
</li>
</ul>
<div class="tabs-content" tab-content="docs">
<!-- the tab-content value should evaluate to the relevant TabArea object -->
</div>
<button ng-click="config()">Configure the Editor</button>
</body>
Tab controllers can defer the 'loaded' event such that they don't get shown until they want to be shown.
function EditorCtrl ($scope, $http, Tab) {
if (Tab.options.filename !== 'untitled') {
Tab.deferLoading();
$http.get(Tab.options.filename).then(function (response) {
$scope.text = response.data;
Tab.options.savedText = $scope.text;
Tab.doneLoading();
});
} else {
$scope.text = "";
Tab.options.savedText = $scope.text;
}
They can also intercept the 'close'
event, which gets fired when Tab.close()
is called, but not when Tab.close(true)
is called.
Tab.disableAutoClose();
Tab.on("close", function () {
if ($scope.text === Tab.options.savedText
|| window.confirm("You have unsaved changes. Are you sure?")) {
Tab.close(true); // force close
}
});
$scope.save = function () {
Tab.options.savedText = $scope.text;
$http.post(whatever...);
};
}
When providing a controller in the tab type, content templates should not use ng-controller
, since the controller needs to be injected with the Tab
pseudo-service by tabangular.
<!-- templates/editor.html -->
<textarea ng-model='text'></textarea>
<button ng-click='save()'>Save</button>
However, if the tab doesn't need to know the fact that it's in a tab, ng-controller
is fine.
<!-- assume config tab type is simply {templateID: 'config.html'} -->
<script type='text/javascript'>
function ConfigCtrl ($scope) {
... configuration logic
}
</script>
<script type='text/ng-template' id='config.html'>
<div ng-controller='ConfigCtrl'>
... configuration controls
</div>
</script>
Persistence may be achieved by passing options to Tabs.newArea
. There is a default localStorage
persistence option which can be enabled by giving a string id to the tab area
$scope.docs = Tabs.newArea({id: "myEditor"});
Alternatively, provide persist
and getExisting
functions to e.g. save state to a server.
$scope.docs = Tabs.newArea({
// persist is called whenever tabs are opened, closed, moved, and focused,
// so if bandwidth is an issue for you, it is probably best to wrap the
// post/put request somehow.
persist: function (state) {
// the state parameter is a string.
$http.post('/tabs-state', {state: state});
},
// getExisting is called only once
getExisting: function (cb) {
$http.get('/tabs-state').success(function (result) {
cb(result.data.state);
});
}
});
A simple, lightweight events system. Extended by Tab
and TabArea
, cannot be instantiated directly.
tab.on('foo', function () {
console.log("i am foo");
});
tab.trigger('foo');
// => i am foo
tab.one('bar', function () {
console.log("bar happens only once");
});
tab.trigger('bar');
// => bar happens only once
tab.trigger('bar');
// nothing happens
tab.on('hello', function (name) {
console.log("Hello, " + name + "!");
});
tab.trigger('hello', 'Steve');
// => Hello, Steve!
// callbacks have their context set to the relevant object
function hello () {
console.log("Hello, " + this.options.name + "!");
}
tab1.on('hello', hello);
tab2.on('hello', hello);
tab1.options.name = "John";
tab2.options.name = "Wilbur";
tab1.trigger('hello');
// => Hello, John!
tab2.trigger('hello');
// => Hello, Wilbur!
Binds callback
as a handler for event
. Returns a function which, when invoked, unbinds the callback.
##### `one` :: `(event : string, callback : function) : function`
As Evented.on
but unbinds the callback automatically after being invoked for the first time.
##### `trigger` :: `(event : string [, data : object]) : void`
Fires an event
event, passing data
as the first parameter to any bound callbacks.
TabsProvider
can be used to configure the Tabs
service.
Registers a tab type. id
should be a unique string id, options
should be an object with some combination of the following:
-
scope
::boolean
Specifies whether or not to define a new scope for tabs of this type. defaults to
true
-
templateUrl
::string
Specifies a url from which to load a template, or the id of a template already in the dom (e.g. 'foo.html' for the template
<script type='text/ng-template' id='foo.html'>...</script>'
) -
template
::string
Specifies the template to use in the tab. Takes precedence over
templateUrl
-
controller
::function | string
Specifies the controller to call against the scope. Should be a function or a string denoting the controller to use (see $controller).
Examples:
module.config(function (TabsProvider) {
TabsProvider.registerTabType("myTabType", {
templateUrl: "templates/my-tab-type.html",
controller: "MyTabCtrl"
});
TabsProvider.registerTabType("myOtherTabType", {
template: "<span>Hello {{name}}!</span>",
controller: function ($scope, Tab) { $scope.name = Tab.options.name; }
});
});
##### `typeFetcherFactory` :: `(factory : function) : void`
Registers a factory function for a tab type fetcher. The tab type fetcher resolves named tab types dynamically, if they haven't been previously registered. The factory function is invoked using Angular's dependency injector, to allow the use of services such at $http
when resolving tab types. It should return the fetcher function which has the signature (deferred : Deferred, typeID : string) : void
. The fetcher function is responsible for resolving the deferred object with the relevant tab type (see registerTabType
for the type options), or rejecting it when no such type can be found. See $q for the Deferred
api.
Example which finds a template in Angular's template cache:
module.config(function (TabsProvider) {
TabsProvider.typeFetcherFactory(function ($templateCache) {
return function (dfd, id) {
var template = $templateCache.get(id + ".html");
if (template) {
dfd.resolve({
template: template
scope: false
});
} else {
dfd.reject("Couldn't find template: " + id + ".html");
}
};
});
});
The 'tabs' service allows the creation of new tab areas.
Creates a new tab area. options
should be an object with some combination of the following:
-
id
::string
Activates the default localStorage persistence mechanism. Should be unique on a per-tab-area basis.
-
persist
::function (state : string) : void
Takes a string representation of the tab area's current state and puts it somewhere for safe keeping. Called when tabs are opened, closed, and focused. Also called upon the window's
beforeunload
event. By default it is a function which, ifid
has been defined, stores the state inlocalStorage['tabangular:'+id]
-
getExisting
::function (cb : function (state : string) : void) : void
Takes a callback which should be invoked with the stored state string at some point (or null if no state stored). Called once upon tab area construction. By default it is a function which, if
id
has been defined, looks up the state inlocalStorage['tabangular:'+id]
-
transformOptions
::function (options : object) : object
Takes the in-use version of a tab's options object and transforms it such that it is JSON stringifiable. By default it is the identity function.
-
parseOptions
::function (options : object) : object
The reverse of
transformOptions
. Takes the deserialised version of a tab's options object and transforms it such that it is identical to how it was before being serialised. By default it is the identity function.
The TabArea
class represents an ordered grouping of tabs and provides methods for creating new tabs. A tab area may have only one tab focused at one point in time. TabArea instances are created using the Tabs.newArea
method.
Loads and returns a new tab. type
should be a named tab type or an anonymous tab type object (see registerTabType for details).
options
can be anything and is attached to the returned Tab object such that load(foo, bar).options === bar
.
##### `open` :: `(type : string | object [, options : object]) : Tab`
Convenience method. As TabArea.load
but calls Tab.focus
before returning the tab.
##### `list` :: `() : [Tab]`
Returns an array of the tabs currently in this area. For performance reasons, it currently returns the internally-used array which should not be modified.
##### `handleExisting` :: `([cb : function (tab : object) : bool]) : void`
Triggers the reloading of tabs from persistent storage.
If called without arguments, simply reloads all tabs from storage.
If given the cb
parameter, calls cb
on each of the stored tab objects which have the structure:
-
type
::string | object
The tab type, as passed into
TabArea.load
orTabArea.open
when the tab was created. -
focused
::bool
Whether or not the tab was focused when the area was persisted.
-
options
::object
The tab options, as gleaned from
Tab.options
when the area was persisted.
If cb
returns true
, the tab is automatically reloaded. Hence area.handleExisting()
is shorthand for area.handleExisting(function () { return true; })
Example:
// Some tab types might require event listeners, so you can use `handleExisting`
// to reload the tabs and attach the event listeners.
area.handleExisting(function (tab) {
switch (tab.type) {
case "foo":
var fooID = tab.options.id;
var foo = area.load("foo", tab.options);
foo.on("foo_event", function () {
$http.post("react/to/foo_event", {fooID: fooID});
});
tab.focused && foo.focus();
return false;
case "blah":
.
.
.
}
})
-
loaded
Triggered when the tab area is connected to it's content element via the
tabContent
directive.Note that waiting on the
loaded
event to callTabArea
methods is not required, since actions not ready to be undertaken are automatically put in a queue and executed later at the appropriate time.
Tab
instances are created using the TabArea.load
or TabArea.open
methods.
When a tab type has a controller specified, it may be injected with it's own Tab instance.
e.g.
function MyTabCtrl (Tab) {
// acquire resources
Tab.on("closed", function () {
// release resources
});
}
Display's the tab's content element, sets Tab.focused
to true, and triggers the focused
event if/when the tab has finished loading. Returns the tab in question.
##### `close` :: `([silent : bool]) : Tab`
When called with Tab.autoClose
set to false and no arguments or silent
set to false
, simply triggers the close
event.
When called with silent
set to true
, closes the tab. This involves:
- Removing the tab's content element from the DOM.
- Destroying the tab's scope (if it has its own)
- Triggering the
closed
event. - Focusing the previously focused tab, if such a tab exists.
Returns the tab in question.
##### `move` :: `(toArea : TabArea, idx : integer) : Tab`
Moves the tab to toArea
and positions it at the idx
th place.
Returns the tab in question.
##### `deferLoading` :: `() : Tab`
When called by the tab's controller, or before the controller has been executed, deferLoading
prevents the automatic triggering of the loaded
event.
This is useful if it doesn't make sense to show the tab's content before its controller has had a chance to, e.g. asynchronously fetch some data.
Returns the tab in question.
##### `doneLoading` :: `() : Tab`
Triggers the loaded
event and sets Tab.loading
to false.
Returns the tab in question.
##### `enableAutoClose` :: `() : Tab`
Sets Tab.autoClose
to true and returns the tab in question.
##### `disableAutoClose` :: `() : Tab`
Sets Tab.autoClose
to false and returns the tab in question.
-
loaded
If
Tab.deferLoading
was called, theloaded
event is triggered whenTab.doneLoading
is called. Otherwise it is triggered when the tab's content element has been placed in the DOM. -
dom_ready
Triggered when the tab's content element has been placed in the DOM. This is useful if
Tab.deferLoading
was called. -
focused
Triggered when the tab's content element is shown.
-
close
Triggered when
Tab.close()
is called ifTab.autoClose
is set to false. -
closed
Triggered when
Tab.close(true)
is called, or whenTab.close()
is called ifTab.autoClose
is set to true.
As passed into TabArea.load
or TabArea.open
.
Note that if it is an object and contains non-json-serializable data (e.g. a function), the persistence mechanism will not work. See Tabs.newArea
.
As passed into TabArea.load
or TabArea.open
.
Note that if it is or contains non-json-serializable data (e.g. a function), the persistence mechanism will not work without specifying custom parseOptions
and transformOptions
functions. See Tabs.newArea
.
When set to true
, causes Tab.close()
to be equivalent to Tab.close(true)
.
true
if the tab's content element is visible, false
otherwise. Should not be manually set.
true
if the tab has been closed, false
otherwise. Should not be manually set.
true
if the tab has not finished loading, false
otherwise. Should not be manually set.
Restricted to an attribute which should evaluate to a tab area. Registers the element with the TabArea so it knows where to put content elements.
Example:
<div tab-content="myTabArea"></div>
Copyright (c) 2014 David Sheldrick. All rights reserved.
Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.