@feezal/feezal-element 0.8.0 → 3.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/feezal-element.js CHANGED
@@ -1,122 +1,185 @@
1
- /* global window, feezal */
1
+ /* global feezal */
2
+ import {LitElement, html, css} from 'lit';
3
+ import {FeezalConditions} from './feezal-conditions.js';
2
4
 
3
- import {PolymerElement, html} from '@polymer/polymer/polymer-element.js';
5
+ /**
6
+ * Shared base styles — identical to the Polymer dom-module 'feezal-style-element'.
7
+ * Exported so subclasses can compose via:
8
+ * static styles = [FeezalElementLit.styles, css`...additional...`];
9
+ */
10
+ export const feezalBaseStyles = css`
11
+ :host {
12
+ display: inline-block;
13
+ box-sizing: border-box;
14
+ overflow: hidden;
15
+ }
16
+ :host([hidden]) {
17
+ display: none;
18
+ }
19
+ :host(.feezal-editable) {
20
+ outline: 1px dashed rgba(var(--feezal-selection-rgb, 2,132,199), 0.8);
21
+ }
22
+ :host(.feezal-editable) * {
23
+ pointer-events: none;
24
+ }
25
+ :host(.feezal-editable.feezal-selected) {
26
+ outline: 2px dashed rgba(var(--feezal-selection-rgb, 2,132,199), 0.9);
27
+ }
28
+ `;
4
29
 
