@feezal/feezal-element 3.0.0 → 3.0.2

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
@@ -60,6 +60,28 @@ export class FeezalElement extends LitElement {
60
60
  // bound to MQTT topics). Never reflected — the attribute is the
61
61
  // source of truth; effects only apply in the viewer.
62
62
  conditions: {type: String, attribute: 'conditions'},
63
+ // ── N31: availability — universal base-class machinery ───────────
64
+ // subscribe-availability accepts a single topic string (back-compat)
65
+ // OR a JSON array of topics / {topic, property} entries (the modern
66
+ // HA discovery form: bridge state + device availability). The base
67
+ // subscribes, tracks per-topic status and combines it per
68
+ // availability-mode into the reactive `_available` flag; rendering
69
+ // (badges, dimming) stays element business. Elements that want the
70
+ // fields in their inspector declare the attributes in their own
71
+ // descriptor — the machinery works regardless.
72
+ // Deliberately NOT reflected: Lit reflects constructor defaults on
73
+ // first update, which would stamp subscribe-availability="" /
74
+ // payload-available="online" / … onto EVERY element and serialize
75
+ // that junk into every saved dashboard (and it broke the U32
76
+ // create-component attribute table). The inspector and discovery
77
+ // write the attributes directly; attribute → property sync is all
78
+ // the machinery needs.
79
+ subscribeAvailability: {type: String, attribute: 'subscribe-availability'},
80
+ availabilityMode: {type: String, attribute: 'availability-mode'},
81
+ payloadAvailable: {type: String, attribute: 'payload-available'},
82
+ payloadUnavailable: {type: String, attribute: 'payload-unavailable'},
83
+ msgPropAvailability: {type: String, attribute: 'message-property-availability'},
84
+ _available: {state: true},
63
85
  };
64
86
 
