Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Makes createProperty easier to use to customize properties #914

Merged
merged 16 commits into from
Mar 10, 2020
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
## Unreleased

### Changed
* The value returned by `render` is always rendered, even if it isn't a `TemplateResult`. ([#712](https://github.com/Polymer/lit-element/issues/712)
* The static `createProperty` method has a new argument to allow easier customization of property accessors. A `descriptorFactory(options, descriptor, key)` can be optionally passed and should return a `PropertyDescriptor` to install on the property. If no descriptor is returned, a property accessors will not be created. ([#911](https://github.com/Polymer/lit-element/issues/911))
* The value returned by `render` is always rendered, even if it isn't a `TemplateResult`. ([#712](https://github.com/Polymer/lit-element/issues/712))

### Added
* Added `@queryAsync(selector)` decorator which returns a Promise that resolves to the result of querying for the given selector after the element's `updateComplete` Promise resolves ([#903](https://github.com/Polymer/lit-element/issues/903)).
Expand Down
2 changes: 1 addition & 1 deletion src/demo/ts-element.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ class TSElement extends LitElement {
@property() message = 'Hi';

@property(
{attribute: 'more-info', converter: (value: string) => `[${value}]`})
{attribute: 'more-info', converter: (value: string|null) => `[${value}]`})
extra = '';

render() {
Expand Down
61 changes: 41 additions & 20 deletions src/lib/updating-element.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ export interface ComplexAttributeConverter<Type = unknown, TypeHint = unknown> {
}

type AttributeConverter<Type = unknown, TypeHint = unknown> =
ComplexAttributeConverter<Type>|((value: string, type?: TypeHint) => Type);
ComplexAttributeConverter<Type>|((value: string|null, type?: TypeHint) => Type);

/**
* Defines options for a property accessor.
Expand Down Expand Up @@ -112,6 +112,10 @@ export interface PropertyDeclaration<Type = unknown, TypeHint = unknown> {
* the property changes.
*/
readonly noAccessor?: boolean;

// Allows extension while preserving the ability to use the
// @property decorator.
[index: string]: unknown;
}

/**
Expand Down Expand Up @@ -169,6 +173,9 @@ export interface HasChanged {
(value: unknown, old: unknown): boolean;
}

export type PropertyDescriptorFactory = (options: PropertyDeclaration,
sorvell marked this conversation as resolved.
Show resolved Hide resolved
descriptor: PropertyDescriptor, key: string|symbol) => PropertyDescriptor|null|void;

/**
* Change function that returns true if `value` is different from `oldValue`.
* This method is used as the default for a property's `hasChanged` function.
Expand Down Expand Up @@ -289,7 +296,8 @@ export abstract class UpdatingElement extends HTMLElement {
*/
static createProperty(
name: PropertyKey,
options: PropertyDeclaration = defaultPropertyDeclaration) {
options: PropertyDeclaration = defaultPropertyDeclaration,
descriptorFactory?: PropertyDescriptorFactory) {
sorvell marked this conversation as resolved.
Show resolved Hide resolved
// Note, since this can be called by the `@property` decorator which
// is called before `finalize`, we ensure storage exists for property
// metadata.
Expand All @@ -304,7 +312,7 @@ export abstract class UpdatingElement extends HTMLElement {
return;
}
const key = typeof name === 'symbol' ? Symbol() : `__${name}`;
sorvell marked this conversation as resolved.
Show resolved Hide resolved
Object.defineProperty(this.prototype, name, {
let descriptor: PropertyDescriptor|null|void = {
sorvell marked this conversation as resolved.
Show resolved Hide resolved
// tslint:disable-next-line:no-any no symbol in index
get(): any {
return (this as {[key: string]: unknown})[key as string];
Expand All @@ -313,11 +321,22 @@ export abstract class UpdatingElement extends HTMLElement {
const oldValue =
(this as {} as {[key: string]: unknown})[name as string];
(this as {} as {[key: string]: unknown})[key as string] = value;
(this as unknown as UpdatingElement)._requestUpdate(name, oldValue);
(this as unknown as UpdatingElement).requestUpdateInternal(name, oldValue);
},
configurable: true,
enumerable: true
});
};
if (typeof descriptorFactory === 'function') {
descriptor = descriptorFactory(options, descriptor, key);
}
if (descriptor != null) {
Object.defineProperty(this.prototype, name, descriptor);
}
}

protected static getDeclarationForProperty(name: PropertyKey) {
sorvell marked this conversation as resolved.
Show resolved Hide resolved
return this._classProperties && this._classProperties.get(name) ||
defaultPropertyDeclaration;
}

/**
Expand Down Expand Up @@ -392,9 +411,10 @@ export abstract class UpdatingElement extends HTMLElement {
private static _propertyValueFromAttribute(
value: string|null, options: PropertyDeclaration) {
const type = options.type;
const converter = options.converter || defaultConverter;
const converter = options.converter ||
defaultPropertyDeclaration.converter;
const fromAttribute =
(typeof converter === 'function' ? converter : converter.fromAttribute);
(typeof converter === 'function' ? converter : converter!.fromAttribute);
return fromAttribute ? fromAttribute(value, type) : value;
}

Expand All @@ -412,11 +432,14 @@ export abstract class UpdatingElement extends HTMLElement {
return;
}
const type = options.type;
const converter = options.converter;
const toAttribute =
converter && (converter as ComplexAttributeConverter).toAttribute ||
defaultConverter.toAttribute;
return toAttribute!(value, type);
let converter = options.converter;
let toAttribute =
converter && (converter as ComplexAttributeConverter).toAttribute;
if (!toAttribute) {
converter = defaultPropertyDeclaration.converter;
toAttribute = converter && (converter as ComplexAttributeConverter).toAttribute;
}
return toAttribute ? toAttribute(value, type) : value;
sorvell marked this conversation as resolved.
Show resolved Hide resolved
}

private _updateState: UpdateState = 0;
Expand Down Expand Up @@ -452,7 +475,7 @@ export abstract class UpdatingElement extends HTMLElement {
this._saveInstanceProperties();
// ensures first update will be caught by an early access of
// `updateComplete`
this._requestUpdate();
this.requestUpdateInternal();
}

/**
Expand Down Expand Up @@ -563,8 +586,7 @@ export abstract class UpdatingElement extends HTMLElement {
const ctor = (this.constructor as typeof UpdatingElement);
const propName = ctor._attributeToPropertyMap.get(name);
if (propName !== undefined) {
const options =
ctor._classProperties!.get(propName) || defaultPropertyDeclaration;
const options = ctor.getDeclarationForProperty(propName);
// mark state reflecting
this._updateState = this._updateState | STATE_IS_REFLECTING_TO_PROPERTY;
this[propName as keyof this] =
Expand All @@ -576,17 +598,16 @@ export abstract class UpdatingElement extends HTMLElement {
}

/**
* This private version of `requestUpdate` does not access or return the
* This protected version of `requestUpdate` does not access or return the
* `updateComplete` promise. This promise can be overridden and is therefore
* not free to access.
*/
private _requestUpdate(name?: PropertyKey, oldValue?: unknown) {
protected requestUpdateInternal(name?: PropertyKey, oldValue?: unknown) {
let shouldRequestUpdate = true;
// If we have a property key, perform property update steps.
if (name !== undefined) {
const ctor = this.constructor as typeof UpdatingElement;
const options =
ctor._classProperties!.get(name) || defaultPropertyDeclaration;
const options = ctor.getDeclarationForProperty(name);
if (ctor._valueHasChanged(
this[name as keyof this], oldValue, options.hasChanged)) {
if (!this._changedProperties.has(name)) {
Expand Down Expand Up @@ -627,7 +648,7 @@ export abstract class UpdatingElement extends HTMLElement {
* @returns {Promise} A Promise that is resolved when the update completes.
*/
requestUpdate(name?: PropertyKey, oldValue?: unknown) {
this._requestUpdate(name, oldValue);
this.requestUpdateInternal(name, oldValue);
return this.updateComplete;
}

Expand Down
132 changes: 130 additions & 2 deletions src/test/lib/updating-element_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@
* http://polymer.github.io/PATENTS.txt
*/

import {property} from '../../lib/decorators.js';
import {ComplexAttributeConverter, PropertyDeclarations, PropertyValues, UpdatingElement} from '../../lib/updating-element.js';
import {property, customElement} from '../../lib/decorators.js';
import {ComplexAttributeConverter, PropertyDeclarations, PropertyValues, UpdatingElement, PropertyDeclaration, PropertyDescriptorFactory, defaultConverter} from '../../lib/updating-element.js';
import {generateElementName} from '../test-helpers.js';

// tslint:disable:no-any ok in tests
Expand Down Expand Up @@ -1802,6 +1802,134 @@ suite('UpdatingElement', () => {
assert.equal(sub.getAttribute('foo'), '5');
});

test('can provide a default property declaration by implementing createProperty', async () => {

const SpecialNumber = {};

const myPropertyDeclaration = {
type: SpecialNumber,
reflect: true,
converter: {
toAttribute: function(value: unknown, type?: unknown): unknown {
switch (type) {
case String:
return value === undefined ? null : value;
default:
return defaultConverter.toAttribute!(value, type);
}
},
fromAttribute: function(value: string|null, type?: unknown) {
switch (type) {
case SpecialNumber:
return Number(value) + 10;
default:
return defaultConverter.fromAttribute!(value, type);
}
}
}
};

@customElement(generateElementName())
class E extends UpdatingElement {

static createProperty(
name: PropertyKey,
options: PropertyDeclaration, descriptorFactory: PropertyDescriptorFactory) {
// Always mix into defaults to preserve custom converter.
options = Object.assign(Object.create(myPropertyDeclaration), options);
super.createProperty(name, options, descriptorFactory);
}

@property()
foo = 5;

@property({type: String})
bar?: string = 'bar';
}

const el = new E();
container.appendChild(el);
el.setAttribute('foo', '10');
el.setAttribute('bar', 'attrBar');
await el.updateComplete;
assert.equal(el.foo, 20);
assert.equal(el.bar, 'attrBar');
el.foo = 5;
el.bar = undefined;
await el.updateComplete;
assert.equal(el.getAttribute('foo'), '5');
assert.isFalse(el.hasAttribute('bar'));
});

test('can customize property options and accessor creation by implementing createProperty', async () => {

interface MyPropertyDeclaration<TypeHint = unknown> extends PropertyDeclaration {
validator?: (value: any) => TypeHint;
observer?: (oldValue: TypeHint) => void;
}

@customElement(generateElementName())
class E extends UpdatingElement {

static createProperty(
name: PropertyKey,
options: MyPropertyDeclaration) {
const descriptorFactory = (options: MyPropertyDeclaration,
descriptor: PropertyDescriptor) => ({
// tslint:disable-next-line:no-any no symbol in index
get: descriptor.get,
set(this: E, value: unknown) {
if (options.validator) {
value = options.validator(value);
}
descriptor.set?.call(this, value);
},
configurable: descriptor.configurable,
enumerable: descriptor.enumerable
});
super.createProperty(name, options, descriptorFactory);
}

updated(changedProperties: PropertyValues) {
super.updated(changedProperties);
changedProperties.forEach((value: unknown, key: PropertyKey) => {
const options = (this.constructor as typeof UpdatingElement)
.getDeclarationForProperty(key) as MyPropertyDeclaration;
const observer = options.observer;
if (typeof observer === 'function') {
observer.call(this, value);
}
});
}

@property({type: Number, validator: (value: number) => Math.min(10, Math.max(value, 0))})
foo = 5;

@property({})
bar = 'bar';

// tslint:disable-next-line:no-any
_observedZot?: any;

@property({observer: function(this: E, oldValue: string) { this._observedZot = {value: this.zot, oldValue}; } })
zot = '';
}

const el = new E();
container.appendChild(el);
await el.updateComplete;
el.foo = 20;
assert.equal(el.foo, 10);
assert.deepEqual(el._observedZot, {value: '', oldValue: undefined});
el.foo = -5;
assert.equal(el.foo, 0);
el.bar = 'bar2';
assert.equal(el.bar, 'bar2');
el.zot = 'zot';
await el.updateComplete;
assert.deepEqual(el._observedZot, {value: 'zot', oldValue: ''});
});

test('attribute-based property storage', async () => {
class E extends UpdatingElement {
_updateCount = 0;
Expand Down