@dinoreic/fez 0.4.1 → 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 (typeof value == 'string') {
134
- this.root.setAttribute(name, value)
135
- }
136
- else {
137
- this.root[name] = value
138
- }
139
- }
140
- }
141
- }
173
+ connect() {}
174
+ onMount() {}
175
+ beforeRender() {}
176
+ afterRender() {}
177
+ onDestroy() {}
178
+ onStateChange() {}
179
+ onGlobalStateChange() {}
180
+ onPropsChange() {}
142
181
 
143
- // clear all node references
144
- // Centralized destroy logic
182
+ /**
183
+ * Centralized destroy logic - called by MutationObserver when element is removed
184
+ */
145
185
  fezOnDestroy() {
146
- // 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)
147
191
  if (this._onDestroyCallbacks) {
148
- this._onDestroyCallbacks.forEach(callback => {
192
+ this._onDestroyCallbacks.forEach((callback) => {
149
193
  try {
150
194
  callback();
151
195
  } catch (e) {
152
- console.error('Fez: Error in cleanup callback:', e);
196
+ this.fezError("destroy", "Error in cleanup callback", e);
153
197
  }
154
198
  });
155
199
  this._onDestroyCallbacks = [];
156
200
  }
157
201
 
158
- // Call user's onDestroy lifecycle hook
159
- this.onDestroy()
160
- this.onDestroy = () => {}
202
+ // Call user's onDestroy hook
203
+ this.onDestroy();
204
+ this.onDestroy = () => {};
161
205
 
162
206
  // Clean up root references
163
207
  if (this.root) {
164
- this.root.fez = undefined
208
+ this.root.fez = undefined;
165
209
  }
166
-
167
- this.root = undefined
210
+ this.root = undefined;
168
211
  }
169
212
 
170
- // Add a cleanup callback to be executed on destroy
213
+ /**
214
+ * Add a cleanup callback for destroy
215
+ */
171
216
  addOnDestroy(callback) {
172
217
  this._onDestroyCallbacks = this._onDestroyCallbacks || [];
173
218
  this._onDestroyCallbacks.push(callback);
174
219
  }
175
220
 
176
- // Generic function to handle window events with automatic cleanup
177
- on(eventName, func, delay = 200) {
178
- this._eventHandlers = this._eventHandlers || {};
179
-
180
- if (this._eventHandlers[eventName]) {
181
- window.removeEventListener(eventName, this._eventHandlers[eventName]);
182
- }
183
-
184
- const throttledFunc = Fez.throttle(() => {
185
- if (this.isConnected) {
186
- func.call(this);
187
- }
188
- }, delay);
189
-
190
- this._eventHandlers[eventName] = throttledFunc;
191
- window.addEventListener(eventName, throttledFunc);
192
-
193
- this.addOnDestroy(() => {
194
- window.removeEventListener(eventName, throttledFunc);
195
- delete this._eventHandlers[eventName];
196
- });
197
- }
198
-
199
- // Helper function for resize events
200
- onWindowResize(func, delay) {
201
- this.on('resize', func, delay);
202
- func();
203
- }
204
-
205
- // Helper function for scroll events
206
- onWindowScroll(func, delay) {
207
- this.on('scroll', func, delay);
208
- func();
209
- }
210
-
211
- // Helper function for element resize events using ResizeObserver
212
- onElementResize(el, func, delay = 200) {
213
- const throttledFunc = Fez.throttle(() => {
214
- if (this.isConnected) {
215
- func.call(this, el.getBoundingClientRect(), el);
216
- }
217
- }, delay);
221
+ // ===========================================================================
222
+ // RENDERING
223
+ // ===========================================================================
218
224
 
219
- const observer = new ResizeObserver(throttledFunc);
220
- observer.observe(el);
221
-
222
- func.call(this, el.getBoundingClientRect(), el);
223
-
224
- this.addOnDestroy(() => {
225
- observer.disconnect();
226
- });
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();
227
234
  }
228
235
 
229
- // copy child nodes, natively to preserve bound events
230
- // if node name is SLOT insert adjacent and remove SLOT, else as a child nodes
231
- slot(source, target) {
232
- target ||= document.createElement('template')
233
- const isSlot = target.nodeName == 'SLOT'
234
-
235
- while (source.firstChild) {
236
- if (isSlot) {
237
- target.parentNode.insertBefore(source.lastChild, target.nextSibling);
238
- } else {
239
- target.appendChild(source.firstChild)
240
- }
241
- }
242
-
243
- if (isSlot) {
244
- 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);
245
246
  } else {
246
- source.innerHTML = ''
247
+ window.requestAnimationFrame(func.bind(this));
247
248
  }
248
-
249
- return target
250
249
  }
251
250
 
252
- setStyle(key, value) {
253
- this.root.style.setProperty(key, value);
254
- }
255
-
256
- connect() {}
257
- onMount() {}
258
- beforeRender() {}
259
- afterRender() {}
260
- onDestroy() {}
261
- onStateChange() {}
262
- onGlobalStateChange() {}
263
-
264
- // component publish will search for parent component that subscribes by name
265
- publish(channel, ...args) {
266
- const handle_publish = (component) => {
267
- if (Fez._subs && Fez._subs[channel]) {
268
- const sub = Fez._subs[channel].find(([comp]) => comp === component)
269
- if (sub) {
270
- sub[1].bind(component)(...args)
271
- return true
272
- }
273
- }
274
- return false
275
- }
276
-
277
- // Check if current component has subscription
278
- if (handle_publish(this)) {
279
- return true
280
- }
281
-
282
- // Bubble up to parent components
283
- let parent = this.root.parentElement
284
- while (parent) {
285
- if (parent.fez) {
286
- if (handle_publish(parent.fez)) {
287
- return true
288
- }
289
- }
290
- parent = parent.parentElement
291
- }
292
-
293
- // If no parent handled it, fall back to global publish
294
- // Fez.publish(channel, ...args)
295
- return false
251
+ /**
252
+ * Force a re-render on next frame
253
+ */
254
+ fezRefresh() {
255
+ this.fezNextTick(() => this.fezRender(), "refresh");
296
256
  }
297
257
 
298
- fezBlocks = {}
299
-
300
- parseHtml(text) {
301
- const base = this.fezHtmlRoot.replaceAll('"', '&quot;')
302
-
303
- text = text
304
- .replace(/([!'"\s;])fez\.(\w)/g, `$1${base}$2`)
305
- .replace(/>\s+</g, '><')
306
-
307
- return text.trim()
258
+ /**
259
+ * Alias for fezRefresh - can be overwritten
260
+ */
261
+ refresh() {
262
+ this.fezRefresh();
308
263
  }
309
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;
310
272
 
311
- // pass name to have only one tick of a kind
312
- nextTick(func, name) {
313
- if (name) {
314
- this._nextTicks ||= {}
315
- this._nextTicks[name] ||= window.requestAnimationFrame(() => {
316
- func.bind(this)()
317
- this._nextTicks[name] = null
318
- }, name)
319
- } else {
320
- window.requestAnimationFrame(func.bind(this))
321
- }
322
- }
273
+ if (!template || !this.root) return;
323
274
 
324
- // inject htmlString as innerHTML and replace $$. with local pointer
325
- // $$. will point to current fez instance
326
- // <slot></slot> will be replaced with current root
327
- // this.render('...loading')
328
- // this.render('.images', '...loading')
329
- render(template) {
330
- template ||= this?.class?.fezHtmlFunc
275
+ // Prevent re-render loops from state changes in beforeRender/afterRender
276
+ this._isRendering = true;
331
277
 
332
- if (!template || !this.root) return
278
+ this.beforeRender();
333
279
 
334
- this.beforeRender()
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");
335
285
 
336
- const nodeName = typeof this.class.nodeName == 'function' ? this.class.nodeName(this.root) : this.class.nodeName
337
- const newNode = document.createElement(nodeName || 'div')
338
-
339
- let renderedTpl
286
+ let renderedTpl;
340
287
  if (Array.isArray(template)) {
341
- // array nodes this.n(...), look tabs example
342
288
  if (template[0] instanceof Node) {
343
- template.forEach( n => newNode.appendChild(n) )
344
- } else{
345
- renderedTpl = template.join('')
289
+ template.forEach((n) => newNode.appendChild(n));
290
+ } else {
291
+ renderedTpl = template.join("");
346
292
  }
347
- }
348
- else if (typeof template == 'string') {
349
- renderedTpl = createTemplate(template)(this)
350
- }
351
- else if (typeof template == 'function') {
352
- 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);
353
298
  }
354
299
 
355
300
  if (renderedTpl) {
356
- renderedTpl = renderedTpl.replace(/\s\w+="undefined"/g, '')
357
- 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
+ }
358
320
  }
359
321
 
360
- // Handle fez-keep attributes
361
- this.fezKeepNode(newNode)
322
+ this.fezKeepNode(newNode);
362
323
 
363
- // Handle fez-memoize attributes
364
- 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
+ });
365
334
 
366
- Fez.morphdom(this.root, newNode)
335
+ Fez.morphdom(this.root, newNode);
367
336
 
368
- this.fezRenderPostProcess()
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
+ });
348
+
349
+ this.fezRenderPostProcess();
350
+ this.afterRender();
369
351
 
370
- this.afterRender()
352
+ this._isRendering = false;
371
353
  }