5
- const styleElement = document.createElement('dom-module');
30
+ /**
31
+ * FeezalElement — Lit base class for feezal elements.
32
+ *
33
+ * A faithful port of the Polymer-based FeezalPolymerElement to Lit 3.
34
+ * Paper elements keep importing the Polymer FeezalPolymerElement unchanged;
35
+ * only the basic-* elements use this class.
36
+ *
37
+ * Key differences from the Polymer base:
38
+ * - Subclass CSS goes in static styles = [FeezalElement.styles, css`...`]
39
+ * - Polymer observers become updated(changed) guards
40
+ * - this.$.id becomes this.renderRoot.querySelector('#id')
41
+ * - Lit needs explicit attribute: 'kebab-name' for camelCase properties
42
+ */
43
+ export class FeezalElement extends LitElement {
44
+ static styles = feezalBaseStyles;
6
45
 
7
- styleElement.innerHTML =
8
- `<template>
9
- <style>
10
- :host {
11
- display: inline-block;
12
- box-sizing: border-box;
13
- overflow: hidden;
14
- }
15
- :host([hidden]) {
16
- display: none;
17
- }
18
- :host(.feezal-editable) {
19
- outline: 1px dashed rgba(250, 120, 0, 0.8);
20
- }
21
- :host(.feezal-editable) * {
22
- pointer-events: none;
23
- }
24
- :host(.feezal-editable.feezal-selected) {
25
- outline: 2px dashed rgba(250, 120, 0, 0.8);
26
- }
27
- </style>
28
- </template>`;
46
+ static get feezal() {
47
+ return {attributes: [], styles: []};
48
+ }
29
49
 
30
- styleElement.register('feezal-style-element');
50
+ static properties = {
51
+ // NOTE: Lit does NOT auto-convert camelCase to kebab-case for attribute
52
+ // names (unlike Polymer). Each camelCase property needs an explicit
53
+ // attribute option so that saved views (which store kebab-case) are
54
+ // read back correctly.
55
+ subscribe: {type: String, reflect: true, attribute: 'subscribe'},
56
+ messageProperty: {type: String, reflect: true, attribute: 'message-property'},
57
+ dynamicSubscriptions: {type: Boolean, reflect: true, attribute: 'dynamic-subscriptions'},
58
+ visible: {type: Boolean, reflect: true},
59
+ // E50: JSON list of condition rows (visibility/class/style/attribute
60
+ // bound to MQTT topics). Never reflected — the attribute is the
61
+ // source of truth; effects only apply in the viewer.
62
+ conditions: {type: String, attribute: 'conditions'},
63
+ };
31
64
 
32
- class FeezalElement extends PolymerElement {
33
- static get properties() {
34
- return {
35
- name: {
36
- type: String,
37
- value: '',
38
- reflectToAttribute: true,
39
- },
40
- 'selected': {
41
- type: Boolean,
42
- value: false
43
- },
44
- subscribeTopic: {
45
- type: String,
46
- value: '',
47
- reflectToAttribute: true,
48
- },
49
- messageProperty: {
50
- type: String,
51
- value: 'payload',
52
- reflectToAttribute: true,
53
- },
54
- 'dynamicSubscriptions': {
55
- type: Boolean,
56
- value: false,
57
- reflectToAttribute: true
58
- },
59
- 'visible': {
60
- type: Boolean,
61
- value: false,
62
- observer: '_visibleChanged'
63
- }
64
- }
65
+ constructor() {
66
+ super();
67
+ this._subscriptions = [];
68
+ this._subscribed = false;
69
+ this.messageProperty = 'payload';
70
+ this.dynamicSubscriptions = false;
71
+ this.visible = false;
72
+ this._conditions = new FeezalConditions(this);
65
73
  }
66
- static get feezal() {
67
- return {
68
- attributes: [],
69
- styles: []
74
+
75
+ connectedCallback() {
76
+ super.connectedCallback();
77
+ this.classList.add('feezal-element');
78
+ if (this.visible || !this.dynamicSubscriptions) {
79
+ this._subscribe();
80
+ this._conditions.connect();
70
81
  }
71
82
  }
72
83
 
73
- _payloadCast(type, payload) {
74
- if (typeof payload === 'string') {
75
- switch (type) {
76
- case Boolean:
77
- return Number(payload) !== 0 && payload.toLowerCase() !== 'false';
78
- default:
79
- return payload;
84
+ disconnectedCallback() {
85
+ super.disconnectedCallback();
86
+ this._unsubscribe();
87
+ this._conditions.disconnect();
88
+ }
89
+
90
+ updated(changed) {
91
+ if (changed.has('visible') && this.dynamicSubscriptions) {
92
+ if (this.visible) {
93
+ this._subscribe();
94
+ this._conditions.connect();
95
+ } else {
96
+ this._unsubscribe();
97
+ this._conditions.disconnect();
80
98
  }
81
- } else {
82
- return payload;
99
+ }
100
+
101
+ // E50: conditions added/edited/removed at runtime → restart the
102
+ // engine. connect() is idempotent for an unchanged attribute, so the
103
+ // first update after mount is a no-op.
104
+ if (changed.has('conditions') && (this.visible || !this.dynamicSubscriptions)) {
105
+ this._conditions.connect();
83
106
  }
84
107
  }
85
108
 
86
- subscribe(topic, callback) {
87
- this._subscriptions.push(feezal.connection.subscribe(topic, callback));
109
+ // ── Subscription helpers ─────────────────────────────────────────────────
110
+
111
+ /** Subscribe to a single topic — convenience wrapper for subclasses. */
112
+ addSubscription(topic, callback) {
113
+ this._subscriptions.push(feezal.connection.sub(topic, callback));
88
114
  }
89
115
 
90
116
  _subscribe() {
91
- if (!this.subscribeTopic) {
117
+ // The _subscribed guard makes this idempotent: an element that mounts
118
+ // with visible already set (the attribute reflects, so it can arrive
119
+ // from saved view HTML) hits _subscribe from both connectedCallback
120
+ // and the first updated() — without the guard every topic would be
121
+ // subscribed twice and each message processed twice.
122
+ if (!this.subscribe || this._subscribed) {
92
123
  return;
93
124
  }
94
125
 
126
+ // In the editor, runtime MQTT manipulation of elements is gated by a user
127
+ // setting (Editor settings → "Prevent MQTT element manipulation in editor",
128
+ // default ON) so live broker values never get written onto elements and
129
+ // serialized into the saved view.
130
+ if (feezal.isEditor && feezal.preventEditorMqtt !== false) {
131
+ return;
132
+ }
133
+
134
+ this._subscribed = true;
135
+
136
+ const base = this.subscribe;
95
137
  const elemClass = window.customElements.get(this.localName);
96
138
 
97
- this._subscriptions.push(feezal.connection.subscribe(this.subscribeTopic + '/#', msg => {
98
- let key;
99
- if (msg.topic === this.subscribeTopic && elemClass.feezal) {
100
- key = elemClass.feezal.baseAttribute;
101
- } else {
102
- key = msg.topic.split('/').pop();
103
- }
139
+ // Primary state topic → baseAttribute (exact topic, no wildcard).
140
+ const baseAttribute = elemClass?.feezal?.baseAttribute;
141
+ if (baseAttribute) {
142
+ this._subscriptions.push(feezal.connection.sub(base, msg => {
143
+ const type = (elemClass.properties?.[baseAttribute] || {}).type;
144
+ const val = this._payloadCast(type, this.getProperty(msg, this.messageProperty));
145
+ if (type === Boolean && !val) {
146
+ this.removeAttribute(baseAttribute);
147
+ } else {
148
+ this.setAttribute(baseAttribute, val);
149
+ }
150
+ }));
151
+ }
104
152
 
105
- const type = (elemClass.properties[key] || {}).type;
153
+ // Reserved runtime-control channel distinct, exact topics so device
154
+ // telemetry sharing the base topic can never reach the element.
155
+ // Consistent with feezal-view / feezal-site addclass / removeclass.
156
+ this._subscribeControl(base);
157
+ }
106
158
 
107
- const val = this._payloadCast(key === 'style' ? Object : type, this.getProperty(msg, this.messageProperty));
108
-
109
- if (key === 'style') {
110
- Object.assign(this.style, msg.payload);
111
- } else if (type === Boolean && !val) {
112
- this.removeAttribute(key);
113
- } else {
114
- this.setAttribute(key, val);
159
+ /** Subscribe to the reserved <base>/{setattribute,removeattribute,setstyle,removestyle,addclass,removeclass} control topics. */
160
+ _subscribeControl(base) {
161
+ const sub = (suffix, cb) => this._subscriptions.push(feezal.connection.sub(base + '/' + suffix, cb));
162
+ const val = msg => this.getProperty(msg, this.messageProperty);
163
+ const list = p => (Array.isArray(p) ? p : String(p).split(/[,\s]+/)).filter(Boolean);
164
+
165
+ sub('setattribute', msg => {
166
+ const obj = val(msg);
167
+ if (obj && typeof obj === 'object') {
168
+ for (const [k, v] of Object.entries(obj)) this.setAttribute(k, String(v));
115
169
  }
116
- }));
170
+ });
171
+ sub('removeattribute', msg => list(val(msg)).forEach(n => this.removeAttribute(n)));
172
+ sub('setstyle', msg => {
173
+ const obj = val(msg);
174
+ if (obj && typeof obj === 'object') Object.assign(this.style, obj);
175
+ });
176
+ sub('removestyle', msg => list(val(msg)).forEach(n => this.style.removeProperty(n)));
177
+ sub('addclass', msg => this.classList.add(val(msg)));
178
+ sub('removeclass', msg => this.classList.remove(val(msg)));
117
179
  }
118
180
 
119
181
  _unsubscribe() {
182
+ this._subscribed = false;
120
183
  const sub = this._subscriptions.shift();
121
184
  if (sub) {
122
185
  feezal.connection.unsubscribe(sub);
@@ -124,146 +187,72 @@ class FeezalElement extends PolymerElement {
124
187
  }
125
188
  }
126
189
 
127
- constructor() {
128
- super();
129
- this._subscriptions = [];
130
- }
190
+ // ── Utilities (ported 1:1 from the Polymer base) ─────────────────────────
131
191
 
132
- connectedCallback() {
133
- super.connectedCallback();
134
- this.classList.add('feezal-element');
135
- if (feezal.isEditor) {
136
- /*
137
- if (!this.shadowRoot.querySelector('.feezal-blocker')) {
138
- const el = document.createElement('div');
139
- el.className = 'feezal-blocker';
140
- this.shadowRoot.prepend(el);
141
- }
142
- */
143
- this.shadowRoot.querySelectorAll('*').forEach(el => {
144
- if (el.hasAttribute('tabindex')) {
145
- el.addEventListener('focus', event => {
146
- event.preventDefault();
147
- if (event.relatedTarget) {
148
- event.relatedTarget.focus();
149
- } else {
150
- event.currentTarget.blur();
151
- }
152
- });
153
- el.setAttribute('tabindex', '-1');
154
- }
155
- });
156
- } else {
157
- if (this.visible || !this.dynamicSubscriptions) {
158
- this._subscribe();
159
- }
160
- }
161
- }
162
- disconnectedCallback() {
163
- super.disconnectedCallback();
164
- this._unsubscribe();
165
- }
166
-
167
- _visibleChanged(visible) {
168
- if (feezal.isEditor || !this.dynamicSubscriptions) {
169
- return;
170
- }
171
- if (visible) {
172
- this._subscribe();
173
- } else {
174
- this._unsubscribe();
192
+ _payloadCast(type, payload) {
193
+ if (typeof payload === 'string' && type === Boolean) {
194
+ return Number(payload) !== 0 && payload.toLowerCase() !== 'false';
175
195
  }
196
+ return payload;
176
197
  }
177
198
 
178
- // Utils
179
199
  throttle(func, limit) {
180
200
  let lastFunc;
181
201
  let lastRan;
182
202
  return function () {
183
203
  const context = this;
184
- const args = arguments;
185
-
204
+ const args = arguments;
186
205
  if (!lastRan) {
187
206
  func.apply(context, args);
188
207
  lastRan = Date.now();
189
208
  } else {
190
209
  clearTimeout(lastFunc);
191
- this._inThrottle = true;
192
210
  lastFunc = setTimeout(function () {
193
211
  if ((Date.now() - lastRan) >= limit) {
194
212
  func.apply(context, args);
195
213
  lastRan = Date.now();
196
- this._inThrottle = false;
197
214
  }
198
215
  }, limit - (Date.now() - lastRan));
199
216
  }
200
- }
217
+ };
201
218
  }
202
219
 
203
- /**
204
- * @method split
205
- * Split str by '.' - supports backslash escaped delimiters
206
- * @param {string} str
207
- * @returns {Array.<string>}
208
- */
209
220
  split(str) {
210
221
  str = String(str);
211
-
212
- // Use native split if possible
213
222
  if (str.indexOf('\\') === -1) {
214
223
  return str.split('.');
215
224
  }
216
-
217
- var res = []; // The result array
218
- var pos = 0; // Starting position of current chunk
219
-
225
+ const res = [];
226
+ let pos = 0;
220
227
  function chunk(start, end) {
221
- // Slice, unescape and push onto result array.
222
228
  res.push(str.slice(start, end).replace(/\\\\/g, '\\').replace(/\\\./g, '.'));
223
- // Set starting position of next chunk.
224
229
  pos = end + 1;
225
230
  }
226
-
227
- var esc; // Boolean indicating if a dot is escaped
228
- var j;
229
- var i;
230
- var l = str.length;
231
+ let esc, j;
232
+ const l = str.length;
233
+ let i;
231
234
  for (i = 0; i < l; i++) {
232
235
  if (str[i] === '.') {
233
236
  esc = false;
234
- // Walk over preceding backslashes in reverse direction
235
237
  for (j = i - 1; str[j] === '\\'; j--) {
236
238
  esc = !esc;
237
239
  }
238
- // Dot is escaped only if preceded by an odd number of backslashes
239
240
  if (!esc) {
240
241
  chunk(pos, i);
241
242
  }
242
243
  }
243
244
  }
244
-
245
245
  chunk(pos, i);
246
-
247
246
  return res;
248
247
  }
249
248
 
250
- /**
251
- * @method getProperty
252
- * get an objects property. supports nested properties through dot-notation, dots may be escaped by backslash
253
- * @param {Object} obj
254
- * @param {string} prop
255
- * @returns {all} the properties value or undefined
256
- */
257
249
  getProperty(obj, prop) {
258
- var type = typeof obj;
250
+ const type = typeof obj;
259
251
  if (type !== 'object' && type !== 'function') {
260
- if (typeof prop === 'undefined') {
261
- return obj;
262
- }
263
- return undefined;
252
+ return typeof prop === 'undefined' ? obj : undefined;
264
253
  }
265
- var arr = this.split(String(prop));
266
- var res = obj;
254
+ const arr = this.split(String(prop));
255
+ let res = obj;
267
256
  for (let i = 0, l = arr.length; i < l; i++) {
268
257
  if (res) {
269
258
  res = res[arr[i]];
@@ -273,6 +262,5 @@ class FeezalElement extends PolymerElement {
273
262
  }
274
263
  }
275
264
 
276
- window.customElements.define('feezal-element', FeezalElement);
277
-
278
- export {FeezalElement, html};
265
+ // Re-export so element files can do a single import.
266
+ export {html, css};