ui-modules/utils/bottom-sheet/bottom-sheet.js (315 lines of code) (raw):

/* * 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); }