372
354
 
355
+ /**
356
+ * Post-render processing for fez-* attributes
357
+ */
373
358
  fezRenderPostProcess() {
374
359
  const fetchAttr = (name, func) => {
375
- this.root.querySelectorAll(`*[${name}]`).forEach((n)=>{
376
- let value = n.getAttribute(name)
377
- n.removeAttribute(name)
360
+ this.root.querySelectorAll(`*[${name}]`).forEach((n) => {
361
+ let value = n.getAttribute(name);
362
+ n.removeAttribute(name);
378
363
  if (value) {
379
- func.bind(this)(value, n)
364
+ func.bind(this)(value, n);
380
365
  }
381
- })
382
- }
366
+ });
367
+ };
383
368
 
384
- // <button fez-this="button" -> this.button = node
385
- fetchAttr('fez-this', (value, n) => {
386
- (new Function('n', `this.${value} = n`)).bind(this)(n)
387
- })
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
+ });
388
375
 
389
- // <button fez-use="animate" -> this.animate(node]
390
- fetchAttr('fez-use', (value, n) => {
391
- if (value.includes('=>')) {
392
- // fez-use="el => el.focus()"
393
- Fez.getFunction(value)(n)
394
- }
395
- else {
396
- if (value.includes('.')) {
397
- // fez-use="this.focus()"
398
- Fez.getFunction(value).bind(n)()
399
- }
400
- else {
401
- // fez-use="animate"
402
- const target = this[value]
403
- if (typeof target == 'function') {
404
- 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);
405
387
  } else {
406
- console.error(`Fez error: "${value}" is not a function in ${this.fezName}`)
388
+ this.fezError("fez-use", `"${value}" is not a function`);
407
389
  }
408
390
  }
409
391
  }
410
- })
392
+ });
411
393
 
