@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/LICENSE +21 -0
- package/README.md +82 -0
- package/feezal-conditions.js +262 -0
- package/feezal-element.js +173 -185
- package/feezal-polymer-element.js +326 -0
- package/feezal-topic-input.js +173 -0
- package/package.json +37 -25
- package/dist/sw.js +0 -2
- package/dist/sw.js.map +0 -1
- package/dist/workbox-bacb9a40.js +0 -2
- package/dist/workbox-bacb9a40.js.map +0 -1
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
|
+
}
|