├── .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 | }
--------------------------------------------------------------------------------