412
- // <button fez-class="dialog animate" -> add class "animate" after node init to trigger animation
413
- fetchAttr('fez-class', (value, n) => {
414
- let classes = value.split(/\s+/)
415
- let lastClass = classes.pop()
416
- 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));
417
399
  if (lastClass) {
418
- setTimeout(()=>{
419
- n.classList.add(lastClass)
420
- }, 1)
421
- }
422
- })
423
-
424
- // <input fez-bind="state.inputNode" -> this.state.inputNode will be the value of input
425
- fetchAttr('fez-bind', (text, n) => {
426
- if (['INPUT', 'SELECT', 'TEXTAREA'].includes(n.nodeName)) {
427
- const value = (new Function(`return this.${text}`)).bind(this)()
428
- const isCb = n.type.toLowerCase() == 'checkbox'
429
- const eventName = ['SELECT'].includes(n.nodeName) || isCb ? 'onchange' : 'onkeyup'
430
- n.setAttribute(eventName, `${this.fezHtmlRoot}${text} = this.${isCb ? 'checked' : 'value'}`)
431
- this.val(n, value)
432
- } else {
433
- 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);
434
403
  }
435
- })
404
+ });
436
405
 
437
- this.root.querySelectorAll(`*[disabled]`).forEach((n)=>{
438
- let value = n.getAttribute('disabled')
439
- if (['false'].includes(value)) {
440
- 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;
441
420
  } else {
442
- n.setAttribute('disabled', 'true')
421
+ this.fezError(
422
+ "fez-bind",
423
+ `Can't bind "${text}" to ${n.nodeName} (needs INPUT, SELECT or TEXTAREA)`,
424
+ );
443
425
  }
444
- })
445
- }
426
+ });
446
427
 
