pulse-js-framework 1.7.10 → 1.7.12

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,374 @@
1
+ /**
2
+ * Pulse Context API - Dependency Injection and Prop Drilling Prevention
3
+ * @module pulse-js-framework/runtime/context
4
+ *
5
+ * Provides a React-like Context API for passing data through the component tree
6
+ * without explicit prop passing at every level.
7
+ *
8
+ * @example
9
+ * import { createContext, useContext, Provider } from './context.js';
10
+ * import { pulse } from './pulse.js';
11
+ *
12
+ * // Create a context with default value
13
+ * const ThemeContext = createContext('light');
14
+ *
15
+ * // Provide a value to the subtree
16
+ * const App = () => {
17
+ * const theme = pulse('dark');
18
+ * return Provider(ThemeContext, theme, () => [
19
+ * Header(),
20
+ * Content()
21
+ * ]);
22
+ * };
23
+ *
24
+ * // Consume the context anywhere in the subtree
25
+ * const Button = () => {
26
+ * const theme = useContext(ThemeContext);
27
+ * return el(`button.btn-${theme.get()}`);
28
+ * };
29
+ */
30
+
31
+ import { pulse, effect, computed, Pulse } from './pulse.js';
32
+
33
+ /**
34
+ * Check if a value is a Pulse instance
35
+ * @param {any} value - Value to check
36
+ * @returns {boolean} True if value is a Pulse
37
+ */
38
+ function isPulse(value) {
39
+ return value instanceof Pulse;
40
+ }
41
+ import { loggers } from './logger.js';
42
+ import { PulseError } from './errors.js';
43
+
44
+ const log = loggers.pulse;
45
+
46
+ // =============================================================================
47
+ // CONTEXT REGISTRY
48
+ // =============================================================================
49
+
50
+ /**
51
+ * Unique ID counter for contexts
52
+ * @type {number}
53
+ */
54
+ let contextIdCounter = 0;
55
+
56
+ /**
57
+ * Context stack for provider nesting
58
+ * Each context has its own stack of provided values
59
+ * @type {Map<symbol, Array<any>>}
60
+ */
61
+ const contextStacks = new Map();
62
+
63
+ /**
64
+ * Map of context IDs to their default values
65
+ * @type {Map<symbol, any>}
66
+ */
67
+ const contextDefaults = new Map();
68
+
69
+ /**
70
+ * Map of context IDs to their display names (for debugging)
71
+ * @type {Map<symbol, string>}
72
+ */
73
+ const contextNames = new Map();
74
+
75
+ // =============================================================================
76
+ // CONTEXT CREATION
77
+ // =============================================================================
78
+
79
+ /**
80
+ * @typedef {Object} Context
81
+ * @property {symbol} _id - Unique identifier for this context
82
+ * @property {string} displayName - Human-readable name for debugging
83
+ * @property {any} defaultValue - Default value when no provider is found
84
+ */
85
+
86
+ /**
87
+ * Create a new context with a default value
88
+ *
89
+ * @template T
90
+ * @param {T} defaultValue - Value used when no Provider is found in the tree
91
+ * @param {Object} [options={}] - Context options
92
+ * @param {string} [options.displayName] - Name for debugging purposes
93
+ * @returns {Context<T>} Context object to pass to Provider and useContext
94
+ *
95
+ * @example
96
+ * // Create a theme context
97
+ * const ThemeContext = createContext('light');
98
+ *
99
+ * // Create a user context with object default
100
+ * const UserContext = createContext({ name: 'Guest', role: 'anonymous' });
101
+ *
102
+ * // Create with display name for debugging
103
+ * const AuthContext = createContext(null, { displayName: 'AuthContext' });
104
+ */
105
+ export function createContext(defaultValue, options = {}) {
106
+ const id = Symbol(`pulse.context.${++contextIdCounter}`);
107
+ const displayName = options.displayName || `Context${contextIdCounter}`;
108
+
109
+ // Initialize the context stack
110
+ contextStacks.set(id, []);
111
+ contextDefaults.set(id, defaultValue);
112
+ contextNames.set(id, displayName);
113
+
114
+ const context = {
115
+ _id: id,
116
+ displayName,
117
+ defaultValue,
118
+
119
+ // Provider shorthand method
120
+ Provider: (value, children) => Provider(context, value, children),
121
+
122
+ // Consumer shorthand method
123
+ Consumer: (render) => Consumer(context, render)
124
+ };
125
+
126
+ Object.freeze(context);
127
+ return context;
128
+ }
129
+
130
+ // =============================================================================
131
+ // CONTEXT PROVIDER
132
+ // =============================================================================
133
+
134
+ /**
135
+ * Provide a value to a context for all descendants
136
+ *
137
+ * @template T
138
+ * @param {Context<T>} context - Context object from createContext
139
+ * @param {T|Pulse<T>} value - Value to provide (can be reactive pulse)
140
+ * @param {Function|Array} children - Child elements or render function
141
+ * @returns {Node|Array<Node>} The rendered children
142
+ *
143
+ * @example
144
+ * // Provide a static value
145
+ * Provider(ThemeContext, 'dark', () => [
146
+ * Header(),
147
+ * Content()
148
+ * ]);
149
+ *
150
+ * // Provide a reactive value
151
+ * const theme = pulse('dark');
152
+ * Provider(ThemeContext, theme, () => [
153
+ * Header(),
154
+ * Content()
155
+ * ]);
156
+ *
157
+ * // Using context.Provider shorthand
158
+ * ThemeContext.Provider('dark', () => App());
159
+ */
160
+ export function Provider(context, value, children) {
161
+ if (!context || !context._id) {
162
+ throw new PulseError('Provider requires a valid context created with createContext()', {
163
+ code: 'INVALID_CONTEXT'
164
+ });
165
+ }
166
+
167
+ const stack = contextStacks.get(context._id);
168
+ if (!stack) {
169
+ throw new PulseError(`Context "${context.displayName}" has been disposed`, {
170
+ code: 'CONTEXT_DISPOSED'
171
+ });
172
+ }
173
+
174
+ // Wrap non-pulse values in a pulse for consistent reactive API
175
+ const reactiveValue = isPulse(value) ? value : pulse(value);
176
+
177
+ // Push value onto context stack
178
+ stack.push(reactiveValue);
179
+ log.debug(`Provider: pushed value to ${context.displayName}, depth=${stack.length}`);
180
+
181
+ let result;
182
+ try {
183
+ // Render children with the new context value available
184
+ result = typeof children === 'function' ? children() : children;
185
+ } finally {
186
+ // Pop value from stack when provider scope ends
187
+ stack.pop();
188
+ log.debug(`Provider: popped value from ${context.displayName}, depth=${stack.length}`);
189
+ }
190
+
191
+ return result;
192
+ }
193
+
194
+ // =============================================================================
195
+ // CONTEXT CONSUMER
196
+ // =============================================================================
197
+
198
+ /**
199
+ * Get the current value from a context
200
+ *
201
+ * Returns a reactive pulse that updates when the provided value changes.
202
+ * If no Provider is found, returns the context's default value.
203
+ *
204
+ * @template T
205
+ * @param {Context<T>} context - Context object from createContext
206
+ * @returns {Pulse<T>} Reactive pulse containing the context value
207
+ *
208
+ * @example
209
+ * const theme = useContext(ThemeContext);
210
+ * console.log(theme.get()); // 'dark' (or default if no provider)
211
+ *
212
+ * // Reactive usage in effects
213
+ * effect(() => {
214
+ * document.body.className = theme.get();
215
+ * });
216
+ */
217
+ export function useContext(context) {
218
+ if (!context || !context._id) {
219
+ throw new PulseError('useContext requires a valid context created with createContext()', {
220
+ code: 'INVALID_CONTEXT'
221
+ });
222
+ }
223
+
224
+ const stack = contextStacks.get(context._id);
225
+ if (!stack) {
226
+ throw new PulseError(`Context "${context.displayName}" has been disposed`, {
227
+ code: 'CONTEXT_DISPOSED'
228
+ });
229
+ }
230
+
231
+ // Get the current provider value (top of stack) or default
232
+ if (stack.length > 0) {
233
+ const value = stack[stack.length - 1];
234
+ log.debug(`useContext: got value from ${context.displayName}, depth=${stack.length}`);
235
+ return value;
236
+ }
237
+
238
+ // No provider found, return default as reactive pulse
239
+ log.debug(`useContext: using default for ${context.displayName}`);
240
+ const defaultVal = contextDefaults.get(context._id);
241
+
242
+ // Wrap default in pulse for consistent API
243
+ return isPulse(defaultVal) ? defaultVal : pulse(defaultVal);
244
+ }
245
+
246
+ /**
247
+ * Consumer component pattern for context consumption
248
+ *
249
+ * @template T
250
+ * @param {Context<T>} context - Context object from createContext
251
+ * @param {Function} render - Render function receiving the context value
252
+ * @returns {any} Result of the render function
253
+ *
254
+ * @example
255
+ * Consumer(ThemeContext, (theme) => {
256
+ * return el(`button.btn-${theme.get()}`, 'Click me');
257
+ * });
258
+ *
259
+ * // Using shorthand
260
+ * ThemeContext.Consumer((theme) => el('span', theme.get()));
261
+ */
262
+ export function Consumer(context, render) {
263
+ const value = useContext(context);
264
+ return render(value);
265
+ }
266
+
267
+ // =============================================================================
268
+ // CONTEXT UTILITIES
269
+ // =============================================================================
270
+
271
+ /**
272
+ * Check if a value is a valid context object
273
+ *
274
+ * @param {any} value - Value to check
275
+ * @returns {boolean} True if value is a valid context
276
+ */
277
+ export function isContext(value) {
278
+ return value !== null &&
279
+ typeof value === 'object' &&
280
+ typeof value._id === 'symbol' &&
281
+ contextStacks.has(value._id);
282
+ }
283
+
284
+ /**
285
+ * Get the current provider depth for a context (useful for debugging)
286
+ *
287
+ * @param {Context} context - Context to check
288
+ * @returns {number} Current nesting depth of providers
289
+ */
290
+ export function getContextDepth(context) {
291
+ if (!context || !context._id) return 0;
292
+ const stack = contextStacks.get(context._id);
293
+ return stack ? stack.length : 0;
294
+ }
295
+
296
+ /**
297
+ * Dispose a context and clean up its resources
298
+ * Should be called when a context is no longer needed (e.g., in tests)
299
+ *
300
+ * @param {Context} context - Context to dispose
301
+ */
302
+ export function disposeContext(context) {
303
+ if (!context || !context._id) return;
304
+
305
+ contextStacks.delete(context._id);
306
+ contextDefaults.delete(context._id);
307
+ contextNames.delete(context._id);
308
+
309
+ log.debug(`Context disposed: ${context.displayName}`);
310
+ }
311
+
312
+ /**
313
+ * Create a derived context value from one or more contexts
314
+ *
315
+ * @template T
316
+ * @param {Function} selector - Function that receives context values and returns derived value
317
+ * @param {...Context} contexts - Contexts to derive from
318
+ * @returns {Pulse<T>} Computed pulse with derived value
319
+ *
320
+ * @example
321
+ * const theme = useContextSelector(
322
+ * (settings, user) => settings.get().theme || user.get().preferredTheme,
323
+ * SettingsContext,
324
+ * UserContext
325
+ * );
326
+ */
327
+ export function useContextSelector(selector, ...contexts) {
328
+ const values = contexts.map(ctx => useContext(ctx));
329
+
330
+ return computed(() => {
331
+ return selector(...values);
332
+ });
333
+ }
334
+
335
+ /**
336
+ * Provide multiple contexts at once
337
+ *
338
+ * @param {Array<[Context, any]>} providers - Array of [context, value] pairs
339
+ * @param {Function} children - Render function for children
340
+ * @returns {any} Rendered children
341
+ *
342
+ * @example
343
+ * provideMany([
344
+ * [ThemeContext, 'dark'],
345
+ * [UserContext, currentUser],
346
+ * [LocaleContext, 'fr']
347
+ * ], () => App());
348
+ */
349
+ export function provideMany(providers, children) {
350
+ if (providers.length === 0) {
351
+ return typeof children === 'function' ? children() : children;
352
+ }
353
+
354
+ const [first, ...rest] = providers;
355
+ const [context, value] = first;
356
+
357
+ return Provider(context, value, () => provideMany(rest, children));
358
+ }
359
+
360
+ // =============================================================================
361
+ // DEFAULT EXPORT
362
+ // =============================================================================
363
+
364
+ export default {
365
+ createContext,
366
+ useContext,
367
+ Provider,
368
+ Consumer,
369
+ isContext,
370
+ getContextDepth,
371
+ disposeContext,
372
+ useContextSelector,
373
+ provideMany
374
+ };
@@ -29,6 +29,19 @@ const BIND_URL_ATTRIBUTES = new Set([
29
29
  // REACTIVE BINDINGS
30
30
  // =============================================================================
31
31
 
32
+ /**
33
+ * Attributes that should be set as properties (not attributes) on form elements
34
+ * because the attribute doesn't reflect the current value after user input
35
+ * @private
36
+ */
37
+ const BIND_PROPERTY_ATTRIBUTES = new Set(['value', 'checked', 'selected']);
38
+
39
+ /**
40
+ * Tags where certain attributes should be set as properties
41
+ * @private
42
+ */
43
+ const FORM_ELEMENT_TAGS = new Set(['input', 'textarea', 'select', 'option']);
44
+
32
45
  /**
33
46
  * Bind an attribute reactively with XSS protection
34
47
  *
@@ -44,9 +57,22 @@ export function bind(element, attr, getValue) {
44
57
  const lowerAttr = attr.toLowerCase();
45
58
  const isUrlAttr = BIND_URL_ATTRIBUTES.has(lowerAttr);
46
59
 
60
+ // For form elements, certain attributes need to be set as properties
61
+ const tagName = dom.getTagName(element);
62
+ const useProperty = BIND_PROPERTY_ATTRIBUTES.has(lowerAttr) && FORM_ELEMENT_TAGS.has(tagName);
63
+
47
64
  if (typeof getValue === 'function') {
48
65
  effect(() => {
49
66
  const value = getValue();
67
+
68
+ // For form element properties (value, checked, selected), use setProperty
69
+ if (useProperty) {
70
+ if (dom.getProperty(element, attr) !== value) {
71
+ dom.setProperty(element, attr, value ?? '');
72
+ }
73
+ return;
74
+ }
75
+
50
76
  if (value == null || value === false) {
51
77
  dom.removeAttribute(element, attr);
52
78
  } else if (value === true) {
@@ -69,6 +95,12 @@ export function bind(element, attr, getValue) {
69
95
  }
70
96
  });
71
97
  } else {
98
+ // For form element properties, use setProperty
99
+ if (useProperty) {
100
+ dom.setProperty(element, attr, getValue ?? '');
101
+ return element;
102
+ }
103
+
72
104
  // Sanitize URL attributes for static values too
73
105
  if (isUrlAttr) {
74
106
  const sanitized = sanitizeUrl(String(getValue));