@feezal/feezal-element 0.6.0 → 3.0.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2019-2026 Sebastian Raff
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,82 @@
1
+ # @feezal/feezal-element
2
+
3
+ Base classes for [feezal](https://github.com/feezal/feezal) dashboard elements.
4
+
5
+ feezal is a browser-based MQTT dashboard editor and viewer. Dashboards are composed of web components (`feezal-element-*` packages); this package provides the base classes those elements extend:
6
+
7
+ - **`FeezalElement`** — the Lit 3 base class. MQTT subscription lifecycle (`addSubscription`, automatic unsubscribe), payload path extraction (`getProperty` / `message-property`), payload casting, the reserved `<subscribe>/setattribute|setstyle|addclass|…` runtime-control channel, and the per-element conditions engine.
8
+ - **`feezalBaseStyles`** — shared host styles every element composes into its `static styles`.
9
+ - **`html`, `css`** — re-exported from Lit so element packages need a single import.
10
+ - **`FeezalPolymerElement`** (`feezal-polymer-element.js`) — legacy Polymer 3 base used by the paper element family. New elements should extend `FeezalElement`.
11
+ - **`feezal-conditions.js`** — the shared conditions engine (declaratively bind visibility, classes, styles or attributes to MQTT topics). Wired up by both base classes; element authors get it for free.
12
+
13
+ ## Installation
14
+
15
+ ```sh
16
+ npm install @feezal/feezal-element
17
+ ```
18
+
19
+ ## Minimal element
20
+
21
+ ```js
22
+ /* global feezal */
23
+ import {FeezalElement, feezalBaseStyles, html, css} from '@feezal/feezal-element';
24
+
25
+ class FeezalElementMyWidget extends FeezalElement {
26
+ static get feezal() {
27
+ return {
28
+ palette: {name: 'My Widget', category: 'Basic', color: '#4a6080'},
29
+ attributes: [
30
+ {name: 'subscribe', type: 'mqttTopic', help: 'State topic.'},
31
+ {name: 'message-property', type: 'string', default: 'payload',
32
+ help: 'Dot-notation path to the value within the MQTT message.'},
33
+ ],
34
+ styles: ['top', 'left', 'width', 'height'],
35
+ defaultStyle: {width: '80px', height: '40px'},
36
+ };
37
+ }
38
+
39
+ static properties = {
40
+ _value: {state: true},
41
+ };
42
+
43
+ static styles = [feezalBaseStyles, css``];
44
+
45
+ constructor() {
46
+ super();
47
+ this._value = null; // reactive properties: constructor, never class fields
48
+ }
49
+
50
+ connectedCallback() {
51
+ super.connectedCallback();
52
+ if (this.subscribe) {
53
+ this.addSubscription(this.subscribe, msg => {
54
+ this._value = this.getProperty(msg, this.messageProperty);
55
+ });
56
+ }
57
+ }
58
+
59
+ render() {
60
+ return html`<div>${this._value}</div>`;
61
+ }
62
+ }
63
+
64
+ customElements.define('feezal-element-my-widget', FeezalElementMyWidget);
65
+ export {FeezalElementMyWidget};
66
+ ```
67
+
68
+ ## Authoring elements
69
+
70
+ Read the full element authoring specification before building anything real — descriptor reference (`palette`, `attributes`, `styles`, `discovery`, custom inspectors), MQTT topic conventions, CSS custom property conventions, Lit 3 gotchas and the publishing checklist:
71
+
72
+ **[docs/element-spec.md](https://github.com/feezal/feezal/blob/master/docs/element-spec.md)**
73
+
74
+ Element packages are named `feezal-element-<category>-<name>` under an npm scope; the categories `basic` and `paper` are reserved for official feezal elements. Scaffolding: [`create-feezal-element`](https://github.com/feezal/feezal/tree/master/packages/create-feezal-element).
75
+
76
+ ## Versioning
77
+
78
+ This package uses lockstep **major** versions with the feezal server and the official element packages; minor/patch versions are independent. Element packages should depend on the matching major, e.g. `"@feezal/feezal-element": "^3.0.0"`.
79
+
80
+ ## License
81
+
82
+ MIT. The base class and the viewer runtime your element runs inside are MIT-licensed — subclassing does **not** place your element under feezal's AGPL (which covers only the server and editor). You may license and distribute your element packages under any terms you choose.
@@ -0,0 +1,262 @@
1
+ /* global feezal */
2
+
3
+ /**
4
+ * E50 — per-element conditions engine.
5
+ *
6
+ * Every element can carry a `conditions` attribute holding a JSON list of
7
+ * rows that declaratively bind visibility, CSS classes, styles or attributes
8
+ * to MQTT topics (or page-locally published ones, E49):
9
+ *
10
+ * [{"subscribe": "home/alarm/state", "operator": "=", "value": "armed",
11
+ * "action": "attribute", "attribute": "disabled", "attribute-value": ""}]
12
+ *
13
+ * Row schema:
14
+ * subscribe topic (exact or `${param}` placeholder inside components)
15
+ * property optional dot-path into the message (default "payload")
16
+ * operator = | != | > | < | >= | <= | matches (default =)
17
+ * value compare value (regex source for `matches`)
18
+ * action show | hide | class | style | attribute
19
+ * class (action=class) class name to add while matched
20
+ * style (action=style) {prop: value, …} applied while matched
21
+ * attribute (action=attribute) attribute name
22
+ * attribute-value (action=attribute) value set while matched
23
+ * keep-layout (action=show/hide) use visibility:hidden instead of
24
+ * display:none so the element keeps its layout box
25
+ *
26
+ * Semantics (decided in the E50 roadmap entry):
27
+ * - rows evaluate independently against the last value on their topic;
28
+ * - visibility rows AND-combine — visible only if every `show` row matches
29
+ * and no `hide` row matches;
30
+ * - class/style/attribute rows apply while matched and revert when
31
+ * unmatched; several matching rows targeting the same style property or
32
+ * attribute: the later row wins;
33
+ * - reverting restores the element's pristine value (captured before any
34
+ * condition effect was applied);
35
+ * - effects are never applied in the editor (a badge shows instead).
36
+ *
37
+ * The engine is host-agnostic so both element base classes (Lit
38
+ * FeezalElement and Polymer FeezalPolymerElement) share it.
39
+ */
40
+
41
+ const OPERATORS = ['=', '!=', '>', '<', '>=', '<=', 'matches'];
42
+ const ACTIONS = ['show', 'hide', 'class', 'style', 'attribute'];
43
+
44
+ /** Parse + validate a conditions attribute value. Garbage in → []. */
45
+ export function parseConditions(raw) {
46
+ if (!raw || typeof raw !== 'string') return [];
47
+ let list;
48
+ try {
49
+ list = JSON.parse(raw);
50
+ } catch {
51
+ return [];
52
+ }
53
+
54
+ if (!Array.isArray(list)) return [];
55
+ return list.filter(row =>
56
+ row && typeof row === 'object' &&
57
+ typeof row.subscribe === 'string' && row.subscribe.length > 0 &&
58
+ ACTIONS.includes(row.action) &&
59
+ (row.operator === undefined || OPERATORS.includes(row.operator)));
60
+ }
61
+
62
+ /** Evaluate one condition against a payload value. Pure. */
63
+ export function evalCondition(operator, compareValue, payload) {
64
+ const op = operator || '=';
65
+ switch (op) {
66
+ case '=':
67
+ return String(payload) === String(compareValue);
68
+ case '!=':
69
+ return String(payload) !== String(compareValue);
70
+ case '>':
71
+ case '<':
72
+ case '>=':
73
+ case '<=': {
74
+ const a = Number(payload);
75
+ const b = Number(compareValue);
76
+ if (isNaN(a) || isNaN(b)) return false;
77
+ if (op === '>') return a > b;
78
+ if (op === '<') return a < b;
79
+ if (op === '>=') return a >= b;
80
+ return a <= b;
81
+ }
82
+ case 'matches':
83
+ try {
84
+ return new RegExp(String(compareValue)).test(String(payload));
85
+ } catch {
86
+ return false;
87
+ }
88
+ default:
89
+ return false;
90
+ }
91
+ }
92
+
93
+ export class FeezalConditions {
94
+ /** @param {HTMLElement} host element the effects apply to (needs getProperty()) */
95
+ constructor(host) {
96
+ this.host = host;
97
+ this._subs = [];
98
+ this._rows = [];
99
+ this._matched = [];
100
+ this._pristine = null;
101
+ this._active = false;
102
+ }
103
+
104
+ /**
105
+ * (Re)start the engine from the host's current `conditions` attribute.
106
+ * No-ops in the editor (effects are editor-badged, never applied) and
107
+ * when the attribute is absent/empty/invalid.
108
+ */
109
+ connect() {
110
+ const raw = this.host.getAttribute('conditions');
111
+ // Idempotent for an unchanged attribute — connectedCallback and the
112
+ // first Lit update may both call connect() for the same value.
113
+ if (this._active && raw === this._lastRaw) return;
114
+
115
+ this.disconnect();
116
+ this._lastRaw = raw;
117
+ if (typeof feezal === 'undefined' || feezal.isEditor) return;
118
+
119
+ this._rows = parseConditions(raw);
120
+ if (this._rows.length === 0) return;
121
+
122
+ this._matched = this._rows.map(() => false);
123
+ this._capturePristine();
124
+ this._active = true;
125
+
126
+ this._rows.forEach((row, i) => {
127
+ this._subs.push(feezal.connection.sub(row.subscribe, msg => {
128
+ const payload = this.host.getProperty
129
+ ? this.host.getProperty(msg, row.property || 'payload')
130
+ : (msg && typeof msg === 'object' ? msg.payload : msg);
131
+ this._matched[i] = evalCondition(row.operator, row.value, payload);
132
+ this._apply();
133
+ }));
134
+ });
135
+ }
136
+
137
+ /** Stop: unsubscribe and revert every applied effect. */
138
+ disconnect() {
139
+ for (const sub of this._subs.splice(0)) {
140
+ feezal.connection.unsubscribe(sub);
141
+ }
142
+
143
+ if (this._active) {
144
+ // Revert to pristine so a re-connect (element moved in the DOM,
145
+ // conditions edited) starts from a clean slate.
146
+ this._matched = this._rows.map(() => false);
147
+ this._apply();
148
+ }
149
+
150
+ this._rows = [];
151
+ this._matched = [];
152
+ this._pristine = null;
153
+ this._active = false;
154
+ this._lastRaw = undefined;
155
+ }
156
+
157
+ /** Record the element's pre-condition values for everything rows may touch. */
158
+ _capturePristine() {
159
+ const host = this.host;
160
+ const p = {styles: {}, attrs: {}, display: null, visibility: null};
161
+
162
+ for (const row of this._rows) {
163
+ if (row.action === 'style' && row.style && typeof row.style === 'object') {
164
+ for (const prop of Object.keys(row.style)) {
165
+ if (!(prop in p.styles)) p.styles[prop] = host.style.getPropertyValue(prop);
166
+ }
167
+ } else if (row.action === 'attribute' && row.attribute) {
168
+ if (!(row.attribute in p.attrs)) {
169
+ p.attrs[row.attribute] = {
170
+ had: host.hasAttribute(row.attribute),
171
+ value: host.getAttribute(row.attribute),
172
+ };
173
+ }
174
+ } else if (row.action === 'show' || row.action === 'hide') {
175
+ if (p.display === null) {
176
+ p.display = host.style.getPropertyValue('display');
177
+ p.visibility = host.style.getPropertyValue('visibility');
178
+ }
179
+ }
180
+ }
181
+
182
+ this._pristine = p;
183
+ }
184
+
185
+ /** Re-derive and apply the full effect state from the per-row match flags. */
186
+ _apply() {
187
+ const host = this.host;
188
+ const p = this._pristine;
189
+
190
+ // ── classes: add while matched, remove when unmatched (row order) ──
191
+ this._rows.forEach((row, i) => {
192
+ if (row.action === 'class' && row.class) {
193
+ if (this._matched[i]) host.classList.add(row.class);
194
+ else host.classList.remove(row.class);
195
+ }
196
+ });
197
+
198
+ // ── visibility: AND-combine show/hide rows ──
199
+ if (p.display !== null) {
200
+ const visible = this._rows.every((row, i) => {
201
+ if (row.action === 'show') return this._matched[i];
202
+ if (row.action === 'hide') return !this._matched[i];
203
+ return true;
204
+ });
205
+ const keepLayout = this._rows.some((row, i) =>
206
+ (row.action === 'show' || row.action === 'hide') && row['keep-layout'] &&
207
+ (row.action === 'show' ? !this._matched[i] : this._matched[i]));
208
+
209
+ if (visible) {
210
+ // Restore pristine inline values ('' removes the property).
211
+ this._setOrRemoveStyle('display', p.display);
212
+ this._setOrRemoveStyle('visibility', p.visibility);
213
+ } else if (keepLayout) {
214
+ this._setOrRemoveStyle('display', p.display);
215
+ host.style.setProperty('visibility', 'hidden');
216
+ } else {
217
+ this._setOrRemoveStyle('visibility', p.visibility);
218
+ host.style.setProperty('display', 'none');
219
+ }
220
+ }
221
+
222
+ // ── styles: desired map from matching rows in order (later wins) ──
223
+ const desiredStyles = {};
224
+ this._rows.forEach((row, i) => {
225
+ if (row.action === 'style' && this._matched[i] && row.style && typeof row.style === 'object') {
226
+ Object.assign(desiredStyles, row.style);
227
+ }
228
+ });
229
+ for (const [prop, pristineValue] of Object.entries(p.styles)) {
230
+ if (prop in desiredStyles) {
231
+ host.style.setProperty(prop, String(desiredStyles[prop]));
232
+ } else {
233
+ this._setOrRemoveStyle(prop, pristineValue);
234
+ }
235
+ }
236
+
237
+ // ── attributes: desired map from matching rows in order (later wins) ──
238
+ const desiredAttrs = {};
239
+ this._rows.forEach((row, i) => {
240
+ if (row.action === 'attribute' && this._matched[i] && row.attribute) {
241
+ desiredAttrs[row.attribute] = row['attribute-value'] ?? '';
242
+ }
243
+ });
244
+ for (const [name, pristine] of Object.entries(p.attrs)) {
245
+ if (name in desiredAttrs) {
246
+ host.setAttribute(name, String(desiredAttrs[name]));
247
+ } else if (pristine.had) {
248
+ host.setAttribute(name, pristine.value);
249
+ } else {
250
+ host.removeAttribute(name);
251
+ }
252
+ }
253
+ }
254
+
255
+ _setOrRemoveStyle(prop, value) {
256
+ if (value) {
257
+ this.host.style.setProperty(prop, value);
258
+ } else {
259
+ this.host.style.removeProperty(prop);
260
+ }
261
+ }
262
+ }
package/feezal-element.js CHANGED
@@ -1,113 +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.feezal-selected) {
22
- outline: 2px dashed rgba(250, 120, 0, 0.8);
23
- }
24
- .feezal-blocker {
25
- top: 0;
26
- left: 0;
27
- display: block;
28
- width: 100%;
29
- height: 100%;
30
- position: absolute;
31
- z-index: 10;
32
- }
33
- </style>
34
- </template>`;
35
-
36
- styleElement.register('feezal-style-element');
37
-
38
- class FeezalElement extends PolymerElement {
39
- static get properties() {
40
- return {
41
- 'selected': {
42
- type: Boolean,
43
- value: false
44
- },
45
- 'dynamicSubscriptions': {
46
- type: Boolean,
47
- value: false,
48
- reflectToAttribute: true
49
- },
50
- 'visible': {
51
- type: Boolean,
52
- value: false,
53
- observer: '_visibleChanged'
54
- }
55
- }
56
- }
57
46
  static get feezal() {
58
- return {
59
- attributes: [],
60
- styles: []
47
+ return {attributes: [], styles: []};
48
+ }
49
+
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
+ };
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);
73
+ }
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();
61
81
  }
62
82
  }
63
83
 
64
- _payloadCast(type, payload) {
65
- if (typeof payload === 'string') {
66
- switch (type) {
67
- case Boolean:
68
- return Number(payload) !== 0 && payload.toLowerCase() !== 'false';
69
- default:
70
- 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();
71
98
  }
72
- } else {
73
- 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();
74
106
  }
75
107
  }
76
108
 
77
- subscribe(topic, callback) {
78
- 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));
79
114
  }
80
115
 
81
116
  _subscribe() {
82
- 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) {
83
123
  return;
84
124
  }
85
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;
86
137
  const elemClass = window.customElements.get(this.localName);
87
138
 
88
- this._subscriptions.push(feezal.connection.subscribe(this.subscribeTopic + '/#', msg => {
89
- let key;
90
- if (msg.topic === this.subscribeTopic && elemClass.feezal) {
91
- key = elemClass.feezal.baseAttribute;
92
- } else {
93
- key = msg.topic.split('/').pop();
94
- }
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
+ }
95
152
 
96
- 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
+ }
97
158
 
98
- const val = this._payloadCast(key === 'style' ? Object : type, msg.payload);
99
-
100
- if (key === 'style') {
101
- Object.assign(this.style, msg.payload);
102
- } else if (type === Boolean && !val) {
103
- this.removeAttribute(key);
104
- } else {
105
- 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));
106
169
  }
107
- }));
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)));
108
179
  }
109
180
 
110
181
  _unsubscribe() {
182
+ this._subscribed = false;
111
183
  const sub = this._subscriptions.shift();
112
184
  if (sub) {
113
185
  feezal.connection.unsubscribe(sub);
@@ -115,80 +187,80 @@ class FeezalElement extends PolymerElement {
115
187
  }
116
188
  }
117
189
 
118
- constructor() {
119
- super();
120
- this._subscriptions = [];
121
- }
190
+ // ── Utilities (ported 1:1 from the Polymer base) ─────────────────────────
122
191
 
123
- connectedCallback() {
124
- super.connectedCallback();
125
- this.classList.add('feezal-element');
126
- if (feezal.isEditor) {
127
- const el = document.createElement('div');
128
- el.className = 'feezal-blocker';
129
- this.shadowRoot.prepend(el);
130
- this.shadowRoot.querySelectorAll('*').forEach(el => {
131
- if (el.hasAttribute('tabindex')) {
132
- el.addEventListener('focus', event => {
133
- event.preventDefault();
134
- if (event.relatedTarget) {
135
- event.relatedTarget.focus();
136
- } else {
137
- event.currentTarget.blur();
138
- }
139
- });
140
- el.setAttribute('tabindex', '-1');
141
- }
142
- });
143
- } else {
144
- if (this.visible || !this.dynamicSubscriptions) {
145
- this._subscribe();
146
- }
147
- }
148
- }
149
- disconnectedCallback() {
150
- super.disconnectedCallback();
151
- this._unsubscribe();
152
- }
153
-
154
- _visibleChanged(visible) {
155
- if (feezal.isEditor || !this.dynamicSubscriptions) {
156
- return;
157
- }
158
- if (visible) {
159
- this._subscribe();
160
- } else {
161
- this._unsubscribe();
192
+ _payloadCast(type, payload) {
193
+ if (typeof payload === 'string' && type === Boolean) {
194
+ return Number(payload) !== 0 && payload.toLowerCase() !== 'false';
162
195
  }
196
+ return payload;
163
197
  }
164
198
 
165
- // Utils
166
199
  throttle(func, limit) {
167
200
  let lastFunc;
168
201
  let lastRan;
169
202
  return function () {
170
203
  const context = this;
171
- const args = arguments;
172
-
204
+ const args = arguments;
173
205
  if (!lastRan) {
174
206
  func.apply(context, args);
175
207
  lastRan = Date.now();
176
208
  } else {
177
209
  clearTimeout(lastFunc);
178
- this._inThrottle = true;
179
210
  lastFunc = setTimeout(function () {
180
211
  if ((Date.now() - lastRan) >= limit) {
181
212
  func.apply(context, args);
182
213
  lastRan = Date.now();
183
- this._inThrottle = false;
184
214
  }
185
215
  }, limit - (Date.now() - lastRan));
186
216
  }
217
+ };
218
+ }
219
+
220
+ split(str) {
221
+ str = String(str);
222
+ if (str.indexOf('\\') === -1) {
223
+ return str.split('.');
224
+ }
225
+ const res = [];
226
+ let pos = 0;
227
+ function chunk(start, end) {
228
+ res.push(str.slice(start, end).replace(/\\\\/g, '\\').replace(/\\\./g, '.'));
229
+ pos = end + 1;
230
+ }
231
+ let esc, j;
232
+ const l = str.length;
233
+ let i;
234
+ for (i = 0; i < l; i++) {
235
+ if (str[i] === '.') {
236
+ esc = false;
237
+ for (j = i - 1; str[j] === '\\'; j--) {
238
+ esc = !esc;
239
+ }
240
+ if (!esc) {
241
+ chunk(pos, i);
242
+ }
243
+ }
187
244
  }
245
+ chunk(pos, i);
246
+ return res;
188
247
  }
189
248
 
249
+ getProperty(obj, prop) {
250
+ const type = typeof obj;
251
+ if (type !== 'object' && type !== 'function') {
252
+ return typeof prop === 'undefined' ? obj : undefined;
253
+ }
254
+ const arr = this.split(String(prop));
255
+ let res = obj;
256
+ for (let i = 0, l = arr.length; i < l; i++) {
257
+ if (res) {
258
+ res = res[arr[i]];
259
+ }
260
+ }
261
+ return res;
262
+ }
190
263
  }
191
264
 
192
- window.customElements.define('feezal-element', FeezalElement);
193
-
194
- export {FeezalElement, html};
265
+ // Re-export so element files can do a single import.
266
+ export {html, css};
@@ -0,0 +1,326 @@
1
+ /* global window, feezal */
2
+
3
+ import {PolymerElement, html} from '@polymer/polymer/polymer-element.js';
4
+ import {FeezalConditions} from './feezal-conditions.js';
5
+
6
+ const styleElement = document.createElement('dom-module');
7
+
8
+ styleElement.innerHTML =
9
+ `<template>
10
+ <style>
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
+ :host(.feezal-editable[locked]) {
29
+ outline: 1.5px dashed #f59e0b !important;
30
+ }
31
+ :host(.feezal-editable.feezal-selected[locked]) {
32
+ outline: 2px solid #f59e0b !important;
33
+ }
34
+ </style>
35
+ </template>`;
36
+
37
+ styleElement.register('feezal-style-element');
38
+
39
+ class FeezalPolymerElement extends PolymerElement {
40
+ static get properties() {
41
+ return {
42
+ name: {
43
+ type: String,
44
+ value: '',
45
+ reflectToAttribute: true,
46
+ },
47
+ 'selected': {
48
+ type: Boolean,
49
+ value: false
50
+ },
51
+ subscribe: {
52
+ type: String,
53
+ value: '',
54
+ reflectToAttribute: true,
55
+ },
56
+ messageProperty: {
57
+ type: String,
58
+ value: 'payload',
59
+ reflectToAttribute: true,
60
+ },
61
+ 'dynamicSubscriptions': {
62
+ type: Boolean,
63
+ value: false,
64
+ reflectToAttribute: true
65
+ },
66
+ 'visible': {
67
+ type: Boolean,
68
+ value: false,
69
+ observer: '_visibleChanged'
70
+ },
71
+ // E50: JSON list of condition rows — shared engine, see
72
+ // feezal-conditions.js. The attribute is the source of truth.
73
+ conditions: {
74
+ type: String,
75
+ observer: '_conditionsChanged'
76
+ }
77
+ }
78
+ }
79
+ static get feezal() {
80
+ return {
81
+ attributes: [],
82
+ styles: []
83
+ }
84
+ }
85
+
86
+ _payloadCast(type, payload) {
87
+ if (typeof payload === 'string') {
88
+ switch (type) {
89
+ case Boolean:
90
+ return Number(payload) !== 0 && payload.toLowerCase() !== 'false';
91
+ default:
92
+ return payload;
93
+ }
94
+ } else {
95
+ return payload;
96
+ }
97
+ }
98
+
99
+ addSubscription(topic, callback) {
100
+ this._subscriptions.push(feezal.connection.sub(topic, callback));
101
+ }
102
+
103
+ _subscribe() {
104
+ if (!this.subscribe) {
105
+ return;
106
+ }
107
+
108
+ // In the editor, runtime MQTT manipulation of elements is gated by a user
109
+ // setting (Editor settings → "Prevent MQTT element manipulation in editor",
110
+ // default ON) so live broker values never get written onto elements and
111
+ // serialized into the saved view.
112
+ if (feezal.isEditor && feezal.preventEditorMqtt !== false) {
113
+ return;
114
+ }
115
+
116
+ const base = this.subscribe;
117
+ const elemClass = window.customElements.get(this.localName);
118
+
119
+ // Primary state topic → baseAttribute (exact topic, no wildcard).
120
+ const baseAttribute = elemClass && elemClass.feezal && elemClass.feezal.baseAttribute;
121
+ if (baseAttribute) {
122
+ this._subscriptions.push(feezal.connection.sub(base, msg => {
123
+ const type = (elemClass.properties[baseAttribute] || {}).type;
124
+ const val = this._payloadCast(type, this.getProperty(msg, this.messageProperty));
125
+ if (type === Boolean && !val) {
126
+ this.removeAttribute(baseAttribute);
127
+ } else {
128
+ this.setAttribute(baseAttribute, val);
129
+ }
130
+ }));
131
+ }
132
+
133
+ // Reserved runtime-control channel — distinct, exact topics so device
134
+ // telemetry sharing the base topic can never reach the element.
135
+ // Consistent with feezal-view / feezal-site addclass / removeclass.
136
+ this._subscribeControl(base);
137
+ }
138
+
139
+ /** Subscribe to the reserved <base>/{setattribute,removeattribute,setstyle,removestyle,addclass,removeclass} control topics. */
140
+ _subscribeControl(base) {
141
+ const sub = (suffix, cb) => this._subscriptions.push(feezal.connection.sub(base + '/' + suffix, cb));
142
+ const val = msg => this.getProperty(msg, this.messageProperty);
143
+ const list = p => (Array.isArray(p) ? p : String(p).split(/[,\s]+/)).filter(Boolean);
144
+
145
+ sub('setattribute', msg => {
146
+ const obj = val(msg);
147
+ if (obj && typeof obj === 'object') {
148
+ for (const [k, v] of Object.entries(obj)) this.setAttribute(k, String(v));
149
+ }
150
+ });
151
+ sub('removeattribute', msg => list(val(msg)).forEach(n => this.removeAttribute(n)));
152
+ sub('setstyle', msg => {
153
+ const obj = val(msg);
154
+ if (obj && typeof obj === 'object') Object.assign(this.style, obj);
155
+ });
156
+ sub('removestyle', msg => list(val(msg)).forEach(n => this.style.removeProperty(n)));
157
+ sub('addclass', msg => this.classList.add(val(msg)));
158
+ sub('removeclass', msg => this.classList.remove(val(msg)));
159
+ }
160
+
161
+ _unsubscribe() {
162
+ const sub = this._subscriptions.shift();
163
+ if (sub) {
164
+ feezal.connection.unsubscribe(sub);
165
+ this._unsubscribe();
166
+ }
167
+ }
168
+
169
+ constructor() {
170
+ super();
171
+ this._subscriptions = [];
172
+ this._conditions = new FeezalConditions(this);
173
+ }
174
+
175
+ connectedCallback() {
176
+ super.connectedCallback();
177
+ this.classList.add('feezal-element');
178
+ // Prevent tab-focus on shadow DOM controls while in the editor.
179
+ if (feezal.isEditor && this.shadowRoot) {
180
+ this.shadowRoot.querySelectorAll('*').forEach(el => {
181
+ if (el.hasAttribute('tabindex')) {
182
+ el.addEventListener('focus', event => {
183
+ event.preventDefault();
184
+ if (event.relatedTarget) {
185
+ event.relatedTarget.focus();
186
+ } else {
187
+ event.currentTarget.blur();
188
+ }
189
+ });
190
+ el.setAttribute('tabindex', '-1');
191
+ }
192
+ });
193
+ }
194
+ if (this.visible || !this.dynamicSubscriptions) {
195
+ this._subscribe();
196
+ this._conditions.connect();
197
+ }
198
+ }
199
+ disconnectedCallback() {
200
+ super.disconnectedCallback();
201
+ this._unsubscribe();
202
+ this._conditions.disconnect();
203
+ }
204
+
205
+ _visibleChanged(visible) {
206
+ if (!this.dynamicSubscriptions) {
207
+ return;
208
+ }
209
+ if (visible) {
210
+ this._subscribe();
211
+ this._conditions.connect();
212
+ } else {
213
+ this._unsubscribe();
214
+ this._conditions.disconnect();
215
+ }
216
+ }
217
+
218
+ // E50: conditions added/edited/removed at runtime → restart the engine
219
+ // (idempotent for an unchanged attribute value).
220
+ _conditionsChanged() {
221
+ if (this.isConnected && (this.visible || !this.dynamicSubscriptions)) {
222
+ this._conditions.connect();
223
+ }
224
+ }
225
+
226
+ // Utils
227
+ throttle(func, limit) {
228
+ let lastFunc;
229
+ let lastRan;
230
+ return function () {
231
+ const context = this;
232
+ const args = arguments;
233
+
234
+ if (!lastRan) {
235
+ func.apply(context, args);
236
+ lastRan = Date.now();
237
+ } else {
238
+ clearTimeout(lastFunc);
239
+ this._inThrottle = true;
240
+ lastFunc = setTimeout(function () {
241
+ if ((Date.now() - lastRan) >= limit) {
242
+ func.apply(context, args);
243
+ lastRan = Date.now();
244
+ this._inThrottle = false;
245
+ }
246
+ }, limit - (Date.now() - lastRan));
247
+ }
248
+ }
249
+ }
250
+
251
+ /**
252
+ * @method split
253
+ * Split str by '.' - supports backslash escaped delimiters
254
+ * @param {string} str
255
+ * @returns {Array.<string>}
256
+ */
257
+ split(str) {
258
+ str = String(str);
259
+
260
+ // Use native split if possible
261
+ if (str.indexOf('\\') === -1) {
262
+ return str.split('.');
263
+ }
264
+
265
+ var res = []; // The result array
266
+ var pos = 0; // Starting position of current chunk
267
+
268
+ function chunk(start, end) {
269
+ // Slice, unescape and push onto result array.
270
+ res.push(str.slice(start, end).replace(/\\\\/g, '\\').replace(/\\\./g, '.'));
271
+ // Set starting position of next chunk.
272
+ pos = end + 1;
273
+ }
274
+
275
+ var esc; // Boolean indicating if a dot is escaped
276
+ var j;
277
+ var i;
278
+ var l = str.length;
279
+ for (i = 0; i < l; i++) {
280
+ if (str[i] === '.') {
281
+ esc = false;
282
+ // Walk over preceding backslashes in reverse direction
283
+ for (j = i - 1; str[j] === '\\'; j--) {
284
+ esc = !esc;
285
+ }
286
+ // Dot is escaped only if preceded by an odd number of backslashes
287
+ if (!esc) {
288
+ chunk(pos, i);
289
+ }
290
+ }
291
+ }
292
+
293
+ chunk(pos, i);
294
+
295
+ return res;
296
+ }
297
+
298
+ /**
299
+ * @method getProperty
300
+ * get an objects property. supports nested properties through dot-notation, dots may be escaped by backslash
301
+ * @param {Object} obj
302
+ * @param {string} prop
303
+ * @returns {all} the properties value or undefined
304
+ */
305
+ getProperty(obj, prop) {
306
+ var type = typeof obj;
307
+ if (type !== 'object' && type !== 'function') {
308
+ if (typeof prop === 'undefined') {
309
+ return obj;
310
+ }
311
+ return undefined;
312
+ }
313
+ var arr = this.split(String(prop));
314
+ var res = obj;
315
+ for (let i = 0, l = arr.length; i < l; i++) {
316
+ if (res) {
317
+ res = res[arr[i]];
318
+ }
319
+ }
320
+ return res;
321
+ }
322
+ }
323
+
324
+ window.customElements.define('feezal-element', FeezalPolymerElement);
325
+
326
+ export {FeezalPolymerElement, html};
package/package.json CHANGED
@@ -1,28 +1,38 @@
1
1
  {
2
- "author": {
3
- "name": "Sebastian Raff",
4
- "email": "hobbyquaker@gmail.com"
5
- },
6
- "bugs": {
7
- "url": "https://github.com/feezal/node-red-contrib-feezal/issues"
8
- },
9
- "bundleDependencies": false,
10
- "dependencies": {
11
- "@polymer/polymer": "^3.0.0"
12
- },
13
- "deprecated": false,
14
- "description": "",
15
- "feezal": {},
16
- "homepage": "https://github.com/feezal/node-red-contrib-feezal#readme",
17
- "license": "MIT",
18
- "main": "feezal-element.js",
19
- "name": "@feezal/feezal-element",
20
- "repository": {
21
- "type": "git",
22
- "url": "git+https://github.com/feezal/node-red-contrib-feezal.git"
23
- },
24
- "scripts": {
25
- "test": "echo \"Error: no test specified\" && exit 1"
26
- },
27
- "version": "0.6.0"
2
+ "author": {
3
+ "name": "Sebastian Raff",
4
+ "email": "hobbyquaker@gmail.com"
5
+ },
6
+ "bugs": {
7
+ "url": "https://github.com/feezal/feezal/issues"
8
+ },
9
+ "dependencies": {
10
+ "@polymer/polymer": "^3.0.0",
11
+ "lit": "^3.2.0"
12
+ },
13
+ "description": "Base classes for feezal dashboard elements — FeezalElement (Lit 3), FeezalPolymerElement (legacy paper elements) and the shared conditions engine",
14
+ "feezal": {},
15
+ "files": [
16
+ "feezal-element.js",
17
+ "feezal-conditions.js",
18
+ "feezal-polymer-element.js",
19
+ "LICENSE"
20
+ ],
21
+ "homepage": "https://github.com/feezal/feezal#readme",
22
+ "keywords": [
23
+ "feezal",
24
+ "feezal-element"
25
+ ],
26
+ "license": "MIT",
27
+ "main": "feezal-element.js",
28
+ "name": "@feezal/feezal-element",
29
+ "publishConfig": {
30
+ "access": "public"
31
+ },
32
+ "repository": {
33
+ "type": "git",
34
+ "url": "git+https://github.com/feezal/feezal.git",
35
+ "directory": "www/packages/@feezal/feezal-element"
36
+ },
37
+ "version": "3.0.0"
28
38
  }