diff --git a/.gitignore b/.gitignore index 2385c03..166082c 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,5 @@ bower_components build coverage node_modules +/lib +**/*.soy.js diff --git a/.jshintrc b/.jshintrc index fe0a07a..6262805 100644 --- a/.jshintrc +++ b/.jshintrc @@ -8,6 +8,7 @@ "evil": false, "forin": false, "globals": { + "IncrementalDOM": true, "soy": true, "soydata": true }, diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..70f93e9 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,29 @@ +# Software License Agreement (BSD License) + +Copyright (c) 2014, Liferay Inc. +All rights reserved. + +Redistribution and use of this software in source and binary forms, with or without modification, are +permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above + copyright notice, this list of conditions and the + following disclaimer. + +* Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the + following disclaimer in the documentation and/or other + materials provided with the distribution. + +* The name of Liferay Inc. may not be used to endorse or promote products + derived from this software without specific prior + written permission of Liferay Inc. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED +WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A +PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS +INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR +TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF +ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/README.md b/README.md index 09039cf..66a10bf 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,27 @@ -# Alloy Modal +# metal-modal -Alloy's modal component. +Metal's modal component. + +## Setup + +1. Install NodeJS >= [v0.12.0](http://nodejs.org/dist/v0.12.0/), if you don't have it yet. + +2. Install global dependencies: + + ``` + [sudo] npm install -g gulp + ``` + +3. Install local dependencies: + + ``` + npm install + ``` + +4. Build the code: + + ``` + gulp build + ``` + +5. Open the demo at demos/index.html on your browser. diff --git a/bower.json b/bower.json deleted file mode 100644 index 0c4204a..0000000 --- a/bower.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "name": "alloy-modal", - "version": "0.1.0", - "homepage": "https://github.com/metal/alloy-modal", - "authors": [ - "Maira Bello " - ], - "description": "AlloyUI's modal component", - "main": "src/Modal.js", - "moduleType": [ - "es6" - ], - "keywords": [ - "alloy", - "alloyui", - "metal" - ], - "ignore": [ - "*", - "!src/**/*" - ], - "license": "MIT", - "dependencies": { - "soyutils": "2014.04.22", - "traceur": "~0.0.90", - "system.js": "~0.17.1", - "bootstrap": "~3.3.4", - "requirejs": "~2.1.18", - "metal": "~0.3.1", - "metal-jquery-adapter": "~0.0.1" - } -} diff --git a/demos/amd.html b/demos/amd.html deleted file mode 100644 index 58f1a6f..0000000 --- a/demos/amd.html +++ /dev/null @@ -1,27 +0,0 @@ - - - - - Demo: Modal - - - - - - - -

Modal

- - - diff --git a/demos/index.html b/demos/index.html index 1b97268..d65360a 100644 --- a/demos/index.html +++ b/demos/index.html @@ -2,21 +2,28 @@ + Demo: Modal - + -

Modal

+ + diff --git a/demos/jquery.html b/demos/jquery.html deleted file mode 100644 index 5a085d5..0000000 --- a/demos/jquery.html +++ /dev/null @@ -1,24 +0,0 @@ - - - - - Demo: Modal - - - - - - - - -

Modal

- - - - diff --git a/demos/systemJsConfig.js b/demos/systemJsConfig.js deleted file mode 100644 index 2852e5a..0000000 --- a/demos/systemJsConfig.js +++ /dev/null @@ -1,10 +0,0 @@ -System.config({ - defaultJSExtensions: true, - baseURL: '../', - map: { - traceur: 'bower:traceur/traceur' - }, - paths: { - 'bower:*': 'bower_components/*' - } -}); diff --git a/demos/systemjs.html b/demos/systemjs.html deleted file mode 100644 index 5ad0d3c..0000000 --- a/demos/systemjs.html +++ /dev/null @@ -1,27 +0,0 @@ - - - - - Demo: Modal - - - - - - - - -