65
87
  constructor() {
@@ -70,6 +92,15 @@ export class FeezalElement extends LitElement {
70
92
  this.dynamicSubscriptions = false;
71
93
  this.visible = false;
72
94
  this._conditions = new FeezalConditions(this);
95
+ // N31 availability defaults (payload values = HA defaults)
96
+ this.subscribeAvailability = '';
97
+ this.availabilityMode = 'all';
98
+ this.payloadAvailable = 'online';
99
+ this.payloadUnavailable = 'offline';
100
+ this.msgPropAvailability = '';
101
+ this._available = true;
102
+ this._availabilitySubs = [];
103
+ this._availabilityStatus = {};
73
104
  }
74
105
 
75
106
  connectedCallback() {
@@ -79,11 +110,17 @@ export class FeezalElement extends LitElement {
79
110
  this._subscribe();
80
111
  this._conditions.connect();
81
112
  }
113
+ // N31: availability is independent of the primary-subscription
114
+ // machinery (elements overriding _subscribe with an empty body still
115
+ // get it) and not editor-gated — it only feeds internal state, never
116
+ // writes serializable attributes in the editor.
117
+ this._subscribeAvailability();
82
118
  }
83
119
 
84
120
  disconnectedCallback() {
85
121
  super.disconnectedCallback();
86
122
  this._unsubscribe();
123
+ this._unsubscribeAvailability();
87
124
  this._conditions.disconnect();
88
125
  }
89
126
 
@@ -104,6 +141,98 @@ export class FeezalElement extends LitElement {
104
141
  if (changed.has('conditions') && (this.visible || !this.dynamicSubscriptions)) {
105
142
  this._conditions.connect();
106
143
  }
144
+
145
+ // N31: availability wiring changed at runtime (inspector edits on the
146
+ // live canvas) → rewire. Signature-guarded so the first update after
147
+ // connect is a no-op.
148
+ if (this.isConnected && this.__availSig !== undefined &&
149
+ this._availabilitySignature() !== this.__availSig) {
150
+ this._unsubscribeAvailability();
151
+ this._subscribeAvailability();
152
+ }
153
+ }
154
+
155
+ // ── N31: availability ────────────────────────────────────────────────────
156
+
157
+ _availabilitySignature() {
158
+ return [this.subscribeAvailability, this.availabilityMode,
159
+ this.payloadAvailable, this.payloadUnavailable, this.msgPropAvailability].join('|');
160
+ }
161
+
162
+ /** Parse subscribe-availability: '' → [], plain topic string → one entry,
163
+ * JSON array of strings / {topic, property} objects → many entries. */
164
+ _availabilityEntries() {
165
+ const raw = (this.subscribeAvailability || '').trim();
166
+ if (!raw) return [];
167
+ if (raw.startsWith('[')) {
168
+ try {
169
+ const arr = JSON.parse(raw);
170
+ return (Array.isArray(arr) ? arr : [])
171
+ .map(entry => (typeof entry === 'string' ? {topic: entry} : entry))
172
+ .filter(entry => entry && typeof entry.topic === 'string' && entry.topic);
173
+ } catch {
174
+ return [];
175
+ }
176
+ }
177
+ return [{topic: raw}];
178
+ }
179
+
180
+ _subscribeAvailability() {
181
+ this.__availSig = this._availabilitySignature();
182
+ this._availabilityStatus = {};
183
+ const entries = this._availabilityEntries();
184
+ if (entries.length === 0) {
185
+ this._setAvailable(true);
186
+ return;
187
+ }
188
+ entries.forEach((entry, index) => {
189
+ const key = index + ':' + entry.topic;
190
+ this._availabilityStatus[key] = null; // unknown until the first message
191
+ this._availabilitySubs.push(feezal.connection.sub(entry.topic, msg => {
192
+ let v = this.getProperty(msg, entry.property || this.msgPropAvailability || this.messageProperty);
193
+ // Unwrap JSON {"state": "online"} payloads (zigbee2mqtt availability)
194
+ if (typeof v === 'string') {
195
+ try {
196
+ const parsed = JSON.parse(v);
197
+ if (parsed && typeof parsed === 'object' && 'state' in parsed) v = parsed.state;
198
+ } catch { /* not JSON — use the raw string */ }
199
+ } else if (v && typeof v === 'object' && 'state' in v) {
200
+ v = v.state;
201
+ }
202
+ const s = String(v).toLowerCase();
203
+ this._availabilityStatus[key] = String(v) === this.payloadAvailable ||
204
+ (s !== String(this.payloadUnavailable).toLowerCase() &&
205
+ s !== 'offline' && s !== 'false' && s !== '0' && s !== 'unavailable');
206
+ this._recomputeAvailability();
207
+ }));
208
+ });
209
+ this._recomputeAvailability();
210
+ }
211
+
212
+ _unsubscribeAvailability() {
213
+ this._availabilitySubs.forEach(sub => feezal.connection.unsubscribe(sub));
214
+ this._availabilitySubs = [];
215
+ }
216
+
217
+ /** Combine per-topic status per availability-mode into `_available`.
218
+ * Topics without a message yet count as available (matches the previous
219
+ * per-element behaviour — an element never starts out badged). */
220
+ _recomputeAvailability() {
221
+ const statuses = Object.values(this._availabilityStatus);
222
+ const known = statuses.filter(v => v !== null);
223
+ this._setAvailable(this.availabilityMode === 'any'
224
+ ? (known.length === 0 || known.some(Boolean))
225
+ : statuses.every(v => v !== false));
226
+ }
227
+
228
+ _setAvailable(available) {
229
+ this._available = available;
230
+ // Styling hook for themes/zero-effort elements — viewer only: a
231
+ // reflected attribute in the editor would be serialized into the
232
+ // saved views.html.
233
+ if (!feezal.isEditor) {
234
+ this.toggleAttribute('unavailable', !available);
235
+ }
107
236
  }
108
237
 
109
238
  // ── Subscription helpers ─────────────────────────────────────────────────
@@ -262,5 +391,28 @@ export class FeezalElement extends LitElement {
262
391
  }
263
392
  }
264
393
 
