@jqhtml/core 2.3.64 → 2.3.67
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/component-name.d.ts +7 -0
- package/dist/component-name.d.ts.map +1 -1
- package/dist/component.d.ts +8 -0
- package/dist/component.d.ts.map +1 -1
- package/dist/debug-overlay/index.d.ts +35 -0
- package/dist/debug-overlay/index.d.ts.map +1 -0
- package/dist/index.cjs +2044 -1466
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +15 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2040 -1467
- package/dist/index.js.map +1 -1
- package/dist/instruction-processor.d.ts.map +1 -1
- package/dist/jqhtml-core.esm.js +2041 -1468
- package/dist/jqhtml-core.esm.js.map +1 -1
- package/dist/jquery-plugin.d.ts.map +1 -1
- package/dist/value-printers.d.ts +47 -0
- package/dist/value-printers.d.ts.map +1 -0
- package/package.json +2 -1
package/dist/index.cjs
CHANGED
|
@@ -217,6 +217,23 @@ const COMPONENT_NAME_RULE = 'must start with a capital letter, optionally preced
|
|
|
217
217
|
function is_component_name(name) {
|
|
218
218
|
return COMPONENT_NAME_PATTERN.test(name);
|
|
219
219
|
}
|
|
220
|
+
/**
|
|
221
|
+
* The render-time check behind <{expression}>: the expression must evaluate
|
|
222
|
+
* to a component name string that a literal tag would accept - no more, no
|
|
223
|
+
* less. A valid name that names nothing renders the ordinary placeholder
|
|
224
|
+
* component, exactly as an undefined literal tag does.
|
|
225
|
+
*/
|
|
226
|
+
function dynamic_component_name(value) {
|
|
227
|
+
if (typeof value !== 'string' || value === '') {
|
|
228
|
+
const shown = value === '' ? 'an empty string' : typeof value === 'string' ? `'${value}'` : `${value === null ? 'null' : typeof value}`;
|
|
229
|
+
throw new Error(`[JQHTML] Dynamic component tag <{...}> evaluated to ${shown}; expected a non-empty component name string`);
|
|
230
|
+
}
|
|
231
|
+
if (!is_component_name(value)) {
|
|
232
|
+
throw new Error(`[JQHTML] Dynamic component tag <{...}> evaluated to '${value}', which is not a valid component name: ` +
|
|
233
|
+
`a component name ${COMPONENT_NAME_RULE}, then letters, digits and underscores`);
|
|
234
|
+
}
|
|
235
|
+
return value;
|
|
236
|
+
}
|
|
220
237
|
|
|
221
238
|
/**
|
|
222
239
|
* JQHTML v2 Component Registry
|
|
@@ -1115,13 +1132,10 @@ async function initialize_component(element, compData) {
|
|
|
1115
1132
|
if (slots) {
|
|
1116
1133
|
options._slots = slots;
|
|
1117
1134
|
}
|
|
1118
|
-
// Always pass
|
|
1119
|
-
//
|
|
1120
|
-
//
|
|
1121
|
-
|
|
1122
|
-
if (ComponentClass.name !== name) {
|
|
1123
|
-
options._component_name = name;
|
|
1124
|
-
}
|
|
1135
|
+
// Always pass the invocation name: it is what component_name() reports, it
|
|
1136
|
+
// selects the template when the class is inherited via an extends chain or
|
|
1137
|
+
// is the bare Jqhtml_Component, and it is the class the element carries.
|
|
1138
|
+
options._component_name = name;
|
|
1125
1139
|
// Pass lifecycle truncation flags via options (not DOM attributes, since _ prefix is filtered).
|
|
1126
1140
|
// Accept both the plain key (cascaded/programmatic) and the '$'-prefixed key (template-authored).
|
|
1127
1141
|
if (props._load_only === true || props['$_load_only'] === true) {
|
|
@@ -1866,7 +1880,7 @@ Load_Coordinator._registry = new Map();
|
|
|
1866
1880
|
const class_registry = Object.create(null);
|
|
1867
1881
|
// Markers for serialized class instances (unique to avoid collisions with user data)
|
|
1868
1882
|
// Replaced at build time by rollup (see rollup.config.js).
|
|
1869
|
-
const CORE_VERSION = '2.3.
|
|
1883
|
+
const CORE_VERSION = '2.3.67';
|
|
1870
1884
|
const CLASS_MARKER = '__jqhtml_class__';
|
|
1871
1885
|
const PROPS_MARKER = '__jqhtml_props__';
|
|
1872
1886
|
/**
|
|
@@ -3114,870 +3128,1273 @@ function write_cache_on_loaded(component, data_changed) {
|
|
|
3114
3128
|
}
|
|
3115
3129
|
|
|
3116
3130
|
/**
|
|
3117
|
-
* JQHTML
|
|
3131
|
+
* JQHTML v2 Template Renderer
|
|
3118
3132
|
*
|
|
3119
|
-
*
|
|
3120
|
-
*
|
|
3133
|
+
* Connects compiled templates to components
|
|
3134
|
+
* Processes instruction arrays and handles bindings
|
|
3135
|
+
*/
|
|
3136
|
+
/**
|
|
3137
|
+
* Process slot-only template inheritance
|
|
3121
3138
|
*
|
|
3122
|
-
*
|
|
3123
|
-
*
|
|
3124
|
-
* - Same-type pending operations collapse (fan-in: all callers share one execution)
|
|
3125
|
-
* - Different-type pending operations: new replaces old (old callers get the new result)
|
|
3139
|
+
* When a component's template returns {_slots: {...}}, walk the prototype
|
|
3140
|
+
* chain to find a parent template and invoke it with the child's slots as content.
|
|
3126
3141
|
*
|
|
3127
|
-
*
|
|
3142
|
+
* @param component The component instance
|
|
3143
|
+
* @param childSlots The slots object from the child template
|
|
3144
|
+
* @returns [instructions, context] tuple or null if no parent found
|
|
3128
3145
|
*/
|
|
3129
|
-
|
|
3130
|
-
|
|
3131
|
-
|
|
3132
|
-
|
|
3133
|
-
|
|
3134
|
-
|
|
3135
|
-
|
|
3136
|
-
|
|
3137
|
-
|
|
3138
|
-
|
|
3139
|
-
|
|
3140
|
-
* - Nothing running → execute immediately
|
|
3141
|
-
* - Something running, no pending → create pending entry
|
|
3142
|
-
* - Something running, same type pending → collapse (share pending promise)
|
|
3143
|
-
* - Something running, different type pending → replace pending (all callers get new result)
|
|
3144
|
-
*
|
|
3145
|
-
* @param type - Operation type ('render', 'reload', 'refresh', 'load')
|
|
3146
|
-
* @param executor - The async function to execute
|
|
3147
|
-
* @returns Promise that resolves when the operation completes
|
|
3148
|
-
*/
|
|
3149
|
-
enqueue(type, executor) {
|
|
3150
|
-
// If nothing running, execute immediately
|
|
3151
|
-
if (!this._current) {
|
|
3152
|
-
return this._execute(type, executor);
|
|
3153
|
-
}
|
|
3154
|
-
// Something is running — add to pending
|
|
3155
|
-
if (this._pending) {
|
|
3156
|
-
if (this._pending.type === type) {
|
|
3157
|
-
// Same type pending → collapse (fan-in)
|
|
3158
|
-
// All callers share the same future execution
|
|
3159
|
-
return new Promise((resolve, reject) => {
|
|
3160
|
-
this._pending.resolvers.push(resolve);
|
|
3161
|
-
this._pending.rejecters.push(reject);
|
|
3162
|
-
});
|
|
3163
|
-
}
|
|
3164
|
-
else {
|
|
3165
|
-
// Different type pending → replace
|
|
3166
|
-
// Old pending callers join the new operation
|
|
3167
|
-
// (their operation was superseded by a different type)
|
|
3168
|
-
const old_resolvers = this._pending.resolvers;
|
|
3169
|
-
const old_rejecters = this._pending.rejecters;
|
|
3170
|
-
return new Promise((resolve, reject) => {
|
|
3171
|
-
this._pending = {
|
|
3172
|
-
type,
|
|
3173
|
-
executor,
|
|
3174
|
-
resolvers: [...old_resolvers, resolve],
|
|
3175
|
-
rejecters: [...old_rejecters, reject]
|
|
3176
|
-
};
|
|
3177
|
-
});
|
|
3178
|
-
}
|
|
3146
|
+
async function process_slot_inheritance(component, childSlots) {
|
|
3147
|
+
// Walk the prototype chain to find parent class with template
|
|
3148
|
+
let currentClass = Object.getPrototypeOf(component.constructor);
|
|
3149
|
+
console.log(`[JQHTML] Walking prototype chain for ${component.constructor.name}`);
|
|
3150
|
+
while (currentClass && currentClass !== Jqhtml_Component && currentClass.name !== 'Object') {
|
|
3151
|
+
const className = currentClass.name;
|
|
3152
|
+
console.log(`[JQHTML] Checking parent class: ${className}`);
|
|
3153
|
+
// Skip internal framework classes
|
|
3154
|
+
if (className === '_Jqhtml_Component' || className === '_Base_Jqhtml_Component') {
|
|
3155
|
+
currentClass = Object.getPrototypeOf(currentClass);
|
|
3156
|
+
continue;
|
|
3179
3157
|
}
|
|
3180
|
-
//
|
|
3181
|
-
|
|
3182
|
-
|
|
3183
|
-
|
|
3184
|
-
|
|
3185
|
-
|
|
3186
|
-
|
|
3187
|
-
|
|
3188
|
-
|
|
3189
|
-
|
|
3190
|
-
|
|
3191
|
-
|
|
3192
|
-
|
|
3193
|
-
|
|
3194
|
-
|
|
3195
|
-
const promise = (async () => {
|
|
3196
|
-
try {
|
|
3197
|
-
await executor();
|
|
3198
|
-
}
|
|
3199
|
-
finally {
|
|
3200
|
-
this._current = null;
|
|
3201
|
-
// Run pending if exists
|
|
3202
|
-
if (this._pending) {
|
|
3203
|
-
const pending = this._pending;
|
|
3204
|
-
this._pending = null;
|
|
3205
|
-
try {
|
|
3206
|
-
await this._execute(pending.type, pending.executor);
|
|
3207
|
-
for (const resolve of pending.resolvers)
|
|
3208
|
-
resolve();
|
|
3209
|
-
}
|
|
3210
|
-
catch (err) {
|
|
3211
|
-
for (const reject of pending.rejecters)
|
|
3212
|
-
reject(err);
|
|
3213
|
-
}
|
|
3158
|
+
// Try to find template for this parent class
|
|
3159
|
+
try {
|
|
3160
|
+
const parentTemplate = get_template(className);
|
|
3161
|
+
console.log(`[JQHTML] Template found for ${className}:`, parentTemplate ? parentTemplate.name : 'null');
|
|
3162
|
+
// Check if we got the default template (means no real template found)
|
|
3163
|
+
if (parentTemplate && parentTemplate.name !== 'Jqhtml_Component') {
|
|
3164
|
+
console.log(`[JQHTML] Invoking parent template ${className}`);
|
|
3165
|
+
// Found a real parent template - invoke it with child's slots as content
|
|
3166
|
+
const [parentInstructions, parentContext] = parentTemplate.render.call(component, component.data, component.args, childSlots // Pass child slots as content parameter
|
|
3167
|
+
);
|
|
3168
|
+
// Check if parent also returns slots (recursive inheritance)
|
|
3169
|
+
if (parentInstructions && typeof parentInstructions === 'object' && parentInstructions._slots) {
|
|
3170
|
+
// Parent also has slot-only template - recurse
|
|
3171
|
+
console.log(`[JQHTML] Parent also slot-only, recursing`);
|
|
3172
|
+
return await process_slot_inheritance(component, parentInstructions._slots);
|
|
3214
3173
|
}
|
|
3174
|
+
// Parent returned HTML - we're done
|
|
3175
|
+
console.log(`[JQHTML] Parent returned instructions, inheritance complete`);
|
|
3176
|
+
return [parentInstructions, parentContext];
|
|
3215
3177
|
}
|
|
3216
|
-
}
|
|
3217
|
-
|
|
3218
|
-
|
|
3219
|
-
|
|
3220
|
-
|
|
3221
|
-
|
|
3222
|
-
*/
|
|
3223
|
-
get is_busy() {
|
|
3224
|
-
return this._current !== null;
|
|
3225
|
-
}
|
|
3226
|
-
/**
|
|
3227
|
-
* Check if there's a pending operation waiting.
|
|
3228
|
-
*/
|
|
3229
|
-
get has_pending() {
|
|
3230
|
-
return this._pending !== null;
|
|
3178
|
+
}
|
|
3179
|
+
catch (error) {
|
|
3180
|
+
console.warn(`[JQHTML] Error looking up parent template for ${className}:`, error);
|
|
3181
|
+
}
|
|
3182
|
+
// Move to next parent
|
|
3183
|
+
currentClass = Object.getPrototypeOf(currentClass);
|
|
3231
3184
|
}
|
|
3232
|
-
|
|
3233
|
-
|
|
3234
|
-
|
|
3235
|
-
* JQHTML SSR Preload Data System
|
|
3236
|
-
*
|
|
3237
|
-
* Captures component data during SSR rendering and replays it on the client
|
|
3238
|
-
* to skip redundant on_load() calls during hydration.
|
|
3239
|
-
*
|
|
3240
|
-
* Server-side: start_data_capture() → render → get_captured_data()
|
|
3241
|
-
* Client-side: set_preload_data(entries) → components boot with preloaded data → clear_preload_data()
|
|
3242
|
-
*/
|
|
3243
|
-
// --- Capture (Server Side) ---
|
|
3244
|
-
let _capture_enabled = false;
|
|
3245
|
-
let _capture_buffer = [];
|
|
3246
|
-
// Track captured keys to avoid duplicates from Load Coordinator followers
|
|
3247
|
-
let _captured_keys = new Set();
|
|
3248
|
-
/**
|
|
3249
|
-
* Enable data capture mode. After this call, every component that completes
|
|
3250
|
-
* on_load() has its final this.data recorded.
|
|
3251
|
-
*
|
|
3252
|
-
* Idempotent — calling twice is fine.
|
|
3253
|
-
*/
|
|
3254
|
-
function start_data_capture() {
|
|
3255
|
-
_capture_enabled = true;
|
|
3256
|
-
_capture_buffer = [];
|
|
3257
|
-
_captured_keys.clear();
|
|
3185
|
+
// No parent template found
|
|
3186
|
+
console.warn(`[JQHTML] No parent template found after walking chain`);
|
|
3187
|
+
return null;
|
|
3258
3188
|
}
|
|
3259
3189
|
/**
|
|
3260
|
-
*
|
|
3261
|
-
*
|
|
3262
|
-
*
|
|
3263
|
-
* @param component_name - Component name
|
|
3264
|
-
* @param args - Component args at time of on_load()
|
|
3265
|
-
* @param data - this.data after on_load() completed
|
|
3266
|
-
* @param cache_key - The cache key for deduplication (null = always capture)
|
|
3190
|
+
* Render a template for a component
|
|
3191
|
+
* Templates are functions that return [instructions, context] tuples
|
|
3267
3192
|
*/
|
|
3268
|
-
function
|
|
3269
|
-
if
|
|
3270
|
-
|
|
3271
|
-
|
|
3272
|
-
|
|
3273
|
-
|
|
3274
|
-
return;
|
|
3275
|
-
_captured_keys.add(cache_key);
|
|
3193
|
+
async function render_template(component, template_fn) {
|
|
3194
|
+
// Get template from registry if not provided
|
|
3195
|
+
let render_fn = template_fn;
|
|
3196
|
+
if (!render_fn) {
|
|
3197
|
+
const template_def = get_template_by_class(component.constructor);
|
|
3198
|
+
render_fn = template_def.render;
|
|
3276
3199
|
}
|
|
3277
|
-
|
|
3278
|
-
|
|
3279
|
-
|
|
3280
|
-
const cloned_data = JSON.parse(JSON.stringify(data));
|
|
3281
|
-
_capture_buffer.push({
|
|
3282
|
-
component: component_name,
|
|
3283
|
-
args: cloned_args,
|
|
3284
|
-
data: cloned_data,
|
|
3285
|
-
});
|
|
3200
|
+
if (!render_fn) {
|
|
3201
|
+
// No template, component should handle its own rendering
|
|
3202
|
+
return;
|
|
3286
3203
|
}
|
|
3287
|
-
|
|
3288
|
-
|
|
3289
|
-
|
|
3290
|
-
|
|
3204
|
+
// Clear existing content
|
|
3205
|
+
component.$.empty();
|
|
3206
|
+
// Execute template function
|
|
3207
|
+
// Templates receive: (data, args, content)
|
|
3208
|
+
// Provide a default empty content function if none provided
|
|
3209
|
+
const defaultContent = () => '';
|
|
3210
|
+
let [instructions, context] = render_fn.call(component, component.data, component.args, defaultContent // Default content function that returns empty string
|
|
3211
|
+
);
|
|
3212
|
+
// Check for slot-only template inheritance
|
|
3213
|
+
// If instructions is {_slots: {...}}, this is a slot-only template
|
|
3214
|
+
// We need to find the parent template and invoke it with these slots
|
|
3215
|
+
if (instructions && typeof instructions === 'object' && instructions._slots) {
|
|
3216
|
+
console.log(`[JQHTML] Slot-only template detected for ${component.constructor.name}, invoking inheritance`);
|
|
3217
|
+
const result = await process_slot_inheritance(component, instructions._slots);
|
|
3218
|
+
if (result) {
|
|
3219
|
+
console.log(`[JQHTML] Parent template found, using parent instructions`);
|
|
3220
|
+
instructions = result[0];
|
|
3221
|
+
context = result[1];
|
|
3222
|
+
}
|
|
3223
|
+
else {
|
|
3224
|
+
console.warn(`[JQHTML] No parent template found for ${component.constructor.name}, rendering empty`);
|
|
3225
|
+
// No parent template found - return empty
|
|
3226
|
+
instructions = [];
|
|
3291
3227
|
}
|
|
3292
3228
|
}
|
|
3229
|
+
// Process instructions to generate DOM
|
|
3230
|
+
await process_instructions(instructions, component.$, component);
|
|
3231
|
+
// Process bindings after DOM is created
|
|
3232
|
+
await process_bindings(component);
|
|
3233
|
+
// Attach event handlers
|
|
3234
|
+
await attach_event_handlers(component);
|
|
3293
3235
|
}
|
|
3294
3236
|
/**
|
|
3295
|
-
*
|
|
3296
|
-
* One-shot: calling clears the buffer.
|
|
3297
|
-
*/
|
|
3298
|
-
function get_captured_data() {
|
|
3299
|
-
const result = _capture_buffer;
|
|
3300
|
-
_capture_buffer = [];
|
|
3301
|
-
_captured_keys.clear();
|
|
3302
|
-
return result;
|
|
3303
|
-
}
|
|
3304
|
-
/**
|
|
3305
|
-
* Check if data capture is currently enabled.
|
|
3237
|
+
* Process data bindings (attributes created by :prop syntax)
|
|
3306
3238
|
*/
|
|
3307
|
-
function
|
|
3308
|
-
|
|
3239
|
+
async function process_bindings(component) {
|
|
3240
|
+
// Find all elements with data-bind-* attributes
|
|
3241
|
+
component.$.find('[data-bind-prop], [data-bind-value], [data-bind-text], [data-bind-html], [data-bind-class], [data-bind-style]').each((_, element) => {
|
|
3242
|
+
const el = $(element);
|
|
3243
|
+
const attrs = element.attributes;
|
|
3244
|
+
for (let i = 0; i < attrs.length; i++) {
|
|
3245
|
+
const attr = attrs[i];
|
|
3246
|
+
if (attr.name.startsWith('data-bind-')) {
|
|
3247
|
+
const binding_type = attr.name.substring(10); // Remove 'data-bind-'
|
|
3248
|
+
const expression = attr.value;
|
|
3249
|
+
try {
|
|
3250
|
+
// Evaluate expression in component context
|
|
3251
|
+
const value = evaluate_expression(expression, component);
|
|
3252
|
+
// Apply binding based on type
|
|
3253
|
+
switch (binding_type) {
|
|
3254
|
+
case 'prop':
|
|
3255
|
+
// Generic property binding
|
|
3256
|
+
const prop_name = el.attr('data-bind-prop-name') || 'value';
|
|
3257
|
+
el.prop(prop_name, value);
|
|
3258
|
+
break;
|
|
3259
|
+
case 'value':
|
|
3260
|
+
el.val(value);
|
|
3261
|
+
break;
|
|
3262
|
+
case 'text':
|
|
3263
|
+
el.text(value);
|
|
3264
|
+
break;
|
|
3265
|
+
case 'html':
|
|
3266
|
+
el.html(value);
|
|
3267
|
+
break;
|
|
3268
|
+
case 'class':
|
|
3269
|
+
if (typeof value === 'object') {
|
|
3270
|
+
// Object syntax: {active: true, disabled: false}
|
|
3271
|
+
Object.entries(value).forEach(([className, enabled]) => {
|
|
3272
|
+
el.toggleClass(className, !!enabled);
|
|
3273
|
+
});
|
|
3274
|
+
}
|
|
3275
|
+
else {
|
|
3276
|
+
// String syntax
|
|
3277
|
+
el.addClass(String(value));
|
|
3278
|
+
}
|
|
3279
|
+
break;
|
|
3280
|
+
case 'style':
|
|
3281
|
+
if (typeof value === 'object') {
|
|
3282
|
+
el.css(value);
|
|
3283
|
+
}
|
|
3284
|
+
else {
|
|
3285
|
+
el.attr('style', String(value));
|
|
3286
|
+
}
|
|
3287
|
+
break;
|
|
3288
|
+
default:
|
|
3289
|
+
// Custom binding - set as attribute
|
|
3290
|
+
el.attr(binding_type, value);
|
|
3291
|
+
}
|
|
3292
|
+
}
|
|
3293
|
+
catch (error) {
|
|
3294
|
+
console.error(`Error evaluating binding "${expression}":`, error);
|
|
3295
|
+
}
|
|
3296
|
+
}
|
|
3297
|
+
}
|
|
3298
|
+
});
|
|
3309
3299
|
}
|
|
3310
3300
|
/**
|
|
3311
|
-
*
|
|
3301
|
+
* Attach event handlers (attributes created by @event syntax)
|
|
3312
3302
|
*/
|
|
3313
|
-
function
|
|
3314
|
-
|
|
3315
|
-
|
|
3316
|
-
|
|
3303
|
+
async function attach_event_handlers(component) {
|
|
3304
|
+
// Find all elements with data-on-* attributes
|
|
3305
|
+
component.$.find('[data-on-click], [data-on-change], [data-on-submit], [data-on-keyup], [data-on-keydown], [data-on-focus], [data-on-blur]').each((_, element) => {
|
|
3306
|
+
const el = $(element);
|
|
3307
|
+
const attrs = element.attributes;
|
|
3308
|
+
for (let i = 0; i < attrs.length; i++) {
|
|
3309
|
+
const attr = attrs[i];
|
|
3310
|
+
if (attr.name.startsWith('data-on-')) {
|
|
3311
|
+
const event_name = attr.name.substring(8); // Remove 'data-on-'
|
|
3312
|
+
const handler_expr = attr.value;
|
|
3313
|
+
// Remove the attribute to avoid re-processing
|
|
3314
|
+
el.removeAttr(attr.name);
|
|
3315
|
+
// Attach event handler
|
|
3316
|
+
el.on(event_name, function (event) {
|
|
3317
|
+
try {
|
|
3318
|
+
// Create handler function in component context
|
|
3319
|
+
const handler = evaluate_handler(handler_expr, component);
|
|
3320
|
+
if (typeof handler === 'function') {
|
|
3321
|
+
// Call with component as 'this' and pass event
|
|
3322
|
+
handler.call(component, event);
|
|
3323
|
+
}
|
|
3324
|
+
else {
|
|
3325
|
+
// Expression to execute
|
|
3326
|
+
evaluate_expression(handler_expr, component, { $event: event });
|
|
3327
|
+
}
|
|
3328
|
+
}
|
|
3329
|
+
catch (error) {
|
|
3330
|
+
console.error(`Error in ${event_name} handler "${handler_expr}":`, error);
|
|
3331
|
+
}
|
|
3332
|
+
});
|
|
3333
|
+
}
|
|
3334
|
+
}
|
|
3335
|
+
});
|
|
3317
3336
|
}
|
|
3318
|
-
// --- Preload (Client Side) ---
|
|
3319
|
-
let _preload_cache = new Map();
|
|
3320
3337
|
/**
|
|
3321
|
-
*
|
|
3322
|
-
* Must be called before any component _load() runs.
|
|
3323
|
-
*
|
|
3324
|
-
* @param entries - Array of { component, args, data } entries from get_captured_data()
|
|
3338
|
+
* Evaluate an expression in component context
|
|
3325
3339
|
*/
|
|
3326
|
-
function
|
|
3327
|
-
|
|
3328
|
-
|
|
3329
|
-
|
|
3330
|
-
|
|
3331
|
-
|
|
3332
|
-
|
|
3333
|
-
|
|
3334
|
-
|
|
3335
|
-
|
|
3340
|
+
function evaluate_expression(expression, component, locals = {}) {
|
|
3341
|
+
// Create evaluation context
|
|
3342
|
+
const context = {
|
|
3343
|
+
// Component properties
|
|
3344
|
+
data: component.data,
|
|
3345
|
+
args: component.args,
|
|
3346
|
+
$: component.$,
|
|
3347
|
+
// Component methods
|
|
3348
|
+
$sid: component.$sid.bind(component),
|
|
3349
|
+
// Locals (like $event)
|
|
3350
|
+
...locals
|
|
3351
|
+
};
|
|
3352
|
+
// Build function that evaluates in context
|
|
3353
|
+
const keys = Object.keys(context);
|
|
3354
|
+
const values = Object.values(context);
|
|
3355
|
+
try {
|
|
3356
|
+
// Use Function constructor for better performance than eval
|
|
3357
|
+
const fn = new Function(...keys, `return (${expression})`);
|
|
3358
|
+
return fn(...values);
|
|
3359
|
+
}
|
|
3360
|
+
catch (error) {
|
|
3361
|
+
console.error(`Invalid expression: ${expression}`, error);
|
|
3362
|
+
return undefined;
|
|
3336
3363
|
}
|
|
3337
3364
|
}
|
|
3338
3365
|
/**
|
|
3339
|
-
*
|
|
3340
|
-
* If found, returns the data and removes the entry (one-shot).
|
|
3341
|
-
*
|
|
3342
|
-
* @param cache_key - The cache key (same format as Load Coordinator)
|
|
3343
|
-
* @returns The preloaded data, or null if no match
|
|
3366
|
+
* Evaluate a handler expression (might be method name or inline code)
|
|
3344
3367
|
*/
|
|
3345
|
-
function
|
|
3346
|
-
if
|
|
3368
|
+
function evaluate_handler(expression, component) {
|
|
3369
|
+
// Check if it's a method name on the component
|
|
3370
|
+
if (expression in component && typeof component[expression] === 'function') {
|
|
3371
|
+
return component[expression];
|
|
3372
|
+
}
|
|
3373
|
+
// Otherwise treat as inline code
|
|
3374
|
+
try {
|
|
3375
|
+
return new Function('$event', `
|
|
3376
|
+
const { data, args, $, emit, $sid } = this;
|
|
3377
|
+
${expression}
|
|
3378
|
+
`).bind(component);
|
|
3379
|
+
}
|
|
3380
|
+
catch (error) {
|
|
3381
|
+
console.error(`Invalid handler: ${expression}`, error);
|
|
3347
3382
|
return null;
|
|
3348
|
-
const data = _preload_cache.get(cache_key);
|
|
3349
|
-
if (data !== undefined) {
|
|
3350
|
-
_preload_cache.delete(cache_key);
|
|
3351
|
-
return data;
|
|
3352
3383
|
}
|
|
3353
|
-
return null;
|
|
3354
3384
|
}
|
|
3355
3385
|
/**
|
|
3356
|
-
*
|
|
3357
|
-
* Call after initial page hydration is complete.
|
|
3386
|
+
* Helper to escape HTML for safe output
|
|
3358
3387
|
*/
|
|
3359
|
-
function
|
|
3360
|
-
|
|
3388
|
+
function escape_html(str) {
|
|
3389
|
+
const div = document.createElement('div');
|
|
3390
|
+
div.textContent = str;
|
|
3391
|
+
return div.innerHTML;
|
|
3361
3392
|
}
|
|
3362
3393
|
/**
|
|
3363
|
-
*
|
|
3394
|
+
* Helper to escape HTML and convert newlines to <br /> tags
|
|
3395
|
+
* Used by <%br= %> syntax for displaying user-entered text with line breaks
|
|
3364
3396
|
*/
|
|
3365
|
-
function
|
|
3366
|
-
|
|
3397
|
+
function escape_html_nl2br(str) {
|
|
3398
|
+
const div = document.createElement('div');
|
|
3399
|
+
div.textContent = str;
|
|
3400
|
+
// Escape HTML first, then replace newlines with <br />
|
|
3401
|
+
return div.innerHTML.replace(/\n/g, '<br />');
|
|
3367
3402
|
}
|
|
3368
3403
|
|
|
3369
3404
|
/**
|
|
3370
|
-
*
|
|
3405
|
+
* Value printers: how <%= %> renders an OBJECT.
|
|
3371
3406
|
*
|
|
3372
|
-
*
|
|
3373
|
-
*
|
|
3407
|
+
* Primitives never come here - the generated template code routes only
|
|
3408
|
+
* non-null, non-array objects to print_object(), so a hundred-row list of
|
|
3409
|
+
* strings costs exactly what it did before. An object reaching an
|
|
3410
|
+
* interpolation is walked through the printer chain in registration order;
|
|
3411
|
+
* the first printer to return anything but undefined handles it. A printer
|
|
3412
|
+
* returns a string (escaped or not according to the construct, exactly as a
|
|
3413
|
+
* string literal at that site) or a component descriptor:
|
|
3374
3414
|
*
|
|
3375
|
-
*
|
|
3376
|
-
* in on_stop() - a component that is stopped or removed from the document simply
|
|
3377
|
-
* stops being found by the dispatcher.
|
|
3378
|
-
*/
|
|
3379
|
-
/** Trailing-edge debounce applied to the window resize listener. */
|
|
3380
|
-
const VIEWPORT_RESIZE_DEBOUNCE_MS = 30;
|
|
3381
|
-
let debounce_timer = null;
|
|
3382
|
-
let listener_installed = false;
|
|
3383
|
-
/**
|
|
3384
|
-
* Current viewport width in CSS pixels.
|
|
3415
|
+
* { component: { name: 'Rich_Text_Display', args: { value }, attrs: { class: 'x' } } }
|
|
3385
3416
|
*
|
|
3386
|
-
*
|
|
3387
|
-
*
|
|
3417
|
+
* which jqhtml mounts as if the template had written the tag - live in the
|
|
3418
|
+
* browser, server-rendered under SSR - with no environment awareness on the
|
|
3419
|
+
* printer's side. A descriptor carries no content.
|
|
3388
3420
|
*
|
|
3389
|
-
*
|
|
3421
|
+
* Attribute position (<div title="<%= v %>">) compiles through a different
|
|
3422
|
+
* path and never consults printers.
|
|
3390
3423
|
*/
|
|
3391
|
-
|
|
3392
|
-
return typeof window !== 'undefined' ? window.innerWidth : 0;
|
|
3393
|
-
}
|
|
3424
|
+
const printers = [];
|
|
3394
3425
|
/**
|
|
3395
|
-
*
|
|
3396
|
-
*
|
|
3397
|
-
* Walks $('.Component') in document order (parents before children). Elements
|
|
3398
|
-
* with no component instance and stopped components are skipped. A component
|
|
3399
|
-
* that throws is logged and the walk continues - one bad handler must not stop
|
|
3400
|
-
* the rest of the page from resizing.
|
|
3426
|
+
* Register a printer at the end of the chain. Printers run in registration
|
|
3427
|
+
* order; return undefined to decline, a string, or a component descriptor.
|
|
3401
3428
|
*/
|
|
3402
|
-
function
|
|
3403
|
-
|
|
3404
|
-
|
|
3405
|
-
|
|
3406
|
-
|
|
3407
|
-
|
|
3408
|
-
|
|
3409
|
-
|
|
3410
|
-
|
|
3411
|
-
component.on_viewport_resize(viewport_width);
|
|
3412
|
-
}
|
|
3413
|
-
catch (error) {
|
|
3414
|
-
console.error(`[JQHTML] Error in on_viewport_resize() for ${component.component_name?.() || 'component'}:`, error);
|
|
3415
|
-
}
|
|
3416
|
-
});
|
|
3429
|
+
function add_object_printer(printer) {
|
|
3430
|
+
if (typeof printer !== 'function') {
|
|
3431
|
+
throw new Error('[JQHTML] add_object_printer() expects a function (value) => undefined | string | descriptor');
|
|
3432
|
+
}
|
|
3433
|
+
printers.push(printer);
|
|
3434
|
+
}
|
|
3435
|
+
/** The registered printers, in order. Read-only view for diagnostics. */
|
|
3436
|
+
function get_object_printers() {
|
|
3437
|
+
return printers;
|
|
3417
3438
|
}
|
|
3418
3439
|
/**
|
|
3419
|
-
*
|
|
3420
|
-
*
|
|
3440
|
+
* Render an object at an interpolation site. Called by compiled templates for
|
|
3441
|
+
* every non-null, non-array object reaching <%= %>, <%!= %> or <%br= %>.
|
|
3442
|
+
* Throws when nothing handles the value or a printer returns garbage.
|
|
3421
3443
|
*/
|
|
3422
|
-
function
|
|
3423
|
-
if (typeof
|
|
3424
|
-
|
|
3425
|
-
|
|
3426
|
-
|
|
3427
|
-
|
|
3428
|
-
|
|
3429
|
-
|
|
3430
|
-
|
|
3431
|
-
|
|
3432
|
-
|
|
3433
|
-
|
|
3434
|
-
}
|
|
3435
|
-
|
|
3444
|
+
function print_object(value, mode) {
|
|
3445
|
+
if (value === null || typeof value !== 'object') {
|
|
3446
|
+
throw new TypeError(`[JQHTML] print_object() is the object branch of interpolation; got ${typeof value}`);
|
|
3447
|
+
}
|
|
3448
|
+
for (let i = 0; i < printers.length; i++) {
|
|
3449
|
+
const result = printers[i](value);
|
|
3450
|
+
if (result === undefined)
|
|
3451
|
+
continue;
|
|
3452
|
+
if (typeof result === 'string')
|
|
3453
|
+
return print_string(result, mode);
|
|
3454
|
+
if (is_descriptor(result))
|
|
3455
|
+
return descriptor_to_instruction(result, i);
|
|
3456
|
+
throw new Error(`[JQHTML] Object printer #${i + 1} of ${printers.length} returned ${describe(result)}; ` +
|
|
3457
|
+
`a printer must return undefined (decline), a string, or a { component: { name, args, attrs } } descriptor`);
|
|
3458
|
+
}
|
|
3459
|
+
throw new Error(`[JQHTML] Cannot interpolate a ${constructor_name(value)} object: ` +
|
|
3460
|
+
(printers.length
|
|
3461
|
+
? `none of the ${printers.length} registered object printer(s) handled it. `
|
|
3462
|
+
: `no object printers are registered. `) +
|
|
3463
|
+
`Register one with jqhtml.add_object_printer(fn), or interpolate a primitive.`);
|
|
3464
|
+
}
|
|
3465
|
+
function print_string(text, mode) {
|
|
3466
|
+
if (mode === 'escape')
|
|
3467
|
+
return escape_html(text);
|
|
3468
|
+
if (mode === 'nl2br')
|
|
3469
|
+
return escape_html_nl2br(text);
|
|
3470
|
+
return text;
|
|
3471
|
+
}
|
|
3472
|
+
function is_descriptor(value) {
|
|
3473
|
+
return !!value && typeof value === 'object' && !Array.isArray(value) &&
|
|
3474
|
+
!!value.component && typeof value.component === 'object' && !Array.isArray(value.component);
|
|
3475
|
+
}
|
|
3476
|
+
function descriptor_to_instruction(descriptor, index) {
|
|
3477
|
+
const { name, args, attrs } = descriptor.component;
|
|
3478
|
+
const where = `Object printer #${index + 1}`;
|
|
3479
|
+
if (typeof name !== 'string' || !is_component_name(name)) {
|
|
3480
|
+
throw new Error(`[JQHTML] ${where} returned a descriptor whose component name ${describe(name)} is invalid: ` +
|
|
3481
|
+
`a component name ${COMPONENT_NAME_RULE}, then letters, digits and underscores`);
|
|
3482
|
+
}
|
|
3483
|
+
const props = {};
|
|
3484
|
+
if (attrs !== undefined) {
|
|
3485
|
+
if (!is_plain_object(attrs))
|
|
3486
|
+
throw new Error(`[JQHTML] ${where}: descriptor attrs must be a plain object`);
|
|
3487
|
+
for (const [key, value] of Object.entries(attrs)) {
|
|
3488
|
+
if (key.startsWith('$'))
|
|
3489
|
+
throw new Error(`[JQHTML] ${where}: descriptor attrs key '${key}' - component arguments go in args, without the $ sigil`);
|
|
3490
|
+
props[key] = value;
|
|
3491
|
+
}
|
|
3492
|
+
}
|
|
3493
|
+
if (args !== undefined) {
|
|
3494
|
+
if (!is_plain_object(args))
|
|
3495
|
+
throw new Error(`[JQHTML] ${where}: descriptor args must be a plain object`);
|
|
3496
|
+
for (const [key, value] of Object.entries(args)) {
|
|
3497
|
+
if (key.startsWith('$'))
|
|
3498
|
+
throw new Error(`[JQHTML] ${where}: descriptor args key '${key}' - the $ sigil is template syntax, not part of the argument name`);
|
|
3499
|
+
props[`$${key}`] = value;
|
|
3500
|
+
}
|
|
3501
|
+
}
|
|
3502
|
+
return { comp: [name, props] };
|
|
3503
|
+
}
|
|
3504
|
+
function is_plain_object(value) {
|
|
3505
|
+
return !!value && typeof value === 'object' && !Array.isArray(value);
|
|
3506
|
+
}
|
|
3507
|
+
function constructor_name(value) {
|
|
3508
|
+
const ctor = value.constructor;
|
|
3509
|
+
return ctor && ctor.name ? ctor.name : 'Object';
|
|
3510
|
+
}
|
|
3511
|
+
function describe(value) {
|
|
3512
|
+
if (value === null)
|
|
3513
|
+
return 'null';
|
|
3514
|
+
if (typeof value === 'string')
|
|
3515
|
+
return `'${value}'`;
|
|
3516
|
+
if (typeof value === 'object')
|
|
3517
|
+
return Array.isArray(value) ? 'an array' : `a ${constructor_name(value)} object without a valid component property`;
|
|
3518
|
+
return `${typeof value} ${String(value)}`;
|
|
3436
3519
|
}
|
|
3437
3520
|
|
|
3438
3521
|
/**
|
|
3439
|
-
* JQHTML
|
|
3522
|
+
* JQHTML Component Lifecycle Queue
|
|
3440
3523
|
*
|
|
3441
|
-
*
|
|
3442
|
-
*
|
|
3443
|
-
*
|
|
3444
|
-
* -
|
|
3445
|
-
* -
|
|
3524
|
+
* Serializes lifecycle operations (render, reload, refresh) per component.
|
|
3525
|
+
* Replaces _create_debounced_function with a proper queue that:
|
|
3526
|
+
*
|
|
3527
|
+
* - At most one operation runs at a time per component
|
|
3528
|
+
* - At most one operation is pending (waiting for the current to finish)
|
|
3529
|
+
* - Same-type pending operations collapse (fan-in: all callers share one execution)
|
|
3530
|
+
* - Different-type pending operations: new replaces old (old callers get the new result)
|
|
3531
|
+
*
|
|
3532
|
+
* Boot bypasses this queue entirely - it runs the lifecycle directly.
|
|
3446
3533
|
*/
|
|
3447
|
-
|
|
3448
|
-
|
|
3449
|
-
|
|
3450
|
-
|
|
3451
|
-
|
|
3452
|
-
this.
|
|
3453
|
-
|
|
3454
|
-
|
|
3455
|
-
|
|
3456
|
-
|
|
3457
|
-
|
|
3458
|
-
|
|
3459
|
-
|
|
3460
|
-
|
|
3461
|
-
|
|
3462
|
-
|
|
3463
|
-
|
|
3464
|
-
|
|
3465
|
-
|
|
3466
|
-
|
|
3467
|
-
|
|
3468
|
-
|
|
3469
|
-
this.
|
|
3470
|
-
|
|
3471
|
-
// Cache mode properties
|
|
3472
|
-
this._cache_key = null; // Cache key for caching
|
|
3473
|
-
// 'html' mode caching
|
|
3474
|
-
this._cached_html = null; // Cached HTML to inject on first render
|
|
3475
|
-
this._used_cached_html = false; // Flag if cached HTML was used (forces re-render after on_load)
|
|
3476
|
-
this._should_cache_html_after_ready = false; // Flag to cache HTML after on_ready lifecycle
|
|
3477
|
-
this._is_dynamic = false; // True if this.data changed during on_load() (used for HTML cache sync)
|
|
3478
|
-
// on_render synchronization (HTML cache mode)
|
|
3479
|
-
this._on_render_complete = false; // True after on_render() has been called post-on_load
|
|
3480
|
-
// use_cached_data feature - skip on_load() when cache hit occurs
|
|
3481
|
-
this._use_cached_data_hit = false; // True if use_cached_data=true AND cache was used
|
|
3482
|
-
// _load_only: create + load only. No render, no children, no on_render, no on_loaded, no on_ready.
|
|
3483
|
-
this._load_only = false;
|
|
3484
|
-
// _load_render_only: create + render + load + re-render. No on_render, no on_loaded, no on_ready.
|
|
3485
|
-
this._load_render_only = false;
|
|
3486
|
-
// Detached optimization: true when element is not in the DOM at boot time.
|
|
3487
|
-
// Skips initial render and cache read — just on_load then render.
|
|
3488
|
-
// Override with _force_initial_render to keep normal double-render behavior.
|
|
3489
|
-
this._is_detached = false;
|
|
3490
|
-
// _force_initial_render: override detached optimization — render even when not in DOM.
|
|
3491
|
-
// Use when you need the loading spinner visible immediately after appending.
|
|
3492
|
-
this._force_initial_render = false;
|
|
3493
|
-
// rendered event - fires once after the synchronous render chain completes
|
|
3494
|
-
// (after on_load's re-render if applicable, or after first render if no on_load)
|
|
3495
|
-
this._has_rendered = false;
|
|
3496
|
-
// Sequential queue for setting this.data after on_load completes
|
|
3497
|
-
// Multiple _load calls can run on_load in parallel, but setting this.data is serialized
|
|
3498
|
-
this._load_queue = Promise.resolve();
|
|
3499
|
-
// Cached flag: true if this component has a custom on_load() override
|
|
3500
|
-
// Set in constructor (prototype chain is established before constructor runs)
|
|
3501
|
-
// Used to skip expensive cache/load operations for static components
|
|
3502
|
-
this.__has_custom_on_load = false;
|
|
3503
|
-
// gate_load() — "load gates" registered during on_create() that must settle
|
|
3504
|
-
// before the component's FIRST on_load() runs. See gate_load() / _await_load_gates().
|
|
3505
|
-
this.__load_gates = []; // Pending gates (cleared once the first load begins)
|
|
3506
|
-
this.__first_load_started = false; // Latch: true once the first load phase begins (enforces one-shot + throw-after)
|
|
3507
|
-
this.__gate_resume = null; // Resolver to release an in-progress gate wait early (reload/refresh/stop)
|
|
3508
|
-
// Detect custom on_load() override immediately
|
|
3509
|
-
// Prototype chain is set up before constructor runs, so this.on_load
|
|
3510
|
-
// correctly resolves to child class override if one exists
|
|
3511
|
-
this.__has_custom_on_load = this.on_load !== Jqhtml_Component.prototype.on_load;
|
|
3512
|
-
this._cid = this._generate_cid();
|
|
3513
|
-
this._lifecycle_manager = LifecycleManager.get_instance();
|
|
3514
|
-
// Create or wrap element
|
|
3515
|
-
if (element) {
|
|
3516
|
-
this.$ = $(element);
|
|
3517
|
-
}
|
|
3518
|
-
else {
|
|
3519
|
-
// Use native createElement for better performance
|
|
3520
|
-
const div = document.createElement('div');
|
|
3521
|
-
this.$ = $(div);
|
|
3534
|
+
class Component_Queue {
|
|
3535
|
+
constructor() {
|
|
3536
|
+
/** Currently executing operation */
|
|
3537
|
+
this._current = null;
|
|
3538
|
+
/** Next operation waiting to execute after current completes */
|
|
3539
|
+
this._pending = null;
|
|
3540
|
+
}
|
|
3541
|
+
/**
|
|
3542
|
+
* Enqueue a lifecycle operation.
|
|
3543
|
+
*
|
|
3544
|
+
* Behavior:
|
|
3545
|
+
* - Nothing running → execute immediately
|
|
3546
|
+
* - Something running, no pending → create pending entry
|
|
3547
|
+
* - Something running, same type pending → collapse (share pending promise)
|
|
3548
|
+
* - Something running, different type pending → replace pending (all callers get new result)
|
|
3549
|
+
*
|
|
3550
|
+
* @param type - Operation type ('render', 'reload', 'refresh', 'load')
|
|
3551
|
+
* @param executor - The async function to execute
|
|
3552
|
+
* @returns Promise that resolves when the operation completes
|
|
3553
|
+
*/
|
|
3554
|
+
enqueue(type, executor) {
|
|
3555
|
+
// If nothing running, execute immediately
|
|
3556
|
+
if (!this._current) {
|
|
3557
|
+
return this._execute(type, executor);
|
|
3522
3558
|
}
|
|
3523
|
-
//
|
|
3524
|
-
|
|
3525
|
-
|
|
3526
|
-
|
|
3527
|
-
|
|
3528
|
-
|
|
3529
|
-
|
|
3530
|
-
|
|
3531
|
-
|
|
3532
|
-
|
|
3533
|
-
|
|
3534
|
-
|
|
3535
|
-
|
|
3559
|
+
// Something is running — add to pending
|
|
3560
|
+
if (this._pending) {
|
|
3561
|
+
if (this._pending.type === type) {
|
|
3562
|
+
// Same type pending → collapse (fan-in)
|
|
3563
|
+
// All callers share the same future execution
|
|
3564
|
+
return new Promise((resolve, reject) => {
|
|
3565
|
+
this._pending.resolvers.push(resolve);
|
|
3566
|
+
this._pending.rejecters.push(reject);
|
|
3567
|
+
});
|
|
3568
|
+
}
|
|
3569
|
+
else {
|
|
3570
|
+
// Different type pending → replace
|
|
3571
|
+
// Old pending callers join the new operation
|
|
3572
|
+
// (their operation was superseded by a different type)
|
|
3573
|
+
const old_resolvers = this._pending.resolvers;
|
|
3574
|
+
const old_rejecters = this._pending.rejecters;
|
|
3575
|
+
return new Promise((resolve, reject) => {
|
|
3576
|
+
this._pending = {
|
|
3577
|
+
type,
|
|
3578
|
+
executor,
|
|
3579
|
+
resolvers: [...old_resolvers, resolve],
|
|
3580
|
+
rejecters: [...old_rejecters, reject]
|
|
3581
|
+
};
|
|
3582
|
+
});
|
|
3536
3583
|
}
|
|
3537
3584
|
}
|
|
3538
|
-
//
|
|
3539
|
-
|
|
3540
|
-
|
|
3541
|
-
|
|
3542
|
-
|
|
3543
|
-
|
|
3544
|
-
|
|
3545
|
-
|
|
3546
|
-
|
|
3547
|
-
const defineArgs = template_for_args?.defineArgs || {};
|
|
3548
|
-
this.args = { ...defineArgs, ...dataAttrs, ...args };
|
|
3549
|
-
// Set lifecycle truncation flags from args
|
|
3550
|
-
if (this.args._load_only === true) {
|
|
3551
|
-
this._load_only = true;
|
|
3552
|
-
}
|
|
3553
|
-
if (this.args._load_render_only === true) {
|
|
3554
|
-
this._load_render_only = true;
|
|
3555
|
-
}
|
|
3556
|
-
if (this.args._force_initial_render === true) {
|
|
3557
|
-
this._force_initial_render = true;
|
|
3558
|
-
}
|
|
3559
|
-
// Attach component to element
|
|
3560
|
-
this.$.data('_component', this);
|
|
3561
|
-
// Apply CSS classes and attributes
|
|
3562
|
-
this._apply_css_classes();
|
|
3563
|
-
this._apply_default_attributes(); // Apply defaultAttributes from template
|
|
3564
|
-
this._set_attributes();
|
|
3565
|
-
// Find DOM parent component (for lifecycle coordination)
|
|
3566
|
-
this._find_dom_parent();
|
|
3567
|
-
// Setup data property with freeze enforcement using Proxy
|
|
3568
|
-
// @see data-proxy.ts for full implementation
|
|
3569
|
-
setup_data_property(this);
|
|
3570
|
-
// Initialize this.state as an empty object (convention for arbitrary component state)
|
|
3571
|
-
// No special meaning to framework - mutable anywhere, not cached, not frozen
|
|
3572
|
-
this.state = {};
|
|
3573
|
-
this._log_lifecycle('construct', 'complete');
|
|
3585
|
+
// No pending entry yet — create one
|
|
3586
|
+
return new Promise((resolve, reject) => {
|
|
3587
|
+
this._pending = {
|
|
3588
|
+
type,
|
|
3589
|
+
executor,
|
|
3590
|
+
resolvers: [resolve],
|
|
3591
|
+
rejecters: [reject]
|
|
3592
|
+
};
|
|
3593
|
+
});
|
|
3574
3594
|
}
|
|
3575
3595
|
/**
|
|
3576
|
-
*
|
|
3577
|
-
* Stores original implementations in WeakMap, replaces with guarded wrappers
|
|
3596
|
+
* Execute an operation and handle pending queue after completion.
|
|
3578
3597
|
* @private
|
|
3579
3598
|
*/
|
|
3580
|
-
|
|
3581
|
-
const
|
|
3582
|
-
|
|
3583
|
-
|
|
3584
|
-
|
|
3585
|
-
|
|
3586
|
-
|
|
3587
|
-
|
|
3588
|
-
|
|
3589
|
-
|
|
3590
|
-
|
|
3591
|
-
|
|
3592
|
-
|
|
3593
|
-
|
|
3594
|
-
|
|
3595
|
-
|
|
3596
|
-
|
|
3597
|
-
|
|
3598
|
-
|
|
3599
|
-
if (!self.__lifecycle_authorized) {
|
|
3600
|
-
throw new Error(`[JQHTML] ${name}() cannot be called manually. ${help}\n` +
|
|
3601
|
-
`Component: ${self.component_name()} (_cid: ${self._cid})`);
|
|
3599
|
+
_execute(type, executor) {
|
|
3600
|
+
const promise = (async () => {
|
|
3601
|
+
try {
|
|
3602
|
+
await executor();
|
|
3603
|
+
}
|
|
3604
|
+
finally {
|
|
3605
|
+
this._current = null;
|
|
3606
|
+
// Run pending if exists
|
|
3607
|
+
if (this._pending) {
|
|
3608
|
+
const pending = this._pending;
|
|
3609
|
+
this._pending = null;
|
|
3610
|
+
try {
|
|
3611
|
+
await this._execute(pending.type, pending.executor);
|
|
3612
|
+
for (const resolve of pending.resolvers)
|
|
3613
|
+
resolve();
|
|
3614
|
+
}
|
|
3615
|
+
catch (err) {
|
|
3616
|
+
for (const reject of pending.rejecters)
|
|
3617
|
+
reject(err);
|
|
3602
3618
|
}
|
|
3603
|
-
return lifecycle_impls.get(self)[name].apply(self, args);
|
|
3604
3619
|
}
|
|
3605
|
-
}
|
|
3606
|
-
}
|
|
3607
|
-
|
|
3620
|
+
}
|
|
3621
|
+
})();
|
|
3622
|
+
this._current = { type, promise };
|
|
3623
|
+
return promise;
|
|
3608
3624
|
}
|
|
3609
3625
|
/**
|
|
3610
|
-
*
|
|
3611
|
-
* Framework calls this to invoke lifecycle methods, bypassing the protection wrapper.
|
|
3612
|
-
* The flag is set momentarily for the wrapper check, then reset BEFORE user code runs.
|
|
3613
|
-
* This ensures any nested lifecycle calls from user code will fail the flag check.
|
|
3614
|
-
* @private
|
|
3626
|
+
* Check if any operation is currently running.
|
|
3615
3627
|
*/
|
|
3616
|
-
|
|
3617
|
-
|
|
3618
|
-
const impl = lifecycle_impls.get(this)?.[name] || this[name];
|
|
3619
|
-
// Call original directly - no flag needed since we bypass the wrapper
|
|
3620
|
-
return await impl.call(context || this);
|
|
3628
|
+
get is_busy() {
|
|
3629
|
+
return this._current !== null;
|
|
3621
3630
|
}
|
|
3622
3631
|
/**
|
|
3623
|
-
*
|
|
3624
|
-
* @private
|
|
3632
|
+
* Check if there's a pending operation waiting.
|
|
3625
3633
|
*/
|
|
3626
|
-
|
|
3627
|
-
|
|
3628
|
-
const impl = lifecycle_impls.get(this)?.[name] || this[name];
|
|
3629
|
-
// Call original directly - no flag needed since we bypass the wrapper
|
|
3630
|
-
return impl.call(this);
|
|
3634
|
+
get has_pending() {
|
|
3635
|
+
return this._pending !== null;
|
|
3631
3636
|
}
|
|
3632
|
-
|
|
3633
|
-
|
|
3634
|
-
|
|
3635
|
-
|
|
3636
|
-
|
|
3637
|
-
|
|
3638
|
-
|
|
3639
|
-
|
|
3640
|
-
|
|
3641
|
-
|
|
3637
|
+
}
|
|
3638
|
+
|
|
3639
|
+
/**
|
|
3640
|
+
* JQHTML SSR Preload Data System
|
|
3641
|
+
*
|
|
3642
|
+
* Captures component data during SSR rendering and replays it on the client
|
|
3643
|
+
* to skip redundant on_load() calls during hydration.
|
|
3644
|
+
*
|
|
3645
|
+
* Server-side: start_data_capture() → render → get_captured_data()
|
|
3646
|
+
* Client-side: set_preload_data(entries) → components boot with preloaded data → clear_preload_data()
|
|
3647
|
+
*/
|
|
3648
|
+
// --- Capture (Server Side) ---
|
|
3649
|
+
let _capture_enabled = false;
|
|
3650
|
+
let _capture_buffer = [];
|
|
3651
|
+
// Track captured keys to avoid duplicates from Load Coordinator followers
|
|
3652
|
+
let _captured_keys = new Set();
|
|
3653
|
+
/**
|
|
3654
|
+
* Enable data capture mode. After this call, every component that completes
|
|
3655
|
+
* on_load() has its final this.data recorded.
|
|
3656
|
+
*
|
|
3657
|
+
* Idempotent — calling twice is fine.
|
|
3658
|
+
*/
|
|
3659
|
+
function start_data_capture() {
|
|
3660
|
+
_capture_enabled = true;
|
|
3661
|
+
_capture_buffer = [];
|
|
3662
|
+
_captured_keys.clear();
|
|
3663
|
+
}
|
|
3664
|
+
/**
|
|
3665
|
+
* Record a component's data after on_load() completes.
|
|
3666
|
+
* Called internally from _apply_load_result().
|
|
3667
|
+
*
|
|
3668
|
+
* @param component_name - Component name
|
|
3669
|
+
* @param args - Component args at time of on_load()
|
|
3670
|
+
* @param data - this.data after on_load() completed
|
|
3671
|
+
* @param cache_key - The cache key for deduplication (null = always capture)
|
|
3672
|
+
*/
|
|
3673
|
+
function capture_component_data(component_name, args, data, cache_key) {
|
|
3674
|
+
if (!_capture_enabled)
|
|
3675
|
+
return;
|
|
3676
|
+
// Deduplicate: if Load Coordinator already captured this key, skip
|
|
3677
|
+
if (cache_key !== null) {
|
|
3678
|
+
if (_captured_keys.has(cache_key))
|
|
3679
|
+
return;
|
|
3680
|
+
_captured_keys.add(cache_key);
|
|
3681
|
+
}
|
|
3682
|
+
// Deep clone args and data to plain objects (no proxies, no class instances)
|
|
3683
|
+
try {
|
|
3684
|
+
const cloned_args = JSON.parse(JSON.stringify(args));
|
|
3685
|
+
const cloned_data = JSON.parse(JSON.stringify(data));
|
|
3686
|
+
_capture_buffer.push({
|
|
3687
|
+
component: component_name,
|
|
3688
|
+
args: cloned_args,
|
|
3689
|
+
data: cloned_data,
|
|
3690
|
+
});
|
|
3691
|
+
}
|
|
3692
|
+
catch (error) {
|
|
3693
|
+
// Non-serializable data — skip capture for this component
|
|
3694
|
+
if (typeof console !== 'undefined') {
|
|
3695
|
+
console.warn(`[JQHTML SSR Capture] Failed to serialize data for ${component_name}:`, error);
|
|
3642
3696
|
}
|
|
3643
|
-
|
|
3644
|
-
|
|
3697
|
+
}
|
|
3698
|
+
}
|
|
3699
|
+
/**
|
|
3700
|
+
* Returns all captured component data and resets the capture buffer.
|
|
3701
|
+
* One-shot: calling clears the buffer.
|
|
3702
|
+
*/
|
|
3703
|
+
function get_captured_data() {
|
|
3704
|
+
const result = _capture_buffer;
|
|
3705
|
+
_capture_buffer = [];
|
|
3706
|
+
_captured_keys.clear();
|
|
3707
|
+
return result;
|
|
3708
|
+
}
|
|
3709
|
+
/**
|
|
3710
|
+
* Check if data capture is currently enabled.
|
|
3711
|
+
*/
|
|
3712
|
+
function is_capture_enabled() {
|
|
3713
|
+
return _capture_enabled;
|
|
3714
|
+
}
|
|
3715
|
+
/**
|
|
3716
|
+
* Stop data capture and clear all state.
|
|
3717
|
+
*/
|
|
3718
|
+
function stop_data_capture() {
|
|
3719
|
+
_capture_enabled = false;
|
|
3720
|
+
_capture_buffer = [];
|
|
3721
|
+
_captured_keys.clear();
|
|
3722
|
+
}
|
|
3723
|
+
// --- Preload (Client Side) ---
|
|
3724
|
+
let _preload_cache = new Map();
|
|
3725
|
+
/**
|
|
3726
|
+
* Seed the preload cache with SSR-captured data.
|
|
3727
|
+
* Must be called before any component _load() runs.
|
|
3728
|
+
*
|
|
3729
|
+
* @param entries - Array of { component, args, data } entries from get_captured_data()
|
|
3730
|
+
*/
|
|
3731
|
+
function set_preload_data(entries) {
|
|
3732
|
+
if (!entries || entries.length === 0)
|
|
3733
|
+
return;
|
|
3734
|
+
_preload_cache.clear();
|
|
3735
|
+
for (const entry of entries) {
|
|
3736
|
+
// Generate key using the same algorithm as Load Coordinator
|
|
3737
|
+
const result = Load_Coordinator.generate_invocation_key(entry.component, entry.args);
|
|
3738
|
+
if (result.key !== null) {
|
|
3739
|
+
_preload_cache.set(result.key, entry.data);
|
|
3645
3740
|
}
|
|
3646
3741
|
}
|
|
3647
|
-
|
|
3648
|
-
|
|
3649
|
-
|
|
3650
|
-
|
|
3651
|
-
|
|
3652
|
-
|
|
3653
|
-
|
|
3654
|
-
|
|
3742
|
+
}
|
|
3743
|
+
/**
|
|
3744
|
+
* Check if preloaded data exists for a given cache key.
|
|
3745
|
+
* If found, returns the data and removes the entry (one-shot).
|
|
3746
|
+
*
|
|
3747
|
+
* @param cache_key - The cache key (same format as Load Coordinator)
|
|
3748
|
+
* @returns The preloaded data, or null if no match
|
|
3749
|
+
*/
|
|
3750
|
+
function consume_preload_data(cache_key) {
|
|
3751
|
+
if (_preload_cache.size === 0)
|
|
3752
|
+
return null;
|
|
3753
|
+
const data = _preload_cache.get(cache_key);
|
|
3754
|
+
if (data !== undefined) {
|
|
3755
|
+
_preload_cache.delete(cache_key);
|
|
3756
|
+
return data;
|
|
3655
3757
|
}
|
|
3656
|
-
|
|
3657
|
-
|
|
3658
|
-
|
|
3659
|
-
|
|
3660
|
-
|
|
3661
|
-
|
|
3662
|
-
|
|
3663
|
-
|
|
3664
|
-
|
|
3665
|
-
|
|
3666
|
-
|
|
3758
|
+
return null;
|
|
3759
|
+
}
|
|
3760
|
+
/**
|
|
3761
|
+
* Clear any remaining unconsumed preload entries.
|
|
3762
|
+
* Call after initial page hydration is complete.
|
|
3763
|
+
*/
|
|
3764
|
+
function clear_preload_data() {
|
|
3765
|
+
_preload_cache.clear();
|
|
3766
|
+
}
|
|
3767
|
+
/**
|
|
3768
|
+
* Check if any preload data is available (for fast early exit in _load).
|
|
3769
|
+
*/
|
|
3770
|
+
function has_preload_data() {
|
|
3771
|
+
return _preload_cache.size > 0;
|
|
3772
|
+
}
|
|
3773
|
+
|
|
3774
|
+
/**
|
|
3775
|
+
* JQHTML Viewport Resize Dispatch
|
|
3776
|
+
*
|
|
3777
|
+
* One framework-owned, debounced window 'resize' listener fans out to every live
|
|
3778
|
+
* component in the document by calling on_viewport_resize(viewport_width).
|
|
3779
|
+
*
|
|
3780
|
+
* Components never bind their own window listener, so there is nothing to unbind
|
|
3781
|
+
* in on_stop() - a component that is stopped or removed from the document simply
|
|
3782
|
+
* stops being found by the dispatcher.
|
|
3783
|
+
*/
|
|
3784
|
+
/** Trailing-edge debounce applied to the window resize listener. */
|
|
3785
|
+
const VIEWPORT_RESIZE_DEBOUNCE_MS = 30;
|
|
3786
|
+
let debounce_timer = null;
|
|
3787
|
+
let listener_installed = false;
|
|
3788
|
+
/**
|
|
3789
|
+
* Current viewport width in CSS pixels.
|
|
3790
|
+
*
|
|
3791
|
+
* window.innerWidth is used (not $(window).width()) because it matches the width
|
|
3792
|
+
* CSS media queries resolve against. It includes the scrollbar gutter.
|
|
3793
|
+
*
|
|
3794
|
+
* Returns 0 outside a browser (SSR).
|
|
3795
|
+
*/
|
|
3796
|
+
function get_viewport_width() {
|
|
3797
|
+
return typeof window !== 'undefined' ? window.innerWidth : 0;
|
|
3798
|
+
}
|
|
3799
|
+
/**
|
|
3800
|
+
* Call on_viewport_resize(width) on every component currently in the document.
|
|
3801
|
+
*
|
|
3802
|
+
* Walks $('.Component') in document order (parents before children). Elements
|
|
3803
|
+
* with no component instance and stopped components are skipped. A component
|
|
3804
|
+
* that throws is logged and the walk continues - one bad handler must not stop
|
|
3805
|
+
* the rest of the page from resizing.
|
|
3806
|
+
*/
|
|
3807
|
+
function dispatch_viewport_resize(jQuery) {
|
|
3808
|
+
const viewport_width = get_viewport_width();
|
|
3809
|
+
jQuery('.Component').each(function () {
|
|
3810
|
+
const component = jQuery(this).data('_component');
|
|
3811
|
+
if (!component || component._stopped)
|
|
3667
3812
|
return;
|
|
3668
|
-
|
|
3669
|
-
|
|
3670
|
-
|
|
3671
|
-
|
|
3672
|
-
await this._lifecycle_manager.boot_component(this);
|
|
3673
|
-
}
|
|
3674
|
-
// -------------------------------------------------------------------------
|
|
3675
|
-
// Lifecycle Methods (called by LifecycleManager)
|
|
3676
|
-
// -------------------------------------------------------------------------
|
|
3677
|
-
/**
|
|
3678
|
-
* Internal render phase - Create DOM structure
|
|
3679
|
-
* Called top-down (parent before children) when part of lifecycle
|
|
3680
|
-
* This is an internal method - users should call render() instead
|
|
3681
|
-
*
|
|
3682
|
-
* @param id Optional scoped ID - if provided, delegates to child component's _render()
|
|
3683
|
-
* @returns The current _render_count after incrementing (used to detect stale renders)
|
|
3684
|
-
* @private
|
|
3685
|
-
*/
|
|
3686
|
-
_render(id = null, options = {}) {
|
|
3687
|
-
// Increment render count to track this specific render
|
|
3688
|
-
this._render_count++;
|
|
3689
|
-
const current_render_id = this._render_count;
|
|
3690
|
-
if (this._stopped)
|
|
3691
|
-
return current_render_id;
|
|
3692
|
-
// If id provided, delegate to child component
|
|
3693
|
-
if (id) {
|
|
3694
|
-
// First check if element with scoped ID exists
|
|
3695
|
-
const $element = this.$sid(id);
|
|
3696
|
-
if ($element.length === 0) {
|
|
3697
|
-
throw new Error(`[JQHTML] render("${id}") - no such id.\n` +
|
|
3698
|
-
`Component "${this.component_name()}" has no child element with $sid="${id}".`);
|
|
3699
|
-
}
|
|
3700
|
-
// Element exists, check if it's a component
|
|
3701
|
-
const child = $element.data('_component');
|
|
3702
|
-
if (!child) {
|
|
3703
|
-
throw new Error(`[JQHTML] render("${id}") - element is not a component or does not have $redrawable attribute set.\n` +
|
|
3704
|
-
`Element with $sid="${id}" exists but is not initialized as a component.\n` +
|
|
3705
|
-
`Add $redrawable attribute or make it a proper component.`);
|
|
3706
|
-
}
|
|
3707
|
-
return child._render(null, options);
|
|
3813
|
+
if (typeof component.on_viewport_resize !== 'function')
|
|
3814
|
+
return;
|
|
3815
|
+
try {
|
|
3816
|
+
component.on_viewport_resize(viewport_width);
|
|
3708
3817
|
}
|
|
3709
|
-
|
|
3710
|
-
|
|
3711
|
-
if (this._cached_html !== null) {
|
|
3712
|
-
if (window.jqhtml?.debug?.verbose) {
|
|
3713
|
-
console.log(`[Cache html] Component ${this._cid} (${this.component_name()}) injecting cached HTML`, { html_length: this._cached_html.length });
|
|
3714
|
-
}
|
|
3715
|
-
// Inject cached HTML directly
|
|
3716
|
-
this.$[0].innerHTML = this._cached_html;
|
|
3717
|
-
// Mark that we used cached HTML (forces re-render after on_load)
|
|
3718
|
-
this._used_cached_html = true;
|
|
3719
|
-
// Clear cached HTML so next render uses template
|
|
3720
|
-
this._cached_html = null;
|
|
3721
|
-
// Mark first render complete
|
|
3722
|
-
this._did_first_render = true;
|
|
3723
|
-
this._log_lifecycle('render', 'complete (cached HTML)');
|
|
3724
|
-
// Call on_render() after cache inject (unless suppressed by _load_render_only)
|
|
3725
|
-
if (!options.skip_on_render) {
|
|
3726
|
-
const cacheRenderResult = this._call_lifecycle_sync('on_render');
|
|
3727
|
-
if (cacheRenderResult && typeof cacheRenderResult.then === 'function') {
|
|
3728
|
-
console.warn(`[JQHTML] Component "${this.component_name()}" returned a Promise from on_render(). ` +
|
|
3729
|
-
`on_render() must be synchronous code. Remove 'async' from the function declaration.`);
|
|
3730
|
-
}
|
|
3731
|
-
this._fire_viewport_resize();
|
|
3732
|
-
}
|
|
3733
|
-
// Emit lifecycle event
|
|
3734
|
-
this.trigger('render');
|
|
3735
|
-
// Store args/data snapshots for later comparison
|
|
3736
|
-
try {
|
|
3737
|
-
this._args_on_last_render = JSON.parse(JSON.stringify(this.args));
|
|
3738
|
-
}
|
|
3739
|
-
catch (error) {
|
|
3740
|
-
this._args_on_last_render = null;
|
|
3741
|
-
}
|
|
3742
|
-
this._data_on_last_render = JSON.stringify(this.data);
|
|
3743
|
-
return current_render_id;
|
|
3818
|
+
catch (error) {
|
|
3819
|
+
console.error(`[JQHTML] Error in on_viewport_resize() for ${component.component_name?.() || 'component'}:`, error);
|
|
3744
3820
|
}
|
|
3745
|
-
|
|
3746
|
-
|
|
3747
|
-
|
|
3748
|
-
|
|
3749
|
-
|
|
3750
|
-
|
|
3821
|
+
});
|
|
3822
|
+
}
|
|
3823
|
+
/**
|
|
3824
|
+
* Install the single window resize listener. Idempotent - repeated init() calls
|
|
3825
|
+
* and non-browser environments are no-ops.
|
|
3826
|
+
*/
|
|
3827
|
+
function init_viewport_resize(jQuery) {
|
|
3828
|
+
if (typeof window === 'undefined')
|
|
3829
|
+
return;
|
|
3830
|
+
if (listener_installed)
|
|
3831
|
+
return;
|
|
3832
|
+
listener_installed = true;
|
|
3833
|
+
jQuery(window).on('resize.jqhtml_viewport', function () {
|
|
3834
|
+
if (debounce_timer !== null)
|
|
3835
|
+
clearTimeout(debounce_timer);
|
|
3836
|
+
debounce_timer = setTimeout(function () {
|
|
3837
|
+
debounce_timer = null;
|
|
3838
|
+
dispatch_viewport_resize(jQuery);
|
|
3839
|
+
}, VIEWPORT_RESIZE_DEBOUNCE_MS);
|
|
3840
|
+
});
|
|
3841
|
+
}
|
|
3842
|
+
|
|
3843
|
+
/**
|
|
3844
|
+
* JQHTML v2 Component Base Class
|
|
3845
|
+
*
|
|
3846
|
+
* Core component implementation following v2 specification:
|
|
3847
|
+
* - 5-stage lifecycle coordinated by LifecycleManager
|
|
3848
|
+
* - Direct jQuery manipulation (no virtual DOM)
|
|
3849
|
+
* - Scoped IDs using _cid pattern
|
|
3850
|
+
* - Event emission and CSS class hierarchy
|
|
3851
|
+
*/
|
|
3852
|
+
// WeakMap storage for protected lifecycle method implementations (Option 2)
|
|
3853
|
+
// See docs/internal/lifecycle-method-protection.md for design details
|
|
3854
|
+
const lifecycle_impls = new WeakMap();
|
|
3855
|
+
class Jqhtml_Component {
|
|
3856
|
+
constructor(element, args = {}) {
|
|
3857
|
+
this._ready_state = 0; // 0=created, 1=init, 2=loaded, 3=rendered, 4=ready
|
|
3858
|
+
this._instantiator = null; // Component that instantiated this one
|
|
3859
|
+
this._dom_parent = null; // Closest component in DOM tree (for lifecycle)
|
|
3860
|
+
this._dom_children = new Set(); // Components in DOM subtree (for lifecycle)
|
|
3861
|
+
this._use_dom_fallback = false; // If true, use find() fallback instead of _dom_children optimization
|
|
3862
|
+
this._stopped = false;
|
|
3863
|
+
this._booted = false; // Guard to prevent double boot() calls
|
|
3864
|
+
this._data_before_render = null; // Store data state before initial render
|
|
3865
|
+
this._lifecycle_callbacks = new Map();
|
|
3866
|
+
this._lifecycle_states = new Map(); // Track which lifecycle events have occurred and their data
|
|
3867
|
+
this._did_first_render = false; // Track if component has rendered at least once
|
|
3868
|
+
this._render_count = 0; // Incremented each time _render() is called, used to detect stale renders
|
|
3869
|
+
this._args_on_last_render = null; // Args snapshot from last render (for reload() comparison)
|
|
3870
|
+
this._data_on_last_render = null; // Data snapshot from last render (for refresh() comparison)
|
|
3871
|
+
this.__initial_data_snapshot = null; // Snapshot of this.data after on_create() for restoration before on_load()
|
|
3872
|
+
this.__data_frozen = false; // Track if this.data is currently frozen
|
|
3873
|
+
this._queue = new Component_Queue(); // Lifecycle operation queue (render/reload/refresh)
|
|
3874
|
+
this.next_reload_force_refresh = null; // State machine for reload()/refresh() debounce precedence
|
|
3875
|
+
this.__lifecycle_authorized = false; // Flag for lifecycle method protection
|
|
3876
|
+
// Cache mode properties
|
|
3877
|
+
this._cache_key = null; // Cache key for caching
|
|
3878
|
+
// 'html' mode caching
|
|
3879
|
+
this._cached_html = null; // Cached HTML to inject on first render
|
|
3880
|
+
this._used_cached_html = false; // Flag if cached HTML was used (forces re-render after on_load)
|
|
3881
|
+
this._should_cache_html_after_ready = false; // Flag to cache HTML after on_ready lifecycle
|
|
3882
|
+
this._is_dynamic = false; // True if this.data changed during on_load() (used for HTML cache sync)
|
|
3883
|
+
// on_render synchronization (HTML cache mode)
|
|
3884
|
+
this._on_render_complete = false; // True after on_render() has been called post-on_load
|
|
3885
|
+
// use_cached_data feature - skip on_load() when cache hit occurs
|
|
3886
|
+
this._use_cached_data_hit = false; // True if use_cached_data=true AND cache was used
|
|
3887
|
+
// _load_only: create + load only. No render, no children, no on_render, no on_loaded, no on_ready.
|
|
3888
|
+
this._load_only = false;
|
|
3889
|
+
// _load_render_only: create + render + load + re-render. No on_render, no on_loaded, no on_ready.
|
|
3890
|
+
this._load_render_only = false;
|
|
3891
|
+
// Detached optimization: true when element is not in the DOM at boot time.
|
|
3892
|
+
// Skips initial render and cache read — just on_load then render.
|
|
3893
|
+
// Override with _force_initial_render to keep normal double-render behavior.
|
|
3894
|
+
this._is_detached = false;
|
|
3895
|
+
// _force_initial_render: override detached optimization — render even when not in DOM.
|
|
3896
|
+
// Use when you need the loading spinner visible immediately after appending.
|
|
3897
|
+
this._force_initial_render = false;
|
|
3898
|
+
// rendered event - fires once after the synchronous render chain completes
|
|
3899
|
+
// (after on_load's re-render if applicable, or after first render if no on_load)
|
|
3900
|
+
this._has_rendered = false;
|
|
3901
|
+
// Sequential queue for setting this.data after on_load completes
|
|
3902
|
+
// Multiple _load calls can run on_load in parallel, but setting this.data is serialized
|
|
3903
|
+
this._load_queue = Promise.resolve();
|
|
3904
|
+
// Cached flag: true if this component has a custom on_load() override
|
|
3905
|
+
// Set in constructor (prototype chain is established before constructor runs)
|
|
3906
|
+
// Used to skip expensive cache/load operations for static components
|
|
3907
|
+
this.__has_custom_on_load = false;
|
|
3908
|
+
// gate_load() — "load gates" registered during on_create() that must settle
|
|
3909
|
+
// before the component's FIRST on_load() runs. See gate_load() / _await_load_gates().
|
|
3910
|
+
this.__load_gates = []; // Pending gates (cleared once the first load begins)
|
|
3911
|
+
this.__first_load_started = false; // Latch: true once the first load phase begins (enforces one-shot + throw-after)
|
|
3912
|
+
this.__gate_resume = null; // Resolver to release an in-progress gate wait early (reload/refresh/stop)
|
|
3913
|
+
// Detect custom on_load() override immediately
|
|
3914
|
+
// Prototype chain is set up before constructor runs, so this.on_load
|
|
3915
|
+
// correctly resolves to child class override if one exists
|
|
3916
|
+
this.__has_custom_on_load = this.on_load !== Jqhtml_Component.prototype.on_load;
|
|
3917
|
+
this._cid = this._generate_cid();
|
|
3918
|
+
this._lifecycle_manager = LifecycleManager.get_instance();
|
|
3919
|
+
// Create or wrap element
|
|
3920
|
+
if (element) {
|
|
3921
|
+
this.$ = $(element);
|
|
3751
3922
|
}
|
|
3752
3923
|
else {
|
|
3753
|
-
|
|
3924
|
+
// Use native createElement for better performance
|
|
3925
|
+
const div = document.createElement('div');
|
|
3926
|
+
this.$ = $(div);
|
|
3754
3927
|
}
|
|
3755
|
-
//
|
|
3756
|
-
|
|
3757
|
-
|
|
3758
|
-
|
|
3759
|
-
|
|
3760
|
-
|
|
3761
|
-
|
|
3928
|
+
// Extract $ attributes from element and merge with args
|
|
3929
|
+
// $ attributes are stored via jQuery .data() (not as data-* attributes in DOM)
|
|
3930
|
+
const dataAttrs = {};
|
|
3931
|
+
// Get all jQuery .data() values from the element
|
|
3932
|
+
if (this.$.length > 0) {
|
|
3933
|
+
// Get all data via jQuery .data() without arguments (returns all data as object)
|
|
3934
|
+
const allData = this.$.data() || {};
|
|
3935
|
+
for (const key in allData) {
|
|
3936
|
+
// Skip internal attributes
|
|
3937
|
+
if (key !== 'cid' && key !== 'tid' && key !== 'componentName' && key !== 'readyState' &&
|
|
3938
|
+
key !== '_lifecycleState' && !key.startsWith('_')) {
|
|
3939
|
+
dataAttrs[key] = allData[key];
|
|
3762
3940
|
}
|
|
3763
|
-
}
|
|
3764
|
-
|
|
3765
|
-
|
|
3941
|
+
}
|
|
3942
|
+
}
|
|
3943
|
+
// Get template to check for defineArgs
|
|
3944
|
+
let template_for_args;
|
|
3945
|
+
if (args._component_name) {
|
|
3946
|
+
template_for_args = get_template(args._component_name);
|
|
3766
3947
|
}
|
|
3767
3948
|
else {
|
|
3768
|
-
|
|
3949
|
+
template_for_args = get_template_by_class(this.constructor);
|
|
3769
3950
|
}
|
|
3770
|
-
//
|
|
3771
|
-
|
|
3772
|
-
|
|
3773
|
-
|
|
3774
|
-
|
|
3951
|
+
// Merge in order: defineArgs (defaults from Define tag) < dataAttrs < args (invocation overrides)
|
|
3952
|
+
const defineArgs = template_for_args?.defineArgs || {};
|
|
3953
|
+
this.args = { ...defineArgs, ...dataAttrs, ...args };
|
|
3954
|
+
// The name this instance was invoked as. Every invocation path passes it
|
|
3955
|
+
// in args._component_name (template tag, $(el).component('Name'), boot
|
|
3956
|
+
// placeholder); a class handed directly to $(el).component(Class) has
|
|
3957
|
+
// only its own name to offer.
|
|
3958
|
+
const ctor = this.constructor;
|
|
3959
|
+
this._component_name = (typeof args._component_name === 'string' && args._component_name)
|
|
3960
|
+
|| ctor.component_name
|
|
3961
|
+
|| ctor.name;
|
|
3962
|
+
// Set lifecycle truncation flags from args
|
|
3963
|
+
if (this.args._load_only === true) {
|
|
3964
|
+
this._load_only = true;
|
|
3775
3965
|
}
|
|
3776
|
-
|
|
3777
|
-
|
|
3778
|
-
// Get template and render it
|
|
3779
|
-
let template_def;
|
|
3780
|
-
// If we have a component name, try looking up by name first (for unregistered components)
|
|
3781
|
-
if (this.args._component_name) {
|
|
3782
|
-
template_def = get_template(this.args._component_name);
|
|
3966
|
+
if (this.args._load_render_only === true) {
|
|
3967
|
+
this._load_render_only = true;
|
|
3783
3968
|
}
|
|
3784
|
-
|
|
3785
|
-
|
|
3786
|
-
template_def = get_template_by_class(this.constructor);
|
|
3969
|
+
if (this.args._force_initial_render === true) {
|
|
3970
|
+
this._force_initial_render = true;
|
|
3787
3971
|
}
|
|
3788
|
-
|
|
3789
|
-
|
|
3790
|
-
|
|
3791
|
-
|
|
3792
|
-
|
|
3793
|
-
|
|
3794
|
-
|
|
3795
|
-
|
|
3796
|
-
|
|
3797
|
-
|
|
3798
|
-
|
|
3799
|
-
|
|
3800
|
-
|
|
3801
|
-
|
|
3802
|
-
|
|
3803
|
-
|
|
3804
|
-
|
|
3805
|
-
|
|
3806
|
-
|
|
3807
|
-
|
|
3808
|
-
|
|
3809
|
-
|
|
3810
|
-
|
|
3811
|
-
|
|
3812
|
-
|
|
3813
|
-
|
|
3814
|
-
|
|
3815
|
-
|
|
3816
|
-
|
|
3817
|
-
|
|
3818
|
-
|
|
3819
|
-
|
|
3820
|
-
|
|
3821
|
-
|
|
3822
|
-
|
|
3823
|
-
|
|
3824
|
-
|
|
3825
|
-
|
|
3826
|
-
|
|
3827
|
-
|
|
3828
|
-
|
|
3829
|
-
|
|
3830
|
-
|
|
3831
|
-
}
|
|
3832
|
-
};
|
|
3833
|
-
};
|
|
3834
|
-
const contentFunction = createContentFunction();
|
|
3835
|
-
let [instructions, context] = template_def.render.bind(this)(this.data, this.args, contentFunction, // Content function with slot support
|
|
3836
|
-
jqhtml // Utilities object
|
|
3837
|
-
);
|
|
3838
|
-
// Check for template inheritance (slot-only OR explicit extends)
|
|
3839
|
-
// If instructions is {_slots: {...}}, find parent template and invoke it
|
|
3840
|
-
if (instructions && typeof instructions === 'object' && instructions._slots && !Array.isArray(instructions)) {
|
|
3841
|
-
const componentName = template_def.name || this.args._component_name || this.constructor.name;
|
|
3842
|
-
console.log(`[JQHTML] Slot-only template detected for ${componentName}`);
|
|
3843
|
-
let parentTemplate = null;
|
|
3844
|
-
let parentTemplateName = null;
|
|
3845
|
-
// First check for explicit extends attribute in template metadata
|
|
3846
|
-
if (template_def.extends) {
|
|
3847
|
-
console.log(`[JQHTML] Using explicit extends: ${template_def.extends}`);
|
|
3848
|
-
parentTemplate = get_template(template_def.extends);
|
|
3849
|
-
parentTemplateName = template_def.extends;
|
|
3850
|
-
}
|
|
3851
|
-
// If no explicit extends, walk the prototype chain to find parent class with template
|
|
3852
|
-
if (!parentTemplate) {
|
|
3853
|
-
let currentClass = Object.getPrototypeOf(this.constructor);
|
|
3854
|
-
while (currentClass && currentClass.name !== 'Object' && currentClass.name !== 'Jqhtml_Component') {
|
|
3855
|
-
const className = currentClass.name;
|
|
3856
|
-
console.log(`[JQHTML] Checking parent: ${className}`);
|
|
3857
|
-
try {
|
|
3858
|
-
const classTemplate = get_template(className);
|
|
3859
|
-
if (classTemplate && classTemplate.name !== 'Jqhtml_Component') {
|
|
3860
|
-
console.log(`[JQHTML] Found parent template: ${className}`);
|
|
3861
|
-
parentTemplate = classTemplate;
|
|
3862
|
-
parentTemplateName = className;
|
|
3863
|
-
break;
|
|
3864
|
-
}
|
|
3865
|
-
}
|
|
3866
|
-
catch (error) {
|
|
3867
|
-
console.warn(`[JQHTML] Error finding parent template ${className}:`, error);
|
|
3868
|
-
}
|
|
3869
|
-
currentClass = Object.getPrototypeOf(currentClass);
|
|
3870
|
-
}
|
|
3871
|
-
}
|
|
3872
|
-
// If we found a parent template, invoke it with child's slots
|
|
3873
|
-
if (parentTemplate) {
|
|
3874
|
-
try {
|
|
3875
|
-
// Create a content function that invokes child slots
|
|
3876
|
-
// When parent calls content('slotName'), it invokes the child's slot function
|
|
3877
|
-
const childSlots = instructions._slots;
|
|
3878
|
-
const contentFunction = (slotName, ...slotArgs) => {
|
|
3879
|
-
if (childSlots[slotName] && typeof childSlots[slotName] === 'function') {
|
|
3880
|
-
// Invoke the slot function with everything content() was given
|
|
3881
|
-
const [slotInstructions, slotContext] = childSlots[slotName](...slotArgs);
|
|
3882
|
-
// Return in render function format: [instructions, context]
|
|
3883
|
-
// The template expression handler expects this format
|
|
3884
|
-
return [slotInstructions, slotContext];
|
|
3885
|
-
}
|
|
3886
|
-
// Slot not found, return empty
|
|
3887
|
-
return '';
|
|
3888
|
-
};
|
|
3889
|
-
// Invoke parent template with child's slots as content function
|
|
3890
|
-
const [parentInstructions, parentContext] = parentTemplate.render.bind(this)(this.data, this.args, contentFunction, // Pass content function that invokes child slots
|
|
3891
|
-
jqhtml);
|
|
3892
|
-
console.log(`[JQHTML] Parent template invoked successfully`);
|
|
3893
|
-
instructions = parentInstructions;
|
|
3894
|
-
context = parentContext;
|
|
3895
|
-
}
|
|
3896
|
-
catch (error) {
|
|
3897
|
-
console.warn(`[JQHTML] Error invoking parent template ${parentTemplateName}:`, error);
|
|
3898
|
-
instructions = [];
|
|
3972
|
+
// Attach component to element
|
|
3973
|
+
this.$.data('_component', this);
|
|
3974
|
+
// Apply CSS classes and attributes
|
|
3975
|
+
this._apply_css_classes();
|
|
3976
|
+
this._apply_default_attributes(); // Apply defaultAttributes from template
|
|
3977
|
+
this._set_attributes();
|
|
3978
|
+
// Find DOM parent component (for lifecycle coordination)
|
|
3979
|
+
this._find_dom_parent();
|
|
3980
|
+
// Setup data property with freeze enforcement using Proxy
|
|
3981
|
+
// @see data-proxy.ts for full implementation
|
|
3982
|
+
setup_data_property(this);
|
|
3983
|
+
// Initialize this.state as an empty object (convention for arbitrary component state)
|
|
3984
|
+
// No special meaning to framework - mutable anywhere, not cached, not frozen
|
|
3985
|
+
this.state = {};
|
|
3986
|
+
this._log_lifecycle('construct', 'complete');
|
|
3987
|
+
}
|
|
3988
|
+
/**
|
|
3989
|
+
* Protect lifecycle methods from manual invocation
|
|
3990
|
+
* Stores original implementations in WeakMap, replaces with guarded wrappers
|
|
3991
|
+
* @private
|
|
3992
|
+
*/
|
|
3993
|
+
_protect_lifecycle_methods() {
|
|
3994
|
+
const methods = {
|
|
3995
|
+
on_create: 'Called automatically during creation.',
|
|
3996
|
+
on_render: 'Use render() to trigger a re-render.',
|
|
3997
|
+
on_load: 'Use reload() to refresh data.',
|
|
3998
|
+
on_ready: 'Called automatically when ready.',
|
|
3999
|
+
on_stop: 'Use stop() to stop the component.'
|
|
4000
|
+
};
|
|
4001
|
+
const impls = {};
|
|
4002
|
+
const self = this;
|
|
4003
|
+
for (const [name, help] of Object.entries(methods)) {
|
|
4004
|
+
const original = this[name];
|
|
4005
|
+
// Skip if using base class default (empty method)
|
|
4006
|
+
if (original === Jqhtml_Component.prototype[name])
|
|
4007
|
+
continue;
|
|
4008
|
+
impls[name] = original;
|
|
4009
|
+
// Create wrapper with same function name (for stack traces)
|
|
4010
|
+
this[name] = {
|
|
4011
|
+
[name](...args) {
|
|
4012
|
+
if (!self.__lifecycle_authorized) {
|
|
4013
|
+
throw new Error(`[JQHTML] ${name}() cannot be called manually. ${help}\n` +
|
|
4014
|
+
`Component: ${self.component_name()} (_cid: ${self._cid})`);
|
|
3899
4015
|
}
|
|
4016
|
+
return lifecycle_impls.get(self)[name].apply(self, args);
|
|
3900
4017
|
}
|
|
3901
|
-
|
|
3902
|
-
console.warn(`[JQHTML] No parent template found for ${this.constructor.name}, rendering empty`);
|
|
3903
|
-
instructions = [];
|
|
3904
|
-
}
|
|
3905
|
-
}
|
|
3906
|
-
// Process instructions to generate DOM. ['_content', ...] markers from
|
|
3907
|
-
// content() are handled by the processor, which renders them in the
|
|
3908
|
-
// component that wrote them.
|
|
3909
|
-
// This kicks off child component boots but doesn't wait for them
|
|
3910
|
-
process_instructions(instructions, this.$, this);
|
|
3911
|
-
}
|
|
3912
|
-
// Don't update ready state here - let phases complete in order
|
|
3913
|
-
this._update_debug_attrs();
|
|
3914
|
-
this._log_lifecycle('render', 'complete');
|
|
3915
|
-
// Call on_render() after render completes (unless suppressed by _load_render_only)
|
|
3916
|
-
if (!options.skip_on_render) {
|
|
3917
|
-
const renderResult = this._call_lifecycle_sync('on_render');
|
|
3918
|
-
if (renderResult && typeof renderResult.then === 'function') {
|
|
3919
|
-
console.warn(`[JQHTML] Component "${this.component_name()}" returned a Promise from on_render(). ` +
|
|
3920
|
-
`on_render() must be synchronous code. Remove 'async' from the function declaration.`);
|
|
3921
|
-
}
|
|
3922
|
-
this._fire_viewport_resize();
|
|
4018
|
+
}[name];
|
|
3923
4019
|
}
|
|
3924
|
-
|
|
3925
|
-
|
|
3926
|
-
|
|
3927
|
-
|
|
3928
|
-
|
|
3929
|
-
|
|
4020
|
+
lifecycle_impls.set(this, impls);
|
|
4021
|
+
}
|
|
4022
|
+
/**
|
|
4023
|
+
* Call a lifecycle method with authorization (async)
|
|
4024
|
+
* Framework calls this to invoke lifecycle methods, bypassing the protection wrapper.
|
|
4025
|
+
* The flag is set momentarily for the wrapper check, then reset BEFORE user code runs.
|
|
4026
|
+
* This ensures any nested lifecycle calls from user code will fail the flag check.
|
|
4027
|
+
* @private
|
|
4028
|
+
*/
|
|
4029
|
+
async _call_lifecycle(name, context) {
|
|
4030
|
+
// Get the original implementation (bypasses wrapper entirely)
|
|
4031
|
+
const impl = lifecycle_impls.get(this)?.[name] || this[name];
|
|
4032
|
+
// Call original directly - no flag needed since we bypass the wrapper
|
|
4033
|
+
return await impl.call(context || this);
|
|
4034
|
+
}
|
|
4035
|
+
/**
|
|
4036
|
+
* Call a lifecycle method with authorization (sync version for sync methods like on_stop)
|
|
4037
|
+
* @private
|
|
4038
|
+
*/
|
|
4039
|
+
_call_lifecycle_sync(name) {
|
|
4040
|
+
// Get the original implementation (bypasses wrapper entirely)
|
|
4041
|
+
const impl = lifecycle_impls.get(this)?.[name] || this[name];
|
|
4042
|
+
// Call original directly - no flag needed since we bypass the wrapper
|
|
4043
|
+
return impl.call(this);
|
|
4044
|
+
}
|
|
4045
|
+
/**
|
|
4046
|
+
* Fire on_viewport_resize() with the current viewport width.
|
|
4047
|
+
* Called after every on_render() and on_ready(); the debounced window
|
|
4048
|
+
* listener in viewport.ts drives it from then on. Errors are isolated so a
|
|
4049
|
+
* bad handler cannot break the lifecycle.
|
|
4050
|
+
* @private
|
|
4051
|
+
*/
|
|
4052
|
+
_fire_viewport_resize() {
|
|
3930
4053
|
try {
|
|
3931
|
-
this.
|
|
4054
|
+
this.on_viewport_resize(get_viewport_width());
|
|
3932
4055
|
}
|
|
3933
4056
|
catch (error) {
|
|
3934
|
-
|
|
3935
|
-
this._args_on_last_render = null;
|
|
3936
|
-
}
|
|
3937
|
-
// Store data snapshot for refresh() comparison
|
|
3938
|
-
this._data_on_last_render = JSON.stringify(this.data);
|
|
3939
|
-
// HTML CACHE MODE: Mark on_render complete after second render (post-on_load)
|
|
3940
|
-
// This signals to parent components that this component's DOM is fully rendered
|
|
3941
|
-
// with fresh data and ready for HTML snapshot
|
|
3942
|
-
if (this._ready_state >= 2) {
|
|
3943
|
-
this._on_render_complete = true;
|
|
4057
|
+
console.error(`[JQHTML] Error in on_viewport_resize() for ${this.component_name()}:`, error);
|
|
3944
4058
|
}
|
|
3945
|
-
// Return the render ID so callers can check if this render is still current
|
|
3946
|
-
return current_render_id;
|
|
3947
4059
|
}
|
|
3948
4060
|
/**
|
|
3949
|
-
*
|
|
3950
|
-
*
|
|
3951
|
-
*
|
|
3952
|
-
*
|
|
3953
|
-
|
|
3954
|
-
|
|
3955
|
-
|
|
3956
|
-
|
|
4061
|
+
* Check if on_load() is overridden in a subclass
|
|
4062
|
+
* Used to skip the load phase entirely for components that don't fetch data
|
|
4063
|
+
* Returns cached value set in _boot() for O(1) performance
|
|
4064
|
+
* @private
|
|
4065
|
+
*/
|
|
4066
|
+
_has_on_load() {
|
|
4067
|
+
return this.__has_custom_on_load;
|
|
4068
|
+
}
|
|
4069
|
+
/**
|
|
4070
|
+
* Boot - Start the full component lifecycle
|
|
4071
|
+
* Called immediately after construction by instruction processor
|
|
4072
|
+
*/
|
|
4073
|
+
/**
|
|
4074
|
+
* Internal boot method - starts component lifecycle
|
|
4075
|
+
* @private
|
|
4076
|
+
*/
|
|
4077
|
+
async _boot() {
|
|
4078
|
+
// Guard: prevent double boot() calls
|
|
4079
|
+
if (this._booted)
|
|
4080
|
+
return;
|
|
4081
|
+
this._booted = true;
|
|
4082
|
+
// Protect lifecycle methods from manual invocation (must happen after subclass constructor)
|
|
4083
|
+
// Note: __has_custom_on_load is already set in constructor
|
|
4084
|
+
this._protect_lifecycle_methods();
|
|
4085
|
+
await this._lifecycle_manager.boot_component(this);
|
|
4086
|
+
}
|
|
4087
|
+
// -------------------------------------------------------------------------
|
|
4088
|
+
// Lifecycle Methods (called by LifecycleManager)
|
|
4089
|
+
// -------------------------------------------------------------------------
|
|
4090
|
+
/**
|
|
4091
|
+
* Internal render phase - Create DOM structure
|
|
4092
|
+
* Called top-down (parent before children) when part of lifecycle
|
|
4093
|
+
* This is an internal method - users should call render() instead
|
|
3957
4094
|
*
|
|
3958
|
-
*
|
|
3959
|
-
*
|
|
4095
|
+
* @param id Optional scoped ID - if provided, delegates to child component's _render()
|
|
4096
|
+
* @returns The current _render_count after incrementing (used to detect stale renders)
|
|
4097
|
+
* @private
|
|
3960
4098
|
*/
|
|
3961
|
-
|
|
4099
|
+
_render(id = null, options = {}) {
|
|
4100
|
+
// Increment render count to track this specific render
|
|
4101
|
+
this._render_count++;
|
|
4102
|
+
const current_render_id = this._render_count;
|
|
3962
4103
|
if (this._stopped)
|
|
3963
|
-
return
|
|
3964
|
-
// Invalidate ready event so new handlers wait for this render cycle to complete
|
|
3965
|
-
// This prevents .on('ready') handlers from firing immediately based on previous lifecycle
|
|
3966
|
-
this.invalidate('ready');
|
|
4104
|
+
return current_render_id;
|
|
3967
4105
|
// If id provided, delegate to child component
|
|
3968
4106
|
if (id) {
|
|
4107
|
+
// First check if element with scoped ID exists
|
|
3969
4108
|
const $element = this.$sid(id);
|
|
3970
4109
|
if ($element.length === 0) {
|
|
3971
4110
|
throw new Error(`[JQHTML] render("${id}") - no such id.\n` +
|
|
3972
4111
|
`Component "${this.component_name()}" has no child element with $sid="${id}".`);
|
|
3973
4112
|
}
|
|
4113
|
+
// Element exists, check if it's a component
|
|
3974
4114
|
const child = $element.data('_component');
|
|
3975
4115
|
if (!child) {
|
|
3976
4116
|
throw new Error(`[JQHTML] render("${id}") - element is not a component or does not have $redrawable attribute set.\n` +
|
|
3977
4117
|
`Element with $sid="${id}" exists but is not initialized as a component.\n` +
|
|
3978
4118
|
`Add $redrawable attribute or make it a proper component.`);
|
|
3979
4119
|
}
|
|
3980
|
-
return child.
|
|
4120
|
+
return child._render(null, options);
|
|
4121
|
+
}
|
|
4122
|
+
this._log_lifecycle('render', 'start');
|
|
4123
|
+
// HTML CACHE MODE - If we have cached HTML, inject it directly and skip template rendering
|
|
4124
|
+
if (this._cached_html !== null) {
|
|
4125
|
+
if (window.jqhtml?.debug?.verbose) {
|
|
4126
|
+
console.log(`[Cache html] Component ${this._cid} (${this.component_name()}) injecting cached HTML`, { html_length: this._cached_html.length });
|
|
4127
|
+
}
|
|
4128
|
+
// Inject cached HTML directly
|
|
4129
|
+
this.$[0].innerHTML = this._cached_html;
|
|
4130
|
+
// Mark that we used cached HTML (forces re-render after on_load)
|
|
4131
|
+
this._used_cached_html = true;
|
|
4132
|
+
// Clear cached HTML so next render uses template
|
|
4133
|
+
this._cached_html = null;
|
|
4134
|
+
// Mark first render complete
|
|
4135
|
+
this._did_first_render = true;
|
|
4136
|
+
this._log_lifecycle('render', 'complete (cached HTML)');
|
|
4137
|
+
// Call on_render() after cache inject (unless suppressed by _load_render_only)
|
|
4138
|
+
if (!options.skip_on_render) {
|
|
4139
|
+
const cacheRenderResult = this._call_lifecycle_sync('on_render');
|
|
4140
|
+
if (cacheRenderResult && typeof cacheRenderResult.then === 'function') {
|
|
4141
|
+
console.warn(`[JQHTML] Component "${this.component_name()}" returned a Promise from on_render(). ` +
|
|
4142
|
+
`on_render() must be synchronous code. Remove 'async' from the function declaration.`);
|
|
4143
|
+
}
|
|
4144
|
+
this._fire_viewport_resize();
|
|
4145
|
+
}
|
|
4146
|
+
// Emit lifecycle event
|
|
4147
|
+
this.trigger('render');
|
|
4148
|
+
// Store args/data snapshots for later comparison
|
|
4149
|
+
try {
|
|
4150
|
+
this._args_on_last_render = JSON.parse(JSON.stringify(this.args));
|
|
4151
|
+
}
|
|
4152
|
+
catch (error) {
|
|
4153
|
+
this._args_on_last_render = null;
|
|
4154
|
+
}
|
|
4155
|
+
this._data_on_last_render = JSON.stringify(this.data);
|
|
4156
|
+
return current_render_id;
|
|
4157
|
+
}
|
|
4158
|
+
// Determine child-finding strategy: If component is off-DOM, children can't register
|
|
4159
|
+
// via _find_dom_parent() (no parent in DOM to find), so we'll need find() fallback later.
|
|
4160
|
+
// If attached to DOM, children register normally and we can use the fast _dom_children path.
|
|
4161
|
+
// Check is cheap ($.contains uses native Node.contains, ~500K ops/sec).
|
|
4162
|
+
if (!$.contains(document.documentElement, this.$[0])) {
|
|
4163
|
+
this._use_dom_fallback = true;
|
|
4164
|
+
}
|
|
4165
|
+
else {
|
|
4166
|
+
this._use_dom_fallback = false;
|
|
4167
|
+
}
|
|
4168
|
+
// If this is not the first render, stop child components and clear DOM
|
|
4169
|
+
if (this._did_first_render) {
|
|
4170
|
+
// Stop all child components before clearing DOM
|
|
4171
|
+
this.$.find('.Component').each(function () {
|
|
4172
|
+
const child = $(this).data('_component');
|
|
4173
|
+
if (child && !child._stopped) {
|
|
4174
|
+
child._stop(); // Stop just the component, DOM will be cleared next
|
|
4175
|
+
}
|
|
4176
|
+
});
|
|
4177
|
+
// Clear the DOM
|
|
4178
|
+
this.$[0].innerHTML = '';
|
|
4179
|
+
}
|
|
4180
|
+
else {
|
|
4181
|
+
this._did_first_render = true;
|
|
4182
|
+
}
|
|
4183
|
+
// Remove _Component_Stopped class if present (allows re-render after stop)
|
|
4184
|
+
this.$.removeClass('_Component_Stopped');
|
|
4185
|
+
// Capture data state before first render for comparison later
|
|
4186
|
+
if (this._data_before_render === null) {
|
|
4187
|
+
this._data_before_render = JSON.stringify(this.data);
|
|
4188
|
+
}
|
|
4189
|
+
// Clear DOM children tracking - they're destroyed by innerHTML = '' anyway
|
|
4190
|
+
this._dom_children.clear();
|
|
4191
|
+
// Get template and render it
|
|
4192
|
+
let template_def;
|
|
4193
|
+
// If we have a component name, try looking up by name first (for unregistered components)
|
|
4194
|
+
if (this.args._component_name) {
|
|
4195
|
+
template_def = get_template(this.args._component_name);
|
|
4196
|
+
}
|
|
4197
|
+
else {
|
|
4198
|
+
// Otherwise look up by class
|
|
4199
|
+
template_def = get_template_by_class(this.constructor);
|
|
4200
|
+
}
|
|
4201
|
+
if (template_def && template_def.render) {
|
|
4202
|
+
// Create jqhtml utilities object
|
|
4203
|
+
const jqhtml = {
|
|
4204
|
+
escape_html: (str) => {
|
|
4205
|
+
const div = document.createElement('div');
|
|
4206
|
+
div.textContent = String(str);
|
|
4207
|
+
return div.innerHTML;
|
|
4208
|
+
},
|
|
4209
|
+
escape_html_nl2br: (str) => {
|
|
4210
|
+
const div = document.createElement('div');
|
|
4211
|
+
div.textContent = String(str);
|
|
4212
|
+
// Escape HTML first, then replace newlines with <br />
|
|
4213
|
+
return div.innerHTML.replace(/\n/g, '<br />');
|
|
4214
|
+
},
|
|
4215
|
+
// Objects reaching <%= %> / <%!= %> / <%br= %> - see value-printers.ts
|
|
4216
|
+
print_object,
|
|
4217
|
+
// <{expression}> name validation - see component-name.ts
|
|
4218
|
+
dynamic_component_name
|
|
4219
|
+
};
|
|
4220
|
+
// Execute template function
|
|
4221
|
+
// Commented out debug logging
|
|
4222
|
+
// console.log('[Component] About to call render, this:', this);
|
|
4223
|
+
// console.log('[Component] this.constructor.name:', this.constructor.name);
|
|
4224
|
+
// console.log('[Component] this.data:', this.data);
|
|
4225
|
+
// console.log('[Component] this.args:', this.args);
|
|
4226
|
+
// Create content function that handles both default content and named slots
|
|
4227
|
+
const createContentFunction = () => {
|
|
4228
|
+
const defaultContentFn = this.args._innerhtml_function;
|
|
4229
|
+
const slotsObj = this.args._slots;
|
|
4230
|
+
// Return a function that handles both slot names and default content
|
|
4231
|
+
return (slotName, ...slotArgs) => {
|
|
4232
|
+
// If slot name provided and slots object exists, try to use named slot
|
|
4233
|
+
if (slotName && slotsObj && slotsObj[slotName]) {
|
|
4234
|
+
// Call the slot function - it returns [instructions, context]
|
|
4235
|
+
return slotsObj[slotName](...slotArgs);
|
|
4236
|
+
}
|
|
4237
|
+
// If slot name provided but not found, return empty
|
|
4238
|
+
else if (slotName) {
|
|
4239
|
+
return '';
|
|
4240
|
+
}
|
|
4241
|
+
// No slot name = default content
|
|
4242
|
+
else if (defaultContentFn) {
|
|
4243
|
+
return defaultContentFn(this);
|
|
4244
|
+
}
|
|
4245
|
+
// No content at all
|
|
4246
|
+
else {
|
|
4247
|
+
return '';
|
|
4248
|
+
}
|
|
4249
|
+
};
|
|
4250
|
+
};
|
|
4251
|
+
const contentFunction = createContentFunction();
|
|
4252
|
+
let [instructions, context] = template_def.render.bind(this)(this.data, this.args, contentFunction, // Content function with slot support
|
|
4253
|
+
jqhtml // Utilities object
|
|
4254
|
+
);
|
|
4255
|
+
// Check for template inheritance (slot-only OR explicit extends)
|
|
4256
|
+
// If instructions is {_slots: {...}}, find parent template and invoke it
|
|
4257
|
+
if (instructions && typeof instructions === 'object' && instructions._slots && !Array.isArray(instructions)) {
|
|
4258
|
+
const componentName = template_def.name || this.args._component_name || this.constructor.name;
|
|
4259
|
+
console.log(`[JQHTML] Slot-only template detected for ${componentName}`);
|
|
4260
|
+
let parentTemplate = null;
|
|
4261
|
+
let parentTemplateName = null;
|
|
4262
|
+
// First check for explicit extends attribute in template metadata
|
|
4263
|
+
if (template_def.extends) {
|
|
4264
|
+
console.log(`[JQHTML] Using explicit extends: ${template_def.extends}`);
|
|
4265
|
+
parentTemplate = get_template(template_def.extends);
|
|
4266
|
+
parentTemplateName = template_def.extends;
|
|
4267
|
+
}
|
|
4268
|
+
// If no explicit extends, walk the prototype chain to find parent class with template
|
|
4269
|
+
if (!parentTemplate) {
|
|
4270
|
+
let currentClass = Object.getPrototypeOf(this.constructor);
|
|
4271
|
+
while (currentClass && currentClass.name !== 'Object' && currentClass.name !== 'Jqhtml_Component') {
|
|
4272
|
+
const className = currentClass.name;
|
|
4273
|
+
console.log(`[JQHTML] Checking parent: ${className}`);
|
|
4274
|
+
try {
|
|
4275
|
+
const classTemplate = get_template(className);
|
|
4276
|
+
if (classTemplate && classTemplate.name !== 'Jqhtml_Component') {
|
|
4277
|
+
console.log(`[JQHTML] Found parent template: ${className}`);
|
|
4278
|
+
parentTemplate = classTemplate;
|
|
4279
|
+
parentTemplateName = className;
|
|
4280
|
+
break;
|
|
4281
|
+
}
|
|
4282
|
+
}
|
|
4283
|
+
catch (error) {
|
|
4284
|
+
console.warn(`[JQHTML] Error finding parent template ${className}:`, error);
|
|
4285
|
+
}
|
|
4286
|
+
currentClass = Object.getPrototypeOf(currentClass);
|
|
4287
|
+
}
|
|
4288
|
+
}
|
|
4289
|
+
// If we found a parent template, invoke it with child's slots
|
|
4290
|
+
if (parentTemplate) {
|
|
4291
|
+
try {
|
|
4292
|
+
// Create a content function that invokes child slots
|
|
4293
|
+
// When parent calls content('slotName'), it invokes the child's slot function
|
|
4294
|
+
const childSlots = instructions._slots;
|
|
4295
|
+
const contentFunction = (slotName, ...slotArgs) => {
|
|
4296
|
+
if (childSlots[slotName] && typeof childSlots[slotName] === 'function') {
|
|
4297
|
+
// Invoke the slot function with everything content() was given
|
|
4298
|
+
const [slotInstructions, slotContext] = childSlots[slotName](...slotArgs);
|
|
4299
|
+
// Return in render function format: [instructions, context]
|
|
4300
|
+
// The template expression handler expects this format
|
|
4301
|
+
return [slotInstructions, slotContext];
|
|
4302
|
+
}
|
|
4303
|
+
// Slot not found, return empty
|
|
4304
|
+
return '';
|
|
4305
|
+
};
|
|
4306
|
+
// Invoke parent template with child's slots as content function
|
|
4307
|
+
const [parentInstructions, parentContext] = parentTemplate.render.bind(this)(this.data, this.args, contentFunction, // Pass content function that invokes child slots
|
|
4308
|
+
jqhtml);
|
|
4309
|
+
console.log(`[JQHTML] Parent template invoked successfully`);
|
|
4310
|
+
instructions = parentInstructions;
|
|
4311
|
+
context = parentContext;
|
|
4312
|
+
}
|
|
4313
|
+
catch (error) {
|
|
4314
|
+
console.warn(`[JQHTML] Error invoking parent template ${parentTemplateName}:`, error);
|
|
4315
|
+
instructions = [];
|
|
4316
|
+
}
|
|
4317
|
+
}
|
|
4318
|
+
else {
|
|
4319
|
+
console.warn(`[JQHTML] No parent template found for ${this.constructor.name}, rendering empty`);
|
|
4320
|
+
instructions = [];
|
|
4321
|
+
}
|
|
4322
|
+
}
|
|
4323
|
+
// Process instructions to generate DOM. ['_content', ...] markers from
|
|
4324
|
+
// content() are handled by the processor, which renders them in the
|
|
4325
|
+
// component that wrote them.
|
|
4326
|
+
// This kicks off child component boots but doesn't wait for them
|
|
4327
|
+
process_instructions(instructions, this.$, this);
|
|
4328
|
+
}
|
|
4329
|
+
// Don't update ready state here - let phases complete in order
|
|
4330
|
+
this._update_debug_attrs();
|
|
4331
|
+
this._log_lifecycle('render', 'complete');
|
|
4332
|
+
// Call on_render() after render completes (unless suppressed by _load_render_only)
|
|
4333
|
+
if (!options.skip_on_render) {
|
|
4334
|
+
const renderResult = this._call_lifecycle_sync('on_render');
|
|
4335
|
+
if (renderResult && typeof renderResult.then === 'function') {
|
|
4336
|
+
console.warn(`[JQHTML] Component "${this.component_name()}" returned a Promise from on_render(). ` +
|
|
4337
|
+
`on_render() must be synchronous code. Remove 'async' from the function declaration.`);
|
|
4338
|
+
}
|
|
4339
|
+
this._fire_viewport_resize();
|
|
4340
|
+
}
|
|
4341
|
+
// Emit lifecycle event
|
|
4342
|
+
this.trigger('render');
|
|
4343
|
+
// Apply debug delay after render
|
|
4344
|
+
const isRerender = this._ready_state >= 3; // Already rendered once
|
|
4345
|
+
applyDebugDelay(isRerender ? 'rerender' : 'render');
|
|
4346
|
+
// Store args snapshot for reload() comparison (skip if non-serializable)
|
|
4347
|
+
try {
|
|
4348
|
+
this._args_on_last_render = JSON.parse(JSON.stringify(this.args));
|
|
4349
|
+
}
|
|
4350
|
+
catch (error) {
|
|
4351
|
+
// Args contain circular references - skip snapshot (reload comparison will be disabled)
|
|
4352
|
+
this._args_on_last_render = null;
|
|
4353
|
+
}
|
|
4354
|
+
// Store data snapshot for refresh() comparison
|
|
4355
|
+
this._data_on_last_render = JSON.stringify(this.data);
|
|
4356
|
+
// HTML CACHE MODE: Mark on_render complete after second render (post-on_load)
|
|
4357
|
+
// This signals to parent components that this component's DOM is fully rendered
|
|
4358
|
+
// with fresh data and ready for HTML snapshot
|
|
4359
|
+
if (this._ready_state >= 2) {
|
|
4360
|
+
this._on_render_complete = true;
|
|
4361
|
+
}
|
|
4362
|
+
// Return the render ID so callers can check if this render is still current
|
|
4363
|
+
return current_render_id;
|
|
4364
|
+
}
|
|
4365
|
+
/**
|
|
4366
|
+
* Public render method - re-renders component and completes lifecycle
|
|
4367
|
+
* This is what users should call when they want to update a component.
|
|
4368
|
+
*
|
|
4369
|
+
* Lifecycle sequence (serialized via component queue):
|
|
4370
|
+
* 1. _render() - Updates DOM synchronously, calls on_render(), fires 'render' event
|
|
4371
|
+
* 2. _wait_for_children_ready() - Waits for all children to reach ready state
|
|
4372
|
+
* 3. on_ready() - Calls user's ready hook
|
|
4373
|
+
* 4. trigger('ready') - Fires ready event
|
|
4374
|
+
*
|
|
4375
|
+
* Goes through the component queue to prevent concurrent lifecycle operations.
|
|
4376
|
+
* Returns a Promise that resolves when the full render lifecycle completes.
|
|
4377
|
+
*/
|
|
4378
|
+
render(id = null) {
|
|
4379
|
+
if (this._stopped)
|
|
4380
|
+
return Promise.resolve();
|
|
4381
|
+
// Invalidate ready event so new handlers wait for this render cycle to complete
|
|
4382
|
+
// This prevents .on('ready') handlers from firing immediately based on previous lifecycle
|
|
4383
|
+
this.invalidate('ready');
|
|
4384
|
+
// If id provided, delegate to child component
|
|
4385
|
+
if (id) {
|
|
4386
|
+
const $element = this.$sid(id);
|
|
4387
|
+
if ($element.length === 0) {
|
|
4388
|
+
throw new Error(`[JQHTML] render("${id}") - no such id.\n` +
|
|
4389
|
+
`Component "${this.component_name()}" has no child element with $sid="${id}".`);
|
|
4390
|
+
}
|
|
4391
|
+
const child = $element.data('_component');
|
|
4392
|
+
if (!child) {
|
|
4393
|
+
throw new Error(`[JQHTML] render("${id}") - element is not a component or does not have $redrawable attribute set.\n` +
|
|
4394
|
+
`Element with $sid="${id}" exists but is not initialized as a component.\n` +
|
|
4395
|
+
`Add $redrawable attribute or make it a proper component.`);
|
|
4396
|
+
}
|
|
4397
|
+
return child.render();
|
|
3981
4398
|
}
|
|
3982
4399
|
// Enqueue the full render lifecycle through the component queue
|
|
3983
4400
|
// Return the queue promise so `await this.render()` resolves when the
|
|
@@ -4809,765 +5226,910 @@ class Jqhtml_Component {
|
|
|
4809
5226
|
}
|
|
4810
5227
|
this._ready_state = 99;
|
|
4811
5228
|
this._update_debug_attrs();
|
|
4812
|
-
this._log_lifecycle('destroy', 'complete');
|
|
4813
|
-
}
|
|
4814
|
-
/**
|
|
4815
|
-
* Stop component lifecycle - stops all descendant components then self
|
|
4816
|
-
* Leaves DOM intact, just stops lifecycle engine and fires cleanup hooks
|
|
4817
|
-
*/
|
|
4818
|
-
stop() {
|
|
4819
|
-
// Stop all descendant components (flat iteration, no recursion needed)
|
|
4820
|
-
this.$.find('.Component').each(function () {
|
|
4821
|
-
const child = $(this).data('_component');
|
|
4822
|
-
if (child && !child._stopped) {
|
|
4823
|
-
child._stop(); // Not recursive - we already have all descendants
|
|
4824
|
-
}
|
|
4825
|
-
});
|
|
4826
|
-
// Then stop self
|
|
4827
|
-
this._stop();
|
|
4828
|
-
}
|
|
4829
|
-
// -------------------------------------------------------------------------
|
|
4830
|
-
// Overridable Lifecycle Hooks
|
|
4831
|
-
// -------------------------------------------------------------------------
|
|
4832
|
-
on_render() { }
|
|
4833
|
-
on_create() { }
|
|
4834
|
-
on_load() { } // Override to fetch data asynchronously
|
|
4835
|
-
on_loaded() { } // Override to process loaded data (e.g., clone this.data to this.state)
|
|
4836
|
-
async on_ready() { }
|
|
4837
|
-
on_stop() { }
|
|
4838
|
-
/**
|
|
4839
|
-
* Called with the viewport width in CSS pixels:
|
|
4840
|
-
* - after every on_render()
|
|
4841
|
-
* - after every on_ready()
|
|
4842
|
-
* - on window resize, debounced 30ms, for every component in the document
|
|
4843
|
-
*
|
|
4844
|
-
* MUST be synchronous. Prefer CSS media/container queries for pure styling -
|
|
4845
|
-
* override this only when layout requires imperative measurement.
|
|
4846
|
-
*/
|
|
4847
|
-
on_viewport_resize(viewport_width) { }
|
|
4848
|
-
/**
|
|
4849
|
-
* Should component re-render after load?
|
|
4850
|
-
* By default, only re-renders if data has changed
|
|
4851
|
-
* Override to control re-rendering behavior
|
|
4852
|
-
*/
|
|
4853
|
-
/**
|
|
4854
|
-
* Internal method to determine if component should re-render after on_load()
|
|
4855
|
-
* @private
|
|
4856
|
-
*/
|
|
4857
|
-
_should_rerender() {
|
|
4858
|
-
// HTML CACHE MODE - If we used cached HTML, always re-render to get live components
|
|
4859
|
-
if (this._used_cached_html) {
|
|
4860
|
-
if (window.jqhtml?.debug?.verbose) {
|
|
4861
|
-
console.log(`[Cache html] Component ${this._cid} (${this.component_name()}) forcing re-render after cached HTML`);
|
|
4862
|
-
}
|
|
4863
|
-
// Clear the flag
|
|
4864
|
-
this._used_cached_html = false;
|
|
4865
|
-
return true;
|
|
4866
|
-
}
|
|
4867
|
-
// OPTIMIZATION: If no custom on_load(), data cannot change, skip serialization
|
|
4868
|
-
// Data is frozen after on_create() and only unfrozen during on_load()
|
|
4869
|
-
if (!this.__has_custom_on_load) {
|
|
4870
|
-
return false;
|
|
4871
|
-
}
|
|
4872
|
-
// Compare current data state with data state before initial render
|
|
4873
|
-
const currentDataState = JSON.stringify(this.data);
|
|
4874
|
-
const dataChanged = this._data_before_render !== currentDataState;
|
|
4875
|
-
// Update stored state for next comparison
|
|
4876
|
-
if (dataChanged) {
|
|
4877
|
-
this._data_before_render = currentDataState;
|
|
4878
|
-
}
|
|
4879
|
-
return dataChanged;
|
|
4880
|
-
}
|
|
4881
|
-
// -------------------------------------------------------------------------
|
|
4882
|
-
// Public API
|
|
4883
|
-
// -------------------------------------------------------------------------
|
|
4884
|
-
/**
|
|
4885
|
-
* Get component name for debugging
|
|
4886
|
-
*/
|
|
4887
|
-
component_name() {
|
|
4888
|
-
return this.constructor.name;
|
|
4889
|
-
}
|
|
4890
|
-
/**
|
|
4891
|
-
* Register event callback - delegates to component-events.ts
|
|
4892
|
-
* @see component-events.ts for full documentation
|
|
4893
|
-
*/
|
|
4894
|
-
on(event_name, callback) {
|
|
4895
|
-
return event_on(this, event_name, callback);
|
|
4896
|
-
}
|
|
4897
|
-
/**
|
|
4898
|
-
* Register a callback that fires exactly once - delegates to component-events.ts
|
|
4899
|
-
* If the event already occurred, fires immediately and does not register.
|
|
4900
|
-
* @see component-events.ts for full documentation
|
|
4901
|
-
*/
|
|
4902
|
-
once(event_name, callback) {
|
|
4903
|
-
return event_once(this, event_name, callback);
|
|
4904
|
-
}
|
|
4905
|
-
/**
|
|
4906
|
-
* Trigger a lifecycle event - delegates to component-events.ts
|
|
4907
|
-
* @see component-events.ts for full documentation
|
|
4908
|
-
*/
|
|
4909
|
-
trigger(event_name, data) {
|
|
4910
|
-
event_trigger(this, event_name, data);
|
|
4911
|
-
}
|
|
4912
|
-
/**
|
|
4913
|
-
* Check if any callbacks are registered for a given event
|
|
4914
|
-
*/
|
|
4915
|
-
_on_registered(event_name) {
|
|
4916
|
-
return event_on_registered(this, event_name);
|
|
4917
|
-
}
|
|
4918
|
-
/**
|
|
4919
|
-
* Invalidate a lifecycle event - delegates to component-events.ts
|
|
4920
|
-
* @see component-events.ts for full documentation
|
|
4921
|
-
*/
|
|
4922
|
-
invalidate(event_name) {
|
|
4923
|
-
event_invalidate(this, event_name);
|
|
4924
|
-
}
|
|
4925
|
-
/**
|
|
4926
|
-
* Find element by scoped ID
|
|
4927
|
-
*
|
|
4928
|
-
* Searches for elements with id="local_id:THIS_COMPONENT_CID"
|
|
4929
|
-
*
|
|
4930
|
-
* Example:
|
|
4931
|
-
* Template: <button $sid="save_btn">Save</button>
|
|
4932
|
-
* Rendered: <button id="save_btn:abc123" data-sid="save_btn">Save</button>
|
|
4933
|
-
* Access: this.$sid('save_btn') // Returns jQuery element
|
|
4934
|
-
*
|
|
4935
|
-
* Performance: Uses native document.getElementById() when component is in DOM,
|
|
4936
|
-
* falls back to jQuery.find() for components not yet attached to DOM.
|
|
4937
|
-
*
|
|
4938
|
-
* @param local_id The local ID (without _cid suffix)
|
|
4939
|
-
* @returns jQuery element with id="local_id:_cid", or empty jQuery object if not found
|
|
4940
|
-
*/
|
|
4941
|
-
$sid(local_id) {
|
|
4942
|
-
const scopedId = `${local_id}:${this._cid}`;
|
|
4943
|
-
// Try getElementById first (fast path - works when component is in DOM)
|
|
4944
|
-
const el = document.getElementById(scopedId);
|
|
4945
|
-
if (el) {
|
|
4946
|
-
return $(el);
|
|
4947
|
-
}
|
|
4948
|
-
// Fallback: component not in DOM yet, search within component subtree
|
|
4949
|
-
// This allows $sid() to work on components before they're appended to body
|
|
4950
|
-
// Must escape the ID because it contains ':' which jQuery treats as a pseudo-selector
|
|
4951
|
-
return this.$.find(`#${$.escapeSelector(scopedId)}`);
|
|
4952
|
-
}
|
|
4953
|
-
/**
|
|
4954
|
-
* Get component instance by scoped ID
|
|
4955
|
-
*
|
|
4956
|
-
* Convenience method that finds element by scoped ID and returns the component instance.
|
|
4957
|
-
*
|
|
4958
|
-
* Example:
|
|
4959
|
-
* Template: <User_Card $sid="active_user" />
|
|
4960
|
-
* Access: const user = this.sid('active_user'); // Returns User_Card instance
|
|
4961
|
-
* user.data.name // Access component's data
|
|
4962
|
-
*
|
|
4963
|
-
* To get the scoped ID string itself:
|
|
4964
|
-
* this.$sid('active_user').attr('id') // Returns "active_user:abc123xyz"
|
|
4965
|
-
*
|
|
4966
|
-
* @param local_id The local ID (without _cid suffix)
|
|
4967
|
-
* @returns Component instance or null if not found or not a component
|
|
4968
|
-
*/
|
|
4969
|
-
sid(local_id) {
|
|
4970
|
-
const element = this.$sid(local_id);
|
|
4971
|
-
const component = element.data('_component');
|
|
4972
|
-
// If no component found but element exists, warn developer
|
|
4973
|
-
if (!component && element.length > 0) {
|
|
4974
|
-
console.warn(`Component ${this.constructor.name} tried to call .sid('${local_id}') - ` +
|
|
4975
|
-
`${local_id} exists, however, it is not a component or $redrawable. ` +
|
|
4976
|
-
`Did you forget to add $redrawable to the tag?`);
|
|
4977
|
-
}
|
|
4978
|
-
return component || null;
|
|
4979
|
-
}
|
|
4980
|
-
/**
|
|
4981
|
-
* Get the component that instantiated this component (rendered it in their template)
|
|
4982
|
-
* Returns null if component was created programmatically via $().component()
|
|
4983
|
-
*/
|
|
4984
|
-
instantiator() {
|
|
4985
|
-
return this._instantiator;
|
|
4986
|
-
}
|
|
4987
|
-
/**
|
|
4988
|
-
* Find descendant components by CSS selector
|
|
4989
|
-
*/
|
|
4990
|
-
find(selector) {
|
|
4991
|
-
const components = [];
|
|
4992
|
-
this.$.find(selector).each((_, el) => {
|
|
4993
|
-
const comp = $(el).data('_component');
|
|
4994
|
-
if (comp instanceof Jqhtml_Component) {
|
|
4995
|
-
components.push(comp);
|
|
4996
|
-
}
|
|
4997
|
-
});
|
|
4998
|
-
return components;
|
|
5229
|
+
this._log_lifecycle('destroy', 'complete');
|
|
4999
5230
|
}
|
|
5000
5231
|
/**
|
|
5001
|
-
*
|
|
5002
|
-
*
|
|
5003
|
-
* A bare component name - a plain identifier starting with a capital letter,
|
|
5004
|
-
* optionally preceded by a single underscore, such as 'Parent_Dashboard' or
|
|
5005
|
-
* '_Root_Layout' - is treated as the component-name class
|
|
5006
|
-
* selector '.Parent_Dashboard'. Components carry a CSS class for every name
|
|
5007
|
-
* in their prototype chain, so a base class name matches its subclasses too.
|
|
5008
|
-
* Every other selector ('.Foo', '#id', '[attr]', 'div > .Component') is
|
|
5009
|
-
* passed to jQuery unchanged.
|
|
5232
|
+
* Stop component lifecycle - stops all descendant components then self
|
|
5233
|
+
* Leaves DOM intact, just stops lifecycle engine and fires cleanup hooks
|
|
5010
5234
|
*/
|
|
5011
|
-
|
|
5012
|
-
|
|
5013
|
-
|
|
5014
|
-
|
|
5015
|
-
|
|
5016
|
-
|
|
5017
|
-
if (current.is(search)) {
|
|
5018
|
-
const comp = current.data('_component');
|
|
5019
|
-
if (comp instanceof Jqhtml_Component) {
|
|
5020
|
-
return comp;
|
|
5021
|
-
}
|
|
5235
|
+
stop() {
|
|
5236
|
+
// Stop all descendant components (flat iteration, no recursion needed)
|
|
5237
|
+
this.$.find('.Component').each(function () {
|
|
5238
|
+
const child = $(this).data('_component');
|
|
5239
|
+
if (child && !child._stopped) {
|
|
5240
|
+
child._stop(); // Not recursive - we already have all descendants
|
|
5022
5241
|
}
|
|
5023
|
-
|
|
5024
|
-
|
|
5025
|
-
|
|
5242
|
+
});
|
|
5243
|
+
// Then stop self
|
|
5244
|
+
this._stop();
|
|
5026
5245
|
}
|
|
5027
5246
|
// -------------------------------------------------------------------------
|
|
5028
|
-
//
|
|
5247
|
+
// Overridable Lifecycle Hooks
|
|
5029
5248
|
// -------------------------------------------------------------------------
|
|
5249
|
+
on_render() { }
|
|
5250
|
+
on_create() { }
|
|
5251
|
+
on_load() { } // Override to fetch data asynchronously
|
|
5252
|
+
on_loaded() { } // Override to process loaded data (e.g., clone this.data to this.state)
|
|
5253
|
+
async on_ready() { }
|
|
5254
|
+
on_stop() { }
|
|
5030
5255
|
/**
|
|
5031
|
-
*
|
|
5256
|
+
* Called with the viewport width in CSS pixels:
|
|
5257
|
+
* - after every on_render()
|
|
5258
|
+
* - after every on_ready()
|
|
5259
|
+
* - on window resize, debounced 30ms, for every component in the document
|
|
5260
|
+
*
|
|
5261
|
+
* MUST be synchronous. Prefer CSS media/container queries for pure styling -
|
|
5262
|
+
* override this only when layout requires imperative measurement.
|
|
5032
5263
|
*/
|
|
5033
|
-
|
|
5034
|
-
|
|
5035
|
-
|
|
5036
|
-
|
|
5037
|
-
|
|
5038
|
-
|
|
5039
|
-
|
|
5040
|
-
|
|
5041
|
-
|
|
5042
|
-
|
|
5043
|
-
|
|
5044
|
-
|
|
5045
|
-
|
|
5046
|
-
|
|
5047
|
-
|
|
5048
|
-
normalizedName = 'Component'; // Use 'Component' instead of 'Jqhtml_Component' for CSS class
|
|
5049
|
-
}
|
|
5050
|
-
else if (normalizedName === 'Jqhtml_Component') {
|
|
5051
|
-
normalizedName = 'Component'; // Use 'Component' instead of 'Jqhtml_Component' for CSS class
|
|
5052
|
-
}
|
|
5053
|
-
classes.push(normalizedName);
|
|
5054
|
-
}
|
|
5055
|
-
// Get the next prototype
|
|
5056
|
-
const nextProto = Object.getPrototypeOf(ctor);
|
|
5057
|
-
// Stop if we've reached the end of the chain
|
|
5058
|
-
if (!nextProto || nextProto === Object.prototype || nextProto.constructor === Object) {
|
|
5059
|
-
break;
|
|
5060
|
-
}
|
|
5061
|
-
ctor = nextProto;
|
|
5062
|
-
}
|
|
5063
|
-
return classes;
|
|
5064
|
-
}
|
|
5065
|
-
// -------------------------------------------------------------------------
|
|
5066
|
-
// Private Implementation
|
|
5067
|
-
// -------------------------------------------------------------------------
|
|
5068
|
-
_generate_cid() {
|
|
5069
|
-
return uid();
|
|
5070
|
-
}
|
|
5071
|
-
_apply_css_classes() {
|
|
5072
|
-
const hierarchy = this.constructor.get_class_hierarchy();
|
|
5073
|
-
// If component name differs from class name, add component name to hierarchy
|
|
5074
|
-
// This happens when:
|
|
5075
|
-
// 1. Using base Jqhtml_Component with _component_name
|
|
5076
|
-
// 2. Using parent class via extends chain (e.g., Contacts_DataGrid using DataGrid_Abstract class)
|
|
5077
|
-
const classesToAdd = [...hierarchy];
|
|
5078
|
-
if (this.args._component_name && this.args._component_name !== this.constructor.name) {
|
|
5079
|
-
// Add component name at the beginning (most specific)
|
|
5080
|
-
classesToAdd.unshift(this.args._component_name);
|
|
5081
|
-
}
|
|
5082
|
-
// Filter out private/mangled classes (starting with _) and invalid class names.
|
|
5083
|
-
// A component name with the reserved single-underscore prefix (_Root_Layout)
|
|
5084
|
-
// is public and is kept - see component-name.ts.
|
|
5085
|
-
const publicClasses = classesToAdd.filter(className => {
|
|
5086
|
-
// Guard against undefined, null, or non-string values
|
|
5087
|
-
if (!className || typeof className !== 'string') {
|
|
5088
|
-
console.warn('[JQHTML] Filtered out invalid class name:', className);
|
|
5089
|
-
return false;
|
|
5090
|
-
}
|
|
5091
|
-
return !className.startsWith('_') || is_component_name(className);
|
|
5092
|
-
});
|
|
5093
|
-
if (publicClasses.length > 0) {
|
|
5094
|
-
this.$.addClass(publicClasses.join(' '));
|
|
5095
|
-
}
|
|
5096
|
-
}
|
|
5097
|
-
_apply_default_attributes() {
|
|
5098
|
-
// Get template using same logic as render() method
|
|
5099
|
-
let template;
|
|
5100
|
-
// If we have a component name, try looking up by name first (for unregistered components)
|
|
5101
|
-
if (this.args._component_name) {
|
|
5102
|
-
template = get_template(this.args._component_name);
|
|
5103
|
-
}
|
|
5104
|
-
else {
|
|
5105
|
-
// Otherwise look up by class
|
|
5106
|
-
template = get_template_by_class(this.constructor);
|
|
5107
|
-
}
|
|
5108
|
-
if (!template)
|
|
5109
|
-
return;
|
|
5110
|
-
// Walk the extends chain to collect all defaultAttributes from parent templates
|
|
5111
|
-
// Apply from parent to child so child attributes take precedence
|
|
5112
|
-
const templateChain = [];
|
|
5113
|
-
let currentTemplate = template;
|
|
5114
|
-
// Build chain from child to parent
|
|
5115
|
-
while (currentTemplate) {
|
|
5116
|
-
templateChain.unshift(currentTemplate); // Add to front so parent comes first
|
|
5117
|
-
// Check if this template extends another
|
|
5118
|
-
if (currentTemplate.extends) {
|
|
5119
|
-
try {
|
|
5120
|
-
currentTemplate = get_template(currentTemplate.extends);
|
|
5121
|
-
}
|
|
5122
|
-
catch (error) {
|
|
5123
|
-
// Parent template not found, stop chain
|
|
5124
|
-
break;
|
|
5125
|
-
}
|
|
5126
|
-
}
|
|
5127
|
-
else {
|
|
5128
|
-
break;
|
|
5129
|
-
}
|
|
5130
|
-
}
|
|
5131
|
-
// Apply defaultAttributes from each template in the chain (parent first, child last)
|
|
5132
|
-
for (const tmpl of templateChain) {
|
|
5133
|
-
if (!tmpl.defaultAttributes)
|
|
5134
|
-
continue;
|
|
5135
|
-
// Filter out 'tag' since it determines tag name, not an attribute
|
|
5136
|
-
const defineAttrs = { ...tmpl.defaultAttributes };
|
|
5137
|
-
delete defineAttrs.tag;
|
|
5138
|
-
// Debug logging
|
|
5139
|
-
if (window.jqhtml?.debug?.enabled) {
|
|
5140
|
-
const componentName = tmpl.name || this.args._component_name || this.constructor.name;
|
|
5141
|
-
console.log(`[Component] Applying defaultAttributes for ${componentName}:`, defineAttrs);
|
|
5142
|
-
}
|
|
5143
|
-
// Apply each default attribute
|
|
5144
|
-
for (const [key, value] of Object.entries(defineAttrs)) {
|
|
5145
|
-
if (key === 'class') {
|
|
5146
|
-
// Special handling for class - merge with existing
|
|
5147
|
-
const existingClasses = this.$.attr('class');
|
|
5148
|
-
if (existingClasses) {
|
|
5149
|
-
const existing = existingClasses.split(/\s+/).filter(c => c);
|
|
5150
|
-
const newClasses = String(value).split(/\s+/).filter(c => c);
|
|
5151
|
-
for (const newClass of newClasses) {
|
|
5152
|
-
if (!existing.includes(newClass)) {
|
|
5153
|
-
existing.push(newClass);
|
|
5154
|
-
}
|
|
5155
|
-
}
|
|
5156
|
-
this.$.attr('class', existing.join(' '));
|
|
5157
|
-
}
|
|
5158
|
-
else {
|
|
5159
|
-
this.$.attr('class', value);
|
|
5160
|
-
}
|
|
5161
|
-
}
|
|
5162
|
-
else if (key === 'style') {
|
|
5163
|
-
// Special handling for style - merge with existing
|
|
5164
|
-
// Define attributes are applied AFTER invocation attributes
|
|
5165
|
-
// So we only add Define style properties that don't already exist
|
|
5166
|
-
// This ensures invocation wins conflicts (invocation overrides Define)
|
|
5167
|
-
const existingStyle = this.$.attr('style');
|
|
5168
|
-
if (existingStyle) {
|
|
5169
|
-
// Parse existing styles (from invocation)
|
|
5170
|
-
const existingRules = new Map();
|
|
5171
|
-
existingStyle.split(';').forEach(rule => {
|
|
5172
|
-
const [prop, val] = rule.split(':').map(s => s.trim());
|
|
5173
|
-
if (prop && val)
|
|
5174
|
-
existingRules.set(prop, val);
|
|
5175
|
-
});
|
|
5176
|
-
// Parse Define styles and only add if not already present
|
|
5177
|
-
String(value).split(';').forEach(rule => {
|
|
5178
|
-
const [prop, val] = rule.split(':').map(s => s.trim());
|
|
5179
|
-
if (prop && val && !existingRules.has(prop)) {
|
|
5180
|
-
// Only add if property doesn't exist (invocation wins)
|
|
5181
|
-
existingRules.set(prop, val);
|
|
5182
|
-
}
|
|
5183
|
-
});
|
|
5184
|
-
// Build merged style string
|
|
5185
|
-
const merged = Array.from(existingRules.entries())
|
|
5186
|
-
.map(([prop, val]) => `${prop}: ${val}`)
|
|
5187
|
-
.join('; ');
|
|
5188
|
-
this.$.attr('style', merged);
|
|
5189
|
-
}
|
|
5190
|
-
else {
|
|
5191
|
-
this.$.attr('style', value);
|
|
5192
|
-
}
|
|
5193
|
-
}
|
|
5194
|
-
else if (key.startsWith('$') || key.startsWith('data-')) {
|
|
5195
|
-
// Data attributes - also update args
|
|
5196
|
-
const dataKey = key.startsWith('$') ? key.substring(1) :
|
|
5197
|
-
key.startsWith('data-') ? key.substring(5) : key;
|
|
5198
|
-
// Only apply if not already set in args
|
|
5199
|
-
if (!(dataKey in this.args)) {
|
|
5200
|
-
this.args[dataKey] = value;
|
|
5201
|
-
this.$.data(dataKey, value);
|
|
5202
|
-
this.$.attr(key.startsWith('$') ? `data-${dataKey}` : key, String(value));
|
|
5203
|
-
}
|
|
5204
|
-
}
|
|
5205
|
-
else {
|
|
5206
|
-
// Regular attributes - apply directly if not already set
|
|
5207
|
-
if (!this.$.attr(key)) {
|
|
5208
|
-
this.$.attr(key, value);
|
|
5209
|
-
}
|
|
5210
|
-
}
|
|
5264
|
+
on_viewport_resize(viewport_width) { }
|
|
5265
|
+
/**
|
|
5266
|
+
* Should component re-render after load?
|
|
5267
|
+
* By default, only re-renders if data has changed
|
|
5268
|
+
* Override to control re-rendering behavior
|
|
5269
|
+
*/
|
|
5270
|
+
/**
|
|
5271
|
+
* Internal method to determine if component should re-render after on_load()
|
|
5272
|
+
* @private
|
|
5273
|
+
*/
|
|
5274
|
+
_should_rerender() {
|
|
5275
|
+
// HTML CACHE MODE - If we used cached HTML, always re-render to get live components
|
|
5276
|
+
if (this._used_cached_html) {
|
|
5277
|
+
if (window.jqhtml?.debug?.verbose) {
|
|
5278
|
+
console.log(`[Cache html] Component ${this._cid} (${this.component_name()}) forcing re-render after cached HTML`);
|
|
5211
5279
|
}
|
|
5280
|
+
// Clear the flag
|
|
5281
|
+
this._used_cached_html = false;
|
|
5282
|
+
return true;
|
|
5212
5283
|
}
|
|
5213
|
-
|
|
5214
|
-
|
|
5215
|
-
|
|
5216
|
-
|
|
5217
|
-
// _cid property, and the transient data-cid placeholder the instruction processor
|
|
5218
|
-
// matches on is written and removed before this ever runs.
|
|
5219
|
-
if (debug_attributes_enabled()) {
|
|
5220
|
-
this.$.attr('data-cid', this._cid);
|
|
5221
|
-
}
|
|
5222
|
-
// Only set lifecycle state attribute if verbose mode enabled
|
|
5223
|
-
if (window.jqhtml?.debug?.verbose) {
|
|
5224
|
-
this.$.attr('data-_lifecycle-state', this._ready_state.toString());
|
|
5284
|
+
// OPTIMIZATION: If no custom on_load(), data cannot change, skip serialization
|
|
5285
|
+
// Data is frozen after on_create() and only unfrozen during on_load()
|
|
5286
|
+
if (!this.__has_custom_on_load) {
|
|
5287
|
+
return false;
|
|
5225
5288
|
}
|
|
5226
|
-
|
|
5227
|
-
|
|
5228
|
-
|
|
5229
|
-
|
|
5230
|
-
|
|
5289
|
+
// Compare current data state with data state before initial render
|
|
5290
|
+
const currentDataState = JSON.stringify(this.data);
|
|
5291
|
+
const dataChanged = this._data_before_render !== currentDataState;
|
|
5292
|
+
// Update stored state for next comparison
|
|
5293
|
+
if (dataChanged) {
|
|
5294
|
+
this._data_before_render = currentDataState;
|
|
5231
5295
|
}
|
|
5296
|
+
return dataChanged;
|
|
5232
5297
|
}
|
|
5233
|
-
|
|
5234
|
-
|
|
5235
|
-
|
|
5236
|
-
|
|
5237
|
-
|
|
5238
|
-
|
|
5239
|
-
|
|
5240
|
-
|
|
5241
|
-
|
|
5242
|
-
|
|
5243
|
-
|
|
5298
|
+
// -------------------------------------------------------------------------
|
|
5299
|
+
// Public API
|
|
5300
|
+
// -------------------------------------------------------------------------
|
|
5301
|
+
/**
|
|
5302
|
+
* Get component name for debugging
|
|
5303
|
+
*/
|
|
5304
|
+
/**
|
|
5305
|
+
* The name this component was invoked as - the tag written in the template
|
|
5306
|
+
* (<User_Card> -> 'User_Card'), the name given to $(el).component('User_Card'),
|
|
5307
|
+
* or the boot placeholder's data-component-init-name. For a template-only
|
|
5308
|
+
* component this is the template name, not 'Jqhtml_Component'. Falls back to
|
|
5309
|
+
* the class's static component_name, then its JS class name.
|
|
5310
|
+
*/
|
|
5311
|
+
component_name() {
|
|
5312
|
+
return this._component_name;
|
|
5244
5313
|
}
|
|
5245
5314
|
/**
|
|
5246
|
-
*
|
|
5247
|
-
*
|
|
5248
|
-
* @private - Used internally for lifecycle coordination
|
|
5315
|
+
* Register event callback - delegates to component-events.ts
|
|
5316
|
+
* @see component-events.ts for full documentation
|
|
5249
5317
|
*/
|
|
5250
|
-
|
|
5251
|
-
|
|
5252
|
-
// Use DOM traversal to find direct children (works off-DOM, slightly slower)
|
|
5253
|
-
if (this._use_dom_fallback) {
|
|
5254
|
-
const directChildren = [];
|
|
5255
|
-
this.$.find('.Component').each((_, el) => {
|
|
5256
|
-
const $el = $(el);
|
|
5257
|
-
const comp = $el.data('_component');
|
|
5258
|
-
if (comp instanceof Jqhtml_Component) {
|
|
5259
|
-
// Only include if this component is the direct parent (not a grandparent)
|
|
5260
|
-
// Check: closest parent component is us (or no parent = we're the root)
|
|
5261
|
-
const closestParent = $el.parent().closest('.Component');
|
|
5262
|
-
if (closestParent.length === 0 || closestParent.data('_component') === this) {
|
|
5263
|
-
directChildren.push(comp);
|
|
5264
|
-
}
|
|
5265
|
-
}
|
|
5266
|
-
});
|
|
5267
|
-
return directChildren;
|
|
5268
|
-
}
|
|
5269
|
-
// Fast path: component was in-DOM during render, children registered normally
|
|
5270
|
-
// Filter out any that have since been removed from DOM (destroyed/replaced)
|
|
5271
|
-
const children = Array.from(this._dom_children);
|
|
5272
|
-
return children.filter(child => {
|
|
5273
|
-
return $.contains(document.documentElement, child.$[0]);
|
|
5274
|
-
});
|
|
5318
|
+
on(event_name, callback) {
|
|
5319
|
+
return event_on(this, event_name, callback);
|
|
5275
5320
|
}
|
|
5276
|
-
|
|
5277
|
-
|
|
5278
|
-
|
|
5279
|
-
|
|
5280
|
-
|
|
5281
|
-
|
|
5282
|
-
|
|
5283
|
-
ready_state: this._ready_state,
|
|
5284
|
-
args: this.args
|
|
5285
|
-
});
|
|
5286
|
-
}
|
|
5321
|
+
/**
|
|
5322
|
+
* Register a callback that fires exactly once - delegates to component-events.ts
|
|
5323
|
+
* If the event already occurred, fires immediately and does not register.
|
|
5324
|
+
* @see component-events.ts for full documentation
|
|
5325
|
+
*/
|
|
5326
|
+
once(event_name, callback) {
|
|
5327
|
+
return event_once(this, event_name, callback);
|
|
5287
5328
|
}
|
|
5288
|
-
|
|
5289
|
-
|
|
5290
|
-
|
|
5329
|
+
/**
|
|
5330
|
+
* Trigger a lifecycle event - delegates to component-events.ts
|
|
5331
|
+
* @see component-events.ts for full documentation
|
|
5332
|
+
*/
|
|
5333
|
+
trigger(event_name, data) {
|
|
5334
|
+
event_trigger(this, event_name, data);
|
|
5335
|
+
}
|
|
5336
|
+
/**
|
|
5337
|
+
* Check if any callbacks are registered for a given event
|
|
5338
|
+
*/
|
|
5339
|
+
_on_registered(event_name) {
|
|
5340
|
+
return event_on_registered(this, event_name);
|
|
5341
|
+
}
|
|
5342
|
+
/**
|
|
5343
|
+
* Invalidate a lifecycle event - delegates to component-events.ts
|
|
5344
|
+
* @see component-events.ts for full documentation
|
|
5345
|
+
*/
|
|
5346
|
+
invalidate(event_name) {
|
|
5347
|
+
event_invalidate(this, event_name);
|
|
5348
|
+
}
|
|
5349
|
+
/**
|
|
5350
|
+
* Find element by scoped ID
|
|
5351
|
+
*
|
|
5352
|
+
* Searches for elements with id="local_id:THIS_COMPONENT_CID"
|
|
5353
|
+
*
|
|
5354
|
+
* Example:
|
|
5355
|
+
* Template: <button $sid="save_btn">Save</button>
|
|
5356
|
+
* Rendered: <button id="save_btn:abc123" data-sid="save_btn">Save</button>
|
|
5357
|
+
* Access: this.$sid('save_btn') // Returns jQuery element
|
|
5358
|
+
*
|
|
5359
|
+
* Performance: Uses native document.getElementById() when component is in DOM,
|
|
5360
|
+
* falls back to jQuery.find() for components not yet attached to DOM.
|
|
5361
|
+
*
|
|
5362
|
+
* @param local_id The local ID (without _cid suffix)
|
|
5363
|
+
* @returns jQuery element with id="local_id:_cid", or empty jQuery object if not found
|
|
5364
|
+
*/
|
|
5365
|
+
$sid(local_id) {
|
|
5366
|
+
const scopedId = `${local_id}:${this._cid}`;
|
|
5367
|
+
// Try getElementById first (fast path - works when component is in DOM)
|
|
5368
|
+
const el = document.getElementById(scopedId);
|
|
5369
|
+
if (el) {
|
|
5370
|
+
return $(el);
|
|
5291
5371
|
}
|
|
5372
|
+
// Fallback: component not in DOM yet, search within component subtree
|
|
5373
|
+
// This allows $sid() to work on components before they're appended to body
|
|
5374
|
+
// Must escape the ID because it contains ':' which jQuery treats as a pseudo-selector
|
|
5375
|
+
return this.$.find(`#${$.escapeSelector(scopedId)}`);
|
|
5292
5376
|
}
|
|
5293
|
-
|
|
5294
|
-
|
|
5295
|
-
|
|
5296
|
-
|
|
5297
|
-
|
|
5298
|
-
|
|
5299
|
-
|
|
5300
|
-
|
|
5301
|
-
|
|
5302
|
-
|
|
5303
|
-
|
|
5304
|
-
|
|
5305
|
-
|
|
5306
|
-
|
|
5307
|
-
|
|
5308
|
-
|
|
5309
|
-
|
|
5310
|
-
|
|
5311
|
-
|
|
5312
|
-
|
|
5313
|
-
|
|
5314
|
-
|
|
5315
|
-
|
|
5316
|
-
|
|
5317
|
-
let currentClass = Object.getPrototypeOf(component.constructor);
|
|
5318
|
-
console.log(`[JQHTML] Walking prototype chain for ${component.constructor.name}`);
|
|
5319
|
-
while (currentClass && currentClass !== Jqhtml_Component && currentClass.name !== 'Object') {
|
|
5320
|
-
const className = currentClass.name;
|
|
5321
|
-
console.log(`[JQHTML] Checking parent class: ${className}`);
|
|
5322
|
-
// Skip internal framework classes
|
|
5323
|
-
if (className === '_Jqhtml_Component' || className === '_Base_Jqhtml_Component') {
|
|
5324
|
-
currentClass = Object.getPrototypeOf(currentClass);
|
|
5325
|
-
continue;
|
|
5377
|
+
/**
|
|
5378
|
+
* Get component instance by scoped ID
|
|
5379
|
+
*
|
|
5380
|
+
* Convenience method that finds element by scoped ID and returns the component instance.
|
|
5381
|
+
*
|
|
5382
|
+
* Example:
|
|
5383
|
+
* Template: <User_Card $sid="active_user" />
|
|
5384
|
+
* Access: const user = this.sid('active_user'); // Returns User_Card instance
|
|
5385
|
+
* user.data.name // Access component's data
|
|
5386
|
+
*
|
|
5387
|
+
* To get the scoped ID string itself:
|
|
5388
|
+
* this.$sid('active_user').attr('id') // Returns "active_user:abc123xyz"
|
|
5389
|
+
*
|
|
5390
|
+
* @param local_id The local ID (without _cid suffix)
|
|
5391
|
+
* @returns Component instance or null if not found or not a component
|
|
5392
|
+
*/
|
|
5393
|
+
sid(local_id) {
|
|
5394
|
+
const element = this.$sid(local_id);
|
|
5395
|
+
const component = element.data('_component');
|
|
5396
|
+
// If no component found but element exists, warn developer
|
|
5397
|
+
if (!component && element.length > 0) {
|
|
5398
|
+
console.warn(`Component ${this.constructor.name} tried to call .sid('${local_id}') - ` +
|
|
5399
|
+
`${local_id} exists, however, it is not a component or $redrawable. ` +
|
|
5400
|
+
`Did you forget to add $redrawable to the tag?`);
|
|
5326
5401
|
}
|
|
5327
|
-
|
|
5328
|
-
|
|
5329
|
-
|
|
5330
|
-
|
|
5331
|
-
|
|
5332
|
-
|
|
5333
|
-
|
|
5334
|
-
|
|
5335
|
-
|
|
5336
|
-
|
|
5337
|
-
|
|
5338
|
-
|
|
5339
|
-
|
|
5340
|
-
|
|
5341
|
-
|
|
5342
|
-
|
|
5343
|
-
|
|
5344
|
-
|
|
5345
|
-
|
|
5402
|
+
return component || null;
|
|
5403
|
+
}
|
|
5404
|
+
/**
|
|
5405
|
+
* Get the component that instantiated this component (rendered it in their template)
|
|
5406
|
+
* Returns null if component was created programmatically via $().component()
|
|
5407
|
+
*/
|
|
5408
|
+
instantiator() {
|
|
5409
|
+
return this._instantiator;
|
|
5410
|
+
}
|
|
5411
|
+
/**
|
|
5412
|
+
* Find descendant components by CSS selector
|
|
5413
|
+
*/
|
|
5414
|
+
find(selector) {
|
|
5415
|
+
const components = [];
|
|
5416
|
+
this.$.find(selector).each((_, el) => {
|
|
5417
|
+
const comp = $(el).data('_component');
|
|
5418
|
+
if (comp instanceof Jqhtml_Component) {
|
|
5419
|
+
components.push(comp);
|
|
5420
|
+
}
|
|
5421
|
+
});
|
|
5422
|
+
return components;
|
|
5423
|
+
}
|
|
5424
|
+
/**
|
|
5425
|
+
* Find closest ancestor component matching selector
|
|
5426
|
+
*
|
|
5427
|
+
* A bare component name - a plain identifier starting with a capital letter,
|
|
5428
|
+
* optionally preceded by a single underscore, such as 'Parent_Dashboard' or
|
|
5429
|
+
* '_Root_Layout' - is treated as the component-name class
|
|
5430
|
+
* selector '.Parent_Dashboard'. Components carry a CSS class for every name
|
|
5431
|
+
* in their prototype chain, so a base class name matches its subclasses too.
|
|
5432
|
+
* Every other selector ('.Foo', '#id', '[attr]', 'div > .Component') is
|
|
5433
|
+
* passed to jQuery unchanged.
|
|
5434
|
+
*/
|
|
5435
|
+
closest(selector) {
|
|
5436
|
+
const search = Jqhtml_Component.COMPONENT_NAME_PATTERN.test(selector)
|
|
5437
|
+
? `.${selector}`
|
|
5438
|
+
: selector;
|
|
5439
|
+
let current = this.$.parent();
|
|
5440
|
+
while (current.length > 0) {
|
|
5441
|
+
if (current.is(search)) {
|
|
5442
|
+
const comp = current.data('_component');
|
|
5443
|
+
if (comp instanceof Jqhtml_Component) {
|
|
5444
|
+
return comp;
|
|
5445
|
+
}
|
|
5346
5446
|
}
|
|
5447
|
+
current = current.parent();
|
|
5347
5448
|
}
|
|
5348
|
-
|
|
5349
|
-
|
|
5449
|
+
return null;
|
|
5450
|
+
}
|
|
5451
|
+
// -------------------------------------------------------------------------
|
|
5452
|
+
// Static Methods
|
|
5453
|
+
// -------------------------------------------------------------------------
|
|
5454
|
+
/**
|
|
5455
|
+
* Get CSS class hierarchy for this component type
|
|
5456
|
+
*/
|
|
5457
|
+
static get_class_hierarchy() {
|
|
5458
|
+
// v2.2.13 - Fixed to handle undefined values in prototype chain
|
|
5459
|
+
const classes = [];
|
|
5460
|
+
let ctor = this;
|
|
5461
|
+
while (ctor) {
|
|
5462
|
+
// Check if constructor has a valid name property
|
|
5463
|
+
if (!ctor.name || typeof ctor.name !== 'string') {
|
|
5464
|
+
// console.warn('[JQHTML v2.2.13] Invalid constructor name in hierarchy:', ctor);
|
|
5465
|
+
break;
|
|
5466
|
+
}
|
|
5467
|
+
// Only add valid class names (non-empty strings, not 'Object')
|
|
5468
|
+
if (ctor.name !== 'Object' && ctor.name !== '') {
|
|
5469
|
+
// Normalize base class names - handle different import patterns
|
|
5470
|
+
let normalizedName = ctor.name;
|
|
5471
|
+
if (normalizedName === '_Jqhtml_Component' || normalizedName === '_Base_Jqhtml_Component') {
|
|
5472
|
+
normalizedName = 'Component'; // Use 'Component' instead of 'Jqhtml_Component' for CSS class
|
|
5473
|
+
}
|
|
5474
|
+
else if (normalizedName === 'Jqhtml_Component') {
|
|
5475
|
+
normalizedName = 'Component'; // Use 'Component' instead of 'Jqhtml_Component' for CSS class
|
|
5476
|
+
}
|
|
5477
|
+
classes.push(normalizedName);
|
|
5478
|
+
}
|
|
5479
|
+
// Get the next prototype
|
|
5480
|
+
const nextProto = Object.getPrototypeOf(ctor);
|
|
5481
|
+
// Stop if we've reached the end of the chain
|
|
5482
|
+
if (!nextProto || nextProto === Object.prototype || nextProto.constructor === Object) {
|
|
5483
|
+
break;
|
|
5484
|
+
}
|
|
5485
|
+
ctor = nextProto;
|
|
5350
5486
|
}
|
|
5351
|
-
|
|
5352
|
-
currentClass = Object.getPrototypeOf(currentClass);
|
|
5487
|
+
return classes;
|
|
5353
5488
|
}
|
|
5354
|
-
//
|
|
5355
|
-
|
|
5356
|
-
|
|
5357
|
-
|
|
5358
|
-
|
|
5359
|
-
* Render a template for a component
|
|
5360
|
-
* Templates are functions that return [instructions, context] tuples
|
|
5361
|
-
*/
|
|
5362
|
-
async function render_template(component, template_fn) {
|
|
5363
|
-
// Get template from registry if not provided
|
|
5364
|
-
let render_fn = template_fn;
|
|
5365
|
-
if (!render_fn) {
|
|
5366
|
-
const template_def = get_template_by_class(component.constructor);
|
|
5367
|
-
render_fn = template_def.render;
|
|
5489
|
+
// -------------------------------------------------------------------------
|
|
5490
|
+
// Private Implementation
|
|
5491
|
+
// -------------------------------------------------------------------------
|
|
5492
|
+
_generate_cid() {
|
|
5493
|
+
return uid();
|
|
5368
5494
|
}
|
|
5369
|
-
|
|
5370
|
-
|
|
5371
|
-
|
|
5495
|
+
_apply_css_classes() {
|
|
5496
|
+
const hierarchy = this.constructor.get_class_hierarchy();
|
|
5497
|
+
// If component name differs from class name, add component name to hierarchy
|
|
5498
|
+
// This happens when:
|
|
5499
|
+
// 1. Using base Jqhtml_Component with _component_name
|
|
5500
|
+
// 2. Using parent class via extends chain (e.g., Contacts_DataGrid using DataGrid_Abstract class)
|
|
5501
|
+
const classesToAdd = [...hierarchy];
|
|
5502
|
+
if (this.args._component_name && this.args._component_name !== this.constructor.name) {
|
|
5503
|
+
// Add component name at the beginning (most specific)
|
|
5504
|
+
classesToAdd.unshift(this.args._component_name);
|
|
5505
|
+
}
|
|
5506
|
+
// Filter out private/mangled classes (starting with _) and invalid class names.
|
|
5507
|
+
// A component name with the reserved single-underscore prefix (_Root_Layout)
|
|
5508
|
+
// is public and is kept - see component-name.ts.
|
|
5509
|
+
const publicClasses = classesToAdd.filter(className => {
|
|
5510
|
+
// Guard against undefined, null, or non-string values
|
|
5511
|
+
if (!className || typeof className !== 'string') {
|
|
5512
|
+
console.warn('[JQHTML] Filtered out invalid class name:', className);
|
|
5513
|
+
return false;
|
|
5514
|
+
}
|
|
5515
|
+
return !className.startsWith('_') || is_component_name(className);
|
|
5516
|
+
});
|
|
5517
|
+
if (publicClasses.length > 0) {
|
|
5518
|
+
this.$.addClass(publicClasses.join(' '));
|
|
5519
|
+
}
|
|
5372
5520
|
}
|
|
5373
|
-
|
|
5374
|
-
|
|
5375
|
-
|
|
5376
|
-
|
|
5377
|
-
|
|
5378
|
-
|
|
5379
|
-
let [instructions, context] = render_fn.call(component, component.data, component.args, defaultContent // Default content function that returns empty string
|
|
5380
|
-
);
|
|
5381
|
-
// Check for slot-only template inheritance
|
|
5382
|
-
// If instructions is {_slots: {...}}, this is a slot-only template
|
|
5383
|
-
// We need to find the parent template and invoke it with these slots
|
|
5384
|
-
if (instructions && typeof instructions === 'object' && instructions._slots) {
|
|
5385
|
-
console.log(`[JQHTML] Slot-only template detected for ${component.constructor.name}, invoking inheritance`);
|
|
5386
|
-
const result = await process_slot_inheritance(component, instructions._slots);
|
|
5387
|
-
if (result) {
|
|
5388
|
-
console.log(`[JQHTML] Parent template found, using parent instructions`);
|
|
5389
|
-
instructions = result[0];
|
|
5390
|
-
context = result[1];
|
|
5521
|
+
_apply_default_attributes() {
|
|
5522
|
+
// Get template using same logic as render() method
|
|
5523
|
+
let template;
|
|
5524
|
+
// If we have a component name, try looking up by name first (for unregistered components)
|
|
5525
|
+
if (this.args._component_name) {
|
|
5526
|
+
template = get_template(this.args._component_name);
|
|
5391
5527
|
}
|
|
5392
5528
|
else {
|
|
5393
|
-
|
|
5394
|
-
|
|
5395
|
-
instructions = [];
|
|
5529
|
+
// Otherwise look up by class
|
|
5530
|
+
template = get_template_by_class(this.constructor);
|
|
5396
5531
|
}
|
|
5397
|
-
|
|
5398
|
-
|
|
5399
|
-
|
|
5400
|
-
|
|
5401
|
-
|
|
5402
|
-
|
|
5403
|
-
|
|
5404
|
-
|
|
5405
|
-
|
|
5406
|
-
|
|
5407
|
-
|
|
5408
|
-
async function process_bindings(component) {
|
|
5409
|
-
// Find all elements with data-bind-* attributes
|
|
5410
|
-
component.$.find('[data-bind-prop], [data-bind-value], [data-bind-text], [data-bind-html], [data-bind-class], [data-bind-style]').each((_, element) => {
|
|
5411
|
-
const el = $(element);
|
|
5412
|
-
const attrs = element.attributes;
|
|
5413
|
-
for (let i = 0; i < attrs.length; i++) {
|
|
5414
|
-
const attr = attrs[i];
|
|
5415
|
-
if (attr.name.startsWith('data-bind-')) {
|
|
5416
|
-
const binding_type = attr.name.substring(10); // Remove 'data-bind-'
|
|
5417
|
-
const expression = attr.value;
|
|
5532
|
+
if (!template)
|
|
5533
|
+
return;
|
|
5534
|
+
// Walk the extends chain to collect all defaultAttributes from parent templates
|
|
5535
|
+
// Apply from parent to child so child attributes take precedence
|
|
5536
|
+
const templateChain = [];
|
|
5537
|
+
let currentTemplate = template;
|
|
5538
|
+
// Build chain from child to parent
|
|
5539
|
+
while (currentTemplate) {
|
|
5540
|
+
templateChain.unshift(currentTemplate); // Add to front so parent comes first
|
|
5541
|
+
// Check if this template extends another
|
|
5542
|
+
if (currentTemplate.extends) {
|
|
5418
5543
|
try {
|
|
5419
|
-
|
|
5420
|
-
|
|
5421
|
-
|
|
5422
|
-
|
|
5423
|
-
|
|
5424
|
-
|
|
5425
|
-
|
|
5426
|
-
|
|
5427
|
-
|
|
5428
|
-
|
|
5429
|
-
|
|
5430
|
-
|
|
5431
|
-
|
|
5432
|
-
|
|
5433
|
-
|
|
5434
|
-
|
|
5435
|
-
|
|
5436
|
-
|
|
5437
|
-
|
|
5438
|
-
|
|
5439
|
-
|
|
5440
|
-
|
|
5441
|
-
|
|
5442
|
-
|
|
5443
|
-
|
|
5444
|
-
|
|
5445
|
-
|
|
5446
|
-
|
|
5447
|
-
|
|
5448
|
-
|
|
5449
|
-
|
|
5450
|
-
|
|
5451
|
-
|
|
5544
|
+
currentTemplate = get_template(currentTemplate.extends);
|
|
5545
|
+
}
|
|
5546
|
+
catch (error) {
|
|
5547
|
+
// Parent template not found, stop chain
|
|
5548
|
+
break;
|
|
5549
|
+
}
|
|
5550
|
+
}
|
|
5551
|
+
else {
|
|
5552
|
+
break;
|
|
5553
|
+
}
|
|
5554
|
+
}
|
|
5555
|
+
// Apply defaultAttributes from each template in the chain (parent first, child last)
|
|
5556
|
+
for (const tmpl of templateChain) {
|
|
5557
|
+
if (!tmpl.defaultAttributes)
|
|
5558
|
+
continue;
|
|
5559
|
+
// Filter out 'tag' since it determines tag name, not an attribute
|
|
5560
|
+
const defineAttrs = { ...tmpl.defaultAttributes };
|
|
5561
|
+
delete defineAttrs.tag;
|
|
5562
|
+
// Debug logging
|
|
5563
|
+
if (window.jqhtml?.debug?.enabled) {
|
|
5564
|
+
const componentName = tmpl.name || this.args._component_name || this.constructor.name;
|
|
5565
|
+
console.log(`[Component] Applying defaultAttributes for ${componentName}:`, defineAttrs);
|
|
5566
|
+
}
|
|
5567
|
+
// Apply each default attribute
|
|
5568
|
+
for (const [key, value] of Object.entries(defineAttrs)) {
|
|
5569
|
+
if (key === 'class') {
|
|
5570
|
+
// Special handling for class - merge with existing
|
|
5571
|
+
const existingClasses = this.$.attr('class');
|
|
5572
|
+
if (existingClasses) {
|
|
5573
|
+
const existing = existingClasses.split(/\s+/).filter(c => c);
|
|
5574
|
+
const newClasses = String(value).split(/\s+/).filter(c => c);
|
|
5575
|
+
for (const newClass of newClasses) {
|
|
5576
|
+
if (!existing.includes(newClass)) {
|
|
5577
|
+
existing.push(newClass);
|
|
5452
5578
|
}
|
|
5453
|
-
|
|
5454
|
-
|
|
5579
|
+
}
|
|
5580
|
+
this.$.attr('class', existing.join(' '));
|
|
5581
|
+
}
|
|
5582
|
+
else {
|
|
5583
|
+
this.$.attr('class', value);
|
|
5584
|
+
}
|
|
5585
|
+
}
|
|
5586
|
+
else if (key === 'style') {
|
|
5587
|
+
// Special handling for style - merge with existing
|
|
5588
|
+
// Define attributes are applied AFTER invocation attributes
|
|
5589
|
+
// So we only add Define style properties that don't already exist
|
|
5590
|
+
// This ensures invocation wins conflicts (invocation overrides Define)
|
|
5591
|
+
const existingStyle = this.$.attr('style');
|
|
5592
|
+
if (existingStyle) {
|
|
5593
|
+
// Parse existing styles (from invocation)
|
|
5594
|
+
const existingRules = new Map();
|
|
5595
|
+
existingStyle.split(';').forEach(rule => {
|
|
5596
|
+
const [prop, val] = rule.split(':').map(s => s.trim());
|
|
5597
|
+
if (prop && val)
|
|
5598
|
+
existingRules.set(prop, val);
|
|
5599
|
+
});
|
|
5600
|
+
// Parse Define styles and only add if not already present
|
|
5601
|
+
String(value).split(';').forEach(rule => {
|
|
5602
|
+
const [prop, val] = rule.split(':').map(s => s.trim());
|
|
5603
|
+
if (prop && val && !existingRules.has(prop)) {
|
|
5604
|
+
// Only add if property doesn't exist (invocation wins)
|
|
5605
|
+
existingRules.set(prop, val);
|
|
5455
5606
|
}
|
|
5456
|
-
|
|
5457
|
-
|
|
5458
|
-
|
|
5459
|
-
|
|
5607
|
+
});
|
|
5608
|
+
// Build merged style string
|
|
5609
|
+
const merged = Array.from(existingRules.entries())
|
|
5610
|
+
.map(([prop, val]) => `${prop}: ${val}`)
|
|
5611
|
+
.join('; ');
|
|
5612
|
+
this.$.attr('style', merged);
|
|
5613
|
+
}
|
|
5614
|
+
else {
|
|
5615
|
+
this.$.attr('style', value);
|
|
5460
5616
|
}
|
|
5461
5617
|
}
|
|
5462
|
-
|
|
5463
|
-
|
|
5618
|
+
else if (key.startsWith('$') || key.startsWith('data-')) {
|
|
5619
|
+
// Data attributes - also update args
|
|
5620
|
+
const dataKey = key.startsWith('$') ? key.substring(1) :
|
|
5621
|
+
key.startsWith('data-') ? key.substring(5) : key;
|
|
5622
|
+
// Only apply if not already set in args
|
|
5623
|
+
if (!(dataKey in this.args)) {
|
|
5624
|
+
this.args[dataKey] = value;
|
|
5625
|
+
this.$.data(dataKey, value);
|
|
5626
|
+
this.$.attr(key.startsWith('$') ? `data-${dataKey}` : key, String(value));
|
|
5627
|
+
}
|
|
5628
|
+
}
|
|
5629
|
+
else {
|
|
5630
|
+
// Regular attributes - apply directly if not already set
|
|
5631
|
+
if (!this.$.attr(key)) {
|
|
5632
|
+
this.$.attr(key, value);
|
|
5633
|
+
}
|
|
5464
5634
|
}
|
|
5465
5635
|
}
|
|
5466
5636
|
}
|
|
5467
|
-
}
|
|
5637
|
+
}
|
|
5638
|
+
_set_attributes() {
|
|
5639
|
+
// data-cid on the rendered element is debug-only: it mirrors this._cid so component
|
|
5640
|
+
// boundaries are legible in DevTools. The runtime never reads it - scoping uses the
|
|
5641
|
+
// _cid property, and the transient data-cid placeholder the instruction processor
|
|
5642
|
+
// matches on is written and removed before this ever runs.
|
|
5643
|
+
if (debug_attributes_enabled()) {
|
|
5644
|
+
this.$.attr('data-cid', this._cid);
|
|
5645
|
+
}
|
|
5646
|
+
// Only set lifecycle state attribute if verbose mode enabled
|
|
5647
|
+
if (window.jqhtml?.debug?.verbose) {
|
|
5648
|
+
this.$.attr('data-_lifecycle-state', this._ready_state.toString());
|
|
5649
|
+
}
|
|
5650
|
+
}
|
|
5651
|
+
_update_debug_attrs() {
|
|
5652
|
+
// Only update lifecycle state attribute if verbose mode enabled
|
|
5653
|
+
if (window.jqhtml?.debug?.verbose) {
|
|
5654
|
+
this.$.attr('data-_lifecycle-state', this._ready_state.toString());
|
|
5655
|
+
}
|
|
5656
|
+
}
|
|
5657
|
+
_find_dom_parent() {
|
|
5658
|
+
let current = this.$.parent();
|
|
5659
|
+
while (current.length > 0) {
|
|
5660
|
+
const parent = current.data('_component');
|
|
5661
|
+
if (parent instanceof Jqhtml_Component) {
|
|
5662
|
+
this._dom_parent = parent;
|
|
5663
|
+
parent._dom_children.add(this);
|
|
5664
|
+
break;
|
|
5665
|
+
}
|
|
5666
|
+
current = current.parent();
|
|
5667
|
+
}
|
|
5668
|
+
}
|
|
5669
|
+
/**
|
|
5670
|
+
* Get DOM children (components in DOM subtree)
|
|
5671
|
+
* Uses fast _dom_children registry when possible, falls back to DOM traversal for off-DOM components
|
|
5672
|
+
* @private - Used internally for lifecycle coordination
|
|
5673
|
+
*/
|
|
5674
|
+
_get_dom_children() {
|
|
5675
|
+
// If component was off-DOM during render, children couldn't register via _find_dom_parent()
|
|
5676
|
+
// Use DOM traversal to find direct children (works off-DOM, slightly slower)
|
|
5677
|
+
if (this._use_dom_fallback) {
|
|
5678
|
+
const directChildren = [];
|
|
5679
|
+
this.$.find('.Component').each((_, el) => {
|
|
5680
|
+
const $el = $(el);
|
|
5681
|
+
const comp = $el.data('_component');
|
|
5682
|
+
if (comp instanceof Jqhtml_Component) {
|
|
5683
|
+
// Only include if this component is the direct parent (not a grandparent)
|
|
5684
|
+
// Check: closest parent component is us (or no parent = we're the root)
|
|
5685
|
+
const closestParent = $el.parent().closest('.Component');
|
|
5686
|
+
if (closestParent.length === 0 || closestParent.data('_component') === this) {
|
|
5687
|
+
directChildren.push(comp);
|
|
5688
|
+
}
|
|
5689
|
+
}
|
|
5690
|
+
});
|
|
5691
|
+
return directChildren;
|
|
5692
|
+
}
|
|
5693
|
+
// Fast path: component was in-DOM during render, children registered normally
|
|
5694
|
+
// Filter out any that have since been removed from DOM (destroyed/replaced)
|
|
5695
|
+
const children = Array.from(this._dom_children);
|
|
5696
|
+
return children.filter(child => {
|
|
5697
|
+
return $.contains(document.documentElement, child.$[0]);
|
|
5698
|
+
});
|
|
5699
|
+
}
|
|
5700
|
+
_log_lifecycle(phase, status) {
|
|
5701
|
+
// Use new debug module
|
|
5702
|
+
logLifecycle(this, phase, status);
|
|
5703
|
+
// Keep legacy support for window.JQHTML_DEBUG
|
|
5704
|
+
if (typeof window !== 'undefined' && window.JQHTML_DEBUG) {
|
|
5705
|
+
window.JQHTML_DEBUG.log(this.component_name(), phase, status, {
|
|
5706
|
+
cid: this._cid,
|
|
5707
|
+
ready_state: this._ready_state,
|
|
5708
|
+
args: this.args
|
|
5709
|
+
});
|
|
5710
|
+
}
|
|
5711
|
+
}
|
|
5712
|
+
_log_debug(action, ...args) {
|
|
5713
|
+
if (typeof window !== 'undefined' && window.JQHTML_DEBUG) {
|
|
5714
|
+
window.JQHTML_DEBUG.log(this.component_name(), 'debug', `${action}: ${args.map(a => JSON.stringify(a)).join(', ')}`);
|
|
5715
|
+
}
|
|
5716
|
+
}
|
|
5468
5717
|
}
|
|
5718
|
+
// Static properties
|
|
5719
|
+
Jqhtml_Component.__jqhtml_component = true; // Marker for unified register() detection
|
|
5720
|
+
// A bare component name, as accepted by closest() in place of a class selector
|
|
5721
|
+
Jqhtml_Component.COMPONENT_NAME_PATTERN = COMPONENT_NAME_PATTERN;
|
|
5722
|
+
|
|
5723
|
+
var light_css = "html[data-jqhtml-debug] .jqhtml-debug-hit {\n outline: 2px solid #e5322d;\n outline-offset: -2px;\n}\n\nhtml[data-jqhtml-debug] .jqhtml-debug-hit.jqhtml-debug-depth-0 {\n outline-color: #e5322d;\n}\n\nhtml[data-jqhtml-debug] .jqhtml-debug-hit.jqhtml-debug-depth-1 {\n outline-color: #2f6fe4;\n}\n\nhtml[data-jqhtml-debug] .jqhtml-debug-hit.jqhtml-debug-depth-2 {\n outline-color: #1f9d55;\n}\n\nhtml[data-jqhtml-debug] .jqhtml-debug-hit.jqhtml-debug-depth-3 {\n outline-color: #d97706;\n}\n\nhtml[data-jqhtml-debug] .jqhtml-debug-hit.jqhtml-debug-depth-4 {\n outline-color: #7c3aed;\n}\n\nhtml[data-jqhtml-debug] .jqhtml-debug-hit.jqhtml-debug-depth-5 {\n outline-color: #0e7490;\n}";
|
|
5724
|
+
|
|
5725
|
+
var shadow_css = ":host {\n all: initial;\n display: block;\n position: fixed;\n box-sizing: border-box;\n top: 0;\n left: 0;\n width: 100vw;\n height: 100vh;\n z-index: 2147483647;\n pointer-events: none;\n font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, \"Liberation Mono\", monospace;\n font-size: 12px;\n font-weight: normal;\n font-style: normal;\n font-variant: normal;\n line-height: 1.4;\n color: #1c1c1c;\n text-align: left;\n text-transform: none;\n text-indent: 0;\n text-shadow: none;\n letter-spacing: normal;\n word-spacing: normal;\n white-space: normal;\n word-break: normal;\n overflow-wrap: anywhere;\n hyphens: none;\n tab-size: 4;\n direction: ltr;\n visibility: visible;\n cursor: default;\n list-style: none;\n quotes: none;\n}\n\n.jqhtml-debug-layer {\n position: absolute;\n top: 0;\n left: 0;\n width: 100%;\n height: 100%;\n overflow: hidden;\n pointer-events: none;\n}\n\n.jqhtml-debug-label {\n position: absolute;\n box-sizing: border-box;\n display: block;\n padding: 1px 5px;\n min-height: 18px;\n color: #ffffff;\n background: #e5322d;\n border-radius: 0 0 3px 0;\n font-size: 11px;\n line-height: 16px;\n white-space: normal;\n overflow-wrap: anywhere;\n overflow: hidden;\n pointer-events: none;\n}\n\n.jqhtml-debug-label.jqhtml-debug-depth-0 {\n background: #e5322d;\n}\n\n.jqhtml-debug-label.jqhtml-debug-depth-1 {\n background: #2f6fe4;\n}\n\n.jqhtml-debug-label.jqhtml-debug-depth-2 {\n background: #1f9d55;\n}\n\n.jqhtml-debug-label.jqhtml-debug-depth-3 {\n background: #d97706;\n}\n\n.jqhtml-debug-label.jqhtml-debug-depth-4 {\n background: #7c3aed;\n}\n\n.jqhtml-debug-label.jqhtml-debug-depth-5 {\n background: #0e7490;\n}\n\n.jqhtml-debug-label-name {\n font-weight: bold;\n}\n\n.jqhtml-debug-label-args {\n opacity: 0.9;\n margin-left: 6px;\n}\n\n.jqhtml-debug-modal {\n display: none;\n position: absolute;\n box-sizing: border-box;\n top: 24px;\n right: 24px;\n width: 520px;\n max-width: calc(100% - 48px);\n max-height: calc(100% - 48px);\n overflow: auto;\n padding: 0 0 12px 0;\n background: #ffffff;\n color: #1c1c1c;\n border: 1px solid #d0d0d0;\n border-radius: 6px;\n box-shadow: 0 8px 32px rgba(0, 0, 0, 0.25);\n pointer-events: auto;\n}\n\n.jqhtml-debug-modal.jqhtml-debug-modal-open {\n display: block;\n}\n\n.jqhtml-debug-title {\n display: flex;\n align-items: center;\n gap: 8px;\n position: sticky;\n top: 0;\n padding: 8px 12px;\n background: #1c1c1c;\n color: #ffffff;\n font-weight: bold;\n}\n\n.jqhtml-debug-title-name {\n flex: 1 1 auto;\n font-size: 13px;\n}\n\n.jqhtml-debug-button {\n all: unset;\n display: inline-block;\n box-sizing: border-box;\n padding: 2px 8px;\n border: 1px solid rgba(255, 255, 255, 0.6);\n border-radius: 3px;\n color: #ffffff;\n font-size: 11px;\n line-height: 16px;\n cursor: pointer;\n user-select: none;\n}\n\n.jqhtml-debug-button:hover {\n background: rgba(255, 255, 255, 0.15);\n}\n\n.jqhtml-debug-section {\n padding: 8px 12px 0 12px;\n}\n\n.jqhtml-debug-h {\n margin: 4px 0;\n color: #666666;\n font-size: 11px;\n text-transform: uppercase;\n letter-spacing: 0.06em;\n}\n\n.jqhtml-debug-table {\n display: grid;\n grid-template-columns: max-content max-content 1fr;\n column-gap: 12px;\n row-gap: 2px;\n align-items: baseline;\n}\n\n.jqhtml-debug-key {\n color: #005cc5;\n white-space: nowrap;\n}\n\n.jqhtml-debug-type {\n color: #666666;\n font-size: 11px;\n white-space: nowrap;\n}\n\n.jqhtml-debug-value {\n white-space: pre-wrap;\n overflow-wrap: anywhere;\n}\n\n.jqhtml-debug-empty {\n color: #666666;\n font-style: italic;\n}\n\n.jqhtml-debug-list {\n display: block;\n margin: 0;\n padding: 0;\n}\n\n.jqhtml-debug-link {\n display: block;\n padding: 2px 4px;\n margin: 0 -4px;\n border-radius: 3px;\n cursor: pointer;\n}\n\n.jqhtml-debug-link:hover {\n background: #eef3ff;\n}\n\n.jqhtml-debug-link-cid {\n color: #666666;\n margin-left: 6px;\n}\n\n.jqhtml-debug-note {\n margin-top: 4px;\n color: #92400e;\n font-size: 11px;\n}";
|
|
5726
|
+
|
|
5469
5727
|
/**
|
|
5470
|
-
*
|
|
5728
|
+
* Component debug overlay.
|
|
5729
|
+
*
|
|
5730
|
+
* `jqhtml.debug_overlay.enable()` turns the page into an inspector: hovering
|
|
5731
|
+
* any element outlines the component under the pointer and every component
|
|
5732
|
+
* above it, each with a small tab naming the component and its simple args;
|
|
5733
|
+
* clicking opens a modal describing the innermost component (identity, args,
|
|
5734
|
+
* data, state, ancestry, instantiator) instead of firing the click. Alt+click
|
|
5735
|
+
* passes through. `disable()` puts everything back.
|
|
5736
|
+
*
|
|
5737
|
+
* Non-invasiveness, the design goal (details in ./CLAUDE.md):
|
|
5738
|
+
* - The overlay's own UI lives in a shadow root on a host element appended to
|
|
5739
|
+
* <body>, styled by shadow.scss. Page CSS cannot select into it and :host
|
|
5740
|
+
* cuts inheritance, so its look is fixed whatever the page does.
|
|
5741
|
+
* - The only thing added to application elements is a pair of classes,
|
|
5742
|
+
* .jqhtml-debug-hit / .jqhtml-debug-depth-N, which light.scss paints as an
|
|
5743
|
+
* inset outline (no layout effect) and only while <html data-jqhtml-debug>.
|
|
5744
|
+
* - Listeners are attached on enable and removed on disable. The stylesheets
|
|
5745
|
+
* and the (detached) host stay once installed: inert, and cheap to re-enable.
|
|
5746
|
+
* - Nothing is registered per component, so components created after enable
|
|
5747
|
+
* are covered automatically.
|
|
5471
5748
|
*/
|
|
5472
|
-
|
|
5473
|
-
|
|
5474
|
-
|
|
5475
|
-
|
|
5476
|
-
|
|
5477
|
-
|
|
5478
|
-
|
|
5479
|
-
|
|
5480
|
-
|
|
5481
|
-
|
|
5482
|
-
|
|
5483
|
-
|
|
5484
|
-
|
|
5485
|
-
|
|
5486
|
-
|
|
5487
|
-
|
|
5488
|
-
|
|
5489
|
-
|
|
5490
|
-
|
|
5491
|
-
|
|
5492
|
-
|
|
5493
|
-
|
|
5494
|
-
|
|
5495
|
-
|
|
5496
|
-
|
|
5497
|
-
|
|
5498
|
-
|
|
5499
|
-
|
|
5500
|
-
|
|
5501
|
-
|
|
5502
|
-
|
|
5503
|
-
|
|
5749
|
+
const PREFIX = 'jqhtml-debug-';
|
|
5750
|
+
const ROOT_ATTR = 'data-jqhtml-debug'; // on <html> while enabled - gates light.scss
|
|
5751
|
+
const HOST_ATTR = 'data-jqhtml-debug-root'; // the shadow host
|
|
5752
|
+
const LIGHT_STYLE_ID = 'jqhtml-debug-light-styles';
|
|
5753
|
+
const HIT_CLASS = `${PREFIX}hit`;
|
|
5754
|
+
const DEPTH_CLASS = `${PREFIX}depth-`;
|
|
5755
|
+
const DEPTH_COLORS = 6; // keep in step with $depth-colors in _tokens.scss
|
|
5756
|
+
const LABEL_HEIGHT = 18;
|
|
5757
|
+
const READY_STATES = ['created', 'init', 'loaded', 'rendered', 'ready'];
|
|
5758
|
+
const state = {
|
|
5759
|
+
enabled: false,
|
|
5760
|
+
host: null,
|
|
5761
|
+
shadow: null,
|
|
5762
|
+
layer: null,
|
|
5763
|
+
modal: null,
|
|
5764
|
+
chain: [],
|
|
5765
|
+
hit_elements: [],
|
|
5766
|
+
modal_component: null,
|
|
5767
|
+
};
|
|
5768
|
+
// ---------------------------------------------------------------------------
|
|
5769
|
+
// Public API
|
|
5770
|
+
// ---------------------------------------------------------------------------
|
|
5771
|
+
const debug_overlay = {
|
|
5772
|
+
/** Turn the overlay on. Returns false where there is no DOM (SSR). */
|
|
5773
|
+
enable() {
|
|
5774
|
+
if (typeof document === 'undefined' || !document.documentElement)
|
|
5775
|
+
return false;
|
|
5776
|
+
if (state.enabled)
|
|
5777
|
+
return true;
|
|
5778
|
+
install();
|
|
5779
|
+
if (!state.host.isConnected)
|
|
5780
|
+
(document.body || document.documentElement).appendChild(state.host);
|
|
5781
|
+
document.documentElement.setAttribute(ROOT_ATTR, '');
|
|
5782
|
+
document.addEventListener('mouseover', on_mouseover, true);
|
|
5783
|
+
document.addEventListener('mouseout', on_mouseout, true);
|
|
5784
|
+
document.addEventListener('click', on_click, true);
|
|
5785
|
+
document.addEventListener('keydown', on_keydown, true);
|
|
5786
|
+
document.addEventListener('scroll', on_reposition, true);
|
|
5787
|
+
window.addEventListener('resize', on_reposition);
|
|
5788
|
+
state.enabled = true;
|
|
5789
|
+
return true;
|
|
5790
|
+
},
|
|
5791
|
+
/** Turn the overlay off. Styles stay installed but match nothing. */
|
|
5792
|
+
disable() {
|
|
5793
|
+
if (!state.enabled)
|
|
5794
|
+
return;
|
|
5795
|
+
document.removeEventListener('mouseover', on_mouseover, true);
|
|
5796
|
+
document.removeEventListener('mouseout', on_mouseout, true);
|
|
5797
|
+
document.removeEventListener('click', on_click, true);
|
|
5798
|
+
document.removeEventListener('keydown', on_keydown, true);
|
|
5799
|
+
document.removeEventListener('scroll', on_reposition, true);
|
|
5800
|
+
window.removeEventListener('resize', on_reposition);
|
|
5801
|
+
clear_hover();
|
|
5802
|
+
close_modal();
|
|
5803
|
+
document.documentElement.removeAttribute(ROOT_ATTR);
|
|
5804
|
+
if (state.host && state.host.isConnected)
|
|
5805
|
+
state.host.remove();
|
|
5806
|
+
state.enabled = false;
|
|
5807
|
+
},
|
|
5808
|
+
is_enabled() {
|
|
5809
|
+
return state.enabled;
|
|
5810
|
+
},
|
|
5811
|
+
/**
|
|
5812
|
+
* Open the modal for a component, an element inside one, or a jQuery
|
|
5813
|
+
* object, without needing a click. Handy from the console and from tests.
|
|
5814
|
+
*/
|
|
5815
|
+
inspect(target) {
|
|
5816
|
+
if (!state.enabled)
|
|
5817
|
+
return false;
|
|
5818
|
+
const component = target instanceof Jqhtml_Component
|
|
5819
|
+
? target
|
|
5820
|
+
: chain_for(target && target.jquery ? target[0] : target)[0];
|
|
5821
|
+
if (!component)
|
|
5822
|
+
return false;
|
|
5823
|
+
open_modal(component);
|
|
5824
|
+
return true;
|
|
5825
|
+
},
|
|
5826
|
+
};
|
|
5827
|
+
// ---------------------------------------------------------------------------
|
|
5828
|
+
// Installation (once)
|
|
5829
|
+
// ---------------------------------------------------------------------------
|
|
5830
|
+
function install() {
|
|
5831
|
+
if (state.host)
|
|
5832
|
+
return;
|
|
5833
|
+
if (!document.getElementById(LIGHT_STYLE_ID)) {
|
|
5834
|
+
const style = document.createElement('style');
|
|
5835
|
+
style.id = LIGHT_STYLE_ID;
|
|
5836
|
+
style.textContent = light_css;
|
|
5837
|
+
document.head.appendChild(style);
|
|
5838
|
+
}
|
|
5839
|
+
const host = document.createElement('div');
|
|
5840
|
+
host.setAttribute(HOST_ATTR, '');
|
|
5841
|
+
const shadow = host.attachShadow({ mode: 'open' });
|
|
5842
|
+
const style = document.createElement('style');
|
|
5843
|
+
style.textContent = shadow_css;
|
|
5844
|
+
shadow.appendChild(style);
|
|
5845
|
+
const layer = el('div', 'layer');
|
|
5846
|
+
shadow.appendChild(layer);
|
|
5847
|
+
const modal = el('div', 'modal');
|
|
5848
|
+
shadow.appendChild(modal);
|
|
5849
|
+
state.host = host;
|
|
5850
|
+
state.shadow = shadow;
|
|
5851
|
+
state.layer = layer;
|
|
5852
|
+
state.modal = modal;
|
|
5853
|
+
}
|
|
5854
|
+
// ---------------------------------------------------------------------------
|
|
5855
|
+
// Component chain
|
|
5856
|
+
// ---------------------------------------------------------------------------
|
|
5857
|
+
/** Components from the one containing `node` outward to the root. */
|
|
5858
|
+
function chain_for(node) {
|
|
5859
|
+
const chain = [];
|
|
5860
|
+
let element = node instanceof Element ? node : (node && node.parentElement) || null;
|
|
5861
|
+
while (element) {
|
|
5862
|
+
const owner = element.closest('.Component');
|
|
5863
|
+
if (!owner)
|
|
5864
|
+
break;
|
|
5865
|
+
const component = $(owner).data('_component');
|
|
5866
|
+
if (component instanceof Jqhtml_Component && !component.$.hasClass('_Component_Stopped'))
|
|
5867
|
+
chain.push(component);
|
|
5868
|
+
element = owner.parentElement;
|
|
5869
|
+
}
|
|
5870
|
+
return chain;
|
|
5871
|
+
}
|
|
5872
|
+
function is_ours(event) {
|
|
5873
|
+
return !!state.host && event.composedPath().includes(state.host);
|
|
5874
|
+
}
|
|
5875
|
+
// ---------------------------------------------------------------------------
|
|
5876
|
+
// Hover
|
|
5877
|
+
// ---------------------------------------------------------------------------
|
|
5878
|
+
function on_mouseover(event) {
|
|
5879
|
+
if (is_ours(event))
|
|
5880
|
+
return;
|
|
5881
|
+
const chain = chain_for(event.target);
|
|
5882
|
+
if (chain[0] === state.chain[0])
|
|
5883
|
+
return;
|
|
5884
|
+
set_hover(chain);
|
|
5885
|
+
}
|
|
5886
|
+
function on_mouseout(event) {
|
|
5887
|
+
if (!event.relatedTarget)
|
|
5888
|
+
clear_hover(); // pointer left the document
|
|
5889
|
+
}
|
|
5890
|
+
function on_reposition() {
|
|
5891
|
+
if (state.chain.length)
|
|
5892
|
+
render_labels();
|
|
5893
|
+
}
|
|
5894
|
+
function set_hover(chain) {
|
|
5895
|
+
clear_hover();
|
|
5896
|
+
state.chain = chain;
|
|
5897
|
+
chain.forEach((component, depth) => {
|
|
5898
|
+
const element = component.$[0];
|
|
5899
|
+
element.classList.add(HIT_CLASS, DEPTH_CLASS + (depth % DEPTH_COLORS));
|
|
5900
|
+
state.hit_elements.push(element);
|
|
5504
5901
|
});
|
|
5902
|
+
render_labels();
|
|
5903
|
+
}
|
|
5904
|
+
function clear_hover() {
|
|
5905
|
+
for (const element of state.hit_elements) {
|
|
5906
|
+
element.classList.remove(HIT_CLASS);
|
|
5907
|
+
for (let i = 0; i < DEPTH_COLORS; i++)
|
|
5908
|
+
element.classList.remove(DEPTH_CLASS + i);
|
|
5909
|
+
}
|
|
5910
|
+
state.hit_elements = [];
|
|
5911
|
+
state.chain = [];
|
|
5912
|
+
if (state.layer)
|
|
5913
|
+
state.layer.textContent = '';
|
|
5505
5914
|
}
|
|
5506
5915
|
/**
|
|
5507
|
-
*
|
|
5916
|
+
* One tab per component in the chain, pinned to the component's top-left
|
|
5917
|
+
* corner and no wider than the component. Outermost first so the innermost
|
|
5918
|
+
* paints on top; tabs that share a corner (a child filling its parent, the
|
|
5919
|
+
* usual case) stack downward instead of hiding each other.
|
|
5508
5920
|
*/
|
|
5509
|
-
function
|
|
5510
|
-
|
|
5511
|
-
|
|
5512
|
-
|
|
5513
|
-
|
|
5514
|
-
|
|
5515
|
-
|
|
5516
|
-
|
|
5517
|
-
|
|
5518
|
-
|
|
5519
|
-
|
|
5520
|
-
|
|
5521
|
-
|
|
5522
|
-
|
|
5523
|
-
|
|
5524
|
-
|
|
5525
|
-
|
|
5526
|
-
|
|
5527
|
-
|
|
5921
|
+
function render_labels() {
|
|
5922
|
+
const layer = state.layer;
|
|
5923
|
+
layer.textContent = '';
|
|
5924
|
+
const taken = new Map();
|
|
5925
|
+
for (let depth = state.chain.length - 1; depth >= 0; depth--) {
|
|
5926
|
+
const component = state.chain[depth];
|
|
5927
|
+
const rect = component.$[0].getBoundingClientRect();
|
|
5928
|
+
if (rect.width <= 0 || rect.height <= 0)
|
|
5929
|
+
continue;
|
|
5930
|
+
const left = Math.max(0, Math.round(rect.left));
|
|
5931
|
+
const top = Math.max(0, Math.round(rect.top));
|
|
5932
|
+
const key = `${left},${top}`;
|
|
5933
|
+
const stacked = taken.get(key) || 0;
|
|
5934
|
+
taken.set(key, stacked + 1);
|
|
5935
|
+
const label = el('div', `label ${PREFIX}depth-${depth % DEPTH_COLORS}`);
|
|
5936
|
+
label.style.left = `${left}px`;
|
|
5937
|
+
label.style.top = `${top + stacked * LABEL_HEIGHT}px`;
|
|
5938
|
+
label.style.maxWidth = `${Math.max(24, Math.round(rect.width))}px`;
|
|
5939
|
+
label.appendChild(el('span', 'label-name', component.component_name()));
|
|
5940
|
+
const args = simple_args(component);
|
|
5941
|
+
if (args)
|
|
5942
|
+
label.appendChild(el('span', 'label-args', args));
|
|
5943
|
+
layer.appendChild(label);
|
|
5528
5944
|
}
|
|
5529
|
-
|
|
5530
|
-
|
|
5531
|
-
|
|
5945
|
+
}
|
|
5946
|
+
/** `$id=7 $title="Users"` - only strings, numbers and booleans, never internals. */
|
|
5947
|
+
function simple_args(component) {
|
|
5948
|
+
const parts = [];
|
|
5949
|
+
for (const [key, value] of Object.entries(component.args || {})) {
|
|
5950
|
+
if (key.startsWith('_'))
|
|
5951
|
+
continue;
|
|
5952
|
+
if (typeof value === 'string')
|
|
5953
|
+
parts.push(`$${key}=${JSON.stringify(truncate(value, 24))}`);
|
|
5954
|
+
else if (typeof value === 'number' || typeof value === 'boolean')
|
|
5955
|
+
parts.push(`$${key}=${value}`);
|
|
5532
5956
|
}
|
|
5957
|
+
return parts.join(' ');
|
|
5533
5958
|
}
|
|
5534
|
-
|
|
5535
|
-
|
|
5536
|
-
|
|
5537
|
-
function
|
|
5538
|
-
|
|
5539
|
-
|
|
5540
|
-
|
|
5959
|
+
// ---------------------------------------------------------------------------
|
|
5960
|
+
// Click -> modal
|
|
5961
|
+
// ---------------------------------------------------------------------------
|
|
5962
|
+
function on_click(event) {
|
|
5963
|
+
if (is_ours(event) || event.altKey)
|
|
5964
|
+
return;
|
|
5965
|
+
event.preventDefault();
|
|
5966
|
+
event.stopPropagation();
|
|
5967
|
+
event.stopImmediatePropagation();
|
|
5968
|
+
const chain = chain_for(event.target);
|
|
5969
|
+
if (!chain.length) {
|
|
5970
|
+
close_modal();
|
|
5971
|
+
return;
|
|
5541
5972
|
}
|
|
5542
|
-
|
|
5973
|
+
open_modal(chain[0]);
|
|
5974
|
+
}
|
|
5975
|
+
function on_keydown(event) {
|
|
5976
|
+
if (event.key === 'Escape' && state.modal_component) {
|
|
5977
|
+
close_modal();
|
|
5978
|
+
event.stopPropagation();
|
|
5979
|
+
}
|
|
5980
|
+
}
|
|
5981
|
+
function close_modal() {
|
|
5982
|
+
if (!state.modal)
|
|
5983
|
+
return;
|
|
5984
|
+
state.modal.classList.remove(`${PREFIX}modal-open`);
|
|
5985
|
+
state.modal.textContent = '';
|
|
5986
|
+
state.modal_component = null;
|
|
5987
|
+
}
|
|
5988
|
+
function open_modal(component) {
|
|
5989
|
+
const modal = state.modal;
|
|
5990
|
+
modal.textContent = '';
|
|
5991
|
+
state.modal_component = component;
|
|
5992
|
+
const chain = chain_for(component.$[0]);
|
|
5993
|
+
const ancestors = chain.slice(1);
|
|
5994
|
+
const element = component.$[0];
|
|
5995
|
+
// Title bar
|
|
5996
|
+
const title = el('div', 'title');
|
|
5997
|
+
title.appendChild(el('span', 'title-name', `<${component.component_name()}>`));
|
|
5998
|
+
const log = el('button', 'button', 'Log to console');
|
|
5999
|
+
log.addEventListener('click', () => {
|
|
6000
|
+
console.log(`[JQHTML debug] <${component.component_name()}>`, component, element);
|
|
6001
|
+
});
|
|
6002
|
+
title.appendChild(log);
|
|
6003
|
+
const close = el('button', 'button', 'Close');
|
|
6004
|
+
close.addEventListener('click', close_modal);
|
|
6005
|
+
title.appendChild(close);
|
|
6006
|
+
modal.appendChild(title);
|
|
6007
|
+
// Identity
|
|
6008
|
+
const ctor = component.constructor;
|
|
6009
|
+
const hierarchy = ctor.get_class_hierarchy ? ctor.get_class_hierarchy() : [ctor.name];
|
|
6010
|
+
const identity = table();
|
|
6011
|
+
row(identity, 'name', '', component.component_name());
|
|
6012
|
+
row(identity, 'class', '', hierarchy.join(' → '));
|
|
6013
|
+
row(identity, 'element', '', describe_element(element));
|
|
6014
|
+
row(identity, '_cid', '', String(component._cid));
|
|
6015
|
+
row(identity, 'lifecycle', '', READY_STATES[component._ready_state] || String(component._ready_state));
|
|
6016
|
+
const nocache = element.getAttribute('data-nocache');
|
|
6017
|
+
if (nocache)
|
|
6018
|
+
row(identity, 'cache', '', `declined: ${nocache}`);
|
|
6019
|
+
modal.appendChild(section('Identity', identity));
|
|
6020
|
+
// Args / data / state
|
|
6021
|
+
modal.appendChild(section('Args', object_table(component.args)));
|
|
6022
|
+
modal.appendChild(section('Data', object_table(component.data)));
|
|
6023
|
+
modal.appendChild(section('State', object_table(component.state)));
|
|
6024
|
+
// Ancestry
|
|
6025
|
+
const list = el('div', 'list');
|
|
6026
|
+
if (!ancestors.length)
|
|
6027
|
+
list.appendChild(el('div', 'empty', 'none - this is a root component'));
|
|
6028
|
+
for (const ancestor of ancestors) {
|
|
6029
|
+
const link = el('div', 'link');
|
|
6030
|
+
link.appendChild(el('span', 'link-name', `<${ancestor.component_name()}>`));
|
|
6031
|
+
link.appendChild(el('span', 'link-cid', String(ancestor._cid)));
|
|
6032
|
+
link.addEventListener('click', () => open_modal(ancestor));
|
|
6033
|
+
list.appendChild(link);
|
|
6034
|
+
}
|
|
6035
|
+
modal.appendChild(section('Ancestry (DOM, nearest first)', list));
|
|
6036
|
+
// Instantiator - the component whose template wrote this tag. Differs from
|
|
6037
|
+
// the DOM parent for markup written in a slot body or default content.
|
|
6038
|
+
const instantiator = component.instantiator ? component.instantiator() : null;
|
|
6039
|
+
const inst = el('div', 'list');
|
|
6040
|
+
if (instantiator) {
|
|
6041
|
+
const link = el('div', 'link');
|
|
6042
|
+
link.appendChild(el('span', 'link-name', `<${instantiator.component_name()}>`));
|
|
6043
|
+
link.appendChild(el('span', 'link-cid', String(instantiator._cid)));
|
|
6044
|
+
link.addEventListener('click', () => open_modal(instantiator));
|
|
6045
|
+
inst.appendChild(link);
|
|
6046
|
+
if (ancestors[0] && instantiator !== ancestors[0]) {
|
|
6047
|
+
inst.appendChild(el('div', 'note', 'Written in this component\'s template but rendered inside a different parent (slot or default content): handlers and $sid bind here.'));
|
|
6048
|
+
}
|
|
6049
|
+
}
|
|
6050
|
+
else {
|
|
6051
|
+
inst.appendChild(el('div', 'empty', 'none - created directly'));
|
|
6052
|
+
}
|
|
6053
|
+
modal.appendChild(section('Instantiator', inst));
|
|
6054
|
+
modal.classList.add(`${PREFIX}modal-open`);
|
|
6055
|
+
}
|
|
6056
|
+
// ---------------------------------------------------------------------------
|
|
6057
|
+
// DOM helpers (shadow side)
|
|
6058
|
+
// ---------------------------------------------------------------------------
|
|
6059
|
+
function el(tag, classes, text) {
|
|
6060
|
+
const node = document.createElement(tag);
|
|
6061
|
+
node.className = classes.split(' ').map((c) => (c.startsWith(PREFIX) ? c : PREFIX + c)).join(' ');
|
|
6062
|
+
if (text !== undefined)
|
|
6063
|
+
node.textContent = text;
|
|
6064
|
+
return node;
|
|
6065
|
+
}
|
|
6066
|
+
function section(heading, body) {
|
|
6067
|
+
const wrap = el('div', 'section');
|
|
6068
|
+
wrap.appendChild(el('div', 'h', heading));
|
|
6069
|
+
wrap.appendChild(body);
|
|
6070
|
+
return wrap;
|
|
6071
|
+
}
|
|
6072
|
+
function table() {
|
|
6073
|
+
return el('div', 'table');
|
|
6074
|
+
}
|
|
6075
|
+
function row(t, key, type, value) {
|
|
6076
|
+
t.appendChild(el('span', 'key', key));
|
|
6077
|
+
t.appendChild(el('span', 'type', type));
|
|
6078
|
+
t.appendChild(el('span', 'value', value));
|
|
6079
|
+
}
|
|
6080
|
+
function object_table(source) {
|
|
6081
|
+
const entries = source && typeof source === 'object' ? Object.entries(source) : [];
|
|
6082
|
+
if (!entries.length)
|
|
6083
|
+
return el('div', 'empty', 'empty');
|
|
6084
|
+
const t = table();
|
|
6085
|
+
for (const [key, value] of entries)
|
|
6086
|
+
row(t, key, type_of(value), preview(value));
|
|
6087
|
+
return t;
|
|
6088
|
+
}
|
|
6089
|
+
function type_of(value) {
|
|
6090
|
+
if (value === null)
|
|
6091
|
+
return 'null';
|
|
6092
|
+
if (Array.isArray(value))
|
|
6093
|
+
return `array(${value.length})`;
|
|
6094
|
+
if (value instanceof Jqhtml_Component)
|
|
6095
|
+
return 'component';
|
|
6096
|
+
if (value && value.jquery)
|
|
6097
|
+
return `jQuery(${value.length})`;
|
|
6098
|
+
if (typeof value === 'object' && value instanceof Element)
|
|
6099
|
+
return 'element';
|
|
6100
|
+
return typeof value;
|
|
6101
|
+
}
|
|
6102
|
+
function preview(value) {
|
|
6103
|
+
if (value === undefined)
|
|
6104
|
+
return 'undefined';
|
|
6105
|
+
if (value === null)
|
|
6106
|
+
return 'null';
|
|
6107
|
+
if (typeof value === 'string')
|
|
6108
|
+
return JSON.stringify(truncate(value, 200));
|
|
6109
|
+
if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'bigint')
|
|
6110
|
+
return String(value);
|
|
6111
|
+
if (typeof value === 'function')
|
|
6112
|
+
return `ƒ ${value.name || 'anonymous'}()`;
|
|
6113
|
+
if (value instanceof Jqhtml_Component)
|
|
6114
|
+
return `<${value.component_name()}> ${value._cid}`;
|
|
6115
|
+
if (value.jquery)
|
|
6116
|
+
return value.length ? describe_element(value[0]) : 'empty';
|
|
6117
|
+
if (value instanceof Element)
|
|
6118
|
+
return describe_element(value);
|
|
5543
6119
|
try {
|
|
5544
|
-
return
|
|
5545
|
-
const { data, args, $, emit, $sid } = this;
|
|
5546
|
-
${expression}
|
|
5547
|
-
`).bind(component);
|
|
6120
|
+
return truncate(JSON.stringify(value), 200);
|
|
5548
6121
|
}
|
|
5549
|
-
catch
|
|
5550
|
-
|
|
5551
|
-
return null;
|
|
6122
|
+
catch {
|
|
6123
|
+
return `{${Object.keys(value).join(', ')}}`;
|
|
5552
6124
|
}
|
|
5553
6125
|
}
|
|
5554
|
-
|
|
5555
|
-
|
|
5556
|
-
|
|
5557
|
-
|
|
5558
|
-
const div = document.createElement('div');
|
|
5559
|
-
div.textContent = str;
|
|
5560
|
-
return div.innerHTML;
|
|
6126
|
+
function describe_element(element) {
|
|
6127
|
+
const id = element.id ? `#${element.id}` : '';
|
|
6128
|
+
const classes = element.classList.length ? `.${Array.from(element.classList).join('.')}` : '';
|
|
6129
|
+
return `${element.tagName.toLowerCase()}${id}${classes}`;
|
|
5561
6130
|
}
|
|
5562
|
-
|
|
5563
|
-
|
|
5564
|
-
* Used by <%br= %> syntax for displaying user-entered text with line breaks
|
|
5565
|
-
*/
|
|
5566
|
-
function escape_html_nl2br(str) {
|
|
5567
|
-
const div = document.createElement('div');
|
|
5568
|
-
div.textContent = str;
|
|
5569
|
-
// Escape HTML first, then replace newlines with <br />
|
|
5570
|
-
return div.innerHTML.replace(/\n/g, '<br />');
|
|
6131
|
+
function truncate(text, max) {
|
|
6132
|
+
return text.length > max ? `${text.slice(0, max - 1)}…` : text;
|
|
5571
6133
|
}
|
|
5572
6134
|
|
|
5573
6135
|
/**
|
|
@@ -5858,8 +6420,11 @@ function init_jquery_plugin(jQuery) {
|
|
|
5858
6420
|
}
|
|
5859
6421
|
}
|
|
5860
6422
|
else {
|
|
5861
|
-
// Direct class reference
|
|
6423
|
+
// Direct class reference. The invocation name is the class's own; it is
|
|
6424
|
+
// recorded for component_name() but does not drive the template tag
|
|
6425
|
+
// check below, which is for named (template-backed) invocations.
|
|
5862
6426
|
ComponentClass = componentOrName;
|
|
6427
|
+
args = { ...args, _component_name: componentOrName.component_name || componentOrName.name };
|
|
5863
6428
|
}
|
|
5864
6429
|
// Check if element tag matches expected tag from template
|
|
5865
6430
|
let targetElement = element;
|
|
@@ -6195,7 +6760,7 @@ function init(jQuery, config) {
|
|
|
6195
6760
|
}
|
|
6196
6761
|
}
|
|
6197
6762
|
// Version - will be replaced during build with actual version from package.json
|
|
6198
|
-
const version = '2.3.
|
|
6763
|
+
const version = '2.3.67';
|
|
6199
6764
|
// Default export with all functionality
|
|
6200
6765
|
const jqhtml = {
|
|
6201
6766
|
// Core
|
|
@@ -6226,6 +6791,14 @@ const jqhtml = {
|
|
|
6226
6791
|
// Integration configuration - see config.ts
|
|
6227
6792
|
configure,
|
|
6228
6793
|
get_config,
|
|
6794
|
+
// Value printers: how <%= %> renders an object (see value-printers.ts).
|
|
6795
|
+
// print_object and dynamic_component_name are called by compiled templates.
|
|
6796
|
+
add_object_printer,
|
|
6797
|
+
print_object,
|
|
6798
|
+
dynamic_component_name,
|
|
6799
|
+
// Component debug overlay: hover outlines + click-to-inspect modal.
|
|
6800
|
+
// jqhtml.debug_overlay.enable() / .disable() / .is_enabled() / .inspect(x)
|
|
6801
|
+
debug_overlay,
|
|
6229
6802
|
// Debug settings
|
|
6230
6803
|
debug: {
|
|
6231
6804
|
enabled: false,
|
|
@@ -6338,13 +6911,16 @@ exports.Jqhtml_LifecycleManager = LifecycleManager;
|
|
|
6338
6911
|
exports.Jqhtml_Local_Storage = Jqhtml_Local_Storage;
|
|
6339
6912
|
exports.LifecycleManager = LifecycleManager;
|
|
6340
6913
|
exports.Load_Coordinator = Load_Coordinator;
|
|
6914
|
+
exports.add_object_printer = add_object_printer;
|
|
6341
6915
|
exports.applyDebugDelay = applyDebugDelay;
|
|
6342
6916
|
exports.boot = boot;
|
|
6343
6917
|
exports.clear_preload_data = clear_preload_data;
|
|
6344
6918
|
exports.configure = configure;
|
|
6345
6919
|
exports.create_component = create_component;
|
|
6920
|
+
exports.debug_overlay = debug_overlay;
|
|
6346
6921
|
exports.default = jqhtml;
|
|
6347
6922
|
exports.devWarn = devWarn;
|
|
6923
|
+
exports.dynamic_component_name = dynamic_component_name;
|
|
6348
6924
|
exports.escape_html = escape_html;
|
|
6349
6925
|
exports.escape_html_nl2br = escape_html_nl2br;
|
|
6350
6926
|
exports.extract_slots = extract_slots;
|
|
@@ -6352,6 +6928,7 @@ exports.get_captured_data = get_captured_data;
|
|
|
6352
6928
|
exports.get_component_class = get_component_class;
|
|
6353
6929
|
exports.get_component_names = get_component_names;
|
|
6354
6930
|
exports.get_config = get_config;
|
|
6931
|
+
exports.get_object_printers = get_object_printers;
|
|
6355
6932
|
exports.get_registered_templates = get_registered_templates;
|
|
6356
6933
|
exports.get_template = get_template;
|
|
6357
6934
|
exports.get_template_by_class = get_template_by_class;
|
|
@@ -6366,6 +6943,7 @@ exports.logDataChange = logDataChange;
|
|
|
6366
6943
|
exports.logDispatch = logDispatch;
|
|
6367
6944
|
exports.logInstruction = logInstruction;
|
|
6368
6945
|
exports.logLifecycle = logLifecycle;
|
|
6946
|
+
exports.print_object = print_object;
|
|
6369
6947
|
exports.process_instructions = process_instructions;
|
|
6370
6948
|
exports.register = register;
|
|
6371
6949
|
exports.register_cache_class = register_cache_class;
|