diff --git a/examples/demo-append-to-body.html b/examples/demo-append-to-body.html new file mode 100644 index 000000000..951d96bb9 --- /dev/null +++ b/examples/demo-append-to-body.html @@ -0,0 +1,130 @@ +<!DOCTYPE html> +<html lang="en" ng-app="demo"> +<head> + <meta charset="utf-8"> + <title>AngularJS ui-select</title> + + <!-- + IE8 support, see AngularJS Internet Explorer Compatibility http://docs.angularjs.org/guide/ie + For Firefox 3.6, you will also need to include jQuery and ECMAScript 5 shim + --> + <!--[if lt IE 9]> + <script src="http://ajax.googleapis.com/ajax/libs/jquery/1.11.0/jquery.js"></script> + <script src="http://cdnjs.cloudflare.com/ajax/libs/es5-shim/2.2.0/es5-shim.js"></script> + <script> + document.createElement('ui-select'); + document.createElement('ui-select-match'); + document.createElement('ui-select-choices'); + </script> + <![endif]--> + + <script src="http://ajax.googleapis.com/ajax/libs/angularjs/1.2.18/angular.js"></script> + <script src="http://ajax.googleapis.com/ajax/libs/angularjs/1.2.18/angular-sanitize.js"></script> + <link rel="stylesheet" href="http://netdna.bootstrapcdn.com/bootstrap/3.1.1/css/bootstrap.css"> + + <!-- ui-select files --> + <script src="../dist/select.js"></script> + <link rel="stylesheet" href="../dist/select.css"> + + <script src="demo.js"></script> + + <!-- Select2 theme --> + <link rel="stylesheet" href="http://cdnjs.cloudflare.com/ajax/libs/select2/3.4.5/select2.css"> + + <!-- + Selectize theme + Less versions are available at https://github.com/brianreavis/selectize.js/tree/master/dist/less + --> + <link rel="stylesheet" href="http://cdnjs.cloudflare.com/ajax/libs/selectize.js/0.8.5/css/selectize.default.css"> + <!-- <link rel="stylesheet" href="http://cdnjs.cloudflare.com/ajax/libs/selectize.js/0.8.5/css/selectize.bootstrap2.css"> --> + <!-- <link rel="stylesheet" href="http://cdnjs.cloudflare.com/ajax/libs/selectize.js/0.8.5/css/selectize.bootstrap3.css"> --> + + <style> + body { + padding: 15px; + } + + .select2 > .select2-choice.ui-select-match { + /* Because of the inclusion of Bootstrap */ + height: 29px; + } + + .selectize-control > .selectize-dropdown { + top: 36px; + } + + /* Some additional styling to demonstrate that append-to-body helps achieve the proper z-index layering. */ + .select-box { + background: #fff; + position: relative; + z-index: 1; + } + .alert-info.positioned { + margin-top: 1em; + position: relative; + z-index: 10000; // The select2 dropdown has a z-index of 9999 + } + </style> +</head> + +<body ng-controller="DemoCtrl"> + <script src="demo.js"></script> + + <button class="btn btn-default btn-xs" ng-click="enable()">Enable ui-select</button> + <button class="btn btn-default btn-xs" ng-click="disable()">Disable ui-select</button> + <button class="btn btn-default btn-xs" ng-click="appendToBodyDemo.startToggleTimer()" + ng-disabled="appendToBodyDemo.remainingTime"> + {{ appendToBodyDemo.remainingTime ? 'Toggling in ' + (appendToBodyDemo.remainingTime / 1000) + ' seconds' : 'Toggle ui-select presence' }} + </button> + <button class="btn btn-default btn-xs" ng-click="clear()">Clear ng-model</button> + + <div class="select-box" ng-show="appendToBodyDemo.present"> + <h3>Bootstrap theme</h3> + <p>Selected: {{address.selected.formatted_address}}</p> + <ui-select ng-model="address.selected" + theme="bootstrap" + ng-disabled="disabled" + reset-search-input="false" + style="width: 300px;" + title="Choose an address" + append-to-body="true"> + <ui-select-match placeholder="Enter an address...">{{$select.selected.formatted_address}}</ui-select-match> + <ui-select-choices repeat="address in addresses track by $index" + refresh="refreshAddresses($select.search)" + refresh-delay="0"> + <div ng-bind-html="address.formatted_address | highlight: $select.search"></div> + </ui-select-choices> + </ui-select> + <p class="alert alert-info positioned">The select dropdown menu should be displayed above this element.</p> + </div> + + <div class="select-box" ng-if="appendToBodyDemo.present"> + <h3>Select2 theme</h3> + <p>Selected: {{person.selected}}</p> + <ui-select ng-model="person.selected" theme="select2" ng-disabled="disabled" style="min-width: 300px;" title="Choose a person" append-to-body="true"> + <ui-select-match placeholder="Select a person in the list or search his name/age...">{{$select.selected.name}}</ui-select-match> + <ui-select-choices repeat="person in people | propsFilter: {name: $select.search, age: $select.search}"> + <div ng-bind-html="person.name | highlight: $select.search"></div> + <small> + email: {{person.email}} + age: <span ng-bind-html="''+person.age | highlight: $select.search"></span> + </small> + </ui-select-choices> + </ui-select> + <p class="alert alert-info positioned">The select dropdown menu should be displayed above this element.</p> + </div> + + <div class="select-box" ng-if="appendToBodyDemo.present"> + <h3>Selectize theme</h3> + <p>Selected: {{country.selected}}</p> + <ui-select ng-model="country.selected" theme="selectize" ng-disabled="disabled" style="width: 300px;" title="Choose a country" append-to-body="true"> + <ui-select-match placeholder="Select or search a country in the list...">{{$select.selected.name}}</ui-select-match> + <ui-select-choices repeat="country in countries | filter: $select.search"> + <span ng-bind-html="country.name | highlight: $select.search"></span> + <small ng-bind-html="country.code | highlight: $select.search"></small> + </ui-select-choices> + </ui-select> + <p class="alert alert-info positioned">The select dropdown menu should be displayed above this element.</p> + </div> +</body> +</html> diff --git a/examples/demo.js b/examples/demo.js index 623385804..bbdc1c322 100644 --- a/examples/demo.js +++ b/examples/demo.js @@ -39,7 +39,7 @@ app.filter('propsFilter', function() { }; }); -app.controller('DemoCtrl', function($scope, $http, $timeout) { +app.controller('DemoCtrl', function($scope, $http, $timeout, $interval) { $scope.disabled = undefined; $scope.searchEnabled = undefined; @@ -147,6 +147,23 @@ app.controller('DemoCtrl', function($scope, $http, $timeout) { $scope.multipleDemo.selectedPeopleWithGroupBy = [$scope.people[8], $scope.people[6]]; $scope.multipleDemo.selectedPeopleSimple = ['samantha@email.com','wladimir@email.com']; + $scope.appendToBodyDemo = { + remainingToggleTime: 0, + present: true, + startToggleTimer: function() { + var scope = $scope.appendToBodyDemo; + var promise = $interval(function() { + if (scope.remainingTime < 1000) { + $interval.cancel(promise); + scope.present = !scope.present; + scope.remainingTime = 0; + } else { + scope.remainingTime -= 1000; + } + }, 1000); + scope.remainingTime = 3000; + } + }; $scope.address = {}; $scope.refreshAddresses = function(address) { diff --git a/src/common.css b/src/common.css index 944fa6c36..6939207ad 100644 --- a/src/common.css +++ b/src/common.css @@ -36,6 +36,10 @@ display:none; } +body > .select2-container { + z-index: 9999; /* The z-index Select2 applies to the select2-drop */ +} + /* Selectize theme */ /* Helper class to show styles when focus */ @@ -116,6 +120,10 @@ margin-top: -1px; } +body > .ui-select-bootstrap { + z-index: 1000; /* Standard Bootstrap dropdown z-index */ +} + .ui-select-multiple.ui-select-bootstrap { height: auto; padding: 3px 3px 0 3px; diff --git a/src/common.js b/src/common.js index f687a7062..efc7a5b9f 100644 --- a/src/common.js +++ b/src/common.js @@ -95,7 +95,8 @@ var uis = angular.module('ui.select', []) closeOnSelect: true, generateId: function() { return latestId++; - } + }, + appendToBody: false }) // See Rename minErr and make it accessible from outside https://github.com/angular/angular.js/issues/6913 @@ -133,5 +134,25 @@ var uis = angular.module('ui.select', []) return function(matchItem, query) { return query && matchItem ? matchItem.replace(new RegExp(escapeRegexp(query), 'gi'), '<span class="ui-select-highlight">$&</span>') : matchItem; }; -}); +}) +/** + * A read-only equivalent of jQuery's offset function: http://api.jquery.com/offset/ + * + * Taken from AngularUI Bootstrap Position: + * See https://github.com/angular-ui/bootstrap/blob/master/src/position/position.js#L70 + */ +.factory('uisOffset', + ['$document', '$window', + function ($document, $window) { + + return function(element) { + var boundingClientRect = element[0].getBoundingClientRect(); + return { + width: boundingClientRect.width || element.prop('offsetWidth'), + height: boundingClientRect.height || element.prop('offsetHeight'), + top: boundingClientRect.top + ($window.pageYOffset || $document[0].documentElement.scrollTop), + left: boundingClientRect.left + ($window.pageXOffset || $document[0].documentElement.scrollLeft) + }; + }; +}]); diff --git a/src/uiSelectDirective.js b/src/uiSelectDirective.js index 00fe1aa28..5a030db66 100644 --- a/src/uiSelectDirective.js +++ b/src/uiSelectDirective.js @@ -1,6 +1,6 @@ uis.directive('uiSelect', - ['$document', 'uiSelectConfig', 'uiSelectMinErr', '$compile', '$parse', '$timeout', - function($document, uiSelectConfig, uiSelectMinErr, $compile, $parse, $timeout) { + ['$document', 'uiSelectConfig', 'uiSelectMinErr', 'uisOffset', '$compile', '$parse', '$timeout', + function($document, uiSelectConfig, uiSelectMinErr, uisOffset, $compile, $parse, $timeout) { return { restrict: 'EA', @@ -368,6 +368,62 @@ uis.directive('uiSelect', } element.querySelectorAll('.ui-select-choices').replaceWith(transcludedChoices); }); + + // Support for appending the select field to the body when its open + var appendToBody = scope.$eval(attrs.appendToBody); + if (appendToBody !== undefined ? appendToBody : uiSelectConfig.appendToBody) { + scope.$watch('$select.open', function(isOpen) { + if (isOpen) { + positionDropdown(); + } else { + resetDropdown(); + } + }); + + // Move the dropdown back to its original location when the scope is destroyed. Otherwise + // it might stick around when the user routes away or the select field is otherwise removed + scope.$on('$destroy', function() { + resetDropdown(); + }); + } + + // Hold on to a reference to the .ui-select-container element for appendToBody support + var placeholder = null; + + function positionDropdown() { + // Remember the absolute position of the element + var offset = uisOffset(element); + + // Clone the element into a placeholder element to take its original place in the DOM + placeholder = angular.element('<div class="ui-select-placeholder"></div>'); + placeholder[0].style.width = offset.width + 'px'; + placeholder[0].style.height = offset.height + 'px'; + element.after(placeholder); + + // Now move the actual dropdown element to the end of the body + $document.find('body').append(element); + + element[0].style.position = 'absolute'; + element[0].style.left = offset.left + 'px'; + element[0].style.top = offset.top + 'px'; + element[0].style.width = offset.width + 'px'; + } + + function resetDropdown() { + if (placeholder === null) { + // The dropdown has not actually been display yet, so there's nothing to reset + return; + } + + // Move the dropdown element back to its original location in the DOM + placeholder.replaceWith(element); + placeholder = null; + + element[0].style.position = ''; + element[0].style.left = ''; + element[0].style.top = ''; + element[0].style.width = ''; + } } }; }]); diff --git a/test/select.spec.js b/test/select.spec.js index 8be66afa8..94581faba 100644 --- a/test/select.spec.js +++ b/test/select.spec.js @@ -37,6 +37,17 @@ describe('ui-select tests', function() { }); beforeEach(module('ngSanitize', 'ui.select', 'wrapperDirective')); + + beforeEach(function() { + module(function($provide) { + $provide.factory('uisOffset', function() { + return function(el) { + return {top: 100, left: 200, width: 300, height: 400}; + }; + }); + }); + }); + beforeEach(inject(function(_$rootScope_, _$compile_, _$timeout_, _$injector_) { $rootScope = _$rootScope_; scope = $rootScope.$new(); @@ -92,6 +103,7 @@ describe('ui-select tests', function() { if (attrs.tagging !== undefined) { attrsHtml += ' tagging="' + attrs.tagging + '"'; } if (attrs.taggingTokens !== undefined) { attrsHtml += ' tagging-tokens="' + attrs.taggingTokens + '"'; } if (attrs.title !== undefined) { attrsHtml += ' title="' + attrs.title + '"'; } + if (attrs.appendToBody != undefined) { attrsHtml += ' append-to-body="' + attrs.appendToBody + '"'; } } return compileTemplate( @@ -161,6 +173,12 @@ describe('ui-select tests', function() { scope.$digest(); }; + function closeDropdown(el) { + var $select = el.scope().$select; + $select.open = false; + scope.$digest(); + } + // Tests @@ -1791,4 +1809,60 @@ describe('ui-select tests', function() { } }); }); + + describe('select with the append to body option', function() { + var body; + + beforeEach(inject(function($document) { + body = $document.find('body')[0]; + })); + + it('should only be moved to the body when the appendToBody option is true', function() { + var el = createUiSelect({appendToBody: false}); + openDropdown(el); + expect(el.parent()[0]).not.toBe(body); + }); + + it('should be moved to the body when the appendToBody is true in uiSelectConfig', inject(function(uiSelectConfig) { + uiSelectConfig.appendToBody = true; + var el = createUiSelect(); + openDropdown(el); + expect(el.parent()[0]).toBe(body); + })); + + it('should be moved to the body when opened', function() { + var el = createUiSelect({appendToBody: true}); + openDropdown(el); + expect(el.parent()[0]).toBe(body); + closeDropdown(el); + expect(el.parent()[0]).not.toBe(body); + }); + + it('should remove itself from the body when the scope is destroyed', function() { + var el = createUiSelect({appendToBody: true}); + openDropdown(el); + expect(el.parent()[0]).toBe(body); + el.scope().$destroy(); + expect(el.parent()[0]).not.toBe(body); + }); + + it('should have specific position and dimensions', function() { + var el = createUiSelect({appendToBody: true}); + var originalPosition = el.css('position'); + var originalTop = el.css('top'); + var originalLeft = el.css('left'); + var originalWidth = el.css('width'); + openDropdown(el); + expect(el.css('position')).toBe('absolute'); + expect(el.css('top')).toBe('100px'); + expect(el.css('left')).toBe('200px'); + expect(el.css('width')).toBe('300px'); + closeDropdown(el); + expect(el.css('position')).toBe(originalPosition); + expect(el.css('top')).toBe(originalTop); + expect(el.css('left')).toBe(originalLeft); + expect(el.css('width')).toBe(originalWidth); + }); + }); + });