Skip to content
This repository has been archived by the owner on May 29, 2019. It is now read-only.

Commit

Permalink
feat(modal): add component support
Browse files Browse the repository at this point in the history
- Add support for `component` option

Closes #5683
Closes #6179
  • Loading branch information
wesleycho committed Aug 19, 2016
1 parent f5ff12c commit 2ade054
Show file tree
Hide file tree
Showing 3 changed files with 199 additions and 52 deletions.
42 changes: 28 additions & 14 deletions src/modal/docs/readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,23 +10,23 @@ The `$uibModal` service has only one method: `open(options)`.
* `animation`
_(Type: `boolean`, Default: `true`)_ -
Set to false to disable animations on new modal/backdrop. Does not toggle animations for modals/backdrops that are already displayed.
* `appendTo`

* `appendTo`
_(Type: `angular.element`, Default: `body`: Example: `$document.find('aside').eq(0)`)_ -
Appends the modal to a specific element.

* `ariaDescribedBy`
* `ariaDescribedBy`
_(Type: `string`, `my-modal-description`)_ -
Sets the [`aria-describedby`](https://www.w3.org/TR/wai-aria/states_and_properties#aria-describedby) property on the modal. The value should be an id (without the leading `#`) pointing to the element that describes your modal. Typically, this will be the text on your modal, but does not include something the user would interact with, like buttons or a form. Omitting this option will not impact sighted users but will weaken your accessibility support.

* `ariaLabelledBy`
* `ariaLabelledBy`
_(Type: `string`, `my-modal-title`)_ -
Sets the [`aria-labelledby`](https://www.w3.org/TR/wai-aria/states_and_properties#aria-labelledby) property on the modal. The value should be an id (without the leading `#`) pointing to the element that labels your modal. Typically, this will be a header element. Omitting this option will not impact sighted users but will weaken your accessibility support.

* `backdrop`
_(Type: `boolean|string`, Default: `true`)_ -
Controls presence of a backdrop. Allowed values: `true` (default), `false` (no backdrop), `'static'` (disables modal closing by click on the backdrop).

* `backdropClass`
_(Type: `string`)_ -
Additional CSS class(es) to be added to a modal backdrop template.
Expand All @@ -35,15 +35,29 @@ The `$uibModal` service has only one method: `open(options)`.
_(Type: `boolean`, Default: `false`)_ -
When used with `controllerAs` & set to `true`, it will bind the $scope properties onto the controller.

* `component`
_(Type: `string`, Example: `myComponent`)_ -
A string reference to the component to be rendered that is registered with Angular's compiler. If using a directive, the directive must have `restrict: 'E'` and a template or templateUrl set.

It supports these bindings:

* `close` - A method that can be used to close a modal, passing a result. The result must be passed in this format: `{$value: myResult}`

* `dismiss` - A method that can be used to dismiss a modal, passing a result. The result must be passed in this format: `{$value: myRejectedResult}`

* `modalInstance` - The modal instance. This is the same `$uibModalInstance` injectable found when using `controller`.

* `resolve` - An object of the modal resolve values. See [UI Router resolves](#ui-router-resolves) for details.

* `controller`
_(Type: `function|string|array`, Example: `MyModalController`)_ -
A controller for the modal instance, either a controller name as a string, or an inline controller function, optionally wrapped in array notation for dependency injection. Allows the controller-as syntax. Has a special `$uibModalInstance` injectable to access the modal instance.

* `controllerAs`
_(Type: `string`, Example: `ctrl`)_ -
_(Type: `string`, Example: `ctrl`)_ -
An alternative to the controller-as syntax. Requires the `controller` option to be provided as well.

* `keyboard` -
* `keyboard` -
_(Type: `boolean`, Default: `true`)_ -
Indicates whether the dialog should be closable by hitting the ESC key.

Expand Down Expand Up @@ -84,7 +98,7 @@ The `$uibModal` service has only one method: `open(options)`.
CSS class(es) to be added to the top modal window.

Global defaults may be set for `$uibModal` via `$uibModalProvider.options`.

#### return

The `open` method returns a modal instance, an object with the following properties:
Expand All @@ -111,8 +125,8 @@ The `open` method returns a modal instance, an object with the following propert

* `rendered`
_(Type: `promise`)_ -
Is resolved when a modal is rendered.
Is resolved when a modal is rendered.

---

The scope associated with modal's content is augmented with:
Expand All @@ -133,9 +147,9 @@ Also, when using `bindToController`, you can define an `$onInit` method in the c

Events fired:

* `$uibUnscheduledDestruction` -
* `$uibUnscheduledDestruction` -
This event is fired if the $scope is destroyed via unexpected mechanism, such as it being passed in the modal options and a $route/$state transition occurs. The modal will also be dismissed.

* `modal.closing` -
This event is broadcast to the modal scope before the modal closes. If the listener calls preventDefault() on the event, then the modal will remain open.
Also, the `$close` and `$dismiss` methods returns true if the event was executed. This event also includes a parameter for the result/reason and a boolean that indicates whether the modal is being closed (true) or dismissed.
Expand All @@ -144,4 +158,4 @@ Events fired:

If one wants to have the modal resolve using [UI Router's](https://github.com/angular-ui/ui-router) pre-1.0 resolve mechanism, one can call `$uibResolve.setResolver('$resolve')` in the configuration phase of the application. One can also provide a custom resolver as well, as long as the signature conforms to UI Router's [$resolve](http://angular-ui.github.io/ui-router/site/#/api/ui.router.util.$resolve).

When the modal is opened with a controller, a `$resolve` object is exposed on the template with the resolved values from the resolve object.
When the modal is opened with a controller, a `$resolve` object is exposed on the template with the resolved values from the resolve object. If using the component option, see details on how to access this object in component section of the modal documentation.
116 changes: 83 additions & 33 deletions src/modal/modal.js
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,15 @@ angular.module('ui.bootstrap.modal', ['ui.bootstrap.stackedMap', 'ui.bootstrap.p
'button:not([disabled]):not([tabindex=\'-1\']),select:not([disabled]):not([tabindex=\'-1\']), textarea:not([disabled]):not([tabindex=\'-1\']), ' +
'iframe, object, embed, *[tabindex]:not([tabindex=\'-1\']), *[contenteditable=true]';
var scrollbarPadding;
var SNAKE_CASE_REGEXP = /[A-Z]/g;

// TODO: extract into common dependency with tooltip
function snake_case(name) {
var separator = '-';
return name.replace(SNAKE_CASE_REGEXP, function(letter, pos) {
return (pos ? separator : '') + letter.toLowerCase();
});
}

function isVisible(element) {
return !!(element.offsetWidth ||
Expand Down Expand Up @@ -496,6 +505,21 @@ angular.module('ui.bootstrap.modal', ['ui.bootstrap.stackedMap', 'ui.bootstrap.p
}
}

var content;
if (modal.component) {
content = document.createElement(snake_case(modal.component.name));
content = angular.element(content);
content.attr({
resolve: '$resolve',
'modal-instance': '$uibModalInstance',
close: 'close($value)',
dismiss: 'dismiss($value)'
});
content = $compile(content)(modal.scope);
} else {
content = modal.content;
}

// Set the top modal index based on the index of the previous top modal
topModalIndex = previousTopOpenedModal ? parseInt(previousTopOpenedModal.value.modalDomEl.attr('index'), 10) + 1 : 0;
var angularDomEl = angular.element('<div uib-modal-window="modal-window"></div>');
Expand All @@ -513,7 +537,7 @@ angular.module('ui.bootstrap.modal', ['ui.bootstrap.stackedMap', 'ui.bootstrap.p
'tabindex': -1,
'uib-modal-animation-class': 'fade',
'modal-in-class': 'in'
}).html(modal.content);
}).html(content);
if (modal.windowClass) {
angularDomEl.addClass(modal.windowClass);
}
Expand Down Expand Up @@ -682,12 +706,17 @@ angular.module('ui.bootstrap.modal', ['ui.bootstrap.stackedMap', 'ui.bootstrap.p
modalOptions.appendTo = modalOptions.appendTo || $document.find('body').eq(0);

//verify options
if (!modalOptions.template && !modalOptions.templateUrl) {
throw new Error('One of template or templateUrl options is required.');
if (!modalOptions.component && !modalOptions.template && !modalOptions.templateUrl) {
throw new Error('One of component or template or templateUrl options is required.');
}

var templateAndResolvePromise =
$q.all([getTemplatePromise(modalOptions), $uibResolve.resolve(modalOptions.resolve, {}, null, null)]);
var templateAndResolvePromise;
if (modalOptions.component) {
templateAndResolvePromise = $q.when($uibResolve.resolve(modalOptions.resolve, {}, null, null));
} else {
templateAndResolvePromise =
$q.all([getTemplatePromise(modalOptions), $uibResolve.resolve(modalOptions.resolve, {}, null, null)]);
}

function resolveWithTemplate() {
return templateAndResolvePromise;
Expand All @@ -713,17 +742,34 @@ angular.module('ui.bootstrap.modal', ['ui.bootstrap.stackedMap', 'ui.bootstrap.p
}
});

var modal = {
scope: modalScope,
deferred: modalResultDeferred,
renderDeferred: modalRenderDeferred,
closedDeferred: modalClosedDeferred,
animation: modalOptions.animation,
backdrop: modalOptions.backdrop,
keyboard: modalOptions.keyboard,
backdropClass: modalOptions.backdropClass,
windowTopClass: modalOptions.windowTopClass,
windowClass: modalOptions.windowClass,
windowTemplateUrl: modalOptions.windowTemplateUrl,
ariaLabelledBy: modalOptions.ariaLabelledBy,
ariaDescribedBy: modalOptions.ariaDescribedBy,
size: modalOptions.size,
openedClass: modalOptions.openedClass,
appendTo: modalOptions.appendTo
};

var component = {};
var ctrlInstance, ctrlInstantiate, ctrlLocals = {};

//controllers
if (modalOptions.controller) {
ctrlLocals.$scope = modalScope;
ctrlLocals.$scope.$resolve = {};
ctrlLocals.$uibModalInstance = modalInstance;
angular.forEach(tplAndVars[1], function(value, key) {
ctrlLocals[key] = value;
ctrlLocals.$scope.$resolve[key] = value;
});
if (modalOptions.component) {
constructLocals(component, false, true, false);
component.name = modalOptions.component;
modal.component = component;
} else if (modalOptions.controller) {
constructLocals(ctrlLocals, true, false, true);

// the third param will make the controller instantiate later,private api
// @see https://github.com/angular/angular.js/blob/master/src/ng/controller.js#L126
Expand All @@ -744,27 +790,31 @@ angular.module('ui.bootstrap.modal', ['ui.bootstrap.stackedMap', 'ui.bootstrap.p
}
}

$modalStack.open(modalInstance, {
scope: modalScope,
deferred: modalResultDeferred,
renderDeferred: modalRenderDeferred,
closedDeferred: modalClosedDeferred,
content: tplAndVars[0],
animation: modalOptions.animation,
backdrop: modalOptions.backdrop,
keyboard: modalOptions.keyboard,
backdropClass: modalOptions.backdropClass,
windowTopClass: modalOptions.windowTopClass,
windowClass: modalOptions.windowClass,
windowTemplateUrl: modalOptions.windowTemplateUrl,
ariaLabelledBy: modalOptions.ariaLabelledBy,
ariaDescribedBy: modalOptions.ariaDescribedBy,
size: modalOptions.size,
openedClass: modalOptions.openedClass,
appendTo: modalOptions.appendTo
});
if (!modalOptions.component) {
modal.content = tplAndVars[0];
}

$modalStack.open(modalInstance, modal);
modalOpenedDeferred.resolve(true);

function constructLocals(obj, template, instanceOnScope, injectable) {
obj.$scope = modalScope;
obj.$scope.$resolve = {};
if (instanceOnScope) {
obj.$scope.$uibModalInstance = modalInstance;
} else {
obj.$uibModalInstance = modalInstance;
}

var resolves = template ? tplAndVars[1] : tplAndVars;
angular.forEach(resolves, function(value, key) {
if (injectable) {
obj[key] = value;
}

obj.$scope.$resolve[key] = value;
});
}
}, function resolveError(reason) {
modalOpenedDeferred.reject(reason);
modalResultDeferred.reject(reason);
Expand Down
93 changes: 88 additions & 5 deletions src/modal/test/modal.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,16 @@ describe('$uibModal', function() {
elem.focus();
}
};
}).component('fooBar', {
bindings: {
resolve: '<',
modalInstance: '<',
close: '&',
dismiss: '&'
},
controller: angular.noop,
controllerAs: 'foobar',
template: '<div>Foo Bar</div>'
});
}));

Expand Down Expand Up @@ -930,16 +940,89 @@ describe('$uibModal', function() {
});
});

describe('option by option', function () {
describe('template and templateUrl', function () {
it('should throw an error if none of template and templateUrl are provided', function() {
describe('option by option', function() {
describe('component', function() {
function getModalComponent($document) {
return $document.find('body > div.modal > div.modal-dialog > div.modal-content foo-bar');
}

it('should use as modal content', function() {
open({
component: 'fooBar'
});

var component = getModalComponent($document);
expect(component.html()).toBe('<div>Foo Bar</div>');
});

it('should bind expected values', function() {
var modal = open({
component: 'fooBar',
resolve: {
foo: function() {
return 'bar';
}
}
});

var component = getModalComponent($document);
var componentScope = component.isolateScope();

expect(componentScope.foobar.resolve.foo).toBe('bar');
expect(componentScope.foobar.modalInstance).toBe(modal);
expect(componentScope.foobar.close).toEqual(jasmine.any(Function));
expect(componentScope.foobar.dismiss).toEqual(jasmine.any(Function));
});

it('should close the modal', function() {
var modal = open({
component: 'fooBar',
resolve: {
foo: function() {
return 'bar';
}
}
});

var component = getModalComponent($document);
var componentScope = component.isolateScope();

componentScope.foobar.close({
$value: 'baz'
});

expect(modal.result).toBeResolvedWith('baz');
});

it('should dismiss the modal', function() {
var modal = open({
component: 'fooBar',
resolve: {
foo: function() {
return 'bar';
}
}
});

var component = getModalComponent($document);
var componentScope = component.isolateScope();

componentScope.foobar.dismiss({
$value: 'baz'
});

expect(modal.result).toBeRejectedWith('baz');
});
});

describe('template and templateUrl', function() {
it('should throw an error if none of component, template and templateUrl are provided', function() {
expect(function(){
var modal = open({});
}).toThrow(new Error('One of template or templateUrl options is required.'));
}).toThrow(new Error('One of component or template or templateUrl options is required.'));
});

it('should not fail if a templateUrl contains leading / trailing white spaces', function() {

$templateCache.put('whitespace.html', ' <div>Whitespaces</div> ');
open({templateUrl: 'whitespace.html'});
expect($document).toHaveModalOpenWithContent('Whitespaces', 'div');
Expand Down

0 comments on commit 2ade054

Please sign in to comment.