447
- fezKeepNode(newNode) {
448
- newNode.querySelectorAll('[fez-keep]').forEach(newEl => {
449
- const key = newEl.getAttribute('fez-keep')
450
- const oldEl = this.root.querySelector(`[fez-keep="${key}"]`)
451
-
452
- if (oldEl) {
453
- // Keep the old element in place of the new one
454
- newEl.parentNode.replaceChild(oldEl, newEl)
455
- } else if (key === 'default-slot') {
456
- if (newEl.getAttribute('hide')) {
457
- // You cant use state any more
458
- this.state = null
459
-
460
- const parent = newEl.parentNode
461
-
462
- // Insert all root children before the slot's next sibling
463
- Array.from(this.root.childNodes).forEach(child => {
464
- parent.insertBefore(child, newEl)
465
- })
466
-
467
- // Remove the slot element
468
- newEl.remove()
469
- }
470
- else {
471
- // First render - populate the slot with current root children
472
- Array.from(this.root.childNodes).forEach(
473
- child => {
474
- newEl.appendChild(child)
475
- }
476
- )
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);
477
437
  }
478
- }
479
- })
438
+ });
439
+ }
480
440
  }
481
441
 
482
- fezMemoization(newNode) {
483
- // Find the single memoize element in new DOM (excluding fez components)
484
- const newMemoEl = newNode.querySelector('[fez-memoize]:not(.fez)')
485
- 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
+ }
486
461
 
487
- this.fezMemoStore ||= new Map()
462
+ // ===========================================================================
463
+ // REACTIVE STATE
464
+ // ===========================================================================
488
465
 
489
- const newMemoElKey = newMemoEl.getAttribute('fez-memoize')
490
- 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
+ }
491
473
 
492
- if (storedNode) {
493
- Fez.log(`Memoize restore ${this.fezName}: ${newMemoElKey}`)
494
- newMemoEl.parentNode.replaceChild(storedNode.cloneNode(true), newMemoEl)
495
- } else {
496
- const oldMemoEl = this.root.querySelector('[fez-memoize]:not(.fez)')
497
- if (oldMemoEl) {
498
- const oldMemoElKey = oldMemoEl.getAttribute('fez-memoize')
499
- this.fezMemoStore.set(oldMemoElKey, oldMemoEl.cloneNode(true))
500
- }
474
+ if (this.class.css) {
475
+ this.class.css = Fez.globalCss(this.class.css, { name: this.fezName });
501
476
  }
477
+
478
+ this.state ||= this.fezReactiveStore();
479
+ this.globalState = Fez.state.createProxy(this);
480
+ this.fezRegisterBindMethods();
502
481
  }