Modal

- - - diff --git a/gulpfile.js b/gulpfile.js index b8c1174..784e335 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -5,6 +5,5 @@ var metal = require('gulp-metal'); metal.registerTasks({ bundleCssFileName: 'modal.css', bundleFileName: 'modal.js', - globalName: 'alloy', - moduleName: 'alloy-modal' + moduleName: 'metal-modal' }); diff --git a/karma.conf.js b/karma.conf.js deleted file mode 100644 index 91671dd..0000000 --- a/karma.conf.js +++ /dev/null @@ -1,44 +0,0 @@ -var isparta = require('isparta'); -var metal = require('gulp-metal'); - -var babelOptions = { - resolveModuleSource: metal.renameAlias, - sourceMap: 'both' -}; - -module.exports = function (config) { - config.set({ - frameworks: ['mocha', 'chai', 'source-map-support', 'commonjs'], - - files: [ - 'bower_components/soyutils/soyutils.js', - 'bower_components/metal/src/**/*.js', - 'src/**/*.js', - 'test/**/*.js' - ], - - preprocessors: { - 'src/!(*.soy).js': ['coverage', 'commonjs'], - 'src/*.soy.js': ['babel', 'commonjs'], - 'bower_components/metal/**/*.js': ['babel', 'commonjs'], - 'test/**/*.js': ['babel', 'commonjs'] - }, - - browsers: ['Chrome'], - - reporters: ['coverage', 'progress'], - - babelPreprocessor: {options: babelOptions}, - - coverageReporter: { - instrumenters: {isparta : isparta}, - instrumenter: {'**/*.js': 'isparta'}, - instrumenterOptions: {isparta: {babel: babelOptions}}, - reporters: [ - {type: 'html'}, - {type: 'lcov', subdir: 'lcov'}, - {type: 'text-summary'} - ] - } - }); -} diff --git a/package.json b/package.json index fad4873..099f46e 100644 --- a/package.json +++ b/package.json @@ -1,38 +1,45 @@ { - "name": "alloy-modal", - "version": "0.1.0", - "description": "Alloy's modal component", - "license": "MIT", - "repository": "metal/alloy-modal", + "name": "metal-modal", + "version": "2.0.3", + "description": "Metal's modal component", + "license": "BSD", + "repository": "metal/metal-modal", "author": { "name": "Maira Bello", "email": "maira.araujo@liferay.com" }, "engines": { - "node": ">=0.12.0" + "node": ">=0.12.0", + "npm": ">=3.0.0" }, + "jsnext:main": "src/Modal.js", + "main": "lib/Modal.js", + "files": [ + "lib", + "src", + "test" + ], "scripts": { + "compile": "babel --presets metal -d lib/ src/", + "prepublish": "gulp soy && npm run compile", "test": "gulp test" }, "keywords": [ - "alloy", - "alloyui", "metal", "modal" ], + "dependencies": { + "metal": "^2.0.0", + "metal-component": "^2.0.0", + "metal-dom": "^2.0.0", + "metal-events": "^2.0.0", + "metal-soy": "^2.0.0" + }, "devDependencies": { - "chai": "^2.3.0", + "bootstrap": "^3.3.6", + "babel-cli": "^6.4.5", + "babel-preset-metal": "^4.0.0", "gulp": "^3.8.11", - "gulp-metal": "^0.1.1", - "isparta": "^3.0.3", - "karma": "^0.12.31", - "karma-babel-preprocessor": "^5.2.1", - "karma-chai": "^0.1.0", - "karma-chrome-launcher": "^0.1.12", - "karma-commonjs": "0.0.13", - "karma-coverage": "^0.3.1", - "karma-mocha": "^0.1.10", - "karma-source-map-support": "^1.0.0", - "mocha": "^2.2.5" + "gulp-metal": "^1.0.0" } } diff --git a/src/Modal.js b/src/Modal.js index 6ebdf53..24863c5 100644 --- a/src/Modal.js +++ b/src/Modal.js @@ -1,20 +1,61 @@ 'use strict'; -import core from 'bower:metal/src/core'; -import dom from 'bower:metal/src/dom/dom'; -import ComponentRegistry from 'bower:metal/src/component/ComponentRegistry'; -import SoyComponent from 'bower:metal/src/soy/SoyComponent'; -import './Modal.soy'; +import core from 'metal'; +import dom from 'metal-dom'; +import { EventHandler } from 'metal-events'; +import templates from './Modal.soy.js'; +import Component from 'metal-component'; +import Soy from 'metal-soy'; /** * Modal component. */ -class Modal extends SoyComponent { +class Modal extends Component { /** * @inheritDoc */ - constructor(opt_config) { - super(opt_config); + created() { + this.eventHandler_ = new EventHandler(); + } + + /** + * @inheritDoc + */ + attached() { + this.autoFocus_(this.autoFocus); + } + + /** + * Automatically focuses the element specified by the given selector. + * @param {boolean|string} autoFocusSelector The selector, or false if no + * element should be automatically focused. + * @protected + */ + autoFocus_(autoFocusSelector) { + if (this.inDocument && this.visible && autoFocusSelector) { + var element = this.element.querySelector(autoFocusSelector); + if (element) { + element.focus(); + } + } + } + + /** + * @inheritDoc + */ + detached() { + super.detached(); + this.eventHandler_.removeAllListeners(); + } + + /** + * Adds a class to the body to disable body scrolling when modal is larger + * than window height. Allows modal to scroll + */ + disableBodyScroll() { + var body = document.querySelector('body'); + + dom.addClasses(body, 'modal-open'); } /** @@ -22,25 +63,93 @@ class Modal extends SoyComponent { */ disposeInternal() { dom.exitDocument(this.overlayElement); + this.unrestrictFocus_(); super.disposeInternal(); } /** - * Hides the modal, setting its `visible` attribute to false. + * Removes the "modal-open" class from the body, enabling it to scroll again + */ + enableBodyScroll() { + var body = document.querySelector('body'); + + dom.removeClasses(body, 'modal-open'); + } + + /** + * Handles a `focus` event on the document. If the focused element is + * outside the modal and an overlay is being used, focuses the modal back. + * @param {!Event} event + * @protected + */ + handleDocumentFocus_(event) { + if (this.overlay && !this.element.contains(event.target)) { + this.autoFocus_('.modal-dialog'); + } + } + + /** + * Handles document click in order to close the alert. + * @param {!Event} event + * @protected + */ + handleKeyup_(event) { + if (event.keyCode === 27) { + this.hide(); + } + } + + /** + * Hides the modal, setting its `visible` state key to false. */ hide() { this.visible = false; } /** - * Shows the modal, setting its `visible` attribute to true. + * Restricts focus to the modal while it's visible. + * @protected + */ + restrictFocus_() { + if (!this.restrictFocusHandle_) { + this.restrictFocusHandle_ = dom.on(document, 'focus', this.handleDocumentFocus_.bind(this), true); + } + } + + /** + * Shifts the focus back to the last element that had been focused before the + * modal was shown. + * @protected + */ + shiftFocusBack_() { + if (this.lastFocusedElement_) { + this.lastFocusedElement_.focus(); + this.lastFocusedElement_ = null; + } + } + + /** + * Shows the modal, setting its `visible` state key to true. */ show() { this.visible = true; } /** - * @inheritDoc + * Syncs the component according to the value of the `hideOnEscape` state key. + * @param {boolean} hideOnEscape + */ + syncHideOnEscape(hideOnEscape) { + if (hideOnEscape) { + this.eventHandler_.add(dom.on(document, 'keyup', this.handleKeyup_.bind(this))); + } else { + this.eventHandler_.removeAllListeners(); + } + } + + /** + * Syncs the component according to the value of the `overlay` state key. + * @param {boolean} overlay */ syncOverlay(overlay) { var willShowOverlay = overlay && this.visible; @@ -48,51 +157,115 @@ class Modal extends SoyComponent { } /** - * @inheritDoc + * Syncs the component according to the value of the `visible` state key. */ - syncVisible(visible) { - this.element.style.display = visible ? 'block' : ''; + syncVisible() { this.syncOverlay(this.overlay); + + if (this.visible) { + this.lastFocusedElement_ = this.lastFocusedElement_ || document.activeElement; + this.autoFocus_(this.autoFocus); + this.restrictFocus_(); + this.disableBodyScroll(); + } else { + this.unrestrictFocus_(); + this.shiftFocusBack_(); + this.enableBodyScroll(); + } } /** - * @inheritDoc + * Removes the handler that restricts focus to elements inside the modal. + * @protected + */ + unrestrictFocus_() { + if (this.restrictFocusHandle_) { + this.restrictFocusHandle_.removeListener(); + this.restrictFocusHandle_ = null; + } + } + + /** + * Defines the default value for the `overlayElement` state key. + * @protected */ valueOverlayElementFn_() { return dom.buildFragment('').firstChild; } } -/** - * Default modal elementClasses. - * @default modal - * @type {String} - * @static - */ -Modal.ELEMENT_CLASSES = 'modal'; +Modal.STATE = { + /** + * A selector for the element that should be automatically focused when the modal + * becomes visible, or `false` if no auto focus should happen. Defaults to the + * modal's close button. + * @type {boolean|string} + */ + autoFocus: { + validator: val => val === false || core.isString(val), + value: '.close' + }, -Modal.ATTRS = { /** - * Content to be placed inside modal body. - * @type {string|SanitizedHtml} + * Content to be placed inside modal body. Can be either an html string or + * a function that calls incremental dom for rendeirng the body. + * @type {string|function()} */ body: { }, /** - * Content to be placed inside modal footer. - * @type {string|SanitizedHtml} + * The id used by the body element. + * @type {string} + */ + bodyId: { + valueFn: () => 'modal-body-' + core.getUid() + }, + + /** + * Content to be placed inside modal footer. Can be either an html string or + * a function that calls incremental dom for rendeirng the footer. + * @type {string|function()} */ footer: { }, /** - * Content to be placed inside modal header. - * @type {string|SanitizedHtml} + * The id used by the header element. + * @type {string} + */ + headerId: { + valueFn: () => 'modal-header-' + core.getUid() + }, + + /** + * Content to be placed inside modal header. Can be either an html string or + * a function that calls incremental dom for rendeirng the header. + * @type {string|function()} */ header: { }, + /** + * Whether modal should hide on esc. + * @type {boolean} + * @default true + */ + hideOnEscape: { + validator: core.isBoolean, + value: true + }, + + /** + * Flag indicating if the default "x" button for closing the modal should be + * added or not. + * @type {boolean} + * @default false + */ + noCloseButton: { + value: false + }, + /** * Whether overlay should be visible when modal is visible. * @type {boolean} @@ -110,10 +283,19 @@ Modal.ATTRS = { overlayElement: { initOnly: true, valueFn: 'valueOverlayElementFn_' + }, + + /** + * The ARIA role to be used for this modal. + * @type {string} + * @default 'dialog' + */ + role: { + validator: core.isString, + value: 'dialog' } }; -ComponentRegistry.register('Modal', Modal); +Soy.register(Modal, templates); export default Modal; - diff --git a/src/Modal.soy b/src/Modal.soy index fa99eb0..7a0578b 100644 --- a/src/Modal.soy +++ b/src/Modal.soy @@ -1,71 +1,49 @@ -{namespace Templates.Modal} +{namespace Modal} /** * This renders the main element. */ -{template .content} -