diff --git a/index.d.ts b/index.d.ts index ef8a22f3..ffe605d3 100644 --- a/index.d.ts +++ b/index.d.ts @@ -687,6 +687,7 @@ declare module '@lightningjs/blits' { * Watchers for changes to state variables, props or computed properties */ watch?: W & ComponentContext + } export interface RouterHooks { @@ -904,7 +905,9 @@ declare module '@lightningjs/blits' { } } - type ComponentFactory = () => void + type ComponentFactory = (() => void) & { + needsInteractive?: boolean + } // Launch Related @@ -1353,7 +1356,7 @@ declare module '@lightningjs/blits' { M extends Methods, C extends Computed, W extends Watch>(name: string, config: ComponentConfig) : ComponentFactory - /** + /** * Blits Launch */ Launch(App: ComponentFactory, target: HTMLElement | String, settings?: Settings) : void diff --git a/src/application.js b/src/application.js index a10651d7..d6cf74f2 100644 --- a/src/application.js +++ b/src/application.js @@ -165,6 +165,7 @@ const Application = (config) => { if (node === null) { currentComponent = undefined + currentNode = null Hover.clear() return } diff --git a/src/component.js b/src/component.js index 1999ff2a..022b7c31 100644 --- a/src/component.js +++ b/src/component.js @@ -499,6 +499,16 @@ const Component = (name = required('name'), config = required('config')) => { // To determine whether dynamic component is actual Blits component or not factory[symbols.isComponent] = true + // Getter to determine if the component needs interactive hover support + Object.defineProperty(factory, 'needsInteractive', { + get() { + return ( + (config.hooks && !!(config.hooks.hover || config.hooks.unhover)) || + !!(config.code && config.code.usesHoverState) + ) + }, + }) + return factory } diff --git a/src/engines/L3/element.test.js b/src/engines/L3/element.test.js index 09e8e59b..ef39ffaa 100644 --- a/src/engines/L3/element.test.js +++ b/src/engines/L3/element.test.js @@ -1255,3 +1255,29 @@ test('Element - Set maxheight property', (assert) => { assert.equal(el.props.props['contain'], 'height', 'maxheight should set contain to height') assert.end() }) + +test('Element - holder sets interactive to the passed value (true)', (assert) => { + assert.capture(renderer, 'createNode', () => new EventEmitter()) + const component = {} + const el = element({ parent: { node: { w: 1920, h: 1080 } } }, component) + el.populate({ parent: { node: new EventEmitter() }, holder: true }) + assert.equal( + el.props.props['interactive'], + true, + 'interactive should be true when holder value is true' + ) + assert.end() +}) + +test('Element - holder sets interactive to the passed value (false)', (assert) => { + assert.capture(renderer, 'createNode', () => new EventEmitter()) + const component = {} + const el = element({ parent: { node: { w: 1920, h: 1080 } } }, component) + el.populate({ parent: { node: new EventEmitter() }, holder: false }) + assert.equal( + el.props.props['interactive'], + false, + 'interactive should be false when holder value is false' + ) + assert.end() +}) diff --git a/src/lib/codegenerator/generator.js b/src/lib/codegenerator/generator.js index f8ab03bc..d54eb37c 100644 --- a/src/lib/codegenerator/generator.js +++ b/src/lib/codegenerator/generator.js @@ -87,6 +87,8 @@ export default function (templateObject = { children: [] }, devMode = false) { }, skips} `) + const renderCodeString = ctx.renderCode.join('\n') + return { render: new Function( 'parent', @@ -96,7 +98,7 @@ export default function (templateObject = { children: [] }, devMode = false) { 'effect', 'getRaw', 'Log', - ctx.renderCode.join('\n') + renderCodeString ), effects: ctx.effectsCode.map( (code) => @@ -112,6 +114,7 @@ export default function (templateObject = { children: [] }, devMode = false) { ) ), context: ctx.context, + usesHoverState: renderCodeString.includes('.$isHovered'), } } @@ -187,7 +190,7 @@ const generateElementCode = function ( } if (options.holder) { - renderCode.push(`elementConfigs[${counter}]['holder'] = true`) + renderCode.push(`elementConfigs[${counter}]['holder'] = cmps[${counter}].needsInteractive`) } Object.keys(templateObject).forEach((key) => { diff --git a/src/lib/codegenerator/generator.test.js b/src/lib/codegenerator/generator.test.js index e5cbfecf..23dfac2f 100644 --- a/src/lib/codegenerator/generator.test.js +++ b/src/lib/codegenerator/generator.test.js @@ -1542,7 +1542,7 @@ test('Generate code for a template with custom components', (assert) => { elementConfigs[1] = {} elms[1] = this.element({ parent: parent || 'root' }, inSlot === true ? slotComponent : component) - elementConfigs[1]['holder'] = true + elementConfigs[1]['holder'] = cmps[1].needsInteractive skips[1] = [] if (typeof cmps[1] !== 'undefined' && cmps[1][Symbol.for('config')].props !== undefined) { let props = cmps[1][Symbol.for('config')].props @@ -1591,7 +1591,7 @@ test('Generate code for a template with custom components', (assert) => { elementConfigs[3] = {} elms[3] = this.element({ parent: parent || 'root' }, inSlot === true ? slotComponent : component) - elementConfigs[3]['holder'] = true + elementConfigs[3]['holder'] = cmps[3].needsInteractive skips[3] = [] if (typeof cmps[3] !== 'undefined' && cmps[3][Symbol.for('config')].props !== undefined) { let props = cmps[3][Symbol.for('config')].props @@ -1730,7 +1730,7 @@ test('Generate code for a template with an unregistered custom component', (asse parent = elms[0] elementConfigs[1] = {} elms[1] = this.element({ parent: parent || 'root' }, inSlot === true ? slotComponent : component) - elementConfigs[1]['holder'] = true + elementConfigs[1]['holder'] = cmps[1].needsInteractive skips[1] = [] if (typeof cmps[1] !== 'undefined' && cmps[1][Symbol.for('config')].props !== undefined) { let props = cmps[1][Symbol.for('config')].props @@ -1775,7 +1775,7 @@ test('Generate code for a template with an unregistered custom component', (asse parent = elms[0] elementConfigs[3] = {} elms[3] = this.element({ parent: parent || 'root' }, inSlot === true ? slotComponent : component) - elementConfigs[3]['holder'] = true + elementConfigs[3]['holder'] = cmps[3].needsInteractive skips[3] = [] if (typeof cmps[3] !== 'undefined' && cmps[3][Symbol.for('config')].props !== undefined) { let props = cmps[3][Symbol.for('config')].props @@ -1913,7 +1913,7 @@ test('Generate code for a template with custom components with arguments', (asse parent = elms[0] elementConfigs[1] = {} elms[1] = this.element({ parent: parent || 'root' }, inSlot === true ? slotComponent : component) - elementConfigs[1]['holder'] = true + elementConfigs[1]['holder'] = cmps[1].needsInteractive elementConfigs[1]['x'] = 10 skips[1] = [] if(typeof cmps[1] !== 'undefined' && cmps[1][Symbol.for('config')].props !== undefined) { @@ -1960,7 +1960,7 @@ test('Generate code for a template with custom components with arguments', (asse parent = elms[0] elementConfigs[3] = {} elms[3] = this.element({ parent: parent || 'root' }, inSlot === true ? slotComponent : component) - elementConfigs[3]['holder'] = true + elementConfigs[3]['holder'] = cmps[3].needsInteractive elementConfigs[3]['x'] = 100 elementConfigs[3]['img'] = component.img skips[3] = [] @@ -2107,7 +2107,7 @@ test('Generate code for a template with custom components with argument value as elms[1] = this.element({ parent: parent || 'root' }, inSlot === true ? slotComponent : component) - elementConfigs[1]['holder'] = true + elementConfigs[1]['holder'] = cmps[1].needsInteractive elementConfigs[1]['x'] = 1000 elementConfigs[1]['y'] = 100 elementConfigs[1]['h'] = parent.node.h * (45 / 100) @@ -2262,7 +2262,7 @@ test('Generate code for a template with custom components with reactive props', parent = elms[0] elementConfigs[1] = {} elms[1] = this.element({ parent: parent || 'root' }, inSlot === true ? slotComponent : component) - elementConfigs[1]['holder'] = true + elementConfigs[1]['holder'] = cmps[1].needsInteractive elementConfigs[1]['x'] = 10 elementConfigs[1]['img'] = component.image skips[1] = [] @@ -2316,7 +2316,7 @@ test('Generate code for a template with custom components with reactive props', parent = elms[0] elementConfigs[3] = {} elms[3] = this.element({ parent: parent || 'root' }, inSlot === true ? slotComponent : component) - elementConfigs[3]['holder'] = true + elementConfigs[3]['holder'] = cmps[3].needsInteractive elementConfigs[3]['x'] = 100 elementConfigs[3]['img'] = component.image skips[3] = [] @@ -2592,7 +2592,7 @@ test('Generate code for a template with slot content', (assert) => { elementConfigs[1] = {} elms[1] = this.element({ parent: parent || 'root' }, inSlot === true ? slotComponent : component) - elementConfigs[1]['holder'] = true + elementConfigs[1]['holder'] = cmps[1].needsInteractive elementConfigs[1]['w'] = 1920 elementConfigs[1]['h'] = 1080 skips[1] = [] @@ -2781,7 +2781,7 @@ test('Generate code for a template with slot content, using a named slot', (asse elementConfigs[1] = {} elms[1] = this.element({ parent: parent || 'root' }, inSlot === true ? slotComponent : component) - elementConfigs[1]['holder'] = true + elementConfigs[1]['holder'] = cmps[1].needsInteractive elementConfigs[1]['w'] = 1920 elementConfigs[1]['h'] = 1080 skips[1] = [] @@ -4411,7 +4411,7 @@ test('Generate code for a template with a simple for-loop on a Component with a if(elms[1][scope.key] === undefined) { elms[1][scope.key] = this.element({parent: parent || 'root'}, inSlot === true ? slotComponent : component) } - elementConfigs[1]['holder'] = true + elementConfigs[1]['holder'] = cmps[1].needsInteractive skips[1] = [] if(typeof cmps[1] !== 'undefined' && cmps[1][Symbol.for('config')].props !== undefined) { // attributes that are a prop should be removed from element config (even if it's a know element prop) diff --git a/src/lib/hooks.js b/src/lib/hooks.js index f0a18c80..5bb45cf0 100644 --- a/src/lib/hooks.js +++ b/src/lib/hooks.js @@ -57,3 +57,7 @@ export const registerHooks = (hooks = {}, identifier) => { if (typeof hooks[hook] === 'function') cbs[identifier][hook] = hooks[hook] }) } + +export const hasHook = (hook, identifier) => { + return cbs[identifier] !== undefined && hook in cbs[identifier] +} diff --git a/src/lib/precompiler/precompiler.js b/src/lib/precompiler/precompiler.js index 7552b20f..1aed01a2 100644 --- a/src/lib/precompiler/precompiler.js +++ b/src/lib/precompiler/precompiler.js @@ -31,6 +31,11 @@ export default (source, filePath, mode) => { // Use MagicString to track changes const s = new MagicString(source) + // Find component definition start positions for scoping JS-layer checks + const componentStarts = [...source.matchAll(/Blits\.(Component|Application)\s*\(/g)] + .map((m) => m.index) + .sort((a, b) => a - b) + for (const template of templates) { if (template[2]) { const templateContent = template[2] @@ -52,10 +57,28 @@ export default (source, filePath, mode) => { // Generate the code const code = generator.call({ components: {} }, parsed, mode === 'development') + // Determine the component scope containing this template + let scopeStart = 0 + let scopeEnd = source.length + for (let i = 0; i < componentStarts.length; i++) { + if (componentStarts[i] <= templateStartIndex) { + scopeStart = componentStarts[i] + scopeEnd = i + 1 < componentStarts.length ? componentStarts[i + 1] : source.length + } + } + const componentScope = source.slice(scopeStart, scopeEnd) + + // Derive usesHoverState from both template ($$isHovered) and JS layer (this.$isHovered) + // Strip comments before checking to avoid false positives from commented-out code + const strippedScope = componentScope + .replace(/\/\/[^\n]*/g, '') + .replace(/\/\*[\s\S]*?\*\//g, '') + const usesHoverState = code.usesHoverState || strippedScope.includes('this.$isHovered') + // Insert the code in the component using the 'code' key, replacing the template key const replacement = `/* eslint-disable no-unused-vars */ \ncode: { render: ${code.render.toString()}, effects: [${code.effects.map( (fn) => fn.toString() - )}], context: {}}` + )}], context: {}, usesHoverState: ${usesHoverState}}` // MagicString tracks all changes automatically, no offset needed s.overwrite(templateStartIndex, templateEndIndex, replacement) diff --git a/src/router/router.js b/src/router/router.js index 436ef4ce..9d2393be 100644 --- a/src/router/router.js +++ b/src/router/router.js @@ -42,7 +42,7 @@ import Settings from '../settings.js' * @property {Object} hooks - The hooks of the route * @property {Object} transition - The transition of the route * @property {Object} announce - The announce of the route - * @property {(options: Object, parentEl: BlitsElement, parentComponent: BlitsComponent, rootComponent?: BlitsComponent) => BlitsComponent} component - The component factory of the route + * @property {BlitsComponentFactory} component - The component factory of the route (or a lazy import function) * * @typedef {Object} Hash * @property {string} path - The path of the hash @@ -427,8 +427,11 @@ export const navigate = async function () { if (!view) { // create a holder element for the new view + // for lazy imports needsInteractive is undefined, default to true + const needsInteractive = + route.component.needsInteractive !== undefined ? route.component.needsInteractive : true holder = stage.element({ parent: this[symbols.children][0] }) - holder.populate({}) + holder.populate({ holder: needsInteractive }) holder.set('w', '100%') holder.set('h', '100%')