503
482
 
504
- // refresh single node only
505
- refresh(selector) {
506
- alert('NEEDS FIX and remove htmlTemplate')
507
- if (selector) {
508
- const n = Fez.domRoot(this.class.htmlTemplate)
509
- const tpl = n.querySelector(selector).innerHTML
510
- this.render(selector, tpl)
511
- } else {
512
- this.render()
513
- }
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)));
514
494
  }
515
495
 
516
- // run only if node is attached, clear otherwise
517
- setInterval(func, tick, name) {
518
- if (typeof func == 'number') {
519
- [tick, func] = [func, tick]
520
- }
496
+ /**
497
+ * Create a reactive store that triggers re-renders on changes
498
+ */
499
+ fezReactiveStore(obj, handler) {
500
+ obj ||= {};
521
501
 
522
- 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
+ };
523
511
 
524
- this._setIntervalCache ||= {}
525
- clearInterval(this._setIntervalCache[name])
512
+ handler.bind(this);
526
513
 
527
- const intervalID = setInterval(() => {
528
- if (this.isConnected) {
529
- 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;
530
526
  }
531
- }, tick)
532
527
 
533
- this._setIntervalCache[name] = intervalID
528
+ return new Proxy(obj, {
529
+ set(target, property, value, receiver) {
530
+ const currentValue = Reflect.get(target, property, receiver);
534
531
 
535
- // Register cleanup callback
536
- this.addOnDestroy(() => {
537
- clearInterval(intervalID);
538
- delete this._setIntervalCache[name];
539
- });
532
+ if (currentValue !== value) {
533
+ if (shouldProxy(value)) {
534
+ value = createReactive(value, handler);
535
+ }
536
+
537
+ const result = Reflect.set(target, property, value, receiver);
538
+ handler(target, property, value, currentValue);
539
+ return result;
540
+ }
540
541
 
541
- return intervalID
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);
542
555
  }
543
556
 
557
+ // ===========================================================================
558
+ // DOM HELPERS
559
+ // ===========================================================================
560
+
561
+ /**
562
+ * Find element by selector
563
+ */
544
564
  find(selector) {
545
- 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));
575
+ }
576
+
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);
546
582
  }
547
583
 
548
- // get or set node value
584
+ /**
585
+ * Get or set node value (input/textarea/select or innerHTML)
586
+ */
549
587
  val(selector, data) {
550
- const node = this.find(selector)
588
+ const node = this.find(selector);
551
589
 
552
590
  if (node) {
553
- if (['INPUT', 'TEXTAREA', 'SELECT'].includes(node.nodeName)) {
554
- if (typeof data != 'undefined') {
555
- if (node.type == 'checkbox') {
556
- 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;
557
595
  } else {
558
- node.value = data
596
+ node.value = data;
559
597
  }
560
598
  } else {
561
- return node.value
599
+ return node.value;
562
600
  }
563
601
  } else {
564
- if (typeof data != 'undefined') {
565
- node.innerHTML = data
602
+ if (typeof data != "undefined") {
603
+ node.innerHTML = data;
566
604
  } else {
567
- return node.innerHTML
605
+ return node.innerHTML;
568
606
  }
569
607
  }
570
608
  }
571
609
  }
572
610
 
611
+ /**
612
+ * Instance form data helper
613
+ */
573
614
  formData(node) {
574
- return this.class.formData(node || this.root)
615
+ return this.class.formData(node || this.root);
575
616
  }
576
617
 
577
- // get or set attribute
618
+ /**
619
+ * Get or set root attribute
620
+ */
578
621
  attr(name, value) {
579
- if (typeof value === 'undefined') {
580
- return this.root.getAttribute(name)
622
+ if (typeof value === "undefined") {
623
+ return this.root.getAttribute(name);
581
624
  } else {
582
- this.root.setAttribute(name, value)
583
- return value
625
+ this.root.setAttribute(name, value);
626
+ return value;
584
627
  }
585
628
  }
586
629
 
587
- // 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
+ */
588
635
  childNodes(func) {
589
- let children = Array.from(this.root.children)
590
-
591
- if (func) {
592
- // Create temporary container to avoid ancestor-parent errors
593
- const tmpContainer = document.createElement('div')
594
- tmpContainer.style.display = 'none'
595
- document.body.appendChild(tmpContainer)
596
- children.forEach(child => tmpContainer.appendChild(child))
597
-
598
- children = Array.from(tmpContainer.children).map(func)
599
- 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);
600
647
  }
601
-
602
- return children
648
+ return children;
603
649
  }
604
650
 
605
- subscribe(channel, func) {
606
- Fez._subs ||= {}
607
- Fez._subs[channel] ||= []
608
- Fez._subs[channel] = Fez._subs[channel].filter((el) => el[0].isConnected)
609
- 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
+ }
610
662
  }
