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