├── .eslintrc.json ├── .gitignore ├── .release-it.json ├── README.md ├── demo ├── addons │ ├── properties-changed-callback.html │ ├── properties-changed-handler.html │ └── property-changed-handler.html ├── dom-properties.html ├── observed-properties.html ├── properties.html └── reflected-properties.html ├── package-lock.json ├── package.json ├── src ├── addons │ ├── index.js │ ├── properties-changed-callback-mixin.js │ ├── properties-changed-handler-mixin.js │ └── property-changed-handler-mixin.js ├── dom-properties-mixin.js ├── index.js ├── observed-properties-mixin.js ├── properties-mixin.js ├── reflected-properties-mixin.js └── utils │ └── attribute-converters │ ├── boolean-converter.js │ ├── index.js │ ├── number-converter.js │ ├── object-converter.js │ └── string-converter.js └── test ├── dom-properties.js ├── index.html ├── observed-properties.js └── reflected-properties.js /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es6": true 5 | }, 6 | "extends": "eslint:recommended", 7 | "globals": { 8 | "Atomics": "readonly", 9 | "SharedArrayBuffer": "readonly" 10 | }, 11 | "parserOptions": { 12 | "ecmaVersion": 11, 13 | "sourceType": "module", 14 | "allowImportExportEverywhere": true 15 | }, 16 | "rules": { 17 | "indent": [ 18 | "error", 19 | 2 20 | ], 21 | "linebreak-style": [ 22 | "error", 23 | "unix" 24 | ], 25 | "quotes": [ 26 | "error", 27 | "single" 28 | ], 29 | "semi": [ 30 | "error", 31 | "always" 32 | ] 33 | } 34 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | *.pem 3 | -------------------------------------------------------------------------------- /.release-it.json: -------------------------------------------------------------------------------- 1 | { 2 | "git": { 3 | "commit": true, 4 | "commitMessage": "Release ${version}", 5 | "push": true, 6 | "requireCleanWorkingDir": true, 7 | "tagName": "v${version}", 8 | "tag": true 9 | }, 10 | "github": { 11 | "release": true, 12 | "releaseName": "${version}" 13 | }, 14 | "npm": { 15 | "publish": true 16 | } 17 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # # html-element-property-mixins 2 | 3 | ## Installation 4 | 5 | ```bash 6 | $ npm install html-element-property-mixins 7 | ``` 8 | 9 | ## Introduction 10 | `html-element-property-mixins` is a collection of mixins extending `HTMLElement` with properties, powering custom elements. 11 | 12 | 1. **[ObservedProperties](#ObservedProperties)** enables observed properties (just like built-in `observedAttributes`). 13 | 2. **[DOMProperties](#DOMProperties)** enables attribute to property synchonisation. 14 | 3. **[ReflectedProperties](#ReflectedProperties)** enables property to attribute synchonisation. 15 | 4. **[Properties](#Properties)** combines all three above. 16 | 17 | Furthermore, we created a bunch of addons: 18 | 19 | 1. **[PropertiesChangedCallback](#PropertiesChangedCallback)** Debounces / batches property changes for efficient DOM-rendering. 20 | 2. **[PropertyChangedHandler](#PropertyChangedHandler)** enables change handlers methods for property changes. 21 | 3. **[PropertiesChangedHandler](#PropertiesChangedHandler)** enables change handlers methods for multiple property changes. 22 | 23 | ## Mixins 24 | 25 | ### ObservedProperties 26 | 27 | ```javascript 28 | import { ObservedProperties } from 'html-element-property-mixins'; 29 | ``` 30 | 31 | #### Observing 32 | By default, Custom Elements can observe attribute value changes whitelisted in the `observedAttributes` Array. `ObservedProperties` offers a similar solution for DOM properties using `observedProperties`. 33 | When a property has changed, `propertyChangedCallback` is called, passing the property name, the old value and the new value.' 34 | 35 | ```javascript 36 | class DemoElement extends ObservedProperties(HTMLElement) { 37 | 38 | static get observedProperties() { 39 | return ['firstName', 'lastName', 'age'] 40 | } 41 | 42 | propertyChangedCallback(propName, oldValue, newValue) { 43 | console.info(`${propName} changed from ${oldValue} to ${newValue}`); 44 | } 45 | 46 | } 47 | ``` 48 | 49 | If you like you can add your own getter / setter pairs: 50 | 51 | ```javascript 52 | static get observedProperties() { 53 | return ['initials'] 54 | } 55 | 56 | get initials() { 57 | return this._initials; 58 | } 59 | 60 | set initials(val) { 61 | this._initials = val.toUpperCase(); 62 | } 63 | 64 | constructor() { 65 | this.initials = 'a.b.c.'; 66 | } 67 | 68 | propertyChangedCallback(propName, oldValue, newValue) { 69 | console.info(`${propName} changed to ${newValue}`); //initials changed to A.B.C; 70 | } 71 | ``` 72 | 73 | Accessors don't require a getter / setter pair. Keep in mind though that by default, private property values are assigned using the following pattern: `#${propName}`. 74 | 75 | ```javascript 76 | static get observedProperties() { 77 | return ['firstName'] 78 | } 79 | 80 | get firstName() { 81 | return this['#firstName'].toLowerCase() 82 | } 83 | 84 | ``` 85 | 86 | ### DOMProperties 87 | 88 | ```javascript 89 | import { DOMProperties } from 'html-element-property-mixins'; 90 | ``` 91 | 92 | Some native properties (e.g. input `value`) can be set using a DOM attribute. This mixin adds exactly this behavior: attribute to property sync: 93 | 94 | ```javascript 95 | class DemoElement extends DOMProperties(HTMLElement) { 96 | 97 | static get DOMProperties() { 98 | return ['firstname', 'lastname'] 99 | } 100 | 101 | } 102 | ``` 103 | 104 | ```html 105 | 106 | 109 | ``` 110 | 111 | By default, attributes are lowercased property names (e.g. 'myPropName' becomes 'mypropname'). You can configure custom attribute mappings using 'propertyAttributeNames': 112 | 113 | ```javascript 114 | static get DOMProperties() { 115 | return ['myBestFriend'] 116 | } 117 | 118 | static get propertyAttributeNames() { 119 | return { 120 | myBestFriend: 'my-best-friend', 121 | } 122 | } 123 | ``` 124 | 125 | ```html 126 | 127 | ``` 128 | 129 | #### Attribute Converters 130 | Attribute values are always strings. If you wish to set attributes based on properties taht have a specific type, you can confifure converters using `propertyFromAttributeConverters`: 131 | 132 | 133 | ```javascript 134 | static get DOMProperties() { 135 | return ['married', 'friends'] 136 | } 137 | 138 | static get propertyFromAttributeConverters() { 139 | return { 140 | married: function(value) { 141 | if(value === '') return true; 142 | return false; 143 | }, 144 | friends: function(value) { 145 | if(!value) return null; 146 | return JSON.parse(value); 147 | } 148 | } 149 | } 150 | ``` 151 | 152 | ```html 153 | 154 | 157 | ``` 158 | 159 | `html-element-property-mixins` come with a set of attribute converters for `boolean`, `string`, `number` and `object` types: 160 | 161 | ```javascript 162 | import { StringConverter, NumberConverter, BooleanConverter, ObjectConverter } from 'html-element-property-mixins/utils/attribute-converters'; 163 | 164 | static get propertyFromAttributeConverters() { 165 | return { 166 | firstName: StringConverter.fromAttribute, 167 | age: NumberConverter.fromAttribute, 168 | married: BooleanConverter.fromAttribute, 169 | friends: ObjectConverter.fromAttribute, 170 | } 171 | } 172 | ``` 173 | 174 | ### ReflectedProperties 175 | 176 | ```javascript 177 | import { ReflectedProperties, ObservedProperties } from 'html-element-property-mixins'; 178 | ``` 179 | 180 | This enables property to attribute sync. Using the 'reflectedProperties' object, one can map properties (keys) to attributes (values). The [ObservedProperties](#ObservedProperties) mixin is required. 181 | 182 | ```javascript 183 | class DemoElement extends ReflectedProperties(ObservedProperties(HTMLElement)) { 184 | 185 | static get observedProperties() { 186 | return ['firstname', 'lastname', 'age'] 187 | } 188 | 189 | static get reflectedProperties() { 190 | return ['firstname', 'lastname', 'age'] 191 | } 192 | 193 | constructor() { 194 | this.firstname = 'Amira'; 195 | this.firstname = 'Arif'; 196 | this.age = 24; 197 | } 198 | 199 | } 200 | ``` 201 | 202 | By default, attributes are lowercased property names (e.g. 'myPropName' becomes 'mypropname'). You can configure custom attribute mappings using 'propertyAttributeNames': 203 | 204 | ```javascript 205 | static get reflectedProperties() { 206 | return ['firstName'] 207 | } 208 | 209 | static get propertyAttributeNames() { 210 | return { 211 | firstName: 'first-name', 212 | } 213 | } 214 | ``` 215 | 216 | ```html 217 | 218 | ``` 219 | 220 | #### Attribute Converters 221 | Attribute values are always strings. If you wish to set attributes based on properties taht have a specific type, you can confifure converters using `propertyToAttributeConverters`: 222 | 223 | 224 | ```javascript 225 | static get reflectedProperties() { 226 | return ['married', 'friends'] 227 | } 228 | 229 | static get propertyToAttributeConverters() { 230 | return { 231 | married: function(value) { 232 | if(value === '') return true; 233 | return false; 234 | }, 235 | friends: function(value) { 236 | if(!value) return null; 237 | return JSON.parse(value); 238 | } 239 | } 240 | } 241 | 242 | ``` 243 | 244 | ```html 245 | 246 | 249 | ``` 250 | 251 | `html-element-property-mixins` come with a set of attribute converters for `boolean`, `string`, `number` and `object` types. Attributes are set based on the return value of these functions: when `false` or `undefined`, `removeAttribute` is called. Otherwise, `setAttribute` is called using the return value. 252 | 253 | ```javascript 254 | import { StringConverter, NumberConverter, BooleanConverter, ObjectConverter } from 'html-element-property-mixins/utils/attribute-converters'; 255 | 256 | static get reflectedProperties() { 257 | return ['firstName', 'age', 'married', 'friends'] 258 | } 259 | 260 | static get propertyToAttributeConverters() { 261 | return { 262 | firstName: StringConverter.toAttribute, 263 | age: NumberConverter.toAttribute, 264 | married: BooleanConverter.toAttribute, 265 | friends: ObjectConverter.toAttribute, 266 | } 267 | } 268 | ``` 269 | 270 | > NOTE: `ObservedProperties` is required for `ReflectedProperties`. 271 | 272 | ### Properties 273 | 274 | ```javascript 275 | import { Properties } from 'html-element-property-mixins'; 276 | ``` 277 | 278 | This wraps all property mixins into a single `properties` configuration object. 279 | 280 | ```javascript 281 | class DemoElement extends Properties(HTMLElement) { 282 | 283 | static get properties() { 284 | return { 285 | firstName: { 286 | observe: true, //add to `observedProperties` array 287 | DOM: true, //add to `DOMProperties` array 288 | reflect: true, //add to `reflectedProperties` array 289 | attributeName: 'first-name', //map to custom attribute name, 290 | toAttributeConverter: StringConverter.toAttribute, //run when converting to attribute 291 | fromAttributeConverter: StringConverter.fromAttribute //run when converting from attribute 292 | } 293 | } 294 | } 295 | 296 | } 297 | ``` 298 | 299 | If you use the [PropertyChangedHandler](#PropertyChangedHandler) addon, you can add 'changedHandler' to your config: 300 | 301 | ```javascript 302 | class DemoElement extends PropertyChangedHandler(Properties(HTMLElement)) { 303 | 304 | static get properties() { 305 | return { 306 | age: { 307 | observe: true, 308 | changedHandler: '_firstNameChanged', 309 | } 310 | } 311 | } 312 | 313 | _firstNameChanged(oldValue, newValue) { 314 | //custom handler here! 315 | } 316 | 317 | } 318 | ``` 319 | 320 | 321 | ## Addons 322 | 323 | ### PropertiesChangedCallback 324 | ```javascript 325 | import { ObservedProperties } from 'html-element-property-mixins'; 326 | import { PropertiesChangedCallback } from 'html-element-property-mixins/src/addons'; 327 | 328 | ``` 329 | 330 | When declaring observed properties using the `observedProperties` array, property changes are fired each time a a property changes using the `propertyChangedCallback`. For efficiency reasons (e.g. when rendering DOM), the `propertiesChangedCallback` (plural!) can be used. This callback is debounced by cancel / requestAnimationFrame on every property change. In the following example, `render` is invoked only once: 331 | 332 | ```javascript 333 | import { PropertiesChangedCallback } from 'html-element-property-mixins/src/addons'; 334 | import { ObservedProperties } from 'html-element-property-mixins'; 335 | 336 | class DemoElement extends PropertiesChangedCallback(ObservedProperties(HTMLElement)) { 337 | 338 | constructor() { 339 | super(); 340 | this._renderCount = 0; 341 | } 342 | 343 | static get observedProperties() { 344 | return ['firstName', 'lastName', 'age']; 345 | } 346 | 347 | propertiesChangedCallback(propNames, oldValues, newValues) { 348 | this._renderCount++; 349 | this.render(); 350 | } 351 | 352 | render() { 353 | this.innerHTML = ` 354 | Hello, ${this.firstName} ${this.lastName} (${this.age} years).
355 | Render Count = ${this._renderCount}. 356 | ` 357 | } 358 | 359 | constructor() { 360 | super(); 361 | this.firstName = 'Amina'; 362 | this.lastName = 'Hamzaoui'; 363 | this.age = 24; 364 | } 365 | 366 | } 367 | ``` 368 | 369 | ### PropertyChangedHandler 370 | 371 | ```javascript 372 | import { ObservedProperties } from 'html-element-property-mixins'; 373 | import { PropertyChangedHandler } from 'html-element-property-mixins/src/addons'; 374 | ``` 375 | 376 | Value changes to properties whitelisted in the `observedProperties` array are always notified using `propertyChangedCallback`. PropertyChangedHandler provides for custom callbacks for property changes: 377 | 378 | ```javascript 379 | class DemoElement extends PropertyChangedHandler(ObservedProperties((HTMLElement)) { 380 | static get observedProperties() { 381 | return ['firstName'] 382 | } 383 | 384 | static get propertyChangedHandlers() { 385 | return { 386 | firstName: function(newValue, oldValue) { 387 | console.info('firstName changed!', newValue, oldValue); 388 | } 389 | } 390 | } 391 | } 392 | ``` 393 | 394 | Alternatively, callbacks can be passed as string references: 395 | ```javascript 396 | static get propertyChangedHandlers() { 397 | return { firstName: '_firstNameChanged' } 398 | } 399 | 400 | _firstNameChanged(newValue, oldValue) { 401 | console.info('firstName changed!', newValue, oldValue); 402 | } 403 | ``` 404 | 405 | > **Note**: `PropertyChangedHandler` should always be used in conjunction with `ObservedProperties`. 406 | 407 | ### PropertiesChangedHandler 408 | 409 | ```javascript 410 | import { ObservedProperties } from 'html-element-property-mixins'; 411 | import { PropertiesChangedHandler } from 'html-element-property-mixins/src/addons'; 412 | ``` 413 | 414 | Its plural companion `propertiesChangedHandlers` can be used to invoke a function when one of many properties have changed. Key / value pairs are now swapped. A key refers to the handler function, the value holds an array of the observed properties. 415 | 416 | ```javascript 417 | class DemoElement extends PropertiesChangedHandler(ObservedProperties((HTMLElement)) { 418 | static get observedProperties() { 419 | return ['firstName', 'lastName'] 420 | } 421 | 422 | static get propertiesChangedHandlers() { 423 | return { 424 | _nameChanged: ['firstName', 'lastName'] 425 | } 426 | } 427 | 428 | _nameChanged(propNames, newValues, oldValues) { 429 | console.info(newValues.firstName, newValues.lastName); 430 | } 431 | 432 | } 433 | ``` 434 | 435 | > **Note**: `PropertiesChangedHandler` should always be used in conjunction with `ObservedProperties`. 436 | -------------------------------------------------------------------------------- /demo/addons/properties-changed-callback.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 32 | 33 | 34 | 35 | 36 | 37 | 44 | 45 | 46 | -------------------------------------------------------------------------------- /demo/addons/properties-changed-handler.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 29 | 30 | 31 | 32 | 33 | 34 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /demo/addons/property-changed-handler.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 34 | 35 | 36 | 37 | 38 | 39 | 43 | 44 | 45 | -------------------------------------------------------------------------------- /demo/dom-properties.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 50 | 51 | 52 | 53 | 59 | 60 | -------------------------------------------------------------------------------- /demo/observed-properties.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 32 | 33 | 34 | 35 | 36 | 37 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /demo/properties.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 78 | 79 | 80 | 81 | 88 | 89 | 90 | -------------------------------------------------------------------------------- /demo/reflected-properties.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 47 | 48 | 49 | 50 | 51 | 52 | 59 | 60 | 61 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "html-element-property-mixins", 3 | "version": "0.11.0", 4 | "description": "A collection of mixins extending HTMLElement with properties.", 5 | "main": "src/index.js", 6 | "scripts": { 7 | "start": "es-dev-server --app-index demo/index.html --http2 --node-resolve --watch --open", 8 | "start:compatibility": "es-dev-server --app-index demo/index.html --http2 --compatibility --node-resolve --watch --open", 9 | "lint": "eslint --ext .js . --ignore-path .gitignore", 10 | "format": "eslint --ext .js src/ --fix --ignore-path .gitignore", 11 | "release": "release-it" 12 | }, 13 | "author": "Wouter Vroege", 14 | "license": "MIT", 15 | "bugs": { 16 | "url": "https://github.com/woutervroege/html-element-property-mixins/issues" 17 | }, 18 | "homepage": "https://github.com/woutervroege/html-element-property-mixins#readme", 19 | "devDependencies": { 20 | "chai": "^4.3.4", 21 | "es-dev-server": "^2.1.0", 22 | "eslint": "^7.26.0", 23 | "husky": "^6.0.0", 24 | "mocha": "^8.4.0", 25 | "release-it": "^14.6.2" 26 | }, 27 | "husky": { 28 | "hooks": { 29 | "pre-commit": "npm run format" 30 | } 31 | }, 32 | "files": [ 33 | "src" 34 | ] 35 | } 36 | -------------------------------------------------------------------------------- /src/addons/index.js: -------------------------------------------------------------------------------- 1 | export { PropertyChangedHandler } from './property-changed-handler-mixin.js'; 2 | export { PropertiesChangedHandler } from './properties-changed-handler-mixin.js'; 3 | export { PropertiesChangedCallback } from './properties-changed-callback-mixin.js'; -------------------------------------------------------------------------------- /src/addons/properties-changed-callback-mixin.js: -------------------------------------------------------------------------------- 1 | export const PropertiesChangedCallback = (SuperClass) => class extends SuperClass { 2 | 3 | propertyChangedCallback(propName, oldValue, newValue) { 4 | super.propertyChangedCallback && super.propertyChangedCallback(propName, oldValue, newValue); 5 | if(!this.__changedProperties) this.__changedProperties = new Map(); 6 | this.constructor.__addChangedProperty.call(this, propName, oldValue); 7 | } 8 | 9 | static __addChangedProperty(propName, oldValue) { 10 | if(!this.__changedProperties.has(propName)) this.__changedProperties.set(propName, oldValue); 11 | window.setTimeout(this.constructor.__invokeCallback.bind(this)); 12 | } 13 | 14 | static __invokeCallback() { 15 | if(this.__changedProperties.size === 0) return; 16 | const oldValues = {}; 17 | const newValues = {}; 18 | this.__changedProperties.forEach((oldValue, propName) => oldValues[propName] = oldValue); 19 | this.__changedProperties.forEach((oldValue, propName) => newValues[propName] = this[propName]); 20 | const propNames = Object.keys(oldValues); 21 | 22 | this.__changedProperties.clear(); 23 | this.propertiesChangedCallback && this.propertiesChangedCallback(propNames, oldValues, newValues); 24 | } 25 | 26 | }; -------------------------------------------------------------------------------- /src/addons/properties-changed-handler-mixin.js: -------------------------------------------------------------------------------- 1 | export const PropertiesChangedHandler = (SuperClass) => class extends SuperClass { 2 | 3 | propertiesChangedCallback(propNames, oldValues, newValues) { 4 | super.propertiesChangedCallback && super.propertiesChangedCallback(propNames, oldValues, newValues); 5 | this.constructor.__callMultiPropertyHandlers.call(this, propNames); 6 | } 7 | 8 | static __callMultiPropertyHandlers(propNames) { 9 | const callMethods = new Map(); 10 | const handlers = this.constructor.propertiesChangedHandlers || {}; 11 | for(let i in propNames) { 12 | for(let methodName in handlers) { 13 | const handlerPropNames = handlers[methodName]; 14 | if(handlerPropNames.indexOf(propNames[i]) !== -1) callMethods.set(methodName, handlerPropNames); 15 | } 16 | } 17 | callMethods.forEach((props, methodName) => this[methodName].call(this, ...[...props.map(propName => this[propName])])); 18 | } 19 | 20 | static get propertiesChangedHandlers() { return {}; } 21 | 22 | }; -------------------------------------------------------------------------------- /src/addons/property-changed-handler-mixin.js: -------------------------------------------------------------------------------- 1 | export const PropertyChangedHandler = (SuperClass) => class extends SuperClass { 2 | 3 | propertyChangedCallback(propName, oldValue, newValue) { 4 | super.propertyChangedCallback && super.propertyChangedCallback(propName, oldValue, newValue); 5 | this.constructor.__callPropertyHandlers.call(this, propName, oldValue, newValue); 6 | } 7 | 8 | static __callPropertyHandlers(propName, oldValue, newValue) { 9 | const handlers = this.constructor.propertyChangedHandlers || {}; 10 | const handler = handlers[propName]; 11 | if(!handler || !handler.constructor) return; 12 | if(handler.constructor.name === 'Function') handler.call(this, oldValue, newValue); 13 | else if(handler.constructor.name === 'String' && this[handler]) return this[handler].call(this, oldValue, newValue); 14 | } 15 | 16 | }; -------------------------------------------------------------------------------- /src/dom-properties-mixin.js: -------------------------------------------------------------------------------- 1 | export const DOMProperties = (SuperClass) => class extends SuperClass { 2 | 3 | static get observedAttributes() { 4 | const observedAttributes = []; 5 | const DOMProps = this.DOMProperties || []; 6 | for(let i in DOMProps) observedAttributes.push((this.propertyAttributeNames || {})[DOMProps[i]] || DOMProps[i].toLowerCase()); 7 | return observedAttributes; 8 | } 9 | 10 | attributeChangedCallback(attrName, oldValue, newValue) { 11 | if(oldValue === newValue) return; 12 | const propName = this.constructor.__getPropertyNameByAttributeName.call(this, attrName); 13 | if(!propName) return; 14 | this.constructor.__setDOMProperty.call(this, propName, this[propName], newValue); 15 | } 16 | 17 | static __getPropertyNameByAttributeName(attrName) { 18 | const attributeNames = this.constructor.propertyAttributeNames; 19 | for(let propName in attributeNames) if(attributeNames[propName] === attrName) return propName; 20 | const DOMPropertyNames = this.constructor.DOMProperties || []; 21 | for(let i in DOMPropertyNames) if(DOMPropertyNames[i].toLowerCase() === attrName) return DOMPropertyNames[i]; 22 | } 23 | 24 | static __setDOMProperty(propName, oldValue, newValue) { 25 | const converters = this.constructor.propertyFromAttributeConverters || {}; 26 | const converter = converters[propName]; 27 | if(converter) newValue = converter.call(this, oldValue, newValue); 28 | this[propName] = newValue; 29 | } 30 | 31 | }; -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | export { Properties } from './properties-mixin.js'; 2 | export { ObservedProperties } from './observed-properties-mixin.js'; 3 | export { DOMProperties } from './dom-properties-mixin.js'; 4 | export { ReflectedProperties } from './reflected-properties-mixin.js'; -------------------------------------------------------------------------------- /src/observed-properties-mixin.js: -------------------------------------------------------------------------------- 1 | export const ObservedProperties = (SuperClass) => class extends SuperClass { 2 | 3 | constructor() { 4 | super(); 5 | this.constructor.__saveInitialPropertyValues.call(this); 6 | this.constructor.__initProperties.call(this); 7 | } 8 | 9 | connectedCallback() { 10 | super.connectedCallback && super.connectedCallback(); 11 | this.constructor.__setInitialPropertyValues.call(this); 12 | } 13 | 14 | static __saveInitialPropertyValues() { 15 | this.__initialPropertyValues = new Map(); 16 | (this.constructor.observedProperties || []).map(propName => this.__initialPropertyValues.set(propName, this[propName])); 17 | } 18 | 19 | static __setInitialPropertyValues() { 20 | this.__initialPropertyValues.forEach((val, propName) => { 21 | if(val !== undefined && this[propName] === undefined) this[propName] = val; 22 | }); 23 | } 24 | 25 | static __initProperties() { 26 | this.constructor.__propertyAccessors = {}; 27 | const observedProps = this.constructor.observedProperties || []; 28 | observedProps.map(propName => this.constructor.__initProperty.call(this, propName)); 29 | } 30 | 31 | static __initProperty(propName) { 32 | this.constructor.__propertyAccessors[propName] = this.__getPropertyDescriptor(propName); 33 | Object.defineProperty(this, propName, { 34 | set(val) { this.constructor.__setProperty.call(this, propName, val); }, 35 | get() { return this.constructor.__getProperty.call(this, propName); }, 36 | }); 37 | } 38 | 39 | static __getProperty(propName) { 40 | const customAccessors = this.constructor.__propertyAccessors[propName] || {}; 41 | if(customAccessors.get) return customAccessors.get.call(this, propName); 42 | return this[`#${propName}`]; 43 | } 44 | 45 | static __setProperty(propName, newValue) { 46 | const customAccessors = this.constructor.__propertyAccessors[propName] || {}; 47 | const oldValue = this[propName]; 48 | if(customAccessors.set) customAccessors.set.call(this, newValue); 49 | else this[`#${propName}`] = newValue; 50 | this.constructor.__propertyValueChanged.call(this, propName, oldValue, this[propName]); 51 | } 52 | 53 | static __propertyValueChanged(propName, oldValue, newValue) { 54 | if(oldValue === newValue) return; 55 | this.propertyChangedCallback && this.propertyChangedCallback(propName, oldValue, newValue); 56 | } 57 | 58 | __getPropertyDescriptor(key) { 59 | const values = []; 60 | var obj = this; 61 | while(obj) { 62 | if(Object.getOwnPropertyDescriptor(obj, key)) values.push(Object.getOwnPropertyDescriptor(obj, key)); 63 | obj = Object.getPrototypeOf(obj); 64 | } 65 | const getter = values.find(item => item.get); 66 | const setter = values.find(item => item.set); 67 | const value = values.find(item => item.value); 68 | return {get: getter?.get, set: setter?.set, value: value?.value}; 69 | } 70 | 71 | }; -------------------------------------------------------------------------------- /src/properties-mixin.js: -------------------------------------------------------------------------------- 1 | import { ObservedProperties } from './observed-properties-mixin.js'; 2 | import { DOMProperties } from './dom-properties-mixin.js'; 3 | import { ReflectedProperties } from './reflected-properties-mixin.js'; 4 | 5 | export const Properties = (SuperClass) => class extends ReflectedProperties(DOMProperties(ObservedProperties(SuperClass))) { 6 | 7 | static get properties() { return {}; } 8 | 9 | static get observedProperties() { 10 | return Object.keys(this.__getFilteredProperties.call(this, 'observe', true)); 11 | } 12 | 13 | static get DOMProperties() { 14 | return Object.keys(this.__getFilteredProperties.call(this, 'DOM', true)); 15 | } 16 | 17 | static get reflectedProperties() { 18 | return Object.keys(this.__getFilteredProperties.call(this, 'reflect', true)); 19 | } 20 | 21 | static get propertyChangedHandlers() { 22 | return this.__getPropertyValues.call(this, 'changedHandler'); 23 | } 24 | 25 | static get propertyAttributeNames() { 26 | const propValues = {}; 27 | const props = this.properties; 28 | for(let propName in props) propValues[propName] = props[propName]['attributeName'] || propName.toLowerCase(); 29 | return propValues; 30 | } 31 | 32 | static get propertyToAttributeConverters() { 33 | return this.__getPropertyValues.call(this, 'toAttributeConverter'); 34 | } 35 | 36 | static get propertyFromAttributeConverters() { 37 | return this.__getPropertyValues.call(this, 'fromAttributeConverter'); 38 | } 39 | 40 | static __getFilteredProperties(key, value) { 41 | const filteredProps = {}; 42 | const props = this.properties; 43 | for(let propName in props) if(props[propName][key] === value) filteredProps[propName] = props[propName]; 44 | return filteredProps; 45 | } 46 | 47 | static __getPropertyValues(key) { 48 | const propValues = {}; 49 | const props = this.properties; 50 | for(let propName in props) propValues[propName] = props[propName][key]; 51 | return propValues; 52 | } 53 | 54 | }; -------------------------------------------------------------------------------- /src/reflected-properties-mixin.js: -------------------------------------------------------------------------------- 1 | export const ReflectedProperties = (SuperClass) => class extends SuperClass { 2 | 3 | connectedCallback() { 4 | for(var i in this.constructor.reflectedProperties) { 5 | const propName = this.constructor.reflectedProperties[i]; 6 | const attrName = this.constructor.__getAttributeNameByPropertyName.call(this, propName); 7 | this.constructor.__setDOMAttribute.call(this, attrName, propName, this[propName]); 8 | } 9 | super.connectedCallback(); 10 | } 11 | 12 | propertyChangedCallback(propName, oldValue, newValue) { 13 | super.propertyChangedCallback && super.propertyChangedCallback(propName, oldValue, newValue); 14 | if(!this.isConnected) return; 15 | 16 | const reflectedProps = this.constructor.reflectedProperties || {}; 17 | const attrReflects = reflectedProps.indexOf(propName) !== -1; 18 | if(!attrReflects) return; 19 | 20 | const attrName = this.constructor.__getAttributeNameByPropertyName.call(this, propName); 21 | this.constructor.__setDOMAttribute.call(this, attrName, propName, newValue); 22 | } 23 | 24 | static __setDOMAttribute(attrName, propName, value) { 25 | const converters = this.constructor.propertyToAttributeConverters || {}; 26 | const converter = converters[propName]; 27 | if(converter) value = converter.call(this, value); 28 | if(value === null || value === undefined) return this.removeAttribute(attrName); 29 | this.setAttribute(attrName, value); 30 | } 31 | 32 | static __getAttributeNameByPropertyName(propName) { 33 | const reflectedProps = this.constructor.reflectedProperties || []; 34 | const attrNames = this.constructor.propertyAttributeNames || {}; 35 | if(reflectedProps.indexOf(propName) === -1) return; 36 | const attrName = attrNames[propName] || propName.toLowerCase(); 37 | return attrName; 38 | } 39 | 40 | }; -------------------------------------------------------------------------------- /src/utils/attribute-converters/boolean-converter.js: -------------------------------------------------------------------------------- 1 | export const BooleanFromAttribute = (oldValue, newValue) => { 2 | if(newValue === '') return true; 3 | return false; 4 | }; 5 | 6 | export const BooleanToAttribute = (newValue) => { 7 | if(!newValue) return; 8 | return ''; 9 | }; -------------------------------------------------------------------------------- /src/utils/attribute-converters/index.js: -------------------------------------------------------------------------------- 1 | import { BooleanFromAttribute, BooleanToAttribute } from './boolean-converter.js'; 2 | import { NumberFromAttribute, NumberToAttribute } from './number-converter.js'; 3 | import { ObjectFromAttribute, ObjectToAttribute } from './object-converter.js'; 4 | import { StringFromAttribute, StringToAttribute } from './string-converter.js'; 5 | 6 | export const BooleanConverter = { fromAttribute: BooleanFromAttribute, toAttribute: BooleanToAttribute }; 7 | export const NumberConverter = { fromAttribute: NumberFromAttribute, toAttribute: NumberToAttribute }; 8 | export const ObjectConverter = { fromAttribute: ObjectFromAttribute, toAttribute: ObjectToAttribute }; 9 | export const StringConverter = { fromAttribute: StringFromAttribute, toAttribute: StringToAttribute }; -------------------------------------------------------------------------------- /src/utils/attribute-converters/number-converter.js: -------------------------------------------------------------------------------- 1 | export const NumberFromAttribute = (oldValue, newValue) => { 2 | if(!oldValue && !newValue) return oldValue; 3 | if(newValue === '') return null; 4 | if(!newValue) return newValue; 5 | return Number(newValue); 6 | }; 7 | 8 | export const NumberToAttribute = (newValue) => { 9 | if(isNaN(newValue)) return; 10 | return newValue; 11 | }; -------------------------------------------------------------------------------- /src/utils/attribute-converters/object-converter.js: -------------------------------------------------------------------------------- 1 | export const ObjectFromAttribute = (oldValue, newValue) => { 2 | if(!oldValue && !newValue) return oldValue; 3 | if(!newValue) return newValue; 4 | try { return JSON.parse(newValue); } 5 | catch(e) { return null; } 6 | }; 7 | 8 | export const ObjectToAttribute = (newValue) => { 9 | if(!newValue) return; 10 | return JSON.stringify(newValue); 11 | }; -------------------------------------------------------------------------------- /src/utils/attribute-converters/string-converter.js: -------------------------------------------------------------------------------- 1 | export const StringFromAttribute = (oldValue, newValue) => { 2 | if(!oldValue && !newValue) return oldValue; 3 | if(!newValue) return newValue; 4 | return String(newValue); 5 | }; 6 | 7 | export const StringToAttribute = (newValue) => { 8 | if(newValue === '') return; 9 | return newValue; 10 | }; -------------------------------------------------------------------------------- /test/dom-properties.js: -------------------------------------------------------------------------------- 1 | import { DOMProperties } from '../src/dom-properties-mixin.js'; 2 | 3 | describe('default', async () => { 4 | 5 | const elementName = generateElementName(); 6 | 7 | customElements.define(elementName, class extends DOMProperties(HTMLElement) { 8 | static get DOMProperties() { return ['name']; } 9 | }); 10 | 11 | const el = document.createElement(elementName); 12 | 13 | it('propertyChangedCallback should invoke when property declared in `DOMProperties` array changes.', async () => { 14 | el.setAttribute('name', 'Giacomo'); 15 | chai.expect(el.name).to.equal('Giacomo'); 16 | }); 17 | 18 | }); 19 | 20 | function generateElementName() { 21 | var result = ''; 22 | var characters = 'abcdefghijklmnopqrstuvwxyz'; 23 | var charactersLength = characters.length; 24 | for ( var i = 0; i < 16; i++ ) { 25 | result += characters.charAt(Math.floor(Math.random() * charactersLength)); 26 | } 27 | return `${result}-element`; 28 | } -------------------------------------------------------------------------------- /test/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Mocha Tests 5 | 6 | 7 | 8 |
9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 22 | 23 | -------------------------------------------------------------------------------- /test/observed-properties.js: -------------------------------------------------------------------------------- 1 | import { ObservedProperties } from '../src/observed-properties-mixin.js'; 2 | 3 | describe('default', () => { 4 | 5 | const elementName = generateElementName(); 6 | 7 | customElements.define(elementName, class extends ObservedProperties(HTMLElement) { 8 | static get observedProperties() { return ['name']; } 9 | propertyChangedCallback(propName, oldValue, newValue) { 10 | this._propertyChangedCallbackInvoked = true; 11 | if(!this._changedProps) this._changedProps = {}; 12 | this._changedProps[propName] = arguments; 13 | } 14 | }); 15 | 16 | const el = document.createElement(elementName); 17 | 18 | it('propertyChangedCallback should invoke when property declared in `observedProperties` array changes.', () => { 19 | el.name = 'Giacomo'; 20 | chai.expect(el._propertyChangedCallbackInvoked).to.equal(true); 21 | }); 22 | 23 | it('propertyChangedCallback should have propname, old value and new value in arguments', () => { 24 | chai.expect(el._changedProps.name[0]).to.equal('name'); 25 | chai.expect(el._changedProps.name[1]).to.equal(undefined); 26 | chai.expect(el._changedProps.name[2]).to.equal('Giacomo'); 27 | }); 28 | 29 | }); 30 | 31 | describe('custom getter', () => { 32 | 33 | const elementName = generateElementName(); 34 | 35 | customElements.define(elementName, class extends ObservedProperties(HTMLElement) { 36 | static get observedProperties() { return ['name']; } 37 | 38 | get name() { 39 | return (this['#name'] || '').toLowerCase(); 40 | } 41 | 42 | propertyChangedCallback(propName, oldValue, newValue) { 43 | this._propertyChangedCallbackInvoked = true; 44 | if(!this._changedProps) this._changedProps = {}; 45 | this._changedProps[propName] = arguments; 46 | } 47 | }); 48 | 49 | const el = document.createElement(elementName); 50 | 51 | it('propertyChangedCallback should invoke when property declared in `observedProperties` array changes.', () => { 52 | el.name = 'Giacomo'; 53 | chai.expect(el._propertyChangedCallbackInvoked).to.equal(true); 54 | }); 55 | 56 | it('propertyChangedCallback should have propname, old value and new value (getter return value) in arguments', () => { 57 | chai.expect(el._propertyChangedCallbackInvoked).to.equal(true); 58 | chai.expect(el._changedProps.name[2]).to.equal('giacomo'); 59 | }); 60 | 61 | }); 62 | 63 | describe('custom setter', () => { 64 | 65 | const elementName = generateElementName(); 66 | 67 | customElements.define(elementName, class extends ObservedProperties(HTMLElement) { 68 | static get observedProperties() { return ['name']; } 69 | 70 | set name(name) { 71 | this['#name'] = name.toUpperCase(); 72 | } 73 | 74 | propertyChangedCallback(propName, oldValue, newValue) { 75 | this._propertyChangedCallbackInvoked = true; 76 | if(!this._changedProps) this._changedProps = {}; 77 | this._changedProps[propName] = arguments; 78 | } 79 | }); 80 | 81 | const el = document.createElement(elementName); 82 | 83 | it('propertyChangedCallback should invoke when property declared in `observedProperties` array changes.', () => { 84 | el.name = 'Giacomo'; 85 | chai.expect(el._propertyChangedCallbackInvoked).to.equal(true); 86 | }); 87 | 88 | it('propertyChangedCallback should have propname, old value and new value (setter return value) in arguments', () => { 89 | chai.expect(el._changedProps.name[2]).to.equal('GIACOMO'); 90 | }); 91 | 92 | }); 93 | 94 | function generateElementName() { 95 | var result = ''; 96 | var characters = 'abcdefghijklmnopqrstuvwxyz'; 97 | var charactersLength = characters.length; 98 | for ( var i = 0; i < 16; i++ ) { 99 | result += characters.charAt(Math.floor(Math.random() * charactersLength)); 100 | } 101 | return `${result}-element`; 102 | } -------------------------------------------------------------------------------- /test/reflected-properties.js: -------------------------------------------------------------------------------- 1 | import { ObservedProperties } from '../src/observed-properties-mixin.js'; 2 | import { ReflectedProperties } from '../src/reflected-properties-mixin.js'; 3 | 4 | describe('default', async () => { 5 | 6 | const elementName = generateElementName(); 7 | 8 | customElements.define(elementName, class extends ReflectedProperties(ObservedProperties(HTMLElement)) { 9 | static get observedProperties() { return ['name']; } 10 | static get reflectedProperties() { return ['name']; } 11 | }); 12 | 13 | const el = document.createElement(elementName); 14 | 15 | it('propertyChangedCallback should invoke when property declared in `observedProperties` array changes.', async () => { 16 | document.body.appendChild(el); 17 | el.name = 'Giacomo'; 18 | chai.expect(el.getAttribute('name')).to.equal(el.name); 19 | }); 20 | 21 | }); 22 | 23 | function generateElementName() { 24 | var result = ''; 25 | var characters = 'abcdefghijklmnopqrstuvwxyz'; 26 | var charactersLength = characters.length; 27 | for ( var i = 0; i < 16; i++ ) { 28 | result += characters.charAt(Math.floor(Math.random() * charactersLength)); 29 | } 30 | return `${result}-element`; 31 | } --------------------------------------------------------------------------------