394
+ // ── N31: shared availability badge (optional consistency helper) ─────────────
395
+ // Elements MAY compose these into their render/styles instead of hand-rolling
396
+ // a badge — the base class itself renders nothing. Themable via the
397
+ // --feezal-unavailable-* custom properties.
398
+ export const feezalAvailabilityStyles = css`
399
+ .feezal-unavail-badge {
400
+ position: absolute;
401
+ top: var(--feezal-unavailable-top, 4px);
402
+ right: var(--feezal-unavailable-right, 4px);
403
+ font-size: var(--feezal-unavailable-size, 14px);
404
+ line-height: 1;
405
+ color: var(--feezal-unavailable-color, var(--error-color, #b00020));
406
+ opacity: 0.85;
407
+ pointer-events: none;
408
+ z-index: 2;
409
+ }
410
+ `;
411
+
412
+ /** Badge template partial: renders nothing while available. */
413
+ export function availabilityBadge(available) {
414
+ return available ? '' : html`<span class="feezal-unavail-badge" title="Device unavailable">⚠</span>`;
415
+ }
416
+
265
417
  // Re-export so element files can do a single import.
266
418
  export {html, css};
@@ -0,0 +1,173 @@
1
+ /**
2
+ * feezal-topic-input — shared MQTT-topic input with server-backed
3
+ * autocompletion (B28).
4
+ *
5
+ * Drop-in replacement for the plain `sl-input` topic fields in custom (N6)
6
+ * inspectors: wraps an sl-input and adds the same completion dropdown as the
7
+ * generic attribute inspector (debounced fetch of /api/topics/completions,
8
+ * arrow-key navigation, Enter to pick, descend on `topic/` intermediate
9
+ * completions, Escape/Tab to dismiss).
10
+ *
11
+ * The inner sl-input's `sl-input`/`sl-change` events bubble composed out of
12
+ * the shadow root retargeted to this element, and `value` is kept in sync —
13
+ * existing inspector handlers keep working unchanged with
14
+ * `@sl-change="${e => ...e.target.value...}"`. Picking a completion fires a
15
+ * synthetic `sl-change`; descending an intermediate `…/` completion fires
16
+ * `sl-input` only.
17
+ *
18
+ * Only usable inside the editor (needs the Shoelace bundle and the feezal
19
+ * server API); dashboards never render it.
20
+ */
21
+ import {LitElement, html, css} from 'lit';
22
+
23
+ class FeezalTopicInput extends LitElement {
24
+ static properties = {
25
+ label: {type: String},
26
+ value: {type: String},
27
+ placeholder: {type: String},
28
+ size: {type: String},
29
+ _completions: {state: true},
30
+ _cursor: {state: true},
31
+ };
32
+
33
+ static styles = css`
34
+ :host { display: block; }
35
+ .topic-wrap { position: relative; }
36
+ sl-input { width: 100%; }
37
+ sl-input::part(form-control-label) { color: var(--sl-input-label-color, inherit); font-size: 12px; }
38
+ sl-input::part(base) {
39
+ background: var(--feezal-bg, #fff);
40
+ border-color: var(--feezal-border, #ccc);
41
+ color: var(--feezal-color, #333);
42
+ }
43
+ sl-input::part(input) { background: var(--feezal-bg, #fff); color: var(--sl-input-color, #333); }
44
+ .completions {
45
+ position: absolute; top: 100%; left: 0; right: 0; z-index: 500;
46
+ list-style: none; margin: 2px 0 0; padding: 4px 0;
47
+ background: var(--feezal-bg, #fff); border: 1px solid var(--feezal-border, #ccc);
48
+ border-radius: 4px; box-shadow: 0 4px 14px rgba(0,0,0,0.18);
49
+ max-height: 180px; overflow-y: auto; font-size: 12px;
50
+ }
51
+ .completions li {
52
+ padding: 4px 10px; cursor: pointer;
53
+ color: var(--feezal-color, #333);
54
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
55
+ }
56
+ .completions li:hover, .completions li.active { background: var(--feezal-bg-sub, #f0f0f0); }
57
+ `;
58
+
59
+ constructor() {
60
+ super();
61
+ this.label = '';
62
+ this.value = '';
63
+ this.placeholder = 'mqtt/topic';
64
+ this.size = 'small';
65
+ this._completions = [];
66
+ this._cursor = -1;
67
+ }
68
+
69
+ disconnectedCallback() {
70
+ super.disconnectedCallback();
71
+ clearTimeout(this._fetchTimer);
72
+ clearTimeout(this._closeTimer);
73
+ }
74
+
75
+ _emit(name) {
76
+ this.dispatchEvent(new CustomEvent(name, {bubbles: true, composed: true}));
77
+ }
78
+
79
+ _debounceFetch(prefix) {
80
+ clearTimeout(this._fetchTimer);
81
+ this._fetchTimer = setTimeout(() => this._fetchCompletions(prefix), 150);
82
+ }
83
+
84
+ async _fetchCompletions(prefix) {
85
+ try {
86
+ const r = await fetch(`/api/topics/completions?prefix=${encodeURIComponent(prefix)}`);
87
+ if (!r.ok) { this._completions = []; return; }
88
+ const {completions} = await r.json();
89
+ this._cursor = -1;
90
+ this._completions = completions || [];
91
+ } catch {
92
+ this._completions = [];
93
+ }
94
+ }
95
+
96
+ _close() {
97
+ this._completions = [];
98
+ this._cursor = -1;
99
+ }
100
+
101
+ _onInput(e) {
102
+ this.value = e.target.value;
103
+ this._debounceFetch(this.value);
104
+ }
105
+
106
+ _onKeydown(e) {
107
+ if (!this._completions.length) return;
108
+ if (e.key === 'ArrowDown') {
109
+ e.preventDefault();
110
+ this._cursor = Math.min(this._cursor + 1, this._completions.length - 1);
111
+ } else if (e.key === 'ArrowUp') {
112
+ e.preventDefault();
113
+ this._cursor = Math.max(this._cursor - 1, -1);
114
+ } else if (e.key === 'Enter' && this._cursor >= 0) {
115
+ e.preventDefault();
116
+ e.stopPropagation();
117
+ this._select(this._completions[this._cursor]);
118
+ } else if (e.key === 'Escape' || e.key === 'Tab') {
119
+ this._close();
120
+ }
121
+ }
122
+
123
+ _select(val) {
124
+ this.value = val;
125
+ if (val.endsWith('/')) {
126
+ // Intermediate level — descend and keep the dropdown open.
127
+ this._emit('sl-input');
128
+ this._fetchCompletions(val);
129
+ } else {
130
+ this._emit('sl-input');
131
+ this._emit('sl-change');
132
+ this._close();
133
+ }
134
+ }
135
+
136
+ _scheduleClose() {
137
+ clearTimeout(this._closeTimer);
138
+ this._closeTimer = setTimeout(() => this._close(), 200);
139
+ }
140
+
141
+ render() {
142
+ return html`
143
+ <div class="topic-wrap">
144
+ <sl-input size="${this.size}" autocomplete="off"
145
+ label="${this.label || ''}"
146
+ placeholder="${this.placeholder}"
147
+ .value="${this.value ?? ''}"
148
+ @sl-focus="${() => this._debounceFetch(this.value ?? '')}"
149
+ @sl-input="${this._onInput}"
150
+ @sl-change="${e => { this.value = e.target.value; }}"
151
+ @sl-blur="${this._scheduleClose}"
152
+ @keydown="${this._onKeydown}"></sl-input>
153
+ ${this._completions.length ? html`
154
+ <ul class="completions">
155
+ ${this._completions.map((c, ci) => html`
156
+ <li class="${ci === this._cursor ? 'active' : ''}"
157
+ @mousedown="${e => { e.preventDefault(); this._select(c); }}">
158
+ ${c}
159
+ </li>
160
+ `)}
161
+ </ul>
162
+ ` : ''}
163
+ </div>
164
+ `;
165
+ }
166
+ }
167
+
168
+ // Multiple element bundles may carry a copy (static exports) — guard the define.
169
+ if (!customElements.get('feezal-topic-input')) {
170
+ customElements.define('feezal-topic-input', FeezalTopicInput);
171
+ }
172
+
173
+ export {FeezalTopicInput};
package/package.json CHANGED
@@ -16,6 +16,7 @@
16
16
  "feezal-element.js",
17
17
  "feezal-conditions.js",
18
18
  "feezal-polymer-element.js",
19
+ "feezal-topic-input.js",
19
20
  "LICENSE"
20
21
  ],
21
22
  "homepage": "https://github.com/feezal/feezal#readme",
@@ -34,5 +35,5 @@
34
35
  "url": "git+https://github.com/feezal/feezal.git",
35
36
  "directory": "www/packages/@feezal/feezal-element"
36
37
  },
37
- "version": "3.0.0"
38
+ "version": "3.0.2"
38
39
  }