diff --git a/CHANGELOG.md b/CHANGELOG.md index c389591..71a7f04 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,16 @@ # Changelog +## 0.3.0 - 2015-07-01 +- Upgraded to Polymer 1.0 +- Simplified mapping structure +- Dropped observe-js dependency in favor of native Object.observe() +- Reimplemented object and array observation for changes +- Use property-changed event for primitives and reference changes +- Added/updated built-in support for Iron, Paper, Gold and Google elements + ## 0.2.0 - 2014-12-10 - Upgraded to Polymer 0.5.1 - Removed mapping for core-input, which now works out of the box - Added mappings for core-selector, core-menu and paper-action-dialog - Removed handling of automatic bootstrapping, which is not needed for bleeding edge browsers (Chrome and Opera) -- Changed extension point to use AngularJS constant instead of a global variable \ No newline at end of file +- Changed extension point to use AngularJS constant instead of a global variable diff --git a/README.md b/README.md index 4eea8c9..d020dd6 100644 --- a/README.md +++ b/README.md @@ -1,94 +1,3 @@ # ng-polymer-elements -## Introduction - -Web components make is possible to write encapsulated pieces of view and logic independently from any framework. The Polymer project contains a platform for pollyfilling and writing web components, and two sets of web components: core elements and paper elements. - -Although there are many similarities between the ways AngularJS and Polymer implement two-way binding, it's not possible to use two-way binding of AngularJS model with web components out of the box, and even one-way binding is limited only to strings. - -ng-polymer-elements overcomes this by applying directived to Polymer web components, and each directive is in charge of mapping attributes that reference AngularJS models to the web component's properties. - -For example, the following code binds the scope's "myText" property to Polymer's paper-input "inputValue" property using the ng-model attribute, very similarly to how it looks for a basic text input: - -```html - -``` - -## Installation and Bootstrapping - -The project is available through Bower: `bower install ng-polymer-elements` - -Add the script `ng-polymer-elements.js` or `ng-polymer-elements.min.js` to your page. - -**Important** : In older browsers, AngularJS should be bootstrapped only after the custom elements have been registered, and therefore you can't use automatic bootstrapping (ng-app="..."). You need to call angular.bootstrap() only after Polymer has been loaded, and with a wrapped DOM element: - -```javascript -function bootstrap() { - angular.bootstrap(wrap(document), ['myModule']); -} - -if(angular.isDefined(document.body.attributes['unresolved'])) { - var readyListener = function() { - bootstrap(); - window.removeEventListener('polymer-ready', readyListener); - } - window.addEventListener('polymer-ready', readyListener); -} else { - bootstrap(); -} -``` - -This is not needed if you are only targeting the latest Chrome and Opera, where you can simply use: - -```html -" ], @@ -17,8 +17,8 @@ "tests" ], "dependencies": { - "angular": "~1.3.0", - "polymer": "Polymer/polymer#~0.5.1" + "angular": "^1.4.0", + "polymer": "Polymer/polymer#^1.0.0" }, "devDependencies": { } diff --git a/ng-polymer-elements.js b/ng-polymer-elements.js index 8e6ac53..2af27e5 100644 --- a/ng-polymer-elements.js +++ b/ng-polymer-elements.js @@ -1,177 +1,299 @@ +/*! + * ng-polymer-elements 0.3.0 + * https://gabiaxel.github.io/ng-polymer-elements/ + + * Released under the MIT license + * http://opensource.org/licenses/MIT + * + */ (function(angular) { - angular.module('ng-polymer-elements', []) - .config(['$compileProvider', '$injector', function($compileProvider, $injector) { - - 'use strict'; - - // Each mapping is an object where the key is the directive/custom element - // name in camel case and the value is an object where the keys are the - // AngularJS attributes in camel case and the values are objects where the - // key is the type which can be 'primitive', 'object', 'array' or 'event' - // and the value is the name of the attribute in the web component. - - var inputMappings = { - ngModel: { - primitive: 'value' - }, - ngDisabled: { - primitive: 'disabled' - } - }; - - var selectorMappings = { - ngModel: { - primitive: 'selected' - } - }; - - var checkMappings = { - ngModel: { - primitive: 'checked' - }, - ngDisabled: { - primitive: 'disabled' - } - }; - - var openableMappings = { - ngOpened: { - primitive: 'opened' - } - }; - - var allMappings = { - paperInput: inputMappings, - paperRadioGroup: selectorMappings, - paperTabs: selectorMappings, - coreSelector: selectorMappings, - coreMenu: selectorMappings, - paperCheckbox: checkMappings, - paperToggleButton: checkMappings, - coreOverlay: openableMappings, - paperDialog: openableMappings, - paperActionDialog: openableMappings, - paperToast: openableMappings, - paperSlider: inputMappings, - coreList: { - ngModel: { - array: 'data' - }, - ngTap: { - event: 'core-activate' - } - } - }; - - // Extension point for overriding mappings - - if($injector.has('$ngPolymerMappings')) { - var extendedMappings = $injector.get('$ngPolymerMappings'); - angular.extend(allMappings, extendedMappings); - } - - // A directive is created for each web component according to the mappings - - Object.keys(allMappings).forEach(function(tag) { - var mappings = allMappings[tag]; - - $compileProvider.directive(tag, ['$parse', '$window', function($parse, $window) { - - var scopeDefinition = {}; - - var keys = Object.keys(mappings); - - keys.forEach(function(attr) { - - var conf = mappings[attr]; - - if(conf.primitive || conf.object || conf.array) { - scopeDefinition[attr] = '='; - } else if(!conf.event) { - throw 'Invalid mapping for ' + attr + - ' - must contain primitive | object | array | event'; - } - }); - - return { - restrict: 'E', - scope: scopeDefinition, - - link: function (scope, element, attrs) { - - var el = element[0]; - - keys.forEach(function(attr) { - - // Don't create bindings for non-existent attributes - if(!attrs[attr]) { - return; - } - - var conf = mappings[attr]; - - if(conf.event) { - - var fn = $parse(attrs[attr]); - - el.addEventListener(conf.event, function (e) { - scope.$apply(function() { - fn(scope.$parent, {$event: e}); - }); - - }); - - } else { - - var propertyName = - conf.primitive || conf.object || conf.array; - - if(conf.object) { - el[propertyName] = {}; - } else if(conf.array) { - el[propertyName] = []; - } - - // Copy the scope property value to the web - // component's value - - var handler = function(value) { - if(conf.primitive) { - el[propertyName] = value; - } else { - angular.copy(value, el[propertyName]); - } - }; - - scope.$watch(attr, handler, true); - - handler(scope[attr]); - - // Copy the web component's value to the scope - // property value - - var observer = new PathObserver(el, propertyName); - - observer.open(function (value) { - scope.$apply(function () { - if(conf.primitive) { - scope[attr] = value; - } else { - angular.copy(value, scope[attr]); - } - }); - }); - - } - - }); - } - - }; - }]); - }); - - }]); - + angular.module('ng-polymer-elements', []).config( + ['$compileProvider', '$injector', function($compileProvider, $injector) { + + 'use strict'; + + // Each mapping is an object where the key is the directive/custom element + // name in camel case and the value is an object where the keys are the + // AngularJS attributes in camel case and the values are objects where the + // key is the type which can be 'primitive', 'object', 'array' or 'event' + // and the value is the name of the attribute in the web component. + + var inputMappings = { + ngModel: '=value', + ngDisabled: '=disabled', + ngFocused: '=focused' + }; + + var selectorMappings = { + ngModel: '=selected' + }; + + var multiSelectableMappings = { + ngModel: function property(element) { + return element.hasAttribute('multi') ? 'selectedValues' : 'selected'; + } + }; + + var checkMappings = { + ngModel: '=checked', + ngDisabled: '=disabled', + ngChange: '&iron-change' + }; + + var allMappings = { + ironSelector: multiSelectableMappings, + paperInput: inputMappings, + paperTextArea: inputMappings, + paperRadioGroup: selectorMappings, + paperTabs: selectorMappings, + paperMenu: multiSelectableMappings, + paperCheckbox: checkMappings, + paperToggleButton: checkMappings, + paperDialog: { + ngOpened: '=opened', + ngOverlayOpened: '&iron-overlay-opened', + ngOverlayClosed: '&iron-overlay-closed' + }, + paperSlider: { + ngModel: '=value', + ngChange: '&value-change', + ngDisabled: '=disabled' + }, + goldEmailInput: inputMappings, + goldPhoneInput: inputMappings, + goldCcInput: { + ngModel: '=value', + ngDisabled: '=disabled', + ngFocused: '=focused', + ngCardType: '=cardType' + }, + goldCcExpirationInput: inputMappings, + goldCcCvcInput: inputMappings, + goldZipInput: inputMappings, + googleFeeds: { + ngModel: '=results', + loading: '=loading', + ngError: '&google-feeds-error', + ngQueryError: '&google-feeds-queryerror', + ngQueryResponse: '&google-feeds-queryresponse', + ngResponse: '&google-feeds-response', + ngMultiResponse: '&google-multi-feeds-response' + }, + googleMap: { + ngMap: '=map', + ngLatitude: '=latitude', + ngLongitude: '=longitude' + }, + googleSheets: { + ngRows: '=rows', + ngSheet: '=sheet', + ngTab: '=tab' + } + }; + + // Extension point for overriding mappings + if($injector.has('$ngPolymerMappings')) { + var extendedMappings = $injector.get('$ngPolymerMappings'); + angular.extend(allMappings, extendedMappings); + } + + // A directive is created for each web component according to the mappings + Object.keys(allMappings).forEach(function(tag) { + var mappings = allMappings[tag]; + + $compileProvider.directive(tag, ['$parse', '$window', function($parse, + $window) { + + var scopeDefinition = {}; + var keys = Object.keys(mappings); + keys.forEach(function(attr) { + var mapped = mappings[attr]; + + // For constant mapping, prefix "=" for property mapping and "&" for + // event mapping. + // For dynamic mapping, name the function "property" or "event". + var mappingType; + switch(typeof mapped) { + case 'string': + mappingType = mapped.charAt(0); + if(mappingType !== '=' && mappingType !== '&') { + throw 'Invalid mapping: "' + mapped + + '" - must begin with "=" or "&"'; + } + mapped = mapped.substr(1); + break; + case 'function': + switch(mapped.name) { + case 'property': + mappingType = '='; + break; + case 'event': + mappingType = '&'; + break; + default: + throw 'Invalid mapping for "' + attr + + '" - function name must be "property" or "event"'; + } + break; + default: + throw 'Invalid mapped type for "' + attr + + '" - must be string or function'; + } + scopeDefinition[attr] = mappingType; + }); + + return { + restrict: 'E', + scope: scopeDefinition, + + link: function (scope, element, attrs) { + + var el = element[0]; + + var observers = {} + + scope.$on('$destroy', function () { + Object.keys(observers).forEach(function(key) { + var observer = observers[key]; + Object.unobserve(el[key], observer); + }); + }); + + keys.forEach(function(attr) { + + // Don't create bindings for non-existent attributes + if(!attrs[attr]) { + return; + } + + var mapped = mappings[attr]; + var mappingType; + if(typeof mapped === 'function') { + mappingType = mapped.name === 'property' ? '=' : '&'; + mapped = mapped(el); + } else { + mappingType = mapped.charAt(0); + mapped = mapped.substr(1); + } + + if(mappingType === '&') { + + // Event mapping + + var fn = $parse(attrs[attr]); + el.addEventListener(mapped, function (e) { + scope.$apply(function() { + fn(scope.$parent, {$event: e}); + }); + }); + + } else { + + // Property mapping + + var propertyName = mapped; + var propertyInfo = el.getPropertyInfo(mapped); + var propertyType = propertyInfo.type; + var readOnly = propertyInfo.readOnly; + + // For object and array property types, if the element has no + // initial value - set it to empty object/array. + if(!readOnly && !el[propertyName]) { + switch(propertyType) { + case Array: + el[propertyName] = []; + break; + case Object: + el[propertyName] = {}; + break; + } + } + + // Observe changes to the array/object, and copy its content + // to the directive attribute. + var attachObserver = function() { + if(!readOnly) { + if(observers[propertyName]) { + Object.unobserve(el[propertyName], + observers[propertyName]); + delete observers[propertyName]; + } + switch(propertyType) { + case Array: + case Object: + observers[propertyName] = function() { + scope.$apply(function() { + if(!scope[attr]) { + scope[attr] = propertyType === Array ? [] : {}; + } + angular.copy(el[propertyName], scope[attr]); + }); + } + + Object.observe(el[propertyName], observers[propertyName]); + break; + } + } + }; + + attachObserver(); + + // Copy the directive attribute value to the element's property. + // For arrays and objects, copy the content. + // The copying is deferred to the next event loop because some + // elements (eg. gold-cc-input) may change the property value + // immediately after inputting it, and we want to use only the + // latest value. + var handler = function() { + setTimeout(function() { + var value = scope[attr]; + if(propertyType != Array && propertyType != Object) { + + // Undefined value is ignored in order to allow binding to + // values without initiallizing them with an "empty" + // value. Some elements try to process the value on any + // change without safety check. + if(value !== undefined) { + el[propertyName] = value; + } + } else if(value) { + el[propertyName] = angular.copy(value); + attachObserver(); + } + }); + }; + + if(!readOnly) { + scope.$watch(attr, handler, true); + handler(scope[attr]); + } + + // When the property value changes, copy its new value to the + // directive attribute. + var eventName = propertyName.replace(/([A-Z])/g, function($1) { + return '-' + $1.toLowerCase(); + }) + '-changed'; + el.addEventListener(eventName, function(event) { + var value = el[propertyName]; //event.detail.value; + el.async(function() { + scope.$apply(function () { + if(propertyType === Array || propertyType === Object) { + scope[attr] = angular.copy(value); + } else { + if(scope[attr] != value) { + scope[attr] = value; + } + } + }); + attachObserver(); + }); + }); + } + }); + } + }; + }]); + }); + }]); })(angular); diff --git a/ng-polymer-elements.min.js b/ng-polymer-elements.min.js index 8795f4e..0525499 100644 --- a/ng-polymer-elements.min.js +++ b/ng-polymer-elements.min.js @@ -1 +1 @@ -!function(angular){angular.module("ng-polymer-elements",[]).config(["$compileProvider","$injector",function($compileProvider,$injector){"use strict";var inputMappings={ngModel:{primitive:"value"},ngDisabled:{primitive:"disabled"}},selectorMappings={ngModel:{primitive:"selected"}},checkMappings={ngModel:{primitive:"checked"},ngDisabled:{primitive:"disabled"}},openableMappings={ngOpened:{primitive:"opened"}},allMappings={paperInput:inputMappings,paperRadioGroup:selectorMappings,paperTabs:selectorMappings,coreSelector:selectorMappings,coreMenu:selectorMappings,paperCheckbox:checkMappings,paperToggleButton:checkMappings,coreOverlay:openableMappings,paperDialog:openableMappings,paperActionDialog:openableMappings,paperToast:openableMappings,paperSlider:inputMappings,coreList:{ngModel:{array:"data"},ngTap:{event:"core-activate"}}};if($injector.has("$ngPolymerMappings")){var extendedMappings=$injector.get("$ngPolymerMappings");angular.extend(allMappings,extendedMappings)}Object.keys(allMappings).forEach(function(tag){var mappings=allMappings[tag];$compileProvider.directive(tag,["$parse","$window",function($parse){var scopeDefinition={},keys=Object.keys(mappings);return keys.forEach(function(attr){var conf=mappings[attr];if(conf.primitive||conf.object||conf.array)scopeDefinition[attr]="=";else if(!conf.event)throw"Invalid mapping for "+attr+" - must contain primitive | object | array | event"}),{restrict:"E",scope:scopeDefinition,link:function(scope,element,attrs){var el=element[0];keys.forEach(function(attr){if(attrs[attr]){var conf=mappings[attr];if(conf.event){var fn=$parse(attrs[attr]);el.addEventListener(conf.event,function(e){scope.$apply(function(){fn(scope.$parent,{$event:e})})})}else{var propertyName=conf.primitive||conf.object||conf.array;conf.object?el[propertyName]={}:conf.array&&(el[propertyName]=[]);var handler=function(value){conf.primitive?el[propertyName]=value:angular.copy(value,el[propertyName])};scope.$watch(attr,handler,!0),handler(scope[attr]);var observer=new PathObserver(el,propertyName);observer.open(function(value){scope.$apply(function(){conf.primitive?scope[attr]=value:angular.copy(value,scope[attr])})})}}})}}}])})}])}(angular); \ No newline at end of file +!function(angular){angular.module("ng-polymer-elements",[]).config(["$compileProvider","$injector",function($compileProvider,$injector){"use strict";var inputMappings={ngModel:"=value",ngDisabled:"=disabled",ngFocused:"=focused"},selectorMappings={ngModel:"=selected"},multiSelectableMappings={ngModel:function(element){return element.hasAttribute("multi")?"selectedValues":"selected"}},checkMappings={ngModel:"=checked",ngDisabled:"=disabled",ngChange:"&iron-change"},allMappings={ironSelector:multiSelectableMappings,paperInput:inputMappings,paperTextArea:inputMappings,paperRadioGroup:selectorMappings,paperTabs:selectorMappings,paperMenu:multiSelectableMappings,paperCheckbox:checkMappings,paperToggleButton:checkMappings,paperDialog:{ngOpened:"=opened",ngOverlayOpened:"&iron-overlay-opened",ngOverlayClosed:"&iron-overlay-closed"},paperSlider:{ngModel:"=value",ngChange:"&value-change",ngDisabled:"=disabled"},goldEmailInput:inputMappings,goldPhoneInput:inputMappings,goldCcInput:{ngModel:"=value",ngDisabled:"=disabled",ngFocused:"=focused",ngCardType:"=cardType"},goldCcExpirationInput:inputMappings,goldCcCvcInput:inputMappings,goldZipInput:inputMappings,googleFeeds:{ngModel:"=results",loading:"=loading",ngError:"&google-feeds-error",ngQueryError:"&google-feeds-queryerror",ngQueryResponse:"&google-feeds-queryresponse",ngResponse:"&google-feeds-response",ngMultiResponse:"&google-multi-feeds-response"},googleMap:{ngMap:"=map",ngLatitude:"=latitude",ngLongitude:"=longitude"},googleSheets:{ngRows:"=rows",ngSheet:"=sheet",ngTab:"=tab"}};if($injector.has("$ngPolymerMappings")){var extendedMappings=$injector.get("$ngPolymerMappings");angular.extend(allMappings,extendedMappings)}Object.keys(allMappings).forEach(function(tag){var mappings=allMappings[tag];$compileProvider.directive(tag,["$parse","$window",function($parse,$window){var scopeDefinition={},keys=Object.keys(mappings);return keys.forEach(function(attr){var mappingType,mapped=mappings[attr];switch(typeof mapped){case"string":if(mappingType=mapped.charAt(0),"="!==mappingType&&"&"!==mappingType)throw'Invalid mapping: "'+mapped+'" - must begin with "=" or "&"';mapped=mapped.substr(1);break;case"function":switch(mapped.name){case"property":mappingType="=";break;case"event":mappingType="&";break;default:throw'Invalid mapping for "'+attr+'" - function name must be "property" or "event"'}break;default:throw'Invalid mapped type for "'+attr+'" - must be string or function'}scopeDefinition[attr]=mappingType}),{restrict:"E",scope:scopeDefinition,link:function(scope,element,attrs){var el=element[0],observers={};scope.$on("$destroy",function(){Object.keys(observers).forEach(function(key){var observer=observers[key];Object.unobserve(el[key],observer)})}),keys.forEach(function(attr){if(attrs[attr]){var mappingType,mapped=mappings[attr];if("function"==typeof mapped?(mappingType="property"===mapped.name?"=":"&",mapped=mapped(el)):(mappingType=mapped.charAt(0),mapped=mapped.substr(1)),"&"===mappingType){var fn=$parse(attrs[attr]);el.addEventListener(mapped,function(e){scope.$apply(function(){fn(scope.$parent,{$event:e})})})}else{var propertyName=mapped,propertyInfo=el.getPropertyInfo(mapped),propertyType=propertyInfo.type,readOnly=propertyInfo.readOnly;if(!readOnly&&!el[propertyName])switch(propertyType){case Array:el[propertyName]=[];break;case Object:el[propertyName]={}}var attachObserver=function(){if(!readOnly)switch(observers[propertyName]&&(Object.unobserve(el[propertyName],observers[propertyName]),delete observers[propertyName]),propertyType){case Array:case Object:observers[propertyName]=function(){scope.$apply(function(){scope[attr]||(scope[attr]=propertyType===Array?[]:{}),angular.copy(el[propertyName],scope[attr])})},Object.observe(el[propertyName],observers[propertyName])}};attachObserver();var handler=function(){setTimeout(function(){var value=scope[attr];propertyType!=Array&&propertyType!=Object?void 0!==value&&(el[propertyName]=value):value&&(el[propertyName]=angular.copy(value),attachObserver())})};readOnly||(scope.$watch(attr,handler,!0),handler(scope[attr]));var eventName=propertyName.replace(/([A-Z])/g,function($1){return"-"+$1.toLowerCase()})+"-changed";el.addEventListener(eventName,function(event){var value=el[propertyName];el.async(function(){scope.$apply(function(){propertyType===Array||propertyType===Object?scope[attr]=angular.copy(value):scope[attr]!=value&&(scope[attr]=value)}),attachObserver()})})}}})}}}])})}])}(angular); \ No newline at end of file diff --git a/package.json b/package.json index 760a6a8..897a631 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ng-polymer-elements", - "version": "0.2.0", + "version": "0.3.0", "homepage": "http://gabiaxel.github.io/ng-polymer-elements/", "repository": {