blob: 2f1249ce4c61d41321f21aae5a6274826c2d1499 [file] [log] [blame]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you 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.
*/
import angular from 'angular';
import angularAnimate from 'angular-animate';
import uibModal from 'angular-ui-bootstrap/src/modal/index-nocss';
import template from './bottom-sheet.template.html';
const MODULE_NAME = 'brooklyn.components.bottom-sheet';
const CLASS_ANIMATION_FADE = 'fade';
const CLASS_ANIMATION_SLIDE_UP = 'slide-up';
const CLASS_BACKDROP = 'bottom-sheet-backdrop';
const CLASS_CONTAINER = 'bottom-sheet-container';
const CLASS_MODE_MODAL = 'bottom-sheet-modal';
const CLASS_MODE_INSET = 'bottom-sheet-inset';
const CLASS_OPENED = 'bottom-sheet-open';
const TEMPLATE_CONTAINER_URL = 'br/template/bottom-sheet/window.html';
export const MODES = ['modal', 'inset'];
/**
* @ngdoc module
* @name br.bottom-sheet
* @requires ngAnimate
* @requires ui.bootstrap.module.modal
*
* @description
* [Bottom sheet UI pattern](https://material.io/guidelines/components/bottom-sheets.html#) implementation for Brooklyn
*/
angular.module(MODULE_NAME, [angularAnimate, uibModal])
.directive('brBottomSheetBackdrop', ['$animate', brBottomSheetDackdropDirective])
.directive('brBottomSheetContainer', ['$animate', brBottomSheetContainerDirective])
.provider('brBottomSheet', brBottomSheetProvider)
.run(['$templateCache', brBottomSheetRun]);
export default MODULE_NAME;
/**
* @ngdoc directive
* @name brBottomSheetBackdrop
* @module br.bottom-sheet
* @restrict A
*
* @description
* A helper directive for the `brBottomSheet` service. It creates a backdrop element.
*
* @param {string=} animationClass The animation class to use
* @param {boolean=} animation Whether or not animating the directive
*/
export function brBottomSheetDackdropDirective($animate) {
return {
restrict: 'A',
link: (scope, element, attrs)=> {
element.addClass(CLASS_BACKDROP);
if (attrs.animationClass) {
if (attrs.animation) {
$animate.addClass(element, attrs.animationClass)
} else {
element.addClass(attrs.animationClass);
}
}
}
};
}
/**
* @ngdoc directive
* @name brBottomSheetContainer
* @module br.bottom-sheet
* @restrict A
*
* @description
* A helper directive for the `brBottomSheet` service. It creates a container element.
*
* @param {string=} templateUrl The template URL to use. Default to `br/template/bottom-sheet/window.html`
* @param {string=} animationClass The animation class to use
* @param {boolean=} animation Whether or not animating the directive
*/
export function brBottomSheetContainerDirective($animate) {
return {
restrict: 'A',
transclude: true,
templateUrl: function(tElement, tAttrs) {
return tAttrs.templateUrl || TEMPLATE_CONTAINER_URL;
},
link: (scope, element, attrs)=> {
element.addClass(CLASS_CONTAINER);
if (attrs.animationClass) {
if (attrs.animation) {
$animate.addClass(element, attrs.animationClass)
} else {
element.addClass(attrs.animationClass);
}
}
}
}
}
/**
* @ngdoc provider
* @name brBottomSheetProvider
* @module br.bottom-sheet
*
* @description
* This provider allows you set up global options for the `brBottomSheet` service. It exposes only one method
* `brBottomSheetProvider.setOption(key, value)`.
*/
export function brBottomSheetProvider() {
let options = {
animation: true,
keyboard: true,
mode: MODES[0] // Can be either 'modal' or 'inset'
};
return {
/**
* @ngdoc method
* @name setOption
* @methodOf brBottomSheetProvider
*
* @description
* Globally set an option for the `brBottomSheet` service. If an option `key` already exists, it will be overridden.
*
* @param {string} key The option key to set
* @param {string} value The option value to set
*/
setOption: (key, value)=> {
if (key) {
options[key] = value;
}
},
$get: ['$rootScope', '$q', '$document', '$templateRequest', '$controller', '$uibResolve', '$animate', '$compile', ($rootScope, $q, $document, $templateRequest, $controller, $uibResolve, $animate, $compile, $log)=> {
return new BrBottomSheet($rootScope, $q, $document, $templateRequest, $controller, $uibResolve, $animate, $compile, $log, options);
}]
}
}
/**
* @ngdoc service
* @name brBottomSheet
* @module br.bottom-sheet
*
* @description
* This service provides an easy way of displaying a bottom sheet within a page. As per as the
* [spec](https://material.io/guidelines/components/bottom-sheets.html#), you can have only one bottom sheet at the time
* therefore if you try to open a bottom sheet but one is already displayed, the previous one will close automatically
* before the new one appears.
*
* The service exposes only one method `brBottomSheet.open(options)` that take an object parameters and return the bottom sheet instance.
*
* The scope associated with modal's content is augmented with:
* - `$close(reason)` (Type: `function`) - A method that can be used to close a bottom sheet, passing a reason.
* - `$dismiss(reason)` (Type: `function`) - A method that can be used to dismiss a bottom sheet, passing a reason.
* - `$updateMode(mode)` (Type: `function`) - A method that can be used to update the mode of the current bottom sheet.
*
* Those methods make it easy to close a bottom sheet instance without a need to create a dedicated controller.
* Also, when using `bindToController`, you can define an `$onInit` method in the controller that will fire upon initialization.
*/
function BrBottomSheet($rootScope, $q, $document, $templateRequest, $controller, $uibResolve, $animate, $compile, $log, defaultOptions) {
// Our bottom sheet instance
let bottomSheet;
// We bind to the keydown event listen for the esc key press
$document.on('keydown', keydownListener);
// And we unbind ourselve when the rootScope is destroyed
$rootScope.$on('$destroy', function() {
$document.off('keydown', keydownListener);
});
return {
/**
* @ngdoc method
* @name open
* @methodOf $brBottomSheet
*
* @description
* Open a new bottom sheet based on the given options. If a bottom sheet is already open, it will be closed automatically
* first, then open the new one.
*
* @param {object} options Options to configure the bottom sheet instance. Supported options are as follow:
* - `animation` (Type `boolean`, Default: `true`) - Whether or not enable the animation when opening/dismissing the bottom sheet.
* - `keyboard` (Type `boolean`, Default: `true`) - Whether or not binding the escape key to close the bottom sheet.
* - `mode` (Type `string`, Default: `modal`) - Set the mode of the bottom sheet. Can be `modal` or `inset`.
* If `modal`, the bottom sheet will then take the full focus of the window with a backdrop behind. `inset` will
* display the bottom sheet on top of the current content but will allow a user to interact with the application behind.
* - `backdropClass` (Type `string`) - Custom CSS class to add to the backdrop DOM element.
* - `backdropAnimationClass` (Type `string`, Default: `fade`) - Custom CSS animation class to add to the backdrop
* DOM element. Setting this class will override the default animation.
* - `containerClass` (Type `string`) - Custom CSS class to add to the bottom sheet container DOM element.
* - `containerAnimationClass` (Type `string`, Default: `fade slide-up`) - Custom CSS animation class to add to the
* bottom sheet containet DOM element. Setting this class will override the default animation.
* - `containerTemplateUrl` (Type `string`, Default: `br/template/bottom-sheet/window.html`) - Custom template to use
* for the bottom sheet container. This expect a URL so the template can either be added as a standalone HTML or
* added via `$templateCache` service
* - `openedClass` (Type `string`, Default: `bottom-sheet-open`) - Custom CSS class to add to the `appendTo` DOM element
* when the bottom sheet is opened.
* - `appendTo` (Type: `angular.element,` Default: `body`) - DOM element to append the bottom sheet to.
* - `bindToController` (Type: `boolean`, Default: `false`) - When used with `controllerAs` & set to `true`, it will
* bind the `$scope` properties onto the controller.
* - `template` (Type: `string`) - Inline template representing the bottom sheet's content.
* - `templateUrl` (Type: `string`) - A path to a template representing bottom sheet's content. You need either a `template` or `templateUrl`.
* - `resolve` (Type: `Object`) - Members that will be resolved and passed to the controller as locals;
* it is equivalent of the `resolve` property in the router.
* - `scope` (Type: `$scope`) - The parent scope instance to be used for the bottom sheet's content. Defaults to `$rootScope`.
*
* @returns {object} The bottom sheet instance containing the following properties:
* - `close(result)` (Type: `function`) - Can be used to close a modal, passing a result.
* - `dismiss(reason)` (Type: `function`) - Can be used to dismiss a modal, passing a reason.
* - `updateMode(mode)` (Type: `function`) - Can be used to change the current bottom sheet `mode`.
* - `result` (Type: `promise`) - Is resolved when a modal is closed and rejected when a modal is dismissed.
* - `opened` (Type: `promise`) - Is resolved when a modal gets opened after downloading content's template and resolving all variables.
* - `closed` (Type: `promise`) - Is resolved when a modal is closed and the animation completes.
* - `rendered` (Type: `promise`) - Is resolved when a modal is rendered.
*/
open: (options)=> {
if (bottomSheet) {
// If there is a bottom sheet already, we trigger a dismiss (unless it already has been marked as destroyed)
// then we wait until it has been fully removed to launch the new instance.
if (!bottomSheet.scope.$$brDestructionScheduled) {
close('New bottom sheet on the queue', true);
}
bottomSheet.closedDeferred.promise.then(()=> {
open(options);
});
} else {
open(options);
}
}
};
function open(options) {
options = angular.extend({}, defaultOptions, options);
options.resolve = options.resolve || {};
options.appendTo = options.appendTo || $document.find('body').eq(0);
// Perform some validations on options
if (MODES.indexOf(options.mode) === -1) {
throw new Error('"mode" not supported. Make sure that the mode is one of those: ' + MODES);
}
if (!options.appendTo.length) {
throw new Error('"appendTo" element not found. Make sure that the element passed is in DOM.');
}
if (!options.template && !options.templateUrl) {
throw new Error('One of "template" or "templateUrl" options is required.');
}
// Create promises
let bottomSheetResultDeferred = $q.defer();
let bottomSheetOpenedDeferred = $q.defer();
let bottomSheetClosedDeferred = $q.defer();
let bottomSheetRenderDeferred = $q.defer();
let promises = $q.all([
getTemplatePromise(options),
$uibResolve.resolve(options.resolve, {}, null, null)
]);
// Create bottom sheet instance
let bottomSheetInstance = {
result: bottomSheetResultDeferred.promise,
opened: bottomSheetOpenedDeferred.promise,
closed: bottomSheetClosedDeferred.promise,
rendered: bottomSheetRenderDeferred.promise,
close: (reason)=> {
close(reason, true);
},
dismiss: (reason)=> {
close(reason, false);
},
updateMode: (mode)=> {
updateMode(mode);
}
};
// Let's create our bottom sheet instance
promises.then((tplAndVars)=> {
let providedScope = options.scope || $rootScope;
let bottomSheetScope = providedScope.$new();
bottomSheetScope.$close = bottomSheetInstance.close;
bottomSheetScope.$dismiss = bottomSheetInstance.dismiss;
bottomSheetScope.$updateMode = bottomSheetInstance.updateMode;
bottomSheetScope.$on('$destroy', function() {
if (!bottomSheetScope.$$brDestructionScheduled) {
bottomSheetScope.$dismiss('$brUnscheduledDestruction');
}
});
bottomSheet = {
scope: bottomSheetScope,
deferred: bottomSheetResultDeferred,
renderDeferred: bottomSheetRenderDeferred,
closedDeferred: bottomSheetClosedDeferred,
animation: options.animation,
keyboard: options.keyboard,
mode: options.mode,
backdropClass: options.backdropClass,
backdropAnimationClass: options.backdropAnimationClass,
containerClass: options.containerClass,
containerAnimationClass: options.containerAnimationClass,
containerTemplateUrl: options.containerTemplateUrl,
openedClass: options.openedClass,
appendTo: options.appendTo,
content: tplAndVars[0],
ariaLabelledBy: options.ariaLabelledBy,
ariaDescribedBy: options.ariaDescribedBy,
};
// We create our own instance of controller, based on the given options
let ctrlInstance, ctrlInstantiate, ctrlLocals = {};
ctrlLocals.$scope = bottomSheetScope;
ctrlLocals.$scope.$resolve = {};
ctrlLocals.brBottomSheetInstance = bottomSheetInstance;
// If we passed a resolve block, all vars are injected into the local controller scope
let resolves = tplAndVars[1];
angular.forEach(resolves, function(value, key) {
ctrlLocals[key] = value;
ctrlLocals.$scope.$resolve[key] = value;
});
// 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
ctrlInstantiate = $controller(options.controller, ctrlLocals, true, options.controllerAs);
if (options.controllerAs && options.bindToController) {
ctrlInstance = ctrlInstantiate.instance;
ctrlInstance.$close = bottomSheetScope.$close;
ctrlInstance.$dismiss = bottomSheetScope.$dismiss;
angular.extend(ctrlInstance, {
$resolve: ctrlLocals.$scope.$resolve
}, providedScope);
}
ctrlInstance = ctrlInstantiate();
if (angular.isFunction(ctrlInstance.$onInit)) {
ctrlInstance.$onInit();
}
// Create the backdrop if the mode is set to 'modal'
if (options.mode === MODES[0]) {
createBackdrop();
}
bottomSheet.containerElm = angular.element('<div br-bottom-sheet-container></div>');
bottomSheet.containerElm.attr({
'class': bottomSheet.containerClass,
'animation-class': bottomSheet.containerAnimationClass || CLASS_ANIMATION_SLIDE_UP + ' ' + CLASS_ANIMATION_FADE,
'role': 'dialog',
'tabindex': -1,
}).append(bottomSheet.content);
if (bottomSheet.containerTemplateUrl) {
bottomSheet.containerElm.attr('template-url', bottomSheet.containerTemplateUrl);
}
if (bottomSheet.animation) {
bottomSheet.containerElm.attr('animation', 'true');
}
let bodyClass = bottomSheet.openedClass || CLASS_OPENED;
bottomSheet.appendTo.addClass(bodyClass);
if (bottomSheet.mode === MODES[0]) {
bottomSheet.appendTo.addClass(CLASS_MODE_MODAL);
}
if (bottomSheet.mode === MODES[1]) {
bottomSheet.appendTo.addClass(CLASS_MODE_INSET);
}
$animate.enter($compile(bottomSheet.containerElm)(bottomSheet.scope), bottomSheet.appendTo);
// Focus on the newly created bottom sheet
bottomSheet.containerElm[0].focus();
bottomSheetOpenedDeferred.resolve(true);
}).catch((reason)=> {
bottomSheetOpenedDeferred.reject(reason);
bottomSheetResultDeferred.reject(reason);
});
return bottomSheetInstance;
}
function close(reason, dismiss = false) {
bottomSheet.scope.$$brDestructionScheduled = true;
// Removing the backdrop, if exists
if (bottomSheet.mode === 'modal' && bottomSheet.backdropElm && bottomSheet.backdropScope) {
removeAfterAnimate(bottomSheet.backdropElm, bottomSheet.backdropScope, ()=> {
bottomSheet.backdropElm = undefined;
bottomSheet.backdropScope = undefined;
});
}
// Removing the bottom sheet
removeAfterAnimate(bottomSheet.containerElm, bottomSheet.scope, ()=> {
let bodyClass = bottomSheet.openedClass || CLASS_OPENED;
bottomSheet.appendTo.removeClass(bodyClass, CLASS_MODE_MODAL, CLASS_MODE_INSET);
bottomSheet = undefined;
}, bottomSheet.closedDeferred);
if (dismiss) {
bottomSheet.deferred.reject(reason);
} else {
bottomSheet.deferred.resolve(reason);
}
// Move focus on the appendTo element
bottomSheet.appendTo[0].focus();
}
function updateMode(mode) {
if (MODES.indexOf(mode) === -1) {
$log.error('Mode ' + mode + ' is not supported. You can choose from the following list: ' + MODES);
return;
}
if (!bottomSheet || bottomSheet.mode === mode) {
return;
}
bottomSheet.appendTo.removeClass(CLASS_MODE_MODAL, CLASS_MODE_INSET);
switch (mode) {
case MODES[0]:
createBackdrop().then(()=> {
bottomSheet.mode = mode;
bottomSheet.appendTo.addClass(CLASS_MODE_MODAL);
});
break;
case MODES[1]:
removeAfterAnimate(bottomSheet.backdropElm, bottomSheet.backdropScope, ()=> {
bottomSheet.mode = mode;
bottomSheet.appendTo.addClass(CLASS_MODE_INSET);
});
break;
}
}
function keydownListener(event) {
if (event.isDefaultPrevented()) {
return event;
}
if (bottomSheet && bottomSheet.keyboard) {
switch (event.which) {
case 27: {
if (bottomSheet.keyboard) {
event.preventDefault();
$rootScope.$apply(()=> {
close('Escape key pressed', true);
});
}
break;
}
}
}
}
function createBackdrop(done) {
if (!bottomSheet || bottomSheet.mode === 'inset') {
return;
}
bottomSheet.backdropScope = $rootScope.$new(true);
bottomSheet.backdropElm = angular.element('<div br-bottom-sheet-backdrop></div>');
bottomSheet.backdropElm.attr({
'class': bottomSheet.backdropClass,
'animation-class' : bottomSheet.backdropAnimationClass || CLASS_ANIMATION_FADE
});
if (bottomSheet.animation) {
bottomSheet.backdropElm.attr('animation', 'true');
}
$compile(bottomSheet.backdropElm)(bottomSheet.backdropScope);
$animate.enter(bottomSheet.backdropElm, bottomSheet.appendTo).then(()=> {
if (done) {
done();
}
})
}
function removeAfterAnimate(domEl, scope, done, closedDeferred) {
$animate.leave(domEl).then(function() {
if (done) {
done();
}
domEl.remove();
if (closedDeferred) {
closedDeferred.resolve();
}
});
scope.$destroy();
}
function getTemplatePromise(options) {
return options.template ? $q.when(options.template) :
$templateRequest(angular.isFunction(options.templateUrl) ?
options.templateUrl() : options.templateUrl);
}
}
export function brBottomSheetRun($templateCache) {
$templateCache.put(TEMPLATE_CONTAINER_URL, template);
}