@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.
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Async await helper for {#await} blocks in templates
3
+ *
4
+ * Manages promise state tracking and triggers re-renders when promises resolve/reject.
5
+ */
6
+
7
+ /**
8
+ * Handle promise state for {#await} blocks in templates
9
+ * Returns { status: 'pending'|'resolved'|'rejected', value, error }
10
+ *
11
+ * @param {FezBase} component - The component instance
12
+ * @param {number} awaitId - Unique ID for this await block
13
+ * @param {Promise|any} promiseOrValue - The promise or value to await
14
+ * @returns {Object} { status, value, error }
15
+ */
16
+ export default function awaitHelper(component, awaitId, promiseOrValue) {
17
+ // Initialize await states map on the component
18
+ component._awaitStates ||= new Map()
19
+
20
+ // Check if we already have state for this await block
21
+ const existing = component._awaitStates.get(awaitId)
22
+
23
+ // If not a promise, return resolved immediately
24
+ if (!promiseOrValue || typeof promiseOrValue.then !== 'function') {
25
+ return { status: 'resolved', value: promiseOrValue, error: null }
26
+ }
27
+
28
+ // If we have existing state for this exact promise, return it
29
+ if (existing && existing.promise === promiseOrValue) {
30
+ return existing
31
+ }
32
+
33
+ // New promise - set pending state and start tracking
34
+ const state = { status: 'pending', value: null, error: null, promise: promiseOrValue }
35
+ component._awaitStates.set(awaitId, state)
36
+
37
+ // Handle promise resolution
38
+ promiseOrValue
39
+ .then(value => {
40
+ // Only update if this is still the current promise for this await block
41
+ const current = component._awaitStates.get(awaitId)
42
+ if (current && current.promise === promiseOrValue) {
43
+ current.status = 'resolved'
44
+ current.value = value
45
+ // Trigger re-render
46
+ if (component.isConnected) {
47
+ component.fezNextTick(component.fezRender, 'fezRender')
48
+ }
49
+ }
50
+ })
51
+ .catch(error => {
52
+ const current = component._awaitStates.get(awaitId)
53
+ if (current && current.promise === promiseOrValue) {
54
+ current.status = 'rejected'
55
+ current.error = error
56
+ // Trigger re-render
57
+ if (component.isConnected) {
58
+ component.fezNextTick(component.fezRender, 'fezRender')
59
+ }
60
+ }
61
+ })
62
+
63
+ return state
64
+ }
@@ -3,7 +3,7 @@
3
3
  // Components access state via this.globalState proxy which automatically:
4
4
  // - Registers component as listener when reading a value
5
5
  // - Notifies component when that value changes
6
- // - Calls onGlobalStateChange(key, value) if defined, then render()
6
+ // - Calls onGlobalStateChange(key, value) if defined, then fezRender()
7
7
  //
8
8
  // Example usage:
9
9
  //
@@ -34,15 +34,19 @@ const GlobalState = {
34
34
  globalSubscribers: new Set(), // Set of functions that listen to all changes
35
35
 
36
36
  notify(key, value, oldValue) {
37
- Fez.log(`Global state change for ${key}: ${value} (from ${oldValue})`)
37
+ Fez.consoleLog(`Global state change for ${key}: ${value} (from ${oldValue})`)
38
38
 
39
39
  // Notify component listeners
40
40
  const listeners = this.listeners.get(key)
41
41
  if (listeners) {
42
42
  listeners.forEach(comp => {
43
43
  if (comp.isConnected) {
44
- comp.onGlobalStateChange(key, value, oldValue)
45
- comp.render()
44
+ try {
45
+ comp.onGlobalStateChange(key, value, oldValue)
46
+ comp.fezRender()
47
+ } catch (error) {
48
+ console.error(`Error in component listener for key ${key}:`, error)
49
+ }
46
50
  } else {
47
51
  listeners.delete(comp)
48
52
  }
@@ -72,8 +76,19 @@ const GlobalState = {
72
76
  },
73
77
 
74
78
  createProxy(component) {
79
+ // Register cleanup when component is destroyed
80
+ component.addOnDestroy(() => {
81
+ for (const [key, listeners] of this.listeners) {
82
+ listeners.delete(component)
83
+ }
84
+ component._globalStateKeys?.clear()
85
+ })
86
+
75
87
  return new Proxy({}, {
76
88
  get: (target, key) => {
89
+ // Skip symbol keys and prototype methods
90
+ if (typeof key === 'symbol') return undefined
91
+
77
92
  // Skip if already listening to this key
78
93
  component._globalStateKeys ||= new Set()
79
94
  if (!component._globalStateKeys.has(key)) {
@@ -89,6 +104,9 @@ const GlobalState = {
89
104
  },
90
105
 
91
106
  set: (target, key, value) => {
107
+ // Skip symbol keys
108
+ if (typeof key === 'symbol') return true
109
+
92
110
  const oldValue = this.data[key]
93
111
  if (oldValue !== value) {
94
112
  this.data[key] = value
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Unified Component Index
3
+ *
4
+ * Single source of truth for all component data:
5
+ * Fez.index['ui-btn'].class - Component class
6
+ * Fez.index['ui-btn'].meta - Metadata from META = {...}
7
+ * Fez.index['ui-btn'].demo - Demo HTML string
8
+ * Fez.index['ui-btn'].info - Info HTML string
9
+ * Fez.index['ui-btn'].source - Raw .fez source
10
+ *
11
+ * Helper methods:
12
+ * Fez.index.get('ui-btn') - Get entry with DOM nodes for demo/info
13
+ * Fez.index.apply('ui-btn', el) - Render demo into element
14
+ * Fez.index.names() - Get all registered component names
15
+ * Fez.index.withDemo() - Get names of components with demos
16
+ * Fez.index.all() - Get all components as object
17
+ */
18
+
19
+ function createDomNode(html) {
20
+ const node = document.createElement("div");
21
+ node.innerHTML = html;
22
+ return node;
23
+ }
24
+
25
+ const index = {
26
+ // Component entries stored directly: index['ui-btn'] = { class, meta, ... }
27
+
28
+ /**
29
+ * Get or create entry for component
30
+ * @param {string} name - Component name
31
+ * @returns {{ class: Function|null, meta: Object|null, demo: string|null, info: string|null, source: string|null }}
32
+ */
33
+ ensure(name) {
34
+ if (
35
+ !this[name] ||
36
+ typeof this[name] !== "object" ||
37
+ !("class" in this[name])
38
+ ) {
39
+ this[name] = {
40
+ class: null,
41
+ meta: null,
42
+ demo: null,
43
+ info: null,
44
+ source: null,
45
+ };
46
+ }
47
+ return this[name];
48
+ },
49
+
50
+ /**
51
+ * Get component data with DOM nodes for demo/info
52
+ * @param {string} name - Component name
53
+ * @returns {{ class: Function|null, meta: Object|null, demo: HTMLDivElement|null, info: HTMLDivElement|null, source: string|null }}
54
+ */
55
+ get(name) {
56
+ const entry = this[name];
57
+ if (!entry || typeof entry !== "object" || !("class" in entry)) {
58
+ return { class: null, meta: null, demo: null, info: null, source: null };
59
+ }
60
+
61
+ return {
62
+ class: entry.class,
63
+ meta: entry.meta,
64
+ source: entry.source,
65
+ demo: entry.demo ? createDomNode(entry.demo) : null,
66
+ info: entry.info ? createDomNode(entry.info) : null,
67
+ };
68
+ },
69
+
70
+ /**
71
+ * Apply demo to element and execute scripts
72
+ * Scripts are executed first to define data/variables, then DOM is injected
73
+ * @param {string} name - Component name
74
+ * @param {HTMLElement} target - Target element to render into
75
+ * @returns {boolean} - True if demo was found and applied
76
+ */
77
+ apply(name, target) {
78
+ const entry = this[name];
79
+ if (!entry?.demo || !target) return false;
80
+
81
+ const tempDiv = document.createElement("div");
82
+ tempDiv.innerHTML = entry.demo;
83
+
84
+ // Execute top-level scripts first (before DOM parsing triggers components)
85
+ tempDiv.querySelectorAll(":scope > script").forEach((script) => {
86
+ const content = script.textContent;
87
+ if (content.trim()) {
88
+ try {
89
+ new Function(content)();
90
+ } catch (e) {
91
+ console.error(`Fez.index.apply("${name}") script error:`, e.message);
92
+ }
93
+ }
94
+ script.remove();
95
+ });
96
+
97
+ target.innerHTML = tempDiv.innerHTML;
98
+ return true;
99
+ },
100
+
101
+ /**
102
+ * Get all registered component names
103
+ * @returns {string[]}
104
+ */
105
+ names() {
106
+ return Object.keys(this).filter(
107
+ (k) =>
108
+ typeof this[k] === "object" && this[k] !== null && "class" in this[k],
109
+ );
110
+ },
111
+
112
+ /**
113
+ * Get names of components that have demos
114
+ * @returns {string[]}
115
+ */
116
+ withDemo() {
117
+ return this.names().filter((name) => this[name].demo);
118
+ },
119
+
120
+ /**
121
+ * Get all components as object with DOM nodes
122
+ * @returns {Object} Object with component names as keys
123
+ */
124
+ all() {
125
+ const result = {};
126
+ for (const name of this.names()) {
127
+ result[name] = this.get(name);
128
+ }
129
+ return result;
130
+ },
131
+
132
+ /**
133
+ * Print registered components to console
134
+ */
135
+ info() {
136
+ console.log("Fez components:", this.names());
137
+ },
138
+ };
139
+
140
+ export default index;
@@ -0,0 +1,44 @@
1
+ /**
2
+ * localStorage wrapper with automatic JSON serialization
3
+ * Preserves types: integers, floats, strings, objects, arrays, booleans, null
4
+ *
5
+ * @example
6
+ * localStorage.set('count', 42)
7
+ * localStorage.get('count') // 42 (number, not string)
8
+ *
9
+ * localStorage.set('user', { name: 'John', age: 30 })
10
+ * localStorage.get('user') // { name: 'John', age: 30 }
11
+ *
12
+ * localStorage.get('missing', 'default') // 'default'
13
+ */
14
+
15
+ const storage = () => globalThis.localStorage || window.localStorage
16
+
17
+ function set(key, value) {
18
+ try {
19
+ storage().setItem(key, JSON.stringify(value))
20
+ } catch (e) {
21
+ console.error(`Fez localStorage: Failed to set "${key}"`, e)
22
+ }
23
+ }
24
+
25
+ function get(key, defaultValue = null) {
26
+ try {
27
+ const item = storage().getItem(key)
28
+ if (item === null) return defaultValue
29
+ return JSON.parse(item)
30
+ } catch (e) {
31
+ console.error(`Fez localStorage: Failed to get "${key}"`, e)
32
+ return defaultValue
33
+ }
34
+ }
35
+
36
+ function remove(key) {
37
+ storage().removeItem(key)
38
+ }
39
+
40
+ function clear() {
41
+ storage().clear()
42
+ }
43
+
44
+ export default { set, get, remove, clear }
package/src/fez/lib/n.js CHANGED
@@ -7,59 +7,74 @@
7
7
  // Licence MIT
8
8
 
9
9
  export default function n(name, attrs = {}, data) {
10
- if (typeof attrs === 'string') {
11
- [attrs, data] = [data, attrs]
12
- attrs ||= {}
10
+ if (typeof attrs === "string") {
11
+ [attrs, data] = [data, attrs];
12
+ attrs ||= {};
13
13
  }
14
14
 
15
15
  if (attrs instanceof Node) {
16
- data = attrs
17
- attrs = {}
16
+ data = attrs;
17
+ attrs = {};
18
18
  }
19
19
 
20
20
  if (Array.isArray(name)) {
21
- data = name
22
- name = 'div'
21
+ data = name;
22
+ name = "div";
23
23
  }
24
24
 
25
- if (typeof attrs !== 'object' || Array.isArray(attrs)) {
26
- data = attrs
27
- attrs = {}
25
+ if (typeof attrs !== "object" || Array.isArray(attrs)) {
26
+ data = attrs;
27
+ attrs = {};
28
28
  }
29
29
 
30
- if (name.includes('.')) {
31
- const parts = name.split('.')
32
- name = parts.shift() || 'div'
33
- const c = parts.join(' ');
30
+ if (name.includes(".")) {
31
+ const parts = name.split(".");
32
+ name = parts.shift() || "div";
33
+ const c = parts.join(" ");
34
34
  if (attrs.class) {
35
35
  attrs.class += ` ${c}`;
36
36
  } else {
37
- attrs.class = c
37
+ attrs.class = c;
38
38
  }
39
39
  }
40
40
 
41
41
  const node = document.createElement(name);
42
42
 
43
+ const booleanAttrs = [
44
+ "checked",
45
+ "disabled",
46
+ "selected",
47
+ "readonly",
48
+ "required",
49
+ "hidden",
50
+ "multiple",
51
+ "autofocus",
52
+ ];
53
+
43
54
  for (const [k, v] of Object.entries(attrs)) {
44
- if (typeof v === 'function') {
45
- node[k] = v.bind(this)
55
+ if (typeof v === "function") {
56
+ node[k] = v.bind(this);
57
+ } else if (booleanAttrs.includes(k)) {
58
+ if (v) {
59
+ node.setAttribute(k, k);
60
+ }
46
61
  } else {
47
- const value = String(v).replaceAll('fez.', this.fezHtmlRoot);
48
- node.setAttribute(k, value)
62
+ const value = String(v).replaceAll("fez.", this.fezHtmlRoot);
63
+ node.setAttribute(k, value);
49
64
  }
50
65
  }
51
66
 
52
67
  if (data) {
53
68
  if (Array.isArray(data)) {
54
69
  for (const n of data) {
55
- node.appendChild(n)
70
+ node.appendChild(n);
56
71
  }
57
72
  } else if (data instanceof Node) {
58
- node.appendChild(data)
73
+ node.appendChild(data);
59
74
  } else {
60
- node.innerHTML = String(data)
75
+ node.innerHTML = String(data);
61
76
  }
62
77
  }
63
78
 
64
- return node
79
+ return node;
65
80
  }
@@ -0,0 +1,208 @@
1
+ /**
2
+ * Fez Pub/Sub System
3
+ *
4
+ * Global API:
5
+ * Fez.subscribe('event', callback) // Always fires
6
+ * Fez.subscribe('#selector', 'event', callback) // Fires if selector found at publish time
7
+ * Fez.subscribe(node, 'event', callback) // Fires if node.isConnected
8
+ * Fez.publish('event', ...args) // Broadcast to all
9
+ *
10
+ * Instance API (see instance.js):
11
+ * this.subscribe('event', callback) // Auto-cleanup on destroy
12
+ * this.publish('event', ...args) // Bubble to parent components
13
+ */
14
+
15
+ // =============================================================================
16
+ // STORAGE
17
+ // =============================================================================
18
+
19
+ // Global subscriptions: channel -> Set of { selector, node, callback }
20
+ const globalSubs = new Map()
21
+
22
+ // Component subscriptions: channel -> [[component, callback], ...]
23
+ // Used for parent-child bubbling (this.publish)
24
+ const componentSubs = {}
25
+
26
+ // =============================================================================
27
+ // GLOBAL PUB/SUB
28
+ // =============================================================================
29
+
30
+ /**
31
+ * Subscribe to a channel (global)
32
+ *
33
+ * @param {string|Node} nodeOrSelector - Selector, node, or channel name
34
+ * @param {string|Function} channelOrCallback - Channel name or callback
35
+ * @param {Function} [callback] - Callback function
36
+ * @returns {Function} Unsubscribe function
37
+ *
38
+ * @example
39
+ * subscribe('user-login', (user) => console.log(user))
40
+ * subscribe('#header', 'theme-change', (theme) => ...)
41
+ * subscribe(document.body, 'resize', () => ...)
42
+ */
43
+ function subscribe(nodeOrSelector, channelOrCallback, callback) {
44
+ let selector = null
45
+ let node = null
46
+ let channel
47
+
48
+ // Normalize arguments
49
+ if (typeof channelOrCallback === 'function') {
50
+ // subscribe('event', callback)
51
+ channel = nodeOrSelector
52
+ callback = channelOrCallback
53
+ } else {
54
+ // subscribe(node/selector, 'event', callback)
55
+ channel = channelOrCallback
56
+ if (typeof nodeOrSelector === 'string') {
57
+ selector = nodeOrSelector // Store selector, resolve at publish time
58
+ } else {
59
+ node = nodeOrSelector // Store node reference
60
+ }
61
+ }
62
+
63
+ if (!globalSubs.has(channel)) {
64
+ globalSubs.set(channel, new Set())
65
+ }
66
+
67
+ const channelSubs = globalSubs.get(channel)
68
+
69
+ // Remove duplicate (same selector/node + callback)
70
+ for (const sub of channelSubs) {
71
+ if (sub.callback === callback && sub.selector === selector && sub.node === node) {
72
+ channelSubs.delete(sub)
73
+ }
74
+ }
75
+
76
+ const subscription = { selector, node, callback }
77
+ channelSubs.add(subscription)
78
+
79
+ // Return unsubscribe function
80
+ return () => channelSubs.delete(subscription)
81
+ }
82
+
83
+ /**
84
+ * Publish to a channel (global broadcast)
85
+ *
86
+ * @param {string} channel - Event name
87
+ * @param {...any} args - Arguments to pass to callbacks
88
+ */
89
+ function publish(channel, ...args) {
90
+ const channelSubs = globalSubs.get(channel)
91
+ if (channelSubs) {
92
+ for (const sub of channelSubs) {
93
+ let target = null
94
+
95
+ if (sub.selector) {
96
+ // Resolve selector at publish time
97
+ target = document.querySelector(sub.selector)
98
+ if (!target) continue // Skip if not found
99
+ } else if (sub.node) {
100
+ // Check node connection
101
+ if (!sub.node.isConnected) {
102
+ channelSubs.delete(sub) // Auto-cleanup disconnected
103
+ continue
104
+ }
105
+ target = sub.node
106
+ }
107
+
108
+ // Call with target as context (or null for global)
109
+ try {
110
+ sub.callback.call(target, ...args)
111
+ } catch (e) {
112
+ console.error(`Fez pubsub error on "${channel}":`, e)
113
+ }
114
+ }
115
+ }
116
+
117
+ // Also trigger component subscriptions (legacy compatibility)
118
+ if (componentSubs[channel]) {
119
+ componentSubs[channel].forEach(([comp, cb]) => {
120
+ if (comp.isConnected) {
121
+ cb.bind(comp)(...args)
122
+ }
123
+ })
124
+ }
125
+ }
126
+
127
+ // =============================================================================
128
+ // COMPONENT PUB/SUB (for this.subscribe / this.publish)
129
+ // =============================================================================
130
+
131
+ /**
132
+ * Subscribe from a component (used by this.subscribe)
133
+ * Stores subscription for parent-child bubbling
134
+ *
135
+ * @param {FezBase} component - Component instance
136
+ * @param {string} channel - Event name
137
+ * @param {Function} callback - Handler function
138
+ * @returns {Function} Unsubscribe function
139
+ */
140
+ function componentSubscribe(component, channel, callback) {
141
+ componentSubs[channel] ||= []
142
+
143
+ // Clean up disconnected components
144
+ componentSubs[channel] = componentSubs[channel].filter(([comp]) => comp.isConnected)
145
+
146
+ // Add subscription
147
+ componentSubs[channel].push([component, callback])
148
+
149
+ // Return unsubscribe function
150
+ return () => {
151
+ componentSubs[channel] = componentSubs[channel].filter(
152
+ ([comp, cb]) => !(comp === component && cb === callback)
153
+ )
154
+ }
155
+ }
156
+
157
+ /**
158
+ * Publish from a component (used by this.publish)
159
+ * Bubbles up through parent components
160
+ *
161
+ * @param {FezBase} component - Component instance
162
+ * @param {string} channel - Event name
163
+ * @param {...any} args - Arguments
164
+ * @returns {boolean} True if a parent handled the event
165
+ */
166
+ function componentPublish(component, channel, ...args) {
167
+ const handlePublish = (comp) => {
168
+ if (componentSubs[channel]) {
169
+ const sub = componentSubs[channel].find(([c]) => c === comp)
170
+ if (sub) {
171
+ sub[1].bind(comp)(...args)
172
+ return true
173
+ }
174
+ }
175
+ return false
176
+ }
177
+
178
+ // Check current component first
179
+ if (handlePublish(component)) {
180
+ return true
181
+ }
182
+
183
+ // Bubble up to parent components
184
+ let parent = component.root?.parentElement
185
+ while (parent) {
186
+ if (parent.fez) {
187
+ if (handlePublish(parent.fez)) {
188
+ return true
189
+ }
190
+ }
191
+ parent = parent.parentElement
192
+ }
193
+
194
+ return false
195
+ }
196
+
197
+ // =============================================================================
198
+ // EXPORTS
199
+ // =============================================================================
200
+
201
+ export {
202
+ subscribe,
203
+ publish,
204
+ componentSubscribe,
205
+ componentPublish,
206
+ globalSubs,
207
+ componentSubs
208
+ }