@dinoreic/fez 0.4.0 → 0.5.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.
@@ -1,721 +1,880 @@
1
- // HTML node builder
2
- import parseNode from './lib/n.js'
3
- import createTemplate from './lib/template.js'
1
+ /**
2
+ * FezBase - Base class for all Fez components
3
+ *
4
+ * Provides lifecycle hooks, reactive state, DOM utilities, and template rendering
5
+ */
6
+
7
+ import parseNode from "./lib/n.js";
8
+ import createTemplate from "./lib/template.js";
9
+ import { componentSubscribe, componentPublish } from "./lib/pubsub.js";
4
10
 
5
11
  export default class FezBase {
6
- // get node attributes as object
12
+ // ===========================================================================
13
+ // STATIC METHODS
14
+ // ===========================================================================
15
+
16
+ static nodeName = "div";
17
+
18
+ /**
19
+ * Extract props from a DOM node's attributes
20
+ * Handles :attr syntax for evaluated expressions and data-props JSON
21
+ */
7
22
  static getProps(node, newNode) {
8
- let attrs = {}
23
+ let attrs = {};
9
24
 
10
- // we can directly attach props to DOM node instance
25
+ // Direct props attachment
11
26
  if (node.props) {
12
- return node.props
27
+ return node.props;
13
28
  }
14
29
 
15
- // LOG(node.nodeName, node.attributes)
30
+ // Collect attributes
16
31
  for (const attr of node.attributes) {
17
- attrs[attr.name] = attr.value
32
+ attrs[attr.name] = attr.value;
18
33
  }
19
34
 
35
+ // Evaluate :attr expressions
20
36
  for (const [key, val] of Object.entries(attrs)) {
21
- if ([':'].includes(key[0])) {
22
- // LOG([key, val])
23
- delete attrs[key]
37
+ if ([":"].includes(key[0])) {
38
+ delete attrs[key];
24
39
  try {
25
- const newVal = new Function(`return (${val})`).bind(newNode)()
26
- attrs[key.replace(/[\:_]/, '')] = newVal
27
-
40
+ const newVal = new Function(`return (${val})`).bind(newNode)();
41
+ attrs[key.replace(/[\:_]/, "")] = newVal;
28
42
  } catch (e) {
29
- console.error(`Fez: Error evaluating attribute ${key}="${val}" for ${node.tagName}: ${e.message}`)
43
+ Fez.onError(
44
+ "attr",
45
+ `<${node.tagName.toLowerCase()}> Error evaluating ${key}="${val}": ${e.message}`,
46
+ );
30
47
  }
31
48
  }
32
49
  }
33
50
 
34
- if (attrs['data-props']) {
35
- let data = attrs['data-props']
36
-
37
- if (typeof data == 'object') {
38
- return data
39
- }
40
- else {
41
- if (data[0] != '{') {
42
- data = decodeURIComponent(data)
51
+ // Handle data-props JSON
52
+ if (attrs["data-props"]) {
53
+ let data = attrs["data-props"];
54
+ if (typeof data == "object") {
55
+ return data;
56
+ } else {
57
+ if (data[0] != "{") {
58
+ data = decodeURIComponent(data);
43
59
  }
44
60
  try {
45
- attrs = JSON.parse(data)
61
+ attrs = JSON.parse(data);
46
62
  } catch (e) {
47
- console.error(`Fez: Invalid JSON in data-props for ${node.tagName}: ${e.message}`)
63
+ Fez.onError(
64
+ "props",
65
+ `<${node.tagName.toLowerCase()}> Invalid JSON in data-props: ${e.message}`,
66
+ );
48
67
  }
49
68
  }
50
69
  }
51
-
52
- // pass props as json template
53
- // <script type="text/template">{...}</script>
54
- // <foo-bar data-json-template="true"></foo-bar>
55
- else if (attrs['data-json-template']) {
56
- const data = newNode.previousSibling?.textContent
70
+ // Handle JSON template
71
+ else if (attrs["data-json-template"]) {
72
+ const data = newNode.previousSibling?.textContent;
57
73
  if (data) {
58
74
  try {
59
- attrs = JSON.parse(data)
60
- newNode.previousSibling.remove()
75
+ attrs = JSON.parse(data);
76
+ newNode.previousSibling.remove();
61
77
  } catch (e) {
62
- console.error(`Fez: Invalid JSON in template for ${node.tagName}: ${e.message}`)
78
+ Fez.onError(
79
+ "props",
80
+ `<${node.tagName.toLowerCase()}> Invalid JSON in template: ${e.message}`,
81
+ );
63
82
  }
64
83
  }
65
84
  }
66
85
 
67
- return attrs
86
+ return attrs;
68
87
  }
69
88
 
89
+ /**
90
+ * Get form data from closest/child form
91
+ */
70
92
  static formData(node) {
71
- const formNode = node.closest('form') || node.querySelector('form')
93
+ const formNode = node.closest("form") || node.querySelector("form");
72
94
  if (!formNode) {
73
- Fez.log('No form found for formData()')
74
- return {}
95
+ Fez.consoleLog("No form found for formData()");
96
+ return {};
75
97
  }
76
- const formData = new FormData(formNode)
77
- const formObject = {}
98
+ const formData = new FormData(formNode);
99
+ const formObject = {};
78
100
  formData.forEach((value, key) => {
79
- formObject[key] = value
101
+ formObject[key] = value;
80
102
  });
81
- return formObject
103
+ return formObject;
82
104
  }
83
105
 
84
-
85
- static nodeName = 'div'
86
-
87
- // instance methods
106
+ // ===========================================================================
107
+ // CONSTRUCTOR & CORE
108
+ // ===========================================================================
88
109
 
89
110
  constructor() {}
90
111
 
91
- n = parseNode
92
-
93
- // string selector for use in HTML nodes
112
+ n = parseNode;
113
+ fezBlocks = {};
114
+
115
+ // Store for passing values to child components (e.g., loop vars)
116
+ fezGlobals = {
117
+ _data: new Map(),
118
+ _counter: 0,
119
+ set(value) {
120
+ const key = this._counter++;
121
+ this._data.set(key, value);
122
+ return key;
123
+ },
124
+ delete(key) {
125
+ const value = this._data.get(key);
126
+ this._data.delete(key);
127
+ return value;
128
+ },
129
+ };
130
+
131
+ /**
132
+ * Report error with component name always included
133
+ * @param {string} kind - Error category
134
+ * @param {string} message - Error message
135
+ * @param {Object} [context] - Additional context
136
+ * @returns {string} Formatted error message
137
+ */
138
+ fezError(kind, message, context) {
139
+ const name = this.fezName || this.root?.tagName?.toLowerCase() || "unknown";
140
+ const enhancedContext = context ? { ...context, componentName: name } : { componentName: name };
141
+ return Fez.onError(kind, `<${name}> ${message}`, enhancedContext);
142
+ }
143
+
144
+ /**
145
+ * String selector for use in HTML nodes
146
+ */
94
147
  get fezHtmlRoot() {
95
- return `Fez(${this.UID}).`
96
- // return this.props.id ? `Fez.find("#${this.props.id}").` : `Fez.find(this, "${this.fezName}").`
148
+ return `Fez(${this.UID}).`;
97
149
  }
98
150
 
99
- // checks if node is attached and clears all if not
151
+ /**
152
+ * Check if node is attached to DOM
153
+ */
100
154
  get isConnected() {
101
- if (this.root?.isConnected) {
102
- return true
103
- } else {
104
- this.fezOnDestroy()
105
- return false
106
- }
155
+ return !!this.root?.isConnected;
107
156
  }
108
157
 
109
- // get single node property
158
+ /**
159
+ * Get single node property
160
+ */
110
161
  prop(name) {
111
- let v = this.oldRoot[name] || this.props[name]
112
- if (typeof v == 'function') {
113
- // if this.prop('onclick'), we want "this" to point to this.root (dom node)
114
- v = v.bind(this.root)
162
+ let v = this.oldRoot[name] || this.props[name];
163
+ if (typeof v == "function") {
164
+ v = v.bind(this.root);
115
165
  }
116
- return v
166
+ return v;
117
167
  }
118
168
 
119
- // copy attributes to root node
120
- copy() {
121
- for (const name of Array.from(arguments)) {
122
- let value = this.props[name]
123
-
124
- if (value !== undefined) {
125
- if (name == 'class') {
126
- const klass = this.root.getAttribute(name, value)
127
-
128
- if (klass) {
129
- value = [klass, value].join(' ')
130
- }
131
- }
169
+ // ===========================================================================
170
+ // LIFECYCLE HOOKS
171
+ // ===========================================================================
132
172
 
133
- if (name == 'style' || !this.root[name]) {
134
- if (typeof value == 'string') {
135
- this.root.setAttribute(name, value)
136
- }
137
- else {
138
- this.root[name] = value
139
- }
140
- }
141
- }
142
- }
143
- }
173
+ connect() {}
174
+ onMount() {}
175
+ beforeRender() {}
176
+ afterRender() {}
177
+ onDestroy() {}
178
+ onStateChange() {}
179
+ onGlobalStateChange() {}
180
+ onPropsChange() {}
144
181
 
145
- // clear all node references
146
- // Centralized destroy logic
182
+ /**
183
+ * Centralized destroy logic - called by MutationObserver when element is removed
184
+ */
147
185
  fezOnDestroy() {
148
- // Execute all registered cleanup callbacks
186
+ // Guard against double-cleanup
187
+ if (this._destroyed) return;
188
+ this._destroyed = true;
189
+
190
+ // Execute cleanup callbacks (intervals, observers, event listeners)
149
191
  if (this._onDestroyCallbacks) {
150
- this._onDestroyCallbacks.forEach(callback => {
192
+ this._onDestroyCallbacks.forEach((callback) => {
151
193
  try {
152
194
  callback();
153
195
  } catch (e) {
154
- console.error('Fez: Error in cleanup callback:', e);
196
+ this.fezError("destroy", "Error in cleanup callback", e);
155
197
  }
156
198
  });
157
199
  this._onDestroyCallbacks = [];
158
200
  }
159
201
 
160
- // Call user's onDestroy lifecycle hook
161
- this.onDestroy()
162
- this.onDestroy = () => {}
202
+ // Call user's onDestroy hook
203
+ this.onDestroy();
204
+ this.onDestroy = () => {};
163
205
 
164
206
  // Clean up root references
165
207
  if (this.root) {
166
- this.root.fez = undefined
208
+ this.root.fez = undefined;
167
209
  }
168
-
169
- this.root = undefined
210
+ this.root = undefined;
170
211
  }
171
212
 
172
- // Add a cleanup callback to be executed on destroy
213
+ /**
214
+ * Add a cleanup callback for destroy
215
+ */
173
216
  addOnDestroy(callback) {
174
217
  this._onDestroyCallbacks = this._onDestroyCallbacks || [];
175
218
  this._onDestroyCallbacks.push(callback);
176
219
  }
177
220
 
178
- // Generic function to handle window events with automatic cleanup
179
- on(eventName, func, delay = 200) {
180
- this._eventHandlers = this._eventHandlers || {};
181
-
182
- if (this._eventHandlers[eventName]) {
183
- window.removeEventListener(eventName, this._eventHandlers[eventName]);
184
- }
221
+ // ===========================================================================
222
+ // RENDERING
223
+ // ===========================================================================
185
224
 
186
- const throttledFunc = Fez.throttle(() => {
187
- if (this.isConnected) {
188
- func.call(this);
189
- }
190
- }, delay);
191
-
192
- this._eventHandlers[eventName] = throttledFunc;
193
- window.addEventListener(eventName, throttledFunc);
194
-
195
- this.addOnDestroy(() => {
196
- window.removeEventListener(eventName, throttledFunc);
197
- delete this._eventHandlers[eventName];
198
- });
199
- }
200
-
201
- // Helper function for resize events
202
- onWindowResize(func, delay) {
203
- this.on('resize', func, delay);
204
- func();
205
- }
206
-
207
- // Helper function for scroll events
208
- onWindowScroll(func, delay) {
209
- this.on('scroll', func, delay);
210
- func();
211
- }
212
-
213
- // Helper function for element resize events using ResizeObserver
214
- onElementResize(el, func, delay = 200) {
215
- const throttledFunc = Fez.throttle(() => {
216
- if (this.isConnected) {
217
- func.call(this, el.getBoundingClientRect(), el);
218
- }
219
- }, delay);
220
-
221
- const observer = new ResizeObserver(throttledFunc);
222
- observer.observe(el);
223
-
224
- func.call(this, el.getBoundingClientRect(), el);
225
-
226
- this.addOnDestroy(() => {
227
- observer.disconnect();
228
- });
225
+ /**
226
+ * Parse HTML and replace fez. references
227
+ */
228
+ fezParseHtml(text) {
229
+ const base = this.fezHtmlRoot.replaceAll('"', "&quot;");
230
+ text = text
231
+ .replace(/([!'"\s;])fez\.(\w)/g, `$1${base}$2`)
232
+ .replace(/>\s+</g, "><");
233
+ return text.trim();
229
234
  }
230
235
 
231
- // copy child nodes, natively to preserve bound events
232
- // if node name is SLOT insert adjacent and remove SLOT, else as a child nodes
233
- slot(source, target) {
234
- target ||= document.createElement('template')
235
- const isSlot = target.nodeName == 'SLOT'
236
-
237
- while (source.firstChild) {
238
- if (isSlot) {
239
- target.parentNode.insertBefore(source.lastChild, target.nextSibling);
240
- } else {
241
- target.appendChild(source.firstChild)
242
- }
243
- }
244
-
245
- if (isSlot) {
246
- target.parentNode.removeChild(target)
236
+ /**
237
+ * Schedule work on next animation frame (debounced by name)
238
+ */
239
+ fezNextTick(func, name) {
240
+ if (name) {
241
+ this._nextTicks ||= {};
242
+ this._nextTicks[name] ||= window.requestAnimationFrame(() => {
243
+ func.bind(this)();
244
+ this._nextTicks[name] = null;
245
+ }, name);
247
246
  } else {
248
- source.innerHTML = ''
247
+ window.requestAnimationFrame(func.bind(this));
249
248
  }
250
-
251
- return target
252
249
  }
253
250
 
254
- setStyle(key, value) {
255
- this.root.style.setProperty(key, value);
256
- }
257
-
258
- connect() {}
259
- onMount() {}
260
- beforeRender() {}
261
- afterRender() {}
262
- onDestroy() {}
263
- onStateChange() {}
264
- onGlobalStateChange() {}
265
-
266
- // component publish will search for parent component that subscribes by name
267
- publish(channel, ...args) {
268
- const handle_publish = (component) => {
269
- if (Fez._subs && Fez._subs[channel]) {
270
- const sub = Fez._subs[channel].find(([comp]) => comp === component)
271
- if (sub) {
272
- sub[1].bind(component)(...args)
273
- return true
274
- }
275
- }
276
- return false
277
- }
278
-
279
- // Check if current component has subscription
280
- if (handle_publish(this)) {
281
- return true
282
- }
283
-
284
- // Bubble up to parent components
285
- let parent = this.root.parentElement
286
- while (parent) {
287
- if (parent.fez) {
288
- if (handle_publish(parent.fez)) {
289
- return true
290
- }
291
- }
292
- parent = parent.parentElement
293
- }
294
-
295
- // If no parent handled it, fall back to global publish
296
- // Fez.publish(channel, ...args)
297
- return false
251
+ /**
252
+ * Force a re-render on next frame
253
+ */
254
+ fezRefresh() {
255
+ this.fezNextTick(() => this.fezRender(), "refresh");
298
256
  }
299
257
 
300
- fezBlocks = {}
301
-
302
- parseHtml(text) {
303
- const base = this.fezHtmlRoot.replaceAll('"', '&quot;')
304
-
305
- text = text
306
- .replace(/([!'"\s;])fez\.(\w)/g, `$1${base}$2`)
307
- .replace(/>\s+</g, '><')
308
-
309
- return text.trim()
258
+ /**
259
+ * Alias for fezRefresh - can be overwritten
260
+ */
261
+ refresh() {
262
+ this.fezRefresh();
310
263
  }
311
264
 
265
+ /**
266
+ * Render the component template to DOM
267
+ * Uses component-aware DOM differ with hash-based skip
268
+ */
269
+ fezRender(template) {
270
+ // Check instance-level template first, then class-level
271
+ template ||= this.fezHtmlFunc || this?.class?.fezHtmlFunc;
312
272
 
313
- // pass name to have only one tick of a kind
314
- nextTick(func, name) {
315
- if (name) {
316
- this._nextTicks ||= {}
317
- this._nextTicks[name] ||= window.requestAnimationFrame(() => {
318
- func.bind(this)()
319
- this._nextTicks[name] = null
320
- }, name)
321
- } else {
322
- window.requestAnimationFrame(func.bind(this))
323
- }
324
- }
325
-
326
- // inject htmlString as innerHTML and replace $$. with local pointer
327
- // $$. will point to current fez instance
328
- // <slot></slot> will be replaced with current root
329
- // this.render('...loading')
330
- // this.render('.images', '...loading')
331
- render(template) {
332
- template ||= this?.class?.fezHtmlFunc
273
+ if (!template || !this.root) return;
333
274
 
334
- if (!template || !this.root) return
275
+ // Prevent re-render loops from state changes in beforeRender/afterRender
276
+ this._isRendering = true;
335
277
 
336
- this.beforeRender()
278
+ this.beforeRender();
337
279
 
338
- const nodeName = typeof this.class.nodeName == 'function' ? this.class.nodeName(this.root) : this.class.nodeName
339
- const newNode = document.createElement(nodeName || 'div')
280
+ const nodeName =
281
+ typeof this.class.nodeName == "function"
282
+ ? this.class.nodeName(this.root)
283
+ : this.class.nodeName;
284
+ const newNode = document.createElement(nodeName || "div");
340
285
 
341
- let renderedTpl
286
+ let renderedTpl;
342
287
  if (Array.isArray(template)) {
343
- // array nodes this.n(...), look tabs example
344
288
  if (template[0] instanceof Node) {
345
- template.forEach( n => newNode.appendChild(n) )
346
- } else{
347
- renderedTpl = template.join('')
289
+ template.forEach((n) => newNode.appendChild(n));
290
+ } else {
291
+ renderedTpl = template.join("");
348
292
  }
349
- }
350
- else if (typeof template == 'string') {
351
- renderedTpl = createTemplate(template)(this)
352
- }
353
- else if (typeof template == 'function') {
354
- renderedTpl = template(this)
293
+ } else if (typeof template == "string") {
294
+ const name = this.root?.tagName?.toLowerCase();
295
+ renderedTpl = createTemplate(template, { name })(this);
296
+ } else if (typeof template == "function") {
297
+ renderedTpl = template(this);
355
298
  }
356
299
 
357
300
  if (renderedTpl) {
358
- renderedTpl = renderedTpl.replace(/\s\w+="undefined"/g, '')
359
- newNode.innerHTML = this.parseHtml(renderedTpl)
301
+ if (
302
+ renderedTpl instanceof DocumentFragment ||
303
+ renderedTpl instanceof Node
304
+ ) {
305
+ newNode.appendChild(renderedTpl);
306
+ } else {
307
+ renderedTpl = renderedTpl.replace(/\s\w+="undefined"/g, "");
308
+ const parsedHtml = this.fezParseHtml(renderedTpl);
309
+
310
+ // Hash-skip: if template output is identical, skip the morph entirely
311
+ const newHash = Fez.fnv1(parsedHtml);
312
+ if (newHash === this._fezHash) {
313
+ this._isRendering = false;
314
+ return;
315
+ }
316
+ this._fezHash = newHash;
317
+
318
+ newNode.innerHTML = parsedHtml;
319
+ }
360
320
  }
361
321
 
362
- // Handle fez-keep attributes
363
- this.fezKeepNode(newNode)
322
+ this.fezKeepNode(newNode);
364
323
 
365
- // Handle fez-memoize attributes
366
- this.fezMemoization(newNode)
324
+ // Save input values for fez-this/fez-bind bound elements before morph
325
+ const savedInputValues = new Map();
326
+ this.root.querySelectorAll("input, textarea, select").forEach((el) => {
327
+ if (el._fezThisName) {
328
+ savedInputValues.set(el._fezThisName, {
329
+ value: el.value,
330
+ checked: el.checked,
331
+ });
332
+ }
333
+ });
334
+
335
+ Fez.morphdom(this.root, newNode);
367
336
 
368
- Fez.morphdom(this.root, newNode)
337
+ // Restore input values after morph - find element by _fezThisName property
338
+ savedInputValues.forEach((saved, name) => {
339
+ let el = null;
340
+ this.root.querySelectorAll("input, textarea, select").forEach((input) => {
341
+ if (input._fezThisName === name) el = input;
342
+ });
343
+ if (el) {
344
+ el.value = saved.value;
345
+ if (saved.checked !== undefined) el.checked = saved.checked;
346
+ }
347
+ });
369
348
 
370
- this.fezRenderPostProcess()
349
+ this.fezRenderPostProcess();
350
+ this.afterRender();
371
351
 
372
- this.afterRender()
352
+ this._isRendering = false;
373
353
  }
374
354
 
355
+ /**
356
+ * Post-render processing for fez-* attributes
357
+ */
375
358
  fezRenderPostProcess() {
376
359
  const fetchAttr = (name, func) => {
377
- this.root.querySelectorAll(`*[${name}]`).forEach((n)=>{
378
- let value = n.getAttribute(name)
379
- n.removeAttribute(name)
360
+ this.root.querySelectorAll(`*[${name}]`).forEach((n) => {
361
+ let value = n.getAttribute(name);
362
+ n.removeAttribute(name);
380
363
  if (value) {
381
- func.bind(this)(value, n)
364
+ func.bind(this)(value, n);
382
365
  }
383
- })
384
- }
366
+ });
367
+ };
385
368
 
386
- // <button fez-this="button" -> this.button = node
387
- fetchAttr('fez-this', (value, n) => {
388
- (new Function('n', `this.${value} = n`)).bind(this)(n)
389
- })
369
+ // fez-this="button" -> this.button = node
370
+ fetchAttr("fez-this", (value, n) => {
371
+ new Function("n", `this.${value} = n`).bind(this)(n);
372
+ // Mark element for value preservation on re-render
373
+ n._fezThisName = value;
374
+ });
390
375
 
391
- // <button fez-use="animate" -> this.animate(node]
392
- fetchAttr('fez-use', (value, n) => {
393
- if (value.includes('=>')) {
394
- // fez-use="el => el.focus()"
395
- Fez.getFunction(value)(n)
396
- }
397
- else {
398
- if (value.includes('.')) {
399
- // fez-use="this.focus()"
400
- Fez.getFunction(value).bind(n)()
401
- }
402
- else {
403
- // fez-use="animate"
404
- const target = this[value]
405
- if (typeof target == 'function') {
406
- target(n)
376
+ // fez-use="animate" -> this.animate(node)
377
+ fetchAttr("fez-use", (value, n) => {
378
+ if (value.includes("=>")) {
379
+ Fez.getFunction(value)(n);
380
+ } else {
381
+ if (value.includes(".")) {
382
+ Fez.getFunction(value).bind(n)();
383
+ } else {
384
+ const target = this[value];
385
+ if (typeof target == "function") {
386
+ target(n);
407
387
  } else {
408
- console.error(`Fez error: "${value}" is not a function in ${this.fezName}`)
388
+ this.fezError("fez-use", `"${value}" is not a function`);
409
389
  }
410
390
  }
411
391
  }
412
- })
392
+ });
413
393
 
414
- // <button fez-class="dialog animate" -> add class "animate" after node init to trigger animation
415
- fetchAttr('fez-class', (value, n) => {
416
- let classes = value.split(/\s+/)
417
- let lastClass = classes.pop()
418
- classes.forEach((c)=> n.classList.add(c) )
394
+ // fez-class="dialog animate" -> add class after init for animation
395
+ fetchAttr("fez-class", (value, n) => {
396
+ let classes = value.split(/\s+/);
397
+ let lastClass = classes.pop();
398
+ classes.forEach((c) => n.classList.add(c));
419
399
  if (lastClass) {
420
- setTimeout(()=>{
421
- n.classList.add(lastClass)
422
- }, 1)
423
- }
424
- })
425
-
426
- // <input fez-bind="state.inputNode" -> this.state.inputNode will be the value of input
427
- fetchAttr('fez-bind', (text, n) => {
428
- if (['INPUT', 'SELECT', 'TEXTAREA'].includes(n.nodeName)) {
429
- const value = (new Function(`return this.${text}`)).bind(this)()
430
- const isCb = n.type.toLowerCase() == 'checkbox'
431
- const eventName = ['SELECT'].includes(n.nodeName) || isCb ? 'onchange' : 'onkeyup'
432
- n.setAttribute(eventName, `${this.fezHtmlRoot}${text} = this.${isCb ? 'checked' : 'value'}`)
433
- this.val(n, value)
434
- } else {
435
- console.error(`Cant fez-bind="${text}" to ${n.nodeName} (needs INPUT, SELECT or TEXTAREA. Want to use fez-this?).`)
400
+ setTimeout(() => {
401
+ n.classList.add(lastClass);
402
+ }, 1);
436
403
  }
437
- })
404
+ });
438
405
 
439
- this.root.querySelectorAll(`*[disabled]`).forEach((n)=>{
440
- let value = n.getAttribute('disabled')
441
- if (['false'].includes(value)) {
442
- n.removeAttribute('disabled')
406
+ // fez-bind="state.inputNode" -> two-way binding
407
+ fetchAttr("fez-bind", (text, n) => {
408
+ if (["INPUT", "SELECT", "TEXTAREA"].includes(n.nodeName)) {
409
+ const value = new Function(`return this.${text}`).bind(this)();
410
+ const isCb = n.type.toLowerCase() == "checkbox";
411
+ const eventName =
412
+ ["SELECT"].includes(n.nodeName) || isCb ? "onchange" : "onkeyup";
413
+ n.setAttribute(
414
+ eventName,
415
+ `${this.fezHtmlRoot}${text} = this.${isCb ? "checked" : "value"}`,
416
+ );
417
+ this.val(n, value);
418
+ // Mark element for value preservation on re-render
419
+ n._fezThisName = text;
443
420
  } else {
444
- n.setAttribute('disabled', 'true')
421
+ this.fezError(
422
+ "fez-bind",
423
+ `Can't bind "${text}" to ${n.nodeName} (needs INPUT, SELECT or TEXTAREA)`,
424
+ );
445
425
  }
446
- })
447
- }
426
+ });
448
427
 
449
- fezKeepNode(newNode) {
450
- newNode.querySelectorAll('[fez-keep]').forEach(newEl => {
451
- const key = newEl.getAttribute('fez-keep')
452
- const oldEl = this.root.querySelector(`[fez-keep="${key}"]`)
453
-
454
- if (oldEl) {
455
- // Keep the old element in place of the new one
456
- newEl.parentNode.replaceChild(oldEl, newEl)
457
- } else if (key === 'default-slot') {
458
- if (newEl.getAttribute('hide')) {
459
- // You cant use state any more
460
- this.state = null
461
-
462
- const parent = newEl.parentNode
463
-
464
- // Insert all root children before the slot's next sibling
465
- Array.from(this.root.childNodes).forEach(child => {
466
- parent.insertBefore(child, newEl)
467
- })
468
-
469
- // Remove the slot element
470
- newEl.remove()
471
- }
472
- else {
473
- // First render - populate the slot with current root children
474
- Array.from(this.root.childNodes).forEach(
475
- child => {
476
- newEl.appendChild(child)
477
- }
478
- )
428
+ // Normalize boolean attributes (checked, disabled, selected)
429
+ for (const attr of ["checked", "disabled", "selected"]) {
430
+ this.root.querySelectorAll(`*[${attr}]`).forEach((n) => {
431
+ let value = n.getAttribute(attr);
432
+ if (["false", "null", "undefined"].includes(value)) {
433
+ n.removeAttribute(attr);
434
+ n[attr] = false;
435
+ } else {
436
+ n.setAttribute(attr, attr);
479
437
  }
480
- }
481
- })
438
+ });
439
+ }
482
440
  }
483
441
 
484
- fezMemoization(newNode) {
485
- // Find the single memoize element in new DOM (excluding fez components)
486
- const newMemoEl = newNode.querySelector('[fez-memoize]:not(.fez)')
487
- if (!newMemoEl) return
442
+ /**
443
+ * Handle slot initialization on first render.
444
+ * Moves captured children from _fezSlotNodes into the .fez-slot container.
445
+ * fez-keep matching is handled natively by the differ (morph.js).
446
+ */
447
+ fezKeepNode(newNode) {
448
+ // First render only: move captured children into slot container.
449
+ // On subsequent renders the differ preserves the live .fez-slot via fez-keep.
450
+ if (this._fezSlotInitialized) return;
451
+
452
+ // Safe to use .querySelector - newNode is a fresh template with no nested components yet
453
+ const newSlot = newNode.querySelector(".fez-slot");
454
+ if (newSlot && this._fezSlotNodes) {
455
+ this._fezSlotInitialized = true;
456
+ this._fezSlotNodes.forEach((child) => {
457
+ newSlot.appendChild(child);
458
+ });
459
+ }
460
+ }
488
461
 
489
- this.fezMemoStore ||= new Map()
462
+ // ===========================================================================
463
+ // REACTIVE STATE
464
+ // ===========================================================================
490
465
 
491
- const newMemoElKey = newMemoEl.getAttribute('fez-memoize')
492
- const storedNode = this.fezMemoStore.get(newMemoElKey)
466
+ /**
467
+ * Register component: setup CSS, state, and bind methods
468
+ */
469
+ fezRegister() {
470
+ if (this.css) {
471
+ this.css = Fez.globalCss(this.css, { name: this.fezName, wrap: true });
472
+ }
493
473
 
494
- if (storedNode) {
495
- Fez.log(`Memoize restore ${this.fezName}: ${newMemoElKey}`)
496
- newMemoEl.parentNode.replaceChild(storedNode.cloneNode(true), newMemoEl)
497
- } else {
498
- const oldMemoEl = this.root.querySelector('[fez-memoize]:not(.fez)')
499
- if (oldMemoEl) {
500
- const oldMemoElKey = oldMemoEl.getAttribute('fez-memoize')
501
- this.fezMemoStore.set(oldMemoElKey, oldMemoEl.cloneNode(true))
502
- }
474
+ if (this.class.css) {
475
+ this.class.css = Fez.globalCss(this.class.css, { name: this.fezName });
503
476
  }
477
+
478
+ this.state ||= this.fezReactiveStore();
479
+ this.globalState = Fez.state.createProxy(this);
480
+ this.fezRegisterBindMethods();
504
481
  }
505
482
 
506
- // refresh single node only
507
- refresh(selector) {
508
- alert('NEEDS FIX and remove htmlTemplate')
509
- if (selector) {
510
- const n = Fez.domRoot(this.class.htmlTemplate)
511
- const tpl = n.querySelector(selector).innerHTML
512
- this.render(selector, tpl)
513
- } else {
514
- this.render()
515
- }
483
+ /**
484
+ * Bind all instance methods to this
485
+ */
486
+ fezRegisterBindMethods() {
487
+ const methods = Object.getOwnPropertyNames(
488
+ Object.getPrototypeOf(this),
489
+ ).filter(
490
+ (method) =>
491
+ method !== "constructor" && typeof this[method] === "function",
492
+ );
493
+ methods.forEach((method) => (this[method] = this[method].bind(this)));
516
494
  }
517
495
 
518
- // run only if node is attached, clear otherwise
519
- setInterval(func, tick, name) {
520
- if (typeof func == 'number') {
521
- [tick, func] = [func, tick]
522
- }
496
+ /**
497
+ * Create a reactive store that triggers re-renders on changes
498
+ */
499
+ fezReactiveStore(obj, handler) {
500
+ obj ||= {};
523
501
 
524
- name ||= Fez.fnv1(String(func))
502
+ handler ||= (o, k, v, oldValue) => {
503
+ if (v != oldValue) {
504
+ this.onStateChange(k, v, oldValue);
505
+ // Don't schedule re-render during init/mount or if already rendering
506
+ if (!this._isRendering && !this._isInitializing) {
507
+ this.fezNextTick(this.fezRender, "fezRender");
508
+ }
509
+ }
510
+ };
525
511
 
526
- this._setIntervalCache ||= {}
527
- clearInterval(this._setIntervalCache[name])
512
+ handler.bind(this);
528
513
 
529
- const intervalID = setInterval(() => {
530
- if (this.isConnected) {
531
- func()
514
+ function shouldProxy(obj) {
515
+ return (
516
+ typeof obj === "object" &&
517
+ obj !== null &&
518
+ !(obj instanceof Promise) &&
519
+ !obj.nodeType
520
+ );
521
+ }
522
+
523
+ function createReactive(obj, handler) {
524
+ if (!shouldProxy(obj)) {
525
+ return obj;
532
526
  }
533
- }, tick)
534
527
 
535
- this._setIntervalCache[name] = intervalID
528
+ return new Proxy(obj, {
529
+ set(target, property, value, receiver) {
530
+ const currentValue = Reflect.get(target, property, receiver);
536
531
 
537
- // Register cleanup callback
538
- this.addOnDestroy(() => {
539
- clearInterval(intervalID);
540
- delete this._setIntervalCache[name];
541
- });
532
+ if (currentValue !== value) {
533
+ if (shouldProxy(value)) {
534
+ value = createReactive(value, handler);
535
+ }
542
536
 
543
- return intervalID
537
+ const result = Reflect.set(target, property, value, receiver);
538
+ handler(target, property, value, currentValue);
539
+ return result;
540
+ }
541
+
542
+ return true;
543
+ },
544
+ get(target, property, receiver) {
545
+ const value = Reflect.get(target, property, receiver);
546
+ if (shouldProxy(value)) {
547
+ return createReactive(value, handler);
548
+ }
549
+ return value;
550
+ },
551
+ });
552
+ }
553
+
554
+ return createReactive(obj, handler);
544
555
  }
545
556
 
557
+ // ===========================================================================
558
+ // DOM HELPERS
559
+ // ===========================================================================
560
+
561
+ /**
562
+ * Find element by selector
563
+ */
546
564
  find(selector) {
547
- return typeof selector == 'string' ? this.root.querySelector(selector) : selector
565
+ return typeof selector == "string"
566
+ ? this.root.querySelector(selector)
567
+ : selector;
568
+ }
569
+
570
+ /**
571
+ * Add one or more classes (space-separated) to root or given node
572
+ */
573
+ addClass(names, node) {
574
+ (node || this.root).classList.add(...names.split(/\s+/).filter(Boolean));
548
575
  }
549
576
 
550
- // get or set node value
577
+ /**
578
+ * Toggle a class on root or given node, with optional force boolean
579
+ */
580
+ toggleClass(name, force, node) {
581
+ (node || this.root).classList.toggle(name, force);
582
+ }
583
+
584
+ /**
585
+ * Get or set node value (input/textarea/select or innerHTML)
586
+ */
551
587
  val(selector, data) {
552
- const node = this.find(selector)
588
+ const node = this.find(selector);
553
589
 
554
590
  if (node) {
555
- if (['INPUT', 'TEXTAREA', 'SELECT'].includes(node.nodeName)) {
556
- if (typeof data != 'undefined') {
557
- if (node.type == 'checkbox') {
558
- node.checked = !!data
591
+ if (["INPUT", "TEXTAREA", "SELECT"].includes(node.nodeName)) {
592
+ if (typeof data != "undefined") {
593
+ if (node.type == "checkbox") {
594
+ node.checked = !!data;
559
595
  } else {
560
- node.value = data
596
+ node.value = data;
561
597
  }
562
598
  } else {
563
- return node.value
599
+ return node.value;
564
600
  }
565
601
  } else {
566
- if (typeof data != 'undefined') {
567
- node.innerHTML = data
602
+ if (typeof data != "undefined") {
603
+ node.innerHTML = data;
568
604
  } else {
569
- return node.innerHTML
605
+ return node.innerHTML;
570
606
  }
571
607
  }
572
608
  }
573
609
  }
574
610
 
611
+ /**
612
+ * Instance form data helper
613
+ */
575
614
  formData(node) {
576
- return this.class.formData(node || this.root)
615
+ return this.class.formData(node || this.root);
577
616
  }
578
617
 
579
- // get or set attribute
618
+ /**
619
+ * Get or set root attribute
620
+ */
580
621
  attr(name, value) {
581
- if (typeof value === 'undefined') {
582
- return this.root.getAttribute(name)
622
+ if (typeof value === "undefined") {
623
+ return this.root.getAttribute(name);
583
624
  } else {
584
- this.root.setAttribute(name, value)
585
- return value
625
+ this.root.setAttribute(name, value);
626
+ return value;
586
627
  }
587
628
  }
588
629
 
589
- // get root node child nodes as array
630
+ /**
631
+ * Get root element children as array, optionally transform
632
+ * Returns only element nodes (nodeType === 1), text nodes are excluded.
633
+ * Pass true to convert children to objects with attrs as keys, innerHTML as .html, original node as .ROOT
634
+ */
590
635
  childNodes(func) {
591
- let children = Array.from(this.root.children)
592
-
593
- if (func) {
594
- // Create temporary container to avoid ancestor-parent errors
595
- const tmpContainer = document.createElement('div')
596
- tmpContainer.style.display = 'none'
597
- document.body.appendChild(tmpContainer)
598
- children.forEach(child => tmpContainer.appendChild(child))
599
-
600
- children = Array.from(tmpContainer.children).map(func)
601
- document.body.removeChild(tmpContainer)
636
+ let children = this._fezChildNodes || Array.from(this.root.children);
637
+ if (func === true) {
638
+ children = children.map((node) => {
639
+ const obj = { html: node.innerHTML, ROOT: node };
640
+ for (const attr of node.attributes) {
641
+ obj[attr.name] = attr.value;
642
+ }
643
+ return obj;
644
+ });
645
+ } else if (func) {
646
+ children = children.map(func);
602
647
  }
603
-
604
- return children
648
+ return children;
605
649
  }
606
650
 
607
- subscribe(channel, func) {
608
- Fez._subs ||= {}
609
- Fez._subs[channel] ||= []
610
- Fez._subs[channel] = Fez._subs[channel].filter((el) => el[0].isConnected)
611
- Fez._subs[channel].push([this, func])
651
+ /**
652
+ * Set CSS properties on root
653
+ */
654
+ setStyle(key, value) {
655
+ if (key && typeof key == "object") {
656
+ Object.entries(key).forEach(([prop, val]) => {
657
+ this.root.style.setProperty(prop, val);
658
+ });
659
+ } else {
660
+ this.root.style.setProperty(key, value);
661
+ }
612
662
  }
613
663
 
614
- // get and set root node ID
615
- rootId() {
616
- this.root.id ||= `fez_${this.UID}`
617
- return this.root.id
618
- }
664
+ /**
665
+ * Copy props as attributes to root
666
+ */
667
+ copy() {
668
+ for (const name of Array.from(arguments)) {
669
+ let value = this.props[name];
619
670
 
620
- fezRegister() {
621
- if (this.css) {
622
- this.css = Fez.globalCss(this.css, {name: this.fezName, wrap: true})
623
- }
671
+ if (value !== undefined) {
672
+ if (name == "class") {
673
+ const klass = this.root.getAttribute(name, value);
674
+ if (klass) {
675
+ value = [klass, value].join(" ");
676
+ }
677
+ }
624
678
 
625
- if (this.class.css) {
626
- this.class.css = Fez.globalCss(this.class.css, {name: this.fezName})
679
+ if (typeof value == "string") {
680
+ this.root.setAttribute(name, value);
681
+ } else {
682
+ this.root[name] = value;
683
+ }
684
+ }
627
685
  }
628
-
629
- this.state ||= this.reactiveStore()
630
- this.globalState = Fez.state.createProxy(this)
631
- this.fezRegisterBindMethods()
632
686
  }
633
687
 
634
- // bind all instance method to this, to avoid calling with .bind(this)
635
- fezRegisterBindMethods() {
636
- const methods = Object.getOwnPropertyNames(Object.getPrototypeOf(this))
637
- .filter(method => method !== 'constructor' && typeof this[method] === 'function')
638
-
639
- methods.forEach(method => this[method] = this[method].bind(this))
688
+ /**
689
+ * Get or set root ID
690
+ */
691
+ rootId() {
692
+ this.root.id ||= `fez_${this.UID}`;
693
+ return this.root.id;
640
694
  }
641
695
 
642
- // dissolve into parent, if you want to promote first child or given node with this.root
696
+ /**
697
+ * Dissolve component into parent
698
+ */
643
699
  dissolve(inNode) {
644
700
  if (inNode) {
645
- inNode.classList.add('fez')
646
- inNode.classList.add(`fez-${this.fezName}`)
647
- inNode.fez = this
648
- if (this.attr('id')) inNode.setAttribute('id', this.attr('id'))
701
+ inNode.classList.add("fez");
702
+ inNode.classList.add(`fez-${this.fezName}`);
703
+ inNode.fez = this;
704
+ if (this.attr("id")) inNode.setAttribute("id", this.attr("id"));
649
705
 
650
- this.root.innerHTML = ''
651
- this.root.appendChild(inNode)
706
+ this.root.innerHTML = "";
707
+ this.root.appendChild(inNode);
652
708
  }
653
709
 
654
- const node = this.root
655
- const nodes = this.childNodes()
656
- const parent = this.root.parentNode
710
+ const node = this.root;
711
+ const nodes = this.childNodes();
712
+ const parent = this.root.parentNode;
657
713
 
658
- nodes.reverse().forEach(el => parent.insertBefore(el, node.nextSibling))
714
+ nodes.reverse().forEach((el) => parent.insertBefore(el, node.nextSibling));
659
715
 
660
- this.root.remove()
661
- this.root = undefined
716
+ this.root.remove();
717
+ this.root = undefined;
662
718
 
663
719
  if (inNode) {
664
- this.root = inNode
720
+ this.root = inNode;
665
721
  }
666
722
 
667
- return nodes
723
+ return nodes;
668
724
  }
669
725
 
670
- reactiveStore(obj, handler) {
671
- obj ||= {}
726
+ // ===========================================================================
727
+ // EVENTS
728
+ // ===========================================================================
672
729
 
673
- handler ||= (o, k, v, oldValue) => {
674
- this.onStateChange(k, v, oldValue)
675
- this.nextTick(this.render, 'render')
730
+ /**
731
+ * Add window event listener with auto-cleanup
732
+ */
733
+ on(eventName, func, delay = 200) {
734
+ this._eventHandlers = this._eventHandlers || {};
735
+
736
+ if (this._eventHandlers[eventName]) {
737
+ window.removeEventListener(eventName, this._eventHandlers[eventName]);
676
738
  }
677
739
 
678
- handler.bind(this)
740
+ const throttledFunc = Fez.throttle(() => {
741
+ if (this.isConnected) func.call(this);
742
+ }, delay);
679
743
 
680
- // licence ? -> generated by ChatGPT 2024
681
- function createReactive(obj, handler) {
682
- if (typeof obj !== 'object' || obj === null) {
683
- return obj;
684
- }
744
+ this._eventHandlers[eventName] = throttledFunc;
745
+ window.addEventListener(eventName, throttledFunc);
685
746
 
686
- return new Proxy(obj, {
687
- set(target, property, value, receiver) {
688
- // Get the current value of the property
689
- const currentValue = Reflect.get(target, property, receiver);
747
+ this.addOnDestroy(() => {
748
+ window.removeEventListener(eventName, throttledFunc);
749
+ delete this._eventHandlers[eventName];
750
+ });
751
+ }
690
752
 
691
- // Only proceed if the new value is different from the current value
692
- if (currentValue !== value) {
693
- if (typeof value === 'object' && value !== null) {
694
- value = createReactive(value, handler); // Recursively make nested objects reactive
695
- }
753
+ /**
754
+ * Window resize handler
755
+ */
756
+ onWindowResize(func, delay) {
757
+ this.on("resize", func, delay);
758
+ func();
759
+ }
696
760
 
697
- // Set the new value
698
- const result = Reflect.set(target, property, value, receiver);
761
+ /**
762
+ * Window scroll handler
763
+ */
764
+ onWindowScroll(func, delay) {
765
+ this.on("scroll", func, delay);
766
+ func();
767
+ }
699
768
 
700
- // Call the handler only if the value has changed
701
- handler(target, property, value, currentValue);
769
+ /**
770
+ * Element resize handler using ResizeObserver
771
+ */
772
+ onElementResize(el, func, delay = 200) {
773
+ const throttledFunc = Fez.throttle(() => {
774
+ if (this.isConnected) func.call(this, el.getBoundingClientRect(), el);
775
+ }, delay);
702
776
 
703
- return result;
704
- }
777
+ const observer = new ResizeObserver(throttledFunc);
778
+ observer.observe(el);
705
779
 
706
- // If the value hasn't changed, return true (indicating success) without calling the handler
707
- return true;
708
- },
709
- get(target, property, receiver) {
710
- const value = Reflect.get(target, property, receiver);
711
- if (typeof value === 'object' && value !== null) {
712
- return createReactive(value, handler); // Recursively make nested objects reactive
713
- }
714
- return value;
715
- }
716
- });
780
+ func.call(this, el.getBoundingClientRect(), el);
781
+
782
+ this.addOnDestroy(() => {
783
+ observer.disconnect();
784
+ });
785
+ }
786
+
787
+ /**
788
+ * Timeout with auto-cleanup
789
+ */
790
+ setTimeout(func, delay) {
791
+ const timeoutID = setTimeout(() => {
792
+ if (this.isConnected) func();
793
+ }, delay);
794
+
795
+ this.addOnDestroy(() => clearTimeout(timeoutID));
796
+
797
+ return timeoutID;
798
+ }
799
+
800
+ /**
801
+ * Interval with auto-cleanup
802
+ */
803
+ setInterval(func, tick, name) {
804
+ if (typeof func == "number") {
805
+ [tick, func] = [func, tick];
717
806
  }
718
807
 
719
- return createReactive(obj, handler);
808
+ name ||= Fez.fnv1(String(func));
809
+
810
+ this._setIntervalCache ||= {};
811
+ clearInterval(this._setIntervalCache[name]);
812
+
813
+ const intervalID = setInterval(() => {
814
+ if (this.isConnected) func();
815
+ }, tick);
816
+
817
+ this._setIntervalCache[name] = intervalID;
818
+
819
+ this.addOnDestroy(() => {
820
+ clearInterval(intervalID);
821
+ delete this._setIntervalCache[name];
822
+ });
823
+
824
+ return intervalID;
825
+ }
826
+
827
+ // ===========================================================================
828
+ // PUB/SUB
829
+ // ===========================================================================
830
+
831
+ /**
832
+ * Publish to parent components (bubbles up through DOM)
833
+ * @param {string} channel - Event name
834
+ * @param {...any} args - Arguments to pass
835
+ * @returns {boolean} True if a parent handled the event
836
+ */
837
+ publish(channel, ...args) {
838
+ return componentPublish(this, channel, ...args);
839
+ }
840
+
841
+ /**
842
+ * Subscribe to a channel (auto-cleanup on destroy)
843
+ * @param {string} channel - Event name
844
+ * @param {Function} func - Handler function
845
+ * @returns {Function} Unsubscribe function
846
+ */
847
+ subscribe(channel, func) {
848
+ const unsubscribe = componentSubscribe(this, channel, func);
849
+ this.addOnDestroy(unsubscribe);
850
+ return unsubscribe;
851
+ }
852
+
853
+ // ===========================================================================
854
+ // SLOTS
855
+ // ===========================================================================
856
+
857
+ /**
858
+ * Copy child nodes natively to preserve bound events
859
+ */
860
+ fezSlot(source, target) {
861
+ target ||= document.createElement("template");
862
+ const isSlot = target.nodeName == "SLOT";
863
+
864
+ while (source.firstChild) {
865
+ if (isSlot) {
866
+ target.parentNode.insertBefore(source.lastChild, target.nextSibling);
867
+ } else {
868
+ target.appendChild(source.firstChild);
869
+ }
870
+ }
871
+
872
+ if (isSlot) {
873
+ target.parentNode.removeChild(target);
874
+ } else {
875
+ source.innerHTML = "";
876
+ }
877
+
878
+ return target;
720
879
  }
721
880
  }