diff --git a/src/bindable-property.js b/src/bindable-property.js index 7d516058..2e9f0a68 100644 --- a/src/bindable-property.js +++ b/src/bindable-property.js @@ -4,15 +4,17 @@ import {bindingMode} from 'aurelia-binding'; import {Container} from 'aurelia-dependency-injection'; import {metadata} from 'aurelia-metadata'; +const reflectionConfigured = Symbol('reflection'); + function getObserver(instance, name) { let lookup = instance.__observers__; if (lookup === undefined) { - // We need to lookup the actual behavior for this instance, - // as it might be a derived class (and behavior) rather than + // We need to lookup the actual behavior for this instance, + // as it might be a derived class (and behavior) rather than // the class (and behavior) that declared the property calling getObserver(). - // This means we can't capture the behavior in property get/set/getObserver and pass it here. - // Note that it's probably for the best, as passing the behavior is an overhead + // This means we can't capture the behavior in property get/set/getObserver and pass it here. + // Note that it's probably for the best, as passing the behavior is an overhead // that is only useful in the very first call of the first property of the instance. let ctor = Object.getPrototypeOf(instance).constructor; // Playing safe here, user could have written to instance.constructor. let behavior = metadata.get(metadata.resource, ctor); @@ -27,6 +29,14 @@ function getObserver(instance, name) { return lookup[name]; } +export type BindablePropertyConfig = { + defaultBindingMode?: bindingMode, + reflectToAttribute?: boolean | {(el: Element, name: string, newVal, oldVal): any}, + name?: string, + attribute?: any, + changeHandler?: string +} + /** * Represents a bindable property on a behavior. */ @@ -35,7 +45,7 @@ export class BindableProperty { * Creates an instance of BindableProperty. * @param nameOrConfig The name of the property or a cofiguration object. */ - constructor(nameOrConfig: string | Object) { + constructor(nameOrConfig: string | BindablePropertyConfig) { if (typeof nameOrConfig === 'string') { this.name = nameOrConfig; } else { @@ -58,10 +68,16 @@ export class BindableProperty { * @param descriptor The property descriptor for this property. */ registerWith(target: Function, behavior: HtmlBehaviorResource, descriptor?: Object): void { + let { reflectToAttribute } = this; + behavior.properties.push(this); behavior.attributes[this.attribute] = this; this.owner = behavior; + if (reflectToAttribute) { + behavior.hasReflections = true; + } + if (descriptor) { this.descriptor = descriptor; return this._configureDescriptor(descriptor); @@ -134,27 +150,64 @@ export class BindableProperty { let defaultValue = this.defaultValue; let changeHandlerName = this.changeHandler; let name = this.name; + let reflectToAttribute = this.reflectToAttribute; let initialValue; + let attrName; + let reflectFunction; if (this.hasOptions) { return undefined; } + if (reflectToAttribute) { + attrName = this.attribute === undefined ? _hyphenate(name) : this.attribute; + reflectFunction = typeof reflectToAttribute === 'function' ? reflectToAttribute : reflectFunctions[reflectToAttribute]; + } + if (changeHandlerName in viewModel) { if ('propertyChanged' in viewModel) { + if (reflectFunction !== undefined) { + selfSubscriber = (newValue, oldValue) => { + callReflection(viewModel, reflectFunction, attrName, newValue); + viewModel[changeHandlerName](newValue, oldValue); + viewModel.propertyChanged(name, newValue, oldValue); + }; + } else { + selfSubscriber = (newValue, oldValue) => { + viewModel[changeHandlerName](newValue, oldValue); + viewModel.propertyChanged(name, newValue, oldValue); + }; + } + } else { + if (reflectFunction !== undefined) { + selfSubscriber = (newValue, oldValue) => { + callReflection(viewModel, reflectFunction, attrName, newValue); + viewModel[changeHandlerName](newValue, oldValue); + }; + } else { + selfSubscriber = (newValue, oldValue) => viewModel[changeHandlerName](newValue, oldValue); + } + } + } else if ('propertyChanged' in viewModel) { + if (reflectFunction !== undefined) { selfSubscriber = (newValue, oldValue) => { - viewModel[changeHandlerName](newValue, oldValue); + callReflection(viewModel, reflectFunction, attrName, newValue); viewModel.propertyChanged(name, newValue, oldValue); }; } else { - selfSubscriber = (newValue, oldValue) => viewModel[changeHandlerName](newValue, oldValue); + selfSubscriber = (newValue, oldValue) => viewModel.propertyChanged(name, newValue, oldValue); } - } else if ('propertyChanged' in viewModel) { - selfSubscriber = (newValue, oldValue) => viewModel.propertyChanged(name, newValue, oldValue); } else if (changeHandlerName !== null) { throw new Error(`Change handler ${changeHandlerName} was specified but not declared on the class.`); } + /** + * When view model doesn't have change handler but this property has reflection + */ + if (selfSubscriber === null && reflectFunction !== undefined) { + selfSubscriber = (newValue, oldValue) => callReflection(viewModel, reflectFunction, attrName, newValue); + } + if (defaultValue !== undefined) { initialValue = typeof defaultValue === 'function' ? defaultValue.call(viewModel) : defaultValue; } @@ -248,3 +301,26 @@ export class BindableProperty { observer.selfSubscriber = selfSubscriber; } } + +/** + * @param viewModel the view model instance + * @param attrName name of attribute will be set on the element + * @param newValue + */ +function callReflection(viewModel: Object, reflectFunction: (element: Element, attrName: string, val) => any, attrName: string, newValue) { + let { __element__ } = viewModel.__observers__; + reflectFunction(__element__, attrName, newValue); +} + +const reflectFunctions = { + string(element, attrName, newValue) { + element.setAttribute(attrName, newValue); + }, + boolean(element, attrName, newValue) { + if (newValue) { + element.setAttribute(attrName, ''); + } else { + element.removeAttribute(attrName); + } + } +}; diff --git a/src/html-behavior.js b/src/html-behavior.js index 50547782..2aebc587 100644 --- a/src/html-behavior.js +++ b/src/html-behavior.js @@ -46,6 +46,7 @@ export class HtmlBehaviorResource { this.attributes = {}; this.isInitialized = false; this.primaryProperty = null; + this.hasReflections = false; } /** @@ -155,8 +156,8 @@ export class HtmlBehaviorResource { for (i = 0, ii = properties.length; i < ii; ++i) { properties[i].defineOn(target, this); } - // Because how inherited properties would interact with the default 'value' property - // in a custom attribute is not well defined yet, we only inherit properties on + // Because how inherited properties would interact with the default 'value' property + // in a custom attribute is not well defined yet, we only inherit properties on // custom elements, where it's not a problem. this._copyInheritedProperties(container, target); } @@ -330,6 +331,10 @@ export class HtmlBehaviorResource { let childBindings = this.childBindings; let viewFactory; + if (element !== null && this.hasReflections) { + this.observerLocator.getOrCreateObserversLookup(viewModel).__element__ = element; + } + if (this.liftsContent) { //template controller au.controller = controller; @@ -424,9 +429,9 @@ export class HtmlBehaviorResource { } _copyInheritedProperties(container: Container, target: Function) { - // This methods enables inherited @bindable properties. - // We look for the first base class with metadata, make sure it's initialized - // and copy its properties. + // This methods enables inherited @bindable properties. + // We look for the first base class with metadata, make sure it's initialized + // and copy its properties. // We don't need to walk further than the first parent with metadata because // it had also inherited properties during its own initialization. let behavior, derived = target; @@ -441,7 +446,7 @@ export class HtmlBehaviorResource { break; } } - behavior.initialize(container, target); + behavior.initialize(container, target); for (let i = 0, ii = behavior.properties.length; i < ii; ++i) { let prop = behavior.properties[i]; // Check that the property metadata was not overriden or re-defined in this class @@ -451,6 +456,6 @@ export class HtmlBehaviorResource { // We don't need to call .defineOn() for those properties because it was done // on the parent prototype during initialization. new BindableProperty(prop).registerWith(derived, this); - } + } } } diff --git a/src/view-factory.js b/src/view-factory.js index 0900c694..d2534867 100644 --- a/src/view-factory.js +++ b/src/view-factory.js @@ -104,6 +104,10 @@ function setAttribute(name, value) { this._element.setAttribute(name, value); } +function removeAttribute(name) { + this._element.removeAttribute(name); +} + function makeElementIntoAnchor(element, elementInstruction) { let anchor = DOM.createComment('anchor'); @@ -119,6 +123,7 @@ function makeElementIntoAnchor(element, elementInstruction) { anchor.hasAttribute = hasAttribute; anchor.getAttribute = getAttribute; anchor.setAttribute = setAttribute; + anchor.removeAttribute = removeAttribute; } DOM.replaceNode(anchor, element); diff --git a/test/bindable-property.spec.js b/test/bindable-property.spec.js index 4fd3932e..b130e16d 100644 --- a/test/bindable-property.spec.js +++ b/test/bindable-property.spec.js @@ -13,4 +13,41 @@ describe('BindableProperty', () => { expect(new BindableProperty({ name: 'test', defaultBindingMode: null }).defaultBindingMode).toBe(oneWay); expect(new BindableProperty({ name: 'test', defaultBindingMode: undefined }).defaultBindingMode).toBe(oneWay); }); + + describe('reflects to attribute', () => { + let viewModel; + let element; + let observer; + beforeEach(() => { + element = document.createElement('div'); + viewModel = { __observers__: { __element__: element } }; + }); + + it('should reflect prop as string', () => { + let prop = new BindableProperty({ + reflectToAttribute: 'string', + name: 'prop' + }); + prop.owner = {}; + observer = prop.createObserver(viewModel); + observer.selfSubscriber('Hello'); + expect(element.getAttribute('prop')).toBe('Hello'); + }); + + it('should reflect prop as boolean', () => { + let prop = new BindableProperty({ + reflectToAttribute: 'boolean', + name: 'prop' + }); + prop.owner = {}; + observer = prop.createObserver(viewModel); + observer.selfSubscriber('Hello'); + expect(element.getAttribute('prop')).toBe(''); + ['', NaN, 0, false, null, undefined].forEach(v => { + observer.selfSubscriber(v); + expect(element.hasAttribute('prop')).toBe(false); + element.setAttribute('prop', 'Hello'); + }); + }); + }); }); diff --git a/test/html-behavior.spec.js b/test/html-behavior.spec.js index 26395015..2cb08432 100644 --- a/test/html-behavior.spec.js +++ b/test/html-behavior.spec.js @@ -3,6 +3,7 @@ import {Container} from 'aurelia-dependency-injection'; import {ObserverLocator, bindingMode} from 'aurelia-binding'; import {TaskQueue} from 'aurelia-task-queue'; import {HtmlBehaviorResource} from '../src/html-behavior'; +import {BindableProperty} from '../src/bindable-property'; import {ViewResources} from '../src/view-resources'; describe('html-behavior', () => {