611
663
 
612
- // get and set root node ID
613
- rootId() {
614
- this.root.id ||= `fez_${this.UID}`
615
- return this.root.id
616
- }
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];
617
670
 
618
- fezRegister() {
619
- if (this.css) {
620
- this.css = Fez.globalCss(this.css, {name: this.fezName, wrap: true})
621
- }
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
+ }
622
678
 
623
- if (this.class.css) {
624
- 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
+ }
625
685
  }
626
-
627
- this.state ||= this.reactiveStore()
628
- this.globalState = Fez.state.createProxy(this)
629
- this.fezRegisterBindMethods()
630
686
  }
631
687
 
632
- // bind all instance method to this, to avoid calling with .bind(this)
633
- fezRegisterBindMethods() {
634
- const methods = Object.getOwnPropertyNames(Object.getPrototypeOf(this))
635
- .filter(method => method !== 'constructor' && typeof this[method] === 'function')
636
-
637
- 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;
638
694
  }
639
695
 
640
- // dissolve into parent, if you want to promote first child or given node with this.root
696
+ /**
697
+ * Dissolve component into parent
698
+ */
641
699
  dissolve(inNode) {
642
700
  if (inNode) {
643
- inNode.classList.add('fez')
644
- inNode.classList.add(`fez-${this.fezName}`)
645
- inNode.fez = this
646
- 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"));
647
705
 
648
- this.root.innerHTML = ''
649
- this.root.appendChild(inNode)
706
+ this.root.innerHTML = "";
707
+ this.root.appendChild(inNode);
650
708
  }
651
709
 
652
- const node = this.root
653
- const nodes = this.childNodes()
654
- const parent = this.root.parentNode
710
+ const node = this.root;
711
+ const nodes = this.childNodes();
712
+ const parent = this.root.parentNode;
655
713
 
656
- nodes.reverse().forEach(el => parent.insertBefore(el, node.nextSibling))
714
+ nodes.reverse().forEach((el) => parent.insertBefore(el, node.nextSibling));
657
715
 
658
- this.root.remove()
659
- this.root = undefined
716
+ this.root.remove();
717
+ this.root = undefined;
660
718
 
661
719
  if (inNode) {
662
- this.root = inNode
720
+ this.root = inNode;
663
721
  }
664
722
 
665
- return nodes
723
+ return nodes;
666
724
  }
667
725
 
668
- reactiveStore(obj, handler) {
669
- obj ||= {}
726
+ // ===========================================================================
727
+ // EVENTS
728
+ // ===========================================================================
670
729
 
671
- handler ||= (o, k, v, oldValue) => {
672
- if (v != oldValue) {
673
- this.onStateChange(k, v, oldValue)
674
- this.nextTick(this.render, 'render')
675
- }
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
  }