diff --git a/docs/app/src/.jshintrc b/docs/app/src/.jshintrc
new file mode 100644
index 000000000000..b31f0bb90ef1
--- /dev/null
+++ b/docs/app/src/.jshintrc
@@ -0,0 +1,6 @@
+{
+ "browser": true,
+ "globals": {
+ "angular": false
+ }
+}
diff --git a/docs/app/src/app.js b/docs/app/src/app.js
index af94d44a2b8d..71b4060a7494 100644
--- a/docs/app/src/app.js
+++ b/docs/app/src/app.js
@@ -14,10 +14,16 @@ angular.module('docsApp', [
'tutorials',
'versions',
'bootstrap',
- 'ui.bootstrap.dropdown'
+ 'ui.bootstrap.dropdown',
+ 'heading-offset'
])
.config(['$locationProvider', function($locationProvider) {
$locationProvider.html5Mode(true).hashPrefix('!');
+}])
+
+.run(['headingOffset', function(headingOffset) {
+ // Provide the initial offset for heading anchors
+ headingOffset.value = '120px';
}]);
diff --git a/docs/app/src/heading-offset.js b/docs/app/src/heading-offset.js
new file mode 100644
index 000000000000..48bf86d78660
--- /dev/null
+++ b/docs/app/src/heading-offset.js
@@ -0,0 +1,114 @@
+(function() {
+
+
+function createPostLinkFn(element, attrs, headingOffset, $compile) {
+
+ return function postLink(scope) {
+
+ // Create an anchor for this heading
+ var anchor = $compile('
')(scope);
+
+ // Move the id from the original heading element to the div
+ anchor.attr('id', attrs.id);
+ element.removeAttr('id');
+
+ // Insert this anchor as the first child of the heading
+ element.prepend(anchor);
+
+ var updateStyle = function(offset) {
+ offset = offset || headingOffset.value;
+ anchor.css('margin-top', '-'+offset);
+ anchor.css('height', offset);
+ };
+
+ // Work out whether we are using a specific offset or getting the global default
+ if ( angular.isDefined(element.attr(attrs.$attr.ngOffset)) ) {
+ attrs.$observe('ngOffset', updateStyle);
+ } else {
+ scope.$watch(function() { return headingOffset.value; }, updateStyle);
+ }
+ };
+
+}
+
+
+angular.module('heading-offset', [])
+
+/**
+ * @ngdoc service
+ * @description
+ * The offset to use for heading anchors if not specific offset is given using ngOffset.
+ * You can set this in a `run` block or update it dynamically based on changing sizes of
+ * static header.
+ */
+.value('headingOffset', {value: '0px' })
+
+/**
+ * @ngdoc directive
+ * @name id / ngOffset
+ * @description
+ * A directive that matches id attributes on headings (h1, h2, etc) and anchors (a).
+ * When matched this directive injects a new element that acts as a buffer to ensure that
+ * when we navigate to the element by id (using a hash on the url) the element appears far
+ * enough down the page
+ *
+ * You can specify the offset on an element by element basis using the `ng-offset` attribute.
+ * The attribute can be a static value:
+ *
+ * ```html
+ * Some Heading
+ * ```
+ *
+ * or it can be interpolated:
+ *
+ * ```html
+ * Some Heading
+ * ```
+ *
+ * If no value is given for `ng-offset` then the directive will use the value given by the
+ * `headingOffset` service. You can set the value of this at runtime:
+ *
+ * ```html
+ * Some Heading
+ * ```
+ *
+ * ```js
+ * appModule.run(['headingOffset', function(headingOffset) {
+ * // Provide the initial offset for heading anchors
+ * headingOffset.value = '120px';
+ * }]);
+ * ```
+ *
+ * Be aware that this moves the id to a span below the original element which can play havoc with you
+ * CSS if you are relying on ids in your styles (which you shouldn't).
+ *
+ */
+.directive('id', ['headingOffset', '$compile', function(headingOffset, $compile) {
+
+ var ELEMENTS_TO_MATCH = /^(h\d+|a)$/i;
+
+ return {
+ restrict: 'A',
+ compile: function(element, attrs) {
+
+ // This directive only looks for headings and anchors with id attributes
+ // It doesn't handle the case where ngOffset is defined as that is handled by the other
+ // directive below
+ if ( ELEMENTS_TO_MATCH.test(element[0].nodeName) && angular.isUndefined(attrs.ngOffset)) {
+ return createPostLinkFn(element, attrs, headingOffset, $compile);
+ }
+ }
+ };
+}])
+
+.directive('ngOffset', ['headingOffset', '$compile', function(headingOffset, $compile) {
+ return {
+ restrict: 'A',
+ compile: function(element, attrs) {
+ // This directive handles the case where ngOffset is defined as an attribute
+ return createPostLinkFn(element, attrs, headingOffset, $compile);
+ }
+ };
+}]);
+
+})();
\ No newline at end of file
diff --git a/docs/app/test/.jshintrc b/docs/app/test/.jshintrc
new file mode 100644
index 000000000000..62cdd7f6a764
--- /dev/null
+++ b/docs/app/test/.jshintrc
@@ -0,0 +1,11 @@
+{
+ "browser": true,
+ "globals": {
+ "angular": false,
+ "module": false,
+ "inject": false,
+ "describe": false,
+ "it": false,
+ "beforeEach": false
+ }
+}
diff --git a/docs/app/test/heading-offsetSpec.js b/docs/app/test/heading-offsetSpec.js
new file mode 100644
index 000000000000..4d2b2c186fc5
--- /dev/null
+++ b/docs/app/test/heading-offsetSpec.js
@@ -0,0 +1,88 @@
+describe("heading-offset", function() {
+ describe("id directive", function() {
+
+ function check(element, child, offset, id) {
+ expect(child[0].nodeName).toMatch(/div/i);
+ expect(element.attr('id')).toBeUndefined();
+ expect(child.attr('id')).toEqual(id);
+ expect(child.css('margin-top')).toEqual('-'+offset);
+ expect(child.css('height')).toEqual(offset);
+ }
+
+ var $compile, $scope, headingOffset;
+
+ beforeEach(module('heading-offset'));
+
+ beforeEach(inject(function($rootScope, _$compile_, _headingOffset_) {
+ $scope = $rootScope;
+ $compile = _$compile_;
+ headingOffset = _headingOffset_;
+ headingOffset.value = '40px';
+ }));
+
+ it("should inject a child into headings with ids, while watching the headerOffset service value", function() {
+ var element = $compile('')($scope);
+ var child = element.children();
+ $scope.$digest();
+
+ check(element, child, '40px', 'some-id');
+
+ headingOffset.value = '100px';
+ $scope.$digest();
+
+ check(element, child, '100px', 'some-id');
+ });
+
+ it("should inject a child into anchors with ids, while watching the headerOffset service value", function() {
+ var element = $compile('')($scope);
+ var child = element.children();
+ $scope.$digest();
+
+ check(element, child, '40px', 'some-id');
+
+ headingOffset.value = '100px';
+ $scope.$digest();
+
+ check(element, child, '100px', 'some-id');
+ });
+
+
+ it("should inject a child into heading elements, while observing the ngOffset attribute", function() {
+ var element = $compile('')($scope);
+ var child = element.children();
+ $scope.$digest();
+
+ check(element, child, '40px', 'some-id');
+
+ $scope.x = '50px';
+ $scope.$digest();
+
+ check(element, child, '50px', 'some-id');
+
+ $scope.x = null;
+ $scope.$digest();
+
+ check(element, child, '40px', 'some-id');
+ });
+
+ it("should inject a child into non-heading elements, while observing the ngOffset attribute", function() {
+ var element = $compile('')($scope);
+ var child = element.children();
+
+ $scope.$digest();
+
+ check(element, child, '40px', 'some-id');
+
+ $scope.x = '50px';
+ $scope.$digest();
+
+ check(element, child, '50px', 'some-id');
+
+ $scope.x = null;
+ $scope.$digest();
+
+ check(element, child, '40px', 'some-id');
+ });
+
+ });
+});
\ No newline at end of file